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; });