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 || "http://127.0.0.1:8000/api/v1"; const healthUrl = process.env.PHASE3_HEALTH_URL || "http://127.0.0.1:8000/health"; const readinessUrl = process.env.PHASE3_READINESS_URL || "http://127.0.0.1:8000/api/v1/system/readiness"; const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456"; const shouldSeedAdmin = process.env.PHASE3_SEED_ADMIN !== "false"; 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", "admin login", async () => { let seed = null; if (shouldSeedAdmin) { 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_admin: shouldSeedAdmin, seed_created: seed?.body?.data?.created ?? null, 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);