import { spawn } from 'node:child_process';
import { accessSync } from 'node:fs';
import fs from 'node:fs/promises';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const inheritedChildEnvironment = { ...globalThis['process']['env'] };
const localConfigPath = path.join(appRoot, 'game-creator.config.local.json');
const projectRoot = path.join(
os.tmpdir(),
`genarrative-ai-game-creator-smoke-${Date.now()}`,
);
const prompt = '像素风反弹弹幕厨房';
const smokeAssetPath = 'assets/uploads/smoke-chef.png';
const smokeAssetMarker = 'SMOKE_LOCAL_ASSET:chef';
const smokeAssetBytes = Buffer.concat([
Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
'base64',
),
Buffer.from(smokeAssetMarker),
]);
const smokeAudioAssetPath = 'assets/uploads/smoke-bounce.mp3';
const smokeAudioAssetBytes = 'SMOKE_LOCAL_AUDIO:bounce';
const smokeProjectConversationMarker =
'SMOKE_CONVERSATION_CONTEXT:moonlight-wok';
const smokeAgentConversationMarker =
'SMOKE_AGENT_CONVERSATION_CONTEXT:neon-kitchen';
function handoffs() {
return [
[
'design',
'Gameplay',
'定义反弹厨房核心循环',
['game/game_design.md'],
'交给数值、美术、音乐、程序组',
],
[
'balance',
'Difficulty',
'给出锅盖反弹速度、生命和得分口径',
['game/balance.json'],
'交给程序组读取',
],
[
'art',
'Asset',
'规划像素厨师、夜间厨房和锅盖弹幕资产',
['assets/manifest.art.json'],
'进入画板链路',
],
[
'audio',
'SFX',
'规划锅盖反弹音效和厨房节奏 BGM',
['assets/manifest.audio.json'],
'进入音频链路',
],
[
'code',
'Code',
'生成 canvas 可玩原型',
['game/index.html'],
'交给 Playtest',
],
[
'publishing',
'Publish',
'整理标题、标签和发布说明',
['exports/README.md'],
'等待预览验收',
],
].map(([group, role, summary, outputs, next]) => ({
group,
role,
summary,
outputs,
next,
}));
}
function draft({ withInput }) {
const inputCode = withInput
? "window.addEventListener('keydown', (event) => { if (event.key.toLowerCase() === 'r') resetGame(); player.x += event.key === 'ArrowRight' ? 8 : event.key === 'ArrowLeft' ? -8 : 0; });"
: '';
return {
title: '反弹弹幕厨房',
designMarkdown:
'玩家控制像素厨师移动锅盖反弹月光弹幕,点亮三口锅后获胜,被弹幕击中耗尽生命则失败。',
balance: {
playerSpeed: 220,
playerLives: 3,
scorePerPot: 100,
difficultyRamp: '每 20 秒增加一枚弹幕',
},
artManifest: {
source: 'local-provider',
items: [
{ kind: 'character', title: '像素厨师', status: 'needs-canvas' },
{ kind: 'scene', title: '夜间厨房', status: 'needs-canvas' },
],
},
audioManifest: {
source: 'local-provider',
items: [
{
kind: 'background-music',
title: '厨房节奏 BGM',
status: 'needs-canvas',
},
{ kind: 'sound-effect', title: '锅盖反弹音', status: 'needs-canvas' },
],
},
publishReadme:
'## 标签\n\n弹幕 / 反弹 / 厨房\n\n## 下一步\n\n试玩锅盖反弹手感。',
handoffs: handoffs(),
handoffSummary:
'策划组 / Gameplay:反弹厨房核心循环\n数值组 / Difficulty:生命、速度和得分\n美术组 / Asset:像素厨房资产\n音乐组 / SFX:反弹音效\n程序组 / Code:canvas 原型\n运营组 / Publish:发布包装',
gameHtml: `
反弹弹幕厨房
`,
};
}
const responses = [
'## 核心循环\n\n反弹弹幕点亮三口锅。\n\n## Evaluator 验收\n\n必须有输入监听、主循环、胜负状态和重开路径。',
JSON.stringify(draft({ withInput: false })),
JSON.stringify(draft({ withInput: true })),
];
let responseIndex = 0;
const requestBodies = [];
const server = http.createServer((request, response) => {
let requestBody = '';
request.setEncoding('utf8');
request.on('data', (chunk) => {
requestBody += chunk;
});
request.on('end', () => {
requestBodies.push(requestBody);
let requestJson = null;
try {
requestJson = JSON.parse(requestBody);
} catch {
// Keep the request invalid so the provider fixture can return its normal error path.
}
const content = responses[responseIndex++];
if (!content) {
response.writeHead(500, { 'content-type': 'application/json' });
response.end(JSON.stringify({ error: 'local provider exhausted' }));
return;
}
if (requestJson?.stream === true) {
writeStreamingChatCompletion(response, content);
return;
}
const body = JSON.stringify({
id: `chatcmpl-local-${responseIndex}`,
model: 'local-game-creator-smoke',
choices: [
{
message: { content },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
});
response.writeHead(200, {
'content-type': 'application/json',
'content-length': Buffer.byteLength(body),
});
response.end(body);
});
});
function writeStreamingChatCompletion(response, content) {
response.writeHead(200, {
'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache',
});
const splitAt = Math.max(1, Math.floor(content.length / 2));
for (const chunk of [content.slice(0, splitAt), content.slice(splitAt)]) {
if (!chunk) {
continue;
}
response.write(
`data: ${JSON.stringify({
id: `chatcmpl-local-${responseIndex}`,
model: 'local-game-creator-smoke',
choices: [{ delta: { content: chunk }, finish_reason: null }],
})}\n\n`,
);
}
response.write(
`data: ${JSON.stringify({
id: `chatcmpl-local-${responseIndex}`,
model: 'local-game-creator-smoke',
choices: [{ delta: {}, finish_reason: 'stop' }],
})}\n\n`,
);
response.end('data: [DONE]\n\n');
}
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
const baseUrl = `http://127.0.0.1:${address.port}`;
const previousLocalConfig = await readOptionalFile(localConfigPath);
try {
await seedLocalAsset();
await writeSmokeLocalConfig(baseUrl);
const {
output,
previewHtml,
previewUrl,
previewAsset,
previewAssetHead,
previewAudio,
previewAudioHead,
previewDom,
} = await runAgent();
const tracePath = path.join(projectRoot, '.agent/run.latest.json');
const trace = JSON.parse(await fs.readFile(tracePath, 'utf8'));
const pass2TaskGraph = JSON.parse(
await fs.readFile(
path.join(projectRoot, '.agent/passes/pass-2/task-graph.json'),
'utf8',
),
);
const agentDbRecords = (
await fs.readFile(path.join(projectRoot, '.agent/agent.db'), 'utf8')
)
.trim()
.split('\n')
.map((line) => JSON.parse(line));
const previewLog = await fs.readFile(
path.join(projectRoot, '.agent/logs/preview.log'),
'utf8',
);
const gameHtml = await fs.readFile(
path.join(projectRoot, 'game/index.html'),
'utf8',
);
const agenda2 = await fs.readFile(
path.join(projectRoot, '.agent/passes/pass-2/agenda.md'),
'utf8',
);
assert(output.includes('agent.run.completed'), 'CLI did not complete');
assert(
previewUrl?.startsWith('http://127.0.0.1:'),
'CLI did not print local preview URL',
);
assert(
previewHtml.includes('LOCAL_E2E_MECHANIC:reflect-kitchen'),
'local preview did not serve generated game',
);
assert(
previewDom?.includes('data-smoke-frame=') &&
previewDom.includes('data-smoke-audio="ready"'),
'headless browser did not run generated preview frame',
);
const canvasPixels = extractDomNumber(previewDom, 'smoke-canvas-pixels');
const canvasColors = extractDomNumber(previewDom, 'smoke-canvas-colors');
assert(
canvasPixels > 40 && canvasColors > 2,
`headless browser preview canvas looks blank: pixels=${canvasPixels}, colors=${canvasColors}`,
);
assert(
previewAsset.includes(smokeAssetMarker),
'local preview did not serve referenced project asset',
);
assert(
previewAudio.includes(smokeAudioAssetBytes),
'local preview did not serve referenced audio asset',
);
assert(
previewAssetHead?.statusCode === 200 &&
previewAssetHead.contentLength === String(smokeAssetBytes.length) &&
previewAssetHead.contentType === 'image/png' &&
previewAssetHead.body === '',
'local preview HEAD did not preserve referenced asset content length',
);
assert(
previewAudioHead?.statusCode === 200 &&
previewAudioHead.contentLength ===
String(Buffer.byteLength(smokeAudioAssetBytes)) &&
previewAudioHead.contentType === 'audio/mpeg' &&
previewAudioHead.body === '',
'local preview HEAD did not preserve referenced audio asset metadata',
);
assert(
responseIndex === responses.length,
`provider calls ${responseIndex}/${responses.length}`,
);
assert(
requestBodies.every((body) => body.includes('"stream":true')),
'provider requests did not use streaming LLM mode',
);
assert(
requestBodies.some((body) =>
body.includes('"model":"planner-smoke-model"'),
),
'provider requests did not use planner agent LLM override',
);
assert(
requestBodies.some((body) =>
body.includes('"model":"generator-smoke-model"'),
),
'provider requests did not use generator agent LLM override',
);
assert(
requestBodies.every((body) => !body.includes('global-smoke-model-unused')),
'provider requests unexpectedly used global LLM config',
);
assert(
requestBodies.some(
(body) =>
body.includes('# 本地项目资产') &&
body.includes(smokeAssetPath) &&
body.includes(smokeAudioAssetPath),
),
'provider requests missing local asset prompt context',
);
assert(
requestBodies.some((body) =>
body.includes(smokeProjectConversationMarker),
) &&
requestBodies.some((body) => body.includes(smokeAgentConversationMarker)),
'provider requests missing recent conversation prompt context',
);
assert(
requestBodies.every((body) => !body.includes('sk-smoke-secret')),
'provider requests leaked sensitive conversation context',
);
assert(trace.status === 'preview-stopped', `trace status ${trace.status}`);
assert(trace.passes === 2, `trace passes ${trace.passes}`);
const expectedToolCallCount = trace.steps.reduce(
(total, step) => total + (step.toolCalls?.length ?? 0),
0,
);
assert(
trace.toolCallCount === expectedToolCallCount,
`trace toolCallCount ${trace.toolCallCount}/${expectedToolCallCount}`,
);
assert(
trace.toolCallCount <= trace.maxToolCalls,
`trace tool budget ${trace.toolCallCount}/${trace.maxToolCalls}`,
);
const tracedGroups = new Set(
trace.steps.map((step) => step.group).filter(Boolean),
);
for (const group of [
'design',
'balance',
'art',
'audio',
'code',
'publishing',
]) {
assert(
tracedGroups.has(group),
`trace missing professional group ${group}`,
);
}
assert(
trace.taskGraph?.repairRoutes?.some(
(route) =>
route.reason === 'code-runtime+dependency-impact' &&
route.taskIds.includes('code-prototype') &&
route.taskIds.includes('preview-readiness') &&
route.taskIds.includes('publish-package'),
),
'trace missing code-runtime dependency-impact repair route',
);
assert(
pass2TaskGraph.repairRoutes?.some(
(route) =>
route.reason === 'code-runtime+dependency-impact' &&
route.taskIds.includes('code-prototype') &&
route.taskIds.includes('preview-readiness') &&
route.taskIds.includes('publish-package'),
),
'pass task graph missing downstream-impact code repair route',
);
assert(
trace.artifacts?.some((artifact) => artifact.path === '.agent/agent.db'),
'trace artifacts missing .agent/agent.db',
);
assert(
trace.artifacts?.some(
(artifact) => artifact.path === '.agent/passes/pass-2/task-graph.json',
),
'trace artifacts missing pass-2 task graph',
);
assert(
trace.artifacts?.some(
(artifact) => artifact.path === '.agent/logs/preview.log',
),
'trace artifacts missing preview log',
);
assert(
trace.artifacts?.some(
(artifact) => artifact.path === '.agent/logs/agent.log',
) &&
trace.artifacts?.some(
(artifact) => artifact.path === '.agent/logs/command.log',
),
'trace artifacts missing agent or command logs',
);
assert(
previewLog.includes('preview.running') &&
previewLog.includes('preview.stopped'),
'preview log missing start or stop entries',
);
assert(
agentDbRecords.some((record) => record.recordType === 'project.init') &&
agentDbRecords.some(
(record) => record.recordType === 'game.generate_draft',
),
'agent.db missing init or generation records',
);
assert(
agenda2.includes('activeTasks: code-director'),
'repair agenda did not target code tasks',
);
assert(
agenda2.includes('publish-package'),
'repair agenda did not target downstream publishing tasks',
);
assert(
agenda2.includes('carriedTasks: design-director'),
'repair agenda did not carry design tasks',
);
assert(
trace.steps.some(
(step) =>
step.pass === 2 &&
step.agent === '策划组 / Director' &&
step.status === 'carried-over',
),
'trace missing carried-over design role',
);
assert(
trace.steps.some(
(step) =>
step.pass === 2 &&
step.agent === '程序组 / Director' &&
step.toolCalls?.[0]?.toolId === 'agent.role.brief.code.director',
),
'trace missing code repair role',
);
assert(
trace.steps.some(
(step) =>
step.pass === 2 &&
step.agent === '运营组 / Publish' &&
step.toolCalls?.[0]?.toolId === 'agent.role.brief.publishing.publish',
),
'trace missing downstream publishing repair role',
);
assert(
trace.steps.some(
(step) =>
step.agent === 'Planner' &&
step.inputPaths?.includes('memory/session.md') &&
step.inputPaths?.includes('memory/project.md') &&
step.inputPaths?.includes('memory/blackboard.md') &&
step.inputPaths?.includes('.agent/conversations/project.jsonl') &&
step.inputPaths?.includes('.agent/conversations/agents/') &&
step.inputPaths?.includes('.agent/manifest.json'),
),
'trace missing planner memory, conversation or manifest inputs',
);
assert(
trace.steps.some(
(step) =>
step.agent === '策划组 / Director' &&
step.pass === 1 &&
step.inputPaths?.includes('memory/blackboard.md') &&
step.inputPaths?.includes('.agent/conversations/project.jsonl') &&
step.inputPaths?.includes('.agent/conversations/agents/') &&
step.inputPaths?.includes('memory/agents/design/director.md') &&
step.inputPaths?.includes('.agent/manifest.json') &&
step.inputPaths?.includes('.agent/passes/pass-1/agenda.md'),
),
'trace missing role brief conversation, manifest or agenda inputs',
);
assert(
trace.steps.some(
(step) =>
step.agent === 'Generator' &&
step.pass === 2 &&
step.inputPaths?.includes('memory/blackboard.md') &&
step.inputPaths?.includes('.agent/conversations/project.jsonl') &&
step.inputPaths?.includes('.agent/conversations/agents/') &&
step.inputPaths?.includes('.agent/manifest.json') &&
step.inputPaths?.includes('.agent/passes/pass-2/agenda.md') &&
step.inputPaths?.includes('.agent/passes/pass-2/task-graph.json'),
),
'trace missing generator conversation, manifest, agenda or task graph inputs',
);
assert(
gameHtml.includes('LOCAL_E2E_MECHANIC:reflect-kitchen'),
'game html marker missing',
);
assert(
gameHtml.includes('addEventListener'),
'game html input listener missing',
);
assert(
gameHtml.includes(`/${smokeAssetPath}`),
'game html did not reference seeded local asset',
);
assert(
gameHtml.includes(`/${smokeAudioAssetPath}`),
'game html did not reference seeded local audio asset',
);
console.log('ai-game-creator-shell.agent-run.smoke=passed');
console.log(`projectPath=${projectRoot}`);
console.log(`tracePath=${tracePath}`);
} finally {
await restoreOptionalFile(localConfigPath, previousLocalConfig);
server.close();
}
async function readOptionalFile(filePath) {
try {
return await fs.readFile(filePath);
} catch (error) {
if (error?.code === 'ENOENT') {
return null;
}
throw error;
}
}
async function restoreOptionalFile(filePath, previous) {
if (previous === null) {
await fs.rm(filePath, { force: true });
return;
}
await fs.writeFile(filePath, previous);
}
async function writeSmokeLocalConfig(baseUrl) {
await fs.writeFile(
localConfigPath,
`${JSON.stringify(
{
llm: {
apiKey: 'global-smoke-key-unused',
baseUrl: 'http://127.0.0.1:1/v1',
model: 'global-smoke-model-unused',
apiKind: 'openai_chat',
stream: true,
},
agentLlm: {
planner: {
apiKey: 'planner-smoke-key',
baseUrl,
model: 'planner-smoke-model',
apiKind: 'openai_chat',
stream: true,
},
generator: {
apiKey: 'generator-smoke-key',
baseUrl,
model: 'generator-smoke-model',
apiKind: 'openai_chat',
stream: true,
},
},
},
null,
2,
)}\n`,
);
}
function runAgent() {
return new Promise((resolve, reject) => {
let previewReadStarted = false;
let previewUrl = '';
let previewHtml = '';
let previewAsset = '';
let previewAssetHead = null;
let previewAudio = '';
let previewAudioHead = null;
let previewDom = '';
const child = spawn(
'cargo',
[
'run',
'--manifest-path',
'src-tauri/Cargo.toml',
'--',
'--agent-run',
projectRoot,
prompt,
],
{
cwd: appRoot,
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...inheritedChildEnvironment,
// 该 smoke 只使用一次性 loopback Provider;生产路由仍保持锁定。
GENARRATIVE_AGC_DEBUG_PROVIDER_E2E: '1',
},
},
);
let stdout = '';
let stderr = '';
let previewReadError = null;
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
if (previewReadStarted) {
return;
}
const line = stdout
.split('\n')
.find((entry) => entry.startsWith('previewUrl='));
if (!line) {
return;
}
previewReadStarted = true;
previewUrl = line.slice('previewUrl='.length).trim();
Promise.all([
readHttpText(previewUrl),
readHttpText(new URL(smokeAssetPath, previewUrl).toString()),
readHttpHead(new URL(smokeAssetPath, previewUrl).toString()),
readHttpText(new URL(smokeAudioAssetPath, previewUrl).toString()),
readHttpHead(new URL(smokeAudioAssetPath, previewUrl).toString()),
readBrowserDom(previewUrl),
])
.then(([html, asset, assetHead, audio, audioHead, dom]) => {
previewHtml = html;
previewAsset = asset;
previewAssetHead = assetHead;
previewAudio = audio;
previewAudioHead = audioHead;
previewDom = dom;
})
.catch((error) => {
previewReadError = error;
})
.finally(() => {
child.stdin.write('\n');
});
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', reject);
child.on('close', (code, signal) => {
const output = `${stdout}${stderr}`;
if (previewReadError) {
reject(previewReadError);
return;
}
if (code === 0) {
resolve({
output,
previewHtml,
previewUrl,
previewAsset,
previewAssetHead,
previewAudio,
previewAudioHead,
previewDom,
});
} else {
reject(
new Error(
`agent run failed: exitCode=${code}, signal=${signal ?? 'none'}\n` +
`stderr tail (last 8000 characters):\n${stderr.slice(-8000)}\n` +
`stdout tail (last 4000 characters):\n${stdout.slice(-4000)}`,
),
);
}
});
});
}
async function seedLocalAsset() {
await fs.mkdir(path.join(projectRoot, 'game'), { recursive: true });
await fs.writeFile(
path.join(projectRoot, 'game/index.html'),
'还没有生成游戏',
);
await fs.mkdir(path.join(projectRoot, 'assets/uploads'), { recursive: true });
await fs.mkdir(path.join(projectRoot, '.agent'), { recursive: true });
await seedConversationContext();
await fs.writeFile(path.join(projectRoot, smokeAssetPath), smokeAssetBytes);
await fs.writeFile(
path.join(projectRoot, smokeAudioAssetPath),
smokeAudioAssetBytes,
);
await fs.writeFile(
path.join(projectRoot, '.agent/manifest.json'),
JSON.stringify(
{
schemaVersion: 'game-creation-app.manifest.v1',
projectId: 'local-provider-smoke',
name: '本地 Provider Smoke',
assets: [
{
id: 'smoke-chef',
kind: 'character',
mediaType: 'image/png',
localPath: smokeAssetPath,
source: { kind: 'uploaded' },
},
{
id: 'smoke-bounce',
kind: 'sound-effect',
mediaType: 'audio/mpeg',
localPath: smokeAudioAssetPath,
source: { kind: 'uploaded' },
},
],
},
null,
2,
),
);
}
async function seedConversationContext() {
const projectConversationPath = path.join(
projectRoot,
'.agent/conversations/project.jsonl',
);
const agentConversationPath = path.join(
projectRoot,
'.agent/conversations/agents/art-asset-plan.jsonl',
);
await fs.mkdir(path.dirname(projectConversationPath), { recursive: true });
await fs.mkdir(path.dirname(agentConversationPath), { recursive: true });
await fs.writeFile(
projectConversationPath,
`${JSON.stringify({
schemaVersion: 'game-creator-conversation.v1',
role: 'user',
content: `玩家坚持使用 ${smokeProjectConversationMarker}`,
agentId: null,
updatedAt: 1,
})}\n`,
);
await fs.writeFile(
agentConversationPath,
`${JSON.stringify({
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: `API Key sk-smoke-secret\n美术方向 ${smokeAgentConversationMarker}`,
agentId: 'art-asset-plan',
updatedAt: 2,
})}\n`,
);
}
function readHttpText(url) {
return new Promise((resolve, reject) => {
http
.get(url, (response) => {
let body = '';
response.setEncoding('utf8');
response.on('data', (chunk) => {
body += chunk;
});
response.on('end', () => resolve(body));
})
.on('error', reject);
});
}
function readHttpHead(url) {
return new Promise((resolve, reject) => {
const request = http.request(url, { method: 'HEAD' }, (response) => {
let body = '';
response.setEncoding('utf8');
response.on('data', (chunk) => {
body += chunk;
});
response.on('end', () =>
resolve({
statusCode: response.statusCode,
contentLength: response.headers['content-length'],
contentType: response.headers['content-type'],
body,
}),
);
});
request.on('error', reject);
request.end();
});
}
function readBrowserDom(url) {
return new Promise((resolve, reject) => {
const chrome = resolveChromeBin();
const child = spawn(
chrome,
[
'--headless',
'--disable-gpu',
'--disable-dev-shm-usage',
'--no-sandbox',
'--virtual-time-budget=1000',
'--dump-dom',
url,
],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) {
resolve(stdout);
} else {
reject(new Error(stderr || `headless Chrome exited with ${code}`));
}
});
});
}
function resolveChromeBin() {
const windowsRoot = path.parse(os.homedir()).root;
for (const candidate of [
path.join(
windowsRoot,
'Program Files/Google/Chrome/Application/chrome.exe',
),
path.join(
windowsRoot,
'Program Files (x86)/Google/Chrome/Application/chrome.exe',
),
path.join(
os.homedir(),
'AppData/Local/Google/Chrome/Application/chrome.exe',
),
path.join(
windowsRoot,
'Program Files/Microsoft/Edge/Application/msedge.exe',
),
path.join(
windowsRoot,
'Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
'/opt/google/chrome/chrome',
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
]) {
try {
accessSync(candidate);
return candidate;
} catch {
// Try the next supported system browser path.
}
}
return 'google-chrome';
}
function extractDomNumber(dom, kebabName) {
const match = dom?.match(new RegExp(`data-${kebabName}="(\\d+)"`));
return match ? Number(match[1]) : 0;
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}