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 = `dev-fix-action-${Date.now()}`; const webBase = process.env.DEV_FIX_WEB_URL || "http://127.0.0.1:3005"; const apiBase = process.env.DEV_FIX_API_URL || "http://127.0.0.1:8000/api/v1"; const healthUrl = process.env.DEV_FIX_HEALTH_URL || "http://127.0.0.1:8000/health"; const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456"; const artifactDir = process.env.DEV_FIX_ARTIFACT_DIR || path.resolve(__dirname, "..", "..", "..", "project-docs", "qa-artifacts", "dev-fix-action-regression", stamp); fs.mkdirSync(artifactDir, { recursive: true }); const results = []; const state = { api: null, browser: null, admin: 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 }, ); return context; } 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", async () => { state.admin = await login("admin@example.com", adminPassword); return { user: state.admin.user.username, role: state.admin.user.role }; }); await step("API-001", "conversations list returns 200", async () => { const payload = await apiRequest("get", "/conversations?page=1&page_size=20", { authToken: state.admin.access_token, }); expectOk(payload, "list conversations"); return { total: payload.body.data.total }; }); await step("API-002", "knowledge base list returns 200", async () => { const payload = await apiRequest("get", "/knowledge-bases?page=1&page_size=20", { authToken: state.admin.access_token, }); expectOk(payload, "list knowledge bases"); return { total: payload.body.data.total }; }); await step("ROUTE-001", "admin and pilot aliases resolve to real pages", async () => { const context = await createAuthenticatedContext(state.admin); const checks = [ { path: "/admin", expect: "/admin", needle: "管理模块总览" }, { path: "/admin/users", expect: "/users", needle: "用户管理" }, { path: "/admin/files", expect: "/files", needle: "文件" }, { path: "/admin/knowledge", expect: "/knowledge-bases", needle: "知识库" }, { path: "/pilot", expect: "/pilot-readiness", needle: "试点" }, ]; const details = []; for (const item of checks) { const page = await context.newPage(); await page.goto(`${webBase}${item.path}`, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(1200); const body = await page.locator("body").innerText(); assert(page.url().includes(item.expect), `${item.path} should resolve to ${item.expect}`, { url: page.url() }); assert(body.includes(item.needle), `${item.path} should render expected content`, { body: body.slice(0, 1200), url: page.url() }); details.push({ path: item.path, finalUrl: page.url() }); await page.close(); } await context.close(); return { routes: details }; }); await step("BROWSER-001", "chat history failure shows explicit error state instead of empty state", async () => { const context = await createAuthenticatedContext(state.admin); const page = await context.newPage(); await page.route("**/api/v1/conversations?page=1&page_size=20", async (route) => { await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ code: 500, message: "模拟会话接口失败", data: null }), }); }); await page.goto(`${webBase}/chat`, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(1600); const body = await page.locator("body").innerText(); await page.screenshot({ path: screenshotPath("chat-history-error"), fullPage: true }); assert(body.includes("历史会话加载失败"), "chat page should show explicit history error", body.slice(0, 1800)); assert(!body.includes("还没有历史会话。"), "chat page should not mask 500 as empty history state", body.slice(0, 1800)); await context.close(); return { screenshot: "chat-history-error.png" }; }); await step("BROWSER-002", "knowledge base failure shows explicit error state instead of empty state", async () => { const context = await createAuthenticatedContext(state.admin); const page = await context.newPage(); await page.route("**/api/v1/knowledge-bases?page=1&page_size=20", async (route) => { await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ code: 500, message: "模拟知识库接口失败", data: null }), }); }); await page.goto(`${webBase}/knowledge-bases`, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(1600); const body = await page.locator("body").innerText(); await page.screenshot({ path: screenshotPath("knowledge-bases-error"), fullPage: true }); assert(body.includes("知识库列表加载失败"), "knowledge base page should show explicit list error", body.slice(0, 1800)); assert(!body.includes("还没有知识库。"), "knowledge base page should not mask 500 as empty state", body.slice(0, 1800)); await context.close(); return { screenshot: "knowledge-bases-error.png" }; }); 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, "dev-fix-action-regression-summary.json"), JSON.stringify(summary, null, 2), "utf8"); fs.writeFileSync( path.join(artifactDir, "dev-fix-action-regression-summary.md"), [ "# Dev Fix Action Regression", "", `- Run ID: \`${runId}\``, `- Web: \`${webBase}\``, `- API: \`${apiBase}\``, `- Passed: ${summary.passed}/${summary.total}`, "", "| ID | Status | Title |", "|---|---|---|", ...results.map((item) => `| ${item.id} | ${item.status} | ${item.title.replace(/\|/g, "/")} |`), ].join("\n"), "utf8", ); console.log(JSON.stringify(summary, null, 2)); await state.browser.close().catch(() => undefined); await state.api.dispose().catch(() => undefined); if (summary.failed > 0) { process.exitCode = 1; } } run().catch(async (error) => { const summary = { run_id: runId, web_base: webBase, api_base: apiBase, health_url: healthUrl, artifact_dir: artifactDir, total: results.length + 1, passed: results.filter((item) => item.status === "PASS").length, failed: results.filter((item) => item.status === "FAIL").length + 1, results: [ ...results, { id: "RUNNER", title: "unhandled runner error", status: "FAIL", error: error.message, details: error.details || {}, }, ], }; fs.writeFileSync(path.join(artifactDir, "dev-fix-action-regression-summary.json"), JSON.stringify(summary, null, 2), "utf8"); console.error(JSON.stringify(summary, null, 2)); await state.browser?.close().catch(() => undefined); await state.api?.dispose().catch(() => undefined); process.exit(1); });