Files
DadaoTask/web/e2e/knowledge-notebook.mjs
Steven 7c84ff3b8d
CI / backend (push) Canceled after 0s
feat(knowledge): 知识能挂附件,新建时就能传
知识是备忘录,备忘录不能只有字:手册、日志、导出表格都得能挂上去。
附件本来就落在 item 上(`ALLOWED_KINDS` 早含 knowledge),缺的是"新建时
还没有条目 id"这一段。

- 懒建草稿:`KnowledgeComposer` 第一次传附件(或往正文贴图)时先 `POST /knowledge`
  存一条草稿拿到 id,`draftingRef` 去重防止贴图与上传同时打出两条草稿;
  之后的保存走 `PATCH /knowledge/{id}`。没传过附件就取消,库里不留空条目。
- `RichTextEditor` 增 `ensureItemId`:没有 itemId 时先把草稿存下来再传图,
  于是"新建时不能贴图"这条限制也没了;工具栏上传按钮按是否有落点显隐。
- 新建面板加了附件区:有草稿 id 就是正常的 `AttachmentPanel`(上传/下载/删),
  没有就先给一个「添加附件」按钮,点一下建草稿。
- 安卓知识详情加附件区:列表(文件名 + 大小)、`OpenDocument` 上传任意文件、
  点「打开」用浏览器看签名直链。编辑器原本只能插图,现在手册/日志也能传。
- 文档:API.md 说明附件三类事项共用同一套接口,以及懒建草稿的先后顺序;
  ROADMAP 加 3.13。

验证:后端 pytest 94 passed、ruff 全绿;Web `tsc --noEmit` 通过、`vite build` 通过;
`knowledge-notebook.mjs` 15 项全绿(新增「新建面板里能上传附件」「详情抽屉里能看到
刚传的附件」「附件挂在知识上,详情回读得到」)。安卓端仍是 JDK 25 + Gradle 8.7
不兼容,本次未编译验证。
2026-10-30 16:18:32 +08:00

249 lines
11 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);
// ② 记一条:一个面板里填完标题/类型/分类/标签/正文
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.getByRole("button", { name: "关闭", exact: true }).click();
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(),
]);
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);
}