Files
DadaoTask/web/e2e/tickets.mjs
T
dadao 5be55fdccb feat(web): 重做设计系统与全部页面,前端观感对齐当下审美
用户反馈"UI 太丑、太简陋"。不是换配色,而是先立 token 再让页面去用:

- tailwind.config.js:纸白 paper / 墨绿 primary / 陶土橙 clay + 语义色
  sage/wheat/rust/sky,字号阶、圆角阶、投影、动效
- index.css:卡片、按钮五种变体、输入、pill/chip、导航、骨架屏
- 新增 icons.tsx(线性图标集)与 ui.tsx(Button/Sheet/Toast/空态/骨架/PageHeader)
- 新增 AppShell 取代 Layout + WorkspaceNav:桌面侧栏 + 移动顶部横条,工单未读角标
- 六个页面全部重写:登录、首页、任务、看板、工单、知识、成员、邀请落地
- 详情抽屉改用 Sheet,工单态补齐需求人/办理人/退回次数/动作按钮

e2e 因 DOM 升级同步调整(抽屉按 role=dialog 定位、下拉改用 aria-label),
三套用例(7 + 8 + 7 项)全部重跑通过;tsc 0 错,构建 45 modules / gzip 76.70 kB。
2026-10-28 18:37:35 +08:00

