1266 lines
56 KiB
JavaScript
1266 lines
56 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\s*=/i },
|
|
{ name: "credential value", pattern: /credential\s*[:=]\s*\S+/i },
|
|
{ name: "DSN value", pattern: /\b(?:postgres(?:ql)?|mysql|redis|mongodb(?:\+srv)?):\/\//i },
|
|
{ name: "RCON value", pattern: /\brcon(?:[_-]?(?:password|token))?\s*[:=]\s*\S+/i },
|
|
{ name: "host path", pattern: /\/(?:home|var|etc|opt|root|tmp)\/|[A-Za-z]:\\/ },
|
|
{ name: "PID value", pattern: /\bpid\s*[:=#]?\s*\d+/i },
|
|
{ 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 smokeSeed = await loadSmokeSeed();
|
|
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=${encodeURIComponent(smokeSeed.serverLocalId)}`, 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 === smokeSeed.serverLocalId, `${smokeSeed.serverLocalId} instance`);
|
|
const runEndpoint = findRequired(endpoints.items, (item) => item.id === server.runEndpointId, `${server.runEndpointId} generated 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.apiKeyConfigured === true, "redacted AI provider");
|
|
|
|
assertEqual(server.pluginId, "game.example", "server is backed by game.example");
|
|
assertEqual(server.runEndpointId, smokeSeed.generatedRunEndpointId, "server is assigned to its generated Run");
|
|
if (runEndpoint.capabilities.includes("distribution.build")) {
|
|
throw new Error("generated Run unexpectedly advertises distribution.build");
|
|
}
|
|
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(plugin.bridgeActions, "plugin-lifecycle.request", "plugin exposes Platform lifecycle bridge action");
|
|
assertIncludes(plugin.bridgeActions, "ai.invoke", "plugin exposes Platform AI bridge action");
|
|
assertIncludes(plugin.productionLifecycle?.operations, "rollback", "plugin declares rollback governance");
|
|
assertIncludes(marketplacePlugin.capabilities, "process.start", "marketplace exposes lifecycle capability");
|
|
assertEqual(aiProvider.apiKeyConfigured, true, "AI provider key presence projection");
|
|
assertEqual(aiProvider.baseUrlConfigured, true, "AI provider base URL presence projection");
|
|
if (Object.hasOwn(aiProvider, "baseUrl") || Object.hasOwn(aiProvider, "apiKeyRef")) {
|
|
throw new Error("AI provider response exposed Platform-owned endpoint or secret reference");
|
|
}
|
|
|
|
const productionSeed = await prepareProductionOperations(authHeaders, server, plugin);
|
|
|
|
const chrome = await startChrome();
|
|
const evidence = {
|
|
checkedAt: new Date().toISOString(),
|
|
platformUrl,
|
|
webUrl,
|
|
localDebugRoot,
|
|
smokeSeed,
|
|
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", "baseUrlConfigured", "apiKeyConfigured"]),
|
|
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"]),
|
|
production: productionSeed.apiProof
|
|
},
|
|
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.name,
|
|
"Development plugin",
|
|
"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 提供商管理", "平台 API", aiProvider.name, "密钥状态", "已配置", "AI 配置审查", productionSeed.diff.diffSummary, server.id]
|
|
},
|
|
{
|
|
name: "系统维护",
|
|
hash: "#/maintenance",
|
|
markers: ["系统维护", "容量治理与告警闭环", "运行槽位", productionSeed.alert.title]
|
|
},
|
|
{
|
|
name: "服务器详情",
|
|
hash: `#/servers/${encodeURIComponent(server.id)}`,
|
|
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);
|
|
if (route.name === "服务器管理") {
|
|
const runtimeMenu = await verifyServerQuickRuntimeMenu(chrome, route.name);
|
|
evidence.routes.push({
|
|
name: "服务器管理 / 运行操作菜单",
|
|
url: await chrome.url(),
|
|
...runtimeMenu
|
|
});
|
|
}
|
|
}
|
|
|
|
const pluginPage = await clickAndVerify(chrome, "概览", ["插件概览", "dev-game-plugin 页面 bundle", "server.instances.read"]);
|
|
evidence.routes.push({ name: "服务器详情 / 插件声明页面", url: await chrome.url(), ...pluginPage });
|
|
|
|
evidence.productionInteractions = {
|
|
alert: await verifyAlertInteraction(chrome, authHeaders, productionSeed.alert),
|
|
pluginLifecycle: await verifyPluginLifecycleInteraction(chrome, authHeaders, plugin, server),
|
|
aiDiffApproval: await verifyAIConfigDiffInteraction(chrome, authHeaders, productionSeed.diff)
|
|
};
|
|
|
|
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 loadSmokeSeed() {
|
|
const configPath = path.join(localDebugRoot, "smoke", "run-build-config.env");
|
|
const contents = await readFile(configPath, "utf8");
|
|
const values = {};
|
|
for (const line of contents.split(/\r?\n/)) {
|
|
const separator = line.indexOf("=");
|
|
if (separator <= 0) continue;
|
|
values[line.slice(0, separator)] = line.slice(separator + 1);
|
|
}
|
|
for (const key of ["SMOKE_INVOCATION_ID", "SERVER_LOCAL_ID", "SCUM_ALPHA_ID", "SCUM_BETA_ID", "SCUM_DYNAMIC_ID", "GENERATED_RUN_ENDPOINT_ID"]) {
|
|
if (!values[key]) throw new Error(`smoke seed configuration is missing ${key}`);
|
|
}
|
|
return {
|
|
invocationId: values.SMOKE_INVOCATION_ID,
|
|
serverLocalId: values.SERVER_LOCAL_ID,
|
|
scumAlphaId: values.SCUM_ALPHA_ID,
|
|
scumBetaId: values.SCUM_BETA_ID,
|
|
scumDynamicId: values.SCUM_DYNAMIC_ID,
|
|
generatedRunEndpointId: values.GENERATED_RUN_ENDPOINT_ID
|
|
};
|
|
}
|
|
|
|
async function loginApi() {
|
|
const response = await postJson("/auth/login", {
|
|
account: "operator.local@example.test",
|
|
password: "operator-local"
|
|
}, { "X-Auth-Token-Response": "bearer" });
|
|
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 appliedPalette = await chrome.evaluate(() => document.documentElement.dataset.themePalette || "");
|
|
assertEqual(appliedPalette, scenario.paletteId, `${scenario.name} root theme marker`);
|
|
const routeEvidence = [];
|
|
if (scenario.viewport.mobile) {
|
|
const mobileNavigation = await verifyMobileNavigation(chrome, scenario.name, scenario.themeMarkers);
|
|
routeEvidence.push({
|
|
name: "移动导航抽屉",
|
|
url: await chrome.url(),
|
|
...mobileNavigation,
|
|
layout: await chrome.layoutSnapshot()
|
|
});
|
|
}
|
|
for (const route of routeChecks) {
|
|
const routeThemeMarkers = scenario.viewport.mobile ? [] : scenario.themeMarkers;
|
|
const state = await verifyBrowserRoute(chrome, route.hash, [...route.markers, ...routeThemeMarkers], `${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
|
|
});
|
|
if (route.name === "服务器管理") {
|
|
const runtimeMenu = await verifyServerQuickRuntimeMenu(chrome, `${scenario.name} / 服务器管理`);
|
|
const runtimeMenuLayout = await chrome.layoutSnapshot();
|
|
assertNoVisibleLayoutIssues(runtimeMenuLayout, `${scenario.name} / 服务器管理 / 运行操作菜单`);
|
|
routeEvidence.push({
|
|
name: "服务器管理 / 运行操作菜单",
|
|
url: await chrome.url(),
|
|
requiredMarkers: runtimeMenu.requiredMarkers,
|
|
fallbackScan: runtimeMenu.fallbackScan,
|
|
forbiddenFragmentScan: runtimeMenu.forbiddenFragmentScan,
|
|
layout: runtimeMenuLayout,
|
|
textSample: runtimeMenu.textSample
|
|
});
|
|
}
|
|
}
|
|
|
|
await chrome.evaluate((serverID) => {
|
|
window.location.hash = `#/servers/${encodeURIComponent(serverID)}`;
|
|
}, server.id);
|
|
await chrome.waitForText([server.name, "概览"], `${scenario.name} / server detail tabs`);
|
|
const pluginControls = await clickAndVerify(chrome, "概览", ["插件概览", "dev-game-plugin 页面 bundle", "server.instances.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 verifyServerQuickRuntimeMenu(chrome, label) {
|
|
const markers = ["生成 run", "下载 run", "更新 run", "生成客户端", "依赖检查", "依赖安装", "历史日志"];
|
|
await chrome.evaluate(() => {
|
|
const trigger = Array.from(document.querySelectorAll("button")).find((item) => item.textContent?.includes("运行操作"));
|
|
if (!(trigger instanceof HTMLButtonElement)) {
|
|
throw new Error("runtime action menu trigger not found");
|
|
}
|
|
trigger.click();
|
|
});
|
|
await chrome.waitForText(markers, `${label} runtime action menu`);
|
|
const visibleText = await chrome.visibleText();
|
|
assertMarkers(visibleText, markers, `${label} runtime action menu`);
|
|
scanText(visibleText, `${label} runtime action menu`);
|
|
return {
|
|
requiredMarkers: markers,
|
|
fallbackScan: "passed",
|
|
forbiddenFragmentScan: "passed",
|
|
textSample: visibleText.slice(0, 1200)
|
|
};
|
|
}
|
|
|
|
async function verifyMobileNavigation(chrome, label, themeMarkers) {
|
|
const menuLabels = ["平台概览", "服务器管理", "插件市场", "用户管理", "AI 提供商", "系统工具"];
|
|
const closed = await chrome.evaluate(() => {
|
|
const handle = document.querySelector(".mobile-sidebar-handle");
|
|
const sidebar = document.querySelector("#primary-sidebar");
|
|
return {
|
|
handle: handle instanceof HTMLButtonElement,
|
|
expanded: handle?.getAttribute("aria-expanded") === "true",
|
|
sidebarOpen: sidebar?.classList.contains("app-sidebar-mobile-open") ?? false,
|
|
mainTop: document.querySelector("main")?.getBoundingClientRect().top ?? null
|
|
};
|
|
});
|
|
if (!closed.handle || closed.expanded || closed.sidebarOpen || closed.mainTop !== 0) {
|
|
throw new Error(`${label} mobile drawer was not closed and out of page flow: ${JSON.stringify(closed)}`);
|
|
}
|
|
await chrome.evaluate(() => {
|
|
const handle = document.querySelector(".mobile-sidebar-handle");
|
|
if (!(handle instanceof HTMLButtonElement)) throw new Error("mobile drawer handle not found");
|
|
handle.click();
|
|
});
|
|
await chrome.waitForText([...menuLabels, ...themeMarkers], `${label} mobile drawer open`);
|
|
const opened = await chrome.evaluate(() => ({
|
|
expanded: document.querySelector(".mobile-sidebar-handle")?.getAttribute("aria-expanded") === "true",
|
|
sidebarOpen: document.querySelector("#primary-sidebar")?.classList.contains("app-sidebar-mobile-open") ?? false,
|
|
width: document.querySelector("#primary-sidebar")?.getBoundingClientRect().width ?? 0
|
|
}));
|
|
if (!opened.expanded || !opened.sidebarOpen || opened.width <= 0 || opened.width > 320) {
|
|
throw new Error(`${label} mobile drawer did not open as a bounded text sidebar: ${JSON.stringify(opened)}`);
|
|
}
|
|
const visibleText = await chrome.visibleText();
|
|
assertMarkers(visibleText, [...menuLabels, ...themeMarkers], `${label} mobile drawer`);
|
|
scanText(visibleText, `${label} mobile drawer`);
|
|
await chrome.evaluate(() => {
|
|
const backdrop = document.querySelector(".mobile-sidebar-backdrop");
|
|
if (!(backdrop instanceof HTMLButtonElement)) throw new Error("mobile drawer backdrop not found");
|
|
backdrop.click();
|
|
});
|
|
await delay(220);
|
|
const closedAgain = await chrome.evaluate(() => {
|
|
const backdrop = document.querySelector(".mobile-sidebar-backdrop");
|
|
const style = backdrop ? window.getComputedStyle(backdrop) : null;
|
|
return document.querySelector(".mobile-sidebar-handle")?.getAttribute("aria-expanded") !== "true"
|
|
&& style?.visibility === "hidden"
|
|
&& style?.pointerEvents === "none";
|
|
});
|
|
if (!closedAgain) throw new Error(`${label} mobile drawer did not close from backdrop`);
|
|
return {
|
|
requiredMarkers: [...menuLabels, ...themeMarkers],
|
|
fallbackScan: "passed",
|
|
forbiddenFragmentScan: "passed",
|
|
closed,
|
|
opened,
|
|
closedAgain,
|
|
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 prepareProductionOperations(headers, server, plugin) {
|
|
const stamp = Date.now();
|
|
const lifecycle = await postJson(
|
|
`/plugin-lifecycles/${encodeURIComponent(plugin.id)}/actions`,
|
|
{
|
|
serverInstanceId: server.id,
|
|
operation: "install",
|
|
targetVersion: plugin.version,
|
|
idempotencyKey: `browser-acceptance-plugin-install-${stamp}`,
|
|
confirmed: false
|
|
},
|
|
headers
|
|
);
|
|
if (lifecycle.status !== "queued" || !lifecycle.job?.id || !lifecycle.installation?.id) {
|
|
throw new Error(`plugin lifecycle seed did not create one durable job: ${JSON.stringify(lifecycle)}`);
|
|
}
|
|
|
|
const aiInvocation = await postJson(
|
|
"/ai/invocations",
|
|
{
|
|
requestId: `browser-acceptance-ai-config-${stamp}`,
|
|
serverInstanceId: server.id,
|
|
purpose: "config.suggest",
|
|
prompt: "Keep existing settings and add a reviewed max players recommendation."
|
|
},
|
|
headers
|
|
);
|
|
if (aiInvocation.status !== "ok" || !aiInvocation.configRecommendation?.diffId) {
|
|
throw new Error(`AI invocation did not persist a reviewable diff: ${JSON.stringify(aiInvocation)}`);
|
|
}
|
|
|
|
const admission = await postJson(
|
|
"/production/capacity/admission",
|
|
{
|
|
serverInstanceId: server.id,
|
|
capability: "process.restart",
|
|
idempotencyKey: `browser-acceptance-capacity-gap-${stamp}`
|
|
},
|
|
headers
|
|
);
|
|
if (admission.accepted || admission.state !== "denied" || !admission.alertId || !admission.auditEventId) {
|
|
throw new Error(`capacity admission did not create durable denied evidence: ${JSON.stringify(admission)}`);
|
|
}
|
|
|
|
const [capacity, alerts, lifecycles, diffs] = await Promise.all([
|
|
getJson("/production/capacity", headers),
|
|
getJson("/alerts", headers),
|
|
getJson(`/plugin-lifecycles?pluginId=${encodeURIComponent(plugin.id)}&serverInstanceId=${encodeURIComponent(server.id)}`, headers),
|
|
getJson(`/ai/config-diffs?serverInstanceId=${encodeURIComponent(server.id)}`, headers)
|
|
]);
|
|
const alert = findRequired(alerts.items, (item) => item.id === admission.alertId && item.state === "active", "active capacity alert");
|
|
const installation = findRequired(lifecycles.items, (item) => item.id === lifecycle.installation.id && item.jobId === lifecycle.job.id, "durable plugin lifecycle installation");
|
|
const diff = findRequired(diffs.items, (item) => item.id === aiInvocation.configRecommendation.diffId && item.state === "pending", "pending AI config diff");
|
|
if (capacity.activeAlerts < 1 || !capacity.endpoints.some((item) => item.runEndpointId === server.runEndpointId)) {
|
|
throw new Error(`production capacity summary did not include seeded state: ${JSON.stringify(capacity)}`);
|
|
}
|
|
|
|
for (const [label, value] of Object.entries({ admission, capacity, alert, installation, diff, aiInvocation })) {
|
|
assertNoForbiddenProjection(value, `production seed ${label}`);
|
|
}
|
|
return {
|
|
alert,
|
|
diff,
|
|
apiProof: {
|
|
admission: pick(admission, ["accepted", "state", "reason", "pressureCodes", "alertId", "auditEventId"]),
|
|
capacity: {
|
|
...pick(capacity, ["totalMaxJobs", "totalRunningJobs", "totalQueuedJobs", "activeAlerts", "generatedAt"]),
|
|
endpoints: capacity.endpoints.map((item) => pick(item, ["runEndpointId", "status", "maxJobs", "runningJobs", "queuedJobs", "logBacklogBatches", "artifactBacklogChunks", "pressureCodes"]))
|
|
},
|
|
alert: pick(alert, ["id", "sourceKind", "sourceId", "ruleKey", "severity", "state", "occurrenceCount", "lastAuditEventId"]),
|
|
pluginLifecycle: pick(installation, ["id", "pluginId", "serverInstanceId", "currentVersion", "targetVersion", "desiredState", "currentState", "lastOperation", "compatibility", "dependencyState", "jobId", "auditEventId"]),
|
|
aiConfigDiff: pick(diff, ["id", "requestId", "serverInstanceId", "pluginId", "providerId", "model", "key", "configVersion", "diffSummary", "state", "expiresAt"])
|
|
}
|
|
};
|
|
}
|
|
|
|
async function verifyAlertInteraction(chrome, headers, seededAlert) {
|
|
await chrome.navigate(`${webUrl}/#/home`);
|
|
await chrome.waitForText(["生产容量与告警", seededAlert.title, "确认"], "production alert interaction");
|
|
await chrome.evaluate((title) => {
|
|
const item = Array.from(document.querySelectorAll(".production-alert-list .operation-item")).find((candidate) => candidate.textContent?.includes(title));
|
|
const button = Array.from(item?.querySelectorAll("button") || []).find((candidate) => candidate.textContent?.trim() === "确认");
|
|
if (!(button instanceof HTMLButtonElement)) throw new Error("capacity alert acknowledge button not found");
|
|
button.click();
|
|
}, seededAlert.title);
|
|
await chrome.waitForText(["确认告警", seededAlert.id, "取消"], "alert confirmation dialog");
|
|
await chrome.evaluate(() => {
|
|
const cancel = document.querySelector(".confirm-panel .confirm-actions button");
|
|
if (!(cancel instanceof HTMLButtonElement)) throw new Error("alert confirmation cancel button not found");
|
|
cancel.click();
|
|
});
|
|
await delay(100);
|
|
if (await chrome.evaluate(() => Boolean(document.querySelector(".confirm-panel")))) {
|
|
throw new Error("alert confirmation dialog did not close after cancel");
|
|
}
|
|
const afterCancel = await getJson("/alerts", headers);
|
|
const stillActive = findRequired(afterCancel.items, (item) => item.id === seededAlert.id, "alert after confirmation cancel");
|
|
assertEqual(stillActive.state, "active", "cancel keeps durable alert active");
|
|
|
|
await chrome.evaluate((title) => {
|
|
const item = Array.from(document.querySelectorAll(".production-alert-list .operation-item")).find((candidate) => candidate.textContent?.includes(title));
|
|
const button = Array.from(item?.querySelectorAll("button") || []).find((candidate) => candidate.textContent?.trim() === "确认");
|
|
if (!(button instanceof HTMLButtonElement)) throw new Error("capacity alert acknowledge button not found after cancel");
|
|
button.click();
|
|
}, seededAlert.title);
|
|
await chrome.waitForText(["确认告警", seededAlert.id], "alert confirmation reopen");
|
|
await chrome.evaluate(() => {
|
|
const confirm = document.querySelector(".confirm-panel .confirm-primary");
|
|
if (!(confirm instanceof HTMLButtonElement)) throw new Error("alert confirmation submit button not found");
|
|
confirm.click();
|
|
});
|
|
await chrome.waitForText(["确认已由 Platform 持久化", "acknowledged"], "durable alert acknowledgement");
|
|
const alerts = await getJson("/alerts", headers);
|
|
const acknowledged = findRequired(alerts.items, (item) => item.id === seededAlert.id, "acknowledged capacity alert");
|
|
assertEqual(acknowledged.state, "acknowledged", "browser alert acknowledgement persisted");
|
|
assertNoForbiddenProjection(acknowledged, "acknowledged alert response");
|
|
return {
|
|
cancelPreservedState: stillActive.state,
|
|
persisted: pick(acknowledged, ["id", "state", "acknowledgedBy", "acknowledgedAt", "lastAuditEventId"]),
|
|
forbiddenFragmentScan: "passed",
|
|
textSample: (await chrome.visibleText()).slice(0, 1200)
|
|
};
|
|
}
|
|
|
|
async function verifyPluginLifecycleInteraction(chrome, headers, plugin, server) {
|
|
await chrome.navigate(`${webUrl}/#/plugins`);
|
|
await chrome.waitForText(["插件市场", plugin.id, "查看详情"], "plugin lifecycle marketplace");
|
|
await chrome.evaluate((pluginId) => {
|
|
const card = Array.from(document.querySelectorAll(".catalog-card")).find((candidate) => candidate.textContent?.includes(pluginId));
|
|
const button = card?.querySelector("button[title='查看插件详情']");
|
|
if (!(button instanceof HTMLButtonElement)) throw new Error("plugin detail button not found");
|
|
button.click();
|
|
}, plugin.id);
|
|
await chrome.waitForText([plugin.name, "生产生命周期", server.id, "compatible"], "plugin lifecycle detail");
|
|
await chrome.evaluate(() => {
|
|
const select = document.querySelector("select[aria-label='生命周期操作']");
|
|
if (!(select instanceof HTMLSelectElement)) throw new Error("plugin lifecycle operation select not found");
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
|
|
setter.call(select, "enable");
|
|
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
});
|
|
await chrome.evaluate(() => {
|
|
const button = document.querySelector(".plugin-lifecycle-controls .primary-command");
|
|
if (!(button instanceof HTMLButtonElement)) throw new Error("plugin lifecycle execute button not found");
|
|
button.click();
|
|
});
|
|
await chrome.waitForText(["确认启用", plugin.name, server.id], "plugin lifecycle confirmation");
|
|
await chrome.evaluate(() => {
|
|
const confirm = Array.from(document.querySelectorAll(".confirm-panel .confirm-actions button")).find((item) => item.textContent?.trim() === "启用");
|
|
if (!(confirm instanceof HTMLButtonElement)) throw new Error("plugin lifecycle confirmation submit not found");
|
|
confirm.click();
|
|
});
|
|
await chrome.waitForText(["启用:queued", "任务"], "plugin lifecycle durable dispatch");
|
|
const response = await getJson(`/plugin-lifecycles?pluginId=${encodeURIComponent(plugin.id)}&serverInstanceId=${encodeURIComponent(server.id)}`, headers);
|
|
const installation = findRequired(response.items, (item) => item.pluginId === plugin.id && item.serverInstanceId === server.id, "plugin lifecycle after browser dispatch");
|
|
assertEqual(installation.lastOperation, "enable", "browser lifecycle operation persisted");
|
|
if (!installation.jobId || !installation.auditEventId) {
|
|
throw new Error(`browser lifecycle dispatch missed job/audit linkage: ${JSON.stringify(installation)}`);
|
|
}
|
|
assertNoForbiddenProjection(installation, "browser plugin lifecycle response");
|
|
return {
|
|
persisted: pick(installation, ["id", "pluginId", "serverInstanceId", "currentState", "desiredState", "lastOperation", "dependencyState", "jobId", "auditEventId", "alertId"]),
|
|
forbiddenFragmentScan: "passed",
|
|
textSample: (await chrome.visibleText()).slice(0, 1200)
|
|
};
|
|
}
|
|
|
|
async function verifyAIConfigDiffInteraction(chrome, headers, seededDiff) {
|
|
await chrome.navigate(`${webUrl}/#/aiProviders`);
|
|
await chrome.waitForText(["AI 配置审查", seededDiff.serverInstanceId, seededDiff.diffSummary, "审查并批准"], "AI config diff review");
|
|
await chrome.evaluate(() => {
|
|
const button = Array.from(document.querySelectorAll(".ai-diff-review-panel button")).find((item) => item.textContent?.includes("审查并批准"));
|
|
if (!(button instanceof HTMLButtonElement)) throw new Error("AI diff review button not found");
|
|
button.click();
|
|
});
|
|
await chrome.waitForText(["批准 AI 配置差异", seededDiff.id, "取消"], "AI diff approval confirmation");
|
|
await chrome.evaluate(() => {
|
|
const cancel = document.querySelector(".confirm-panel .confirm-actions button");
|
|
if (!(cancel instanceof HTMLButtonElement)) throw new Error("AI diff approval cancel button not found");
|
|
cancel.click();
|
|
});
|
|
const pendingResponse = await getJson(`/ai/config-diffs?serverInstanceId=${encodeURIComponent(seededDiff.serverInstanceId)}`, headers);
|
|
const pending = findRequired(pendingResponse.items, (item) => item.id === seededDiff.id, "AI diff after approval cancel");
|
|
assertEqual(pending.state, "pending", "cancel keeps AI diff pending");
|
|
|
|
await chrome.evaluate(() => {
|
|
const button = Array.from(document.querySelectorAll(".ai-diff-review-panel button")).find((item) => item.textContent?.includes("审查并批准"));
|
|
if (!(button instanceof HTMLButtonElement)) throw new Error("AI diff review button not found after cancel");
|
|
button.click();
|
|
});
|
|
await chrome.waitForText(["批准 AI 配置差异", seededDiff.id], "AI diff approval confirmation reopen");
|
|
await chrome.evaluate(() => {
|
|
const confirm = document.querySelector(".confirm-panel .confirm-primary");
|
|
if (!(confirm instanceof HTMLButtonElement)) throw new Error("AI diff approval submit button not found");
|
|
confirm.click();
|
|
});
|
|
await chrome.waitForText(["已审批", "写入任务"], "AI diff durable approval");
|
|
const approvedResponse = await getJson(`/ai/config-diffs?serverInstanceId=${encodeURIComponent(seededDiff.serverInstanceId)}`, headers);
|
|
const approved = findRequired(approvedResponse.items, (item) => item.id === seededDiff.id, "approved AI config diff");
|
|
assertEqual(approved.state, "approved", "browser AI diff approval persisted");
|
|
if (!approved.jobId || !approved.approvedBy || !approved.approvedAt) {
|
|
throw new Error(`approved AI diff missed durable approval linkage: ${JSON.stringify(approved)}`);
|
|
}
|
|
assertNoForbiddenProjection(approved, "approved AI diff response");
|
|
return {
|
|
cancelPreservedState: pending.state,
|
|
persisted: pick(approved, ["id", "serverInstanceId", "state", "approvedBy", "approvedAt", "jobId", "configVersion", "currentConfigChecksum"]),
|
|
forbiddenFragmentScan: "passed",
|
|
textSample: (await chrome.visibleText()).slice(0, 1200)
|
|
};
|
|
}
|
|
|
|
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 currentServer = await getJson(`/server-instances/${encodeURIComponent(server.id)}`, headers);
|
|
if (currentServer.configVersion <= server.configVersion) {
|
|
throw new Error(`AI config write did not advance the durable server config version: before=${server.configVersion} after=${currentServer.configVersion}`);
|
|
}
|
|
const action = currentServer.state === "running" ? "stop" : "start";
|
|
const expectedCapability = action === "stop" ? "process.stop" : "process.start";
|
|
const request = { expectedConfigVersion: currentServer.configVersion, idempotencyKey: `browser-acceptance-${action}-${Date.now()}` };
|
|
const result = await postJson(`/server-instances/${encodeURIComponent(currentServer.id)}/${action}`, request, headers);
|
|
if (!result.accepted || !result.job?.id) {
|
|
throw new Error("lifecycle start operation did not return accepted job evidence");
|
|
}
|
|
if (result.job.capability !== expectedCapability) {
|
|
throw new Error(`lifecycle job used unexpected capability ${result.job.capability}`);
|
|
}
|
|
if (result.job.runEndpointId !== currentServer.runEndpointId) {
|
|
throw new Error(`lifecycle job used unexpected run endpoint ${result.job.runEndpointId}`);
|
|
}
|
|
|
|
const job = await waitForJob(headers, currentServer.id, result.job.id);
|
|
|
|
await chrome.navigate(`${webUrl}/#/servers/${encodeURIComponent(currentServer.id)}`);
|
|
await chrome.waitForText([currentServer.name, "管理", "AI 助手"], "server detail after lifecycle operation");
|
|
const manageState = await clickAndVerify(chrome, "管理", ["部署定义", "基本信息", "管理成员"]);
|
|
|
|
return {
|
|
action,
|
|
accepted: result.accepted,
|
|
request: {
|
|
expectedConfigVersion: request.expectedConfigVersion,
|
|
idempotencyKey: request.idempotencyKey,
|
|
configVersionBeforeAIWrite: server.configVersion,
|
|
configVersionAfterAIWrite: currentServer.configVersion
|
|
},
|
|
acceptedJob: pick(result.job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"]),
|
|
job: pick(job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"]),
|
|
proof: `platform API accepted ${expectedCapability} and platform-owned jobs endpoint returned the same job; browser verified safe management entry point without direct run access`,
|
|
browserEvidence: {
|
|
...manageState,
|
|
proofMode: "safe-management-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);
|
|
const floatingMenu = element.closest(".runtime-action-popover");
|
|
const floatingMenuRect = floatingMenu?.getBoundingClientRect();
|
|
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),
|
|
boundedFloatingMenu: Boolean(floatingMenuRect && floatingMenuRect.width <= 320 && floatingMenuRect.height <= 320)
|
|
};
|
|
})
|
|
.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) {
|
|
if (controls[index].boundedFloatingMenu !== controls[otherIndex].boundedFloatingMenu && (controls[index].boundedFloatingMenu || controls[otherIndex].boundedFloatingMenu)) {
|
|
continue;
|
|
}
|
|
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 assertNoForbiddenProjection(value, label) {
|
|
const serialized = JSON.stringify(value);
|
|
const forbidden = forbiddenFragments.find((item) => item.pattern.test(serialized));
|
|
if (forbidden) {
|
|
throw new Error(`${label} contains forbidden API fragment: ${forbidden.name}`);
|
|
}
|
|
const forbiddenField = /"(?:apiKey|apiKeyRef|rawApiKey|baseUrl|hostPath|pid|socket|dsn|rcon|credential|directRunEndpoint)"\s*:/i.exec(serialized);
|
|
if (forbiddenField) {
|
|
throw new Error(`${label} contains forbidden API field ${forbiddenField[0]}`);
|
|
}
|
|
}
|
|
|
|
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 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);
|
|
});
|