Files
ai-contract-platform/frontend/scripts/phase1-closed-loop-e2e.mjs
T

628 lines
25 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 = `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;
}
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isTransientRequestError(error) {
return /Timeout|ECONNRESET|ECONNREFUSED|ETIMEDOUT|socket hang up/i.test(error?.message || "");
}
async function resetApiContext() {
try {
await state.api?.dispose();
} catch {
// Ignore cleanup errors before creating a fresh API request context.
}
state.api = await request.newContext();
}
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 { retries = 2, authToken, headers: optionHeaders = {}, ...requestOptions } = options;
const headers = {
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
...optionHeaders,
};
let lastError = null;
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
const response = await state.api[method](apiUrl(route), { ...requestOptions, headers });
const text = await response.text();
let body = null;
if (text) {
try {
body = JSON.parse(text);
} catch {
body = text;
}
}
return { response, body, text };
} catch (error) {
lastError = error;
if (attempt >= retries || !isTransientRequestError(error)) {
throw error;
}
await resetApiContext();
await delay(500 * (attempt + 1));
}
}
throw lastError;
}
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,
timeout: 90000,
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,
timeout: 90000,
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 = [];
const ignoredConsoleErrors = [
"[antd:",
"Failed to load resource: the server responded with a status of 404 (Not Found)",
];
for (const route of pages) {
const errors = [];
page.removeAllListeners("console");
page.on("console", (message) => {
if (message.type() === "error" && !ignoredConsoleErrors.some((entry) => message.text().includes(entry))) {
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);