Files
DadaoTask/web/e2e/richtext.mjs
T
dadao 17be9d1d42
CI / backend (push) Canceled after 0s
feat(rich): 安卓能编辑富文本了,图片直链换成短期签名,表格不再撑破抽屉
三件欠账一次清:

1. 图片直链签名化
   - 新增 `backend/app/services/signing.py`:HMAC-SHA256,密钥由 `JWT_SECRET` 域分离派生
     (`attachment-raw-v1`),载荷 `workspace_id:attachment_id:exp`,默认 30 分钟
   - `/raw` 支持 `?exp=&sig=`,不需要登录态;签名里绑了工作区,跨租户链接直接拒
   - 附件对象多一个 `raw_url`,前端每次渲染现签,正文里只存不带凭据的 `/raw`
   - 保留旧 `?token=` 通道,给未升级的客户端过渡

2. 安卓富文本编辑器
   - `ui/RichTextEditorScreen.kt`:Markdown 工具栏(粗体/斜体/标题/列表/引用/代码/链接/图片),
     选区包裹与行首前缀都按光标位置来;贴图走附件接口,插入签名直链
   - 预览用 Markwon,图片用 Coil;正文落在一张白纸卡片里,不再贴屏幕边
   - 从任务详情与知识点都能进,保存按 `body_md` 回写(PATCH 带 version)

3. 表格
   - 只读渲染走 markdown-it 的 GFM 表,`display:block + overflow-x:auto`:
     宽表横向滚动、窄表铺满,都不撑破抽屉

验证:pytest 74 passed;richtext 15 / smoke 7 / tickets 8 / knowledge 7 全绿;
tsc -b 0 错;vite build 通过(主包 299.42 kB / gzip 74.96 kB);
安卓 assembleDebug 成功并在 API 35 模拟器走通编辑与预览。

仍欠:保存后回列表而非回详情抽屉、图片压缩与缩略图、HTML 片段按文本显示。
2026-10-29 03:32:53 +08:00

