first commit
This commit is contained in:
@@ -0,0 +1,870 @@
|
||||
#!/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);
|
||||
});
|
||||
Reference in New Issue
Block a user