/** * 知识备忘录形态 e2e:分类树 + 新建时一次填全(标题/类型/分类/标签/正文)+ 按分类筛。 * * 与 knowledge.mjs 的分工:那个脚本验"沉淀 → 发布"闭环,这个只验"知识是最像备忘录的形态"。 * 前面造数据走接口,界面部分点真实按钮。 */ import { chromium } from "playwright-core"; import { mkdirSync, writeFileSync } from "node:fs"; const WEB = process.env.WEB ?? "http://127.0.0.1:5180"; const API = process.env.API ?? "http://127.0.0.1:8100/api/v1"; const stamp = String(Date.now()).slice(-7); const phone = `137${stamp}0`; let failures = 0; function check(name, ok, extra = "") { console.log(`${ok ? "PASS" : "FAIL"} ${name}${extra ? ` — ${extra}` : ""}`); if (!ok) failures += 1; } async function call(method, path, body, token) { const res = await fetch(`${API}${path}`, { method, headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}), ...(method === "GET" ? {} : { "idempotency-key": `e2e-${Math.random()}` }), }, body: body ? JSON.stringify(body) : undefined, }); const text = await res.text(); const json = text ? JSON.parse(text) : {}; if (json.code !== 0) throw new Error(`${method} ${path} -> ${json.code} ${json.message}`); return json.data; } const browser = await chromium.launch({ executablePath: process.env.CHROME ?? `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`, }); const consoleErrors = []; const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const page = await context.newPage(); page.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); }); page.on("response", (r) => { if (r.status() >= 400) consoleErrors.push(`${r.status()} ${r.url()}`); }); try { await call("POST", "/auth/sms/send", { phone }); const login = await call("POST", "/auth/login", { phone, code: "123456" }); const token = login.tokens.access_token; const ws = await call("POST", "/workspaces", { name: `知识备忘 ${stamp}` }, token); await page.addInitScript( ([access, refresh]) => { localStorage.setItem("dadao.access", access); localStorage.setItem("dadao.refresh", refresh); }, [token, login.tokens.refresh_token], ); await page.goto(`${WEB}/w/${ws.id}/knowledge`, { waitUntil: "networkidle" }); await page.getByRole("heading", { name: "知识" }).waitFor({ timeout: 10_000 }); // ① 建两级分类:研发 / 部署 await page.getByRole("button", { name: "+ 新建", exact: true }).click(); await page.getByLabel("新分类名").fill("研发"); await page.getByRole("button", { name: "创建", exact: true }).click(); await page.getByRole("button", { name: /^研发/, exact: false }).first().waitFor(); await page.getByRole("button", { name: "+ 新建", exact: true }).click(); await page.getByLabel("新分类名").fill("部署"); await page.getByLabel("父分类").selectOption({ label: "研发 下" }); await page.getByRole("button", { name: "创建", exact: true }).click(); await page.getByRole("button", { name: /^部署/, exact: false }).first().waitFor(); check("能在侧栏建出「研发 / 部署」两级分类", true); // 树要按「父在前、子紧跟」排:部署必须紧挨在研发后面,而不是堆在别的根分类后面 const navTexts = await page.locator("aside nav button").allInnerTexts(); // 分类行里的色点是按钮但没有文字,剔除掉空串再比顺序 const navNames = navTexts .map((t) => t.replace(/[·\d]/g, "").trim()) .filter(Boolean); check( "侧栏分类按树的顺序排(子紧跟在父后面)", navNames.indexOf("部署") === navNames.indexOf("研发") + 1, navNames.join(" / "), ); // ② 记一条:一个面板里填完标题/类型/分类/标签/正文 await page.getByRole("button", { name: "记一条", exact: true }).click(); await page.getByLabel("标题").fill("回滚前先停灰度"); await page.getByLabel("类型").selectOption("howto"); await page.getByLabel("分类", { exact: true }).selectOption({ label: "研发 / 部署" }); await page.getByLabel("标签").fill("发布 事故"); // 正文是富文本编辑器(Vditor),不是 textarea:先等它挂载,再用真实键盘输入 const composerBody = page.locator('.vditor-ir [contenteditable="true"]').last(); await composerBody.waitFor({ timeout: 20_000 }); await composerBody.click(); await page.keyboard.type("## 步骤", { delay: 10 }); await page.keyboard.press("Enter"); await page.keyboard.type("1. 停掉灰度流量", { delay: 10 }); await page.keyboard.press("Enter"); await page.keyboard.type("回滚镜像到上一个 tag", { delay: 10 }); await page.keyboard.press("Enter"); await page.keyboard.type("验证关键接口", { delay: 10 }); check( "新建面板的正文是富文本编辑器", (await composerBody.getAttribute("aria-label")) === "正文", ); await page.screenshot({ path: "e2e/artifacts/knowledge-composer-editor.png" }); // ②½ 附件:新建面板里就能传——点「添加附件」先把这条存成草稿,再上传 mkdirSync("e2e/artifacts", { recursive: true }); const samplePath = "e2e/artifacts/附件样例.txt"; writeFileSync(samplePath, "命令行回滚手册:kubectl rollout undo deployment/api\n"); await page.getByRole("button", { name: "添加附件", exact: true }).click(); // 面板出现后再走真实的「上传」按钮 + filechooser:页面上不止一个 file input(编辑器也有) await page.getByRole("button", { name: "上传", exact: true }).waitFor({ timeout: 15_000 }); const [chooser] = await Promise.all([ page.waitForEvent("filechooser", { timeout: 15_000 }), page.getByRole("button", { name: "上传", exact: true }).click(), ]); const [uploadResp] = await Promise.all([ page.waitForResponse( (r) => r.url().includes("/attachments") && r.request().method() === "POST", { timeout: 20_000 }, ), chooser.setFiles(samplePath), ]); await page.getByText("附件样例.txt", { exact: false }).first().waitFor({ timeout: 10_000 }); check("新建面板里能上传附件(先懒建草稿)", uploadResp.ok(), `HTTP ${uploadResp.status()}`); await page.screenshot({ path: "e2e/artifacts/knowledge-composer-attachment.png" }); await page.getByRole("button", { name: "保存并继续写", exact: true }).click(); // 保存后直接打开详情:标题、分类路径、标签都在 await page.getByText("回滚前先停灰度", { exact: false }).first().waitFor({ timeout: 10_000 }); const drawerText = (await page.textContent("body")) ?? ""; check( "新建面板能把分类与标签一起存下来", drawerText.includes("研发 / 部署") && drawerText.includes("发布"), ); check("详情抽屉里能看到刚传的附件", drawerText.includes("附件样例.txt")); await page.screenshot({ path: "e2e/artifacts/knowledge-composer-saved.png", fullPage: false }); // 保存之后直接进阅读页:退回列表,回来验卡片 await page.waitForURL(/\/knowledge\/[0-9a-f-]{8,}$/, { timeout: 10_000 }); await page.getByRole("link", { name: "返回知识库" }).click(); await page.waitForURL(/\/knowledge$/, { timeout: 10_000 }); // ③ 列表卡片上能看到分类路径与标签(不用点开) const card = page.locator("li").filter({ hasText: "回滚前先停灰度" }).first(); await card.waitFor({ timeout: 10_000 }); const cardText = (await card.textContent()) ?? ""; check("列表卡片带分类路径与标签", cardText.includes("研发 / 部署") && cardText.includes("#发布")); // ④ 侧栏按分类筛:部署里有,未分类里没有 await page.getByRole("button", { name: /^部署/, exact: false }).first().click(); await page.waitForTimeout(400); check( "点「部署」能看到归属该分类的知识", await page.getByText("回滚前先停灰度", { exact: false }).first().isVisible(), ); await page.getByRole("button", { name: "未分类", exact: true }).click(); await page.waitForTimeout(400); const uncategorizedHasCard = await page .getByText("回滚前先停灰度", { exact: false }) .first() .isVisible() .catch(() => false); check("「未分类」里不出现已归类的知识", !uncategorizedHasCard); await page.screenshot({ path: "e2e/artifacts/knowledge-category-filter.png", fullPage: false }); // ⑤ 抽屉里能改标签与分类,改完接口里就是新的 await page.getByRole("button", { name: "全部知识", exact: true }).click(); // 先等抽屉自己把详情拉回来再填:GET 比手快一步的话,刚填的字会被它覆盖掉 await Promise.all([ page.waitForResponse( (r) => r.url().includes("/knowledge/") && r.request().method() === "GET" && !r.url().endsWith("/knowledge/categories") && r.status() === 200, ), page.getByRole("button", { name: "打开", exact: true }).first().click(), ]); // 详情默认是「看」:Markdown 渲染后的样子,不是编辑器 await page.locator(".prose-dadao h2").first().waitFor({ timeout: 10_000 }); check( "详情抽屉默认渲染样式(不是进来就编辑)", (await page.locator(".vditor-ir").count()) === 0 && (await page.locator(".prose-dadao h2").first().textContent())?.includes("步骤") === true, ); // 要改才点「编辑」 await page.getByRole("button", { name: "编辑", exact: true }).last().click(); const tagInput = page.getByLabel("标签", { exact: true }).last(); await tagInput.waitFor({ timeout: 10_000 }); // 先等抽屉把自己拉回来的旧标签填上——那一刻之后才没有"迟到的响应"来覆盖输入 await page .waitForFunction( () => { const el = document.querySelector('input[aria-label="标签"]'); return !!el && el.value.includes("事故"); }, null, { timeout: 10_000 }, ) .catch(() => {}); // 用真实键盘输入而不是 fill:fill 走原生 setter,React 的受控 input 有时收不到这次变更 await tagInput.click(); await tagInput.press("Control+A"); await page.keyboard.type("发布 回滚", { delay: 20 }); check("抽屉里的标签输入框拿到新值", (await tagInput.inputValue()) === "发布 回滚"); const [patchResp] = await Promise.all([ page.waitForResponse( (r) => r.url().includes("/knowledge/") && r.request().method() === "PATCH" && r.status() === 200, ), page.getByRole("button", { name: "保存", exact: true }).last().click(), ]); check("抽屉里保存标签/分类成功", patchResp.ok()); const list = await call( "GET", `/workspaces/${ws.id}/knowledge?status=draft&status=in_review`, undefined, token, ); const doc = list.items.find((d) => d.title === "回滚前先停灰度"); check( "接口里标签已更新、分类是「研发 / 部署」", String((doc?.tags ?? []).map((t) => t.name).join(" ")).includes("回滚") && doc?.category?.full_path === "研发 / 部署", `tags=${JSON.stringify((doc?.tags ?? []).map((t) => t.name))} category=${doc?.category?.full_path}`, ); check( "正文按 Markdown 存下来了", String(doc?.body_md ?? "").includes("停掉灰度流量"), ); // 列表只带 attachment_count(省一次 N+1),文件清单要问详情 const detail = await call("GET", `/workspaces/${ws.id}/knowledge/${doc.id}`, undefined, token); check( "附件挂在这条知识上(详情回读得到)", (detail?.attachments ?? []).some((a) => a.filename === "附件样例.txt"), `attachments=${JSON.stringify((detail?.attachments ?? []).map((a) => a.filename))}`, ); // ⑥ 分类重名与三级上限:接口层面确认约束还在 const dup = await fetch( `${API}/workspaces/${ws.id}/knowledge/categories`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify({ name: "部署", parent_id: doc.category.parent_id }), }, ).then((r) => r.json()); check("同层重名被拒(7008)", dup.code === 7008, `code=${dup.code}`); check("浏览器控制台与网络无报错", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | ")); } catch (err) { failures += 1; console.log(`FAIL 脚本异常 — ${err.message}`); await page.screenshot({ path: "e2e/artifacts/knowledge-notebook-failure.png" }).catch(() => {}); console.log((await page.textContent("body"))?.slice(0, 600)); } finally { await browser.close(); console.log(failures === 0 ? "\nALL PASS" : `\n${failures} FAILED`); process.exit(failures === 0 ? 0 : 1); }