CI / backend (push) Canceled after 0s
两个都是"看着不对劲"的问题: 1. 侧栏把二级分类全堆在一级分类后面(按 depth 排序的副作用), 「技术」和它的上级「我的备忘」隔着十几行,看着像一棵假树。 后端改成按树的先序返回(父分类后紧跟它的子分类,兄弟仍按 sort_order、name), 前端照 depth 缩进就是个真树;二级以下再加一个「·」标记,小屏也分得清。 2. 点开已发布的知识只有「归档」,没有改的入口——因为后端原来硬性拒绝 修改已发布内容(7003)。但"发布"是给团队看的,写错了总要能改: 现在已发布的可以直接 PATCH,改完立刻重建检索切片(chunk_count 当场更新), 状态和版本号照常推进;归档的仍然要先「恢复成草稿」再改。 Web 与安卓同步:抽屉/详情里已发布也显示「编辑」,安卓分类标签区不再锁。 验证:pytest 95 passed(新增"分类按树返回"用例、发布后可改用例)、 ruff 全绿、tsc 0、vite build ✓;`knowledge-notebook.mjs` 17 项、 `knowledge.mjs` 9 项 e2e 全绿。安卓仍未编译(JDK 25 / Gradle 8.7 不兼容)。
143 lines
6.1 KiB
JavaScript
143 lines
6.1 KiB
JavaScript
/**
|
|
* 知识闭环 e2e:一件事 → 知识草稿 → 核对 → 发布。
|
|
*
|
|
* 走的是真实浏览器 + 真实后端(8100)。前面造数据用接口,
|
|
* 后面验证界面,这样脚本短、失败点也清楚。
|
|
*/
|
|
import { chromium } from "playwright-core";
|
|
|
|
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 = `135${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);
|
|
const task = await call(
|
|
"POST",
|
|
`/workspaces/${ws.id}/items`,
|
|
{ title: "发票原件必须一起寄", body_md: "电子发票打印件不算,财务退回过一次。" },
|
|
token,
|
|
);
|
|
const moved = await call(
|
|
"POST",
|
|
`/workspaces/${ws.id}/items/${task.id}/status`,
|
|
{ status: "todo", version: task.version },
|
|
token,
|
|
);
|
|
await call(
|
|
"POST",
|
|
`/workspaces/${ws.id}/items/${task.id}/status`,
|
|
{ status: "done", version: moved.version },
|
|
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}`, { waitUntil: "networkidle" });
|
|
await page.getByRole("button", { name: "已完成", exact: true }).click();
|
|
await page.getByText("发票原件必须一起寄", { exact: false }).first().click();
|
|
await page.getByRole("button", { name: "沉淀成知识", exact: true }).click();
|
|
await page.getByText("已生成知识草稿", { exact: false }).waitFor({ timeout: 10_000 });
|
|
check("任务详情能把这件事沉淀成草稿", true);
|
|
|
|
await page.getByRole("button", { name: "关闭", exact: true }).click();
|
|
|
|
await page.getByRole("link", { name: "知识", exact: true }).click();
|
|
const card = page.getByText("发票原件必须一起寄", { exact: false }).first();
|
|
await card.waitFor({ timeout: 10_000 });
|
|
const draftLabelVisible = await page.getByText("草稿", { exact: true }).first().isVisible();
|
|
check("草稿出现在「待核对」", draftLabelVisible);
|
|
await page.screenshot({ path: "e2e/artifacts/knowledge-draft.png", fullPage: false });
|
|
|
|
await page.getByRole("button", { name: "打开", exact: true }).first().click();
|
|
// 等这一个请求落地再断言,不然会和抽屉的刷新抢跑("已发布"这四个字在 tab 上也有一份)
|
|
const [publishResp] = await Promise.all([
|
|
page.waitForResponse(
|
|
(r) => r.url().includes("/knowledge/") && r.url().endsWith("/status") && r.status() === 200,
|
|
),
|
|
page.getByRole("button", { name: "发布", exact: true }).click(),
|
|
]);
|
|
check("发布请求成功", publishResp.ok());
|
|
await page.getByText(/发布于 /, { exact: false }).first().waitFor({ timeout: 10_000 });
|
|
const published = await page.getByText(/发布于 /, { exact: false }).first().isVisible();
|
|
check("核对后可以发布", published);
|
|
|
|
// 发布之后也得能改:点「编辑」回到编辑器,改完还是已发布
|
|
const editButton = page.getByRole("button", { name: "编辑", exact: true }).last();
|
|
check("已发布的知识也有「编辑」按钮", await editButton.isVisible());
|
|
await editButton.click();
|
|
await page.locator(".vditor-ir").first().waitFor({ timeout: 10_000 });
|
|
check("点「编辑」回到编辑器", (await page.locator(".vditor-ir").count()) > 0);
|
|
|
|
const again = await fetch(`${API}/workspaces/${ws.id}/knowledge`, {
|
|
headers: { authorization: `Bearer ${token}` },
|
|
}).then((r) => r.json());
|
|
// 同标题可能有多条(每次跑脚本都会新建工作区,但标题固定在两处),取已发布的那条
|
|
const doc = again.data.items.find(
|
|
(d) => d.title === "发票原件必须一起寄" && d.knowledge.status_hint === "published",
|
|
);
|
|
check(
|
|
"发布后接口里 published 且有发布时间",
|
|
doc?.knowledge.status_hint === "published" && Boolean(doc?.knowledge.published_at),
|
|
);
|
|
check("正文带「请先核对再发布」的提示", String(doc?.body_md ?? "").includes("请先核对再发布"));
|
|
await page.screenshot({ path: "e2e/artifacts/knowledge-published.png", fullPage: false });
|
|
|
|
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-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);
|
|
}
|