343 lines
12 KiB
JavaScript
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;
|
|
});
|