Files
DadaoTask/web/e2e/knowledge-notebook.mjs
Steven d34a6ac69e
CI / backend (push) Canceled after 0s
feat(knowledge): 阅读改成独立整页,抽屉只用来编辑
知识原来一律在抽屉里打开,正文贴着屏幕右半边,长文读十几屏眼睛一直在右边。
阅读是「坐下来读一会儿」,编辑是「改两句就走」,两件事不该共用一个容器。

- 新增阅读页 /w/:wsId/knowledge/:itemId:正文列限到 40rem 居中,28px 标题,
  更新/发布/切片行,标签与来源,动作行放编辑与发布/归档/恢复
- 列表页与搜索结果改成跳阅读页,不再弹抽屉
- KnowledgeDrawer 加 initialEditing:进来就是编辑器,保存或取消直接关
- 文案常量搬到 lib/knowledge.ts,列表页与阅读页共用一份

顺带修三个 e2e 脚本:新建后进阅读页没有「关闭」按钮可点、侧栏色点按钮让树
顺序断言多出空串、翻页脚本还在用已关闭的短信登录(改密码登录)。
2026-11-03 12:12:51 +08:00

275 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 知识备忘录形态 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);
}