372 lines
16 KiB
JavaScript
372 lines
16 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 = `phase2-auto-${Date.now()}`;
|
|
const webBase = process.env.PHASE2_WEB_URL || "http://127.0.0.1:3005";
|
|
const apiBase = process.env.PHASE2_API_URL || `${webBase}/api/v1`;
|
|
const healthUrl = process.env.PHASE2_HEALTH_URL || apiBase.replace(/\/api\/v1\/?$/, "/health");
|
|
const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456";
|
|
const artifactDir =
|
|
process.env.PHASE2_ARTIFACT_DIR ||
|
|
path.resolve(__dirname, "..", "..", "..", "project-docs", "qa-artifacts", "phase2-closed-loop", stamp);
|
|
|
|
fs.mkdirSync(artifactDir, { recursive: true });
|
|
|
|
const results = [];
|
|
const state = {
|
|
admin: null,
|
|
browser: null,
|
|
contexts: [],
|
|
api: null,
|
|
reviewId: null,
|
|
knowledgeBaseId: null,
|
|
failedDocumentId: 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 },
|
|
);
|
|
state.contexts.push(context);
|
|
return context;
|
|
}
|
|
|
|
async function createReview() {
|
|
const text = [
|
|
`${runId} 阶段2审查生命周期样本。`,
|
|
"合同约定自动续约,并包含免责、付款条件另行协商和50%违约金。",
|
|
"请重点关注风险解释、后续问答和合同生成衔接。",
|
|
].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 review");
|
|
state.reviewId = create.body.data.id;
|
|
return create.body.data.id;
|
|
}
|
|
|
|
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);
|
|
assert(state.admin.user.role === "admin", "admin role mismatch", state.admin.user);
|
|
return { user: state.admin.user.username, role: state.admin.user.role };
|
|
});
|
|
|
|
await step("REVIEW-LC-001", "review lifecycle fields and report readiness", async () => {
|
|
const reviewId = await createReview();
|
|
const detail = await apiRequest("get", `/reviews/${reviewId}`, { authToken: state.admin.access_token });
|
|
const result = await apiRequest("get", `/reviews/${reviewId}/result`, { authToken: state.admin.access_token });
|
|
expectOk(detail, "review detail");
|
|
expectOk(result, "review result");
|
|
assert(detail.body.data.current_stage === "report", "review current stage should be report", detail.body.data);
|
|
assert(detail.body.data.parse_status === "completed", "parse_status should be completed", detail.body.data);
|
|
assert(detail.body.data.report_status === "ready", "report_status should be ready", detail.body.data);
|
|
assert(detail.body.data.next_action?.includes("审查助手"), "review next_action should point to copilot", detail.body.data);
|
|
assert(result.body.data.risk_summary.overall === "high", "risk summary should be high", result.body.data);
|
|
return {
|
|
review_id: reviewId,
|
|
lifecycle: {
|
|
current_stage: detail.body.data.current_stage,
|
|
parse_status: detail.body.data.parse_status,
|
|
review_status: detail.body.data.review_status,
|
|
report_status: detail.body.data.report_status,
|
|
},
|
|
risk_summary: result.body.data.risk_summary,
|
|
};
|
|
});
|
|
|
|
await step("KB-RETRY-001", "knowledge document retry and replacement file closes lifecycle", async () => {
|
|
const create = await apiRequest("post", "/knowledge-bases", {
|
|
authToken: state.admin.access_token,
|
|
multipart: { name: `${runId} KB`, description: "Phase 2 retry lifecycle" },
|
|
});
|
|
expectOk(create, "create knowledge base");
|
|
state.knowledgeBaseId = create.body.data.id;
|
|
|
|
const failed = await apiRequest("post", `/knowledge-bases/${state.knowledgeBaseId}/documents`, {
|
|
authToken: state.admin.access_token,
|
|
multipart: {
|
|
file: { name: `${runId}-empty.txt`, mimeType: "text/plain", buffer: Buffer.from("") },
|
|
},
|
|
});
|
|
expectOk(failed, "upload failed document");
|
|
state.failedDocumentId = failed.body.data.id;
|
|
assert(failed.body.data.status === "failed", "empty document should fail", failed.body.data);
|
|
|
|
const retryWithoutFile = await apiRequest("post", `/knowledge-bases/${state.knowledgeBaseId}/documents/${state.failedDocumentId}/retry`, {
|
|
authToken: state.admin.access_token,
|
|
multipart: { note: "retry without replacement" },
|
|
});
|
|
expectOk(retryWithoutFile, "retry without file");
|
|
assert(retryWithoutFile.body.data.status === "failed", "retry without source should remain failed", retryWithoutFile.body.data);
|
|
assert(retryWithoutFile.body.data.error_message?.includes("重新上传"), "retry should explain reupload", retryWithoutFile.body.data);
|
|
|
|
const replacement = await apiRequest("post", `/knowledge-bases/${state.knowledgeBaseId}/documents/${state.failedDocumentId}/retry`, {
|
|
authToken: state.admin.access_token,
|
|
multipart: {
|
|
note: "retry with replacement",
|
|
file: {
|
|
name: `${runId}-replacement.txt`,
|
|
mimeType: "text/plain",
|
|
buffer: Buffer.from("阶段2知识库替换文档:付款、保密、违约责任和自动续约。", "utf8"),
|
|
},
|
|
},
|
|
});
|
|
expectOk(replacement, "retry with replacement file");
|
|
assert(replacement.body.data.id === state.failedDocumentId, "replacement retry should keep document id", replacement.body.data);
|
|
assert(replacement.body.data.status === "indexed", "replacement retry should index document", replacement.body.data);
|
|
assert(replacement.body.data.index_status === "completed", "replacement retry index_status mismatch", replacement.body.data);
|
|
|
|
const audits = await apiRequest("get", "/audit-logs?page=1&page_size=10&action=retry_knowledge_document", {
|
|
authToken: state.admin.access_token,
|
|
});
|
|
expectOk(audits, "audit retry_knowledge_document");
|
|
assert(audits.body.data.items.length >= 1, "retry audit event missing", audits.body.data);
|
|
return {
|
|
kb_id: state.knowledgeBaseId,
|
|
document_id: state.failedDocumentId,
|
|
retry_without_file_status: retryWithoutFile.body.data.status,
|
|
replacement_status: replacement.body.data.status,
|
|
retry_audit_total: audits.body.data.total,
|
|
};
|
|
});
|
|
|
|
await step("BROWSER-REVIEW-001", "review detail links to chat and contract context", async () => {
|
|
const context = await createAuthenticatedContext(state.admin);
|
|
const page = await context.newPage();
|
|
await page.goto(`${webBase}/reviews/${state.reviewId}`, { waitUntil: "domcontentloaded" });
|
|
await page.waitForTimeout(1600);
|
|
const body = await page.locator("body").innerText();
|
|
assert(body.includes("QUICK ACTIONS") || body.includes("围绕当前审查结果"), "review detail quick actions missing", body.slice(0, 800));
|
|
assert(body.includes("用审查结果准备合同生成"), "contract context action missing", body.slice(0, 1200));
|
|
|
|
const chatHref = await page.locator('a:has-text("生成修改清单")').first().getAttribute("href");
|
|
assert(chatHref?.includes("/chat?review="), "chat quick action should carry review context", { chatHref });
|
|
await page.goto(new URL(chatHref, webBase).toString(), { waitUntil: "domcontentloaded" });
|
|
await page.waitForTimeout(2000);
|
|
await page.screenshot({ path: screenshotPath("review-chat-context-followup"), fullPage: true });
|
|
const chatBody = await page.locator("body").innerText();
|
|
assert(chatBody.includes("法律问答") || chatBody.includes("审查助手"), "chat page should open from review context", chatBody.slice(0, 1000));
|
|
|
|
await page.goto(`${webBase}/reviews/${state.reviewId}`, { waitUntil: "domcontentloaded" });
|
|
await page.waitForTimeout(1000);
|
|
const contractHref = await page.locator('a:has-text("用审查结果准备合同生成")').first().getAttribute("href");
|
|
assert(contractHref?.includes("/contracts?review="), "contract action should carry review context", { contractHref });
|
|
await page.goto(new URL(contractHref, webBase).toString(), { waitUntil: "domcontentloaded" });
|
|
await page.waitForTimeout(1600);
|
|
const contractBody = await page.locator("body").innerText();
|
|
await page.screenshot({ path: screenshotPath("contract-review-context-consumed"), fullPage: true });
|
|
assert(page.url().includes("/contracts?review="), "contract context URL missing review payload", { url: page.url() });
|
|
assert(contractBody.includes("已接收审查上下文") || contractBody.includes("正在消费审查上下文"), "contract page did not consume review context", contractBody.slice(0, 1000));
|
|
return {
|
|
chat_href_contains_review: chatHref.includes("/chat?review="),
|
|
contract_url_contains_review: page.url().includes("/contracts?review="),
|
|
screenshots: ["review-chat-context-followup.png", "contract-review-context-consumed.png"],
|
|
};
|
|
});
|
|
|
|
await step("BROWSER-KB-001", "knowledge base failed document exposes retry action", async () => {
|
|
const failedAgain = await apiRequest("post", `/knowledge-bases/${state.knowledgeBaseId}/documents`, {
|
|
authToken: state.admin.access_token,
|
|
multipart: {
|
|
file: { name: `${runId}-retry-ui-empty.txt`, mimeType: "text/plain", buffer: Buffer.from("") },
|
|
},
|
|
});
|
|
expectOk(failedAgain, "upload UI failed document");
|
|
|
|
const context = await createAuthenticatedContext(state.admin);
|
|
const page = await context.newPage();
|
|
await page.goto(`${webBase}/knowledge-bases`, { waitUntil: "domcontentloaded" });
|
|
await page.waitForTimeout(2200);
|
|
let body = await page.locator("body").innerText();
|
|
assert(body.includes("重试处理"), "retry button should be visible for failed document", body.slice(-1400));
|
|
await page.getByText("重试处理").first().click();
|
|
await page.waitForTimeout(1800);
|
|
body = await page.locator("body").innerText();
|
|
await page.screenshot({ path: screenshotPath("kb-retry-action-visible"), fullPage: true });
|
|
assert(body.includes("重新上传"), "retry result should explain reupload path", body.slice(-1400));
|
|
const disabledLinks = await page.locator("a.disabled-link[aria-disabled='true']").count();
|
|
assert(disabledLinks >= 2, "failed document chat actions should remain disabled", { disabledLinks });
|
|
return { screenshot: "kb-retry-action-visible.png", disabled_links: disabledLinks };
|
|
});
|
|
|
|
await step("BROWSER-HOME-001", "home cockpit exposes delivery workbench", async () => {
|
|
const context = await createAuthenticatedContext(state.admin);
|
|
const page = await context.newPage();
|
|
await page.goto(`${webBase}/`, { waitUntil: "domcontentloaded" });
|
|
await page.waitForTimeout(1600);
|
|
await page.screenshot({ path: screenshotPath("home-delivery-cockpit"), fullPage: true });
|
|
const body = await page.locator("body").innerText();
|
|
assert(body.includes("优先处理摘要"), "home priority summary missing", body.slice(0, 1400));
|
|
assert(body.includes("运营摘要"), "home operations summary missing", body.slice(0, 1400));
|
|
assert(body.includes("管理员高频入口"), "home admin workbench missing", body.slice(0, 1800));
|
|
return { screenshot: "home-delivery-cockpit.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, "phase2-closed-loop-summary.json"), JSON.stringify(summary, null, 2), "utf8");
|
|
const markdown = [
|
|
"# Phase 2 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, "phase2-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);
|