Files
ai-contract-platform/frontend/scripts/codex-visual-audit.mjs
yuqei 3df85fdb04 feat: land phase15 legal docs and commercial ops
Includes legal document generation workflows, prompt governance runtime wiring, legal change impact scans, operation KPI dashboards, invoice files, sales follow-up events, mobile review polish, deployment automation, and updated project docs.
2026-05-21 14:42:44 +08:00

436 lines
16 KiB
JavaScript

import { chromium } from "playwright";
import fs from "node:fs/promises";
import path from "node:path";
const baseUrl = process.env.CODEX_AUDIT_WEB_URL || "http://127.0.0.1:4302";
const outDir = process.env.CODEX_AUDIT_OUT_DIR || "D:/DevCaches/Temp/ai-contract-deep-audit";
const adminAccount = process.env.CODEX_AUDIT_ADMIN_ACCOUNT || "admin@example.com";
const adminPassword =
process.env.CODEX_AUDIT_ADMIN_PASSWORD ||
(await fs.readFile("C:/Users/yuqei-win/.ai-contract-platform/production-admin-password.txt", "utf8")).trim();
const publicRoutes = ["/login", "/demo-request", "/pricing"];
const privateRoutes = [
"/",
"/review",
"/reviews",
"/contracts",
"/chat",
"/governance",
"/governance/imports",
"/governance/releases",
"/governance/workbench",
"/knowledge-bases",
"/legal-articles",
"/files",
"/rules",
"/templates",
"/users",
"/tenant-settings",
"/audit-logs",
"/pilot-readiness",
"/pilot-requests",
"/profile",
];
const viewports = [
{ name: "desktop", width: 1440, height: 960 },
{ name: "mobile", width: 390, height: 844 },
];
function slug(route) {
return route.replace(/^\/$/, "home").replace(/^\//, "").replace(/[^a-zA-Z0-9_-]+/g, "-").slice(0, 90) || "home";
}
function findItems(json) {
const roots = [json?.data?.items, json?.data?.records, json?.data?.list, json?.items, json?.records, json?.data, json];
return roots.find((item) => Array.isArray(item)) || [];
}
function firstId(json) {
return findItems(json).find((item) => item?.id)?.id || null;
}
async function login() {
const response = await fetch(`${baseUrl}/api/v1/auth/login`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ account: adminAccount, password: adminPassword }),
});
const json = await response.json();
const data = json.data || json;
return {
raw: json,
token: data.access_token || data.token,
user: data.user || null,
};
}
async function api(pathname, token) {
const response = await fetch(`${baseUrl}${pathname}`, {
headers: { authorization: `Bearer ${token}` },
});
let json = null;
try {
json = await response.json();
} catch {
json = null;
}
return { ok: response.ok, status: response.status, json };
}
async function evaluateVisual(page) {
return page.evaluate(async () => {
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const visible = (element) => {
const style = getComputedStyle(element);
if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) {
return false;
}
const rect = element.getBoundingClientRect();
return rect.width > 1 && rect.height > 1 && rect.bottom > 0 && rect.right > 0 && rect.top < innerHeight && rect.left < innerWidth;
};
const label = (element) => (element.innerText || element.value || element.placeholder || element.textContent || "").replace(/\s+/g, " ").trim();
const allowedOverflow = (element) =>
Boolean(
element.closest(
'.ant-table-content,.ant-table-body,.app-nav,.review-detail-anchor-nav,.ant-tabs-nav-list,.review-page-highlight-list,.auth-highlight-list,.monaco-editor,.cm-editor,[data-allow-overflow="true"]',
),
);
const colorParts = (value) => {
const match = String(value || "").match(/rgba?\(([^)]+)\)/);
if (!match) {
return null;
}
const parts = match[1].split(",").map((item) => Number(String(item).trim().replace("%", "")));
if (parts.length < 3 || parts.slice(0, 3).some(Number.isNaN)) {
return null;
}
return [parts[0], parts[1], parts[2], parts.length > 3 ? parts[3] : 1];
};
const luminance = ([red, green, blue]) => {
const values = [red, green, blue].map((item) => {
const value = item / 255;
return value <= 0.03928 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4);
});
return 0.2126 * values[0] + 0.7152 * values[1] + 0.0722 * values[2];
};
const contrastRatio = (foreground, background) => {
const fg = colorParts(foreground);
const bg = colorParts(background);
if (!fg || !bg || bg[3] === 0) {
return null;
}
const first = luminance(fg);
const second = luminance(bg);
return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
};
const solidBackgroundFor = (element) => {
let current = element;
for (let index = 0; current && index < 6; index += 1, current = current.parentElement) {
const style = getComputedStyle(current);
const color = colorParts(style.backgroundColor);
if (color && color[3] > 0.85) {
return style.backgroundColor;
}
}
return "rgb(255,255,255)";
};
const effectiveBackgroundFor = (element) => {
const style = getComputedStyle(element);
const color = colorParts(style.backgroundColor);
return color && color[3] > 0.85 ? style.backgroundColor : solidBackgroundFor(element.parentElement || element);
};
const collectIssues = () => {
const issues = [];
const pageWidth = Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth || 0);
if (pageWidth > innerWidth + 6) {
issues.push({ type: "page-horizontal-overflow", detail: `${pageWidth}px > ${innerWidth}px` });
}
const allElements = Array.from(document.body.querySelectorAll("*")).filter(visible);
for (const element of allElements) {
if (allowedOverflow(element)) {
continue;
}
const rect = element.getBoundingClientRect();
if (rect.right > innerWidth + 7 || rect.left < -7) {
issues.push({
type: "element-overflow",
tag: element.tagName.toLowerCase(),
className: String(element.className || "").slice(0, 80),
text: label(element).slice(0, 80),
rect: { left: Math.round(rect.left), right: Math.round(rect.right), width: Math.round(rect.width) },
});
if (issues.filter((issue) => issue.type === "element-overflow").length >= 8) {
break;
}
}
}
const controls = Array.from(
document.querySelectorAll("button,.ant-btn,.ant-tag,.ant-tabs-tab-btn,.ant-select-selection-item,.ant-picker-input input,input,textarea"),
).filter(visible);
for (const element of controls) {
const text = label(element);
if (!text) {
continue;
}
if ((element.scrollWidth || 0) > (element.clientWidth || 0) + 3 || (element.scrollHeight || 0) > (element.clientHeight || 0) + 3) {
issues.push({
type: "text-clipped",
tag: element.tagName.toLowerCase(),
className: String(element.className || "").slice(0, 80),
text: text.slice(0, 80),
size: `${element.clientWidth}x${element.clientHeight}`,
scroll: `${element.scrollWidth}x${element.scrollHeight}`,
});
}
}
const buttons = Array.from(document.querySelectorAll("button,.ant-btn")).filter(visible);
for (const element of buttons) {
const text = label(element);
const style = getComputedStyle(element);
if (!text || text.length > 80 || (style.backgroundImage && style.backgroundImage !== "none")) {
continue;
}
const ratio = contrastRatio(style.color, effectiveBackgroundFor(element));
if (ratio !== null && ratio < 3) {
issues.push({
type: "low-button-contrast",
text: text.slice(0, 80),
contrast: Number(ratio.toFixed(2)),
color: style.color,
bg: effectiveBackgroundFor(element),
});
}
}
return issues;
};
const positions = [0, Math.max(0, (document.documentElement.scrollHeight - innerHeight) / 2), Math.max(0, document.documentElement.scrollHeight - innerHeight)];
const byScroll = [];
for (const position of positions) {
scrollTo(0, position);
await wait(120);
byScroll.push({ y: Math.round(scrollY), issues: collectIssues() });
}
scrollTo(0, 0);
await wait(80);
const focusIssues = [];
const candidates = Array.from(
document.querySelectorAll("input:not([type=hidden]):not([disabled]), textarea:not([disabled]), .ant-select-selector, .ant-picker, .ant-input-affix-wrapper"),
)
.filter(visible)
.slice(0, 14);
for (const candidate of candidates) {
const focusTarget = candidate.matches("input,textarea") ? candidate : candidate.querySelector("input,textarea") || candidate;
try {
focusTarget.focus();
} catch {
continue;
}
await wait(180);
const wrapper = focusTarget.closest(".ant-input-affix-wrapper,.ant-select-selector,.ant-picker,.ant-input-number,.ant-input") || focusTarget;
const wrapperStyle = getComputedStyle(wrapper);
const inputStyle = getComputedStyle(focusTarget);
const innerHasOwnRing =
focusTarget !== wrapper &&
(inputStyle.boxShadow !== "none" ||
(inputStyle.outlineStyle !== "none" && parseFloat(inputStyle.outlineWidth || "0") > 0) ||
parseFloat(inputStyle.borderTopWidth || "0") > 0);
if (innerHasOwnRing && focusTarget.closest(".ant-input-affix-wrapper")) {
focusIssues.push({
type: "nested-input-double-ring",
placeholder: focusTarget.getAttribute("placeholder") || "",
outerClass: String(wrapper.className || "").slice(0, 80),
inner: { boxShadow: inputStyle.boxShadow, outline: inputStyle.outline, border: inputStyle.borderTopWidth },
});
}
if (wrapperStyle.outlineStyle !== "none" && parseFloat(wrapperStyle.outlineWidth || "0") > 0 && wrapperStyle.boxShadow !== "none") {
focusIssues.push({
type: "focus-outline-and-shadow",
placeholder: focusTarget.getAttribute("placeholder") || "",
outline: wrapperStyle.outline,
shadow: wrapperStyle.boxShadow,
});
}
}
if (location.pathname === "/login") {
const inputs = Array.from(document.querySelectorAll("input")).filter(visible);
const accountInput = inputs.find((input) => input.type !== "password");
const passwordInput = inputs.find((input) => input.type === "password");
const metric = async (input) => {
if (!input) {
return null;
}
input.focus();
await wait(220);
const wrapper = input.closest(".ant-input-affix-wrapper") || input;
const style = getComputedStyle(wrapper);
const rect = wrapper.getBoundingClientRect();
return {
height: Math.round(rect.height),
border: `${style.borderTopColor} ${style.borderTopWidth}`,
shadow: style.boxShadow,
radius: style.borderTopLeftRadius,
};
};
const account = await metric(accountInput);
const password = await metric(passwordInput);
if (
account &&
password &&
(Math.abs(account.height - password.height) > 2 ||
account.border !== password.border ||
account.shadow !== password.shadow ||
account.radius !== password.radius)
) {
focusIssues.push({ type: "login-focus-style-mismatch", account, password });
}
}
return {
byScroll,
focusIssues,
path: location.pathname,
title: document.title,
height: document.documentElement.scrollHeight,
};
});
}
async function auditOne(browser, route, viewport, authenticated, auth) {
const context = await browser.newContext({
viewport: { width: viewport.width, height: viewport.height },
deviceScaleFactor: 1,
isMobile: viewport.name === "mobile",
});
if (authenticated) {
await context.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token);
localStorage.setItem("auth-store", JSON.stringify({ state: { accessToken: token, currentUser: user }, version: 0 }));
},
{ token: auth.token, user: auth.user },
);
}
const page = await context.newPage();
const messages = [];
page.on("console", (message) => {
if (["error", "warning"].includes(message.type())) {
messages.push({ type: message.type(), text: message.text().slice(0, 500) });
}
});
page.on("pageerror", (error) => messages.push({ type: "pageerror", text: String(error.message || error).slice(0, 500) }));
let status = null;
let navError = null;
let visual = null;
try {
const response = await page.goto(`${baseUrl}${route}`, { waitUntil: "domcontentloaded", timeout: 45000 });
status = response?.status() || null;
await page.waitForLoadState("networkidle", { timeout: 7000 }).catch(() => {});
await page.waitForTimeout(1200);
visual = await evaluateVisual(page);
} catch (error) {
navError = String(error.message || error).slice(0, 500);
}
const issueCount =
(visual?.byScroll || []).reduce((count, item) => count + item.issues.length, 0) +
(visual?.focusIssues?.length || 0) +
messages.length +
(navError ? 1 : 0) +
(status && status >= 400 ? 1 : 0);
let screenshot = null;
if (issueCount > 0) {
screenshot = path.join(outDir, `${viewport.name}-${slug(route)}.png`).replace(/\\/g, "/");
await page.screenshot({ path: screenshot, fullPage: false }).catch(() => {});
}
await context.close();
return { route, viewport: viewport.name, status, navError, messages, visual, screenshot, issueCount };
}
async function main() {
await fs.rm(outDir, { recursive: true, force: true }).catch(() => {});
await fs.mkdir(outDir, { recursive: true });
const auth = await login();
if (!auth.token) {
throw new Error(`No access token returned from ${baseUrl}/api/v1/auth/login`);
}
const dynamic = {};
for (const [key, endpoint] of Object.entries({
review: "/api/v1/reviews?page=1&page_size=1",
contract: "/api/v1/contracts?page=1&page_size=1",
asset: "/api/v1/governance/assets?page=1&page_size=1",
})) {
const response = await api(endpoint, auth.token);
dynamic[key] = { status: response.status, id: firstId(response.json) };
}
const routes = [...privateRoutes];
if (dynamic.review.id) {
routes.push(`/reviews/${dynamic.review.id}`);
}
if (dynamic.contract.id) {
routes.push(`/contracts/${dynamic.contract.id}`);
}
if (dynamic.asset.id) {
routes.push(`/governance/assets/${dynamic.asset.id}`);
}
const browser = await chromium.launch({ headless: true });
const results = [];
try {
for (const viewport of viewports) {
for (const route of publicRoutes) {
results.push(await auditOne(browser, route, viewport, false, auth));
}
for (const route of routes) {
results.push(await auditOne(browser, route, viewport, true, auth));
}
}
} finally {
await browser.close();
}
const failures = results.filter((result) => result.issueCount > 0);
const summary = {
proxyLogin: true,
dynamic,
checked: results.length,
failures: failures.length,
topIssues: failures.slice(0, 80).map((result) => ({
route: result.route,
viewport: result.viewport,
status: result.status,
messages: result.messages,
focusIssues: result.visual?.focusIssues || [],
scrollIssues: (result.visual?.byScroll || []).flatMap((scroll) => scroll.issues.map((issue) => ({ y: scroll.y, ...issue }))).slice(0, 12),
screenshot: result.screenshot,
})),
};
await fs.writeFile(path.join(outDir, "audit-results.json"), JSON.stringify({ summary, results }, null, 2), "utf8");
console.log(JSON.stringify(summary, null, 2));
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});