完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。 统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。 纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。 关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。 预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。 完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
这个提交包含在:
@@ -1,9 +1,10 @@
|
||||
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 } from "@playwright/test";
|
||||
import { expect, test, type Browser, type Page, type Request, type Response, type ConsoleMessage } from "@playwright/test";
|
||||
|
||||
import { ADMIN, EMPLOYEE, MANAGER, createAuthenticatedApiClient, type E2EUser } from "./helpers/auth";
|
||||
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;
|
||||
@@ -37,19 +38,63 @@ const MATRIX_PATH_CANDIDATES = [
|
||||
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(ROOT_DIR, "output/playwright/project-wide-visual"),
|
||||
path.join(DEFAULT_RUN_DIR, "screenshots"),
|
||||
);
|
||||
const ARTIFACT_DIR = resolveRootPath(
|
||||
process.env.QA_VISUAL_ARTIFACT_DIR,
|
||||
path.join(ROOT_DIR, "artifacts/qa-flow/project-wide-visual"),
|
||||
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<string, string> = {
|
||||
"/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()) {
|
||||
@@ -122,7 +167,8 @@ function sanitizeRoute(route: string) {
|
||||
function addFinding(finding: VisualFinding) {
|
||||
findings.push({
|
||||
...finding,
|
||||
detail: finding.detail.replace(UUID_RE, "[uuid]"),
|
||||
detail: finding.detail.replaceAll(ROOT_DIR, "<worktree>").replace(UUID_RE, "[uuid]"),
|
||||
screenshot: finding.screenshot ? path.relative(ROOT_DIR, finding.screenshot) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -135,28 +181,6 @@ function replaceDynamicRoute(route: string, prepared: PreparedRoutes) {
|
||||
return route;
|
||||
}
|
||||
|
||||
async function loginForVisual(page: Page, user: E2EUser) {
|
||||
await page.goto("/login");
|
||||
await expect(page.getByTestId("login-identifier")).toBeVisible();
|
||||
await page.getByTestId("login-identifier").fill(user.identifier);
|
||||
await page.getByTestId("login-password").fill(user.password);
|
||||
await page.getByTestId("login-submit").click();
|
||||
await page.waitForURL(/\/(portal|mobile)/, { timeout: 30_000 });
|
||||
await page.waitForLoadState("networkidle").catch(() => undefined);
|
||||
}
|
||||
|
||||
async function newWecomContext(browser: Browser) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
userAgent: WECOM_UA,
|
||||
locale: "zh-CN",
|
||||
serviceWorkers: "block",
|
||||
});
|
||||
return context;
|
||||
}
|
||||
|
||||
async function prepareRoutes() {
|
||||
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
|
||||
const managerClient = await createAuthenticatedApiClient(MANAGER);
|
||||
@@ -189,7 +213,14 @@ async function prepareRoutes() {
|
||||
}
|
||||
}
|
||||
|
||||
function userForRoute(route: string): E2EUser {
|
||||
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") ||
|
||||
@@ -204,36 +235,110 @@ function userForRoute(route: string): E2EUser {
|
||||
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[] = [];
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 500) {
|
||||
responses.push({ status: response.status(), url: response.url() });
|
||||
const pendingRequests = new Set<Request>();
|
||||
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") });
|
||||
}
|
||||
});
|
||||
page.on("console", (message) => {
|
||||
};
|
||||
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);
|
||||
|
||||
await page.goto(input.route);
|
||||
await page.waitForLoadState("networkidle").catch(() => undefined);
|
||||
await page.waitForTimeout(300);
|
||||
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 ?? "";
|
||||
@@ -266,6 +371,7 @@ async function auditPage(page: Page, input: {
|
||||
}));
|
||||
return { text, width, scrollWidth, visibleTables, sidebarLike, clipped };
|
||||
});
|
||||
timing("metrics");
|
||||
|
||||
if (responses.length) {
|
||||
addFinding({
|
||||
@@ -358,6 +464,11 @@ async function auditPage(page: Page, input: {
|
||||
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") {
|
||||
@@ -368,33 +479,51 @@ function buildRoutePlans(matrix: MatrixRow[], prepared: PreparedRoutes, viewport
|
||||
const tokens = splitRoutes(viewport === "pc" ? row.PC_Routes : row.Mobile_WeCom_Routes);
|
||||
for (const token of tokens) {
|
||||
const route = replaceDynamicRoute(token, prepared);
|
||||
if (!route || seen.has(route)) continue;
|
||||
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<string, { user: E2EUser; plans: Array<{ route: string; domain: string }> }>();
|
||||
const plansByUser = new Map<string, { user: E2EUser | null; plans: Array<{ route: string; domain: string }> }>();
|
||||
for (const plan of plans) {
|
||||
const user = userForRoute(plan.route);
|
||||
const entry = plansByUser.get(user.identifier) ?? { user, plans: [] };
|
||||
const entry = plansByUser.get(user?.identifier ?? "anonymous") ?? { user, plans: [] };
|
||||
entry.plans.push(plan);
|
||||
plansByUser.set(user.identifier, entry);
|
||||
plansByUser.set(user?.identifier ?? "anonymous", entry);
|
||||
}
|
||||
|
||||
for (const { user, plans: userPlans } of plansByUser.values()) {
|
||||
const context = viewport === "mobile-wecom"
|
||||
? await newWecomContext(browser)
|
||||
: await browser.newContext({ viewport: { width: 1440, height: 960 }, serviceWorkers: "block" });
|
||||
// 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();
|
||||
await loginForVisual(page, user);
|
||||
for (const plan of userPlans) {
|
||||
await auditPage(page, { ...plan, viewport });
|
||||
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();
|
||||
@@ -402,32 +531,142 @@ async function auditPlans(browser: Browser, plans: Array<{ route: string; domain
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("项目级全页面视觉巡检", () => {
|
||||
test("PC 与企业微信移动端没有已知视觉和文案回归", async ({ browser }) => {
|
||||
const prepared = await prepareRoutes();
|
||||
const matrix = parseMatrix();
|
||||
const pcPlans = buildRoutePlans(matrix, prepared, "pc");
|
||||
const mobilePlans = buildRoutePlans(matrix, prepared, "mobile-wecom");
|
||||
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;
|
||||
|
||||
await auditPlans(browser, pcPlans, "pc");
|
||||
await auditPlans(browser, mobilePlans, "mobile-wecom");
|
||||
|
||||
const reportPath = outputPath(ARTIFACT_DIR, "report.json");
|
||||
writeFileSync(reportPath, JSON.stringify({
|
||||
// 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<VisualPageIdentity & { domain: string }> = [];
|
||||
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: findings.some((finding) => finding.severity === "P1") ? "FAIL" : "PASS",
|
||||
matrix: {
|
||||
pc: "1440x960",
|
||||
mobileWeCom: "390x844",
|
||||
},
|
||||
pcRoutes: pcPlans.map((item) => item.route),
|
||||
mobileRoutes: mobilePlans.map((item) => item.route),
|
||||
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");
|
||||
}
|
||||
|
||||
expect(findings.filter((finding) => finding.severity === "P1"), `项目级视觉巡检失败,报告:${reportPath}`).toEqual([]);
|
||||
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 }) => {
|
||||
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<typeof coverageFixture>) =>
|
||||
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<typeof coverageFixture>) => 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", "<h1>页面不存在</h1>"],
|
||||
["403", "/system/users", "<h1>无权限访问</h1>"],
|
||||
["wrong-page", "/system/users", "<h1>管理</h1>"],
|
||||
["login-fallback", "/login", '<input data-testid="login-identifier"><h1>登录</h1>'],
|
||||
]) {
|
||||
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" ? '<input data-testid="login-identifier">' : "<h1>系统用户管理</h1>" }));
|
||||
await page.goto("/system/users");
|
||||
await assertRouteArrival(page, "/system/users", 100);
|
||||
await page.goto("/login");
|
||||
await assertRouteArrival(page, "/login", 100);
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户