CI / backend (push) Canceled after 0s
1440 的屏幕上 40rem 正文只有 640px,两边各空出半屏,用户直接发截图说"留白比内容都多"。 正文列 56rem(约 55 个汉字一行)、卡片 78rem,行宽到头但不用来回扫行。 e2e 的宽度断言跟着改成 720–920px,避免以后再窄回去没人拦。
160 lines
5.9 KiB
JavaScript
160 lines
5.9 KiB
JavaScript
/**
|
||
* 知识阅读页的验收:点开一条知识应该离开列表、进整页阅读,正文落在视线中间。
|
||
*
|
||
* 断言的是用户提的那件事,而不是"有个 drawer":
|
||
* ① 点卡片后 URL 变成 /w/<ws>/knowledge/<id>,列表页的元素不在了
|
||
* ② 正文渲染成样式(h2 存在),不是编辑器
|
||
* ③ 正文列限宽但别太窄(720–920px,约 50 来个汉字一行),且在内容区里居中
|
||
* ④ 点「编辑」才出现编辑器(抽屉),保存/取消都收回去
|
||
*/
|
||
import { chromium } from "playwright-core";
|
||
import { mkdirSync, readFileSync } 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 PHONE = process.env.PHONE ?? "13900000001";
|
||
const OUT = "e2e/artifacts";
|
||
const PWD_FILE = new URL("../../.local/e2e-account-password.txt", import.meta.url);
|
||
|
||
let pass = 0;
|
||
let fail = 0;
|
||
function check(name, ok, extra = "") {
|
||
if (ok) {
|
||
pass += 1;
|
||
console.log(`PASS ${name}`);
|
||
} else {
|
||
fail += 1;
|
||
console.log(`FAIL ${name}${extra ? ` — ${extra}` : ""}`);
|
||
}
|
||
}
|
||
|
||
// 生产关了短信登录:PASSWORD 优先,其次读本机那份 dev 密码
|
||
const password = (() => {
|
||
if (process.env.PASSWORD) return process.env.PASSWORD;
|
||
try {
|
||
return readFileSync(PWD_FILE, "utf8").trim();
|
||
} catch {
|
||
return "";
|
||
}
|
||
})();
|
||
|
||
const browser = await chromium.launch({
|
||
executablePath:
|
||
process.env.CHROME ??
|
||
`${process.env.LOCALAPPDATA}/ms-playwright/chromium-1223/chrome-win64/chrome.exe`,
|
||
});
|
||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
|
||
const errors = [];
|
||
page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
|
||
page.on("pageerror", (e) => errors.push(String(e)));
|
||
mkdirSync(OUT, { recursive: true });
|
||
|
||
try {
|
||
const r = await fetch(`${API}/auth/login/password`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ phone: PHONE, password }),
|
||
});
|
||
const login = await r.json();
|
||
if (login.code !== 0) throw new Error(`登录失败:${JSON.stringify(login)}`);
|
||
const ws = login.data.user ? null : null;
|
||
void ws;
|
||
const token = login.data.tokens.access_token;
|
||
const refresh = login.data.tokens.refresh_token;
|
||
|
||
// 找一个有正文的知识
|
||
const listResp = await fetch(`${API}/workspaces`, {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
}).then((x) => x.json());
|
||
const workspaces = listResp.data.items ?? listResp.data.workspaces ?? listResp.data;
|
||
const workspace = workspaces.find((w) => w.name?.includes("备忘")) ?? workspaces[0];
|
||
const wsId = workspace.id;
|
||
|
||
const docsResp = await fetch(`${API}/workspaces/${wsId}/knowledge?limit=50`, {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
}).then((x) => x.json());
|
||
const docs = docsResp.data.items.filter((d) => (d.body_md ?? "").length > 200);
|
||
if (docs.length === 0) throw new Error("没有正文够长的知识可用来验收");
|
||
const target = docs[0];
|
||
|
||
await page.addInitScript(
|
||
([access, refreshToken]) => {
|
||
localStorage.setItem("dadao.access", access);
|
||
localStorage.setItem("dadao.refresh", refreshToken);
|
||
},
|
||
[token, refresh],
|
||
);
|
||
|
||
await page.goto(`${WEB}/w/${wsId}/knowledge`);
|
||
await page.waitForSelector("li button");
|
||
|
||
await page
|
||
.locator("li")
|
||
.filter({ hasText: target.title.slice(0, 8) })
|
||
.first()
|
||
.getByRole("button")
|
||
.first()
|
||
.click();
|
||
|
||
const onDoc = await page
|
||
.waitForURL(new RegExp(`/w/${wsId}/knowledge/${target.id}$`), { timeout: 10000 })
|
||
.then(() => true)
|
||
.catch(() => false);
|
||
check("点卡片进的是独立阅读页(不是抽屉)", onDoc, page.url());
|
||
|
||
await page.waitForSelector("h1");
|
||
const title = await page.locator("h1").first().innerText();
|
||
check("整页标题就是这条知识的标题", title.trim() === target.title.trim(), title);
|
||
|
||
// 正文渲染成样式:markdown 里的标题/列表应该变成真元素
|
||
const hasProse = (await page.locator(".prose-dadao").count()) > 0;
|
||
const hasEditor = (await page.locator(".vditor-ir").count()) > 0;
|
||
check("阅读时是渲染后的正文,不是编辑器", hasProse && !hasEditor, `prose=${hasProse} editor=${hasEditor}`);
|
||
|
||
// 正文列在内容区里居中、宽度适合阅读
|
||
const box = await page.locator(".prose-dadao").first().boundingBox();
|
||
const main = await page.locator("main").first().boundingBox();
|
||
if (box && main) {
|
||
const leftGap = box.x - main.x;
|
||
const rightGap = main.x + main.width - (box.x + box.width);
|
||
const width = box.width;
|
||
check(
|
||
"正文列宽度适合长文(720–920px,宽屏不留大片空白)",
|
||
width >= 720 && width <= 920,
|
||
`width=${Math.round(width)}`,
|
||
);
|
||
check(
|
||
"正文列在内容区里居中",
|
||
Math.abs(leftGap - rightGap) < 40,
|
||
`left=${Math.round(leftGap)} right=${Math.round(rightGap)}`,
|
||
);
|
||
} else {
|
||
check("正文列在内容区里居中", false, "拿不到布局尺寸");
|
||
}
|
||
|
||
await page.screenshot({ path: `${OUT}/knowledge-read-page.png` });
|
||
|
||
// 编辑才是抽屉
|
||
await page.getByRole("button", { name: "编辑", exact: true }).click();
|
||
await page.waitForSelector(".vditor-ir", { timeout: 10000 });
|
||
check("点「编辑」才出现编辑器(抽屉)", (await page.locator(".vditor-ir").count()) > 0);
|
||
await page.screenshot({ path: `${OUT}/knowledge-edit-drawer.png` });
|
||
|
||
await page.getByRole("button", { name: "取消", exact: true }).click();
|
||
const closed = await page
|
||
.waitForSelector(".vditor-ir", { state: "detached", timeout: 10000 })
|
||
.then(() => true)
|
||
.catch(() => false);
|
||
check("取消编辑后回到阅读页", closed);
|
||
|
||
check("控制台无报错", errors.length === 0, errors.slice(0, 2).join(" | "));
|
||
} catch (err) {
|
||
fail += 1;
|
||
console.log(`FAIL 脚本异常 — ${err}`);
|
||
} finally {
|
||
await browser.close();
|
||
}
|
||
|
||
console.log(`\n${fail === 0 ? "ALL PASS" : "FAILED"} — pass ${pass}, fail ${fail}`);
|
||
process.exit(fail === 0 ? 0 : 1);
|