perf(browser): index iframe sessions by snapshot reference

This commit is contained in:
m4air
2026-09-11 21:45:23 -07:00
parent 20ab995065
commit 9be49e0f70
3 changed files with 205 additions and 4 deletions
@@ -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 <baseline-ref>: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))
+97
View File
@@ -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<ReturnType<typeof buildSnapshot>>
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)
})
})
+3 -4
View File
@@ -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<string, string>()
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 {