Files
ai-contract-platform/frontend/scripts/defect-ledger-regression.mjs
T
2026-05-03 12:39:53 +08:00

535 lines
23 KiB
JavaScript

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);
});