535 lines
23 KiB
JavaScript
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);
|
|
});
|