diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts index e45614843..82bb16157 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -339,6 +339,303 @@ describe('background tab isolation', () => { ]); }); + it('falls back to iframe targets when the DOM sees a cross-origin frame missing from the frame tree', async () => { + const { chrome } = createChromeMock(); + chrome.debugger.sendCommand = vi.fn(async (_target: unknown, method: string, params?: { expression?: string }) => { + if (method === 'Runtime.enable') return {}; + if (method === 'Runtime.evaluate') { + if (params?.expression === '1') return { result: { value: 1 } }; + return { + result: { + value: [{ url: 'https://frame.example/reviews', name: 'app-iframe' }], + }, + }; + } + if (method === 'Page.getFrameTree') { + return { + frameTree: { + frame: { id: 'root', url: 'https://main.example/' }, + childFrames: [], + }, + }; + } + if (method === 'Target.getTargetInfo') { + return { targetInfo: { targetId: 'root-target', type: 'page' } }; + } + if (method === 'Target.getTargets') { + return { + targetInfos: [ + { targetId: 'oopif-target', parentId: 'root-target', type: 'iframe', url: 'https://frame.example/reviews', title: 'app-iframe' }, + ], + }; + } + return {}; + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); + + const result = await mod.__test__.handleCommand({ id: 'frames-fallback', action: 'frames', session: 'twitter', surface: 'adapter' }); + + expect(result).toEqual({ + id: 'frames-fallback', + ok: true, + data: [ + { index: 0, frameId: 'oopif-target', url: 'https://frame.example/reviews', name: 'app-iframe' }, + ], + }); + }); + + it('does not require iframe targets when Page.getFrameTree already matches the DOM frame', async () => { + const { chrome } = createChromeMock(); + chrome.debugger.sendCommand = vi.fn(async (_target: unknown, method: string, params?: { expression?: string }) => { + if (method === 'Runtime.enable') return {}; + if (method === 'Runtime.evaluate') { + if (params?.expression === '1') return { result: { value: 1 } }; + return { + result: { + value: [{ url: 'https://frame.example/reviews', name: 'app-iframe' }], + }, + }; + } + if (method === 'Page.getFrameTree') { + return { + frameTree: { + frame: { id: 'root', url: 'https://main.example/' }, + childFrames: [ + { frame: { id: 'tree-frame', url: 'https://frame.example/reviews', name: 'app-iframe' } }, + ], + }, + }; + } + if (method === 'Target.getTargets') throw new Error('fallback should not run'); + return {}; + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); + + const result = await mod.__test__.handleCommand({ id: 'frames-tree-only', action: 'frames', session: 'twitter', surface: 'adapter' }); + + expect(result).toEqual({ + id: 'frames-tree-only', + ok: true, + data: [ + { index: 0, frameId: 'tree-frame', url: 'https://frame.example/reviews', name: 'app-iframe' }, + ], + }); + }); + + it('reports frame_enumeration_mismatch when the DOM frame has no frame tree or target match', async () => { + const { chrome } = createChromeMock(); + chrome.debugger.sendCommand = vi.fn(async (_target: unknown, method: string, params?: { expression?: string }) => { + if (method === 'Runtime.enable') return {}; + if (method === 'Runtime.evaluate') { + if (params?.expression === '1') return { result: { value: 1 } }; + return { + result: { + value: [{ url: 'https://missing.example/reviews', name: 'app-iframe' }], + }, + }; + } + if (method === 'Page.getFrameTree') { + return { + frameTree: { + frame: { id: 'root', url: 'https://main.example/' }, + childFrames: [], + }, + }; + } + if (method === 'Target.getTargets') return { targetInfos: [] }; + if (method === 'Target.getTargetInfo') { + return { targetInfo: { targetId: 'root-target', type: 'page' } }; + } + return {}; + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); + + const result = await mod.__test__.handleCommand({ id: 'frames-mismatch', action: 'frames', session: 'twitter', surface: 'adapter' }); + + expect(result).toEqual(expect.objectContaining({ + id: 'frames-mismatch', + ok: false, + errorCode: 'frame_enumeration_mismatch', + })); + }); + + it('does not match a stale DOM frame to a different target with the same name', async () => { + const { chrome } = createChromeMock(); + chrome.debugger.sendCommand = vi.fn(async (_target: unknown, method: string, params?: { expression?: string }) => { + if (method === 'Runtime.enable') return {}; + if (method === 'Runtime.evaluate') { + if (params?.expression === '1') return { result: { value: 1 } }; + return { + result: { + value: [{ url: 'https://old.example/reviews', name: 'app-iframe' }], + }, + }; + } + if (method === 'Page.getFrameTree') { + return { + frameTree: { + frame: { id: 'root', url: 'https://main.example/' }, + childFrames: [], + }, + }; + } + if (method === 'Target.getTargets') { + return { + targetInfos: [ + { targetId: 'new-target', type: 'iframe', url: 'https://new.example/reviews', title: 'app-iframe' }, + ], + }; + } + if (method === 'Target.getTargetInfo') { + return { targetInfo: { targetId: 'root-target', type: 'page' } }; + } + return {}; + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); + + const result = await mod.__test__.handleCommand({ id: 'frames-stale', action: 'frames', session: 'twitter', surface: 'adapter' }); + + expect(result).toEqual(expect.objectContaining({ + id: 'frames-stale', + ok: false, + errorCode: 'frame_enumeration_mismatch', + })); + }); + + it('reports frame_enumeration_mismatch when multiple iframe targets share the same URL', async () => { + const { chrome } = createChromeMock(); + chrome.debugger.sendCommand = vi.fn(async (_target: unknown, method: string, params?: { expression?: string }) => { + if (method === 'Runtime.enable') return {}; + if (method === 'Runtime.evaluate') { + if (params?.expression === '1') return { result: { value: 1 } }; + return { result: { value: [{ url: 'https://frame.example/reviews', name: '' }] } }; + } + if (method === 'Page.getFrameTree') { + return { frameTree: { frame: { id: 'root', url: 'https://main.example/' }, childFrames: [] } }; + } + if (method === 'Target.getTargetInfo') { + return { targetInfo: { targetId: 'root-target', type: 'page' } }; + } + if (method === 'Target.getTargets') { + return { + targetInfos: [ + { targetId: 'iframe-a', parentId: 'root-target', type: 'iframe', url: 'https://frame.example/reviews' }, + { targetId: 'iframe-b', parentId: 'root-target', type: 'iframe', url: 'https://frame.example/reviews' }, + ], + }; + } + return {}; + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); + + const result = await mod.__test__.handleCommand({ id: 'frames-ambiguous', action: 'frames', session: 'twitter', surface: 'adapter' }); + + expect(result).toEqual(expect.objectContaining({ + id: 'frames-ambiguous', + ok: false, + errorCode: 'frame_enumeration_mismatch', + })); + }); + + it('reports frame_enumeration_mismatch when snapshot iframe identity is stale', async () => { + const { chrome } = createChromeMock(); + chrome.debugger.sendCommand = vi.fn(async (_target: unknown, method: string, params?: { expression?: string }) => { + if (method === 'Runtime.enable') return {}; + if (method === 'Runtime.evaluate') { + if (params?.expression === '1') return { result: { value: 1 } }; + return { result: { value: { mismatch: true } } }; + } + if (method === 'Page.getFrameTree') { + return { frameTree: { frame: { id: 'root', url: 'https://main.example/' }, childFrames: [] } }; + } + return {}; + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); + + const result = await mod.__test__.handleCommand({ id: 'frames-stale-identity', action: 'frames', session: 'twitter', surface: 'adapter' }); + + expect(result).toEqual(expect.objectContaining({ + id: 'frames-stale-identity', + ok: false, + errorCode: 'frame_enumeration_mismatch', + })); + }); + + it('uses the same fallback frame for eval --frame after Page.getFrameTree omits it', async () => { + const { chrome } = createChromeMock(); + chrome.debugger.sendCommand = vi.fn(async (target: { tabId?: number; targetId?: string }, method: string, params?: { expression?: string }) => { + if (method === 'Runtime.enable') return {}; + if (method === 'Runtime.evaluate') { + if (params?.expression === '1') return { result: { value: 1 } }; + if (target.targetId === 'oopif-target' && params?.expression === 'document.title') { + return { result: { value: 'Frame title' } }; + } + return { + result: { + value: [{ url: 'https://frame.example/reviews', name: 'app-iframe' }], + }, + }; + } + if (method === 'Page.getFrameTree') { + return { + frameTree: { + frame: { id: 'root', url: 'https://main.example/' }, + childFrames: [], + }, + }; + } + if (method === 'Target.getTargets') { + return { + targetInfos: [ + { targetId: 'oopif-target', parentId: 'root-target', type: 'iframe', url: 'https://frame.example/reviews', title: 'app-iframe' }, + ], + }; + } + if (method === 'Target.getTargetInfo') { + return { targetInfo: { targetId: 'root-target', type: 'page' } }; + } + if (method === 'Target.setDiscoverTargets' || method === 'Target.setAutoAttach') return {}; + return {}; + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId(browserKey('judgeme'), 1); + + const result = await mod.__test__.handleCommand({ + id: 'frame-eval-fallback', + action: 'exec', + session: 'judgeme', + surface: 'browser', + frameIndex: 0, + code: 'document.title', + }); + + expect(result).toEqual(expect.objectContaining({ + id: 'frame-eval-fallback', + ok: true, + data: 'Frame title', + })); + }); + it('does not parse lease-key separators from command session fields', async () => { const { chrome } = createChromeMock(); vi.stubGlobal('chrome', chrome); @@ -1173,7 +1470,7 @@ describe('background tab isolation', () => { // SW restart and can dodge idle expiry indefinitely. expect(scheduledWhen).toBeLessThan(now + 15_000); expect(scheduledWhen).toBeGreaterThan(now + 1_000); - expect(mod.__test__.getSession(adapterKey('twitter')).idleDeadlineAt).toBeLessThan(now + 15_000); + expect(mod.__test__.getSession(adapterKey('twitter'))!.idleDeadlineAt).toBeLessThan(now + 15_000); }); it('releases owned leases from the idle alarm path', async () => { diff --git a/extension/src/background.ts b/extension/src/background.ts index 4918894c2..55582f370 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -1437,6 +1437,108 @@ function enumerateCrossOriginFrames(tree: any): Array<{ index: number; frameId: return frames; } +type CrossOriginFrame = { index: number; frameId: string; url: string; name: string }; +type DomCrossOriginFrame = { url: string; name: string }; + +const READ_SNAPSHOT_FRAMES_JS = `(() => { + const state = window.__opencli_cross_origin_frames; + if (!state || !Array.isArray(state.frames) || !Array.isArray(state.allElements)) return []; + const currentElements = state.allElements.filter((element) => element?.isConnected); + const currentMatchesSnapshot = state.documentUrl === location.href + && currentElements.length === state.allElements.length + && currentElements.every((element, index) => element === state.allElements[index]); + const framesAreCurrent = state.frames.every((frame) => frame.element?.isConnected + && currentElements.includes(frame.element)); + if (!currentMatchesSnapshot || !framesAreCurrent) return { mismatch: true }; + return state.frames.map((frame) => ({ + url: frame.element.src || frame.element.getAttribute('src') || '', + name: frame.element.name || frame.element.title || '', + })); +})()`; + +function sameFrameReference(left: { url: string; name: string }, right: { url: string; name: string }): boolean { + if (left.url && right.url) return left.url === right.url; + return !!left.name && !!right.name && left.name === right.name; +} + +async function resolveCrossOriginFrames(tabId: number): Promise { + const tree = await executor.getFrameTree(tabId); + const treeFrames = enumerateCrossOriginFrames(tree); + const rawDomFrames = await executor.evaluateAsync(tabId, READ_SNAPSHOT_FRAMES_JS); + if (rawDomFrames && typeof rawDomFrames === 'object' && !Array.isArray(rawDomFrames) + && (rawDomFrames as { mismatch?: boolean }).mismatch === true) { + throw new CommandFailure( + 'frame_enumeration_mismatch', + 'The DOM iframe set changed after the last snapshot, so its [F#] references are stale.', + 'Run browser state again before listing or evaluating frames.', + ); + } + if (!Array.isArray(rawDomFrames) || rawDomFrames.length === 0) return treeFrames; + + const domFrames = rawDomFrames.filter((frame): frame is DomCrossOriginFrame => ( + !!frame && typeof frame === 'object' + && typeof (frame as DomCrossOriginFrame).url === 'string' + && typeof (frame as DomCrossOriginFrame).name === 'string' + )); + if (domFrames.length === 0) return treeFrames; + + const unusedTreeFrames = new Set(treeFrames); + let unusedTargets: Set | null = null; + const resolved: CrossOriginFrame[] = []; + + for (const domFrame of domFrames) { + const treeMatches = [...unusedTreeFrames].filter((frame) => sameFrameReference(domFrame, frame)); + if (treeMatches.length === 1) { + const treeFrame = treeMatches[0]; + unusedTreeFrames.delete(treeFrame); + resolved.push({ ...treeFrame, index: resolved.length, name: domFrame.name || treeFrame.name }); + continue; + } + if (treeMatches.length > 1) { + throw new CommandFailure( + 'frame_enumeration_mismatch', + `DOM snapshot frame ${resolved.length} (${domFrame.url || domFrame.name || 'unknown'}) matched multiple Page.getFrameTree frames.`, + 'Use unique iframe URLs/names or refresh browser state before retrying.', + ); + } + + unusedTargets ??= new Set(await executor.getIframeTargets(tabId)); + const targetMatches = [...unusedTargets].filter((candidate) => sameFrameReference(domFrame, { + url: candidate.url, + name: candidate.title, + })); + if (targetMatches.length === 1) { + const target = targetMatches[0]; + unusedTargets.delete(target); + resolved.push({ + index: resolved.length, + frameId: target.targetId, + url: domFrame.url || target.url, + name: domFrame.name || target.title, + }); + continue; + } + if (targetMatches.length > 1) { + throw new CommandFailure( + 'frame_enumeration_mismatch', + `DOM snapshot frame ${resolved.length} (${domFrame.url || domFrame.name || 'unknown'}) matched multiple iframe targets.`, + 'Use unique iframe URLs/names or refresh browser state before retrying.', + ); + } + + throw new CommandFailure( + 'frame_enumeration_mismatch', + `DOM snapshot frame ${resolved.length} (${domFrame.url || domFrame.name || 'unknown'}) was missing from Page.getFrameTree and Target.getTargets.`, + 'Refresh browser state and retry. If the mismatch persists, report the page URL and sanitized frame metadata.', + ); + } + + for (const frame of unusedTreeFrames) { + resolved.push({ ...frame, index: resolved.length }); + } + return resolved; +} + function setLeaseSession( leaseKey: string, session: Omit, @@ -1671,8 +1773,7 @@ async function handleExec(cmd: Command, leaseKey: string): Promise { try { const aggressive = getSurfaceFromKey(leaseKey) === 'browser'; if (cmd.frameIndex != null) { - const tree = await executor.getFrameTree(tabId); - const frames = enumerateCrossOriginFrames(tree); + const frames = await resolveCrossOriginFrames(tabId); if (cmd.frameIndex < 0 || cmd.frameIndex >= frames.length) { return { id: cmd.id, ok: false, error: `Frame index ${cmd.frameIndex} out of range (${frames.length} cross-origin frames available)` }; } @@ -1690,8 +1791,7 @@ async function handleFrames(cmd: Command, leaseKey: string): Promise { const cmdTabId = await resolveCommandTabId(cmd); const tabId = await resolveTabId(cmdTabId, leaseKey); try { - const tree = await executor.getFrameTree(tabId); - return { id: cmd.id, ok: true, data: enumerateCrossOriginFrames(tree) }; + return { id: cmd.id, ok: true, data: await resolveCrossOriginFrames(tabId) }; } catch (err) { return errorResult(cmd.id, err); } diff --git a/extension/src/cdp.test.ts b/extension/src/cdp.test.ts index 3d96fd7ad..5f3403e9d 100644 --- a/extension/src/cdp.test.ts +++ b/extension/src/cdp.test.ts @@ -122,6 +122,69 @@ describe('cdp attach recovery', () => { ); }); + it('returns only iframe targets whose parent chain reaches the current tab target', async () => { + const { chrome, debuggerApi } = createChromeMock(); + debuggerApi.sendCommand = vi.fn(async (_target: unknown, method: string): Promise => { + if (method === 'Target.getTargetInfo') { + return { targetInfo: { targetId: 'current-page', type: 'page' } }; + } + if (method === 'Target.getTargets') { + return { + targetInfos: [ + { targetId: 'current-child', parentId: 'current-page', type: 'iframe', url: 'https://same.example/embed' }, + { targetId: 'nested-child', parentId: 'current-child', type: 'iframe', url: 'https://nested.example/embed' }, + { targetId: 'other-page', type: 'page', url: 'https://other.example/' }, + { targetId: 'other-child', parentId: 'other-page', type: 'iframe', url: 'https://same.example/embed' }, + { targetId: 'unowned-child', type: 'iframe', url: 'https://same.example/embed' }, + ], + }; + } + return {}; + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + const result = await mod.getIframeTargets(1); + + expect(result).toEqual([ + { targetId: 'current-child', url: 'https://same.example/embed', title: '' }, + { targetId: 'nested-child', url: 'https://nested.example/embed', title: '' }, + ]); + }); + + it('attaches a verified iframe target before evaluating in it', async () => { + const { chrome, debuggerApi } = createChromeMock(); + let frameAttached = false; + debuggerApi.attach = vi.fn(async (target?: { targetId?: string }) => { + if (target?.targetId === 'current-child') frameAttached = true; + }); + debuggerApi.sendCommand = vi.fn(async (target: any, method: string): Promise => { + if (method === 'Target.getTargetInfo') { + return { targetInfo: { targetId: 'current-page', type: 'page' } }; + } + if (method === 'Target.getTargets') { + return { + targetInfos: [ + { targetId: 'current-child', parentId: 'current-page', type: 'iframe', url: 'https://frame.test' }, + ], + }; + } + if (target?.targetId === 'current-child' && (method === 'Runtime.enable' || method === 'Runtime.evaluate')) { + if (!frameAttached) throw new Error('Debugger is not attached to the tab with id: current-child'); + if (method === 'Runtime.evaluate') return { result: { value: 'verified-frame-ok' } }; + } + return {}; + }); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + await mod.getIframeTargets(1); + const result = await mod.evaluateInFrame(1, 'document.title', 'current-child'); + + expect(result).toBe('verified-frame-ok'); + expect(debuggerApi.attach).toHaveBeenCalledWith({ targetId: 'current-child' }, '1.3'); + }); + }); function chromeMockForScreenshot(content: { width: number; height: number } = { width: 1024, height: 2048 }) { @@ -476,7 +539,7 @@ describe('cdp network capture correctness', () => { }); function createNetworkMock() { - const onEventListeners = []; + const onEventListeners: Array<(source: { tabId?: number }, method: string, params: any) => void | Promise> = []; const debuggerApi = { attach: vi.fn(async () => {}), detach: vi.fn(async () => {}), @@ -493,7 +556,7 @@ describe('cdp network capture correctness', () => { onRemoved: { addListener: vi.fn() }, onUpdated: { addListener: vi.fn() }, }; - const fire = async (method, params) => { + const fire = async (method: string, params: any) => { for (const fn of onEventListeners) await fn({ tabId: 1 }, method, params); }; return { @@ -563,7 +626,7 @@ describe('cdp evaluateInFrame stale context fallback', () => { }); it('falls back to the frame target when the cached context id went stale', async () => { - const debuggerEventListeners = []; + const debuggerEventListeners: Array<(source: { tabId?: number }, method: string, params: any) => void> = []; const debuggerApi = { attach: vi.fn(async () => {}), detach: vi.fn(async () => {}), diff --git a/extension/src/cdp.ts b/extension/src/cdp.ts index c2ce05149..094e4d707 100644 --- a/extension/src/cdp.ts +++ b/extension/src/cdp.ts @@ -11,6 +11,7 @@ const attached = new Set(); const tabFrameContexts = new Map>(); const frameTargets = new Map(); const frameTargetKeys = new Map(); +const verifiedFrameTargets = new Map(); let frameTargetCleanupRegistered = false; // Large cap so agents stop hitting silent JSON.parse failures on real API bodies. @@ -553,7 +554,8 @@ async function ensureFrameTarget( flatten: true, filter: [{ type: 'iframe', exclude: false }], }).catch(() => {}); - const targetId = await resolveFrameTargetId(tabId, frameId, targetUrl); + const targetId = verifiedFrameTargets.get(key) + || await resolveFrameTargetId(tabId, frameId, targetUrl); try { await chrome.debugger.attach({ targetId } as chrome.debugger.Debuggee, '1.3'); } catch (err) { @@ -650,6 +652,60 @@ export async function getFrameTree(tabId: number): Promise { return sendDebuggerCommand({ tabId }, 'Page.getFrameTree'); } +export type IframeTarget = { + targetId: string; + url: string; + title: string; +}; + +export async function getIframeTargets(tabId: number): Promise { + await ensureAttached(tabId); + type TargetInfo = { + targetId?: string; + id?: string; + type?: string; + url?: string; + title?: string; + parentId?: string; + }; + const [current, result] = await Promise.all([ + sendDebuggerCommand<{ targetInfo?: TargetInfo }>({ tabId }, 'Target.getTargetInfo'), + sendDebuggerCommand<{ targetInfos?: TargetInfo[] }>( + { tabId }, + 'Target.getTargets', + ), + ]); + const rootTargetId = current.targetInfo?.targetId || current.targetInfo?.id; + if (!rootTargetId) return []; + const targetInfos = result.targetInfos ?? []; + const targetsById = new Map(targetInfos.flatMap((target) => { + const targetId = target.targetId || target.id; + return targetId ? [[targetId, target] as const] : []; + })); + + function belongsToCurrentTarget(target: TargetInfo): boolean { + const visited = new Set(); + let parentId = target.parentId; + while (parentId && !visited.has(parentId)) { + if (parentId === rootTargetId) return true; + visited.add(parentId); + parentId = targetsById.get(parentId)?.parentId; + } + return false; + } + + return targetInfos.flatMap((target) => { + const targetId = target.targetId || target.id; + if (target.type !== 'iframe' || !targetId || !belongsToCurrentTarget(target)) return []; + // The resolver passes this targetId to evaluateInFrame. Pin that exact + // current-tab target so a later navigation cannot fall back by URL to a + // same-URL iframe in another tab. + const key = frameTargetKey(tabId, targetId); + verifiedFrameTargets.set(key, targetId); + return [{ targetId, url: target.url || '', title: target.title || '' }]; + }); +} + export async function evaluateInFrame( tabId: number, expression: string, @@ -798,6 +854,9 @@ function clearFrameTargetsForTab(tabId: number): void { frameTargetKeys.delete(targetId); chrome.debugger.detach({ targetId } as chrome.debugger.Debuggee).catch(() => {}); } + for (const key of [...verifiedFrameTargets.keys()]) { + if (key.startsWith(`${tabId}:`)) verifiedFrameTargets.delete(key); + } } export async function detach(tabId: number): Promise { diff --git a/src/browser/dom-snapshot.test.ts b/src/browser/dom-snapshot.test.ts index f37cf2dbf..8e013d16c 100644 --- a/src/browser/dom-snapshot.test.ts +++ b/src/browser/dom-snapshot.test.ts @@ -19,6 +19,14 @@ describe('generateSnapshotJs', () => { expect(js.length).toBeGreaterThan(100); }); + it('stores iframe identity from the snapshot walker instead of a document-only selector', () => { + const js = generateSnapshotJs(); + + expect(js).toContain('iframeElements.push(el)'); + expect(js).toContain('const allElements = iframeElements'); + expect(js).not.toContain("const allElements = Array.from(document.querySelectorAll('iframe'))"); + }); + it('generates syntactically valid JS (can be parsed)', () => { const js = generateSnapshotJs(); expect(() => new Function(js)).not.toThrow(); diff --git a/src/browser/dom-snapshot.ts b/src/browser/dom-snapshot.ts index e47518aed..ab271ae17 100644 --- a/src/browser/dom-snapshot.ts +++ b/src/browser/dom-snapshot.ts @@ -663,6 +663,8 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string { const compoundInfos = {}; let iframeCount = 0; let crossOriginIndex = 0; + const crossOriginFrames = []; + const iframeElements = []; function walk(el, depth, parentPropagatingRect) { if (depth > MAX_DEPTH) return false; @@ -701,6 +703,7 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string { // iframe handling if (tag === 'iframe' && INCLUDE_IFRAMES && iframeCount < MAX_IFRAMES) { + iframeElements.push(el); return walkIframe(el, depth); } @@ -861,6 +864,7 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string { if (!doc || !doc.body) { const attrs = serializeAttrs(el); const frameLabel = '[F' + crossOriginIndex + ']'; + crossOriginFrames.push({ element: el, url: el.src || el.getAttribute('src') || '', name: el.name || el.title || '' }); lines.push(indent + '|iframe|' + frameLabel + ' (cross-origin, use: opencli browser frames + browser eval --frame )'); crossOriginIndex++; return false; @@ -876,6 +880,7 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string { } catch { const attrs = serializeAttrs(el); const frameLabel = '[F' + crossOriginIndex + ']'; + crossOriginFrames.push({ element: el, url: el.src || el.getAttribute('src') || '', name: el.name || el.title || '' }); lines.push(indent + '|iframe|' + frameLabel + ' (blocked, use: opencli browser frames + browser eval --frame )'); crossOriginIndex++; return false; @@ -929,6 +934,16 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string { try { window.__opencli_prev_hashes = JSON.stringify(currentHashes); } catch {} // Store ref identity map for stale-ref detection by target resolver try { window.__opencli_ref_identity = refIdentity; } catch {} + // Keep the DOM snapshot's [F#] inputs so browser frames can recover OOPIFs + // omitted by Page.getFrameTree without assigning a different frame order. + try { + const allElements = iframeElements; + window.__opencli_cross_origin_frames = { + documentUrl: location.href, + allElements: allElements, + frames: crossOriginFrames, + }; + } catch {} return lines.join('\\n'); })()