// 通知与手工开单的端到端验收。 // 两个真实用户:A 开单交给 B、B 收到通知并点开;A 改任务状态,B 也应收到提醒。 import { chromium } from "playwright-core"; const BASE = process.env.BASE ?? "http://127.0.0.1:5180"; const API = process.env.API ?? "http://127.0.0.1:8100/api/v1"; const CHROME = process.env.CHROME ?? "C:/Users/Steven/AppData/Local/ms-playwright/chromium-1223/chrome-win64/chrome.exe"; const stamp = String(Date.now()).slice(-6); const phoneA = `135${stamp}00`; const phoneB = `138${String((Number(stamp) + 2222) % 1e8).padStart(8, "0")}`; const wsName = `通知${stamp}`; let failed = 0; const ok = (cond, msg) => { if (cond) console.log(` PASS ${msg}`); else { failed += 1; console.log(` FAIL ${msg}`); } }; async function login(page, phone) { await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" }); await page.fill("#phone", phone); await page.getByRole("button", { name: "获取验证码" }).click(); await page.waitForSelector("text=开发环境验证码", { timeout: 15000 }); const code = (await page.locator("text=开发环境验证码").first().innerText()).match(/(\d{6})/)?.[1]; await page.fill("#code", code); await page.getByRole("main").getByRole("button", { name: "登录", exact: true }).click(); await page.waitForURL((u) => !u.pathname.startsWith("/login"), { timeout: 15000 }); } const browser = await chromium.launch({ executablePath: CHROME, args: ["--no-sandbox", "--disable-dev-shm-usage"], }); const ctxA = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" }); const pageA = await ctxA.newPage(); const errorsA = []; pageA.on("console", (m) => m.type() === "error" && errorsA.push(m.text())); pageA.on("response", (r) => r.status() >= 400 && errorsA.push(`${r.url()} ${r.status()}`)); try { await login(pageA, phoneA); console.log("PASS 1. A 登录"); // 建工作区 await pageA.getByPlaceholder("例如:达道科技 / 我的事项").fill(wsName); await pageA.getByRole("button", { name: "创建" }).click(); await pageA.waitForURL(/\/w\/[0-9a-f-]{36}/, { timeout: 20000 }); const wsId = pageA.url().match(/\/w\/([0-9a-f-]{36})/)[1]; console.log("PASS 2. 建工作区"); // 邀请 B 加入 await pageA.goto(`${BASE}/w/${wsId}/members`, { waitUntil: "domcontentloaded" }); await pageA.getByRole("button", { name: "邀请成员" }).click(); await pageA.waitForSelector("text=/join/", { timeout: 15000 }); const link = await pageA .locator("text=/join/") .first() .innerText() .then((t) => t.match(/https?:\/\/\S+join\/\S+/)?.[0]); ok(Boolean(link), "3. 拿到邀请链接"); const ctxB = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" }); const pageB = await ctxB.newPage(); const errorsB = []; pageB.on("console", (m) => m.type() === "error" && errorsB.push(m.text())); pageB.on("response", (r) => r.status() >= 400 && errorsB.push(`${r.url()} ${r.status()}`)); await login(pageB, phoneB); await pageB.goto(link, { waitUntil: "domcontentloaded" }); await pageB.waitForURL(new RegExp(`/w/${wsId}`), { timeout: 20000 }); console.log("PASS 4. B 从邀请链接登录后自动加入"); // A 手工开一张工单交给 B await pageA.goto(`${BASE}/w/${wsId}/tickets`, { waitUntil: "domcontentloaded" }); await pageA.getByRole("button", { name: "新建工单" }).click(); await pageA.getByLabel("要办什么").fill(`核对 9 月发票${stamp}`); await pageA.getByLabel("交给谁").selectOption({ index: 1 }); await pageA.getByRole("button", { name: "开单并通知对方" }).click(); await pageA.waitForSelector(`text=核对 9 月发票${stamp}`, { timeout: 20000 }); ok(true, "5. 手工工单开出(不经过任何任务)"); // 接口确认它确实是 kind=ticket 且没有来源任务 const created = await pageA.evaluate( async (ws) => { const t = localStorage.getItem("dadao.access"); const r = await fetch(`/api/v1/workspaces/${ws}/tickets?role=requested`, { headers: { Authorization: `Bearer ${t}` }, }); return r.json(); }, wsId, ); const t0 = created.data.tickets[0]; ok(t0?.kind === "ticket" && t0?.ticket?.parent_item_id === null, "6. 手工单没有来源任务"); // B 收到通知 await pageB.goto(`${BASE}/w/${wsId}/notifications`, { waitUntil: "domcontentloaded" }); await pageB.waitForSelector(`text=核对 9 月发票${stamp}`, { timeout: 20000 }); ok(true, "7. B 收到「派给你的工单」通知"); const unreadBefore = await pageB.locator("li.card").count(); ok(unreadBefore >= 1, "8. 未读列表里有条目"); // 点开通知:跳到工单页 await pageB.locator("li.card").first().click(); await pageB.waitForURL(/\/tickets\?open=/, { timeout: 20000 }); await pageB.waitForSelector(`text=核对 9 月发票${stamp}`, { timeout: 20000 }); ok(true, "9. 点通知跳到工单并展开那张单"); // 标记已读后未读数归零 const unread = await pageB.evaluate(async (ws) => { const t = localStorage.getItem("dadao.access"); const r = await fetch(`/api/v1/workspaces/${ws}/notifications/unread-count`, { headers: { Authorization: `Bearer ${t}` }, }); return (await r.json()).data.unread; }, wsId); ok(unread === 0, "10. 点开后未读数归零"); ok(errorsA.length === 0, `11. A 无报错${errorsA.slice(0, 2).join(" | ")}`); ok(errorsB.length === 0, `12. B 无报错${errorsB.slice(0, 2).join(" | ")}`); await pageB.screenshot({ path: "e2e/artifacts/notifications.png" }); await pageA.screenshot({ path: "e2e/artifacts/ticket-new.png" }); } catch (err) { failed += 1; console.log("FAIL 异常:", err.message); await pageA.screenshot({ path: "e2e/artifacts/notifications-fail-a.png" }).catch(() => {}); } finally { await browser.close(); } console.log(failed === 0 ? "\nALL PASS" : `\n${failed} 项失败`); process.exit(failed === 0 ? 0 : 1);