diff --git a/config/scripts/browser-snapshot-iframe-benchmark.mjs b/config/scripts/browser-snapshot-iframe-benchmark.mjs new file mode 100644 index 00000000000..5dc5b6cef5b --- /dev/null +++ b/config/scripts/browser-snapshot-iframe-benchmark.mjs @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' +import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs' + +// git show :src/main/browser/snapshot-engine.ts | node config/scripts/browser-snapshot-iframe-benchmark.mjs +const target = resolve('src/main/browser/snapshot-engine.ts') +async function load(source) { + const result = await build({ + stdin: { contents: source, loader: 'ts', resolveDir: dirname(target) }, + bundle: true, + write: false, + platform: 'node', + format: 'esm' + }) + return ( + await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) + ).buildSnapshot +} +const baseline = readFileSync(0, 'utf8') +assert.ok(baseline.includes('function buildSnapshot'), 'Pipe baseline source into stdin') +const implementations = { + before: await load(baseline), + after: await load(readFileSync(target, 'utf8')) +} +function makeTree(count) { + const children = Array.from({ length: count }, (_, index) => ({ + nodeId: String(index + 2), + backendDOMNodeId: index + 2, + role: { type: 'role', value: 'button' }, + name: { type: 'computedString', value: `Button ${index % 50}` } + })) + return [ + { + nodeId: '1', + role: { type: 'role', value: 'WebArea' }, + childIds: children.map((node) => node.nodeId) + }, + ...children + ] +} +function makeSender(nodes) { + return async (method) => { + if (method === 'Accessibility.enable') { + return {} + } + if (method === 'Accessibility.getFullAXTree') { + return { nodes } + } + if (method === 'Runtime.evaluate') { + return { result: { value: '[]' } } + } + throw new Error(`Unexpected method: ${method}`) + } +} +const results = [] +for (const [mainRefs, frameCount, refsPerFrame] of [ + [100, 0, 0], + [100, 1, 20], + [500, 2, 250], + [1000, 5, 1000], + [2000, 10, 1000] +]) { + const sender = makeSender(makeTree(mainRefs)) + const iframeSender = makeSender(makeTree(refsPerFrame)) + const sessions = new Map( + Array.from({ length: frameCount }, (_, index) => [`frame-${index}`, `session-${index}`]) + ) + const run = (arm) => implementations[arm](sender, sessions, () => iframeSender) + const expected = await run('before') + assert.deepEqual(await run('after'), expected) + for (let warmup = 0; warmup < 4; warmup += 1) { + await run('before') + await run('after') + } + const totalRefs = mainRefs + frameCount * refsPerFrame + const iterations = Math.max(1, Math.floor(12_000 / totalRefs)) + const samples = { before: [], after: [] } + for (const pair of buildCounterbalancedSchedule(8, 'before', 'after')) { + for (const arm of pair) { + const started = performance.now() + let actual + for (let repeat = 0; repeat < iterations; repeat += 1) { + actual = await run(arm) + } + samples[arm].push(performance.now() - started) + assert.deepEqual(actual, expected) + } + } + results.push({ + mainRefs, + frameCount, + refsPerFrame, + totalRefs, + iterations, + before: summarizeBenchmarkSamples(samples.before), + after: summarizeBenchmarkSamples(samples.after) + }) +} +console.log(JSON.stringify({ node: process.version, platform: process.platform, results }, null, 2)) diff --git a/src/main/browser/snapshot-engine.test.ts b/src/main/browser/snapshot-engine.test.ts index 17f5341b220..e77691afc64 100644 --- a/src/main/browser/snapshot-engine.test.ts +++ b/src/main/browser/snapshot-engine.test.ts @@ -193,4 +193,101 @@ describe('buildSnapshot', () => { expect(result.snapshot).toContain('[Footer]') expect(result.snapshot).toContain('heading "Dashboard"') }) + + it('routes same-named elements with overlapping node IDs to their own iframe sessions', async () => { + const nodes = [ + node('1', 'WebArea', 'page', { childIds: ['2', '3'] }), + node('2', 'button', 'Submit', { backendDOMNodeId: 10 }), + node('3', 'heading', 'Section') + ] + const result = await buildSnapshot( + makeSender(nodes), + new Map([ + ['frame-a', 'session-a'], + ['frame-b', 'session-b'] + ]), + () => makeSender(nodes) + ) + + expect(result.refs).toEqual([ + { ref: '@e1', role: 'button', name: 'Submit' }, + { ref: '@e2', role: 'button', name: 'Submit (2nd)' }, + { ref: '@e3', role: 'button', name: 'Submit (3rd)' } + ]) + expect([...result.refMap.values()]).toEqual( + [undefined, 'session-a', 'session-b'].map((sessionId, index) => ({ + backendDOMNodeId: 10, + role: 'button', + name: 'Submit', + sessionId, + nth: index + 1 + })) + ) + expect(result.snapshot).toBe( + [ + '[@e1] button "Submit"', + 'heading "Section"', + ' [@e2] button "Submit (2nd)"', + ' heading "Section"', + ' [@e3] button "Submit (3rd)"', + ' heading "Section"' + ].join('\n') + ) + }) + + it('skips unavailable iframe trees and keeps session associations local to each snapshot', async () => { + const nodes = [node('1', 'button', 'Submit', { backendDOMNodeId: 10 })] + const result = await buildSnapshot( + makeSender(nodes), + new Map([ + ['empty', 'empty'], + ['stale', 'stale'], + ['ready', 'ready'] + ]), + (sessionId) => { + if (sessionId === 'stale') { + throw new Error('Detached iframe') + } + return makeSender(sessionId === 'empty' ? [] : nodes) + } + ) + expect([...result.refMap].map(([ref, entry]) => [ref, entry.sessionId])).toEqual([ + ['@e1', undefined], + ['@e2', 'ready'] + ]) + + const next = await buildSnapshot(makeSender(nodes)) + expect([...next.refMap].map(([ref, entry]) => [ref, entry.sessionId])).toEqual([ + ['@e1', undefined] + ]) + }) + + it('keeps array-search visits linear when a snapshot contains many iframe references', async () => { + const children = Array.from({ length: 400 }, (_, index) => String(index + 2)) + const nodes = [ + node('1', 'WebArea', 'page', { childIds: children }), + ...children.map((id) => node(id, 'button', `Button ${id}`)) + ] + const originalFind = Array.prototype.find + let visits = 0 + const find = vi + .spyOn(Array.prototype, 'find') + .mockImplementation(function (this: unknown[], predicate, thisArg) { + return originalFind.call(this, (value, index, array) => { + visits += 1 + return predicate.call(thisArg, value, index, array) + }) + }) + let result: Awaited> + try { + result = await buildSnapshot(makeSender(nodes), new Map([['frame', 'session']]), () => + makeSender(nodes) + ) + } finally { + find.mockRestore() + } + expect(result.refs).toHaveLength(800) + expect(result.refMap.get('@e800')?.sessionId).toBe('session') + expect(visits).toBeLessThanOrEqual(result.refs.length * 2) + }) }) diff --git a/src/main/browser/snapshot-engine.ts b/src/main/browser/snapshot-engine.ts index 5cdabbc9375..fb0fe5e19dd 100644 --- a/src/main/browser/snapshot-engine.ts +++ b/src/main/browser/snapshot-engine.ts @@ -61,7 +61,7 @@ export async function buildSnapshot( // Why: cross-origin iframes have their own AX trees accessible only through // their dedicated CDP session. Append their elements after the parent tree // so the agent can see and interact with iframe content. - const iframeRefSessions: { ref: string; sessionId: string }[] = [] + const iframeRefSessions = new Map() if (iframeSessions && makeIframeSender && iframeSessions.size > 0) { for (const [_frameId, sessionId] of iframeSessions) { try { @@ -82,7 +82,7 @@ export async function buildSnapshot( const startRef = refCounter walkTree(iframeRoot, iframeNodeById, 1, entries, () => refCounter++) for (let i = startRef; i < refCounter; i++) { - iframeRefSessions.push({ ref: `@e${i}`, sessionId }) + iframeRefSessions.set(`@e${i}`, sessionId) } } } catch { @@ -120,12 +120,11 @@ export async function buildSnapshot( } lines.push(`${indent}[${entry.ref}] ${entry.role} "${displayName}"`) refs.push({ ref: entry.ref, role: entry.role, name: displayName }) - const iframeSession = iframeRefSessions.find((s) => s.ref === entry.ref) refMap.set(entry.ref, { backendDOMNodeId: entry.backendDOMNodeId, role: entry.role, name: entry.name, - sessionId: iframeSession?.sessionId, + sessionId: iframeRefSessions.get(entry.ref), nth: total > 1 ? nth : undefined }) } else {