import { execSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { expect, test, type Browser, type Page, type Request, type Response, type ConsoleMessage } from "@playwright/test"; import { ADMIN, EMPLOYEE, FINANCE, MANAGER, createAuthenticatedApiClient, createAuthenticatedBrowserStorageState, type E2EUser } from "./helpers/auth"; import { visualCoverageProblems, type VisualPageIdentity, type VisualPageCompletion } from "./helpers/visual-coverage"; type MatrixRow = { Domain: string; Tier: string; PC_Routes: string; Mobile_WeCom_Routes: string; }; type VisualFinding = { severity: "P1" | "P2"; route: string; domain: string; viewport: "pc" | "mobile-wecom"; category: string; detail: string; screenshot?: string; }; type PreparedRoutes = { reimbursementId: string; projectId: string | null; workflowTaskId: string | null; employeeId: string; }; const ROOT_DIR = path.resolve(process.cwd(), ".."); const MATRIX_PATH_CANDIDATES = [ path.join(ROOT_DIR, "docs/trialrun/项目级流程覆盖矩阵.csv"), path.join(ROOT_DIR, "docs/trialrun/project-wide-flow-matrix.csv"), ]; const MATRIX_PATH = MATRIX_PATH_CANDIDATES.find((candidate) => existsSync(candidate)) ?? MATRIX_PATH_CANDIDATES[0]; const ISSUE_ID = process.env.QA_ISSUE_ID?.trim() || "project-wide-visual"; const BASE_URL = process.env.E2E_BASE_URL?.trim() || null; const DEFAULT_RUN_DIR = path.join(ROOT_DIR, "artifacts/qa-layout/project-visual-runs", `${Date.now()}-${process.pid}`); const SCREENSHOT_DIR = resolveRootPath( process.env.QA_VISUAL_OUTPUT_DIR, path.join(DEFAULT_RUN_DIR, "screenshots"), ); const ARTIFACT_DIR = resolveRootPath( process.env.QA_VISUAL_ARTIFACT_DIR, process.env.QA_VISUAL_OUTPUT_DIR ? path.resolve(process.env.QA_VISUAL_OUTPUT_DIR, "project-wide-reports") : path.join(DEFAULT_RUN_DIR, "reports"), ); const WECOM_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN"; const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi; const findings: VisualFinding[] = []; const completedPages: VisualPageCompletion[] = []; const arrivals: Array<{ route: string; actualRoute: string; role: string; viewport: string }> = []; const SYSTEM_HEADINGS: Record = { "/system/reimbursement-templates": "报销模板管理", "/system/users": "系统用户管理", "/system/departments": "部门管理", "/system/master-data": "主数据", "/system/payment-accounts": "企业银行账户", "/system/notification-channels": "通知渠道", "/system/workspace": "工作区设置", "/system/settings": "管理", "/system/approval-rules": "审批规则", "/system/expense-standards": "费用标准管理", "/system/feature-flags": "功能管理", "/system/announcements": "公告管理", "/system/workflow-definitions": "流程定义", "/system/cutover": "切换控制台", }; async function assertRouteArrival(page: Page, route: string, timeout?: number) { const arrivalExpect = timeout === undefined ? expect : expect.configure({ timeout }); await arrivalExpect(page, `必须到达目标路由 ${route.replace(UUID_RE, ":id")}`).toHaveURL(url => url.pathname === route); const checks = [arrivalExpect(page.getByRole("heading", { name: /^(无权限访问|页面不存在)$/ })).toHaveCount(0)]; if (route === "/login") { checks.push(arrivalExpect(page.getByTestId("login-identifier")).toBeVisible()); } else { checks.push(arrivalExpect(page.getByTestId("login-identifier")).toHaveCount(0)); const anchor = route === "/portal" ? "portal-work-summary" : /^\/reimbursements\/[^/]+$/.test(route) ? "reimbursement-desktop-workspace" : /^\/mobile\/reimbursements\/[^/]+$/.test(route) ? "reimbursement-mobile-workspace" : null; if (anchor) { checks.push(arrivalExpect(page.getByTestId(anchor)).toBeVisible()); } else { const heading = SYSTEM_HEADINGS[route]; checks.push(arrivalExpect(page.getByRole("heading", { level: 1, ...(heading ? { name: heading, exact: true } : {}) })).toBeVisible()); } } // All original guards must pass. Preserve error priority for counterexamples // while avoiding sequential browser round trips for independent assertions. for (const result of await Promise.allSettled(checks)) { if (result.status === "rejected") throw result.reason; } } function resolveRootPath(value: string | undefined, fallback: string) { if (!value?.trim()) { return fallback; } return path.isAbsolute(value) ? value : path.join(ROOT_DIR, value); } function currentCommit() { try { return execSync("git rev-parse --short HEAD", { cwd: ROOT_DIR, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }).trim(); } catch { return "unknown"; } } function outputPath(dir: string, fileName: string) { mkdirSync(dir, { recursive: true }); return path.join(dir, fileName); } function parseCsvLine(line: string) { const cells: string[] = []; let current = ""; let quoted = false; for (let index = 0; index < line.length; index += 1) { const char = line[index]; const next = line[index + 1]; if (quoted && char === "\"" && next === "\"") { current += "\""; index += 1; continue; } if (char === "\"") { quoted = !quoted; continue; } if (!quoted && char === ",") { cells.push(current); current = ""; continue; } current += char; } cells.push(current); return cells; } function parseMatrix() { const lines = readFileSync(MATRIX_PATH, "utf8").split(/\r?\n/).filter((line) => line.trim()); const header = parseCsvLine(lines[0] ?? ""); return lines.slice(1).map((line) => { const cells = parseCsvLine(line); return Object.fromEntries(header.map((key, index) => [key, cells[index] ?? ""])) as MatrixRow; }); } function splitRoutes(value: string) { return value.split("|").map((item) => item.trim()).filter(Boolean); } function sanitizeRoute(route: string) { return route.replace(/^\//, "").replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "") || "root"; } function addFinding(finding: VisualFinding) { findings.push({ ...finding, detail: finding.detail.replaceAll(ROOT_DIR, "").replace(UUID_RE, "[uuid]"), screenshot: finding.screenshot ? path.relative(ROOT_DIR, finding.screenshot) : undefined, }); } function replaceDynamicRoute(route: string, prepared: PreparedRoutes) { if (route.includes(":reimbursementId")) return route.replace(":reimbursementId", prepared.reimbursementId); if (route.includes("/reimbursements/:id")) return route.replace(":id", prepared.reimbursementId); if (route.includes(":projectId")) return prepared.projectId ? route.replace(":projectId", prepared.projectId) : null; if (route.includes(":taskId")) return prepared.workflowTaskId ? route.replace(":taskId", prepared.workflowTaskId) : null; if (route.includes(":employeeId")) return route.replace(":employeeId", prepared.employeeId); return route; } async function prepareRoutes() { const employeeClient = await createAuthenticatedApiClient(EMPLOYEE); const managerClient = await createAuthenticatedApiClient(MANAGER); const adminClient = await createAuthenticatedApiClient(ADMIN); try { const created = await employeeClient.api.post("/api/reimbursements", { data: { description: "项目级视觉巡检草稿" }, }); const createdJson = await created.json() as { data?: { id?: string } }; if (!created.ok() || !createdJson.data?.id) { throw new Error(`创建视觉巡检报销草稿失败:HTTP ${created.status()}`); } const projectsResponse = await adminClient.api.get("/api/projects?page=1&pageSize=1"); const projectsJson = await projectsResponse.json().catch(() => null) as { data?: { items?: Array<{ id: string }> } } | null; const projectId = projectsResponse.ok() ? projectsJson?.data?.items?.[0]?.id ?? null : null; const tasksResponse = await managerClient.api.get("/api/workflow/tasks?status=pending&page=1&pageSize=1"); const tasksJson = await tasksResponse.json().catch(() => null) as { data?: { items?: Array<{ id: string }> } } | null; const workflowTaskId = tasksResponse.ok() ? tasksJson?.data?.items?.[0]?.id ?? null : null; return { reimbursementId: createdJson.data.id, projectId, workflowTaskId, employeeId: employeeClient.me.id, }; } finally { await Promise.all([employeeClient.dispose(), managerClient.dispose(), adminClient.dispose()]); } } function roleForRoute(route: string) { const user = userForRoute(route); return user === ADMIN ? "admin" : user === FINANCE ? "finance" : user === MANAGER ? "manager" : user === EMPLOYEE ? "employee" : "anonymous"; } function userForRoute(route: string): E2EUser | null { if (route === "/login") return null; if (route === "/ap-operations") return FINANCE; if (route.startsWith("/mobile/approvals") || route.startsWith("/workflow/tasks")) return MANAGER; if ( route.startsWith("/system") || route.startsWith("/people") || route.startsWith("/organization") || route.startsWith("/audit-logs") || route.startsWith("/payroll") || route.startsWith("/performance") ) { return ADMIN; } return EMPLOYEE; } async function visibleRouteLink(page: Page, route: string) { const links = page.locator(`a[href=${JSON.stringify(route)}]:visible`); return await links.count() ? links.first() : null; } async function navigateForAudit(page: Page, route: string) { const timing = visualTiming(route, "navigation"); let link = await visibleRouteLink(page, route); timing("find-link"); if (!link && route.startsWith("/system/") && route !== "/system/settings") { const management = await visibleRouteLink(page, "/system/settings"); if (management) { await management.click(); await expect(page.getByTestId("system-settings-summary")).toBeVisible(); timing("management-arrival"); link = await visibleRouteLink(page, route); } } if (link || new URL(page.url()).pathname === route) { // Keep the target document HTTP check while using real application links. // Re-loading the entire app for every matrix row repeats authentication. const [document] = await Promise.all([ page.request.get(route, { headers: { accept: "text/html" } }), link ? link.click() : Promise.resolve(), ]); timing("document-and-link-arrival"); return document; } const document = await page.goto(route); timing("document-navigation"); return document; } function visualTiming(route: string, scope: string) { const start = Date.now(); let previous = start; return (stage: string) => { const now = Date.now(); console.info("[visual-audit]", JSON.stringify({ scope, stage, route: route.replace(UUID_RE, ":id"), elapsedMs: now - start, phaseMs: now - previous })); previous = now; }; } async function auditPage(page: Page, input: { route: string; domain: string; viewport: "pc" | "mobile-wecom"; role: string; }) { const timing = visualTiming(input.route, "page"); const responses: Array<{ status: number; url: string }> = []; const consoleErrors: string[] = []; const pendingRequests = new Set(); let quietSince = Date.now(); const onRequest = (request: Request) => { if (!/^https?:/.test(request.url())) return; pendingRequests.add(request); quietSince = 0; }; const onRequestSettled = (request: Request) => { if (pendingRequests.delete(request) && pendingRequests.size === 0) quietSince = Date.now(); }; const onResponse = (response: Response) => { const url = new URL(response.url()); const deniedApi = url.origin === new URL(page.url()).origin && url.pathname.startsWith("/api/") && [403, 404].includes(response.status()); if (response.status() >= 500 || deniedApi) { responses.push({ status: response.status(), url: url.pathname.replace(UUID_RE, ":id") }); } }; const onConsole = (message: ConsoleMessage) => { if (message.type() === "error") { const text = message.text(); if (!/Failed to load resource: the server responded with a status of 4\d\d/i.test(text)) { consoleErrors.push(text); } } }; page.on("response", onResponse); page.on("console", onConsole); page.on("request", onRequest); page.on("requestfinished", onRequestSettled); page.on("requestfailed", onRequestSettled); const navigation = await navigateForAudit(page, input.route); timing("navigation-complete"); expect(navigation?.ok(), `目标文档 HTTP ${navigation?.status()}`).toBe(true); await assertRouteArrival(page, input.route); timing("arrival"); // A same-document route retains the old load state. Observe the requests // made by this navigation instead of treating that old state as readiness. await expect.poll(() => pendingRequests.size === 0 && Date.now() - quietSince >= 500, { message: `目标页面请求完成 ${input.route.replace(UUID_RE, ":id")}`, intervals: [50, 100], }).toBe(true); timing("requests-complete"); arrivals.push({ route: input.route.replace(UUID_RE, ":id"), actualRoute: new URL(page.url()).pathname.replace(UUID_RE, ":id"), role: input.role, viewport: input.viewport }); const screenshot = outputPath( SCREENSHOT_DIR, `${input.viewport}-${sanitizeRoute(input.route)}.png`, ); await page.screenshot({ path: screenshot, fullPage: true }); timing("screenshot"); const metrics = await page.evaluate(() => { const text = document.body?.innerText ?? ""; const root = document.documentElement; const width = window.innerWidth; const scrollWidth = Math.max(root.scrollWidth, document.body?.scrollWidth ?? 0); const visibleTables = Array.from(document.querySelectorAll("table")).filter((element) => { const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden"; }).length; const sidebarLike = Array.from(document.querySelectorAll("aside, nav")).filter((element) => { const rect = element.getBoundingClientRect(); const textContent = element.textContent ?? ""; return rect.width > 160 && textContent.includes("更多"); }).length; const clipped = Array.from(document.querySelectorAll("button,a,[role='button'],select,input,textarea,[data-testid]")) .filter((element) => { const rect = element.getBoundingClientRect(); if (rect.width < 8 || rect.height < 8) return false; const style = getComputedStyle(element); const overflowHidden = ["hidden", "clip"].includes(style.overflowX) || ["hidden", "clip"].includes(style.overflowY); return overflowHidden && (element.scrollWidth > element.clientWidth + 2 || element.scrollHeight > element.clientHeight + 2); }) .slice(0, 8) .map((element) => ({ tag: element.tagName.toLowerCase(), testid: element.getAttribute("data-testid") ?? "", text: (element.textContent ?? element.getAttribute("aria-label") ?? element.getAttribute("placeholder") ?? "").trim().slice(0, 60), })); return { text, width, scrollWidth, visibleTables, sidebarLike, clipped }; }); timing("metrics"); if (responses.length) { addFinding({ severity: "P1", route: input.route, domain: input.domain, viewport: input.viewport, category: "http", detail: JSON.stringify(responses.slice(0, 5)), screenshot, }); } if (consoleErrors.length) { addFinding({ severity: "P1", route: input.route, domain: input.domain, viewport: input.viewport, category: "console", detail: consoleErrors.slice(0, 5).join("\n"), screenshot, }); } if (metrics.scrollWidth > metrics.width + 2) { addFinding({ severity: "P1", route: input.route, domain: input.domain, viewport: input.viewport, category: "responsive", detail: `viewport=${metrics.width}, scrollWidth=${metrics.scrollWidth}`, screenshot, }); } if (input.viewport === "mobile-wecom") { if (metrics.visibleTables > 0) { addFinding({ severity: "P1", route: input.route, domain: input.domain, viewport: input.viewport, category: "mobile-table", detail: `移动页存在可见 table:${metrics.visibleTables}`, screenshot, }); } if (metrics.sidebarLike > 0) { addFinding({ severity: "P1", route: input.route, domain: input.domain, viewport: input.viewport, category: "mobile-pc-shell", detail: "企业微信移动端出现 PC 导航壳。", screenshot, }); } } const rawCodePatterns = [/\bdraft\b/i, /\bbound\b/i, /business_client_meal/i, /expense-type-[a-z0-9-]+/i]; const shouldApplyOrdinaryUserCopyGate = ( input.route.startsWith("/reimbursements") || input.route.startsWith("/mobile/reimbursements") || input.route.startsWith("/mobile/capture") ); const bannedTerms = ["绑定整单", "不绑定明细", "发票池"]; if (shouldApplyOrdinaryUserCopyGate && (rawCodePatterns.some((pattern) => pattern.test(metrics.text)) || bannedTerms.some((term) => metrics.text.includes(term)))) { addFinding({ severity: "P1", route: input.route, domain: input.domain, viewport: input.viewport, category: "copy", detail: "普通用户页面出现原始码值或内部术语。", screenshot, }); } if (metrics.clipped.length) { addFinding({ severity: "P2", route: input.route, domain: input.domain, viewport: input.viewport, category: "clipped", detail: JSON.stringify(metrics.clipped), screenshot, }); } page.off("response", onResponse); page.off("console", onConsole); page.off("request", onRequest); page.off("requestfinished", onRequestSettled); page.off("requestfailed", onRequestSettled); } function buildRoutePlans(matrix: MatrixRow[], prepared: PreparedRoutes, viewport: "pc" | "mobile-wecom") { const seen = new Set(); const plans: Array<{ route: string; domain: string }> = []; for (const row of matrix) { const tokens = splitRoutes(viewport === "pc" ? row.PC_Routes : row.Mobile_WeCom_Routes); for (const token of tokens) { const route = replaceDynamicRoute(token, prepared); if (!route || /[:*]/.test(route)) throw new Error(`视觉矩阵路由未解析: ${token}`); if (route.startsWith("/system/") && !SYSTEM_HEADINGS[route]) throw new Error(`系统路由缺目标合同: ${route}`); if (seen.has(route)) continue; seen.add(route); plans.push({ route, domain: row.Domain }); } } if (viewport === "pc") { // Router additions must be explicitly owned by the visual matrix. const router = readFileSync(path.join(ROOT_DIR, "client/src/router/index.tsx"), "utf8"); const systemRoutes = [...router.matchAll(/path="(system\/[^"*:]+)"/g)].map(match => `/${match[1]}`).sort(); expect(plans.map(plan => plan.route).filter(route => route.startsWith("/system/")).sort()).toEqual(systemRoutes); } return plans; } async function auditPlans(browser: Browser, plans: Array<{ route: string; domain: string }>, viewport: "pc" | "mobile-wecom") { const plansByUser = new Map }>(); for (const plan of plans) { const user = userForRoute(plan.route); const entry = plansByUser.get(user?.identifier ?? "anonymous") ?? { user, plans: [] }; entry.plans.push(plan); plansByUser.set(user?.identifier ?? "anonymous", entry); } for (const { user, plans: userPlans } of plansByUser.values()) { // Each context gets a fresh real server session. Anonymous /login is still // audited, while every business route keeps its document/arrival checks. console.info("[visual-audit]", JSON.stringify({ stage: "session:start", viewport, role: user === ADMIN ? "admin" : user === FINANCE ? "finance" : user === MANAGER ? "manager" : user === EMPLOYEE ? "employee" : "anonymous" })); const storageState = user ? await createAuthenticatedBrowserStorageState(user) : undefined; const context = await browser.newContext({ viewport: viewport === "mobile-wecom" ? { width: 390, height: 844 } : { width: 1440, height: 960 }, serviceWorkers: "block", storageState, ...(viewport === "mobile-wecom" ? { isMobile: true, hasTouch: true, userAgent: WECOM_UA, locale: "zh-CN" } : {}), }); try { const page = await context.newPage(); for (const plan of userPlans) { await test.step(`${viewport} ${plan.route.replace(UUID_RE, ":id")}`, async () => { console.info("[visual-audit]", JSON.stringify({ stage: "route:start", viewport, route: plan.route.replace(UUID_RE, ":id") })); await auditPage(page, { ...plan, viewport, role: roleForRoute(plan.route) }); completedPages.push({ route: plan.route, actualRoute: new URL(page.url()).pathname, role: roleForRoute(plan.route), viewport }); }); } } finally { await context.close(); } } } const VISUAL_GROUPS = [ { viewport: "pc", role: "anonymous", count: 1 }, { viewport: "pc", role: "employee", count: 7 }, { viewport: "pc", role: "finance", count: 1 }, { viewport: "pc", role: "manager", count: 1 }, { viewport: "pc", role: "admin", count: 15 }, { viewport: "mobile-wecom", role: "employee", count: 7 }, { viewport: "mobile-wecom", role: "manager", count: 1 }, ] as const; // Preserve this file's existing sequential page execution. Other files keep // the runner's original workers and zero retries. One failed group skips its // serial successors, which remain incomplete in the aggregate report. test.describe.serial("项目级全页面视觉巡检", () => { const groups = VISUAL_GROUPS.map(group => ({ ...group, id: `${group.viewport}:${group.role}`, status: "pending", })); let plans: Array = []; let preparationMs = 0; const reportPath = () => outputPath(ARTIFACT_DIR, "report.json"); const coverage = () => visualCoverageProblems(plans, completedPages, groups.map(group => group.id), groups); function writeReport() { const coverageProblems = coverage(); writeFileSync(reportPath(), JSON.stringify({ issueId: ISSUE_ID, baseURL: BASE_URL, commit: currentCommit(), checkedAt: new Date().toISOString(), status: coverageProblems.length ? "INCOMPLETE_OR_FAILED" : findings.some(finding => finding.severity === "P1") ? "FAIL" : "PASS", matrix: { pc: "1440x960", mobileWeCom: "390x844" }, preparationMs, groups, coverageProblems, pcRoutes: plans.filter(plan => plan.viewport === "pc").map(plan => plan.route.replace(UUID_RE, ":id")), mobileRoutes: plans.filter(plan => plan.viewport === "mobile-wecom").map(plan => plan.route.replace(UUID_RE, ":id")), arrivals, completedPages: completedPages.map(page => ({ ...page, route: page.route.replace(UUID_RE, ":id"), actualRoute: page.actualRoute.replace(UUID_RE, ":id") })), findings, }, null, 2), "utf8"); } test.beforeAll(async () => { writeReport(); const start = Date.now(); try { const prepared = await prepareRoutes(); const matrix = parseMatrix(); plans = (["pc", "mobile-wecom"] as const).flatMap(viewport => buildRoutePlans(matrix, prepared, viewport).map(plan => ({ ...plan, viewport, role: roleForRoute(plan.route) })), ); expect(plans).toHaveLength(33); for (const group of groups) { expect(plans.filter(plan => plan.viewport === group.viewport && plan.role === group.role), group.id).toHaveLength(group.count); } } finally { preparationMs = Date.now() - start; console.info("[visual-audit]", JSON.stringify({ stage: "preparation", elapsedMs: preparationMs })); writeReport(); } }); test.afterEach(async ({}, testInfo) => { const group = groups.find(group => testInfo.title === group.id); if (group) group.status = testInfo.status ?? "unknown"; writeReport(); }); test.afterAll(async () => { writeReport(); expect(coverage(), "全部33页和7组必须在同一次运行完整通过").toEqual([]); expect(findings.filter(finding => finding.severity === "P1"), `项目级视觉巡检失败,报告:${reportPath()}`).toEqual([]); }); for (const group of groups) { test(group.id, async ({ browser }) => { // Admin audits 15 pages in one group; 180s ran out on a loaded 2-core host. test.setTimeout(240_000); const groupPlans = plans.filter(plan => plan.viewport === group.viewport && plan.role === group.role); await auditPlans(browser, groupPlans, group.viewport); expect(findings.filter(finding => finding.severity === "P1"), `项目级视觉巡检失败,报告:${reportPath()}`).toEqual([]); }); } }); const coverageFixture = () => { const planned = [{ viewport: "pc", role: "employee", route: "/portal" }, { viewport: "mobile-wecom", role: "employee", route: "/mobile" }]; return { planned, completed: planned.map(page => ({ ...page, actualRoute: page.route })), expectedGroups: ["pc:employee", "mobile-wecom:employee"], groups: [ { id: "pc:employee", status: "passed" }, { id: "mobile-wecom:employee", status: "passed" }, ] }; }; const fixtureProblems = (fixture: ReturnType) => visualCoverageProblems(fixture.planned, fixture.completed, fixture.expectedGroups, fixture.groups); test("视觉覆盖接受全部页面审完及全部组通过", () => { expect(fixtureProblems(coverageFixture())).toEqual([]); }); for (const [label, mutate, problem] of [ ["缺页或只到达未审完", (fixture) => { fixture.completed.pop(); }, "missing-page"], ["重复完成", (fixture) => { fixture.completed.push(fixture.completed[0]); }, "duplicate-completion"], ["重复计划", (fixture) => { fixture.planned.push(fixture.planned[0]); }, "duplicate-plan"], ["错误角色", (fixture) => { fixture.completed[0].role = "admin"; }, "unexpected-page"], ["错误终端", (fixture) => { fixture.completed[0].viewport = "mobile-wecom"; }, "unexpected-page"], ["错误到达", (fixture) => { fixture.completed[0].actualRoute = "/login"; }, "wrong-arrival"], ["缺组", (fixture) => { fixture.groups.pop(); }, "incomplete-group"], ["重复组", (fixture) => { fixture.groups.push(fixture.groups[0]); }, "duplicate-group"], ["未知组", (fixture) => { fixture.groups.push({ id: "pc:admin", status: "passed" }); }, "unexpected-group"], ["失败组", (fixture) => { fixture.groups[0].status = "failed"; }, "incomplete-group"], ["跳过组", (fixture) => { fixture.groups[0].status = "skipped"; }, "incomplete-group"], ["未运行组", (fixture) => { fixture.groups[0].status = "pending"; }, "incomplete-group"], ] satisfies Array<[string, (fixture: ReturnType) => void, string]>) { test(`视觉覆盖拒绝${label}`, () => { const fixture = coverageFixture(); mutate(fixture); expect(fixtureProblems(fixture)).toContain(problem); }); } // A SPA can return HTTP 200 for a forbidden/not-found page. Those must not // satisfy the same arrival guard used by the real route matrix. for (const [label, route, html] of [ ["404", "/system/users", "

页面不存在

"], ["403", "/system/users", "

无权限访问

"], ["wrong-page", "/system/users", "

管理

"], ["login-fallback", "/login", '

登录

'], ]) { test(`视觉到达门禁拒绝 ${label}`, async ({ page }) => { await page.route("**/*", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: html })); await page.goto(route); // The synthetic page is final; a short assertion budget avoids waiting for // a state that this counterexample intentionally never produces. await expect(assertRouteArrival(page, "/system/users", 100)).rejects.toThrow(label === "login-fallback" ? /toHaveURL/ : label === "wrong-page" ? /toBeVisible/ : /toHaveCount/); }); } test("视觉到达门禁接受真实目标结构和匿名登录入口", async ({ page }) => { await page.route("**/*", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: new URL(route.request().url()).pathname === "/login" ? '' : "

系统用户管理

" })); await page.goto("/system/users"); await assertRouteArrival(page, "/system/users", 100); await page.goto("/login"); await assertRouteArrival(page, "/login", 100); });