Files
ai-contract-platform/yuqei-infra/scripts/verify-core-user-flows.cjs
T

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