已合并
test(e2e): 补齐产物打包下载与 Skill 文件夹导入的浏览器旅程 #156
test(e2e): 补齐产物打包下载与 Skill 文件夹导入的浏览器旅程 #156
已合并
wang_cheng_zhao创建于 9月20日
共 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+});