3df85fdb04
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.
436 lines
16 KiB
JavaScript
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;
|
|
});
|