310 lines
12 KiB
JavaScript
310 lines
12 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 = `slice4-auto-${Date.now()}`;
|
|
const webBase = process.env.SLICE4_WEB_URL || "http://localhost:3007";
|
|
const apiBase = process.env.SLICE4_API_URL || "http://127.0.0.1:8000/api/v1";
|
|
const healthUrl = process.env.SLICE4_HEALTH_URL || "http://127.0.0.1:8000/health";
|
|
const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456";
|
|
const artifactDir =
|
|
process.env.SLICE4_ARTIFACT_DIR ||
|
|
path.resolve(__dirname, "..", "..", "project-docs", "qa-artifacts", "slice4-auto-review-closed-loop", stamp);
|
|
|
|
fs.mkdirSync(artifactDir, { recursive: true });
|
|
|
|
const results = [];
|
|
const state = {
|
|
admin: null,
|
|
api: null,
|
|
browser: null,
|
|
contexts: [],
|
|
contractId: null,
|
|
reviewId: null,
|
|
chatUrl: 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;
|
|
}
|
|
|
|
function buildVariableValues(template) {
|
|
return Object.fromEntries(
|
|
(template.variables || []).map((variable) => {
|
|
const label = variable.label || variable.name;
|
|
const value = variable.default_value || `${runId}-${label}`;
|
|
return [variable.name, value];
|
|
}),
|
|
);
|
|
}
|
|
|
|
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 },
|
|
);
|
|
await context.route("**/api/v1/**", async (route) => {
|
|
const browserRequest = route.request();
|
|
const browserUrl = new URL(browserRequest.url());
|
|
const targetUrl = apiUrl(browserUrl.pathname.replace(/^\/api\/v1/, "") + browserUrl.search);
|
|
const headers = { ...browserRequest.headers() };
|
|
delete headers.host;
|
|
|
|
const response = await state.api.fetch(targetUrl, {
|
|
method: browserRequest.method(),
|
|
headers,
|
|
data: browserRequest.postDataBuffer() ?? undefined,
|
|
});
|
|
await route.fulfill({ response });
|
|
});
|
|
state.contexts.push(context);
|
|
return context;
|
|
}
|
|
|
|
function decodeReviewContext(chatUrl) {
|
|
const url = new URL(chatUrl, webBase);
|
|
const raw = url.searchParams.get("review");
|
|
assert(raw, "chat URL missing review query", { chatUrl });
|
|
return JSON.parse(raw);
|
|
}
|
|
|
|
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", "default admin seed and login", async () => {
|
|
const seed = await apiRequest("post", "/auth/seed-admin");
|
|
expectOk(seed, "seed default admin");
|
|
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("CONTRACT-001", "contract generation triggers auto review", async () => {
|
|
const templateSeed = await apiRequest("post", "/templates/seed-defaults", {
|
|
authToken: state.admin.access_token,
|
|
});
|
|
expectOk(templateSeed, "seed default templates");
|
|
|
|
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 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",
|
|
options_json: JSON.stringify({ slice: "slice4", run_id: runId }),
|
|
},
|
|
});
|
|
expectOk(generated, "contract generate");
|
|
|
|
const contract = generated.body.data;
|
|
const autoReview = contract.auto_review;
|
|
assert(contract.id, "generated contract missing id", contract);
|
|
assert(contract.file_id, "generated contract missing file asset", contract);
|
|
assert(autoReview, "generated contract missing auto_review", contract);
|
|
assert(autoReview.review_id, "auto review missing review id", autoReview);
|
|
assert(autoReview.status === "completed", "auto review should complete", autoReview);
|
|
assert(autoReview.detail_url === `/reviews/${autoReview.review_id}`, "auto review detail URL mismatch", autoReview);
|
|
assert(autoReview.chat_url?.startsWith("/chat?review="), "auto review chat URL missing", autoReview);
|
|
assert(autoReview.report_download_url?.includes(`/reviews/${autoReview.review_id}/report/download`), "report download URL mismatch", autoReview);
|
|
|
|
state.contractId = contract.id;
|
|
state.reviewId = autoReview.review_id;
|
|
state.chatUrl = autoReview.chat_url;
|
|
return {
|
|
contract_id: state.contractId,
|
|
review_id: state.reviewId,
|
|
auto_review_status: autoReview.status,
|
|
current_stage: autoReview.current_stage,
|
|
risk_level: autoReview.risk_level,
|
|
};
|
|
});
|
|
|
|
await step("REVIEW-001", "auto review detail and result are reachable", async () => {
|
|
const detail = await apiRequest("get", `/reviews/${state.reviewId}`, { authToken: state.admin.access_token });
|
|
const result = await apiRequest("get", `/reviews/${state.reviewId}/result`, { authToken: state.admin.access_token });
|
|
expectOk(detail, "auto review detail");
|
|
expectOk(result, "auto review result");
|
|
assert(detail.body.data.current_stage === "report", "auto review should be in report stage", detail.body.data);
|
|
assert(detail.body.data.source_document?.document_id, "source document should keep generated file id", detail.body.data);
|
|
assert(result.body.data.workflow_state?.review_id === state.reviewId, "workflow state review id mismatch", result.body.data);
|
|
assert(result.body.data.ai_trace?.review_id === state.reviewId, "ai trace review id mismatch", result.body.data);
|
|
return {
|
|
review_status: detail.body.data.status,
|
|
source_document: detail.body.data.source_document?.document_id,
|
|
risk_level: result.body.data.risk_summary?.overall,
|
|
};
|
|
});
|
|
|
|
await step("CHAT-001", "auto review chat context binds review state", async () => {
|
|
const context = decodeReviewContext(state.chatUrl);
|
|
assert(context.reviewId === state.reviewId, "chat context review id mismatch", context);
|
|
assert(context.metadata?.focusType === "generated_contract", "chat context focus type mismatch", context.metadata);
|
|
assert(context.workflowState?.review_id === state.reviewId, "chat context workflow state missing review id", context.workflowState);
|
|
assert(context.aiTrace?.review_id === state.reviewId, "chat context ai trace missing review id", context.aiTrace);
|
|
assert(context.sourceDocument?.document_id, "chat context source document missing generated file id", context.sourceDocument);
|
|
return {
|
|
review_id: context.reviewId,
|
|
focus_type: context.metadata?.focusType,
|
|
prompt_preview: context.prompt?.slice(0, 80),
|
|
};
|
|
});
|
|
|
|
await step("REPORT-001", "auto review report is downloadable", async () => {
|
|
const report = await apiRequest("get", `/reviews/${state.reviewId}/report`, { authToken: state.admin.access_token });
|
|
expectOk(report, "auto review report metadata");
|
|
const download = await apiRequest("get", `/reviews/${state.reviewId}/report/download`, { authToken: state.admin.access_token });
|
|
assert(download.response.status() === 200, `report download HTTP ${download.response.status()}`, { body: download.body });
|
|
assert(download.text.length > 0, "report download returned empty body");
|
|
return {
|
|
file_name: report.body.data.file_name,
|
|
download_bytes: download.text.length,
|
|
};
|
|
});
|
|
|
|
await step("BROWSER-001", "contract detail exposes auto review actions", async () => {
|
|
const context = await createAuthenticatedContext(state.admin);
|
|
const page = await context.newPage();
|
|
await page.goto(`${webBase}/contracts/${state.contractId}`, { waitUntil: "domcontentloaded" });
|
|
await page.waitForTimeout(1600);
|
|
await page.screenshot({ path: screenshotPath("contract-detail-auto-review"), fullPage: true });
|
|
const body = await page.locator("body").innerText();
|
|
assert(body.includes("自动审查"), "contract detail missing auto review section", body.slice(0, 1800));
|
|
assert(body.includes("查看审查详情"), "contract detail missing review detail action", body.slice(0, 2200));
|
|
assert(body.includes("继续审查问答"), "contract detail missing chat action", body.slice(0, 2200));
|
|
assert(body.includes("下载审查报告"), "contract detail missing report action", body.slice(0, 2200));
|
|
return { screenshot: "contract-detail-auto-review.png" };
|
|
});
|
|
}
|
|
|
|
try {
|
|
await run();
|
|
} finally {
|
|
for (const context of state.contexts) {
|
|
await context.close().catch(() => {});
|
|
}
|
|
if (state.browser) {
|
|
await state.browser.close().catch(() => {});
|
|
}
|
|
if (state.api) {
|
|
await state.api.dispose().catch(() => {});
|
|
}
|
|
const summary = {
|
|
run_id: runId,
|
|
artifact_dir: artifactDir,
|
|
passed: results.filter((item) => item.status === "PASS").length,
|
|
failed: results.filter((item) => item.status === "FAIL").length,
|
|
results,
|
|
};
|
|
fs.writeFileSync(path.join(artifactDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8");
|
|
console.log(`Wrote ${path.join(artifactDir, "summary.json")}`);
|
|
if (summary.failed > 0) {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|