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 = `shared-ai-remote-${Date.now()}`; const webBase = process.env.SHARED_AI_REMOTE_WEB_URL || "http://192.168.3.90:3003"; const apiBase = process.env.SHARED_AI_REMOTE_API_URL || "http://192.168.3.90:8001/api/v1"; const healthUrl = process.env.SHARED_AI_REMOTE_HEALTH_URL || "http://192.168.3.90:8001/health"; const readinessUrl = process.env.SHARED_AI_REMOTE_READINESS_URL || "http://192.168.3.90:8001/api/v1/system/readiness"; const sharedAiHealthUrl = process.env.SHARED_AI_REMOTE_SHARED_HEALTH_URL || "http://192.168.3.90:18011/health"; const sharedAiProviderStatusUrl = process.env.SHARED_AI_REMOTE_PROVIDER_STATUS_URL || "http://192.168.3.90:18011/api/v0.2/system/platform-readiness"; const artifactDir = process.env.SHARED_AI_REMOTE_ARTIFACT_DIR || path.resolve(__dirname, "..", "..", "project-docs", "qa-artifacts", "shared-ai-remote-browser-e2e", stamp); fs.mkdirSync(artifactDir, { recursive: true }); const results = []; const state = { api: null, browser: null, member: null, contexts: [], }; function screenshotPath(name) { return path.join(artifactDir, `${name}.png`); } function apiUrl(route) { return `${apiBase.replace(/\/$/, "")}${route}`; } 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 run() { state.api = await request.newContext(); state.browser = await chromium.launch({ headless: true }); await step("ENV-001", "remote frontend, backend and shared-ai health", async () => { const [frontend, backendHealth, readiness, sharedAiHealth, providerStatus] = await Promise.all([ state.api.get(`${webBase}/login`), state.api.get(healthUrl), state.api.get(readinessUrl), state.api.get(sharedAiHealthUrl), state.api.get(sharedAiProviderStatusUrl), ]); assert(frontend.status() === 200, `frontend /login status ${frontend.status()}`); assert(backendHealth.status() === 200, `backend /health status ${backendHealth.status()}`); assert(readiness.status() === 200, `system readiness status ${readiness.status()}`); assert(sharedAiHealth.status() === 200, `shared-ai /health status ${sharedAiHealth.status()}`); assert(providerStatus.status() === 200, `provider-status status ${providerStatus.status()}`); const providerStatusBody = await providerStatus.json(); assert(providerStatusBody.data?.provider_default === "openai_compatible", "provider_default should be openai_compatible", providerStatusBody); assert(providerStatusBody.data?.provider_openai_compatible_enabled === true, "openai_compatible provider should be enabled", providerStatusBody); return { webBase, apiBase, healthUrl, readinessUrl, sharedAiHealthUrl, sharedAiProviderStatusUrl, artifactDir }; }); await step("AUTH-001", "register a fresh member for browser acceptance", async () => { const username = `${runId}@example.com`; const password = "SharedAi123!"; const displayName = "Shared AI Remote QA"; const register = await apiRequest("post", "/auth/register", { data: { username, password, display_name: displayName }, }); expectOk(register, "register remote member"); state.member = await login(username, password); return { username, displayName, role: state.member.user.role }; }); await step("BROWSER-REVIEW-001", "submit contract review from the remote web page", async () => { const context = await createAuthenticatedContext(state.member); const page = await context.newPage(); await page.goto(`${webBase}/review`, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(1800); const contractText = "This agreement includes auto-renewal, limitation of liability, payment milestones, confidentiality obligations, and liquidated damages. Please produce a structured legal review result in Chinese."; const createResponsePromise = page.waitForResponse((response) => response.url().endsWith("/api/v1/reviews") && response.request().method() === "POST" && response.status() === 200, { timeout: 120000 }); await page.locator("textarea").fill(contractText); await page.getByRole("button", { name: /提交审查/ }).click(); const createResponse = await createResponsePromise; const createBody = await createResponse.json(); assert(createBody.code === 0, "review create API should succeed", createBody); const reviewId = createBody.data.id; let navigatedViaRouter = true; try { await page.waitForURL(new RegExp(`/reviews/${reviewId}$`), { timeout: 20000 }); } catch { navigatedViaRouter = false; await page.goto(`${webBase}/reviews/${reviewId}`, { waitUntil: "domcontentloaded" }); } const resultResponse = await page.waitForResponse((response) => response.url().includes(`/api/v1/reviews/${reviewId}/result`) && response.status() === 200, { timeout: 120000 }); const resultBody = await resultResponse.json(); assert(resultBody.code === 0, "review result API should succeed", resultBody); assert(resultBody.data.risk_summary.overall !== "low", "english contract should not degrade to low-risk empty result", resultBody.data); assert(["manual_review", "proceed_with_revision", "proceed"].includes(resultBody.data.recommendation), "recommendation should remain on stable enum", resultBody.data); assert((resultBody.data.risk_items || []).length >= 3, "risk items should be populated for english contract", resultBody.data); const detailBody = await page.locator("body").innerText(); assert(detailBody.includes("风险总览"), "detail page should render risk overview section", detailBody.slice(0, 2000)); assert(detailBody.includes("高风险") || detailBody.includes("中风险"), "detail page should render risk labels", detailBody.slice(0, 2500)); await page.screenshot({ path: screenshotPath("shared-ai-remote-review-detail"), fullPage: true }); await context.close(); return { screenshot: "shared-ai-remote-review-detail.png", url: page.url(), review_id: reviewId, navigated_via_router: navigatedViaRouter, risk_summary: resultBody.data.risk_summary, recommendation: resultBody.data.recommendation, confidence_summary: resultBody.data.confidence_summary, }; }); } 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, shared_ai_health_url: sharedAiHealthUrl, shared_ai_provider_status_url: sharedAiProviderStatusUrl, 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, "shared-ai-remote-browser-e2e-summary.json"), JSON.stringify(summary, null, 2), "utf8"); const markdown = [ "# Shared AI Remote Browser E2E", "", `- Run ID: \`${runId}\``, `- Web: \`${webBase}\``, `- API: \`${apiBase}\``, `- Shared AI: \`${sharedAiHealthUrl}\``, `- 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, "shared-ai-remote-browser-e2e-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, details: error.details || {}, }); console.error(error); process.exitCode = 1; }) .finally(finalize);