9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
426 lines
14 KiB
TypeScript
426 lines
14 KiB
TypeScript
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<string, unknown>;
|
|
};
|
|
|
|
type Result = {
|
|
id: string;
|
|
ok: boolean;
|
|
data?: unknown;
|
|
page?: string;
|
|
error?: string;
|
|
};
|
|
|
|
type FakeBridge = {
|
|
close: () => Promise<void>;
|
|
waitForExtension: () => Promise<void>;
|
|
sendCommand: (command: Omit<Command, 'id'>) => Promise<Result>;
|
|
};
|
|
|
|
type TestSite = {
|
|
url: string;
|
|
close: () => Promise<void>;
|
|
};
|
|
|
|
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<FakeBridge | null> {
|
|
let ws: WebSocket | null = null;
|
|
let nextId = 0;
|
|
const pending = new Map<string, (result: Result) => void>();
|
|
let resolveConnected: (() => void) | null = null;
|
|
const connected = new Promise<void>((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<boolean>((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<void>((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<Result>((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<TestSite> {
|
|
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('<!doctype html><button>Same Frame Button</button>');
|
|
return;
|
|
}
|
|
if (url.pathname === '/cross-frame') {
|
|
res.end('<!doctype html><button>Cross Frame Button</button>');
|
|
return;
|
|
}
|
|
res.end(`<!doctype html>
|
|
<main>
|
|
<button>Parent Button</button>
|
|
<iframe title="same frame" src="/same-frame"></iframe>
|
|
<iframe title="cross frame" src="http://b.opencli.test:${addressPort(server)}/cross-frame"></iframe>
|
|
</main>`);
|
|
});
|
|
|
|
await new Promise<void>((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<void>((resolve, reject) => {
|
|
server.close((err) => err ? reject(err) : resolve());
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
function addressPort(server: ReturnType<typeof createServer>): 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<void> {
|
|
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
child.kill('SIGTERM');
|
|
await Promise.race([
|
|
new Promise<void>((resolve) => child.once('exit', () => resolve())),
|
|
new Promise<void>((resolve) => setTimeout(resolve, 3000)),
|
|
]);
|
|
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
|
}
|
|
|
|
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
return await Promise.race([
|
|
promise,
|
|
new Promise<T>((_, 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);
|
|
});
|