已合并
test(e2e): 补齐产物打包下载与 Skill 文件夹导入的浏览器旅程 #156
wang_cheng_zhao创建于 9月20日
test(e2e): 补齐产物打包下载与 Skill 文件夹导入的浏览器旅程 #156
已合并
共 2 个文件变更+325-0
| @@ -0,0 +1,169 @@ | |||
| 1 | +// Copyright (C) 2026-2026 Huawei Technologies Co., Ltd | ||
| 2 | +// | ||
| 3 | +// Licensed under the Apache License, Version 2.0 (the "License"); | ||
| 4 | +// you may not use this file except in compliance with the License. | ||
| 5 | +// You may obtain a copy of the License at | ||
| 6 | +// | ||
| 7 | +// http://www.apache.org/licenses/LICENSE-2.0 | ||
| 8 | +// | ||
| 9 | +// Unless required by applicable law or agreed to in writing, software | ||
| 10 | +// distributed under the License is distributed on an "AS IS" BASIS, | ||
| 11 | +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| 12 | +// See the License for the specific language governing permissions and | ||
| 13 | +// limitations under the License. | ||
| 14 | + | ||
| 15 | +import { execFileSync } from "node:child_process"; | ||
| 16 | + | ||
| 17 | +import { expect } from "@playwright/test"; | ||
| 18 | + | ||
| 19 | +import { test } from "./helpers/e2e.ts"; | ||
| 20 | +import { | ||
| 21 | + artifactTree, | ||
| 22 | + cleanupJourney, | ||
| 23 | + createProjectAndSession, | ||
| 24 | + expandToolStep, | ||
| 25 | + openProjectSession, | ||
| 26 | + scriptedModel, | ||
| 27 | + sendUserMessage, | ||
| 28 | + waitForRunTerminal, | ||
| 29 | + type JourneyFixture, | ||
| 30 | +} from "./helpers/journeys.ts"; | ||
| 31 | + | ||
| 32 | +/** | ||
| 33 | + * Read the downloaded archive with CPython's `zipfile`, which shares no code | ||
| 34 | + * with the packer under test. Handing the file back to the same library that | ||
| 35 | + * wrote it would pass even on an archive no other unzip tool accepts, and the | ||
| 36 | + * user opens this one with their own tool. | ||
| 37 | + */ | ||
| 38 | +function readZipText(archivePath: string): Record<string, string> { | ||
| 39 | + const script = [ | ||
| 40 | + "import json, sys, zipfile", | ||
| 41 | + "with zipfile.ZipFile(sys.argv[1]) as archive:", | ||
| 42 | + " print(json.dumps({name: archive.read(name).decode('utf-8') for name in archive.namelist()}))", | ||
| 43 | + ].join("\n"); | ||
| 44 | + return JSON.parse(execFileSync("python3", ["-c", script, archivePath], { encoding: "utf8" })) as Record<string, string>; | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +/** | ||
| 48 | + * E2E-META | ||
| 49 | + * Purpose: A researcher can select several declared Artifacts and take them away as one ZIP whose entries keep their catalog paths and content. | ||
| 50 | + * Steps: | ||
| 51 | + * 1. Prepare a local stub model and an isolated Project/Session. | ||
| 52 | + * 2. Ask for an analysis that writes and declares two deliverables in different folders. | ||
| 53 | + * 3. Turn on multi-select and pick both; the download control only becomes usable once something is selected. | ||
| 54 | + * 4. Download the selection and read the ZIP with an independent unpacker: both paths present, content unchanged. | ||
| 55 | + * Environment: Isolated local stack at E2E_BASE_URL with managed Python ready and a journey-owned Project/Session. | ||
| 56 | + * Type: mocked | ||
| 57 | + * LLM: journey-owned OpenAI-compatible HTTP stub on 127.0.0.1; one deterministic user turn. | ||
| 58 | + * WebSearch: none | ||
| 59 | + * PaperSources: none | ||
| 60 | + * MCP: none | ||
| 61 | + * OtherExternal: none — the shell step runs in the offline local sandbox and non-local browser requests are aborted. | ||
| 62 | + * Credentials: E2E_API_TOKEN for the isolated local API only; the stub token has no external access. | ||
| 63 | + * CostSideEffects: no external cost; one local download into the run's output directory and temporary records deleted in finally. | ||
| 64 | + */ | ||
| 65 | +test("J10 多份交付物可以打包成一个压缩包带走", { tag: "@mocked" }, async ({ journey, page }, testInfo) => { | ||
| 66 | + test.setTimeout(210_000); | ||
| 67 | + await page.addInitScript(() => window.localStorage.setItem("sciencediscovery-locale", "zh-CN")); | ||
| 68 | + const marker = `J10-${Date.now()}`; | ||
| 69 | + const summaryText = "# Summary\n\nmean=42.0\n"; | ||
| 70 | + const tableText = "sample,value\na,40\nb,44\n"; | ||
| 71 | + const stub = await scriptedModel([ | ||
| 72 | + [ | ||
| 73 | + { | ||
| 74 | + arguments: { | ||
| 75 | + command: "python3 - <<'PY'\n" + [ | ||
| 76 | + "from pathlib import Path", | ||
| 77 | + "Path('results').mkdir(exist_ok=True)", | ||
| 78 | + "Path('tables').mkdir(exist_ok=True)", | ||
| 79 | + `Path('results/summary.md').write_text(${JSON.stringify(summaryText)}, encoding='utf-8')`, | ||
| 80 | + `Path('tables/measurements.csv').write_text(${JSON.stringify(tableText)}, encoding='utf-8')`, | ||
| 81 | + `print('${marker}')`, | ||
| 82 | + ].join("\n") + "\nPY", | ||
| 83 | + }, | ||
| 84 | + delayMs: 500, | ||
| 85 | + tool: "run_shell", | ||
| 86 | + }, | ||
| 87 | + { arguments: { path: "results/summary.md" }, tool: "declare_artifact" }, | ||
| 88 | + { arguments: { path: "tables/measurements.csv" }, tool: "declare_artifact" }, | ||
| 89 | + { text: "Both deliverables are ready: results/summary.md and tables/measurements.csv." }, | ||
| 90 | + ], | ||
| 91 | + ]); | ||
| 92 | + const fixture: JourneyFixture = await createProjectAndSession(page, { | ||
| 93 | + approvalMode: "always_allow", | ||
| 94 | + model: { | ||
| 95 | + apiToken: stub.apiToken, | ||
| 96 | + baseUrl: stub.baseUrl, | ||
| 97 | + model: stub.model, | ||
| 98 | + name: `J10 local model ${Date.now()}`, | ||
| 99 | + }, | ||
| 100 | + projectName: `J10 artifact archive ${Date.now()}`, | ||
| 101 | + sessionTitle: `J10 archive session ${Date.now()}`, | ||
| 102 | + }); | ||
| 103 | + | ||
| 104 | + journey.scenario({ | ||
| 105 | + goal: "一位研究员这轮分析交付了两份东西:一份报告和一张数据表。她要把这两份一次性打包带给同事," | ||
| 106 | + + "而不是一个个点开下载,并且压缩包解开后目录结构和内容要跟产物区看到的一致。", | ||
| 107 | + preconditions: [ | ||
| 108 | + "隔离栈已启动,托管 Python 可用", | ||
| 109 | + "已有一个由旅程创建的项目与会话,审批模式为「始终允许」", | ||
| 110 | + "模型由旅程自带的本地 stub 驱动:一轮里写出并声明 results/summary.md 与 tables/measurements.csv", | ||
| 111 | + ], | ||
| 112 | + }); | ||
| 113 | + | ||
| 114 | + try { | ||
| 115 | + await journey.step( | ||
| 116 | + "让分析产出两份交付物", | ||
| 117 | + "运行结束后产物区出现两条记录:results/summary.md 和 tables/measurements.csv。", | ||
| 118 | + async () => { | ||
| 119 | + await openProjectSession(page, fixture); | ||
| 120 | + const run = await sendUserMessage(page, fixture.session.id, "Produce the summary report and the measurement table, and declare both as deliverables."); | ||
| 121 | + const terminal = await waitForRunTerminal(page, fixture.session.id, run.id); | ||
| 122 | + expect(terminal.status, terminal.error).toBe("completed"); | ||
| 123 | + await expect(await expandToolStep(page, { contains: marker })).toContainText(marker); | ||
| 124 | + const tree = await artifactTree(page); | ||
| 125 | + await expect(tree.artifactCount).toHaveText("2", { timeout: 30_000 }); | ||
| 126 | + await expect(tree.artifacts).toHaveCount(2); | ||
| 127 | + }, | ||
| 128 | + ); | ||
| 129 | + | ||
| 130 | + await journey.step( | ||
| 131 | + "进入多选并勾选这两份产物", | ||
| 132 | + "没选任何东西时「下载所选」是灰的;勾上两份之后它才可用,按钮上还写明已选 2 项。", | ||
| 133 | + async () => { | ||
| 134 | + const tree = await artifactTree(page); | ||
| 135 | + const download = tree.catalog.getByRole("button", { name: "下载所选" }); | ||
| 136 | + await expect(download).toBeDisabled(); | ||
| 137 | + await tree.catalog.getByRole("button", { name: "多选", exact: true }).click(); | ||
| 138 | + await expect(download).toBeDisabled(); | ||
| 139 | + for (const name of ["results/summary.md", "tables/measurements.csv"]) { | ||
| 140 | + await tree.catalog.getByRole("checkbox", { name: `选择 ${name}` }).click(); | ||
| 141 | + } | ||
| 142 | + await expect(download).toBeEnabled(); | ||
| 143 | + await expect(download).toHaveAttribute("title", /已选 2 项/); | ||
| 144 | + }, | ||
| 145 | + ); | ||
| 146 | + | ||
| 147 | + await journey.step( | ||
| 148 | + "下载压缩包并核对里面的内容", | ||
| 149 | + "浏览器收到 artifacts.zip;用独立的解压实现打开它,里面就是那两份文件,路径与内容跟产物区一致,界面也提示压缩包已下载。", | ||
| 150 | + async () => { | ||
| 151 | + const tree = await artifactTree(page); | ||
| 152 | + const downloadPromise = page.waitForEvent("download"); | ||
| 153 | + await tree.catalog.getByRole("button", { name: "下载所选" }).click(); | ||
| 154 | + const download = await downloadPromise; | ||
| 155 | + expect(download.suggestedFilename()).toBe("artifacts.zip"); | ||
| 156 | + const archivePath = testInfo.outputPath("artifacts.zip"); | ||
| 157 | + await download.saveAs(archivePath); | ||
| 158 | + const entries = readZipText(archivePath); | ||
| 159 | + expect(Object.keys(entries).sort()).toEqual(["results/summary.md", "tables/measurements.csv"]); | ||
| 160 | + expect(entries["results/summary.md"]).toBe(summaryText); | ||
| 161 | + expect(entries["tables/measurements.csv"]).toBe(tableText); | ||
| 162 | + await expect(page.getByText("产物压缩包已下载")).toBeVisible(); | ||
| 163 | + }, | ||
| 164 | + ); | ||
| 165 | + } finally { | ||
| 166 | + await cleanupJourney(page, fixture); | ||
| 167 | + await stub.stop(); | ||
| 168 | + } | ||
| 169 | +}); | ||
| @@ -0,0 +1,156 @@ | |||
| 1 | +// Copyright (C) 2026-2026 Huawei Technologies Co., Ltd | ||
| 2 | +// | ||
| 3 | +// Licensed under the Apache License, Version 2.0 (the "License"); | ||
| 4 | +// you may not use this file except in compliance with the License. | ||
| 5 | +// You may obtain a copy of the License at | ||
| 6 | +// | ||
| 7 | +// http://www.apache.org/licenses/LICENSE-2.0 | ||
| 8 | +// | ||
| 9 | +// Unless required by applicable law or agreed to in writing, software | ||
| 10 | +// distributed under the License is distributed on an "AS IS" BASIS, | ||
| 11 | +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| 12 | +// See the License for the specific language governing permissions and | ||
| 13 | +// limitations under the License. | ||
| 14 | + | ||
| 15 | +import { mkdir, writeFile } from "node:fs/promises"; | ||
| 16 | +import { join } from "node:path"; | ||
| 17 | + | ||
| 18 | +import { expect } from "@playwright/test"; | ||
| 19 | + | ||
| 20 | +import { apiBaseUrl, authorizationHeader } from "./e2e-auth.js"; | ||
| 21 | +import { test } from "./helpers/e2e.ts"; | ||
| 22 | + | ||
| 23 | +/** Write one importable folder on disk; the browser is handed the directory, not a ZIP. */ | ||
| 24 | +async function writeSkillFolder(root: string, files: Record<string, string>): Promise<string> { | ||
| 25 | + for (const [path, content] of Object.entries(files)) { | ||
| 26 | + const target = join(root, path); | ||
| 27 | + await mkdir(join(target, ".."), { recursive: true }); | ||
| 28 | + await writeFile(target, content, "utf8"); | ||
| 29 | + } | ||
| 30 | + return root; | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +/** | ||
| 34 | + * E2E-META | ||
| 35 | + * Purpose: A user can hand the product a local Skill folder and find that Skill installed with its supporting files, and gets a readable reason when the folder is not one. | ||
| 36 | + * Steps: | ||
| 37 | + * 1. Open System settings and the Skills group; the Skill is not installed yet. | ||
| 38 | + * 2. Pick a folder without SKILL.md and read the refusal; nothing is installed. | ||
| 39 | + * 3. Pick the prepared Skill folder; the Skills Explorer opens on it and its file tree carries both packaged files. | ||
| 40 | + * 4. Close the Explorer and find the Skill card in the list with its description, and the installed count one higher. | ||
| 41 | + * Environment: Isolated local stack at E2E_BASE_URL; folders are written into this run's output directory and uploaded through the browser. | ||
| 42 | + * Type: mocked | ||
| 43 | + * LLM: none — importing a Skill package never calls a model. | ||
| 44 | + * WebSearch: none | ||
| 45 | + * PaperSources: none | ||
| 46 | + * MCP: none | ||
| 47 | + * OtherExternal: none — the upload goes to the isolated local API and non-local browser requests are aborted. | ||
| 48 | + * Credentials: E2E_API_TOKEN for the isolated local API only. | ||
| 49 | + * CostSideEffects: no external cost; the imported Skill is deleted in finally. | ||
| 50 | + */ | ||
| 51 | +test("J11 本地 Skill 文件夹可以直接导入", { tag: "@mocked" }, async ({ journey, page }, testInfo) => { | ||
| 52 | + test.setTimeout(120_000); | ||
| 53 | + await page.addInitScript(() => window.localStorage.setItem("sciencediscovery-locale", "zh-CN")); | ||
| 54 | + const skillName = `folder-import-${Date.now()}`; | ||
| 55 | + const description = "Imported from a local folder by the J11 journey."; | ||
| 56 | + const noteText = "# Field notes\n\nStep two reads this supporting file.\n"; | ||
| 57 | + const skillFolder = await writeSkillFolder(testInfo.outputPath(`valid/${skillName}`), { | ||
| 58 | + "SKILL.md": `---\nname: ${skillName}\ndescription: ${description}\n---\n\n# ${skillName}\n\nRead reference/notes.md before starting.\n`, | ||
| 59 | + "reference/notes.md": noteText, | ||
| 60 | + }); | ||
| 61 | + const notASkillFolder = await writeSkillFolder(testInfo.outputPath("invalid/loose-notes"), { | ||
| 62 | + "readme.md": "# Loose notes\n\nThis folder has no SKILL.md.\n", | ||
| 63 | + }); | ||
| 64 | + | ||
| 65 | + const api = async (path: string, method = "GET") => { | ||
| 66 | + const response = await page.request.fetch(apiBaseUrl() + path, { headers: authorizationHeader(), method }); | ||
| 67 | + expect(response.ok(), `${method} ${path}: ${response.status()}`).toBe(true); | ||
| 68 | + return response.json() as Promise<unknown>; | ||
| 69 | + }; | ||
| 70 | + | ||
| 71 | + journey.scenario({ | ||
| 72 | + goal: "一位用户在本机整理好了一个 Skill 目录(SKILL.md 加一个参考文件),想直接把整个目录交给产品," | ||
| 73 | + + "导入后能在 Skill 列表里找到它、并且里面的参考文件也在;如果拿错了目录,要当场看懂为什么不行。", | ||
| 74 | + preconditions: [ | ||
| 75 | + "隔离栈已启动,Skill 库为该次运行独占的初始状态", | ||
| 76 | + "两个待选目录由本旅程当场写到运行输出目录:一个是合规 Skill 目录,一个只有 readme.md", | ||
| 77 | + "本旅程不调用任何模型", | ||
| 78 | + ], | ||
| 79 | + }); | ||
| 80 | + | ||
| 81 | + const importFolderInput = page.locator("input[webkitdirectory]"); | ||
| 82 | + const manager = page.locator(".skill-catalog-manager"); | ||
| 83 | + const skillCard = manager.locator("button.skill-card").filter({ hasText: skillName }); | ||
| 84 | + const installedStat = manager.locator(".skill-manager-stats span").filter({ hasText: "已安装" }).locator("strong"); | ||
| 85 | + let importedSkillId: string | undefined; | ||
| 86 | + let installedBefore = 0; | ||
| 87 | + | ||
| 88 | + try { | ||
| 89 | + await journey.step( | ||
| 90 | + "打开设置里的 Skills 页面", | ||
| 91 | + "Skill 管理器显示当前已安装的 Skill,其中还没有这次要导入的那一个。", | ||
| 92 | + async () => { | ||
| 93 | + await page.goto("/"); | ||
| 94 | + await page.getByRole("button", { name: /^系统设置/ }).click(); | ||
| 95 | + const dialog = page.getByRole("dialog", { name: "系统设置", exact: true }); | ||
| 96 | + await dialog.getByRole("navigation", { name: "设置分组" }).getByRole("button", { name: /^Skills/ }).click(); | ||
| 97 | + await expect(manager).toBeVisible(); | ||
| 98 | + await expect(skillCard).toHaveCount(0); | ||
| 99 | + installedBefore = Number(await installedStat.innerText()); | ||
| 100 | + expect(Number.isInteger(installedBefore)).toBe(true); | ||
| 101 | + }, | ||
| 102 | + ); | ||
| 103 | + | ||
| 104 | + await journey.step( | ||
| 105 | + "先拿一个不是 Skill 的目录试试", | ||
| 106 | + "产品不接收它,并且直接说清楚原因:目录根下必须有 SKILL.md;列表里也没有多出任何东西。", | ||
| 107 | + async () => { | ||
| 108 | + await importFolderInput.setInputFiles(notASkillFolder); | ||
| 109 | + await expect(manager.locator("p.skill-manager-error")).toContainText("SKILL.md"); | ||
| 110 | + await expect(skillCard).toHaveCount(0); | ||
| 111 | + }, | ||
| 112 | + ); | ||
| 113 | + | ||
| 114 | + await journey.step( | ||
| 115 | + "把整理好的 Skill 目录交给产品", | ||
| 116 | + "导入完成后 Skill 资源浏览器直接打开这个 Skill,文件树里既有 SKILL.md,也有目录下的 reference/notes.md——" | ||
| 117 | + + "说明整个目录被打包上传并在服务端完整解开了。", | ||
| 118 | + async () => { | ||
| 119 | + await importFolderInput.setInputFiles(skillFolder); | ||
| 120 | + const explorer = page.getByRole("dialog", { name: "Skill 资源浏览器" }); | ||
| 121 | + await expect(explorer).toBeVisible({ timeout: 30_000 }); | ||
| 122 | + const fileTree = explorer.getByRole("navigation", { name: "Skill 包文件树" }); | ||
| 123 | + await expect(fileTree).toContainText("SKILL.md"); | ||
| 124 | + await fileTree.locator("button.skill-file-tree-folder").filter({ hasText: "reference" }).click(); | ||
| 125 | + await fileTree.locator("button.skill-file-tree-file").filter({ hasText: "notes.md" }).click(); | ||
| 126 | + await expect(explorer.getByLabel("编辑 Skill 文件 reference/notes.md")).toHaveValue(noteText); | ||
| 127 | + const skills = await api("/api/skills") as Array<{ id: string; name: string }>; | ||
| 128 | + importedSkillId = skills.find((skill) => skill.name === skillName)?.id; | ||
| 129 | + expect(importedSkillId, "the imported Skill must be readable through the API a user's client uses").toBeTruthy(); | ||
| 130 | + }, | ||
| 131 | + ); | ||
| 132 | + | ||
| 133 | + await journey.step( | ||
| 134 | + "关掉浏览器,确认它留在了 Skill 列表里", | ||
| 135 | + "列表里出现这张 Skill 卡片,写着 SKILL.md 里的描述,已安装计数也随之增加。", | ||
| 136 | + async () => { | ||
| 137 | + await page.getByRole("button", { name: "关闭 Skill 浏览器", exact: true }).click(); | ||
| 138 | + await expect(skillCard).toHaveCount(1); | ||
| 139 | + await expect(skillCard).toContainText(description); | ||
| 140 | + await expect(installedStat).toHaveText(String(installedBefore + 1)); | ||
| 141 | + }, | ||
| 142 | + ); | ||
| 143 | + } finally { | ||
| 144 | + // A failure between the upload and the id lookup still leaves the Skill | ||
| 145 | + // installed, so resolve it by name rather than trusting the earlier step. | ||
| 146 | + const installed = importedSkillId ?? await api("/api/skills") | ||
| 147 | + .then((skills) => (skills as Array<{ id: string; name: string }>).find((skill) => skill.name === skillName)?.id) | ||
| 148 | + .catch(() => undefined); | ||
| 149 | + if (installed) { | ||
| 150 | + await page.request.fetch(`${apiBaseUrl()}/api/skills/${encodeURIComponent(installed)}`, { | ||
| 151 | + headers: authorizationHeader(), | ||
| 152 | + method: "DELETE", | ||
| 153 | + }).catch(() => undefined); | ||
| 154 | + } | ||
| 155 | + } | ||
| 156 | +}); | ||