feat: add mobile interaction prototype
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
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;
|
||||
});
|
||||
Reference in New Issue
Block a user