Files
ai-contract-platform/frontend/scripts/phase3-closed-loop-e2e.mjs
T

325 lines
12 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 = `phase3-auto-${Date.now()}`;
const webBase = process.env.PHASE3_WEB_URL || "http://127.0.0.1:3006";
const apiBase = process.env.PHASE3_API_URL || `${webBase}/api/v1`;
const healthUrl = process.env.PHASE3_HEALTH_URL || apiBase.replace(/\/api\/v1\/?$/, "/health");
const readinessUrl = process.env.PHASE3_READINESS_URL || `${apiBase.replace(/\/$/, "")}/system/readiness`;
const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456";
const artifactDir =
process.env.PHASE3_ARTIFACT_DIR ||
path.resolve(__dirname, "..", "..", "..", "project-docs", "qa-artifacts", "phase3-closed-loop", stamp);
fs.mkdirSync(artifactDir, { recursive: true });
const results = [];
const state = {
admin: null,
browser: null,
contexts: [],
api: null,
reviewId: null,
knowledgeBaseId: null,
};
function apiUrl(route) {
return `${apiBase.replace(/\/$/, "")}${route}`;
}
function screenshotPath(name) {
return path.join(artifactDir, `${name}.png`);
}
function assert(condition, message, details = undefined) {
if (!condition) {
const error = new Error(message);
error.details = details;
throw error;
}
}
async function step(id, title, fn) {
const startedAt = new Date().toISOString();
const started = Date.now();
try {
const details = await fn();
const item = {
id,
title,
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,
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 },
);
state.contexts.push(context);
return context;
}
async function createPilotReview() {
const text = [
`${runId} 阶段3试点演示审查样本。`,
"合同包含自动续约、免责、付款条件另行协商和50%违约金。",
"请用于演示审查详情、问答追问、合同生成和审计留痕。",
].join("\n");
const create = await apiRequest("post", "/reviews", {
authToken: state.admin.access_token,
multipart: { text, review_as: "party_a", contract_type: "procurement" },
});
expectOk(create, "create pilot review");
state.reviewId = create.body.data.id;
return create.body.data;
}
async function run() {
state.api = await request.newContext();
state.browser = await chromium.launch({ headless: true });
await step("ENV-001", "frontend health and system readiness", async () => {
const frontend = await state.api.get(`${webBase}/login`);
const health = await state.api.get(healthUrl);
const readiness = await state.api.get(readinessUrl);
assert(frontend.status() === 200, `frontend /login status ${frontend.status()}`);
assert(health.status() === 200, `backend /health status ${health.status()}`);
assert(readiness.status() === 200, `system readiness status ${readiness.status()}`);
const readinessBody = await readiness.json();
assert(readinessBody.code === 0, "readiness API code mismatch", readinessBody);
assert(readinessBody.data.summary.failed_required === 0, "readiness has failed required checks", readinessBody.data);
assert(readinessBody.data.pilot_assets.default_admin.username === "admin@example.com", "default admin readiness mismatch", readinessBody.data);
return {
webBase,
apiBase,
healthUrl,
readinessUrl,
artifactDir,
readiness_status: readinessBody.data.status,
warnings: readinessBody.data.summary.warnings,
};
});
await step("AUTH-001", "default admin seed and login", async () => {
const seed = await apiRequest("post", "/auth/seed-admin");
expectOk(seed, "seed default admin");
state.admin = await login("admin@example.com", adminPassword);
assert(state.admin.user.role === "admin", "admin role mismatch", state.admin.user);
return {
seed_created: seed.body.data.created,
user: state.admin.user.username,
role: state.admin.user.role,
};
});
await step("PILOT-DATA-001", "pilot sample data can be prepared", async () => {
const templateSeed = await apiRequest("post", "/templates/seed-defaults", {
authToken: state.admin.access_token,
});
expectOk(templateSeed, "seed default templates");
const review = await createPilotReview();
const reviewDetail = await apiRequest("get", `/reviews/${review.id}`, {
authToken: state.admin.access_token,
});
const reviewResult = await apiRequest("get", `/reviews/${review.id}/result`, {
authToken: state.admin.access_token,
});
expectOk(reviewDetail, "pilot review detail");
expectOk(reviewResult, "pilot review result");
assert(reviewDetail.body.data.current_stage === "report", "pilot review should reach report stage", reviewDetail.body.data);
assert(reviewResult.body.data.risk_summary.overall === "high", "pilot review should be high risk", reviewResult.body.data);
const createKb = await apiRequest("post", "/knowledge-bases", {
authToken: state.admin.access_token,
multipart: { name: `${runId} 试点知识库`, description: "阶段3试点样本知识库" },
});
expectOk(createKb, "create pilot knowledge base");
state.knowledgeBaseId = createKb.body.data.id;
const uploadDoc = await apiRequest("post", `/knowledge-bases/${state.knowledgeBaseId}/documents`, {
authToken: state.admin.access_token,
multipart: {
file: {
name: `${runId}-pilot-kb.txt`,
mimeType: "text/plain",
buffer: Buffer.from("阶段3试点知识库样本:付款、验收、违约责任、保密和自动续约。", "utf8"),
},
},
});
expectOk(uploadDoc, "upload pilot knowledge document");
assert(uploadDoc.body.data.status === "indexed", "pilot knowledge document should be indexed", uploadDoc.body.data);
const audits = await apiRequest("get", "/audit-logs?page=1&page_size=20&keyword=phase3-auto", {
authToken: state.admin.access_token,
});
expectOk(audits, "audit logs reachable");
return {
templates_total: templateSeed.body.data.total,
review_id: state.reviewId,
review_stage: reviewDetail.body.data.current_stage,
review_risk: reviewResult.body.data.risk_summary.overall,
kb_id: state.knowledgeBaseId,
document_status: uploadDoc.body.data.status,
audit_total: audits.body.data.total,
};
});
await step("BROWSER-PILOT-001", "pilot readiness page exposes delivery gates", async () => {
const context = await createAuthenticatedContext(state.admin);
const page = await context.newPage();
await page.goto(`${webBase}/pilot-readiness`, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(1800);
await page.screenshot({ path: screenshotPath("pilot-readiness-dashboard"), fullPage: true });
const body = await page.locator("body").innerText();
assert(body.includes("试点交付准备"), "pilot readiness title missing", body.slice(0, 1200));
assert(body.includes("阶段 3 验收门禁"), "phase 3 gate missing", body.slice(0, 1200));
assert(body.includes("admin@example.com"), "default admin account missing", body.slice(0, 1800));
assert(body.includes("密码状态"), "password rotation status missing", body.slice(0, 1800));
assert(body.includes("演示链路"), "demo flow missing", body.slice(0, 2200));
return { screenshot: "pilot-readiness-dashboard.png" };
});
await step("BROWSER-HOME-001", "home and navigation expose pilot delivery entry", async () => {
const context = await createAuthenticatedContext(state.admin);
const page = await context.newPage();
await page.goto(`${webBase}/`, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(1600);
await page.screenshot({ path: screenshotPath("home-pilot-delivery-entry"), fullPage: true });
const body = await page.locator("body").innerText();
assert(body.includes("试点交付"), "top navigation pilot entry missing", body.slice(0, 1400));
assert(body.includes("试点交付准备"), "home pilot delivery card missing", body.slice(0, 2600));
await page.locator('nav a[href="/pilot-readiness"]').click();
await page.waitForURL(/\/pilot-readiness/);
return { screenshot: "home-pilot-delivery-entry.png", target_url: page.url() };
});
}
async function finalize() {
for (const context of state.contexts) {
await context.close().catch(() => undefined);
}
if (state.browser) {
await state.browser.close().catch(() => undefined);
}
if (state.api) {
await state.api.dispose().catch(() => undefined);
}
const summary = {
run_id: runId,
web_base: webBase,
api_base: apiBase,
health_url: healthUrl,
readiness_url: readinessUrl,
artifact_dir: artifactDir,
total: results.length,
passed: results.filter((item) => item.status === "PASS").length,
failed: results.filter((item) => item.status === "FAIL").length,
results,
};
fs.writeFileSync(path.join(artifactDir, "phase3-closed-loop-summary.json"), JSON.stringify(summary, null, 2), "utf8");
const markdown = [
"# Phase 3 Closed Loop E2E",
"",
`- Run ID: \`${runId}\``,
`- Web: \`${webBase}\``,
`- API: \`${apiBase}\``,
`- Readiness: \`${readinessUrl}\``,
`- Passed: ${summary.passed}/${summary.total}`,
"",
"| ID | Status | Title | Duration |",
"|---|---|---|---:|",
...results.map((item) => `| ${item.id} | ${item.status} | ${item.title.replace(/\|/g, "/")} | ${item.duration_ms}ms |`),
"",
].join("\n");
fs.writeFileSync(path.join(artifactDir, "phase3-closed-loop-summary.md"), markdown, "utf8");
console.log(JSON.stringify(summary, null, 2));
if (summary.failed > 0) {
process.exitCode = 1;
}
}
run()
.catch((error) => {
results.push({
id: "RUNNER",
title: "unhandled runner error",
status: "FAIL",
duration_ms: 0,
started_at: new Date().toISOString(),
error: error.message,
});
process.exitCode = 1;
})
.finally(finalize);