198 lines
9.2 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.
/**
* M2 工单闭环的真浏览器用例(两个用户,两个浏览器上下文)。
*
* A 建任务 → 升级成工单指给 B → B 接收/开始/提交验收 → A 通过验收 → 原任务回流到待办
*
* 跑之前先起后端(8100)与前端(5180):
* node e2e/tickets.mjs
*/
import { existsSync, mkdirSync } from "node:fs";
import { chromium } from "playwright-core";
const APP = process.env.APP_URL ?? "http://127.0.0.1:5180";
const CHROME =
process.env.CHROME_PATH ??
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`;
const SHOT_DIR = new URL("./artifacts/", import.meta.url).pathname.replace(/^\//, "");
if (!existsSync(CHROME)) {
console.error(`找不到 Chromium:${CHROME}\n用 CHROME_PATH 指定可执行文件`);
process.exit(2);
}
mkdirSync(SHOT_DIR, { recursive: true });
let step = 0;
function pass(message) {
console.log(` ✓ ${++step}. ${message}`);
}
function fail(message) {
console.error(` ✗ ${message}`);
process.exit(1);
}
const consoleErrors = [];
function watch(page, tag) {
page.on("console", (msg) => {
if (msg.type() === "error") consoleErrors.push(`[${tag}] ${msg.text()}`);
});
page.on("pageerror", (err) => consoleErrors.push(`[${tag}] ${String(err)}`));
page.on("response", (res) => {
if (res.status() >= 400) consoleErrors.push(`[${tag}] HTTP ${res.status()} ${res.url()}`);
});
page.setDefaultTimeout(15_000);
}
async function fillLogin(page, phone) {
await page.locator("#phone").fill(phone);
await page.getByRole("button", { name: "获取验证码" }).click();
const text = await page.locator("text=开发环境验证码").first().innerText();
const code = text.match(/(\d{6})/)?.[1];
if (!code) fail(`没读到开发验证码:${text}`);
await page.locator("#code").fill(code);
await page.getByRole("main").getByRole("button", { name: "登录", exact: true }).click();
await page.waitForURL((url) => !url.pathname.startsWith("/login"));
}
async function login(page, phone) {
await page.goto(`${APP}/login`, { waitUntil: "domcontentloaded" });
await fillLogin(page, phone);
await page.waitForURL(`${APP}/`);
}
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
const ctxA = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const ctxB = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const pageA = await ctxA.newPage();
const pageB = await ctxB.newPage();
watch(pageA, "A");
watch(pageB, "B");
if (process.env.DEBUG_NET) {
for (const [tag, pg] of [["A", pageA], ["B", pageB]]) {
pg.on("response", async (res) => {
if (!res.url().includes("/api/v1/workspaces/")) return;
const text = await res.text().catch(() => "");
console.log(` [net ${tag}] ${res.status()} ${res.url().replace(APP, "")} ${text.slice(0, 180)}`);
});
}
}
const stamp = String(Date.now()).slice(-8);
// 两个号后四位必须不同:昵称是"用户{后4位}",撞号会让截图里需求人和办理人看着像同一个人
const phoneA = `137${stamp}`;
const phoneB = `136${String((Number(stamp) + 1111) % 100000000).padStart(8, "0")}`;
try {
// 1. A 登录并建工作区
await login(pageA, phoneA);
await pageA.getByPlaceholder("例如:达道科技 / 我的事项").fill("E2E 工单组");
await pageA.getByRole("button", { name: "创建" }).click();
await pageA.waitForURL(/\/w\/[0-9a-f-]{36}$/);
const wsId = pageA.url().match(/\/w\/([0-9a-f-]{36})$/)[1];
await pageA.getByRole("heading", { name: "任务" }).waitFor();
pass("A 登录并建工作区");
// 2. A 建一条会卡住的任务(先切到「全部」,因为「今天」只按到期时间算)
const composer = pageA.getByPlaceholder(/加一件要做的事/);
await composer.fill("开通测试账号权限");
await composer.press("Enter");
await pageA.getByRole("button", { name: "全部", exact: true }).click();
await pageA.locator("div.card > div").filter({ hasText: "开通测试账号权限" }).first().waitFor();
pass("A 新建任务「开通测试账号权限」");
// 3. A 生成邀请码,B 落地加入
await pageA.getByRole("link", { name: "成员" }).click();
await pageA.getByRole("button", { name: "邀请成员" }).click();
const inviteText = await pageA.locator("p.break-all").first().innerText();
if (!inviteText.includes("/join/")) fail(`没拿到邀请链接:${inviteText}`);
await pageB.goto(inviteText.trim(), { waitUntil: "domcontentloaded" });
// 未登录会被送到 /login?next=/join/...,登录后要回到邀请页而不是工作区首页
await pageB.waitForURL(/\/login\?next=/);
await fillLogin(pageB, phoneB);
await pageB.waitForURL(new RegExp(`/w/${wsId}`));
await pageB.getByRole("heading", { name: "任务" }).waitFor();
pass("B 从邀请链接登录后自动加入工作区");
// 4. A 把任务升级成工单,指给 B
await pageA.goto(`${APP}/w/${wsId}`, { waitUntil: "domcontentloaded" });
await pageA.getByRole("button", { name: "全部", exact: true }).click();
await pageA.getByRole("button", { name: "开通测试账号权限" }).click();
const drawerA = pageA.getByRole("dialog");
await drawerA.waitFor();
const memberPicker = drawerA.getByLabel("选择办理人");
await memberPicker.selectOption({ index: 1 });
await drawerA.getByRole("button", { name: "升级成工单" }).click();
await drawerA.locator("text=已把工单交给").first().waitFor();
await pageA.screenshot({ path: `${SHOT_DIR}/promote.png` });
await drawerA.getByRole("button", { name: "关闭", exact: true }).click();
const blockedRow = await pageA
.locator("div.card > div")
.filter({ hasText: "开通测试账号权限" })
.first()
.innerText();
if (!blockedRow.includes("被阻塞")) fail(`升级后任务没变成被阻塞:${blockedRow}`);
pass("任务升级成工单后自身进入「被阻塞」");
// 5. B 在工单页接收 → 开始 → 提交验收
await pageB.goto(`${APP}/w/${wsId}/tickets`, { waitUntil: "domcontentloaded" });
await pageB.getByRole("heading", { name: "工单" }).waitFor();
const ticketCard = pageB.locator("li.card").filter({ hasText: "开通测试账号权限" }).first();
await ticketCard.waitFor();
await ticketCard.getByRole("button", { name: "接收", exact: true }).click();
await ticketCard.getByRole("button", { name: "开始", exact: true }).waitFor();
await ticketCard.getByRole("button", { name: "开始", exact: true }).click();
await ticketCard.getByRole("button", { name: "详情", exact: true }).click();
const drawerB = pageB.getByRole("dialog");
await drawerB.waitFor();
await drawerB.getByPlaceholder("给对方的说明(提交或验收时一并发过去)").fill("已经开了只读账号");
await drawerB.getByRole("button", { name: "提交验收" }).click();
await drawerB.locator("text=待验收").first().waitFor();
await pageB.screenshot({ path: `${SHOT_DIR}/ticket-submitted.png` });
pass("B 接收、开始办理并提交验收");
// 6. A 在「我发起的」里验收通过
await pageA.goto(`${APP}/w/${wsId}/tickets`, { waitUntil: "domcontentloaded" });
await pageA.getByRole("button", { name: "我发起的", exact: true }).click();
const mineCard = pageA.locator("li.card").filter({ hasText: "开通测试账号权限" }).first();
await mineCard.waitFor();
await mineCard.getByRole("button", { name: "详情", exact: true }).click();
const drawerVerify = pageA.getByRole("dialog");
await drawerVerify.waitFor();
await drawerVerify.getByPlaceholder("给对方的说明(提交或验收时一并发过去)").fill("确认可用");
await drawerVerify.getByRole("button", { name: "通过验收" }).click();
await drawerVerify.locator("text=已完成").first().waitFor();
pass("A 验收通过,工单完成");
await pageA.screenshot({ path: `${SHOT_DIR}/ticket-verified.png` });
// 7. 闭环回流:原任务回到待办
await pageA.getByRole("button", { name: "关闭", exact: true }).click();
await pageA.goto(`${APP}/w/${wsId}`, { waitUntil: "domcontentloaded" });
const activeView = pageA.getByRole("button", { name: "进行中" });
await activeView.click();
const reflowed = await pageA
.locator("div.card > div")
.filter({ hasText: "开通测试账号权限" })
.first()
.innerText();
if (!reflowed.includes("待办")) fail(`工单完成后原任务没回流到待办:${reflowed}`);
pass("工单验收通过后,原任务自动回流到「待办」");
if (consoleErrors.length > 0) {
fail(`浏览器控制台有报错:\n${consoleErrors.join("\n")}`);
}
pass("两个上下文控制台均无报错");
console.log(`\n全部通过,截图在 ${SHOT_DIR}`);
await browser.close();
} catch (err) {
await pageA.screenshot({ path: `${SHOT_DIR}/tickets-failure.png` }).catch(() => {});
const drawerText = await pageA.getByRole("dialog").innerText().catch(() => "");
if (drawerText) console.error(`\n[抽屉内容]\n${drawerText}`);
if (consoleErrors.length > 0) console.error(`\n[控制台]\n${consoleErrors.join("\n")}`);
const bodyText = await pageA.locator("body").innerText().catch(() => "");
console.error(`\n[页面文本]\n${bodyText.slice(0, 600)}`);
console.error(`\n失败:${err?.message ?? err}`);
await browser.close();
process.exit(1);
}