Files
browser/platform_web/acceptance/browser-acceptance.mjs
T
2026-07-11 14:56:10 +08:00

871 lines
33 KiB
JavaScript

#!/usr/bin/env node
import { spawn } from "node:child_process";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const platformUrl = process.env.LOCAL_DEBUG_PLATFORM_URL || `http://127.0.0.1:${process.env.LOCAL_DEBUG_PLATFORM_PORT || "18080"}`;
const webUrl = process.env.LOCAL_DEBUG_WEB_URL || `http://127.0.0.1:${process.env.LOCAL_DEBUG_WEB_PORT || "5173"}`;
const localDebugRoot = process.env.LOCAL_DEBUG_ROOT || path.resolve(".local-debug");
const evidenceDir = process.env.BROWSER_ACCEPTANCE_EVIDENCE_DIR || path.join(localDebugRoot, "browser-acceptance");
const apiUrl = `${platformUrl}/api/v1`;
const forbiddenFragments = [
{ name: "host user path", pattern: /\/Users\// },
{ name: "private tmp path", pattern: /\/private\// },
{ name: "unix socket", pattern: /unix:\/\// },
{ name: "tcp socket", pattern: /tcp:\/\// },
{ name: "bearer token", pattern: /Bearer\s+/ },
{ name: "raw api key", pattern: /sk-[A-Za-z0-9_-]+/ },
{ name: "password query", pattern: /password=/ },
{ name: "raw api key ref field", pattern: /apiKeyRef/ },
{ name: "raw api key field", pattern: /rawApiKey/ },
{ name: "session token", pattern: /sessionToken|run session token/i },
{ name: "direct run URL", pattern: /direct run URL/i },
{ name: "plugin-owned transport", pattern: /plugin-owned transport/i }
];
const fallbackFragments = [
{ name: "English fallback", pattern: /fallback/i },
{ name: "demo-only", pattern: /demo-only/i },
{ name: "local fallback", pattern: /local fallback/i },
{ name: "Chinese fallback", pattern: /本地回退|本地演示数据|演示数据|模拟数据|本地视图/ }
];
async function main() {
await mkdir(evidenceDir, { recursive: true });
const session = await loginApi();
const authHeaders = { Authorization: `Bearer ${session.sessionId}` };
await ensureAiProvider(authHeaders);
const [instances, endpoints, jobs, plugins, marketplace, users, providers, logStreams, artifacts, usage] = await Promise.all([
getJson("/server-instances", authHeaders),
getJson("/run/endpoints?status=online", authHeaders),
getJson("/jobs?serverInstanceId=server-local-debug", authHeaders),
getJson("/game-plugins", authHeaders),
getJson("/plugin-marketplace/plugins", authHeaders),
getJson("/users", authHeaders),
getJson("/ai-providers", authHeaders),
getJson("/log-streams", authHeaders),
getJson("/artifacts", authHeaders),
getJson("/metrics/platform", authHeaders)
]);
const server = findRequired(instances.items, (item) => item.id === "server-local-debug", "server-local-debug instance");
const runEndpoint = findRequired(endpoints.items, (item) => item.id === "run-local-debug", "run-local-debug endpoint");
const plugin = findRequired(plugins.items, (item) => item.id === "game.example", "game.example plugin");
const marketplacePlugin = findRequired(marketplace.items, (item) => item.id === "game.example", "game.example marketplace plugin");
const operator = findRequired(users.items, (item) => item.email === "operator.local@example.test", "operator local user");
const aiProvider = findRequired(providers.items, (item) => item.id === "ai.openai" || item.apiKeyRef?.startsWith("secret://"), "redacted AI provider");
assertEqual(server.pluginId, "game.example", "server is backed by game.example");
assertEqual(server.runEndpointId, "run-local-debug", "server is assigned to run-local-debug");
assertIncludes(runEndpoint.capabilities, "process.install", "run endpoint exposes process.install");
assertIncludes(runEndpoint.capabilities, "process.start", "run endpoint exposes process.start");
assertIncludes(runEndpoint.capabilities, "process.stop", "run endpoint exposes process.stop");
assertIncludes(plugin.requiredRunCapabilities, "process.start", "plugin requires process.start");
assertIncludes(plugin.bridgeActions, "logs.query", "plugin exposes logs.query bridge action");
assertIncludes(plugin.bridgeActions, "artifacts.open", "plugin exposes artifacts.open bridge action");
assertIncludes(marketplacePlugin.capabilities, "process.start", "marketplace exposes lifecycle capability");
assertSafeRedactedRef(aiProvider.apiKeyRef, "AI provider key reference");
const chrome = await startChrome();
const evidence = {
checkedAt: new Date().toISOString(),
platformUrl,
webUrl,
localDebugRoot,
seedEvidenceDir: path.join(localDebugRoot, "smoke"),
session: {
userId: session.user.id,
displayName: session.user.displayName,
source: "api"
},
environment: await verifyFrontendEnvironment(),
browser: { chromePath: chrome.chromePath },
apiProof: {
server: pick(server, ["id", "name", "pluginId", "pluginVersion", "runEndpointId", "state", "configVersion"]),
runEndpoint: pick(runEndpoint, ["id", "displayName", "status", "capabilities"]),
plugin: pick(plugin, ["id", "name", "version", "status", "manifestRef", "requiredRunCapabilities", "bridgeActions", "declaredPermissions"]),
marketplacePlugin: pick(marketplacePlugin, ["id", "name", "version", "status", "manifestRef", "capabilities", "bridgeActions", "declaredPermissions"]),
operator: pick(operator, ["id", "displayName", "email", "status", "roles"]),
aiProvider: pick(aiProvider, ["id", "name", "kind", "relayMode", "status", "apiKeyRef"]),
jobs: jobs.items.map((job) => pick(job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"])),
logStreams: logStreams.items.map((stream) => pick(stream, ["id", "serverInstanceId", "streamKey", "source"])),
artifacts: artifacts.items.map((artifact) => pick(artifact, ["id", "ownerKind", "ownerId", "state", "checksum"])),
usage: pick(usage, ["cpuPercent", "memoryPercent", "diskPercent", "source"])
},
routes: [],
safety: {
forbiddenFragments: forbiddenFragments.map((item) => item.name),
fallbackFragments: fallbackFragments.map((item) => item.name)
}
};
try {
await chrome.navigate(webUrl);
const loginState = await loginInBrowser(chrome);
evidence.login = loginState;
const routeChecks = [
{
name: "首页",
hash: "#/home",
markers: ["平台概览", "数据已加载", "game.example", "运行节点", "CPU"]
},
{
name: "服务器管理",
hash: "#/servers",
markers: ["服务器管理", server.name, server.id, "创建服务器", "全部", "离线"]
},
{
name: "插件市场",
hash: "#/plugins",
markers: [
"插件市场",
"平台 API",
marketplacePlugin.id,
marketplacePlugin.manifestRef,
"process.install",
"process.start",
"process.stop",
"server.instances.read",
"jobs.dispatch",
"logs.query",
"artifacts.open"
]
},
{
name: "用户管理",
hash: "#/users",
markers: ["用户管理", "账号 API 已连接", operator.displayName, operator.email, "平台数据"]
},
{
name: "AI 提供商管理",
hash: "#/aiProviders",
markers: ["AI 提供商管理", "已连接", aiProvider.name, aiProvider.apiKeyRef, "密钥引用"]
},
{
name: "服务器详情",
hash: "#/servers/server-local-debug",
markers: [
server.name,
`${server.id} · 插件 ${server.pluginId}@${server.pluginVersion} · 节点 ${server.runEndpointId}`,
"启动",
"停止",
"日志",
"配置",
"插件控制",
"AI 助手",
"操作历史"
]
}
];
for (const route of routeChecks) {
const state = await verifyBrowserRoute(chrome, route.hash, route.markers, route.name);
evidence.routes.push(state);
}
const pluginControls = await clickAndVerify(chrome, "插件控制", ["Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]);
evidence.routes.push({ name: "服务器详情 / 插件控制", url: await chrome.url(), ...pluginControls });
evidence.walkthroughs = await verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server);
const operationProof = await verifyLifecycleOperation(authHeaders, server, chrome);
evidence.operationProof = operationProof;
} finally {
await chrome.close();
}
const evidencePath = path.join(evidenceDir, "browser-acceptance-evidence.json");
await writeFile(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`);
console.log("browser acceptance passed");
console.log(`evidence directory: ${evidenceDir}`);
console.log(`evidence file: ${evidencePath}`);
}
async function loginApi() {
const response = await postJson("/auth/login", {
account: "operator.local@example.test",
password: "operator-local"
});
if (!response.sessionId || response.status !== "authenticated") {
throw new Error("local debug API login did not return an active session");
}
return response;
}
async function loginInBrowser(chrome) {
await chrome.waitForText(["账号 / 邮箱", "密码", "登录"], "login form");
await chrome.evaluate(() => {
const inputs = Array.from(document.querySelectorAll("input"));
const account = inputs.find((input) => input.type !== "password");
const password = inputs.find((input) => input.type === "password");
const submit = document.querySelector('button[type="submit"]');
if (!(account instanceof HTMLInputElement) || !(password instanceof HTMLInputElement) || !(submit instanceof HTMLButtonElement)) {
throw new Error("login controls not found");
}
const setInputValue = (input, value) => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
};
setInputValue(account, "operator.local@example.test");
setInputValue(password, "operator-local");
submit.click();
});
await chrome.waitForText(["平台概览", "数据已加载"], "post-login home");
const visibleText = await chrome.visibleText();
scanText(visibleText, "登录后首页");
return {
url: await chrome.url(),
requiredMarkers: ["平台概览", "数据已加载"],
fallbackScan: "passed",
forbiddenFragmentScan: "passed"
};
}
async function verifyBrowserRoute(chrome, hash, markers, name) {
await chrome.evaluate((nextHash) => {
window.location.hash = nextHash;
}, hash);
await chrome.waitForText(markers, name);
const visibleText = await chrome.visibleText();
assertMarkers(visibleText, markers, name);
scanText(visibleText, name);
return {
name,
url: await chrome.url(),
requiredMarkers: markers,
fallbackScan: "passed",
forbiddenFragmentScan: "passed",
textSample: visibleText.slice(0, 1200)
};
}
async function verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server) {
const walkthroughs = [];
const scenarios = [
{
name: "desktop / black mecha",
viewport: { width: 1440, height: 960, mobile: false },
paletteId: "mecha-black",
backgroundId: "mecha-grid",
themeMarkers: ["Mecha Game Console", "黑色机甲 / OPS"]
},
{
name: "mobile / black mecha",
viewport: { width: 390, height: 844, mobile: true },
paletteId: "mecha-black",
backgroundId: "mecha-grid",
themeMarkers: ["Mecha Game Console", "黑色机甲 / OPS"]
},
{
name: "desktop / magical-girl",
viewport: { width: 1440, height: 960, mobile: false },
paletteId: "magical-girl",
backgroundId: "magic-stage",
themeMarkers: ["Mecha Game Console", "魔法少女 / OPS"]
},
{
name: "mobile / magical-girl",
viewport: { width: 390, height: 844, mobile: true },
paletteId: "magical-girl",
backgroundId: "magic-stage",
themeMarkers: ["Mecha Game Console", "魔法少女 / OPS"]
}
];
for (const scenario of scenarios) {
await chrome.setViewport(scenario.viewport);
await applyBrowserTheme(chrome, scenario.paletteId, scenario.backgroundId);
const routeEvidence = [];
for (const route of routeChecks) {
const state = await verifyBrowserRoute(chrome, route.hash, [...route.markers, ...scenario.themeMarkers], `${scenario.name} / ${route.name}`);
const layout = await chrome.layoutSnapshot();
assertNoVisibleLayoutIssues(layout, `${scenario.name} / ${route.name}`);
routeEvidence.push({
name: route.name,
url: state.url,
requiredMarkers: state.requiredMarkers,
fallbackScan: state.fallbackScan,
forbiddenFragmentScan: state.forbiddenFragmentScan,
layout,
textSample: state.textSample
});
}
await chrome.evaluate(() => {
window.location.hash = "#/servers/server-local-debug";
});
await chrome.waitForText([server.name, "插件控制"], `${scenario.name} / server detail tabs`);
const pluginControls = await clickAndVerify(chrome, "插件控制", ["Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]);
const pluginLayout = await chrome.layoutSnapshot();
assertNoVisibleLayoutIssues(pluginLayout, `${scenario.name} / plugin controls`);
routeEvidence.push({
name: "服务器详情 / 插件控制",
url: await chrome.url(),
requiredMarkers: pluginControls.requiredMarkers,
fallbackScan: pluginControls.fallbackScan,
forbiddenFragmentScan: pluginControls.forbiddenFragmentScan,
layout: pluginLayout,
textSample: pluginControls.textSample
});
walkthroughs.push({
name: scenario.name,
viewport: scenario.viewport,
paletteId: scenario.paletteId,
backgroundId: scenario.backgroundId,
routeCount: routeEvidence.length,
routes: routeEvidence
});
}
return walkthroughs;
}
async function applyBrowserTheme(chrome, paletteId, backgroundId) {
await chrome.evaluate(
({ paletteId: nextPaletteId, backgroundId: nextBackgroundId }) => {
window.localStorage.setItem("platform-web.theme.palette", nextPaletteId);
window.localStorage.setItem("platform-web.theme.backgroundPreset", nextBackgroundId);
window.localStorage.removeItem("platform-web.theme.background");
window.location.reload();
},
{ paletteId, backgroundId }
);
await delay(500);
}
function assertNoVisibleLayoutIssues(layout, label) {
if (layout.horizontalOverflow > 1) {
throw new Error(`${label} has horizontal overflow: scrollWidth ${layout.scrollWidth} > viewport ${layout.innerWidth}`);
}
const overlapping = layout.overlappingControls.filter((item) => item.area > 24);
if (overlapping.length > 0) {
throw new Error(`${label} has overlapping controls: ${JSON.stringify(overlapping.slice(0, 3))}`);
}
if (layout.visibleTinyTextBoxes > 0) {
throw new Error(`${label} has ${layout.visibleTinyTextBoxes} visible clipped/tiny text boxes`);
}
}
async function clickAndVerify(chrome, buttonText, markers) {
await chrome.evaluate((label) => {
const button = Array.from(document.querySelectorAll("button")).find((item) => item.textContent?.includes(label));
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`button not found: ${label}`);
}
button.click();
}, buttonText);
await chrome.waitForText(markers, buttonText);
const visibleText = await chrome.visibleText();
assertMarkers(visibleText, markers, buttonText);
scanText(visibleText, buttonText);
return {
requiredMarkers: markers,
fallbackScan: "passed",
forbiddenFragmentScan: "passed",
textSample: visibleText.slice(0, 1200)
};
}
async function ensureAiProvider(headers) {
const providers = await getJson("/ai-providers", headers);
if (providers.items.some((item) => item.id === "ai.openai")) {
return;
}
await postJson(
"/ai-providers",
{
id: "ai.openai",
name: "OpenAI Relay",
kind: "openai-compatible",
baseUrl: "https://relay.example.test/v1",
apiKeyRef: "secret://providers/openai",
models: ["gpt-4.1", "gpt-4.1-mini"],
defaultModel: "gpt-4.1-mini",
relayMode: "relay",
timeoutMs: 30000,
redactionPolicy: "default"
},
headers
);
}
async function verifyFrontendEnvironment() {
const [html, packageJsonText, viteConfigText] = await Promise.all([
fetchText(webUrl),
readFile(new URL("../package.json", import.meta.url), "utf8"),
readFile(new URL("../vite.config.ts", import.meta.url), "utf8")
]);
const packageJson = JSON.parse(packageJsonText);
const checks = [
{ name: "serves app root", passed: html.includes('<div id="root"></div>') },
{ name: "serves module app entry", passed: html.includes('type="module"') },
{ name: "Vite proxy includes /api/v1", passed: viteConfigText.includes('"/api/v1"') },
{ name: "Vite proxy includes /healthz", passed: viteConfigText.includes('"/healthz"') },
{ name: "Vite env uses platform proxy", passed: viteConfigText.includes("PLATFORM_API_PROXY") },
{ name: "package has browser acceptance script", passed: Boolean(packageJson.scripts?.["acceptance:browser"]) }
];
const failed = checks.filter((check) => !check.passed);
if (failed.length > 0) {
throw new Error(`frontend environment checks failed: ${failed.map((item) => item.name).join(", ")}`);
}
return { webUrl, checks };
}
async function verifyLifecycleOperation(headers, server, chrome) {
const request = { expectedConfigVersion: server.configVersion, idempotencyKey: `browser-acceptance-start-${Date.now()}` };
const result = await postJson(`/server-instances/${encodeURIComponent(server.id)}/start`, request, headers);
if (!result.accepted || !result.job?.id) {
throw new Error("lifecycle start operation did not return accepted job evidence");
}
if (result.job.capability !== "process.start") {
throw new Error(`lifecycle job used unexpected capability ${result.job.capability}`);
}
if (result.job.runEndpointId !== "run-local-debug") {
throw new Error(`lifecycle job used unexpected run endpoint ${result.job.runEndpointId}`);
}
const job = await waitForJob(headers, server.id, result.job.id);
await chrome.navigate(`${webUrl}/#/servers/server-local-debug`);
await chrome.waitForText([server.name, "操作历史"], "server detail after lifecycle operation");
const historyState = await clickAndVerify(chrome, "操作历史", ["操作历史", "平台任务记录", "server-lifecycle", "process."]);
return {
action: "start",
accepted: result.accepted,
request: {
expectedConfigVersion: request.expectedConfigVersion,
idempotencyKey: request.idempotencyKey
},
acceptedJob: pick(result.job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"]),
job: pick(job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"]),
proof: "platform API accepted process.start and platform-owned jobs endpoint returned the same job; browser verified operation history entry point without direct run access",
browserEvidence: {
...historyState,
proofMode: "operation-history-entry-point"
}
};
}
async function waitForJob(headers, serverId, jobId) {
for (let attempt = 0; attempt < 20; attempt += 1) {
const jobs = await getJson(`/jobs?serverInstanceId=${encodeURIComponent(serverId)}`, headers);
const job = jobs.items.find((item) => item.id === jobId);
if (job) {
return job;
}
await delay(250);
}
throw new Error(`platform jobs endpoint did not return lifecycle operation job ${jobId}`);
}
async function startChrome() {
const chromePath = findChromePath();
const userDataDir = path.join(evidenceDir, "chrome-profile");
await rm(userDataDir, { recursive: true, force: true });
await mkdir(userDataDir, { recursive: true });
const chrome = spawn(chromePath, [
"--headless=new",
"--disable-gpu",
"--no-first-run",
"--no-default-browser-check",
"--disable-background-networking",
`--user-data-dir=${userDataDir}`,
"--remote-debugging-port=0",
"about:blank"
], {
stdio: ["ignore", "ignore", "pipe"]
});
let stderr = "";
chrome.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
const activePortFile = path.join(userDataDir, "DevToolsActivePort");
let port = "";
for (let attempt = 0; attempt < 80; attempt += 1) {
try {
const activePort = await readFile(activePortFile, "utf8");
port = activePort.split(/\r?\n/)[0]?.trim();
if (port) {
break;
}
} catch {
// keep waiting for Chrome to write DevToolsActivePort
}
await delay(250);
}
if (!port) {
chrome.kill("SIGTERM");
throw new Error(`Chrome did not expose a DevTools port. ${stderr.slice(0, 500)}`);
}
const targetResponse = await fetch(`http://127.0.0.1:${port}/json/new?${encodeURIComponent("about:blank")}`, { method: "PUT" });
if (!targetResponse.ok) {
chrome.kill("SIGTERM");
throw new Error(`failed to create Chrome target: HTTP ${targetResponse.status}`);
}
const target = await targetResponse.json();
const client = await CdpClient.connect(target.webSocketDebuggerUrl);
await client.send("Page.enable");
await client.send("Runtime.enable");
return {
chromePath,
async setViewport({ width, height, mobile }) {
await client.send("Emulation.setDeviceMetricsOverride", {
width,
height,
deviceScaleFactor: mobile ? 2 : 1,
mobile
});
},
async navigate(url) {
const loaded = client.waitFor("Page.loadEventFired", 15000);
await client.send("Page.navigate", { url });
await loaded.catch(() => undefined);
},
async evaluate(pageFunction, arg) {
const expression = `(${pageFunction.toString()})(${arg === undefined ? "" : JSON.stringify(arg)})`;
const result = await client.send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
if (result.exceptionDetails) {
throw new Error(result.exceptionDetails.text || "browser evaluation failed");
}
return result.result?.value;
},
async visibleText() {
return this.evaluate(() => document.body?.innerText || "");
},
async layoutSnapshot() {
return this.evaluate(() => {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const scrollWidth = document.documentElement.scrollWidth;
const viewportClip = { left: 0, top: 0, right: viewportWidth, bottom: viewportHeight };
const rectFromDomRect = (rect) => ({
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom
});
const intersection = (first, second) => ({
left: Math.max(first.left, second.left),
top: Math.max(first.top, second.top),
right: Math.min(first.right, second.right),
bottom: Math.min(first.bottom, second.bottom)
});
const clipForElement = (element) => {
let clip = viewportClip;
let parent = element.parentElement;
while (parent) {
const style = window.getComputedStyle(parent);
const clipsX = ["auto", "scroll", "hidden", "clip"].includes(style.overflowX);
const clipsY = ["auto", "scroll", "hidden", "clip"].includes(style.overflowY);
if (clipsX || clipsY) {
const parentRect = rectFromDomRect(parent.getBoundingClientRect());
clip = intersection(clip, {
left: clipsX ? parentRect.left : clip.left,
top: clipsY ? parentRect.top : clip.top,
right: clipsX ? parentRect.right : clip.right,
bottom: clipsY ? parentRect.bottom : clip.bottom
});
}
parent = parent.parentElement;
}
return clip;
};
const controls = Array.from(document.querySelectorAll("button, a, input, select, textarea, [role='button'], [role='tab']"))
.filter((element) => {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
})
.map((element) => {
const rect = element.getBoundingClientRect();
const clipped = intersection(rectFromDomRect(rect), clipForElement(element));
const visibleWidth = Math.max(0, clipped.right - clipped.left);
const visibleHeight = Math.max(0, clipped.bottom - clipped.top);
return {
tag: element.tagName.toLowerCase(),
text: (element.textContent || element.getAttribute("aria-label") || "").trim().slice(0, 60),
left: Math.round(clipped.left),
top: Math.round(clipped.top),
right: Math.round(clipped.right),
bottom: Math.round(clipped.bottom),
width: Math.round(visibleWidth),
height: Math.round(visibleHeight)
};
})
.filter((control) => control.width > 0 && control.height > 0);
const overlappingControls = [];
for (let index = 0; index < controls.length; index += 1) {
for (let otherIndex = index + 1; otherIndex < controls.length; otherIndex += 1) {
const left = Math.max(controls[index].left, controls[otherIndex].left);
const top = Math.max(controls[index].top, controls[otherIndex].top);
const right = Math.min(controls[index].right, controls[otherIndex].right);
const bottom = Math.min(controls[index].bottom, controls[otherIndex].bottom);
const width = right - left;
const height = bottom - top;
if (width > 0 && height > 0) {
overlappingControls.push({
first: controls[index].text || controls[index].tag,
second: controls[otherIndex].text || controls[otherIndex].tag,
area: Math.round(width * height)
});
}
}
}
const visibleTinyTextBoxes = Array.from(document.querySelectorAll("button, a, label, h1, h2, h3, p, span, strong, td, th"))
.filter((element) => {
const text = (element.textContent || "").trim();
if (!text) {
return false;
}
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none" && rect.width < 4;
}).length;
return {
innerWidth: viewportWidth,
innerHeight: viewportHeight,
scrollWidth,
horizontalOverflow: Math.max(0, scrollWidth - viewportWidth),
controlCount: controls.length,
overlappingControls: overlappingControls.slice(0, 10),
visibleTinyTextBoxes
};
});
},
async url() {
return this.evaluate(() => window.location.href);
},
async waitForText(markers, label) {
const required = Array.isArray(markers) ? markers : [markers];
for (let attempt = 0; attempt < 80; attempt += 1) {
const text = await this.visibleText();
if (required.every((marker) => text.includes(marker))) {
return;
}
await delay(250);
}
const text = await this.visibleText();
const missing = required.filter((marker) => !text.includes(marker));
throw new Error(`${label} missing browser markers: ${missing.join(", ")}. Visible sample: ${text.slice(0, 500)}`);
},
async close() {
await client.close();
await stopChrome(chrome);
await rm(userDataDir, { recursive: true, force: true });
}
};
}
async function stopChrome(chrome) {
if (chrome.exitCode !== null || chrome.signalCode !== null) {
return;
}
const exited = new Promise((resolve) => {
chrome.once("exit", resolve);
});
chrome.kill("SIGTERM");
const stopped = await Promise.race([
exited.then(() => true),
delay(3000).then(() => false)
]);
if (!stopped && chrome.exitCode === null && chrome.signalCode === null) {
chrome.kill("SIGKILL");
await Promise.race([
exited,
delay(3000)
]);
}
}
class CdpClient {
constructor(socket) {
this.socket = socket;
this.nextId = 1;
this.pending = new Map();
this.waiters = new Map();
socket.addEventListener("message", (event) => this.handleMessage(event));
}
static async connect(url) {
const socket = new WebSocket(url);
await new Promise((resolve, reject) => {
socket.addEventListener("open", resolve, { once: true });
socket.addEventListener("error", reject, { once: true });
});
return new CdpClient(socket);
}
send(method, params = {}) {
const id = this.nextId;
this.nextId += 1;
const promise = new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
});
this.socket.send(JSON.stringify({ id, method, params }));
return promise;
}
waitFor(method, timeoutMs) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
const waiters = this.waiters.get(method) || [];
this.waiters.set(method, waiters.filter((item) => item.resolve !== resolve));
reject(new Error(`timed out waiting for ${method}`));
}, timeoutMs);
const waiters = this.waiters.get(method) || [];
waiters.push({
resolve: (params) => {
clearTimeout(timeout);
resolve(params);
}
});
this.waiters.set(method, waiters);
});
}
handleMessage(event) {
const message = JSON.parse(event.data);
if (message.id) {
const pending = this.pending.get(message.id);
if (pending) {
this.pending.delete(message.id);
if (message.error) {
pending.reject(new Error(message.error.message));
} else {
pending.resolve(message.result);
}
}
return;
}
if (message.method && this.waiters.has(message.method)) {
const waiters = this.waiters.get(message.method);
const waiter = waiters.shift();
if (waiters.length === 0) {
this.waiters.delete(message.method);
}
waiter?.resolve(message.params);
}
}
async close() {
this.socket.close();
}
}
async function getJson(pathname, headers = {}) {
return requestJson("GET", pathname, undefined, headers);
}
async function postJson(pathname, body, headers = {}) {
return requestJson("POST", pathname, body, headers);
}
async function requestJson(method, pathname, body, headers = {}) {
const response = await fetch(`${apiUrl}${pathname}`, {
method,
headers: {
Accept: "application/json",
...(body === undefined ? {} : { "Content-Type": "application/json" }),
...headers
},
body: body === undefined ? undefined : JSON.stringify(body)
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`${method} ${pathname} failed with HTTP ${response.status}: ${text.slice(0, 300)}`);
}
return response.json();
}
async function fetchText(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`GET ${url} failed with HTTP ${response.status}`);
}
return response.text();
}
function findChromePath() {
const candidates = [
process.env.CHROME_BIN,
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"google-chrome",
"chromium",
"chromium-browser"
].filter(Boolean);
return candidates.find((candidate) => {
if (candidate.startsWith("/")) {
return true;
}
return false;
}) || candidates[0];
}
function findRequired(items, predicate, label) {
const item = items.find(predicate);
if (!item) {
throw new Error(`missing required ${label}`);
}
return item;
}
function assertMarkers(text, markers, routeName) {
const missing = markers.filter((marker) => !text.includes(marker));
if (missing.length > 0) {
throw new Error(`${routeName} missing required markers: ${missing.join(", ")}`);
}
}
function scanText(text, routeName) {
const fallback = fallbackFragments.find((item) => item.pattern.test(text));
if (fallback) {
throw new Error(`${routeName} contains fallback/demo marker: ${fallback.name}`);
}
const forbidden = forbiddenFragments.find((item) => item.pattern.test(text));
if (forbidden) {
throw new Error(`${routeName} contains forbidden visible fragment: ${forbidden.name}`);
}
}
function assertEqual(actual, expected, label) {
if (actual !== expected) {
throw new Error(`${label}: expected ${expected}, got ${actual}`);
}
}
function assertIncludes(values, expected, label) {
if (!Array.isArray(values) || !values.includes(expected)) {
throw new Error(`${label}: missing ${expected}`);
}
}
function assertSafeRedactedRef(value, label) {
if (typeof value !== "string" || (!value.startsWith("secret://") && !value.startsWith("env://"))) {
throw new Error(`${label} must be a safe secret/env reference`);
}
}
function pick(value, keys) {
return Object.fromEntries(keys.map((key) => [key, value?.[key]]));
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});