1012 lines
42 KiB
JavaScript
1012 lines
42 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
const { chromium } = require("playwright");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
|
let apiRequestHeaders = {};
|
|
|
|
function parseArgs(argv) {
|
|
const args = {
|
|
baseUrl: "http://localhost:3000",
|
|
contractApiBaseUrl: "http://127.0.0.1:8100",
|
|
contractProxyKeyEnv: "CONTRACT_API_WEB_PROXY_SHARED_SECRET",
|
|
tenantId: process.env.YUQEI_TEST_TENANT_ID || "default",
|
|
userId: process.env.YUQEI_TEST_USER_ID || "user-current-admin",
|
|
email: process.env.YUQEI_TEST_EMAIL || "admin@example.com",
|
|
password: process.env.YUQEI_TEST_PASSWORD || "",
|
|
contractId: "",
|
|
allowDestructiveContractReuse: false,
|
|
outputDir: path.resolve(REPO_ROOT, "output", "playwright", "core-regression"),
|
|
headed: false,
|
|
timeoutMs: 120000,
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
const next = argv[index + 1];
|
|
if (arg === "--base-url") {
|
|
args.baseUrl = requireValue(arg, next);
|
|
index += 1;
|
|
} else if (arg === "--contract-api-base-url") {
|
|
args.contractApiBaseUrl = requireValue(arg, next);
|
|
index += 1;
|
|
} else if (arg === "--contract-proxy-key-env") {
|
|
args.contractProxyKeyEnv = requireValue(arg, next);
|
|
index += 1;
|
|
} else if (arg === "--tenant-id") {
|
|
args.tenantId = requireValue(arg, next);
|
|
index += 1;
|
|
} else if (arg === "--user-id") {
|
|
args.userId = requireValue(arg, next);
|
|
index += 1;
|
|
} else if (arg === "--email") {
|
|
args.email = requireValue(arg, next);
|
|
index += 1;
|
|
} else if (arg === "--password") {
|
|
args.password = requireValue(arg, next);
|
|
index += 1;
|
|
} else if (arg === "--contract-id") {
|
|
args.contractId = requireValue(arg, next);
|
|
index += 1;
|
|
} else if (arg === "--allow-destructive-contract-reuse") {
|
|
args.allowDestructiveContractReuse = true;
|
|
} else if (arg === "--output-dir") {
|
|
args.outputDir = path.resolve(REPO_ROOT, requireValue(arg, next));
|
|
index += 1;
|
|
} else if (arg === "--headed") {
|
|
args.headed = true;
|
|
} else if (arg === "--timeout-ms") {
|
|
args.timeoutMs = Number.parseInt(requireValue(arg, next), 10);
|
|
index += 1;
|
|
} else if (arg === "--help" || arg === "-h") {
|
|
console.log("Usage: node yuqei-infra/scripts/verify-core-user-flows.cjs [options]");
|
|
console.log(" --email <email> Login email (or YUQEI_TEST_EMAIL)");
|
|
console.log(" --password <password> Login password (or YUQEI_TEST_PASSWORD)");
|
|
console.log(" --contract-proxy-key-env <name> Env var containing the Contract API proxy key");
|
|
console.log(" --tenant-id <id> Tenant context for direct API checks");
|
|
console.log(" --user-id <id> User context for direct API checks");
|
|
console.log(" --contract-id <id> Reuse and mutate an existing contract (disabled by default)");
|
|
console.log(" --allow-destructive-contract-reuse Explicitly allow review/edit/archive of --contract-id");
|
|
console.log(" --headed Show the browser window");
|
|
process.exit(0);
|
|
} else {
|
|
throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
}
|
|
|
|
if (!args.password) {
|
|
throw new Error("Provide --password or YUQEI_TEST_PASSWORD for the login regression.");
|
|
}
|
|
if (!Number.isFinite(args.timeoutMs) || args.timeoutMs < 30000) {
|
|
throw new Error("--timeout-ms must be at least 30000.");
|
|
}
|
|
if (args.contractId && !args.allowDestructiveContractReuse) {
|
|
throw new Error(
|
|
"--contract-id reuses, edits, reviews, and archives an existing contract; add --allow-destructive-contract-reuse to opt in explicitly.",
|
|
);
|
|
}
|
|
if (args.allowDestructiveContractReuse && !args.contractId) {
|
|
throw new Error("--allow-destructive-contract-reuse requires --contract-id.");
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function requireValue(arg, value) {
|
|
if (!value || value.startsWith("--")) {
|
|
throw new Error(`${arg} requires a value.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function normalizeUrl(value) {
|
|
return value.replace(/\/+$/, "");
|
|
}
|
|
|
|
function fetchApi(url, init = {}) {
|
|
return fetch(url, {
|
|
...init,
|
|
headers: {
|
|
...apiRequestHeaders,
|
|
...(init.headers || {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
async function fetchJson(url, init = {}) {
|
|
const response = await fetchApi(url, init);
|
|
const payload = await response.json().catch(() => null);
|
|
if (!response.ok) {
|
|
throw new Error(`${init.method || "GET"} ${url} failed: HTTP ${response.status} ${JSON.stringify(payload)}`);
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
function jsonRequest(body) {
|
|
return {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
};
|
|
}
|
|
|
|
function safeFilename(value) {
|
|
return String(value).replace(/[^\w-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 90) || "artifact";
|
|
}
|
|
|
|
function safeRequestPath(value) {
|
|
try {
|
|
return new URL(String(value)).pathname || "/";
|
|
} catch {
|
|
return "<unparseable>";
|
|
}
|
|
}
|
|
|
|
function sanitizeFailure(value) {
|
|
const normalized = String(value || "Unknown failure")
|
|
.replace(/[\u0000-\u001f\u007f]+/g, " ")
|
|
.replace(/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, (match) => `${match.split(/\s+/, 1)[0]} [REDACTED]`)
|
|
.replace(/https?:\/\/[^\s"'<>]+/gi, (url) => safeRequestPath(url))
|
|
.replace(
|
|
/\b(password|passwd|client[-_]?secret|secret|access[-_]?token|refresh[-_]?token|id[-_]?token|session[-_]?token|token|api[-_]?key|proxy[-_]?key|private[-_]?key|credential|authorization|cookie|session(?:[-_]?id)?)\b(\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;}]*)/gi,
|
|
(_match, label, separator) => `${label}${separator}[REDACTED]`,
|
|
)
|
|
.trim();
|
|
return (normalized || "Unknown failure").slice(0, 500);
|
|
}
|
|
|
|
function requestMetadata(request) {
|
|
return {
|
|
method: String(request.method() || "UNKNOWN").toUpperCase(),
|
|
path: safeRequestPath(request.url()),
|
|
resource: String(request.resourceType() || "unknown"),
|
|
};
|
|
}
|
|
|
|
function isExpectedRequestFailure(request, expectedOrigin) {
|
|
const metadata = requestMetadata(request);
|
|
const failure = sanitizeFailure(request.failure()?.errorText);
|
|
let parsed;
|
|
let normalizedExpectedOrigin;
|
|
try {
|
|
parsed = new URL(request.url());
|
|
normalizedExpectedOrigin = new URL(expectedOrigin).origin;
|
|
} catch {
|
|
return false;
|
|
}
|
|
const headers = typeof request.headers === "function" ? request.headers() : {};
|
|
const purpose = `${headers.purpose || ""} ${headers["sec-purpose"] || ""}`.toLowerCase();
|
|
const isPrefetch = String(headers["next-router-prefetch"] || "") === "1"
|
|
|| Object.prototype.hasOwnProperty.call(headers, "next-router-segment-prefetch")
|
|
|| /\bprefetch\b/.test(purpose);
|
|
const isNextRscPrefetch = parsed.origin === normalizedExpectedOrigin
|
|
&& parsed.searchParams.has("_rsc")
|
|
&& String(headers.rsc || "") === "1"
|
|
&& isPrefetch;
|
|
return metadata.method === "GET"
|
|
&& metadata.resource === "fetch"
|
|
&& failure === "net::ERR_ABORTED"
|
|
&& isNextRscPrefetch;
|
|
}
|
|
|
|
function isExpectedHttpError(entry) {
|
|
return entry.method === "GET" && entry.path === "/favicon.ico" && entry.status === 404;
|
|
}
|
|
|
|
function isExpectedConsoleError(message) {
|
|
const location = typeof message.location === "function" ? message.location() : null;
|
|
return safeRequestPath(location?.url || "") === "/favicon.ico";
|
|
}
|
|
|
|
function observeBrowserErrors(page, { baseUrl = "" } = {}) {
|
|
const diagnostics = {
|
|
consoleErrors: [],
|
|
pageErrors: [],
|
|
requestFailures: [],
|
|
ignoredRequestFailures: [],
|
|
unexpectedHttpResponses: [],
|
|
};
|
|
|
|
page.on("console", (message) => {
|
|
if (message.type() === "error" && !isExpectedConsoleError(message)) {
|
|
diagnostics.consoleErrors.push({
|
|
resource: "console",
|
|
failure: sanitizeFailure(message.text()),
|
|
});
|
|
}
|
|
});
|
|
page.on("pageerror", (error) => {
|
|
diagnostics.pageErrors.push({
|
|
resource: "page",
|
|
failure: sanitizeFailure(error?.message || error),
|
|
});
|
|
});
|
|
page.on("requestfailed", (request) => {
|
|
if (isExpectedRequestFailure(request, baseUrl)) {
|
|
diagnostics.ignoredRequestFailures.push({
|
|
...requestMetadata(request),
|
|
failure: sanitizeFailure(request.failure()?.errorText),
|
|
reason: "next_rsc_prefetch_aborted",
|
|
});
|
|
return;
|
|
}
|
|
diagnostics.requestFailures.push({
|
|
...requestMetadata(request),
|
|
failure: sanitizeFailure(request.failure()?.errorText),
|
|
});
|
|
});
|
|
page.on("response", (response) => {
|
|
const status = response.status();
|
|
if (status < 400) {
|
|
return;
|
|
}
|
|
const entry = {
|
|
...requestMetadata(response.request()),
|
|
status,
|
|
failure: `HTTP ${status}`,
|
|
};
|
|
if (!isExpectedHttpError(entry)) {
|
|
diagnostics.unexpectedHttpResponses.push(entry);
|
|
}
|
|
});
|
|
|
|
return diagnostics;
|
|
}
|
|
|
|
function assertBrowserErrorsAbsent(diagnostics) {
|
|
const counts = {
|
|
console: diagnostics.consoleErrors.length,
|
|
page: diagnostics.pageErrors.length,
|
|
request: diagnostics.requestFailures.length,
|
|
http: diagnostics.unexpectedHttpResponses.length,
|
|
};
|
|
const total = Object.values(counts).reduce((sum, count) => sum + count, 0);
|
|
if (total > 0) {
|
|
throw new Error(
|
|
`Unexpected browser errors detected: console=${counts.console}, page=${counts.page}, request=${counts.request}, http=${counts.http}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function waitForContract(apiUrl, predicate, timeoutMs) {
|
|
const deadline = Date.now() + Math.min(timeoutMs, 10000);
|
|
let contract = null;
|
|
while (Date.now() < deadline) {
|
|
contract = await fetchJson(`${apiUrl}?_=${Date.now()}`);
|
|
if (predicate(contract)) {
|
|
return contract;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
}
|
|
return contract;
|
|
}
|
|
|
|
function valueForVariable(label, index) {
|
|
const text = String(label || "");
|
|
if (/\u65e5\u671f|\u671f\u9650|\u65f6\u95f4/.test(text)) {
|
|
return "2026-07-12";
|
|
}
|
|
if (/\u6bd4\u4f8b|\u767e\u5206\u6bd4|\u7a0e\u7387|%/.test(text)) {
|
|
return "10";
|
|
}
|
|
if (/\u91d1\u989d|\u4ef7\u683c|\u8d39\u7528|\u5355\u4ef7|\u603b\u4ef7/.test(text)) {
|
|
return "100000";
|
|
}
|
|
return `RC regression value ${index + 1}`;
|
|
}
|
|
|
|
async function fillTemplateField(field, index) {
|
|
const metadata = await field.evaluate((element) => ({
|
|
name: element.getAttribute("data-template-field") || "",
|
|
label: element.querySelector("label, legend")?.textContent || "",
|
|
}));
|
|
const addLineItem = field.getByRole("button", { name: "\u6dfb\u52a0\u8d27\u7269\u660e\u7ec6", exact: true });
|
|
if (await addLineItem.count()) {
|
|
await addLineItem.click();
|
|
const values = ["RC regression item", "RC regression specification", "1", "\u4ef6", "1000"];
|
|
for (let position = 0; position < values.length; position += 1) {
|
|
await field.locator('input[aria-label^="\u7b2c1\u9879"]').nth(position).fill(values[position]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const control = field.locator('[name^="var:"]').first();
|
|
if (!(await control.count())) {
|
|
throw new Error(`Template field ${metadata.name || index + 1} has no supported input control.`);
|
|
}
|
|
const controlMetadata = await control.evaluate((element) => ({
|
|
tag: element.tagName.toLowerCase(),
|
|
type: element.getAttribute("type") || "",
|
|
inputMode: element.getAttribute("inputmode") || "",
|
|
}));
|
|
if (controlMetadata.tag === "select") {
|
|
const options = await control.locator("option").evaluateAll((elements) => elements.map((element) => element.value));
|
|
if (options.includes("litigation")) {
|
|
await control.selectOption("litigation");
|
|
await field.locator('input[aria-label$="\u673a\u6784"]').fill("\u5408\u540c\u5c65\u884c\u5730\u6709\u7ba1\u8f96\u6743\u7684\u4eba\u6c11\u6cd5\u9662");
|
|
return;
|
|
}
|
|
if (options.includes("dates") || options.includes("relative")) {
|
|
const mode = options.includes("dates") ? "dates" : options.find((value) => value) || "";
|
|
await control.selectOption(mode);
|
|
if (mode === "relative") {
|
|
await field.locator('input[aria-label$="\u8d77\u7b97\u4e8b\u4ef6"]').fill("\u53cc\u65b9\u5b8c\u6210\u9a8c\u6536\u4e4b\u65e5");
|
|
await field.locator('input[aria-label$="\u81ea\u7136\u65e5\u5929\u6570"]').fill("30");
|
|
} else {
|
|
const dates = field.locator('input[type="date"]');
|
|
await dates.nth(0).fill("2026-07-12");
|
|
if ((await dates.count()) > 1) {
|
|
await dates.nth(1).fill("2027-07-11");
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
const firstValue = options.find((value) => value);
|
|
if (!firstValue) {
|
|
throw new Error(`Template choice ${metadata.name || index + 1} has no selectable option.`);
|
|
}
|
|
await control.selectOption(firstValue);
|
|
return;
|
|
}
|
|
if (controlMetadata.type === "date") {
|
|
await control.fill("2026-07-12");
|
|
return;
|
|
}
|
|
const value = controlMetadata.inputMode === "decimal"
|
|
? valueForVariable(`${metadata.name} ${metadata.label} \u91d1\u989d`, index)
|
|
: valueForVariable(`${metadata.name} ${metadata.label}`, index);
|
|
await control.fill(value);
|
|
}
|
|
|
|
async function login(page, args) {
|
|
await page.goto(`${args.baseUrl}/login`, { waitUntil: "domcontentloaded" });
|
|
await page.waitForLoadState("networkidle", { timeout: args.timeoutMs });
|
|
await page.locator('input[type="email"]').fill(args.email);
|
|
await page.locator('input[type="password"]').fill(args.password);
|
|
await page.locator('button[type="submit"]').click();
|
|
await page.waitForURL(/\/workspace(?:\?.*)?$/, { timeout: args.timeoutMs });
|
|
}
|
|
|
|
async function verifyAuthenticatedSession(page, args) {
|
|
const endpoint = `${args.baseUrl}/api/auth/session`;
|
|
const result = await page.evaluate(async (sessionEndpoint) => {
|
|
const response = await fetch(sessionEndpoint, {
|
|
cache: "no-store",
|
|
credentials: "same-origin",
|
|
});
|
|
const payload = await response.json().catch(() => null);
|
|
const session = payload && typeof payload.session === "object" ? payload.session : null;
|
|
return {
|
|
ok: response.ok,
|
|
status: response.status,
|
|
tenantId: typeof session?.tenant_id === "string" ? session.tenant_id : null,
|
|
userId: typeof session?.user_id === "string" ? session.user_id : null,
|
|
};
|
|
}, endpoint);
|
|
|
|
if (!result.ok) {
|
|
throw new Error(`Authenticated session verification failed: HTTP ${result.status}.`);
|
|
}
|
|
if (!result.tenantId || !result.userId) {
|
|
throw new Error("Authenticated session verification failed: the session endpoint returned no tenant/user identity.");
|
|
}
|
|
if (result.tenantId !== args.tenantId || result.userId !== args.userId) {
|
|
throw new Error(
|
|
`Authenticated session identity mismatch: expected tenant=${args.tenantId}, user=${args.userId}; got tenant=${result.tenantId}, user=${result.userId}.`,
|
|
);
|
|
}
|
|
return { tenantId: result.tenantId, userId: result.userId };
|
|
}
|
|
|
|
async function createContractFromTemplate(page, args) {
|
|
await page.goto(`${args.baseUrl}/legal-documents?template=TPL-COOPERATION-FRAMEWORK-001`, {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
await page.waitForLoadState("networkidle", { timeout: args.timeoutMs });
|
|
const templateButton = page.locator(".template-tree-pane button").filter({
|
|
hasText: "\u5408\u4f5c\u6846\u67b6\u534f\u8bae\u6a21\u677f",
|
|
});
|
|
await templateButton.waitFor({ state: "visible", timeout: args.timeoutMs });
|
|
await templateButton.click();
|
|
await page.locator('input[name="title"]').fill(`RC core regression ${Date.now()}`);
|
|
await page.locator('input[name="owner"]').fill("RC regression");
|
|
|
|
let filledFieldCount = 0;
|
|
for (let step = 0; step < 20; step += 1) {
|
|
const guidedStep = page.locator(".guided-draft-step");
|
|
const filledNames = new Set();
|
|
for (let pass = 0; pass < 20; pass += 1) {
|
|
const requiredFields = guidedStep.locator("[data-template-field]");
|
|
const fieldCount = await requiredFields.count();
|
|
let filledThisPass = false;
|
|
for (let index = 0; index < fieldCount; index += 1) {
|
|
const field = requiredFields.nth(index);
|
|
const fieldName = await field.getAttribute("data-template-field");
|
|
if (fieldName && filledNames.has(fieldName)) {
|
|
continue;
|
|
}
|
|
await fillTemplateField(field, filledFieldCount);
|
|
filledNames.add(fieldName || `field-${index}`);
|
|
filledFieldCount += 1;
|
|
filledThisPass = true;
|
|
}
|
|
if (!filledThisPass) {
|
|
break;
|
|
}
|
|
}
|
|
if (filledNames.size === 0) {
|
|
throw new Error("Template form rendered no required variables.");
|
|
}
|
|
const nextButton = guidedStep.getByRole("button", { name: "\u4e0b\u4e00\u6b65", exact: true });
|
|
if ((await nextButton.count()) === 0) {
|
|
break;
|
|
}
|
|
const currentStepLabel = await guidedStep.getAttribute("aria-label");
|
|
await Promise.all([
|
|
page.waitForFunction(
|
|
(label) => document.querySelector(".guided-draft-step")?.getAttribute("aria-label") !== label,
|
|
currentStepLabel,
|
|
{ timeout: args.timeoutMs },
|
|
),
|
|
nextButton.click(),
|
|
]);
|
|
}
|
|
|
|
const submitButton = page.locator("form.template-draft-form button[type=submit]");
|
|
await submitButton.waitFor({ state: "visible", timeout: args.timeoutMs });
|
|
await submitButton.click();
|
|
const navigated = await page.waitForURL(/\/contracts\/[^/]+$/, { timeout: 5000 }).then(() => true).catch(() => false);
|
|
if (!navigated) {
|
|
const warningAcknowledgement = page.locator('.template-validation-box input[type="checkbox"]');
|
|
if ((await warningAcknowledgement.count()) > 0) {
|
|
await warningAcknowledgement.check();
|
|
await submitButton.click();
|
|
}
|
|
await page.waitForURL(/\/contracts\/[^/]+$/, { timeout: args.timeoutMs });
|
|
}
|
|
await page.waitForLoadState("networkidle", { timeout: args.timeoutMs });
|
|
await page.locator(".document-preview").waitFor({ state: "visible", timeout: args.timeoutMs });
|
|
const contractId = new URL(page.url()).pathname.split("/").filter(Boolean).pop();
|
|
if (!contractId) {
|
|
throw new Error(`Could not extract generated contract id from ${page.url()}`);
|
|
}
|
|
return contractId;
|
|
}
|
|
|
|
async function saveRecordDownload(page, args, contractId) {
|
|
await page.waitForLoadState("networkidle", { timeout: args.timeoutMs });
|
|
const downloadButton = page.getByRole("button", { name: "\u4e0b\u8f7d" }).first();
|
|
await downloadButton.waitFor({ state: "visible", timeout: args.timeoutMs });
|
|
const responsePromise = page.waitForResponse(
|
|
(response) => response.url().includes(`/contracts/${contractId}/download`) && response.request().method() === "GET",
|
|
{ timeout: args.timeoutMs },
|
|
);
|
|
const downloadPromise = page
|
|
.waitForEvent("download", { timeout: args.timeoutMs })
|
|
.catch(() => null);
|
|
await downloadButton.click();
|
|
const response = await responsePromise;
|
|
if (!response.ok()) {
|
|
const diagnostic = await response.text().catch(() => "");
|
|
throw new Error(`Word download request failed: HTTP ${response.status()} ${diagnostic.slice(0, 1000)}`);
|
|
}
|
|
const download = await downloadPromise;
|
|
if (!download) {
|
|
throw new Error("Word download response succeeded but no browser download event was emitted.");
|
|
}
|
|
const target = path.join(args.outputDir, `${safeFilename(contractId)}-${download.suggestedFilename()}`);
|
|
await download.saveAs(target);
|
|
const stat = fs.statSync(target);
|
|
if (stat.size < 1000 || !target.toLowerCase().endsWith(".docx")) {
|
|
throw new Error(`Word download is invalid: ${target} (${stat.size} bytes)`);
|
|
}
|
|
return { path: target, size: stat.size, filename: download.suggestedFilename() };
|
|
}
|
|
|
|
function validateReviewOutcome(task, risks, citations) {
|
|
const evidence = task?.review_evidence;
|
|
if (!evidence || evidence.schema_version !== "contract-review.v3") {
|
|
throw new Error(`Review task did not return contract-review.v3 evidence: ${evidence?.schema_version || "missing"}.`);
|
|
}
|
|
if (!Array.isArray(risks?.items) || !Array.isArray(citations?.items)) {
|
|
throw new Error("Structured review result endpoints returned malformed collections.");
|
|
}
|
|
const reviewStatus = evidence.review_status;
|
|
const openItems = Array.isArray(evidence.open_items) ? evidence.open_items : [];
|
|
if (reviewStatus === "findings") {
|
|
if (risks.items.length === 0) {
|
|
throw new Error("A v3 findings result must contain at least one structured risk.");
|
|
}
|
|
} else if (reviewStatus === "no_findings") {
|
|
if (risks.items.length !== 0) {
|
|
throw new Error("A v3 no_findings result must not contain structured risks.");
|
|
}
|
|
} else if (reviewStatus === "needs_information") {
|
|
const blockingItems = openItems.filter(
|
|
(item) => item && item.blocks_current_review === true && item.required_stage === "current_review",
|
|
);
|
|
if (blockingItems.length === 0) {
|
|
throw new Error("A v3 needs_information result must identify a blocking current-review open item.");
|
|
}
|
|
} else {
|
|
throw new Error(`Review task returned unsupported v3 status: ${reviewStatus || "missing"}.`);
|
|
}
|
|
return {
|
|
schemaVersion: evidence.schema_version,
|
|
reviewStatus,
|
|
openItemCount: openItems.length,
|
|
reportExpected: reviewStatus === "findings" || reviewStatus === "no_findings",
|
|
};
|
|
}
|
|
|
|
async function startAndRunReview(page, args, contractId) {
|
|
await page.waitForLoadState("networkidle", { timeout: args.timeoutMs });
|
|
const reviewDetails = page.locator("#files-review");
|
|
if (!(await reviewDetails.evaluate((element) => element.hasAttribute("open")))) {
|
|
await reviewDetails.locator("summary").click();
|
|
}
|
|
const reviewControl = reviewDetails.locator(".review-start-control").first();
|
|
const reviewTargetSelect = reviewControl.locator("select").nth(0);
|
|
if (await reviewTargetSelect.count()) {
|
|
await reviewTargetSelect.selectOption("contract_text");
|
|
}
|
|
const reviewSourceSelect = reviewControl.locator("select").nth(1);
|
|
if (await reviewSourceSelect.count()) {
|
|
await reviewSourceSelect.selectOption("");
|
|
}
|
|
const startButton = reviewControl.getByRole("button", { name: "\u53d1\u8d77\u5ba1\u67e5" });
|
|
await startButton.waitFor({ state: "visible", timeout: args.timeoutMs });
|
|
const reviewCreatePath = `/api/contract-platform/contracts/${encodeURIComponent(contractId)}/review`;
|
|
const reviewCreateResponsePromise = page.waitForResponse(
|
|
(response) => {
|
|
try {
|
|
return response.request().method() === "POST" && new URL(response.url()).pathname === reviewCreatePath;
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
{ timeout: args.timeoutMs },
|
|
);
|
|
await startButton.click();
|
|
const reviewCreateResponse = await reviewCreateResponsePromise;
|
|
const task = await reviewCreateResponse.json().catch(() => null);
|
|
if (!reviewCreateResponse.ok()) {
|
|
throw new Error(`Review task creation failed: HTTP ${reviewCreateResponse.status()} ${JSON.stringify(task)}`);
|
|
}
|
|
if (!task?.id) {
|
|
throw new Error("Review task creation response did not contain a task id.");
|
|
}
|
|
|
|
let completedTask = null;
|
|
const reviewDeadline = Date.now() + args.timeoutMs;
|
|
while (Date.now() < reviewDeadline) {
|
|
const currentTasks = await fetchJson(`${args.contractApiBaseUrl}/api/v1/contracts/${encodeURIComponent(contractId)}/review-tasks`);
|
|
completedTask = currentTasks.find((item) => item.id === task.id) || null;
|
|
if (completedTask?.status === "succeeded" || completedTask?.status === "failed") {
|
|
break;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
}
|
|
if (completedTask?.status !== "succeeded") {
|
|
throw new Error(`Review worker did not complete target task: ${JSON.stringify({
|
|
taskId: task.id,
|
|
status: completedTask?.status || "not_observed",
|
|
error: completedTask?.error_message || null,
|
|
})}`);
|
|
}
|
|
|
|
const reviewQuery = new URLSearchParams({ review_task_id: task.id }).toString();
|
|
const risksResponse = await fetchApi(
|
|
`${args.contractApiBaseUrl}/api/v1/contracts/${encodeURIComponent(contractId)}/review-risks?${reviewQuery}`,
|
|
);
|
|
const risks = await risksResponse.json().catch(() => null);
|
|
const citationsResponse = await fetchApi(
|
|
`${args.contractApiBaseUrl}/api/v1/contracts/${encodeURIComponent(contractId)}/review-citations?${reviewQuery}`,
|
|
);
|
|
const citations = await citationsResponse.json().catch(() => null);
|
|
if (!risksResponse.ok) {
|
|
throw new Error(`Structured review risks failed: ${risksResponse.status} ${JSON.stringify(risks)}`);
|
|
}
|
|
if (!citationsResponse.ok) {
|
|
throw new Error(`Structured review citations failed: ${citationsResponse.status} ${JSON.stringify(citations)}`);
|
|
}
|
|
const outcome = validateReviewOutcome(completedTask, risks, citations);
|
|
|
|
await page.reload({ waitUntil: "domcontentloaded" });
|
|
const reviewBlock = page.locator(".review-result-block").filter({ hasText: completedTask.summary }).first();
|
|
await reviewBlock.waitFor({ state: "visible", timeout: args.timeoutMs });
|
|
const reviewText = await reviewBlock.innerText();
|
|
if (!reviewText.includes("\u5ba1\u67e5\u7ed3\u679c")) {
|
|
throw new Error("Review result block is missing its result label.");
|
|
}
|
|
|
|
let reportPath = null;
|
|
let reportByteCount = 0;
|
|
if (outcome.reportExpected) {
|
|
const reportButton = reviewBlock.getByRole("button", { name: "\u5ba1\u67e5\u62a5\u544a" }).first();
|
|
await reportButton.waitFor({ state: "visible", timeout: args.timeoutMs });
|
|
const reportResponsePromise = page.waitForResponse(
|
|
(response) => response.url().includes("/review-report/download") && response.request().method() === "GET",
|
|
{ timeout: args.timeoutMs },
|
|
);
|
|
await reportButton.click();
|
|
const reportResponse = await reportResponsePromise;
|
|
const reportPayload = await reportResponse.json().catch(() => null);
|
|
const reportBytes = Buffer.from(reportPayload?.content_base64 || "", "base64");
|
|
if (reportResponse.status() !== 200 || reportBytes.length < 100 || reportBytes.subarray(0, 4).toString() !== "%PDF") {
|
|
throw new Error(`Review report download is invalid: HTTP ${reportResponse.status()} bytes=${reportBytes.length}`);
|
|
}
|
|
reportPath = path.join(args.outputDir, reportPayload.file_name || `${contractId}-review-report.pdf`);
|
|
fs.writeFileSync(reportPath, reportBytes);
|
|
reportByteCount = reportBytes.length;
|
|
}
|
|
return {
|
|
taskId: task.id,
|
|
schemaVersion: outcome.schemaVersion,
|
|
reviewStatus: outcome.reviewStatus,
|
|
openItemCount: outcome.openItemCount,
|
|
riskCount: risks.items.length,
|
|
citationCount: citations.items.length,
|
|
reportPath,
|
|
reportBytes: reportByteCount,
|
|
reportSkipped: outcome.reportExpected ? null : "needs_information",
|
|
};
|
|
}
|
|
|
|
async function uploadAndVerifyFile(page, args, contractId) {
|
|
const stamp = Date.now();
|
|
const fileName = `rc-core-regression-${stamp}.txt`;
|
|
const secondFileName = `rc-core-regression-${stamp}-2.txt`;
|
|
await page.goto(`${args.baseUrl}/files?contract_id=${encodeURIComponent(contractId)}`, {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
await page.waitForLoadState("networkidle", { timeout: args.timeoutMs });
|
|
const uploadForm = page.locator(".file-upload-form");
|
|
await uploadForm.locator('input[type="file"]').setInputFiles({
|
|
name: fileName,
|
|
mimeType: "text/plain",
|
|
buffer: Buffer.from("RC core regression evidence file.\n", "utf8"),
|
|
});
|
|
const contractSelect = uploadForm.locator("select").first();
|
|
if (await contractSelect.count()) {
|
|
await contractSelect.selectOption(contractId);
|
|
}
|
|
const autoReviewCheckbox = uploadForm.locator('.file-upload-checkbox input[type="checkbox"]');
|
|
if (await autoReviewCheckbox.isChecked()) {
|
|
await autoReviewCheckbox.uncheck();
|
|
}
|
|
if (await autoReviewCheckbox.isChecked()) {
|
|
throw new Error("File upload auto-review could not be disabled before the deterministic upload check.");
|
|
}
|
|
const uploadResponsePromise = page.waitForResponse(
|
|
(response) => response.url().endsWith("/api/contract-platform/files") && response.request().method() === "POST",
|
|
{ timeout: args.timeoutMs },
|
|
);
|
|
await uploadForm.locator("button[type=submit]").click();
|
|
const uploadResponse = await uploadResponsePromise;
|
|
const uploadPayload = await uploadResponse.json().catch(() => null);
|
|
if (!uploadResponse.ok() || !uploadPayload?.id || uploadPayload.parse_status !== "parsed") {
|
|
throw new Error(
|
|
`File upload or parsing failed: HTTP ${uploadResponse.status()} ${JSON.stringify(uploadPayload)}`,
|
|
);
|
|
}
|
|
await uploadForm.locator(".inline-message").filter({ hasText: "\u6587\u4ef6\u89e3\u6790\u5b8c\u6210" }).waitFor({
|
|
state: "visible",
|
|
timeout: args.timeoutMs,
|
|
});
|
|
const row = page.locator("tr").filter({ hasText: fileName }).first();
|
|
await row.waitFor({ state: "visible", timeout: args.timeoutMs });
|
|
|
|
await row.getByRole("button", { name: "\u9884\u89c8" }).click();
|
|
const preview = row.locator(".file-preview-box");
|
|
await preview.waitFor({ state: "visible", timeout: args.timeoutMs });
|
|
const previewText = await preview.innerText();
|
|
if (!previewText.includes("RC core regression evidence file")) {
|
|
throw new Error(`File preview did not contain uploaded content: ${previewText}`);
|
|
}
|
|
|
|
const downloadResponsePromise = page.waitForResponse(
|
|
(response) => response.url().includes("/files/") && response.url().includes("/download") && response.request().method() === "GET",
|
|
{ timeout: args.timeoutMs },
|
|
);
|
|
await row.getByRole("button", { name: "\u4e0b\u8f7d" }).click();
|
|
const downloadResponse = await downloadResponsePromise;
|
|
const payload = await downloadResponse.json();
|
|
const bytes = Buffer.from(payload.content_base64 || "", "base64");
|
|
if (downloadResponse.status() !== 200 || bytes.length === 0 || !bytes.toString("utf8").includes("RC core regression evidence file")) {
|
|
throw new Error(`File download is invalid: HTTP ${downloadResponse.status()} bytes=${bytes.length}`);
|
|
}
|
|
|
|
const secondUpload = await fetchJson(
|
|
`${args.contractApiBaseUrl}/api/v1/files`,
|
|
jsonRequest({
|
|
file_name: secondFileName,
|
|
related_contract_id: contractId,
|
|
content_type: "text/plain",
|
|
uploaded_by: "RC regression",
|
|
content_base64: Buffer.from("RC core regression second evidence file.\n", "utf8").toString("base64"),
|
|
}),
|
|
);
|
|
await page.reload({ waitUntil: "domcontentloaded" });
|
|
const firstCheckbox = page.locator(`input[type="checkbox"][data-file-id="${uploadPayload.id}"]`);
|
|
const secondCheckbox = page.locator(`input[type="checkbox"][data-file-id="${secondUpload.id}"]`);
|
|
await firstCheckbox.check();
|
|
await secondCheckbox.check();
|
|
const batchResponsePromise = page.waitForResponse(
|
|
(response) => response.url().endsWith("/api/contract-platform/files/batch-download") && response.request().method() === "POST",
|
|
{ timeout: args.timeoutMs },
|
|
);
|
|
const downloadPromise = page.waitForEvent("download", { timeout: args.timeoutMs });
|
|
await page.getByRole("button", { name: "\u6279\u91cf\u4e0b\u8f7d", exact: true }).click();
|
|
const [batchResponse, batchDownload] = await Promise.all([batchResponsePromise, downloadPromise]);
|
|
const batchPayload = await batchResponse.json();
|
|
const batchPath = path.join(args.outputDir, batchDownload.suggestedFilename());
|
|
await batchDownload.saveAs(batchPath);
|
|
const batchBytes = fs.readFileSync(batchPath);
|
|
if (!batchResponse.ok() || batchPayload.file_count !== 2 || batchBytes.subarray(0, 2).toString() !== "PK") {
|
|
throw new Error(`Batch file download is invalid: HTTP ${batchResponse.status()} count=${batchPayload.file_count} bytes=${batchBytes.length}`);
|
|
}
|
|
return {
|
|
fileName,
|
|
preview: true,
|
|
download: true,
|
|
bytes: bytes.length,
|
|
batchDownload: true,
|
|
batchFileCount: batchPayload.file_count,
|
|
batchBytes: batchBytes.length,
|
|
};
|
|
}
|
|
|
|
async function runContractLifecycle(page, args, contractId) {
|
|
const detailUrl = `${args.baseUrl}/contracts/${encodeURIComponent(contractId)}`;
|
|
const apiUrl = `${args.contractApiBaseUrl}/api/v1/contracts/${encodeURIComponent(contractId)}`;
|
|
const editMarker = `RC lifecycle edit ${Date.now()}`;
|
|
const archiveLocation = `RC-${new Date().toISOString().slice(0, 10)}-${contractId.slice(-6)}`;
|
|
const states = [];
|
|
|
|
await page.goto(detailUrl, { waitUntil: "domcontentloaded" });
|
|
await page.waitForLoadState("networkidle", { timeout: args.timeoutMs });
|
|
let contract = await fetchJson(apiUrl);
|
|
states.push(contract.status);
|
|
|
|
await page.getByRole("button", { name: "\u7f16\u8f91", exact: true }).click();
|
|
const editForm = page.locator(".contract-edit-form");
|
|
await editForm.waitFor({ state: "visible", timeout: args.timeoutMs });
|
|
await editForm.locator('input[name="counterparty"]').fill("RC lifecycle counterparty");
|
|
await editForm.locator('input[name="action_hint"]').fill("RC lifecycle follow-up");
|
|
await editForm.locator('textarea[name="body_preview"]').fill(editMarker);
|
|
const bodyEditor = editForm.locator(".contract-body-editor");
|
|
if (!(await bodyEditor.evaluate((element) => element.hasAttribute("open")))) {
|
|
await bodyEditor.locator("summary").click();
|
|
}
|
|
const bodyContent = editForm.locator('textarea[name="body_content"]');
|
|
await bodyContent.fill(`${await bodyContent.inputValue()}\n\n${editMarker}`);
|
|
const editResponsePromise = page.waitForResponse(
|
|
(response) => response.url().includes(`/contracts/${contractId}`) && response.request().method() === "PATCH",
|
|
{ timeout: args.timeoutMs },
|
|
);
|
|
await editForm.getByRole("button", { name: "\u4fdd\u5b58", exact: true }).click();
|
|
const editResponse = await editResponsePromise;
|
|
if (!editResponse.ok()) {
|
|
throw new Error(`Contract edit failed: HTTP ${editResponse.status()}`);
|
|
}
|
|
contract = await waitForContract(
|
|
apiUrl,
|
|
(candidate) => candidate.body_content.includes(editMarker)
|
|
&& candidate.counterparty === "RC lifecycle counterparty",
|
|
args.timeoutMs,
|
|
);
|
|
if (!contract.body_content.includes(editMarker) || contract.counterparty !== "RC lifecycle counterparty") {
|
|
throw new Error("Contract edit did not persist the expected body and counterparty values.");
|
|
}
|
|
states.push(contract.status);
|
|
|
|
await clickWorkflowAction(page, args, contractId, "\u63d0\u4ea4\u5ba1\u6279", "approval/submit");
|
|
contract = await fetchJson(apiUrl);
|
|
assertStatus(contract, "approval_pending", "submit approval");
|
|
states.push(contract.status);
|
|
|
|
await page.getByRole("button", { name: "\u9000\u56de\u4fee\u6539", exact: true }).click();
|
|
const rejectDialog = page.locator(".reject-dialog");
|
|
await rejectDialog.waitFor({ state: "visible", timeout: args.timeoutMs });
|
|
await rejectDialog.locator("textarea").fill("RC lifecycle rejection comment");
|
|
const rejectResponsePromise = page.waitForResponse(
|
|
(response) => response.url().includes(`/contracts/${contractId}/approval/decision`) && response.request().method() === "POST",
|
|
{ timeout: args.timeoutMs },
|
|
);
|
|
await rejectDialog.getByRole("button", { name: "\u9000\u56de\u4fee\u6539", exact: true }).click();
|
|
const rejectResponse = await rejectResponsePromise;
|
|
if (!rejectResponse.ok()) {
|
|
throw new Error(`Reject approval failed: HTTP ${rejectResponse.status()}`);
|
|
}
|
|
await reloadContractPage(page, args, detailUrl);
|
|
contract = await fetchJson(apiUrl);
|
|
assertStatus(contract, "rejected", "reject approval");
|
|
states.push(contract.status);
|
|
|
|
await clickWorkflowAction(page, args, contractId, "\u91cd\u65b0\u63d0\u4ea4", "approval/resubmit");
|
|
contract = await fetchJson(apiUrl);
|
|
assertStatus(contract, "approval_pending", "resubmit approval");
|
|
states.push(contract.status);
|
|
|
|
await clickWorkflowAction(page, args, contractId, "\u5ba1\u6279\u901a\u8fc7", "approval/decision");
|
|
contract = await fetchJson(apiUrl);
|
|
assertStatus(contract, "approved", "approve contract");
|
|
states.push(contract.status);
|
|
|
|
const evidenceDetails = page.locator("#versions");
|
|
if (!(await evidenceDetails.evaluate((element) => element.hasAttribute("open")))) {
|
|
await evidenceDetails.locator("summary").click();
|
|
}
|
|
const archiveForm = page.locator(".contract-archive-form");
|
|
await archiveForm.waitFor({ state: "visible", timeout: args.timeoutMs });
|
|
await archiveForm.locator('input[name="archive_location"]').fill(archiveLocation);
|
|
await archiveForm.locator('textarea[name="summary"]').fill("RC lifecycle archive evidence");
|
|
const archiveResponsePromise = page.waitForResponse(
|
|
(response) => response.url().includes(`/contracts/${contractId}/archive`) && response.request().method() === "POST",
|
|
{ timeout: args.timeoutMs },
|
|
);
|
|
await archiveForm.getByRole("button", { name: "\u786e\u8ba4\u5f52\u6863", exact: true }).click();
|
|
const archiveResponse = await archiveResponsePromise;
|
|
if (!archiveResponse.ok()) {
|
|
throw new Error(`Archive failed: HTTP ${archiveResponse.status()}`);
|
|
}
|
|
await reloadContractPage(page, args, detailUrl);
|
|
contract = await fetchJson(apiUrl);
|
|
assertStatus(contract, "archived", "archive contract");
|
|
states.push(contract.status);
|
|
|
|
const archiveEvidence = await fetchJson(`${apiUrl}/archive-evidence`);
|
|
const signatureEvidence = await fetchJson(`${apiUrl}/signature-evidence`);
|
|
const integrity = await fetchJson(`${apiUrl}/audit-integrity`);
|
|
const archivedPage = await fetchJson(
|
|
`${args.contractApiBaseUrl}/api/v1/contracts?lifecycle=archived&keyword=${encodeURIComponent(contract.title)}&page=1&page_size=20`,
|
|
);
|
|
if (!Array.isArray(archiveEvidence) || !archiveEvidence.some((item) => item.archive_location === archiveLocation)) {
|
|
throw new Error("Archive evidence was not persisted with the submitted location.");
|
|
}
|
|
if (!signatureEvidence?.items?.length) {
|
|
throw new Error("Approval did not create signature evidence.");
|
|
}
|
|
if (!integrity.valid) {
|
|
throw new Error(`Audit integrity failed: ${JSON.stringify(integrity.errors || [])}`);
|
|
}
|
|
if (!Array.isArray(archivedPage.items) || !archivedPage.items.some((item) => item.id === contractId)) {
|
|
throw new Error("Archived contract is missing from the archived lifecycle filter.");
|
|
}
|
|
|
|
return {
|
|
states,
|
|
editPersisted: true,
|
|
archiveLocation,
|
|
archiveEvidenceCount: archiveEvidence.length,
|
|
signatureEvidenceCount: signatureEvidence.items.length,
|
|
archivedFilter: true,
|
|
auditIntegrity: true,
|
|
};
|
|
}
|
|
|
|
async function clickWorkflowAction(page, args, contractId, buttonName, endpoint) {
|
|
const responsePromise = page.waitForResponse(
|
|
(response) => response.url().includes(`/contracts/${contractId}/${endpoint}`) && response.request().method() === "POST",
|
|
{ timeout: args.timeoutMs },
|
|
);
|
|
await page.getByRole("button", { name: buttonName, exact: true }).click();
|
|
const response = await responsePromise;
|
|
if (!response.ok()) {
|
|
throw new Error(`${buttonName} failed: HTTP ${response.status()}`);
|
|
}
|
|
await reloadContractPage(page, args, `${args.baseUrl}/contracts/${encodeURIComponent(contractId)}`);
|
|
}
|
|
|
|
async function reloadContractPage(page, args, detailUrl) {
|
|
await page.goto(detailUrl, { waitUntil: "domcontentloaded" });
|
|
await page.waitForLoadState("networkidle", { timeout: args.timeoutMs });
|
|
await page.locator(".document-preview").waitFor({ state: "visible", timeout: args.timeoutMs });
|
|
}
|
|
|
|
function assertStatus(contract, expected, step) {
|
|
if (contract.status !== expected) {
|
|
throw new Error(`Unexpected contract status after ${step}: expected ${expected}, got ${contract.status}`);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
args.baseUrl = normalizeUrl(args.baseUrl);
|
|
args.contractApiBaseUrl = normalizeUrl(args.contractApiBaseUrl);
|
|
const contractProxyKey = process.env[args.contractProxyKeyEnv]?.trim();
|
|
if (!contractProxyKey) {
|
|
throw new Error(`Missing Contract API proxy key in ${args.contractProxyKeyEnv}.`);
|
|
}
|
|
apiRequestHeaders = {
|
|
"X-Contract-Proxy-Key": contractProxyKey,
|
|
"X-Tenant-Id": args.tenantId,
|
|
"X-User-Id": args.userId,
|
|
};
|
|
fs.mkdirSync(args.outputDir, { recursive: true });
|
|
|
|
const browser = await chromium.launch({ headless: !args.headed, channel: "chrome" });
|
|
const page = await browser.newPage({ viewport: { width: 1440, height: 1050 } });
|
|
page.setDefaultTimeout(args.timeoutMs);
|
|
const browserErrors = observeBrowserErrors(page, { baseUrl: args.baseUrl });
|
|
|
|
const summary = {
|
|
status: "fail",
|
|
checkedAt: new Date().toISOString(),
|
|
baseUrl: args.baseUrl,
|
|
contractApiBaseUrl: args.contractApiBaseUrl,
|
|
outputDir: args.outputDir,
|
|
contractId: args.contractId || null,
|
|
login: false,
|
|
authenticatedSession: null,
|
|
templateDraft: false,
|
|
wordDownload: null,
|
|
review: null,
|
|
fileRepository: null,
|
|
lifecycle: null,
|
|
...browserErrors,
|
|
};
|
|
|
|
try {
|
|
await login(page, args);
|
|
summary.authenticatedSession = await verifyAuthenticatedSession(page, args);
|
|
summary.login = true;
|
|
|
|
if (!summary.contractId) {
|
|
summary.contractId = await createContractFromTemplate(page, args);
|
|
summary.templateDraft = true;
|
|
} else {
|
|
await page.goto(`${args.baseUrl}/contracts/${encodeURIComponent(summary.contractId)}`, {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
await page.locator(".document-preview").waitFor({ state: "visible", timeout: args.timeoutMs });
|
|
}
|
|
|
|
const documentText = await page.locator(".document-preview").innerText();
|
|
if (/^#{1,6}\s+/m.test(documentText)) {
|
|
throw new Error("Contract detail still renders markdown heading markers.");
|
|
}
|
|
summary.wordDownload = await saveRecordDownload(page, args, summary.contractId);
|
|
summary.review = await startAndRunReview(page, args, summary.contractId);
|
|
summary.fileRepository = await uploadAndVerifyFile(page, args, summary.contractId);
|
|
summary.lifecycle = await runContractLifecycle(page, args, summary.contractId);
|
|
await page.waitForTimeout(250);
|
|
assertBrowserErrorsAbsent(browserErrors);
|
|
summary.status = "pass";
|
|
} catch (error) {
|
|
summary.error = error instanceof Error ? error.message : String(error);
|
|
await page.screenshot({ path: path.join(args.outputDir, "core-regression-failure.png"), fullPage: true }).catch(() => null);
|
|
} finally {
|
|
fs.writeFileSync(path.join(args.outputDir, "core-user-flow-summary.json"), JSON.stringify(summary, null, 2), "utf8");
|
|
await browser.close();
|
|
}
|
|
|
|
console.log(JSON.stringify(summary, null, 2));
|
|
console.log(`CORE_USER_FLOWS_${summary.status.toUpperCase()} contract_id=${summary.contractId || "none"} artifact=${path.join(args.outputDir, "core-user-flow-summary.json")}`);
|
|
if (summary.status !== "pass") {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
assertBrowserErrorsAbsent,
|
|
isExpectedHttpError,
|
|
isExpectedRequestFailure,
|
|
observeBrowserErrors,
|
|
parseArgs,
|
|
safeRequestPath,
|
|
sanitizeFailure,
|
|
validateReviewOutcome,
|
|
valueForVariable,
|
|
verifyAuthenticatedSession,
|
|
};
|