Files
DadaoTask/web/e2e/richtext.mjs
T
dadao a24a493403 feat(rich): 备注/工单说明/知识正文支持 Markdown 与图片附件
写一件事的时候,光有纯文本不够:要贴截图、要分段、要标重点。
这次把「正文」这条路补全,思路是**存 Markdown 而不是 HTML**——
能导出、能全文检索、能直接喂给 LLM,也不必每次渲染都重新清洗一遍 HTML。

后端
- attachment 表 + RLS + 迁移 0005;本地磁盘存储,key 由内容 sha256 决定,
  换对象存储只改 services/storage.py 一个文件
- 上传在**读流时**卡 20MB,不先落盘再判断;磁盘名由服务端生成,用户文件名只进库和响应头
- 列表带 attachment_count、详情带 attachments,避免一屏 N 次请求
- raw 直链给 <img src> 用(图片带不了 Authorization 头),只允许本工作区成员,
  非成员一律 8001 不区分「不存在」与「没权限」
- 5 个用例:上传下载、超限、跨租户、删除确认与权限、raw 鉴权

前端
- 自研零依赖 Markdown 渲染器:先转义再上标签,链接协议白名单
- RichTextEditor:工具栏 + Ctrl+B/I + 预览,粘贴或拖入图片自动上传并插入引用
- AttachmentPanel:上传/下载/删除,下载走带 Authorization 的 fetch 转 blob
- 任务备注、工单说明与验收结论、知识正文、评论全部接入

顺手修的
- TaskDetail.hydrate 漏了 setAttachments,导致附件面板永远是空的(e2e 抓到的)

71 passed;web tsc 0 错、构建 255.07 kB │ gzip 80.85 kB;
e2e:richtext 9 项 + smoke 7 + tickets 8 + knowledge 7,全绿。
2026-10-29 01:53:46 +08:00

166 lines
6.3 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:备注能写 Markdown、贴图能上传并渲染、附件能传能列。
*
* 这里刻意走真浏览器(不是只测接口)——因为要验的正是"编辑器把内容写回后,
* 渲染出来是不是那个样子",以及 <img src> 带 token 的那条路能不能真的取到图。
*/
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 = [];
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()}`);
});
/** 记录所有 /raw 请求的状态码,验证图片确实被取到 */
const rawHits = [];
page.on("response", (r) => {
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. Markdown 写作与预览 ——
const editor = drawer.getByLabel("备注");
await editor.click();
await editor.fill("**必须** 一起寄出,参考 `财务口径 v3`。\n\n- 纸质发票\n- 快递单");
await drawer.getByRole("button", { name: "保存备注" }).click();
await drawer.getByRole("button", { name: "预览" }).click();
check("预览渲染粗体", (await drawer.locator(".prose-dadao strong").count()) > 0);
check("预览渲染行内代码", (await drawer.locator(".prose-dadao code").count()) > 0);
check("预览渲染列表", (await drawer.locator(".prose-dadao li").count()) === 2);
// —— 2. 编辑器插入图片(模拟选文件,走的是粘贴/拖拽同一段上传逻辑)——
await drawer.getByRole("button", { name: "继续编辑" }).click();
await drawer
.locator('input[type="file"][accept="image/*"]')
.setInputFiles({ name: "票据截图.png", mimeType: "image/png", buffer: PNG });
await drawer.getByText("![票据截图.png]").or(drawer.locator("textarea")).first().waitFor();
await page.waitForTimeout(1200); // 等上传返回并写回正文
await drawer.getByRole("button", { name: "预览" }).click();
const img = drawer.locator(".prose-dadao img").first();
await img.waitFor({ timeout: 8000 });
const src = await img.getAttribute("src");
check("正文出现图片", Boolean(src?.includes("/raw?token=")), String(src ?? "").slice(0, 48));
await page.waitForTimeout(800);
check("图片资源能取到(/raw 返回 200)", rawHits.includes(200), rawHits.join(","));
// —— 3. 附件面板上传与列表 ——
await drawer
.locator('input[type="file"]:not([accept="image/*"])')
.setInputFiles({
name: "报销须知.txt",
mimeType: "text/plain",
buffer: Buffer.from("抬头必须是公司全称", "utf8"),
});
await drawer.getByText("报销须知.txt").waitFor({ timeout: 8000 });
check("附件上传后出现在列表", true);
// —— 4. 落库后重新打开仍在(不是只活在内存里)——
await drawer.getByRole("button", { name: "关闭" }).click();
await page.getByRole("button", { name: "整理发票原件", exact: true }).click();
const drawer2 = page.getByRole("dialog");
await drawer2.waitFor();
await drawer2.getByRole("button", { name: "预览" }).click();
check(
"重新打开后正文仍是渲染态",
(await drawer2.locator(".prose-dadao strong").count()) > 0,
);
let stillThere = true;
try {
await drawer2.getByText("报销须知.txt").waitFor({ timeout: 6000 });
} catch {
stillThere = false;
}
check("重新打开后附件仍在", stillThere);
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");