import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import net from "node:net"; import os from "node:os"; import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const screenshotDir = path.join(__dirname, "screenshots"); const baseUrl = process.env.BRAINWAVE_PROTOTYPE_URL ?? "http://127.0.0.1:4173/prototype/"; const cases = [ { view: "welcome", file: "01-welcome.png", fullPage: true }, { view: "question", file: "02-question.png", fullPage: true }, { view: "casting", file: "03-casting.png", fullPage: true }, { view: "result", file: "04-result-moving.png", fullPage: true }, { view: "static", file: "05-result-static.png", fullPage: true }, { view: "consent", file: "06-ai-consent.png", fullPage: false }, { view: "explanation", file: "07-ai-explanation.png", fullPage: true }, { view: "error", file: "08-ai-error.png", fullPage: true } ]; function findChrome() { const candidates = process.platform === "win32" ? [ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe" ] : process.platform === "darwin" ? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"] : ["/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"]; const executable = candidates.find(existsSync); if (!executable) { throw new Error("未找到 Chrome/Edge。请安装浏览器,或扩展 capture.mjs 中的候选路径。"); } return executable; } function getFreePort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.unref(); server.on("error", reject); server.listen(0, "127.0.0.1", () => { const address = server.address(); server.close(() => resolve(address.port)); }); }); } function delay(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } async function waitForDebugTarget(port) { const endpoint = `http://127.0.0.1:${port}/json/list`; for (let attempt = 0; attempt < 50; attempt += 1) { try { const response = await fetch(endpoint); if (response.ok) { const targets = await response.json(); const page = targets.find((target) => target.type === "page"); if (page?.webSocketDebuggerUrl) return page.webSocketDebuggerUrl; } } catch { // Chrome may still be starting; retry within the bounded loop. } await delay(100); } throw new Error("Chrome DevTools endpoint did not become ready."); } class CdpClient { constructor(webSocketUrl) { this.socket = new WebSocket(webSocketUrl); this.nextId = 1; this.pending = new Map(); this.listeners = new Map(); } async connect() { await new Promise((resolve, reject) => { this.socket.addEventListener("open", resolve, { once: true }); this.socket.addEventListener("error", reject, { once: true }); }); this.socket.addEventListener("message", (event) => this.#handleMessage(event)); } #handleMessage(event) { const message = JSON.parse(String(event.data)); if (message.id) { const pending = this.pending.get(message.id); if (!pending) return; this.pending.delete(message.id); if (message.error) pending.reject(new Error(message.error.message)); else pending.resolve(message.result); return; } const listeners = this.listeners.get(message.method) ?? []; for (const listener of listeners) listener(message.params); } send(method, params = {}) { const id = this.nextId; this.nextId += 1; return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); this.socket.send(JSON.stringify({ id, method, params })); }); } on(method, listener) { const listeners = this.listeners.get(method) ?? []; listeners.push(listener); this.listeners.set(method, listeners); } waitFor(method, timeoutMs = 5000) { return new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${method}`)), timeoutMs); const listener = (params) => { clearTimeout(timeout); const listeners = this.listeners.get(method) ?? []; this.listeners.set(method, listeners.filter((candidate) => candidate !== listener)); resolve(params); }; this.on(method, listener); }); } close() { this.socket.close(); } } async function evaluate(client, expression) { const result = await client.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }); if (result.exceptionDetails) { throw new Error(result.exceptionDetails.text ?? "Runtime evaluation failed."); } return result.result.value; } async function navigate(client, view) { const loaded = client.waitFor("Page.loadEventFired"); await client.send("Page.navigate", { url: `${baseUrl}?view=${view}` }); await loaded; await delay(320); } async function capture(client, item) { await navigate(client, item.view); const dimensions = await evaluate(client, `(() => ({ width: document.documentElement.scrollWidth, height: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight) }))()`); const height = item.fullPage ? dimensions.height : 792; const screenshot = await client.send("Page.captureScreenshot", { format: "png", fromSurface: true, captureBeyondViewport: true, clip: { x: 0, y: 0, width: 360, height, scale: 1 } }); await writeFile(path.join(screenshotDir, item.file), Buffer.from(screenshot.data, "base64")); return { view: item.view, file: item.file, width: 360, height }; } async function auditView(client, view) { await navigate(client, view); return evaluate(client, `(() => { const visible = (element) => { const style = getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; }; const buttons = [...document.querySelectorAll("button")].filter(visible); const touchIssues = buttons.map((button) => { const rect = button.getBoundingClientRect(); return { name: button.getAttribute("aria-label") || button.innerText.trim(), width: Math.round(rect.width), height: Math.round(rect.height) }; }).filter((item) => item.width < 48 || item.height < 48); const unnamedButtons = buttons .filter((button) => !(button.getAttribute("aria-label") || button.innerText.trim())) .length; const checkboxes = [...document.querySelectorAll('input[type="checkbox"]')].filter(visible); const smallCheckboxLabels = checkboxes.filter((checkbox) => { const label = checkbox.closest("label"); if (!label) return true; const rect = label.getBoundingClientRect(); return rect.width < 48 || rect.height < 48; }).length; return { view: ${JSON.stringify(view)}, unnamedButtons, touchIssues, smallCheckboxLabels }; })()`); } async function run() { const health = await fetch(baseUrl); if (!health.ok) { throw new Error(`原型服务不可用:${baseUrl} (${health.status})`); } const chrome = findChrome(); const port = await getFreePort(); const profileDir = await mkdtemp(path.join(os.tmpdir(), "brainwave-prototype-capture-")); const chromeProcess = spawn(chrome, [ "--headless=new", "--disable-gpu", "--no-first-run", "--no-default-browser-check", `--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, "about:blank" ], { stdio: "ignore", windowsHide: true }); let client; try { const webSocketUrl = await waitForDebugTarget(port); client = new CdpClient(webSocketUrl); await client.connect(); await Promise.all([ client.send("Page.enable"), client.send("Runtime.enable"), client.send("Network.enable"), client.send("Log.enable"), client.send("Emulation.setDeviceMetricsOverride", { width: 360, height: 792, deviceScaleFactor: 1, mobile: false, screenWidth: 360, screenHeight: 792 }) ]); const consoleErrors = []; const runtimeErrors = []; const externalRequests = []; client.on("Log.entryAdded", ({ entry }) => { if (entry.level === "error") consoleErrors.push(entry.text); }); client.on("Runtime.exceptionThrown", ({ exceptionDetails }) => { runtimeErrors.push(exceptionDetails.exception?.description ?? exceptionDetails.text); }); client.on("Network.requestWillBeSent", ({ request }) => { const url = new URL(request.url); if (!["127.0.0.1", "localhost"].includes(url.hostname) && url.protocol !== "data:") { externalRequests.push(request.url); } }); await mkdir(screenshotDir, { recursive: true }); const screenshots = []; for (const item of cases) screenshots.push(await capture(client, item)); await navigate(client, "result"); const movingResult = await evaluate(client, `(() => ({ names: [...document.querySelectorAll(".hexagram-name")].map((node) => node.textContent.trim()), numbers: [...document.querySelectorAll(".hexagram-number")].map((node) => node.textContent.trim()), moving: document.querySelector(".result-kicker")?.textContent.replace(/\\s+/g, " ").trim() }))()`); await navigate(client, "static"); const staticResult = await evaluate(client, `(() => ({ names: [...document.querySelectorAll(".hexagram-name")].map((node) => node.textContent.trim()), transformCount: document.querySelectorAll(".transform-arrow").length, label: document.querySelector(".static-indicator")?.textContent.trim() }))()`); await navigate(client, "consent"); const consentBefore = await evaluate(client, "document.querySelector('[data-action=\"confirm-ai\"]')?.disabled"); await evaluate(client, `(() => { document.querySelector('[data-action="consent-toggle"]').click(); return document.querySelector('[data-action="confirm-ai"]').disabled; })()`); const consentAfter = await evaluate(client, "document.querySelector('[data-action=\"confirm-ai\"]')?.disabled"); await evaluate(client, "document.querySelector('[data-action=\"confirm-ai\"]')?.click()"); await delay(1050); const explanationSource = await evaluate(client, "document.querySelector('.source-badge')?.textContent.trim()"); const accessibility = []; for (const item of cases) accessibility.push(await auditView(client, item.view)); const report = { chrome, baseUrl, screenshots, movingResult, staticResult, consentGate: { disabledBeforeConsent: consentBefore, disabledAfterConsent: consentAfter, explanationSource }, consoleErrors, runtimeErrors, externalRequests: [...new Set(externalRequests)], accessibility }; const failed = movingResult.names.join(",") !== "复,坤" || movingResult.numbers.join(",") !== "第 24 卦,第 2 卦" || !movingResult.moving.includes("1 个动爻") || staticResult.names.join(",") !== "乾" || staticResult.transformCount !== 0 || consentBefore !== true || consentAfter !== false || explanationSource !== "AI 生成" || consoleErrors.length > 0 || runtimeErrors.length > 0 || externalRequests.length > 0 || accessibility.some((item) => item.unnamedButtons > 0 || item.touchIssues.length > 0 || item.smallCheckboxLabels > 0 ); console.log(JSON.stringify(report, null, 2)); if (failed) process.exitCode = 1; } finally { client?.close(); chromeProcess.kill(); await delay(150); await rm(profileDir, { recursive: true, force: true }); } } run().catch((error) => { console.error(error.stack ?? error.message); process.exitCode = 1; });