test(browser): add real Chrome AX smoke (#1445)

* test(browser): add real Chrome AX smoke

* fix(browser): attach cross-origin frame targets directly

* fix(browser): resolve frame target by URL

* test(browser): include frame target URL in AX smoke

* fix(browser): discover iframe targets before routing

* fix(browser): resolve iframe targets through CDP

* fix(browser): auto-attach iframe targets for routing

* test(browser): make cross-origin AX smoke a capability probe

* docs(browser): mark cross-origin AX as best-effort

* ci(browser): keep AX smoke out of normal e2e sweep
This commit is contained in:
jakevin
2026-05-10 21:07:54 +08:00
committed by GitHub
parent 1364a11ab2
commit accdd970a4
15 changed files with 650 additions and 252 deletions
+3 -3
View File
@@ -9,11 +9,11 @@ outputs:
runs:
using: composite
steps:
- name: Install real Chrome (stable)
uses: browser-actions/setup-chrome@v1
- name: Install real Chrome for Testing
uses: browser-actions/setup-chrome@v2
id: setup-chrome
with:
chrome-version: stable
chrome-version: latest
- name: Verify Chrome installation
shell: bash
+23
View File
@@ -59,12 +59,35 @@ jobs:
- name: Build
run: npm run build
- name: Build extension
run: npm run build --prefix extension
- name: Run AX Chrome smoke (Linux, via xvfb)
if: runner.os == 'Linux'
env:
CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
OPENCLI_AX_E2E: '1'
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
npx vitest run --project e2e tests/e2e/browser-ax-chrome.test.ts --reporter=verbose
- name: Run AX Chrome smoke (macOS / Windows)
if: runner.os != 'Linux'
env:
CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
OPENCLI_AX_E2E: '1'
run: npx vitest run --project e2e tests/e2e/browser-ax-chrome.test.ts --reporter=verbose
- name: Run E2E tests (Linux, via xvfb)
if: runner.os == 'Linux'
env:
OPENCLI_AX_E2E: '0'
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
npx vitest run tests/e2e/ --reporter=verbose
- name: Run E2E tests (macOS / Windows)
if: runner.os != 'Linux'
env:
OPENCLI_AX_E2E: '0'
run: npx vitest run tests/e2e/ --reporter=verbose
+1 -1
View File
@@ -14,7 +14,7 @@
* **browser upload** — add `browser upload <target> <file...>` to attach local files to `input[type=file]` targets through CDP `DOM.setFileInputFiles`, with local path validation and file-input verification.
* **browser actions** — add `browser drag <source> <target>` for CDP mouse drag sequences between two resolved element centers.
* **browser wait / extension 1.0.8** — add `browser wait download [pattern]` backed by Chrome's downloads lifecycle API, so agents can wait for file downloads by filename/URL pattern and receive completed/failed download metadata.
* **browser state / extension 1.0.9** — AX snapshots can now include cross-origin iframe refs by routing CDP calls through frame target sessions, allowing `browser click <ref>` to act on accessible OOPIF controls without manual `--frame` selection.
* **browser state / extension 1.0.9** — AX snapshots can now route same-origin iframe refs through `frameId`. Cross-origin OOPIF AX routing is best-effort because real Chrome extension smoke tests show `chrome.debugger` may not expose attachable iframe targets to extensions.
### Bug Fixes
+4 -2
View File
@@ -337,8 +337,10 @@ Status after implementation:
- same-origin iframe AX refs route through `Accessibility.getFullAXTree`
`frameId` params,
- cross-origin iframe AX refs route through Browser Bridge frame target sessions
when Chrome exposes an attachable OOPIF target,
- cross-origin iframe AX routing is best-effort. Real Chrome extension smoke
verified that `chrome.debugger` may not expose attachable OOPIF iframe
targets to extensions even after `Target.setDiscoverTargets`,
`Target.getTargets`, and `Target.setAutoAttach`,
- unsupported frames degrade by omission or typed action failure rather than
requiring global manual frame switching.
+75 -96
View File
@@ -7,11 +7,9 @@ const WS_RECONNECT_MAX_DELAY = 5e3;
const attached = /* @__PURE__ */ new Set();
const tabFrameContexts = /* @__PURE__ */ new Map();
const frameSessions = /* @__PURE__ */ new Map();
const sessionFrameKeys = /* @__PURE__ */ new Map();
const pendingSessionCommands = /* @__PURE__ */ new Map();
let sessionCommandId = 0;
let frameSessionRoutingRegistered = false;
const frameTargets = /* @__PURE__ */ new Map();
const frameTargetKeys = /* @__PURE__ */ new Map();
let frameTargetCleanupRegistered = false;
const CDP_RESPONSE_BODY_CAPTURE_LIMIT = 8 * 1024 * 1024;
const CDP_REQUEST_BODY_CAPTURE_LIMIT = 1 * 1024 * 1024;
const networkCaptures = /* @__PURE__ */ new Map();
@@ -281,95 +279,74 @@ async function waitForDownload(pattern = "", timeoutMs = 3e4) {
});
});
}
function frameSessionKey(tabId, frameId) {
function frameTargetKey(tabId, frameId) {
return `${tabId}:${frameId}`;
}
function registerFrameSessionRouting() {
if (frameSessionRoutingRegistered) return;
frameSessionRoutingRegistered = true;
function registerFrameTargetCleanup() {
if (frameTargetCleanupRegistered) return;
frameTargetCleanupRegistered = true;
chrome.debugger.onEvent.addListener((_source, method, params) => {
if (method === "Target.receivedMessageFromTarget") {
const sessionId = String(params?.sessionId || "");
const raw = typeof params?.message === "string" ? params.message : "";
if (!sessionId || !raw) return;
let message;
try {
message = JSON.parse(raw);
} catch {
return;
}
if (typeof message.id !== "number") return;
const sessionPending = pendingSessionCommands.get(sessionId);
const pending = sessionPending?.get(message.id);
if (!pending) return;
clearTimeout(pending.timer);
sessionPending.delete(message.id);
if (sessionPending.size === 0) pendingSessionCommands.delete(sessionId);
if (message.error) {
pending.reject(new Error(message.error.message || JSON.stringify(message.error)));
} else {
pending.resolve(message.result);
}
}
if (method === "Target.detachedFromTarget") {
const sessionId = String(params?.sessionId || "");
const key = sessionFrameKeys.get(sessionId);
if (key) frameSessions.delete(key);
sessionFrameKeys.delete(sessionId);
const sessionPending = pendingSessionCommands.get(sessionId);
if (sessionPending) {
for (const pending of sessionPending.values()) {
clearTimeout(pending.timer);
pending.reject(new Error(`Frame target session ${sessionId} detached`));
}
pendingSessionCommands.delete(sessionId);
}
const targetId = String(params?.targetId || "");
clearFrameTarget(targetId);
}
});
}
async function ensureFrameSession(tabId, frameId, aggressiveRetry = false) {
registerFrameSessionRouting();
await ensureAttached(tabId, aggressiveRetry);
const key = frameSessionKey(tabId, frameId);
const existing = frameSessions.get(key);
if (existing) return existing;
const result = await chrome.debugger.sendCommand({ tabId }, "Target.attachToTarget", {
targetId: frameId,
flatten: false
});
const sessionId = result.sessionId;
if (!sessionId) {
throw new Error(`Frame ${frameId} did not return a CDP session id`);
}
frameSessions.set(key, sessionId);
sessionFrameKeys.set(sessionId, key);
return sessionId;
function clearFrameTarget(targetId) {
if (!targetId) return;
const key = frameTargetKeys.get(targetId);
if (key) frameTargets.delete(key);
frameTargetKeys.delete(targetId);
}
async function sendCommandInFrameTarget(tabId, frameId, method, params = {}, aggressiveRetry = false, timeoutMs = 3e4) {
const sessionId = await ensureFrameSession(tabId, frameId, aggressiveRetry);
const id = ++sessionCommandId;
const sessionPending = pendingSessionCommands.get(sessionId) ?? /* @__PURE__ */ new Map();
pendingSessionCommands.set(sessionId, sessionPending);
const command = new Promise((resolve, reject) => {
const timer = setTimeout(() => {
sessionPending.delete(id);
if (sessionPending.size === 0) pendingSessionCommands.delete(sessionId);
reject(new Error(`Frame CDP command '${method}' timed out after ${timeoutMs / 1e3}s`));
}, timeoutMs);
sessionPending.set(id, { resolve, reject, timer });
async function ensureFrameTarget(tabId, frameId, aggressiveRetry = false, targetUrl) {
registerFrameTargetCleanup();
await ensureAttached(tabId, aggressiveRetry);
const key = frameTargetKey(tabId, frameId);
const existing = frameTargets.get(key);
if (existing) return existing;
await chrome.debugger.sendCommand({ tabId }, "Target.setDiscoverTargets", { discover: true }).catch(() => {
});
await chrome.debugger.sendCommand({ tabId }, "Target.sendMessageToTarget", {
sessionId,
message: JSON.stringify({ id, method, params })
await chrome.debugger.sendCommand({ tabId }, "Target.setAutoAttach", {
autoAttach: true,
waitForDebuggerOnStart: false,
flatten: true,
filter: [{ type: "iframe", exclude: false }]
}).catch(() => {
});
return command;
const targetId = await resolveFrameTargetId(tabId, frameId, targetUrl);
try {
await chrome.debugger.attach({ targetId }, "1.3");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (!message.includes("Another debugger is already attached")) throw err;
}
frameTargets.set(key, targetId);
frameTargetKeys.set(targetId, key);
return targetId;
}
async function resolveFrameTargetId(tabId, frameId, targetUrl) {
const result = await chrome.debugger.sendCommand({ tabId }, "Target.getTargets").catch(() => null);
const targets = result?.targetInfos ?? [];
const frameTarget = targets.find((candidate) => {
const candidateId = candidate.targetId || candidate.id;
return candidate.type === "iframe" && (candidateId === frameId || !!targetUrl && candidate.url === targetUrl);
});
const targetId = frameTarget?.targetId || frameTarget?.id;
if (targetId) return targetId;
const candidates = targets.filter((target) => target.type === "iframe").map((target) => `${target.targetId || target.id || "?"} ${target.url || ""}`).join("; ");
throw new Error(`No iframe target found for frame ${frameId}${targetUrl ? ` (${targetUrl})` : ""}. Candidates: ${candidates || "none"}`);
}
async function sendCommandInFrameTarget(tabId, frameId, method, params = {}, aggressiveRetry = false, _timeoutMs = 3e4, targetUrl) {
const targetId = await ensureFrameTarget(tabId, frameId, aggressiveRetry, targetUrl);
const target = { targetId };
return chrome.debugger.sendCommand(target, method, params);
}
async function insertText(tabId, text) {
await ensureAttached(tabId);
await chrome.debugger.sendCommand({ tabId }, "Input.insertText", { text });
}
function registerFrameTracking() {
registerFrameSessionRouting();
registerFrameTargetCleanup();
chrome.debugger.onEvent.addListener((source, method, params) => {
const tabId = source.tabId;
if (!tabId) return;
@@ -493,23 +470,17 @@ async function readNetworkCapture(tabId) {
function hasActiveNetworkCapture(tabId) {
return networkCaptures.has(tabId);
}
function clearFrameSessionsForTab(tabId, reason) {
for (const [key, sessionId] of [...frameSessions.entries()]) {
function clearFrameTargetsForTab(tabId) {
for (const [key, targetId] of [...frameTargets.entries()]) {
if (!key.startsWith(`${tabId}:`)) continue;
frameSessions.delete(key);
sessionFrameKeys.delete(sessionId);
const sessionPending = pendingSessionCommands.get(sessionId);
if (sessionPending) {
for (const pending of sessionPending.values()) {
clearTimeout(pending.timer);
pending.reject(new Error(reason));
}
pendingSessionCommands.delete(sessionId);
}
frameTargets.delete(key);
frameTargetKeys.delete(targetId);
chrome.debugger.detach({ targetId }).catch(() => {
});
}
}
async function detach(tabId) {
clearFrameSessionsForTab(tabId, `Tab ${tabId} detached`);
clearFrameTargetsForTab(tabId);
if (!attached.has(tabId)) return;
attached.delete(tabId);
networkCaptures.delete(tabId);
@@ -524,15 +495,17 @@ function registerListeners() {
attached.delete(tabId);
networkCaptures.delete(tabId);
tabFrameContexts.delete(tabId);
clearFrameSessionsForTab(tabId, `Tab ${tabId} removed`);
clearFrameTargetsForTab(tabId);
});
chrome.debugger.onDetach.addListener((source) => {
if (source.tabId) {
attached.delete(source.tabId);
networkCaptures.delete(source.tabId);
tabFrameContexts.delete(source.tabId);
clearFrameSessionsForTab(source.tabId, `Tab ${source.tabId} detached`);
clearFrameTargetsForTab(source.tabId);
return;
}
if (source.targetId) clearFrameTarget(source.targetId);
});
chrome.tabs.onUpdated.addListener(async (tabId, info) => {
if (info.url && !isDebuggableUrl$1(info.url)) {
@@ -1147,7 +1120,11 @@ function initialize() {
initialized = true;
chrome.alarms.create("keepalive", { periodInMinutes: 0.4 });
registerListeners();
registerFrameTracking();
try {
const registerFrameTracking$1 = registerFrameTracking;
registerFrameTracking$1?.();
} catch {
}
void (async () => {
await getCurrentContextId();
await reconcileTargetLeaseRegistry();
@@ -1161,6 +1138,7 @@ chrome.runtime.onInstalled.addListener(() => {
chrome.runtime.onStartup.addListener(() => {
initialize();
});
initialize();
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === "keepalive") void connect();
const workspace = workspaceFromAlarmName(alarm.name);
@@ -1760,7 +1738,8 @@ async function handleCdp(cmd, workspace) {
await ensureAttached(tabId, aggressive);
const params = cmd.cdpParams ?? {};
const routeFrameId = typeof params.frameId === "string" && params.sessionId === "target" ? params.frameId : void 0;
const data = routeFrameId ? await sendCommandInFrameTarget(tabId, routeFrameId, cmd.cdpMethod, stripOpenCliFrameRoutingParams(params, true), aggressive) : await chrome.debugger.sendCommand(
const routeTargetUrl = typeof params.targetUrl === "string" ? params.targetUrl : void 0;
const data = routeFrameId ? await sendCommandInFrameTarget(tabId, routeFrameId, cmd.cdpMethod, stripOpenCliFrameRoutingParams(params, true), aggressive, 3e4, routeTargetUrl) : await chrome.debugger.sendCommand(
{ tabId },
cmd.cdpMethod,
stripOpenCliFrameRoutingParams(params, false)
@@ -1771,7 +1750,7 @@ async function handleCdp(cmd, workspace) {
}
}
function stripOpenCliFrameRoutingParams(params, stripFrameId) {
const { sessionId, frameId, ...rest } = params;
const { sessionId, frameId, targetUrl, ...rest } = params;
if (!stripFrameId && frameId !== void 0) return { ...rest, frameId };
return rest;
}
+4 -2
View File
@@ -288,7 +288,7 @@ describe('background tab isolation', () => {
);
});
it('routes frame-target CDP passthrough calls through Target session messaging', async () => {
it('routes frame-target CDP passthrough calls through the iframe target', async () => {
const { chrome } = createChromeMock();
chrome.debugger.sendCommand = vi.fn(async (_target: unknown, method: string, params?: Record<string, unknown>) => {
if (method === 'Runtime.evaluate') return { result: { value: 1 } };
@@ -316,7 +316,7 @@ describe('background tab isolation', () => {
action: 'cdp',
workspace: 'site:twitter',
cdpMethod: 'Accessibility.getFullAXTree',
cdpParams: { frameId: 'cross-frame', sessionId: 'target' },
cdpParams: { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://frame.test/' },
});
expect(result).toEqual(expect.objectContaining({ ok: true, data: { nodes: [] } }));
@@ -326,6 +326,8 @@ describe('background tab isolation', () => {
'Accessibility.getFullAXTree',
{},
false,
30_000,
'https://frame.test/',
);
});
+14 -3
View File
@@ -645,7 +645,12 @@ function initialize(): void {
initialized = true;
chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds
executor.registerListeners();
executor.registerFrameTracking();
try {
const registerFrameTracking = (executor as { registerFrameTracking?: () => void }).registerFrameTracking;
registerFrameTracking?.();
} catch {
// Some focused tests mock only the cdp functions they exercise.
}
void (async () => {
await getCurrentContextId();
await reconcileTargetLeaseRegistry();
@@ -662,6 +667,11 @@ chrome.runtime.onStartup.addListener(() => {
initialize();
});
// MV3 service workers can be started by events other than install/startup
// (including unpacked-extension e2e launches). Initialize on every worker load;
// initialize() is idempotent, so lifecycle events remain harmless.
initialize();
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === 'keepalive') void connect();
const workspace = workspaceFromAlarmName(alarm.name);
@@ -1376,8 +1386,9 @@ async function handleCdp(cmd: Command, workspace: string): Promise<Result> {
const routeFrameId = typeof params.frameId === 'string' && params.sessionId === 'target'
? params.frameId
: undefined;
const routeTargetUrl = typeof params.targetUrl === 'string' ? params.targetUrl : undefined;
const data = routeFrameId
? await executor.sendCommandInFrameTarget(tabId, routeFrameId, cmd.cdpMethod, stripOpenCliFrameRoutingParams(params, true), aggressive)
? await executor.sendCommandInFrameTarget(tabId, routeFrameId, cmd.cdpMethod, stripOpenCliFrameRoutingParams(params, true), aggressive, 30_000, routeTargetUrl)
: await chrome.debugger.sendCommand(
{ tabId },
cmd.cdpMethod,
@@ -1390,7 +1401,7 @@ async function handleCdp(cmd: Command, workspace: string): Promise<Result> {
}
function stripOpenCliFrameRoutingParams(params: Record<string, unknown>, stripFrameId: boolean): Record<string, unknown> {
const { sessionId, frameId, ...rest } = params;
const { sessionId, frameId, targetUrl, ...rest } = params;
if (!stripFrameId && frameId !== undefined) return { ...rest, frameId };
return rest;
}
+13 -35
View File
@@ -93,32 +93,17 @@ describe('cdp attach recovery', () => {
);
});
it('falls back to a frame target session when no same-target execution context exists', async () => {
it('falls back to a frame target when no same-target execution context exists', async () => {
const { chrome, debuggerApi, debuggerEventListeners } = createChromeMock();
debuggerApi.sendCommand = vi.fn(async (_target: unknown, method: string, params?: any) => {
if (method === 'Runtime.evaluate') return { result: { value: 'root-ok' } };
if (method === 'Target.attachToTarget') return { sessionId: 'session-1' };
if (method === 'Target.sendMessageToTarget') {
const message = JSON.parse(String(params.message));
queueMicrotask(() => {
for (const listener of debuggerEventListeners) {
listener(
{ tabId: 1 },
'Target.receivedMessageFromTarget',
{
sessionId: params.sessionId,
message: JSON.stringify({
id: message.id,
result: message.method === 'Runtime.evaluate'
? { result: { value: 'frame-ok' } }
: {},
}),
},
);
}
});
return {};
debuggerApi.sendCommand = vi.fn(async (target: any, method: string, _params?: any) => {
if (method === 'Target.setDiscoverTargets') return {};
if (method === 'Target.setAutoAttach') return {};
if (method === 'Target.getTargets') return { targetInfos: [{ targetId: 'oopif-frame', type: 'iframe', url: 'https://frame.test' }] };
if (target?.targetId === 'oopif-frame' && method === 'Runtime.enable') return {};
if (target?.targetId === 'oopif-frame' && method === 'Runtime.evaluate') {
return { result: { value: 'frame-ok' } };
}
if (method === 'Runtime.evaluate') return { result: { value: 'root-ok' } };
return {};
});
vi.stubGlobal('chrome', chrome);
@@ -129,18 +114,11 @@ describe('cdp attach recovery', () => {
const result = await mod.evaluateInFrame(1, 'document.title', 'oopif-frame');
expect(result).toBe('frame-ok');
expect(debuggerApi.attach).toHaveBeenCalledWith({ targetId: 'oopif-frame' }, '1.3');
expect(debuggerApi.sendCommand).toHaveBeenCalledWith(
{ tabId: 1 },
'Target.attachToTarget',
{ targetId: 'oopif-frame', flatten: false },
);
expect(debuggerApi.sendCommand).toHaveBeenCalledWith(
{ tabId: 1 },
'Target.sendMessageToTarget',
expect.objectContaining({
sessionId: 'session-1',
message: expect.stringContaining('"Runtime.evaluate"'),
}),
{ targetId: 'oopif-frame' },
'Runtime.evaluate',
expect.any(Object),
);
});
+79 -95
View File
@@ -9,15 +9,9 @@
const attached = new Set<number>();
const tabFrameContexts = new Map<number, Map<string, number>>();
const frameSessions = new Map<string, string>();
const sessionFrameKeys = new Map<string, string>();
const pendingSessionCommands = new Map<string, Map<number, {
resolve: (value: unknown) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
}>>();
let sessionCommandId = 0;
let frameSessionRoutingRegistered = false;
const frameTargets = new Map<string, string>();
const frameTargetKeys = new Map<string, string>();
let frameTargetCleanupRegistered = false;
// Large cap so agents stop hitting silent JSON.parse failures on real API bodies.
// See src/browser/cdp.ts CDP_RESPONSE_BODY_CAPTURE_LIMIT for the matching constant
@@ -419,69 +413,79 @@ export async function waitForDownload(pattern: string = '', timeoutMs: number =
});
}
function frameSessionKey(tabId: number, frameId: string): string {
function frameTargetKey(tabId: number, frameId: string): string {
return `${tabId}:${frameId}`;
}
function registerFrameSessionRouting(): void {
if (frameSessionRoutingRegistered) return;
frameSessionRoutingRegistered = true;
function registerFrameTargetCleanup(): void {
if (frameTargetCleanupRegistered) return;
frameTargetCleanupRegistered = true;
chrome.debugger.onEvent.addListener((_source, method, params: any) => {
if (method === 'Target.receivedMessageFromTarget') {
const sessionId = String(params?.sessionId || '');
const raw = typeof params?.message === 'string' ? params.message : '';
if (!sessionId || !raw) return;
let message: any;
try { message = JSON.parse(raw); } catch { return; }
if (typeof message.id !== 'number') return;
const sessionPending = pendingSessionCommands.get(sessionId);
const pending = sessionPending?.get(message.id);
if (!pending) return;
clearTimeout(pending.timer);
sessionPending!.delete(message.id);
if (sessionPending!.size === 0) pendingSessionCommands.delete(sessionId);
if (message.error) {
pending.reject(new Error(message.error.message || JSON.stringify(message.error)));
} else {
pending.resolve(message.result);
}
}
if (method === 'Target.detachedFromTarget') {
const sessionId = String(params?.sessionId || '');
const key = sessionFrameKeys.get(sessionId);
if (key) frameSessions.delete(key);
sessionFrameKeys.delete(sessionId);
const sessionPending = pendingSessionCommands.get(sessionId);
if (sessionPending) {
for (const pending of sessionPending.values()) {
clearTimeout(pending.timer);
pending.reject(new Error(`Frame target session ${sessionId} detached`));
}
pendingSessionCommands.delete(sessionId);
}
const targetId = String(params?.targetId || '');
clearFrameTarget(targetId);
}
});
}
async function ensureFrameSession(tabId: number, frameId: string, aggressiveRetry: boolean = false): Promise<string> {
registerFrameSessionRouting();
function clearFrameTarget(targetId: string): void {
if (!targetId) return;
const key = frameTargetKeys.get(targetId);
if (key) frameTargets.delete(key);
frameTargetKeys.delete(targetId);
}
async function ensureFrameTarget(
tabId: number,
frameId: string,
aggressiveRetry: boolean = false,
targetUrl?: string,
): Promise<string> {
registerFrameTargetCleanup();
await ensureAttached(tabId, aggressiveRetry);
const key = frameSessionKey(tabId, frameId);
const existing = frameSessions.get(key);
const key = frameTargetKey(tabId, frameId);
const existing = frameTargets.get(key);
if (existing) return existing;
const result = await chrome.debugger.sendCommand({ tabId }, 'Target.attachToTarget', {
targetId: frameId,
flatten: false,
}) as { sessionId?: string };
const sessionId = result.sessionId;
if (!sessionId) {
throw new Error(`Frame ${frameId} did not return a CDP session id`);
await chrome.debugger.sendCommand({ tabId }, 'Target.setDiscoverTargets', { discover: true }).catch(() => {});
await chrome.debugger.sendCommand({ tabId }, 'Target.setAutoAttach', {
autoAttach: true,
waitForDebuggerOnStart: false,
flatten: true,
filter: [{ type: 'iframe', exclude: false }],
}).catch(() => {});
const targetId = await resolveFrameTargetId(tabId, frameId, targetUrl);
try {
await chrome.debugger.attach({ targetId } as chrome.debugger.Debuggee, '1.3');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (!message.includes('Another debugger is already attached')) throw err;
}
frameSessions.set(key, sessionId);
sessionFrameKeys.set(sessionId, key);
return sessionId;
frameTargets.set(key, targetId);
frameTargetKeys.set(targetId, key);
return targetId;
}
async function resolveFrameTargetId(tabId: number, frameId: string, targetUrl?: string): Promise<string> {
const result = await chrome.debugger.sendCommand({ tabId }, 'Target.getTargets').catch(() => null) as
| { targetInfos?: Array<{ targetId?: string; id?: string; type?: string; url?: string }> }
| null;
const targets = result?.targetInfos ?? [];
const frameTarget = targets.find((candidate) => {
const candidateId = candidate.targetId || candidate.id;
return candidate.type === 'iframe'
&& (
candidateId === frameId
|| (!!targetUrl && candidate.url === targetUrl)
);
});
const targetId = frameTarget?.targetId || frameTarget?.id;
if (targetId) return targetId;
const candidates = targets
.filter((target) => target.type === 'iframe')
.map((target) => `${target.targetId || target.id || '?'} ${target.url || ''}`)
.join('; ');
throw new Error(`No iframe target found for frame ${frameId}${targetUrl ? ` (${targetUrl})` : ''}. Candidates: ${candidates || 'none'}`);
}
export async function sendCommandInFrameTarget(
@@ -490,27 +494,12 @@ export async function sendCommandInFrameTarget(
method: string,
params: Record<string, unknown> = {},
aggressiveRetry: boolean = false,
timeoutMs: number = 30_000,
_timeoutMs: number = 30_000,
targetUrl?: string,
): Promise<unknown> {
const sessionId = await ensureFrameSession(tabId, frameId, aggressiveRetry);
const id = ++sessionCommandId;
const sessionPending = pendingSessionCommands.get(sessionId) ?? new Map();
pendingSessionCommands.set(sessionId, sessionPending);
const command = new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
sessionPending.delete(id);
if (sessionPending.size === 0) pendingSessionCommands.delete(sessionId);
reject(new Error(`Frame CDP command '${method}' timed out after ${timeoutMs / 1000}s`));
}, timeoutMs);
sessionPending.set(id, { resolve, reject, timer });
});
await chrome.debugger.sendCommand({ tabId }, 'Target.sendMessageToTarget', {
sessionId,
message: JSON.stringify({ id, method, params }),
});
return command;
const targetId = await ensureFrameTarget(tabId, frameId, aggressiveRetry, targetUrl);
const target = { targetId } as chrome.debugger.Debuggee;
return chrome.debugger.sendCommand(target, method, params);
}
export async function insertText(
@@ -522,7 +511,7 @@ export async function insertText(
}
export function registerFrameTracking(): void {
registerFrameSessionRouting();
registerFrameTargetCleanup();
chrome.debugger.onEvent.addListener((source, method, params: any) => {
const tabId = source.tabId;
if (!tabId) return;
@@ -689,24 +678,17 @@ export function hasActiveNetworkCapture(tabId: number): boolean {
return networkCaptures.has(tabId);
}
function clearFrameSessionsForTab(tabId: number, reason: string): void {
for (const [key, sessionId] of [...frameSessions.entries()]) {
function clearFrameTargetsForTab(tabId: number): void {
for (const [key, targetId] of [...frameTargets.entries()]) {
if (!key.startsWith(`${tabId}:`)) continue;
frameSessions.delete(key);
sessionFrameKeys.delete(sessionId);
const sessionPending = pendingSessionCommands.get(sessionId);
if (sessionPending) {
for (const pending of sessionPending.values()) {
clearTimeout(pending.timer);
pending.reject(new Error(reason));
}
pendingSessionCommands.delete(sessionId);
}
frameTargets.delete(key);
frameTargetKeys.delete(targetId);
chrome.debugger.detach({ targetId } as chrome.debugger.Debuggee).catch(() => {});
}
}
export async function detach(tabId: number): Promise<void> {
clearFrameSessionsForTab(tabId, `Tab ${tabId} detached`);
clearFrameTargetsForTab(tabId);
if (!attached.has(tabId)) return;
attached.delete(tabId);
networkCaptures.delete(tabId);
@@ -719,15 +701,17 @@ export function registerListeners(): void {
attached.delete(tabId);
networkCaptures.delete(tabId);
tabFrameContexts.delete(tabId);
clearFrameSessionsForTab(tabId, `Tab ${tabId} removed`);
clearFrameTargetsForTab(tabId);
});
chrome.debugger.onDetach.addListener((source) => {
if (source.tabId) {
attached.delete(source.tabId);
networkCaptures.delete(source.tabId);
tabFrameContexts.delete(source.tabId);
clearFrameSessionsForTab(source.tabId, `Tab ${source.tabId} detached`);
clearFrameTargetsForTab(source.tabId);
return;
}
if (source.targetId) clearFrameTarget(source.targetId);
});
// Invalidate attached cache when tab URL changes to non-debuggable
chrome.tabs.onUpdated.addListener(async (tabId, info) => {
+6 -1
View File
@@ -130,7 +130,7 @@ Error envelope always includes `error.code` and `error.message`. Target errors (
| command | purpose |
|---------|---------|
| `browser state` | Snapshot: text tree with `[N]` refs, scroll hints, hidden-interactive hints, `compounds (N):` sidecar for date/select/file refs. |
| `browser state --source ax` | Opt-in accessibility-tree snapshot. Use when custom controls, portals, or iframe contents are hard to identify in normal `state`. AX refs can recover stale React re-renders by role/name/nth and can route same-origin plus attachable cross-origin iframe refs. |
| `browser state --source ax` | Opt-in accessibility-tree snapshot. Use when custom controls, portals, or iframe contents are hard to identify in normal `state`. AX refs can recover stale React re-renders by role/name/nth and can route same-origin iframe refs. Cross-origin iframe refs are best-effort because Chrome may not expose attachable OOPIF targets to extensions. |
| `browser state --compare-sources` | Metrics-only DOM vs AX comparison for deciding whether AX should become default. It prints counts and sizes, not page text, so it is safer to share for validation. |
| `browser find --css <sel> [--limit N] [--text-max N]` | Run a CSS query and return one entry per match with `{nth, ref, tag, role, text, attrs, visible, compound?}`. Allocates refs for matches the prior snapshot didn't tag. Cheap alternative to `state` when you already know the selector. |
| `browser find --role button --name Save` | Semantic locator query. Also supports `--label`, `--text`, and `--testid`. Use before raw CSS when a control has accessible labels. |
@@ -400,6 +400,11 @@ opencli browser frames
opencli browser eval "(() => document.querySelector('input[name=cardnumber]')?.value)()" --frame 0
```
`browser state --source ax` may omit cross-origin iframe contents or fail to
route actions into them when Chrome does not expose an attachable OOPIF target
to the extension. In that case use `browser frames` + `browser eval --frame`, a
normal DOM `state`, or navigate/bind directly to the iframe URL when possible.
---
## Pitfalls
+1 -1
View File
@@ -12,7 +12,7 @@ export interface BrowserRef {
role: string;
name: string;
nth?: number;
frame?: { frameId?: string; sessionId?: string; url?: string };
frame?: { frameId?: string; sessionId?: string; url?: string; targetUrl?: string };
}
export interface AxSnapshotTree {
+9 -9
View File
@@ -373,8 +373,8 @@ describe('BasePage native input routing', () => {
await expect(page.click('2')).resolves.toEqual({ matches_n: 1, match_level: 'exact' });
expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', { frameId: 'same-frame' });
expect(page.cdp).toHaveBeenCalledWith('Accessibility.enable', { frameId: 'cross-frame', sessionId: 'target' });
expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', { frameId: 'cross-frame', sessionId: 'target' });
expect(page.cdp).toHaveBeenCalledWith('Accessibility.enable', { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 20 });
expect(page.nativeClick).toHaveBeenCalledWith(120, 210);
});
@@ -412,9 +412,9 @@ describe('BasePage native input routing', () => {
expect(snapshot).toContain('[1]button "Cross Save"');
await expect(page.click('1')).resolves.toEqual({ matches_n: 1, match_level: 'exact' });
expect(page.cdp).toHaveBeenCalledWith('Accessibility.enable', { frameId: 'cross-frame', sessionId: 'target' });
expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', { frameId: 'cross-frame', sessionId: 'target' });
expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 99, frameId: 'cross-frame', sessionId: 'target' });
expect(page.cdp).toHaveBeenCalledWith('Accessibility.enable', { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 99, frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
expect(page.nativeClick).toHaveBeenCalledWith(320, 410);
});
@@ -454,10 +454,10 @@ describe('BasePage native input routing', () => {
await page.snapshot({ source: 'ax' });
await expect(page.click('1')).resolves.toEqual({ matches_n: 1, match_level: 'reidentified' });
expect(page.cdp).toHaveBeenCalledWith('Accessibility.enable', { frameId: 'cross-frame', sessionId: 'target' });
expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', { frameId: 'cross-frame', sessionId: 'target' });
expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 99, frameId: 'cross-frame', sessionId: 'target' });
expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 100, frameId: 'cross-frame', sessionId: 'target' });
expect(page.cdp).toHaveBeenCalledWith('Accessibility.enable', { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
expect(page.cdp).toHaveBeenCalledWith('Accessibility.getFullAXTree', { frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 99, frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
expect(page.cdp).toHaveBeenCalledWith('DOM.getBoxModel', { backendNodeId: 100, frameId: 'cross-frame', sessionId: 'target', targetUrl: 'https://other.example/embed' });
expect(page.nativeClick).toHaveBeenCalledWith(320, 410);
});
+10 -4
View File
@@ -400,7 +400,9 @@ export abstract class BasePage implements IPage {
if (typeof cdp !== 'function') return null;
const result = await cdp.call(this, 'DOM.getBoxModel', {
backendNodeId,
...(frame?.sessionId ? { frameId: frame.frameId, sessionId: frame.sessionId } : {}),
...(frame?.sessionId
? { frameId: frame.frameId, sessionId: frame.sessionId, ...(frame.targetUrl ? { targetUrl: frame.targetUrl } : {}) }
: {}),
}) as
| { model?: { content?: unknown[]; border?: unknown[] } }
| null;
@@ -1178,13 +1180,17 @@ export abstract class BasePage implements IPage {
function axTreeParams(frame: BrowserRef['frame'] | undefined): Record<string, unknown> {
return frame?.frameId
? { frameId: frame.frameId, ...(frame.sessionId ? { sessionId: frame.sessionId } : {}) }
? {
frameId: frame.frameId,
...(frame.sessionId ? { sessionId: frame.sessionId } : {}),
...(frame.targetUrl ? { targetUrl: frame.targetUrl } : {}),
}
: {};
}
function axEnableParams(frame: BrowserRef['frame'] | undefined): Record<string, unknown> {
return frame?.frameId && frame.sessionId
? { frameId: frame.frameId, sessionId: frame.sessionId }
? { frameId: frame.frameId, sessionId: frame.sessionId, ...(frame.targetUrl ? { targetUrl: frame.targetUrl } : {}) }
: {};
}
@@ -1206,7 +1212,7 @@ function collectAxFrameRefs(frameTreeResult: unknown): Array<NonNullable<Browser
frames.push({ frameId, url: frameUrl });
collect(child);
} else {
frames.push({ frameId, url: frameUrl, sessionId: 'target' });
frames.push({ frameId, url: frameUrl, targetUrl: frameUrl, sessionId: 'target' });
}
}
}
+406
View File
@@ -0,0 +1,406 @@
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;
workspace?: string;
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, 15_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, [
`--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');
}
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 (process.env.CI) throw new Error(message);
skipReason = message;
}
}, 30_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 (process.env.CI) throw new Error(skipReason);
console.warn(`skipped — ${skipReason}`);
return;
}
expect(bridge).toBeTruthy();
expect(site).toBeTruthy();
const workspace = `browser:ax-smoke-${Date.now()}`;
const nav = await bridge!.sendCommand({ action: 'navigate', workspace, url: site!.url });
expect(nav.ok, nav.error).toBe(true);
expect(nav.page).toBeTruthy();
const rootEnable = await bridge!.sendCommand({
action: 'cdp',
workspace,
page: nav.page,
cdpMethod: 'Accessibility.enable',
cdpParams: {},
});
expect(rootEnable.ok, rootEnable.error).toBe(true);
const rootAx = await bridge!.sendCommand({
action: 'cdp',
workspace,
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',
workspace,
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',
workspace,
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',
workspace,
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',
workspace,
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);
});
+2
View File
@@ -1,6 +1,7 @@
import { defineConfig } from 'vitest/config';
const includeExtendedE2e = process.env.OPENCLI_E2E === '1';
const includeAxChromeE2e = process.env.OPENCLI_AX_E2E === '1';
export default defineConfig({
test: {
@@ -39,6 +40,7 @@ export default defineConfig({
'tests/e2e/plugin-management.test.ts',
'tests/e2e/browser-tabs.test.ts',
'tests/e2e/article-download-pipeline.test.ts',
...(includeAxChromeE2e ? ['tests/e2e/browser-ax-chrome.test.ts'] : []),
// Extended browser tests (20+ sites) — opt-in only:
// OPENCLI_E2E=1 npx vitest run
...(includeExtendedE2e ? ['tests/e2e/browser-public-extended.test.ts', 'tests/e2e/browser-auth.test.ts', 'tests/e2e/douban.test.ts'] : []),