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 }, { view: "home", file: "09-home-returning.png", fullPage: true }, { view: "home-empty", file: "10-home-empty.png", fullPage: true }, { view: "history", file: "11-history-list.png", fullPage: true }, { view: "history-empty", file: "12-history-empty.png", fullPage: true }, { view: "history-detail", file: "13-history-detail.png", fullPage: true }, { view: "settings", file: "14-settings-default.png", fullPage: true }, { view: "delete", file: "15-delete-confirm.png", fullPage: false }, { view: "settings-off", file: "16-settings-off.png", fullPage: true }, { view: "result-not-saved", file: "17-result-not-saved.png", fullPage: true }, { view: "clear-all", file: "18-clear-all-confirm.png", fullPage: false } ]; 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 auditLayout(client, { name, width, height, view }) { await client.send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, mobile: false, screenWidth: width, screenHeight: height }); await navigate(client, view); return evaluate(client, `(() => ({ name: ${JSON.stringify(name)}, view: ${JSON.stringify(view)}, viewport: { width: innerWidth, height: innerHeight }, documentWidth: document.documentElement.scrollWidth, horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth, mainReachable: Boolean(document.querySelector('#screen')) }))()`); } 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, "casting"); for (let remainingRound = 0; remainingRound < 4; remainingRound += 1) { for (let coinIndex = 0; coinIndex < 3; coinIndex += 1) { await evaluate(client, `document.querySelector('[data-action="toggle-coin"][data-index="${coinIndex}"]')?.click()`); } await evaluate(client, "document.querySelector('[data-action=\"confirm-line\"]')?.click()"); } const completedCastingSave = await evaluate(client, `(() => ({ resultVisible: Boolean(document.querySelector('#result-title')), status: document.querySelector('[data-current-save-status]')?.textContent.replace(/\\s+/g, " ").trim() }))()`); 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 explanationSaveStatus = await evaluate(client, "document.querySelector('[data-current-save-status]')?.textContent.replace(/\\s+/g, ' ').trim()"); await navigate(client, "home"); const homeState = await evaluate(client, `(() => ({ primaryActions: document.querySelectorAll('[data-action="begin"]').length, recentRoute: document.querySelector('.recent-card__route')?.textContent.replace(/\\s+/g, " ").trim(), historyEntry: document.querySelectorAll('[data-action="open-history"]').length, settingsEntry: document.querySelectorAll('[data-action="open-settings"]').length, localNotice: document.querySelector('[data-local-data-notice]')?.textContent.replace(/\\s+/g, " ").trim(), bottomNavigation: document.querySelectorAll('nav, [role="navigation"]').length }))()`); await evaluate(client, "document.querySelector('[data-action=\"begin\"]')?.click()"); const homeForwardTitle = await evaluate(client, "document.querySelector('#question-title')?.textContent.trim()"); await evaluate(client, "document.querySelector('[data-action=\"back\"]')?.click()"); const homeBackTitle = await evaluate(client, "document.querySelector('#home-title')?.textContent.trim()"); await navigate(client, "history"); const historyState = await evaluate(client, `(() => ({ count: document.querySelectorAll('.history-card').length, firstRoute: document.querySelector('.history-card__route')?.textContent.replace(/\\s+/g, " ").trim() }))()`); await navigate(client, "history-empty"); const historyEmptyState = await evaluate(client, `(() => ({ hasEmptyGuidance: Boolean(document.querySelector('[data-history-empty]')), hasPrimaryAction: Boolean(document.querySelector('[data-action="begin"]')), hasSettingsEntry: Boolean(document.querySelector('[data-action="open-settings"]')) }))()`); await navigate(client, "settings"); const settingsDefaults = await evaluate(client, `(() => ({ autoSave: document.querySelector('[data-action="auto-save-toggle"]')?.checked, question: document.querySelector('[data-action="save-question-setting"]')?.checked, explanation: document.querySelector('[data-action="save-explanation-setting"]')?.checked, action: document.querySelector('[data-action="save-action-setting"]')?.checked, detailDisabled: [...document.querySelectorAll('.settings-card__details input')].some((input) => input.disabled), boundary: document.querySelector('.settings-boundary')?.textContent.replace(/\\s+/g, " ").trim() }))()`); await evaluate(client, "document.querySelector('[data-action=\"auto-save-toggle\"]')?.click()"); await delay(100); const settingsDisabled = await evaluate(client, `(() => ({ autoSave: document.querySelector('[data-action="auto-save-toggle"]')?.checked, detailsDisabled: [...document.querySelectorAll('.settings-card__details input')].every((input) => input.disabled), retainedValues: [...document.querySelectorAll('.settings-card__details input')].every((input) => input.checked) }))()`); await navigate(client, "result"); const autoSaveBefore = await evaluate(client, `(() => ({ status: document.querySelector('[data-current-save-status]')?.textContent.replace(/\\s+/g, " ").trim(), canOptOut: Boolean(document.querySelector('[data-action="remove-current-record"]')) }))()`); await evaluate(client, "document.querySelector('[data-action=\"remove-current-record\"]')?.click()"); const optOutDialogVisible = await evaluate(client, "Boolean(document.querySelector('[role=\"alertdialog\"]'))"); await evaluate(client, "document.querySelector('[data-action=\"confirm-delete\"]')?.click()"); await delay(100); const afterOptOut = await evaluate(client, `(() => ({ status: document.querySelector('[data-current-save-status]')?.textContent.replace(/\\s+/g, " ").trim(), canSave: Boolean(document.querySelector('[data-action="save-current"]')) }))()`); await evaluate(client, "document.querySelector('[data-action=\"save-current\"]')?.click()"); await delay(100); const afterManualSave = await evaluate(client, "document.querySelector('[data-current-save-status]')?.textContent.replace(/\\s+/g, ' ').trim()"); await navigate(client, "result-not-saved"); const autoSaveOffResult = await evaluate(client, `(() => ({ status: document.querySelector('[data-current-save-status]')?.textContent.replace(/\\s+/g, " ").trim(), canSave: Boolean(document.querySelector('[data-action="save-current"]')) }))()`); await navigate(client, "clear-all"); const clearBefore = await evaluate(client, `(() => ({ dialogVisible: Boolean(document.querySelector('[role="alertdialog"]')), countText: document.querySelector('#clear-modal-description')?.textContent.trim() }))()`); await evaluate(client, "document.querySelector('[data-action=\"confirm-clear-all\"]')?.click()"); await delay(100); const clearAfter = await evaluate(client, `(() => ({ settingsVisible: document.querySelector('#settings-title')?.textContent.trim(), recordCount: document.querySelector('.settings-danger p')?.textContent.trim(), clearDisabled: document.querySelector('[data-action="open-clear-all"]')?.disabled, autoSaveRetained: document.querySelector('[data-action="auto-save-toggle"]')?.checked, modalClosed: !document.querySelector('[role="alertdialog"]') }))()`); await navigate(client, "delete"); const deleteBefore = await evaluate(client, `(() => ({ dialogVisible: Boolean(document.querySelector('[role="alertdialog"]')), detailVisible: Boolean(document.querySelector('#history-detail-title')) }))()`); await evaluate(client, "document.querySelector('[data-action=\"confirm-delete\"]')?.click()"); await delay(100); const deleteAfter = await evaluate(client, `(() => ({ onHistory: document.querySelector('#history-title')?.textContent.trim(), historyCount: document.querySelectorAll('.history-card').length, modalClosed: !document.querySelector('[role="alertdialog"]') }))()`); const accessibility = []; for (const item of cases) accessibility.push(await auditView(client, item.view)); const layoutAudits = []; for (const layout of [ { name: "small-phone", width: 375, height: 812, view: "home" }, { name: "large-phone", width: 430, height: 932, view: "history" }, { name: "large-phone-settings", width: 430, height: 932, view: "settings" }, { name: "phone-landscape", width: 792, height: 360, view: "history-detail" } ]) { layoutAudits.push(await auditLayout(client, layout)); } await client.send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-reduced-motion", value: "reduce" }] }); await navigate(client, "home"); const reducedMotion = await evaluate(client, `(() => { const page = document.querySelector('.page'); const duration = Number.parseFloat(getComputedStyle(page).animationDuration) || 0; return { animationDurationSeconds: duration, reduced: duration <= 0.001 }; })()`); await client.send("Emulation.setEmulatedMedia", { features: [] }); const report = { chrome, baseUrl, screenshots, completedCastingSave, movingResult, staticResult, consentGate: { disabledBeforeConsent: consentBefore, disabledAfterConsent: consentAfter, explanationSource, explanationSaveStatus }, homeState: { ...homeState, forwardTitle: homeForwardTitle, backTitle: homeBackTitle }, historyState, historyEmptyState, settingsFlow: { defaults: settingsDefaults, disabled: settingsDisabled }, autoSaveFlow: { before: autoSaveBefore, optOutDialogVisible, afterOptOut, afterManualSave, autoSaveOffResult }, clearAllFlow: { before: clearBefore, after: clearAfter }, deleteFlow: { before: deleteBefore, after: deleteAfter }, consoleErrors, runtimeErrors, externalRequests: [...new Set(externalRequests)], accessibility, layoutAudits, reducedMotion }; const failed = completedCastingSave.resultVisible !== true || !completedCastingSave.status?.includes("已自动保存到本机") || movingResult.names.join(",") !== "复,坤" || movingResult.numbers.join(",") !== "第 24 卦,第 2 卦" || !movingResult.moving.includes("1 个动爻") || staticResult.names.join(",") !== "乾" || staticResult.transformCount !== 0 || consentBefore !== true || consentAfter !== false || explanationSource !== "AI 生成" || !explanationSaveStatus?.includes("解读已加入本次记录") || homeState.primaryActions !== 1 || homeState.recentRoute !== "复 之 坤" || homeState.historyEntry < 1 || homeState.settingsEntry !== 1 || !homeState.localNotice?.includes("起卦与历史默认保存在本机,不主动上传") || !homeState.localNotice?.includes("只有你选择 AI 解读时,本次所需内容才会发送") || homeState.bottomNavigation !== 0 || homeForwardTitle !== "你想看清什么?" || homeBackTitle !== "此刻,有什么想安静看清?" || historyState.count !== 3 || historyState.firstRoute !== "复 之 坤" || historyEmptyState.hasEmptyGuidance !== true || historyEmptyState.hasPrimaryAction !== true || historyEmptyState.hasSettingsEntry !== true || settingsDefaults.autoSave !== true || settingsDefaults.question !== true || settingsDefaults.explanation !== true || settingsDefaults.action !== true || settingsDefaults.detailDisabled !== false || !settingsDefaults.boundary?.includes("默认排除系统备份、设备迁移与云同步") || settingsDisabled.autoSave !== false || settingsDisabled.detailsDisabled !== true || settingsDisabled.retainedValues !== true || !autoSaveBefore.status?.includes("已自动保存到本机") || autoSaveBefore.canOptOut !== true || optOutDialogVisible !== true || !afterOptOut.status?.includes("本次未保存") || !afterOptOut.status?.includes("你已选择不保留这次记录") || afterOptOut.canSave !== true || !afterManualSave?.includes("已保存到本机问卦簿") || !autoSaveOffResult.status?.includes("自动保存已关闭") || autoSaveOffResult.canSave !== true || clearBefore.dialogVisible !== true || !clearBefore.countText?.includes("当前 3 条记录") || clearAfter.settingsVisible !== "保存设置" || !clearAfter.recordCount?.includes("0 条记录") || clearAfter.clearDisabled !== true || clearAfter.autoSaveRetained !== true || clearAfter.modalClosed !== true || deleteBefore.dialogVisible !== true || deleteBefore.detailVisible !== true || deleteAfter.onHistory !== "问卦簿" || deleteAfter.historyCount !== 2 || deleteAfter.modalClosed !== true || consoleErrors.length > 0 || runtimeErrors.length > 0 || externalRequests.length > 0 || accessibility.some((item) => item.unnamedButtons > 0 || item.touchIssues.length > 0 || item.smallCheckboxLabels > 0 ) || layoutAudits.some((item) => item.horizontalOverflow || !item.mainReachable) || reducedMotion.reduced !== true; 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; });