Files
ai-contract-platform/frontend/scripts/dev-fix-action-regression.mjs
T
2026-05-03 19:09:26 +08:00

287 lines
10 KiB
JavaScript

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);
});