import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { WebSocketServer, type WebSocket } from 'ws'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '../..'); const EXTENSION_DIR = path.join(ROOT, 'extension'); const DAEMON_PORT = 19825; type Command = { id: string; action: string; session?: string; surface?: 'browser' | 'adapter'; page?: string; url?: string; cdpMethod?: string; cdpParams?: Record; }; type Result = { id: string; ok: boolean; data?: unknown; page?: string; error?: string; }; type FakeBridge = { close: () => Promise; waitForExtension: () => Promise; sendCommand: (command: Omit) => Promise; }; type TestSite = { url: string; close: () => Promise; }; function json(res: ServerResponse, status: number, payload: unknown): void { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(payload)); } async function startFakeBridge(): Promise { let ws: WebSocket | null = null; let nextId = 0; const pending = new Map void>(); let resolveConnected: (() => void) | null = null; const connected = new Promise((resolve) => { resolveConnected = resolve; }); const server = createServer((req, res) => { const pathname = req.url?.split('?')[0] ?? '/'; if (req.method === 'GET' && pathname === '/ping') { json(res, 200, { ok: true }); return; } if (req.method === 'GET' && pathname === '/status') { json(res, 200, { ok: true, pid: process.pid, uptime: 1, daemonVersion: 'e2e', extensionConnected: ws?.readyState === ws?.OPEN, extensionVersion: 'e2e', pending: pending.size, memoryMB: 1, port: DAEMON_PORT, }); return; } json(res, 404, { ok: false, error: 'Not found' }); }); const wss = new WebSocketServer({ noServer: true }); server.on('upgrade', (req, socket, head) => { if (req.url !== '/ext') { socket.destroy(); return; } wss.handleUpgrade(req, socket, head, (client) => { ws = client; client.on('message', (raw) => { const msg = JSON.parse(raw.toString()) as Result | { type?: string }; if ('type' in msg && msg.type === 'hello') { resolveConnected?.(); return; } if ('id' in msg) { const resolver = pending.get(msg.id); if (resolver) { pending.delete(msg.id); resolver(msg); } } }); }); }); const listening = await new Promise((resolve, reject) => { server.once('error', (err: NodeJS.ErrnoException) => { if (err.code === 'EADDRINUSE') { resolve(false); return; } reject(err); }); // The extension connects to "localhost", which can resolve to IPv6 first // on macOS. Bind all loopback-capable interfaces so the smoke does not // depend on local resolver ordering. server.listen(DAEMON_PORT, () => resolve(true)); }); if (!listening) return null; return { close: async () => { ws?.close(); wss.close(); await new Promise((resolve, reject) => { server.close((err) => err ? reject(err) : resolve()); }); }, waitForExtension: () => withTimeout(connected, 45_000, 'Timed out waiting for Browser Bridge extension to connect'), sendCommand: async (command) => { if (!ws || ws.readyState !== ws.OPEN) throw new Error('Extension WebSocket is not connected'); const id = `ax-e2e-${++nextId}`; const result = new Promise((resolve) => { pending.set(id, resolve); }); ws.send(JSON.stringify({ id, ...command })); return withTimeout(result, 30_000, `Timed out waiting for ${command.action}/${command.cdpMethod ?? ''}`); }, }; } async function startTestSite(): Promise { const server = createServer((req: IncomingMessage, res: ServerResponse) => { const url = new URL(req.url ?? '/', 'http://a.opencli.test'); res.setHeader('Content-Type', 'text/html; charset=utf-8'); if (url.pathname === '/same-frame') { res.end(''); return; } if (url.pathname === '/cross-frame') { res.end(''); return; } res.end(`
`); }); await new Promise((resolve) => { server.listen(0, '127.0.0.1', () => resolve()); }); const port = addressPort(server); return { url: `http://a.opencli.test:${port}/`, close: async () => { await new Promise((resolve, reject) => { server.close((err) => err ? reject(err) : resolve()); }); }, }; } function addressPort(server: ReturnType): number { const address = server.address(); if (!address || typeof address !== 'object') throw new Error('Server is not listening'); return address.port; } function findChromeExecutable(): string | null { const candidates = [ process.env.CHROME_PATH, process.env.GOOGLE_CHROME_BIN, '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', ].filter((entry): entry is string => !!entry); for (const candidate of candidates) { if (fs.existsSync(candidate)) return candidate; } for (const binary of ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser']) { const resolved = spawnSync('which', [binary], { encoding: 'utf8' }); const found = resolved.stdout.trim(); if (resolved.status === 0 && found) return found; } return null; } function launchChrome(chromePath: string, userDataDir: string, startUrl: string): ChildProcess { return spawn(chromePath, [ // Headed by default under a real/virtual display (CI Linux runs this under // xvfb). New-headless is convenient locally — no display needed — but on // hosted runners it does not reliably start the MV3 extension service // worker, so CI forces headed via OPENCLI_E2E_HEADED=1. Set OPENCLI_E2E_ // HEADLESS=1 to opt into headless locally. ...(process.env.OPENCLI_E2E_HEADLESS === '1' && process.env.OPENCLI_E2E_HEADED !== '1' ? ['--headless=new'] : []), `--user-data-dir=${userDataDir}`, `--disable-extensions-except=${EXTENSION_DIR}`, `--load-extension=${EXTENSION_DIR}`, '--disable-features=DisableLoadExtensionCommandLineSwitch', '--enable-unsafe-extension-debugging', '--host-resolver-rules=MAP a.opencli.test 127.0.0.1,MAP b.opencli.test 127.0.0.1', '--site-per-process', '--no-first-run', '--no-default-browser-check', '--disable-background-networking', '--disable-sync', '--disable-component-update', '--disable-popup-blocking', '--no-sandbox', '--window-size=1280,720', startUrl, ], { stdio: ['ignore', 'ignore', 'pipe'], }); } async function killProcess(child: ChildProcess | null): Promise { if (!child || child.exitCode !== null || child.signalCode !== null) return; child.kill('SIGTERM'); await Promise.race([ new Promise((resolve) => child.once('exit', () => resolve())), new Promise((resolve) => setTimeout(resolve, 3000)), ]); if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); } async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { let timer: ReturnType | undefined; try { return await Promise.race([ promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), timeoutMs); }), ]); } finally { if (timer) clearTimeout(timer); } } function flattenFrameTree(frameTree: unknown): Array<{ id: string; url: string }> { const frames: Array<{ id: string; url: string }> = []; function visit(node: any): void { const frame = node?.frame; if (typeof frame?.id === 'string') { frames.push({ id: frame.id, url: String(frame.url ?? frame.unreachableUrl ?? '') }); } for (const child of node?.childFrames ?? []) visit(child); } visit((frameTree as any)?.frameTree); return frames; } function axText(axTree: unknown): string { const nodes = Array.isArray((axTree as any)?.nodes) ? (axTree as any).nodes : []; return nodes.map((node: any) => String(node?.name?.value ?? '')).join('\n'); } function shouldFailOnBridgeUnavailable(): boolean { // In CI this smoke is scheduled only on Linux (headed under xvfb — the one // hosted environment where a real Chrome reliably starts an MV3 extension), // so any bridge failure there is a real product/packaging regression and // must block. macOS/Windows gate on the browser-free transport contracts // instead; see the e2e-headed workflow for the rationale. return process.env.CI === 'true'; } describe('Browser Bridge AX real Chrome smoke', () => { let bridge: FakeBridge | null = null; let site: TestSite | null = null; let chrome: ChildProcess | null = null; let chromeStderr = ''; let userDataDir = ''; let skipReason = ''; beforeAll(async () => { bridge = await startFakeBridge(); if (!bridge) { skipReason = process.env.CI ? 'Port 19825 is already in use in CI' : 'Port 19825 is already in use; stop opencli daemon before running this e2e smoke locally'; return; } const chromePath = findChromeExecutable(); if (!chromePath) { skipReason = 'Chrome executable not found'; return; } site = await startTestSite(); userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-ax-chrome-')); chrome = launchChrome(chromePath, userDataDir, 'about:blank'); chrome.stderr?.on('data', (chunk) => { chromeStderr += chunk.toString(); if (chromeStderr.length > 20_000) chromeStderr = chromeStderr.slice(-20_000); }); try { await bridge.waitForExtension(); } catch (err) { const tail = chromeStderr.split('\n').slice(-30).join('\n').trim(); const message = `${err instanceof Error ? err.message : String(err)}${tail ? `\nChrome stderr:\n${tail}` : ''}`; if (shouldFailOnBridgeUnavailable()) throw new Error(message); skipReason = message; } }, 60_000); afterAll(async () => { await killProcess(chrome); await site?.close(); await bridge?.close(); if (userDataDir) { for (let attempt = 0; attempt < 3; attempt++) { try { fs.rmSync(userDataDir, { recursive: true, force: true }); break; } catch { await new Promise((resolve) => setTimeout(resolve, 250)); } } } }); it('returns AX nodes for parent and same-origin iframe, and probes cross-origin frame support', async () => { if (skipReason) { if (shouldFailOnBridgeUnavailable()) throw new Error(skipReason); console.warn(`skipped — ${skipReason}`); return; } expect(bridge).toBeTruthy(); expect(site).toBeTruthy(); const session = `ax-smoke-${Date.now()}`; const browserSession = { session, surface: 'browser' as const }; const nav = await bridge!.sendCommand({ action: 'navigate', ...browserSession, url: site!.url }); expect(nav.ok, nav.error).toBe(true); expect(nav.page).toBeTruthy(); const rootEnable = await bridge!.sendCommand({ action: 'cdp', ...browserSession, page: nav.page, cdpMethod: 'Accessibility.enable', cdpParams: {}, }); expect(rootEnable.ok, rootEnable.error).toBe(true); const rootAx = await bridge!.sendCommand({ action: 'cdp', ...browserSession, page: nav.page, cdpMethod: 'Accessibility.getFullAXTree', cdpParams: {}, }); expect(rootAx.ok, rootAx.error).toBe(true); expect(axText(rootAx.data)).toContain('Parent Button'); const frameTree = await bridge!.sendCommand({ action: 'cdp', ...browserSession, page: nav.page, cdpMethod: 'Page.getFrameTree', cdpParams: {}, }); expect(frameTree.ok, frameTree.error).toBe(true); const frames = flattenFrameTree(frameTree.data); const sameFrame = frames.find((frame) => frame.url.includes('/same-frame')); const crossFrame = frames.find((frame) => frame.url.includes('/cross-frame')); expect(sameFrame).toBeTruthy(); expect(crossFrame).toBeTruthy(); const sameAx = await bridge!.sendCommand({ action: 'cdp', ...browserSession, page: nav.page, cdpMethod: 'Accessibility.getFullAXTree', cdpParams: { frameId: sameFrame!.id }, }); expect(sameAx.ok, sameAx.error).toBe(true); expect(axText(sameAx.data)).toContain('Same Frame Button'); const crossEnable = await bridge!.sendCommand({ action: 'cdp', ...browserSession, page: nav.page, cdpMethod: 'Accessibility.enable', cdpParams: { frameId: crossFrame!.id, sessionId: 'target', targetUrl: crossFrame!.url }, }); if (!crossEnable.ok) { expect(crossEnable.error).toMatch(/No iframe target found|No target with given id|not supported/i); return; } const crossAx = await bridge!.sendCommand({ action: 'cdp', ...browserSession, page: nav.page, cdpMethod: 'Accessibility.getFullAXTree', cdpParams: { frameId: crossFrame!.id, sessionId: 'target', targetUrl: crossFrame!.url }, }); expect(crossAx.ok, crossAx.error).toBe(true); expect(axText(crossAx.data)).toContain('Cross Frame Button'); }, 60_000); });