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 = `slice4-auto-${Date.now()}`; const webBase = process.env.SLICE4_WEB_URL || "http://localhost:3007"; const apiBase = process.env.SLICE4_API_URL || "http://127.0.0.1:8000/api/v1"; const healthUrl = process.env.SLICE4_HEALTH_URL || "http://127.0.0.1:8000/health"; const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456"; const artifactDir = process.env.SLICE4_ARTIFACT_DIR || path.resolve(__dirname, "..", "..", "project-docs", "qa-artifacts", "slice4-auto-review-closed-loop", stamp); fs.mkdirSync(artifactDir, { recursive: true }); const results = []; const state = { admin: null, api: null, browser: null, contexts: [], contractId: null, reviewId: null, chatUrl: 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; } function buildVariableValues(template) { return Object.fromEntries( (template.variables || []).map((variable) => { const label = variable.label || variable.name; const value = variable.default_value || `${runId}-${label}`; return [variable.name, value]; }), ); } 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 }, ); await context.route("**/api/v1/**", async (route) => { const browserRequest = route.request(); const browserUrl = new URL(browserRequest.url()); const targetUrl = apiUrl(browserUrl.pathname.replace(/^\/api\/v1/, "") + browserUrl.search); const headers = { ...browserRequest.headers() }; delete headers.host; const response = await state.api.fetch(targetUrl, { method: browserRequest.method(), headers, data: browserRequest.postDataBuffer() ?? undefined, }); await route.fulfill({ response }); }); state.contexts.push(context); return context; } function decodeReviewContext(chatUrl) { const url = new URL(chatUrl, webBase); const raw = url.searchParams.get("review"); assert(raw, "chat URL missing review query", { chatUrl }); return JSON.parse(raw); } async function run() { state.api = await request.newContext(); state.browser = await chromium.launch({ headless: true }); await step("ENV-001", "frontend and backend health", async () => { const frontend = await state.api.get(`${webBase}/login`); const health = await state.api.get(healthUrl); assert(frontend.status() === 200, `frontend /login status ${frontend.status()}`); assert(health.status() === 200, `backend /health status ${health.status()}`); return { webBase, apiBase, healthUrl, artifactDir }; }); 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 { user: state.admin.user.username, role: state.admin.user.role }; }); await step("CONTRACT-001", "contract generation triggers auto review", async () => { const templateSeed = await apiRequest("post", "/templates/seed-defaults", { authToken: state.admin.access_token, }); expectOk(templateSeed, "seed default templates"); const templates = await apiRequest("get", "/contracts/templates", { authToken: state.admin.access_token }); expectOk(templates, "contract templates"); const template = templates.body.data[0]; assert(template, "no contract template available"); const variableValues = buildVariableValues(template); const generated = await apiRequest("post", "/contracts/generate", { authToken: state.admin.access_token, multipart: { template_id: template.id, variable_values_json: JSON.stringify(variableValues), export_format: "txt", options_json: JSON.stringify({ slice: "slice4", run_id: runId }), }, }); expectOk(generated, "contract generate"); const contract = generated.body.data; const autoReview = contract.auto_review; assert(contract.id, "generated contract missing id", contract); assert(contract.file_id, "generated contract missing file asset", contract); assert(autoReview, "generated contract missing auto_review", contract); assert(autoReview.review_id, "auto review missing review id", autoReview); assert(autoReview.status === "completed", "auto review should complete", autoReview); assert(autoReview.detail_url === `/reviews/${autoReview.review_id}`, "auto review detail URL mismatch", autoReview); assert(autoReview.chat_url?.startsWith("/chat?review="), "auto review chat URL missing", autoReview); assert(autoReview.report_download_url?.includes(`/reviews/${autoReview.review_id}/report/download`), "report download URL mismatch", autoReview); state.contractId = contract.id; state.reviewId = autoReview.review_id; state.chatUrl = autoReview.chat_url; return { contract_id: state.contractId, review_id: state.reviewId, auto_review_status: autoReview.status, current_stage: autoReview.current_stage, risk_level: autoReview.risk_level, }; }); await step("REVIEW-001", "auto review detail and result are reachable", async () => { const detail = await apiRequest("get", `/reviews/${state.reviewId}`, { authToken: state.admin.access_token }); const result = await apiRequest("get", `/reviews/${state.reviewId}/result`, { authToken: state.admin.access_token }); expectOk(detail, "auto review detail"); expectOk(result, "auto review result"); assert(detail.body.data.current_stage === "report", "auto review should be in report stage", detail.body.data); assert(detail.body.data.source_document?.document_id, "source document should keep generated file id", detail.body.data); assert(result.body.data.workflow_state?.review_id === state.reviewId, "workflow state review id mismatch", result.body.data); assert(result.body.data.ai_trace?.review_id === state.reviewId, "ai trace review id mismatch", result.body.data); return { review_status: detail.body.data.status, source_document: detail.body.data.source_document?.document_id, risk_level: result.body.data.risk_summary?.overall, }; }); await step("CHAT-001", "auto review chat context binds review state", async () => { const context = decodeReviewContext(state.chatUrl); assert(context.reviewId === state.reviewId, "chat context review id mismatch", context); assert(context.metadata?.focusType === "generated_contract", "chat context focus type mismatch", context.metadata); assert(context.workflowState?.review_id === state.reviewId, "chat context workflow state missing review id", context.workflowState); assert(context.aiTrace?.review_id === state.reviewId, "chat context ai trace missing review id", context.aiTrace); assert(context.sourceDocument?.document_id, "chat context source document missing generated file id", context.sourceDocument); return { review_id: context.reviewId, focus_type: context.metadata?.focusType, prompt_preview: context.prompt?.slice(0, 80), }; }); await step("REPORT-001", "auto review report is downloadable", async () => { const report = await apiRequest("get", `/reviews/${state.reviewId}/report`, { authToken: state.admin.access_token }); expectOk(report, "auto review report metadata"); const download = await apiRequest("get", `/reviews/${state.reviewId}/report/download`, { authToken: state.admin.access_token }); assert(download.response.status() === 200, `report download HTTP ${download.response.status()}`, { body: download.body }); assert(download.text.length > 0, "report download returned empty body"); return { file_name: report.body.data.file_name, download_bytes: download.text.length, }; }); await step("BROWSER-001", "contract detail exposes auto review actions", async () => { const context = await createAuthenticatedContext(state.admin); const page = await context.newPage(); await page.goto(`${webBase}/contracts/${state.contractId}`, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(1600); await page.screenshot({ path: screenshotPath("contract-detail-auto-review"), fullPage: true }); const body = await page.locator("body").innerText(); assert(body.includes("自动审查"), "contract detail missing auto review section", body.slice(0, 1800)); assert(body.includes("查看审查详情"), "contract detail missing review detail action", body.slice(0, 2200)); assert(body.includes("继续审查问答"), "contract detail missing chat action", body.slice(0, 2200)); assert(body.includes("下载审查报告"), "contract detail missing report action", body.slice(0, 2200)); return { screenshot: "contract-detail-auto-review.png" }; }); } try { await run(); } finally { for (const context of state.contexts) { await context.close().catch(() => {}); } if (state.browser) { await state.browser.close().catch(() => {}); } if (state.api) { await state.api.dispose().catch(() => {}); } const summary = { run_id: runId, artifact_dir: artifactDir, passed: results.filter((item) => item.status === "PASS").length, failed: results.filter((item) => item.status === "FAIL").length, results, }; fs.writeFileSync(path.join(artifactDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8"); console.log(`Wrote ${path.join(artifactDir, "summary.json")}`); if (summary.failed > 0) { process.exitCode = 1; } }