mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 16:02:35 +00:00
perf(browser): index iframe sessions by snapshot reference
This commit is contained in:
@@ -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))
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user