import { chromium, request } from "playwright"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const runId = `defect-ledger-${Date.now()}`; const webBase = process.env.DEFECT_LEDGER_WEB_URL || "http://127.0.0.1:3005"; const apiBase = process.env.DEFECT_LEDGER_API_URL || "http://127.0.0.1:8000/api/v1"; const healthUrl = process.env.DEFECT_LEDGER_HEALTH_URL || "http://127.0.0.1:8000/health"; const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456"; const artifactDir = process.env.DEFECT_LEDGER_ARTIFACT_DIR || path.resolve(__dirname, "..", "..", "..", "project-docs", "qa-artifacts", "defect-ledger-regression", stamp); fs.mkdirSync(artifactDir, { recursive: true }); const FORMAL_DEFECTS = [ { id: 1, title: "管理员初始化后仍无法登录" }, { id: 2, title: "member 无法下载自己生成的合同" }, { id: 3, title: "审查详情页自动触发 401 报告下载" }, { id: 4, title: "审查列表页下载报告不触发真实下载" }, { id: 5, title: "无效 review 详情缺少清晰 404 空态" }, { id: 6, title: "无效 contract 详情缺少清晰 404 空态" }, { id: 7, title: "前端仍接受 PNG 进入已选文件状态" }, { id: 8, title: "Chat 页面出现矛盾的未返回回答提示" }, { id: 9, title: "知识库旧入口路由与权限预期不一致" }, { id: 10, title: "PDF 噪声内容被当作有效审查文本" }, { id: 11, title: "错误登录提示仍为英文" }, { id: 12, title: "登录页暴露默认管理员初始化入口" }, { id: 13, title: "member 误入仅管理员文件中心" }, { id: 14, title: "中文按钮文本被自动插空格" }, { id: 15, title: "PNG 异常文件可直接提交审查" }, { id: 16, title: "EXE 异常文件可直接提交审查" }, { id: 17, title: "慢网抖动期间提交按钮未锁定" }, ]; const results = []; const state = { api: null, browser: null, admin: null, member: null, reviewId: null, contractId: null, }; function screenshotPath(name) { return path.join(artifactDir, `${name}.png`); } function apiUrl(route) { return `${apiBase.replace(/\/$/, "")}${route}`; } function assert(condition, message, details = undefined) { if (!condition) { const error = new Error(message); error.details = details; throw error; } } function buildCoverage(resultsList) { return FORMAL_DEFECTS.map((defect) => { const linkedSteps = resultsList.filter((item) => Array.isArray(item.covers) && item.covers.includes(defect.id)); return { id: defect.id, title: defect.title, step_ids: linkedSteps.map((item) => item.id), status: linkedSteps.length > 0 && linkedSteps.every((item) => item.status === "PASS") ? "PASS" : "FAIL", }; }); } async function step(id, title, covers, fn) { const startedAt = new Date().toISOString(); const started = Date.now(); try { const details = await fn(); const item = { id, title, covers, status: "PASS", duration_ms: Date.now() - started, started_at: startedAt, details: details || {} }; results.push(item); console.log(`PASS ${id} ${title}`); return item; } catch (error) { const item = { id, title, covers, status: "FAIL", duration_ms: Date.now() - started, started_at: startedAt, error: error.message, details: error.details || {}, }; results.push(item); console.error(`FAIL ${id} ${title}: ${error.message}`); return item; } } async function apiRequest(method, route, options = {}) { const headers = { ...(options.authToken ? { Authorization: `Bearer ${options.authToken}` } : {}), ...(options.headers || {}), }; const response = await state.api[method](apiUrl(route), { ...options, headers }); const text = await response.text(); let body = null; if (text) { try { body = JSON.parse(text); } catch { body = text; } } return { response, body, text }; } function expectOk(payload, label) { assert(payload.response.status() >= 200 && payload.response.status() < 300, `${label} HTTP ${payload.response.status()}`, { body: payload.body }); if (payload.body && typeof payload.body === "object" && "code" in payload.body) { assert(payload.body.code === 0, `${label} API code ${payload.body.code}`, { body: payload.body }); } } async function login(account, password) { const payload = await apiRequest("post", "/auth/login", { data: { account, password } }); expectOk(payload, `login ${account}`); return payload.body.data; } async function createAuthenticatedContext(userSession) { const context = await state.browser.newContext({ viewport: { width: 1440, height: 1100 }, acceptDownloads: true }); await context.addInitScript( ({ token, user }) => { window.localStorage.setItem("access_token", token); window.localStorage.setItem("auth-store", JSON.stringify({ state: { accessToken: token, currentUser: user }, version: 0 })); }, { token: userSession.access_token, user: userSession.user }, ); return context; } async function createReview() { const create = await apiRequest("post", "/reviews", { authToken: state.admin.access_token, multipart: { text: `${runId} 审查样本\n自动续约\n免责\n付款`, review_as: "party_a", contract_type: "service", }, }); expectOk(create, "create review"); state.reviewId = create.body.data.id; return state.reviewId; } async function createMemberContract() { const username = `${runId}-member@example.com`; const password = "123456"; const register = await apiRequest("post", "/auth/register", { data: { username, password, display_name: "Defect Ledger Member" }, }); expectOk(register, "register member"); state.member = await login(username, password); const templates = await apiRequest("get", "/contracts/templates", { authToken: state.member.access_token }); expectOk(templates, "member templates"); const template = templates.body.data[0]; const values = {}; for (const variable of template.variables || []) { values[variable.name] = `member-${variable.name}`; } const generate = await apiRequest("post", "/contracts/generate", { authToken: state.member.access_token, multipart: { template_id: template.id, variable_values_json: JSON.stringify(values), export_format: "txt", }, }); expectOk(generate, "member generate contract"); state.contractId = generate.body.data.id; return state.contractId; } async function run() { state.api = await request.newContext(); state.browser = await chromium.launch({ headless: true }); await step("ENV-001", "frontend and backend health", [], async () => { const frontend = await state.api.get(`${webBase}/login`); const health = await state.api.get(healthUrl); assert(frontend.status() === 200, `frontend /login status ${frontend.status()}`); assert(health.status() === 200, `backend /health status ${health.status()}`); state.admin = await login("admin@example.com", adminPassword); return { webBase, apiBase, healthUrl }; }); await step("AUTH-UX-001", "login page hides seed admin and shows chinese error", [1, 11, 12], async () => { const page = await state.browser.newPage({ viewport: { width: 1365, height: 900 } }); await page.goto(`${webBase}/login`, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(1200); const initialBody = await page.locator("body").innerText(); assert(!initialBody.includes("初始化默认管理员"), "seed admin entry should be hidden on non-local login page", initialBody.slice(0, 1200)); await page.getByLabel("账号").fill("admin@example.com"); await page.getByLabel("密码").fill("wrong-password"); await page.getByRole("button", { name: /登录/ }).click(); await page.waitForTimeout(1000); const finalBody = await page.locator("body").innerText(); await page.screenshot({ path: screenshotPath("auth-login-ux"), fullPage: true }); assert(finalBody.includes("账号或密码错误"), "wrong password should show chinese error", finalBody.slice(0, 1400)); await page.close(); return { screenshot: "auth-login-ux.png" }; }); await step("ROUTE-UX-001", "invalid detail pages and legacy knowledge route are clear", [5, 6, 9], async () => { const context = await createAuthenticatedContext(state.admin); const page = await context.newPage(); await page.goto(`${webBase}/reviews/not-a-real-id`, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(1200); let body = await page.locator("body").innerText(); assert(body.includes("审查记录不存在或已被删除"), "invalid review detail should show clear empty state", body.slice(0, 1500)); await page.goto(`${webBase}/contracts/not-a-real-id`, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(1200); body = await page.locator("body").innerText(); assert(body.includes("合同不存在或已被删除"), "invalid contract detail should show clear empty state", body.slice(0, 1500)); await page.goto(`${webBase}/knowledge?tab=all`, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(1200); assert(page.url().includes("/knowledge-bases"), "legacy knowledge route should redirect to /knowledge-bases", { url: page.url() }); await page.screenshot({ path: screenshotPath("route-ux"), fullPage: true }); await context.close(); return { screenshot: "route-ux.png", finalUrl: page.url() }; }); await step("REVIEW-DOWNLOAD-001", "review detail does not auto-401 and list download works", [3, 4], async () => { const reviewId = await createReview(); const context = await createAuthenticatedContext(state.admin); const page = await context.newPage(); const reportFailures = []; page.on("response", async (response) => { if (response.url().includes(`/reviews/${reviewId}/report/download`) && response.status() >= 400) { reportFailures.push({ url: response.url(), status: response.status() }); } }); await page.goto(`${webBase}/reviews/${reviewId}`, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(2200); assert(reportFailures.length === 0, "review detail should not auto-trigger unauthorized report download", { reportFailures }); await page.goto(`${webBase}/reviews`, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(1500); const [download] = await Promise.all([ page.waitForEvent("download", { timeout: 12000 }), page.getByRole("button", { name: "下载报告" }).first().click(), ]); const file = path.join(artifactDir, download.suggestedFilename()); await download.saveAs(file); assert(fs.statSync(file).size > 0, "review list download should produce non-empty file"); await page.screenshot({ path: screenshotPath("review-download"), fullPage: true }); await context.close(); return { screenshot: "review-download.png", file }; }); await step("CHAT-STATE-001", "chat page does not show contradictory empty-answer copy", [8], async () => { const reviewPayload = { reviewId: state.reviewId, prompt: "请总结当前审查结果,并给出最值得优先处理的修改建议。", metadata: { reviewTitle: "Defect Ledger Review Context", reviewStatus: "completed", contractType: "service", overallRisk: "high", reviewOpinion: "请围绕高风险项输出结构化建议。", focusLabel: "高风险项", actionLabel: "生成修改清单", focusType: "risk_item", }, }; const context = await createAuthenticatedContext(state.admin); const page = await context.newPage(); await page.goto(`${webBase}/chat?review=${encodeURIComponent(JSON.stringify(reviewPayload))}`, { waitUntil: "domcontentloaded" }); await page.waitForRequest((request) => request.url().includes("/api/v1/chat/stream"), { timeout: 20000 }); await page.waitForTimeout(4200); const body = await page.locator("body").innerText(); await page.screenshot({ path: screenshotPath("chat-state"), fullPage: true }); assert(!body.includes("本轮未返回回答内容"), "chat page should not show contradictory empty-answer copy", body.slice(-1800)); await context.close(); return { screenshot: "chat-state.png" }; }); await step("PDF-NOISE-001", "noisy pdf content is rejected instead of creating a fake review result", [10], async () => { const noisyPdf = Buffer.from( [ "%PDF-1.4", "1 0 obj", "<< /Type /Catalog /Pages 2 0 R >>", "endobj", "2 0 obj", "<< /Type /Pages /Count 1 /Kids [3 0 R] >>", "endobj", "3 0 obj", "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>", "endobj", "4 0 obj", "<< /Length 44 >>", "stream", "BT /F1 12 Tf 72 720 Td (Catalog Pages Type Parent) Tj ET", "endstream", "endobj", "trailer", "<< /Root 1 0 R >>", "startxref", "0", "%%EOF", ].join("\n"), "utf8", ); const response = await apiRequest("post", "/reviews", { authToken: state.admin.access_token, multipart: { review_as: "party_a", file: { name: `${runId}-noise.pdf`, mimeType: "application/pdf", buffer: noisyPdf }, }, }); assert(response.response.status() === 200, "noisy pdf business rejection should still return structured response", { status: response.response.status(), body: response.body, }); assert(response.body?.code === 40002, "noisy pdf should return 40002", { body: response.body }); assert( String(response.body?.message || "").includes("无法从上传文件中提取可审查文本"), "noisy pdf should return extraction guidance", { body: response.body }, ); return { http_status: response.response.status(), body: response.body }; }); await step("UPLOAD-GUARD-001", "invalid upload formats are rejected and duplicate submit is locked", [7, 15, 16, 17], async () => { const context = await createAuthenticatedContext(state.admin); const page = await context.newPage(); await page.goto(`${webBase}/review`, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(1200); await page.locator("input[type=file]").setInputFiles({ name: `${runId}.png`, mimeType: "image/png", buffer: Buffer.from("png"), }); await page.waitForTimeout(600); let body = await page.locator("body").innerText(); assert(body.includes("仅支持 PDF / DOCX / TXT 文件"), "png upload should be rejected in UI", body.slice(0, 1600)); assert(!body.includes(`${runId}.png`), "rejected png should not remain selected", body.slice(0, 1600)); const pngResponse = await apiRequest("post", "/reviews", { authToken: state.admin.access_token, multipart: { review_as: "party_a", file: { name: `${runId}.png`, mimeType: "image/png", buffer: Buffer.from("png") }, }, }); assert(pngResponse.response.status() === 400, "png upload should be rejected by backend", { status: pngResponse.response.status(), body: pngResponse.body }); const exeResponse = await apiRequest("post", "/reviews", { authToken: state.admin.access_token, multipart: { review_as: "party_a", file: { name: `${runId}.exe`, mimeType: "application/x-msdownload", buffer: Buffer.from("MZ") }, }, }); assert(exeResponse.response.status() === 400, "exe upload should be rejected by backend", { status: exeResponse.response.status(), body: exeResponse.body }); let reviewRequestCount = 0; await page.route("**/api/v1/reviews", async (route) => { reviewRequestCount += 1; await new Promise((resolve) => setTimeout(resolve, 1800)); await route.continue(); }); await page.getByPlaceholder("可直接粘贴合同文本;如同时上传文件,系统会合并文本后一起审查。").fill(`${runId} duplicate guard text`); const submitButton = page.getByRole("button", { name: "提交审查" }); await submitButton.click(); await page.waitForTimeout(120); const disabledAfterClick = await submitButton.isDisabled(); if (!disabledAfterClick) { await submitButton.click({ trial: false }); } await page.waitForTimeout(2600); assert(reviewRequestCount === 1, "duplicate submit should emit only one review request", { reviewRequestCount, disabledAfterClick }); await page.screenshot({ path: screenshotPath("upload-guard"), fullPage: true }); await context.close(); return { screenshot: "upload-guard.png", reviewRequestCount, disabledAfterClick }; }); await step("MEMBER-CONTRACT-001", "member can download own contract and does not see file-center trap", [2, 13], async () => { const contractId = await createMemberContract(); const apiDownload = await apiRequest("get", `/contracts/${contractId}/download`, { authToken: state.member.access_token, }); assert(apiDownload.response.status() === 200, "member api download should succeed", { status: apiDownload.response.status(), body: apiDownload.body }); const context = await createAuthenticatedContext(state.member); const page = await context.newPage(); await page.goto(`${webBase}/contracts/${contractId}`, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(1500); let body = await page.locator("body").innerText(); assert(!body.includes("查看文件资产"), "member contract detail should not expose file center button", body.slice(0, 1600)); const [download] = await Promise.all([ page.waitForEvent("download", { timeout: 12000 }), page.getByRole("button", { name: "下载合同文件" }).click(), ]); const file = path.join(artifactDir, download.suggestedFilename()); await download.saveAs(file); assert(fs.statSync(file).size > 0, "member contract detail download should produce non-empty file"); await page.goto(`${webBase}/contracts`, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(1500); body = await page.locator("body").innerText(); assert(!body.includes("查看文件资产"), "member contract history should not expose file center button", body.slice(0, 1600)); await page.screenshot({ path: screenshotPath("member-contract"), fullPage: true }); await context.close(); return { screenshot: "member-contract.png", file }; }); await step("TEXT-UX-001", "button labels do not expose split chinese spacing", [14], async () => { const context = await createAuthenticatedContext(state.admin); const page = await context.newPage(); await page.goto(`${webBase}/contracts`, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(1400); let body = await page.locator("body").innerText(); assert(!body.includes("选 择"), "contracts page should not show split spacing for 选择", body.slice(0, 1800)); await page.goto(`${webBase}/reviews`, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(1400); body = await page.locator("body").innerText(); assert(!body.includes("详 情"), "reviews page should not show split spacing for 详情", body.slice(0, 1800)); assert(!body.includes("下 载"), "reviews page should not show split spacing for 下载", body.slice(0, 1800)); assert(!body.includes("重 置"), "reviews page should not show split spacing for 重置", body.slice(0, 1800)); await page.screenshot({ path: screenshotPath("text-ux"), fullPage: true }); await context.close(); return { screenshot: "text-ux.png" }; }); await step("COVERAGE-001", "all 17 formal defects are covered by passing regression checks", [], async () => { const coverage = buildCoverage(results); const missing = coverage.filter((item) => item.status !== "PASS"); assert(missing.length === 0, "formal defect coverage is incomplete", { missing, coverage }); return { total_defects: coverage.length, passed_defects: coverage.filter((item) => item.status === "PASS").length, coverage, }; }); const defectCoverage = buildCoverage(results); const summary = { run_id: runId, web_base: webBase, api_base: apiBase, health_url: healthUrl, artifact_dir: artifactDir, total: results.length, passed: results.filter((item) => item.status === "PASS").length, failed: results.filter((item) => item.status === "FAIL").length, formal_defects_total: FORMAL_DEFECTS.length, formal_defects_passed: defectCoverage.filter((item) => item.status === "PASS").length, defect_coverage: defectCoverage, results, }; fs.writeFileSync(path.join(artifactDir, "defect-ledger-regression-summary.json"), JSON.stringify(summary, null, 2), "utf8"); fs.writeFileSync( path.join(artifactDir, "defect-ledger-regression-summary.md"), [ "# Defect Ledger Regression", "", `- Run ID: \`${runId}\``, `- Web: \`${webBase}\``, `- API: \`${apiBase}\``, `- Passed: ${summary.passed}/${summary.total}`, `- Formal Defects Passed: ${summary.formal_defects_passed}/${summary.formal_defects_total}`, "", "| ID | Status | Title |", "|---|---|---|", ...results.map((item) => `| ${item.id} | ${item.status} | ${item.title.replace(/\|/g, "/")} |`), "", "## Formal Defect Coverage", "", "| Defect | Status | Covered By | Title |", "|---|---|---|---|", ...defectCoverage.map( (item) => `| ${item.id} | ${item.status} | ${(item.step_ids.join(", ") || "N/A").replace(/\|/g, "/")} | ${item.title.replace(/\|/g, "/")} |`, ), ].join("\n"), "utf8", ); console.log(JSON.stringify(summary, null, 2)); await state.browser.close().catch(() => undefined); await state.api.dispose().catch(() => undefined); if (summary.failed > 0) { process.exitCode = 1; } } run().catch(async (error) => { const summary = { run_id: runId, web_base: webBase, api_base: apiBase, health_url: healthUrl, artifact_dir: artifactDir, total: results.length + 1, passed: results.filter((item) => item.status === "PASS").length, failed: results.filter((item) => item.status === "FAIL").length + 1, formal_defects_total: FORMAL_DEFECTS.length, defect_coverage: buildCoverage(results), results: [ ...results, { id: "RUNNER", title: "unhandled runner error", status: "FAIL", error: error.message, details: error.details || {}, }, ], }; fs.writeFileSync(path.join(artifactDir, "defect-ledger-regression-summary.json"), JSON.stringify(summary, null, 2), "utf8"); console.error(JSON.stringify(summary, null, 2)); await state.browser?.close().catch(() => undefined); await state.api?.dispose().catch(() => undefined); process.exit(1); });