CI / backend (push) Canceled after 0s
知识是备忘录,备忘录不能只有字:手册、日志、导出表格都得能挂上去。
附件本来就落在 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
不兼容,本次未编译验证。
249 lines
11 KiB
JavaScript
249 lines
11 KiB
JavaScript
/**
|
||
* 知识备忘录形态 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);
|
||
}
|