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 = `phase1-auto-${Date.now()}`; const webBase = process.env.PHASE1_WEB_URL || "http://127.0.0.1:3004"; const apiBase = process.env.PHASE1_API_URL || "http://127.0.0.1:8000/api/v1"; const healthUrl = process.env.PHASE1_HEALTH_URL || "http://127.0.0.1:8000/health"; const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456"; const artifactDir = process.env.PHASE1_ARTIFACT_DIR || path.resolve(__dirname, "..", "..", "..", "project-docs", "qa-artifacts", "phase1-closed-loop", stamp); fs.mkdirSync(artifactDir, { recursive: true }); const results = []; const state = { admin: null, member: null, browser: null, contexts: [], api: null, cleanup: [], }; 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; } function buildVariableValues(template) { const values = {}; for (const variable of template.variables || []) { values[variable.name] = `${runId}-${variable.name}`; } return values; } async function downloadAndSave(route, fileName, token) { const response = await state.api.get(apiUrl(route), { headers: { Authorization: `Bearer ${token}` }, }); assert(response.status() >= 200 && response.status() < 300, `download failed ${route}: ${response.status()}`); const body = await response.body(); const target = path.join(artifactDir, fileName); fs.writeFileSync(target, body); assert(body.length > 0, `downloaded file is empty: ${fileName}`); return { file: target, bytes: body.length }; } 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", "admin login and current user", async () => { state.admin = await login("admin@example.com", adminPassword); const me = await apiRequest("get", "/auth/me", { authToken: state.admin.access_token }); expectOk(me, "auth me"); assert(me.body.data.role === "admin", "admin role mismatch", me.body.data); return { user: me.body.data.username, role: me.body.data.role }; }); await step("AUTH-002", "member registration and permission boundary", async () => { const username = `${runId}-member@example.com`; const password = "123456"; const register = await apiRequest("post", "/auth/register", { data: { username, password, display_name: "Phase1 Auto Member" }, }); expectOk(register, "register member"); state.member = await login(username, password); const forbidden = await apiRequest("get", "/users?page=1&page_size=1", { authToken: state.member.access_token }); assert(forbidden.response.status() === 403 || forbidden.body?.code === 403, "member should not access users API", { status: forbidden.response.status(), body: forbidden.body, }); return { username, forbidden_status: forbidden.response.status() }; }); await step("AUTH-003", "unauthenticated protected page redirects", async () => { const page = await state.browser.newPage({ viewport: { width: 1365, height: 900 } }); await page.goto(`${webBase}/contracts`, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(1200); await page.screenshot({ path: screenshotPath("unauth-contracts-redirect"), fullPage: true }); assert(page.url().includes("/login"), "unauthenticated /contracts did not redirect to /login", { url: page.url() }); const finalUrl = page.url(); await page.close(); return { url: finalUrl, screenshot: "unauth-contracts-redirect.png" }; }); await step("REVIEW-001", "empty review input is rejected", async () => { const payload = await apiRequest("post", "/reviews", { authToken: state.admin.access_token, multipart: { text: " ", review_as: "party_a", contract_type: "procurement" }, }); assert(payload.body?.code === 40001, "empty review should return business validation error", payload.body); return { code: payload.body.code, message: payload.body.message }; }); await step("REVIEW-002", "UTF-8 text review completes and report downloads", async () => { const text = [ "\u9636\u6bb51 UTF-8 \u81ea\u52a8\u5316\u5408\u540c\u5ba1\u67e5\u6837\u672c\u3002", "\u5408\u540c\u7ea6\u5b9a\uff1a\u4efb\u4f55\u4e00\u65b9\u8fdd\u7ea6\uff0c\u5e94\u6309\u5408\u540c\u603b\u91d1\u989d\u768450%\u652f\u4ed8\u8fdd\u7ea6\u91d1\u3002", "\u540c\u65f6\u5b58\u5728\u81ea\u52a8\u7eed\u7ea6\u3001\u514d\u8d23\u548c\u4ed8\u6b3e\u6761\u4ef6\u53e6\u884c\u534f\u5546\u7684\u8868\u8ff0\u3002", ].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 text review"); const reviewId = create.body.data.id; const result = await apiRequest("get", `/reviews/${reviewId}/result`, { authToken: state.admin.access_token }); expectOk(result, "review result"); await downloadAndSave(`/reviews/${reviewId}/report/download`, `${runId}-review-report.pdf`, state.admin.access_token); return { review_id: reviewId, risk_summary: result.body.data.risk_summary, risk_items: (result.body.data.risk_items || []).map((item) => ({ title: item.title, level: item.level })), }; }); await step("REVIEW-003", "file review completes", async () => { const content = Buffer.from(`${runId}\nThis procurement contract includes payment and confidentiality clauses.`, "utf8"); const create = await apiRequest("post", "/reviews", { authToken: state.admin.access_token, multipart: { file: { name: `${runId}-review.txt`, mimeType: "text/plain", buffer: content }, review_as: "party_b", contract_type: "procurement", }, }); expectOk(create, "create file review"); const detail = await apiRequest("get", `/reviews/${create.body.data.id}`, { authToken: state.admin.access_token }); expectOk(detail, "file review detail"); assert(detail.body.data.source_type === "file", "file review source_type mismatch", detail.body.data); return { review_id: create.body.data.id, source_type: detail.body.data.source_type }; }); await step("CONTRACT-001", "contract preview generate detail download asset", async () => { 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 preview = await apiRequest("post", "/contracts/preview", { authToken: state.admin.access_token, multipart: { template_id: template.id, variable_values_json: JSON.stringify(variableValues) }, }); expectOk(preview, "contract preview"); 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", }, }); expectOk(generated, "contract generate"); const contract = generated.body.data; const detail = await apiRequest("get", `/contracts/${contract.id}`, { authToken: state.admin.access_token }); expectOk(detail, "contract detail"); await downloadAndSave(`/contracts/${contract.id}/download`, contract.file_name, state.admin.access_token); assert(contract.file_id, "generated contract did not create file asset", contract); const file = await apiRequest("get", `/files/${contract.file_id}`, { authToken: state.admin.access_token }); expectOk(file, "generated contract file asset"); return { contract_id: contract.id, file_id: contract.file_id, file_name: contract.file_name }; }); await step("FILE-001", "file upload detail download delete", async () => { const upload = await apiRequest("post", "/files", { authToken: state.admin.access_token, multipart: { file: { name: `${runId}-asset.txt`, mimeType: "text/plain", buffer: Buffer.from(`${runId} file asset`, "utf8") }, file_type: "document", bucket: "default", }, }); expectOk(upload, "upload file"); const fileId = upload.body.data.id; const detail = await apiRequest("get", `/files/${fileId}`, { authToken: state.admin.access_token }); expectOk(detail, "file detail"); await downloadAndSave(`/files/${fileId}/download`, `${runId}-asset-downloaded.txt`, state.admin.access_token); const deleted = await apiRequest("delete", `/files/${fileId}`, { authToken: state.admin.access_token }); expectOk(deleted, "delete file"); return { file_id: fileId, deleted: deleted.body.data.success }; }); await step("KB-001", "knowledge base success and failed document lifecycle", async () => { const create = await apiRequest("post", "/knowledge-bases", { authToken: state.admin.access_token, multipart: { name: `${runId} KB`, description: "Phase 1 automated KB" }, }); expectOk(create, "create knowledge base"); const kbId = create.body.data.id; const good = await apiRequest("post", `/knowledge-bases/${kbId}/documents`, { authToken: state.admin.access_token, multipart: { file: { name: `${runId}-good.txt`, mimeType: "text/plain", buffer: Buffer.from("Contract review knowledge: payment, confidentiality, liability.", "utf8"), }, }, }); expectOk(good, "upload good kb document"); const empty = await apiRequest("post", `/knowledge-bases/${kbId}/documents`, { authToken: state.admin.access_token, multipart: { file: { name: `${runId}-empty.txt`, mimeType: "text/plain", buffer: Buffer.from("") }, }, }); expectOk(empty, "upload empty kb document"); assert(good.body.data.status === "indexed", "good KB document should be indexed", good.body.data); assert(empty.body.data.status === "failed", "empty KB document should fail", empty.body.data); assert(Boolean(empty.body.data.next_action), "failed KB document should include next_action", empty.body.data); return { kb_id: kbId, good_status: good.body.data.status, failed_status: empty.body.data.status, failed_next_action: empty.body.data.next_action, }; }); await step("ADMIN-001", "review rule CRUD", async () => { const create = await apiRequest("post", "/review-rules", { authToken: state.admin.access_token, multipart: { name: `${runId} rule`, keyword: runId, contract_type: "all", risk_level: "medium", description: "Phase 1 automated rule", suggestion: "Review this phase1-auto marker.", legal_basis: "Phase 1 automation", is_active: "true", }, }); expectOk(create, "create review rule"); const ruleId = create.body.data.id; const update = await apiRequest("put", `/review-rules/${ruleId}`, { authToken: state.admin.access_token, multipart: { name: `${runId} rule updated`, keyword: runId, contract_type: "all", risk_level: "high", description: "Updated phase 1 automated rule", suggestion: "Updated suggestion.", legal_basis: "Updated basis", is_active: "true", }, }); expectOk(update, "update review rule"); const status = await apiRequest("patch", `/review-rules/${ruleId}/status`, { authToken: state.admin.access_token, multipart: { is_active: "false" }, }); expectOk(status, "disable review rule"); const deleted = await apiRequest("delete", `/review-rules/${ruleId}`, { authToken: state.admin.access_token }); expectOk(deleted, "delete review rule"); return { rule_id: ruleId }; }); await step("ADMIN-002", "template CRUD", async () => { const variables = [{ name: "party", label: "Party" }]; const create = await apiRequest("post", "/templates", { authToken: state.admin.access_token, multipart: { name: `${runId} template`, contract_type: "phase1", description: "Phase 1 automated template", content: "Party: {{party}}", variables_json: JSON.stringify(variables), enabled: "true", }, }); expectOk(create, "create template"); const templateId = create.body.data.id; const update = await apiRequest("put", `/templates/${templateId}`, { authToken: state.admin.access_token, multipart: { name: `${runId} template updated`, contract_type: "phase1", description: "Updated phase 1 automated template", content: "Updated Party: {{party}}", variables_json: JSON.stringify(variables), enabled: "false", }, }); expectOk(update, "update template"); const deleted = await apiRequest("delete", `/templates/${templateId}`, { authToken: state.admin.access_token }); assert(deleted.response.status() === 204, `delete template HTTP ${deleted.response.status()}`); return { template_id: templateId }; }); await step("ADMIN-003", "user CRUD and tenant settings restore", async () => { const created = await apiRequest("post", "/users", { authToken: state.admin.access_token, multipart: { username: `${runId}-managed@example.com`, display_name: "Phase1 Managed User", password: "123456", role: "member", status: "active", }, }); expectOk(created, "create managed user"); const userId = created.body.data.id; const updated = await apiRequest("put", `/users/${userId}`, { authToken: state.admin.access_token, multipart: { username: `${runId}-managed@example.com`, display_name: "Phase1 Managed User Disabled", password: "", role: "member", status: "disabled", }, }); expectOk(updated, "disable managed user"); const currentSettings = await apiRequest("get", "/tenant-settings/me", { authToken: state.admin.access_token }); expectOk(currentSettings, "tenant settings get"); const original = currentSettings.body.data; const changed = await apiRequest("put", "/tenant-settings/me", { authToken: state.admin.access_token, multipart: { name: `${original.name} ${runId}`, code: original.code, status: original.status }, }); expectOk(changed, "tenant settings update"); const restored = await apiRequest("put", "/tenant-settings/me", { authToken: state.admin.access_token, multipart: { name: original.name, code: original.code, status: original.status }, }); expectOk(restored, "tenant settings restore"); return { user_id: userId, tenant_id: original.id }; }); await step("AUDIT-001", "audit log list and action filter", async () => { const list = await apiRequest("get", "/audit-logs?page=1&page_size=20", { authToken: state.admin.access_token }); expectOk(list, "audit log list"); assert(Array.isArray(list.body.data.items), "audit log items missing", list.body.data); const filtered = await apiRequest("get", "/audit-logs?page=1&page_size=20&action=login", { authToken: state.admin.access_token }); expectOk(filtered, "audit log filter"); return { total: list.body.data.total, filtered_total: filtered.body.data.total }; }); await step("BROWSER-001", "admin pages load without hard errors", async () => { const context = await createAuthenticatedContext(state.admin); const page = await context.newPage(); const pages = ["/", "/review", "/reviews", "/chat", "/contracts", "/knowledge-bases", "/files", "/rules", "/templates", "/tenant-settings", "/users", "/audit-logs"]; const failures = []; for (const route of pages) { const errors = []; page.removeAllListeners("console"); page.on("console", (message) => { if (message.type() === "error" && !message.text().includes("[antd:")) { errors.push(message.text()); } }); await page.goto(`${webBase}${route}`, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(800); await page.screenshot({ path: screenshotPath(`page-${route === "/" ? "home" : route.slice(1).replace(/\//g, "-")}`), fullPage: true }); const body = await page.locator("body").innerText(); if (!body.trim() || errors.length) { failures.push({ route, errors, body: body.slice(0, 300) }); } } assert(failures.length === 0, "one or more admin pages failed browser smoke", failures); return { pages: pages.length }; }); await step("BROWSER-002", "member admin page shows permission message", async () => { const context = await createAuthenticatedContext(state.member); const page = await context.newPage(); await page.goto(`${webBase}/users`, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(1000); await page.screenshot({ path: screenshotPath("member-users-forbidden"), fullPage: true }); const body = await page.locator("body").innerText(); assert(body.includes("\u4ec5\u7ba1\u7406\u5458"), "member /users page did not show admin-only message", body.slice(0, 500)); return { screenshot: "member-users-forbidden.png" }; }); await step("BROWSER-003", "failed KB document disables chat entry in current frontend", async () => { const context = await createAuthenticatedContext(state.admin); const page = await context.newPage(); await page.goto(`${webBase}/knowledge-bases`, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(2000); await page.screenshot({ path: screenshotPath("kb-failed-disabled-current"), fullPage: true }); const body = await page.locator("body").innerText(); assert(body.includes("\u4e0b\u4e00\u6b65"), "failed KB document next_action not visible", body.slice(-1200)); const disabledLinks = await page.locator("a.disabled-link[aria-disabled='true']").count(); assert(disabledLinks >= 2, "failed KB chat actions should be disabled", { disabledLinks }); return { disabled_links: disabledLinks, screenshot: "kb-failed-disabled-current.png" }; }); await step("BROWSER-004", "Chat context auto prompt starts a new conversation", async () => { const context = await createAuthenticatedContext(state.admin); const page = await context.newPage(); const payload = { reviewId: null, prompt: `${runId}: summarize this knowledge document only.`, metadata: { reviewTitle: `${runId} KB context probe`, reviewStatus: "failed", reviewOpinion: "This context must not reuse an older conversation.", focusLabel: `${runId}-empty.txt`, actionLabel: "Summarize KB document", focusType: "knowledge_document", }, }; const chatRequests = []; page.on("request", (req) => { if (req.url().includes("/api/v1/chat/stream")) { try { chatRequests.push(JSON.parse(req.postData() || "{}")); } catch { chatRequests.push({}); } } }); await page.goto(`${webBase}/chat?review=${encodeURIComponent(JSON.stringify(payload))}`, { waitUntil: "domcontentloaded" }); await page.waitForRequest((req) => req.url().includes("/api/v1/chat/stream"), { timeout: 20000 }); await page.waitForTimeout(2500); await page.screenshot({ path: screenshotPath("chat-context-new-conversation"), fullPage: true }); const sent = chatRequests[0] || {}; assert(!sent.conversation_id, "context auto prompt should not reuse an existing conversation", sent); assert(sent.review_context?.metadata?.reviewTitle === payload.metadata.reviewTitle, "review context title mismatch", sent); return { sent_conversation_id: sent.conversation_id || null, screenshot: "chat-context-new-conversation.png" }; }); } 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, 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, "phase1-closed-loop-summary.json"), JSON.stringify(summary, null, 2), "utf8"); const markdown = [ `# Phase 1 Closed Loop E2E`, ``, `- Run ID: \`${runId}\``, `- Web: \`${webBase}\``, `- API: \`${apiBase}\``, `- 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, "phase1-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);