267 lines
11 KiB
JavaScript
267 lines
11 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 = `shared-ai-remote-${Date.now()}`;
|
|
const webBase = process.env.SHARED_AI_REMOTE_WEB_URL || "http://192.168.3.90:3003";
|
|
const apiBase = process.env.SHARED_AI_REMOTE_API_URL || "http://192.168.3.90:8001/api/v1";
|
|
const healthUrl = process.env.SHARED_AI_REMOTE_HEALTH_URL || "http://192.168.3.90:8001/health";
|
|
const readinessUrl = process.env.SHARED_AI_REMOTE_READINESS_URL || "http://192.168.3.90:8001/api/v1/system/readiness";
|
|
const sharedAiHealthUrl = process.env.SHARED_AI_REMOTE_SHARED_HEALTH_URL || "http://192.168.3.90:18011/health";
|
|
const sharedAiProviderStatusUrl = process.env.SHARED_AI_REMOTE_PROVIDER_STATUS_URL || "http://192.168.3.90:18011/api/v0.2/system/platform-readiness";
|
|
const artifactDir =
|
|
process.env.SHARED_AI_REMOTE_ARTIFACT_DIR ||
|
|
path.resolve(__dirname, "..", "..", "project-docs", "qa-artifacts", "shared-ai-remote-browser-e2e", stamp);
|
|
|
|
fs.mkdirSync(artifactDir, { recursive: true });
|
|
|
|
const results = [];
|
|
const state = {
|
|
api: null,
|
|
browser: null,
|
|
member: null,
|
|
contexts: [],
|
|
};
|
|
|
|
function screenshotPath(name) {
|
|
return path.join(artifactDir, `${name}.png`);
|
|
}
|
|
|
|
function apiUrl(route) {
|
|
return `${apiBase.replace(/\/$/, "")}${route}`;
|
|
}
|
|
|
|
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 run() {
|
|
state.api = await request.newContext();
|
|
state.browser = await chromium.launch({ headless: true });
|
|
|
|
await step("ENV-001", "remote frontend, backend and shared-ai health", async () => {
|
|
const [frontend, backendHealth, readiness, sharedAiHealth, providerStatus] = await Promise.all([
|
|
state.api.get(`${webBase}/login`),
|
|
state.api.get(healthUrl),
|
|
state.api.get(readinessUrl),
|
|
state.api.get(sharedAiHealthUrl),
|
|
state.api.get(sharedAiProviderStatusUrl),
|
|
]);
|
|
assert(frontend.status() === 200, `frontend /login status ${frontend.status()}`);
|
|
assert(backendHealth.status() === 200, `backend /health status ${backendHealth.status()}`);
|
|
assert(readiness.status() === 200, `system readiness status ${readiness.status()}`);
|
|
assert(sharedAiHealth.status() === 200, `shared-ai /health status ${sharedAiHealth.status()}`);
|
|
assert(providerStatus.status() === 200, `provider-status status ${providerStatus.status()}`);
|
|
const providerStatusBody = await providerStatus.json();
|
|
assert(providerStatusBody.data?.provider_default === "openai_compatible", "provider_default should be openai_compatible", providerStatusBody);
|
|
assert(providerStatusBody.data?.provider_openai_compatible_enabled === true, "openai_compatible provider should be enabled", providerStatusBody);
|
|
return { webBase, apiBase, healthUrl, readinessUrl, sharedAiHealthUrl, sharedAiProviderStatusUrl, artifactDir };
|
|
});
|
|
|
|
await step("AUTH-001", "register a fresh member for browser acceptance", async () => {
|
|
const username = `${runId}@example.com`;
|
|
const password = "SharedAi123!";
|
|
const displayName = "Shared AI Remote QA";
|
|
const register = await apiRequest("post", "/auth/register", {
|
|
data: { username, password, display_name: displayName },
|
|
});
|
|
expectOk(register, "register remote member");
|
|
state.member = await login(username, password);
|
|
return { username, displayName, role: state.member.user.role };
|
|
});
|
|
|
|
await step("BROWSER-REVIEW-001", "submit contract review from the remote web page", async () => {
|
|
const context = await createAuthenticatedContext(state.member);
|
|
const page = await context.newPage();
|
|
await page.goto(`${webBase}/review`, { waitUntil: "domcontentloaded" });
|
|
await page.waitForTimeout(1800);
|
|
|
|
const contractText = "This agreement includes auto-renewal, limitation of liability, payment milestones, confidentiality obligations, and liquidated damages. Please produce a structured legal review result in Chinese.";
|
|
const createResponsePromise = page.waitForResponse((response) => response.url().endsWith("/api/v1/reviews") && response.request().method() === "POST" && response.status() === 200, { timeout: 120000 });
|
|
|
|
await page.locator("textarea").fill(contractText);
|
|
await page.getByRole("button", { name: /提交审查/ }).click();
|
|
const createResponse = await createResponsePromise;
|
|
const createBody = await createResponse.json();
|
|
assert(createBody.code === 0, "review create API should succeed", createBody);
|
|
const reviewId = createBody.data.id;
|
|
|
|
let navigatedViaRouter = true;
|
|
try {
|
|
await page.waitForURL(new RegExp(`/reviews/${reviewId}$`), { timeout: 20000 });
|
|
} catch {
|
|
navigatedViaRouter = false;
|
|
await page.goto(`${webBase}/reviews/${reviewId}`, { waitUntil: "domcontentloaded" });
|
|
}
|
|
|
|
const resultResponse = await page.waitForResponse((response) => response.url().includes(`/api/v1/reviews/${reviewId}/result`) && response.status() === 200, { timeout: 120000 });
|
|
|
|
const resultBody = await resultResponse.json();
|
|
assert(resultBody.code === 0, "review result API should succeed", resultBody);
|
|
assert(resultBody.data.risk_summary.overall !== "low", "english contract should not degrade to low-risk empty result", resultBody.data);
|
|
assert(["manual_review", "proceed_with_revision", "proceed"].includes(resultBody.data.recommendation), "recommendation should remain on stable enum", resultBody.data);
|
|
assert((resultBody.data.risk_items || []).length >= 3, "risk items should be populated for english contract", resultBody.data);
|
|
|
|
const detailBody = await page.locator("body").innerText();
|
|
assert(detailBody.includes("风险总览"), "detail page should render risk overview section", detailBody.slice(0, 2000));
|
|
assert(detailBody.includes("高风险") || detailBody.includes("中风险"), "detail page should render risk labels", detailBody.slice(0, 2500));
|
|
await page.screenshot({ path: screenshotPath("shared-ai-remote-review-detail"), fullPage: true });
|
|
await context.close();
|
|
return {
|
|
screenshot: "shared-ai-remote-review-detail.png",
|
|
url: page.url(),
|
|
review_id: reviewId,
|
|
navigated_via_router: navigatedViaRouter,
|
|
risk_summary: resultBody.data.risk_summary,
|
|
recommendation: resultBody.data.recommendation,
|
|
confidence_summary: resultBody.data.confidence_summary,
|
|
};
|
|
});
|
|
}
|
|
|
|
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,
|
|
readiness_url: readinessUrl,
|
|
shared_ai_health_url: sharedAiHealthUrl,
|
|
shared_ai_provider_status_url: sharedAiProviderStatusUrl,
|
|
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, "shared-ai-remote-browser-e2e-summary.json"), JSON.stringify(summary, null, 2), "utf8");
|
|
const markdown = [
|
|
"# Shared AI Remote Browser E2E",
|
|
"",
|
|
`- Run ID: \`${runId}\``,
|
|
`- Web: \`${webBase}\``,
|
|
`- API: \`${apiBase}\``,
|
|
`- Shared AI: \`${sharedAiHealthUrl}\``,
|
|
`- 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, "shared-ai-remote-browser-e2e-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,
|
|
details: error.details || {},
|
|
});
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
})
|
|
.finally(finalize);
|