Files
DadaoTask/web/e2e/tickets.mjs
T
dadao b1dc3cffa6 feat(web): 工单页与升级面板,修掉视图竞态与标签缺失导致的崩溃
- 新增 /w/:wsId/tickets:派给我的 / 我发起的、状态过滤、列表内联快操作
- 详情抽屉按角色显示受理/开工/提交/验收/驳回/改派/取消;任务态底部可升级成工单
- 导航抽成 WorkspaceNav,带未读角标(30 秒对齐,操作后立刻刷新)
- 修竞态:切视图/切 tab 连发请求时,慢的回包会把新结果盖掉(reqId 序号丢弃过期响应)
- 登录支持 ?next= 站内回跳,邀请链接不再'登录完掉回首页'
- e2e/tickets.mjs:双人双上下文的完整闭环,8 项断言全绿
2026-10-28 13:20:18 +08:00

197 lines
9.0 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);
const phoneA = `137${stamp}`;
const phoneB = `136${stamp}`;
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.locator("aside");
await drawerA.waitFor();
const memberPicker = drawerA.locator("select").nth(1);
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.locator("aside");
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.locator("aside");
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.locator("aside").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);
}