Skip to content

Commit 6b2f4cd

Browse files
xzygisxuezhangyingjackwener
authored
feat(browser): add cross-origin iframe support via CDP execution contexts (#1084)
* feat(browser): add cross-origin iframe support via CDP execution contexts Enable interaction with cross-origin iframes through CDP's execution context mechanism, without requiring content scripts or all_frames. - Track frame execution contexts via Runtime.executionContextCreated events - Add 'frames' action to list all child frames (including cross-origin) - Support frameIndex in 'exec' action to evaluate JS in specific frames - Add Page.frames() and Page.evaluateInFrame() APIs for CLI consumers - Tag cross-origin iframes with [F0]/[F1] indices in DOM snapshots - Add Page.getFrameTree to CDP allowlist Closes #1077 Change-Id: Id03361ddb616912dff3bfa8e59e8b68716de590b * fix(browser): align cross-origin iframe routing contract * fix(browser): unify iframe frame-index routing --------- Co-authored-by: xuezhangying <xuezhangying@bytedance.com> Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent fbdb1b2 commit 6b2f4cd

11 files changed

Lines changed: 364 additions & 8 deletions

File tree

extension/src/background.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,109 @@ describe('background tab isolation', () => {
154154
]);
155155
});
156156

157+
it('lists cross-origin frames in the same order exposed by snapshot [F#] markers', async () => {
158+
const { chrome } = createChromeMock();
159+
chrome.debugger.sendCommand = vi.fn(async (_target: unknown, method: string) => {
160+
if (method === 'Runtime.enable') return {};
161+
if (method === 'Runtime.evaluate') return { result: { value: 1 } };
162+
if (method === 'Page.getFrameTree') {
163+
return {
164+
frameTree: {
165+
frame: { id: 'root', url: 'https://main.example/' },
166+
childFrames: [
167+
{
168+
frame: { id: 'same-origin-parent', url: 'https://main.example/embed' },
169+
childFrames: [
170+
{
171+
frame: { id: 'cross-origin-nested', url: 'https://x.example/widget', name: 'nested-x' },
172+
childFrames: [
173+
{
174+
frame: { id: 'hidden-descendant', url: 'https://x.example/inner' },
175+
},
176+
],
177+
},
178+
],
179+
},
180+
{
181+
frame: { id: 'cross-origin-sibling', url: 'https://y.example/iframe', name: 'sibling-y' },
182+
},
183+
],
184+
},
185+
};
186+
}
187+
return {};
188+
});
189+
vi.stubGlobal('chrome', chrome);
190+
191+
const mod = await import('./background');
192+
mod.__test__.setAutomationWindowId('site:twitter', 1);
193+
194+
const result = await mod.__test__.handleCommand({ id: 'frames', action: 'frames', workspace: 'site:twitter' });
195+
196+
expect(result.ok).toBe(true);
197+
expect(result.data).toEqual([
198+
{ index: 0, frameId: 'cross-origin-nested', url: 'https://x.example/widget', name: 'nested-x' },
199+
{ index: 1, frameId: 'cross-origin-sibling', url: 'https://y.example/iframe', name: 'sibling-y' },
200+
]);
201+
});
202+
203+
it('routes exec frameIndex through the same cross-origin frame ordering as handleFrames', async () => {
204+
const { chrome } = createChromeMock();
205+
vi.stubGlobal('chrome', chrome);
206+
207+
const evaluateInFrame = vi.fn(async () => 'frame-result');
208+
vi.doMock('./cdp', () => ({
209+
registerListeners: vi.fn(),
210+
registerFrameTracking: vi.fn(),
211+
hasActiveNetworkCapture: vi.fn(() => false),
212+
detach: vi.fn(async () => {}),
213+
evaluateAsync: vi.fn(async () => 'main-result'),
214+
evaluateInFrame,
215+
getFrameTree: vi.fn(async () => ({
216+
frameTree: {
217+
frame: { id: 'root', url: 'https://main.example/' },
218+
childFrames: [
219+
{
220+
frame: { id: 'same-origin-parent', url: 'https://main.example/embed' },
221+
childFrames: [
222+
{ frame: { id: 'cross-origin-nested', url: 'https://x.example/widget', name: 'nested-x' } },
223+
],
224+
},
225+
{
226+
frame: { id: 'cross-origin-sibling', url: 'https://y.example/iframe', name: 'sibling-y' },
227+
},
228+
],
229+
},
230+
})),
231+
screenshot: vi.fn(),
232+
setFileInputFiles: vi.fn(),
233+
insertText: vi.fn(),
234+
startNetworkCapture: vi.fn(),
235+
readNetworkCapture: vi.fn(async () => []),
236+
ensureAttached: vi.fn(),
237+
}));
238+
239+
const mod = await import('./background');
240+
mod.__test__.setAutomationWindowId('site:twitter', 1);
241+
242+
const listResult = await mod.__test__.handleCommand({ id: 'frames', action: 'frames', workspace: 'site:twitter' });
243+
const execResult = await mod.__test__.handleCommand({
244+
id: 'exec-in-frame',
245+
action: 'exec',
246+
code: 'document.title',
247+
frameIndex: 0,
248+
workspace: 'site:twitter',
249+
});
250+
251+
expect(listResult.ok).toBe(true);
252+
expect(listResult.data).toEqual([
253+
{ index: 0, frameId: 'cross-origin-nested', url: 'https://x.example/widget', name: 'nested-x' },
254+
{ index: 1, frameId: 'cross-origin-sibling', url: 'https://y.example/iframe', name: 'sibling-y' },
255+
]);
256+
expect(execResult.ok).toBe(true);
257+
expect(evaluateInFrame).toHaveBeenCalledWith(1, 'document.title', 'cross-origin-nested', false);
258+
});
259+
157260
it('creates new tabs inside the automation window', async () => {
158261
const { chrome, create } = createChromeMock();
159262
vi.stubGlobal('chrome', chrome);

extension/src/background.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ function initialize(): void {
267267
initialized = true;
268268
chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds
269269
executor.registerListeners();
270+
executor.registerFrameTracking();
270271
void connect();
271272
console.log('[opencli] OpenCLI extension initialized');
272273
}
@@ -334,6 +335,8 @@ async function handleCommand(cmd: Command): Promise<Result> {
334335
return await handleNetworkCaptureStart(cmd, workspace);
335336
case 'network-capture-read':
336337
return await handleNetworkCaptureRead(cmd, workspace);
338+
case 'frames':
339+
return await handleFrames(cmd, workspace);
337340
default:
338341
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
339342
}
@@ -405,6 +408,47 @@ function matchesBindCriteria(tab: chrome.tabs.Tab, cmd: Command): boolean {
405408
return true;
406409
}
407410

411+
function getUrlOrigin(url: string | undefined): string | null {
412+
if (!url) return null;
413+
try {
414+
return new URL(url).origin;
415+
} catch {
416+
return null;
417+
}
418+
}
419+
420+
function enumerateCrossOriginFrames(tree: any): Array<{ index: number; frameId: string; url: string; name: string }> {
421+
const frames: Array<{ index: number; frameId: string; url: string; name: string }> = [];
422+
423+
function collect(node: any, accessibleOrigin: string | null) {
424+
for (const child of (node.childFrames || [])) {
425+
const frame = child.frame;
426+
const frameUrl = frame.url || frame.unreachableUrl || '';
427+
const frameOrigin = getUrlOrigin(frameUrl);
428+
429+
// Mirror dom-snapshot's [F#] rules:
430+
// - same-origin frames expand inline and do not get an [F#] slot
431+
// - cross-origin / blocked frames get one slot and stop recursion there
432+
if (accessibleOrigin && frameOrigin && frameOrigin === accessibleOrigin) {
433+
collect(child, frameOrigin);
434+
continue;
435+
}
436+
437+
frames.push({
438+
index: frames.length,
439+
frameId: frame.id,
440+
url: frameUrl,
441+
name: frame.name || '',
442+
});
443+
}
444+
}
445+
446+
const rootFrame = tree?.frameTree?.frame;
447+
const rootUrl = rootFrame?.url || rootFrame?.unreachableUrl || '';
448+
collect(tree.frameTree, getUrlOrigin(rootUrl));
449+
return frames;
450+
}
451+
408452
function setWorkspaceSession(workspace: string, session: Omit<AutomationSession, 'idleTimer' | 'idleDeadlineAt'>): void {
409453
const existing = automationSessions.get(workspace);
410454
if (existing?.idleTimer) clearTimeout(existing.idleTimer);
@@ -541,13 +585,33 @@ async function handleExec(cmd: Command, workspace: string): Promise<Result> {
541585
const tabId = await resolveTabId(cmdTabId, workspace);
542586
try {
543587
const aggressive = workspace.startsWith('browser:') || workspace.startsWith('operate:');
588+
if (cmd.frameIndex != null) {
589+
const tree = await executor.getFrameTree(tabId);
590+
const frames = enumerateCrossOriginFrames(tree);
591+
if (cmd.frameIndex < 0 || cmd.frameIndex >= frames.length) {
592+
return { id: cmd.id, ok: false, error: `Frame index ${cmd.frameIndex} out of range (${frames.length} cross-origin frames available)` };
593+
}
594+
const data = await executor.evaluateInFrame(tabId, cmd.code, frames[cmd.frameIndex].frameId, aggressive);
595+
return pageScopedResult(cmd.id, tabId, data);
596+
}
544597
const data = await executor.evaluateAsync(tabId, cmd.code, aggressive);
545598
return pageScopedResult(cmd.id, tabId, data);
546599
} catch (err) {
547600
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
548601
}
549602
}
550603

604+
async function handleFrames(cmd: Command, workspace: string): Promise<Result> {
605+
const cmdTabId = await resolveCommandTabId(cmd);
606+
const tabId = await resolveTabId(cmdTabId, workspace);
607+
try {
608+
const tree = await executor.getFrameTree(tabId);
609+
return { id: cmd.id, ok: true, data: enumerateCrossOriginFrames(tree) };
610+
} catch (err) {
611+
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
612+
}
613+
}
614+
551615
async function handleNavigate(cmd: Command, workspace: string): Promise<Result> {
552616
if (!cmd.url) return { id: cmd.id, ok: false, error: 'Missing url' };
553617
if (!isSafeNavigationUrl(cmd.url)) {
@@ -766,6 +830,7 @@ const CDP_ALLOWLIST = new Set([
766830
// Page metrics & screenshots
767831
'Page.getLayoutMetrics',
768832
'Page.captureScreenshot',
833+
'Page.getFrameTree',
769834
// Runtime.enable needed for CDP attach setup (Runtime.evaluate goes through 'exec' action)
770835
'Runtime.enable',
771836
// Emulation (used by screenshot full-page)

extension/src/cdp.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22

33
function createChromeMock() {
4+
const debuggerEventListeners: Array<(source: { tabId?: number }, method: string, params: any) => void> = [];
5+
const tabRemovedListeners: Array<(tabId: number) => void> = [];
46
const tabs = {
57
get: vi.fn(async (_tabId: number) => ({
68
id: 1,
79
windowId: 1,
810
url: 'https://x.com/home',
911
})),
10-
onRemoved: { addListener: vi.fn() },
12+
onRemoved: { addListener: vi.fn((fn: (tabId: number) => void) => { tabRemovedListeners.push(fn); }) },
1113
onUpdated: { addListener: vi.fn() },
1214
};
1315

@@ -19,6 +21,7 @@ function createChromeMock() {
1921
return {};
2022
}),
2123
onDetach: { addListener: vi.fn() },
24+
onEvent: { addListener: vi.fn((fn: (source: { tabId?: number }, method: string, params: any) => void) => { debuggerEventListeners.push(fn); }) },
2225
};
2326

2427
const scripting = {
@@ -34,6 +37,8 @@ function createChromeMock() {
3437
},
3538
debuggerApi,
3639
scripting,
40+
debuggerEventListeners,
41+
tabRemovedListeners,
3742
};
3843
}
3944

@@ -58,6 +63,34 @@ describe('cdp attach recovery', () => {
5863
expect(scripting.executeScript).not.toHaveBeenCalled();
5964
});
6065

66+
it('uses the default execution context for a frame when isolated worlds also exist', async () => {
67+
const { chrome, debuggerApi, debuggerEventListeners } = createChromeMock();
68+
vi.stubGlobal('chrome', chrome);
69+
70+
const mod = await import('./cdp');
71+
mod.registerFrameTracking();
72+
73+
expect(debuggerEventListeners).toHaveLength(1);
74+
debuggerEventListeners[0](
75+
{ tabId: 1 },
76+
'Runtime.executionContextCreated',
77+
{ context: { id: 11, auxData: { frameId: 'frame-1', isDefault: false } } },
78+
);
79+
debuggerEventListeners[0](
80+
{ tabId: 1 },
81+
'Runtime.executionContextCreated',
82+
{ context: { id: 22, auxData: { frameId: 'frame-1', isDefault: true } } },
83+
);
84+
85+
await mod.evaluateInFrame(1, 'document.title', 'frame-1');
86+
87+
expect(debuggerApi.sendCommand).toHaveBeenCalledWith(
88+
{ tabId: 1 },
89+
'Runtime.evaluate',
90+
expect.objectContaining({ contextId: 22 }),
91+
);
92+
});
93+
6194
// Dead test: chrome.scripting.executeScript was removed from cdp.ts;
6295
// this test references functionality that no longer exists. Delete or rewrite
6396
// when cdp attach-recovery logic is next updated.

0 commit comments

Comments
 (0)