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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Vendored
+75
-96
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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/',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'] : []),
|
||||
|
||||
Reference in New Issue
Block a user