204 lines
8.8 KiB
JavaScript
Raw 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:备注用 Vditor 写 Markdown、贴图能上传并显示、附件能传能列。
*
* 走真浏览器而不是只测接口,因为要验的正是四件"只有真渲染才看得见"的事:
* 1. 输 `**粗体**` 之后编辑器里真的变成了 <strong>(IR 模式的输入规则生效);
* 2. 上传的图片在编辑器里能显示(说明 /raw 直链带对了签名);
* 3. 存进库的正文是**干净**的(不含 ?exp=/?sig=),否则几天后满篇红叉;
* 4. 重新打开抽屉,正文与附件都还在(证明落库而不是活在内存)。
*/
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 = `133${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;
}
/** 1×1 的 PNG,够用来验证"上传 → 直链 → 渲染"整条路。 */
const PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64",
);
const browser = await chromium.launch({
executablePath:
process.env.CHROME ??
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`,
});
const problems = [];
/** 记录所有 /raw 请求的状态码,验证图片确实被取到 */
const rawHits = [];
const context = await browser.newContext({ viewport: { width: 1360, height: 940 } });
const page = await context.newPage();
page.on("console", (m) => {
if (m.type() === "error") problems.push(m.text());
});
page.on("response", (r) => {
if (r.status() >= 400) problems.push(`${r.status()} ${r.url()}`);
if (r.url().includes("/attachments/") && r.url().includes("/raw")) rawHits.push(r.status());
});
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 item = await call(
"POST",
`/workspaces/${ws.id}/items`,
{ title: "整理发票原件" },
token,
);
await page.goto(`${WEB}/login`);
await page.evaluate(
([access, refresh]) => {
localStorage.setItem("dadao.access", access);
localStorage.setItem("dadao.refresh", refresh);
},
[token, login.tokens.refresh_token],
);
await page.goto(`${WEB}/w/${ws.id}`);
await page.getByRole("heading", { name: "任务" }).waitFor();
// 新建的事项默认进收件箱,而默认视图是「今天」(按截止日筛),先切到「全部」
await page.getByRole("button", { name: "全部", exact: true }).click();
await page.getByRole("button", { name: "整理发票原件", exact: true }).click();
const drawer = page.getByRole("dialog");
await drawer.waitFor();
// —— 1. 编辑器确实是成熟组件(Vditor),而不是自研 textarea ——
await page.waitForSelector(".vditor-ir", { timeout: 20000 });
await page.waitForFunction(() => Boolean(window.Lute), null, { timeout: 20000 });
check("编辑器加载为 Vditor(lute 已就绪,非 CDN 依赖)", true);
const editor = drawer.getByLabel("备注");
await editor.click();
// 逐字输入,让 IR 模式的输入规则生效(`**` 收尾会转成粗体,先打字再回车)
await page.keyboard.type("**必须** 一起寄出,参考 ", { delay: 25 });
await page.keyboard.type("`财务口径 v3`", { delay: 25 });
await page.keyboard.type("。", { delay: 25 });
await page.keyboard.press("Enter");
await page.keyboard.type("- 纸质发票", { delay: 40 });
await page.keyboard.press("Enter");
// 列表里回车会自然续上新的一项,不需要再打一遍 "- "(打了会变成嵌套列表)
await page.keyboard.type("快递单", { delay: 40 });
await page.waitForTimeout(500);
const boldCount = await drawer.locator(".vditor-ir strong").count();
const codeCount = await drawer.locator(".vditor-ir code").count();
const listCount = await drawer.locator(".vditor-ir li").count();
check("输入 **粗体** 就地渲染成 strong", boldCount > 0, `strong=${boldCount}`);
check("输入 `行内代码` 就地渲染成 code", codeCount > 0, `code=${codeCount}`);
check("输入 - 列表就地渲染成 li(两条)", listCount === 2, `li=${listCount}`);
await drawer.getByRole("button", { name: "保存备注" }).click();
await page.waitForTimeout(600);
// —— 2. 库里存的是干净正文:不能把令牌写进去 ——
const saved = await call("GET", `/workspaces/${ws.id}/items/${item.id}`, null, token);
const body = String(saved.body_md ?? "");
check("正文已落库", body.includes("必须") && body.includes("财务口径"), body.slice(0, 40));
const leaked = ["token=", "exp=", "sig="].filter((needle) => body.includes(needle));
check("落库正文不含凭据(token/exp/sig)", leaked.length === 0, leaked.join(","));
// —— 3. 编辑器插入图片(走的是粘贴/拖拽同一段上传逻辑)——
const uploadInput = drawer.locator('.vditor input[type="file"]');
await uploadInput.setInputFiles({ name: "票据截图.png", mimeType: "image/png", buffer: PNG });
await page.waitForTimeout(1500);
const img = drawer.locator(".vditor-ir img").first();
await img.waitFor({ timeout: 10000 });
const src = (await img.getAttribute("src")) ?? "";
check("上传后编辑器里出现图片", Boolean(src), src.slice(0, 60));
check(
"图片走带签名的 /raw 直链",
src.includes("/raw?") && (src.includes("sig=") || src.includes("token=")),
src.slice(0, 80),
);
await page.waitForTimeout(800);
check("图片资源能取到(/raw 返回 200)", rawHits.includes(200), rawHits.join(","));
// —— 4. 附件面板上传与列表 ——
await drawer
.locator('input.hidden[type="file"]')
.setInputFiles({
name: "报销须知.txt",
mimeType: "text/plain",
buffer: Buffer.from("抬头必须是公司全称", "utf8"),
});
await drawer.getByText("报销须知.txt").waitFor({ timeout: 10000 });
check("附件上传后出现在列表", true);
// —— 5. 落库后重新打开仍在(不是只活在内存里)——
await drawer.getByRole("button", { name: "关闭" }).click();
await page.getByRole("button", { name: "整理发票原件", exact: true }).click();
const drawer2 = page.getByRole("dialog");
await drawer2.waitFor();
await page.waitForSelector(".vditor-ir strong", { timeout: 20000 });
check("重新打开后正文仍是渲染态", (await drawer2.locator(".vditor-ir strong").count()) > 0);
let stillThere = true;
try {
await drawer2.getByText("报销须知.txt").waitFor({ timeout: 8000 });
} catch {
stillThere = false;
}
check("重新打开后附件仍在", stillThere);
// —— 6. 只读渲染里的表格:宽表能横向滚动,不被挤成一坨 ——
const tableMd = [
"| 项目 | 金额 | 说明 |",
"| --- | --- | --- |",
"| 打印费 | 12.00 | 电子发票 |",
"| 快递费 | 23.50 | 到付 |",
].join("\n");
await call("POST", `/workspaces/${ws.id}/items/${item.id}/comments`, { body_md: tableMd }, token);
await drawer2.getByRole("button", { name: "关闭" }).click();
await page.getByRole("button", { name: "整理发票原件", exact: true }).click();
const drawer3 = page.getByRole("dialog");
await drawer3.waitFor();
const table = drawer3.locator(".prose-dadao table").first();
await table.waitFor({ timeout: 10000 });
check("评论区表格渲染出来了", (await drawer3.locator(".prose-dadao td").count()) >= 4);
const box = await table.boundingBox();
check("表格没有撑破抽屉宽度", box !== null && box.width <= 900, `${box?.width ?? "无"} px`);
const realProblems = problems.filter((p) => !p.includes("favicon"));
check("控制台与网络无报错", realProblems.length === 0, realProblems.slice(0, 3).join(" | "));
} catch (err) {
check("脚本执行", false, err instanceof Error ? err.message : String(err));
} finally {
await context.close();
await browser.close();
}
if (failures > 0) {
console.error(`\n${failures} 项失败`);
process.exit(1);
}
console.log("\nALL PASS");