Files
jackwener--opencli/tests/e2e/browser-ax-chrome.test.ts
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

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);
});