Files
brainwave/prototype/capture.mjs
T

343 lines
12 KiB
JavaScript

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