Skip to content

Commit f5b0d2d

Browse files
authored
fix: stabilize adaptive replay across volatile ids (#2051)
1 parent 2b0588e commit f5b0d2d

3 files changed

Lines changed: 91 additions & 7 deletions

File tree

packages/runtime-playground/src/browser-action-discovery.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export async function discoverBrowserActionCorpusDescriptors(page: Page | Frame)
99
const MAX_REJECTION_DIAGNOSTICS = 20
1010
const descriptors: BrowserActionCorpusDescriptor[] = []
1111
const rejected: Array<{ kind: string; tag: string; label: string }> = []
12+
const identityOccurrences = new Map<string, number>()
1213
const cssEscape = (value: string) => {
1314
const escapeFn = (globalThis as typeof globalThis & { CSS?: { escape?: (raw: string) => string } }).CSS?.escape
1415
return escapeFn ? escapeFn(value) : value.replace(/[^a-zA-Z0-9_-]/g, "\\$&")
@@ -96,7 +97,21 @@ export async function discoverBrowserActionCorpusDescriptors(page: Page | Frame)
9697
if (label && text(label.textContent)) return text(label.textContent)
9798
return text(element.textContent)
9899
}
99-
const descriptorId = (kind: string, selector: string, element: Element) => `${kind}:${selector}:${element.getAttribute("name") || ""}:${labelFor(element)}`
100+
const descriptorId = (kind: string, element: Element) => {
101+
const input = element as HTMLInputElement
102+
const identity = JSON.stringify({
103+
kind,
104+
label: labelFor(element),
105+
name: element.getAttribute("name") || "",
106+
role: element.getAttribute("role") || "",
107+
type: input.type || element.getAttribute("type") || "",
108+
href: element.tagName.toLowerCase() === "a" ? (element as HTMLAnchorElement).href : "",
109+
options: element.tagName.toLowerCase() === "select" ? Array.from((element as HTMLSelectElement).options).map((option) => option.value) : [],
110+
})
111+
const occurrence = identityOccurrences.get(identity) ?? 0
112+
identityOccurrences.set(identity, occurrence + 1)
113+
return `${kind}:${identity}:${occurrence}`
114+
}
100115
const visible = (element: Element) => {
101116
const htmlElement = element as HTMLElement
102117
const style = window.getComputedStyle(htmlElement)
@@ -114,7 +129,7 @@ export async function discoverBrowserActionCorpusDescriptors(page: Page | Frame)
114129
return
115130
}
116131
descriptors.push({
117-
id: descriptorId(kind, selector, element),
132+
id: descriptorId(kind, element),
118133
kind,
119134
selector,
120135
label: labelFor(element),

packages/runtime-playground/src/browser-adaptive-explorer.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ async function captureAdaptiveState(page: Page, contract: BrowserAdaptiveExplora
261261
descriptors.push(...scopedDescriptors.slice(0, contract.descriptorLimits.maxPerState).map((descriptor) => ({ ...descriptor, frameId: identity.id })))
262262
const semantic = await frame.evaluate(({ maxElements, maxTextLength }) => {
263263
const text = (document.body?.innerText || "").replace(/\s+/g, " ").trim().slice(0, maxTextLength)
264+
const semanticId = (id: string | null) => id?.replace(/([_:-](?:[a-z]{0,4})?)[a-f0-9]{8,}$/i, "$1<generated>") || undefined
264265
const loadingSelectors = "[aria-busy='true'], [role='progressbar'], .loading, .spinner, [data-loading='true']"
265266
const visible = (element: Element) => {
266267
const rect = element.getBoundingClientRect()
@@ -271,7 +272,7 @@ async function captureAdaptiveState(page: Page, contract: BrowserAdaptiveExplora
271272
const input = element as HTMLInputElement
272273
return {
273274
tag: element.tagName.toLowerCase(),
274-
id: element.getAttribute("id") || undefined,
275+
id: semanticId(element.getAttribute("id")),
275276
class: element.getAttribute("class") || undefined,
276277
role: element.getAttribute("role") || undefined,
277278
ariaHidden: element.getAttribute("aria-hidden") || undefined,
@@ -330,11 +331,18 @@ async function executeAdaptiveAction(page: Page, action: BrowserAdaptiveAction,
330331
if (action.family === "reload") { await page.reload({ waitUntil: "domcontentloaded", timeout }); return }
331332
const frame = frameById(page, action.frameId)
332333
if (!frame) throw new Error(`Adaptive action frame is no longer available: ${action.frameId}`)
334+
let currentSelector: string | undefined
335+
if (action.descriptorId) {
336+
const current = (await discoverBrowserActionCorpusDescriptors(frame)).descriptors.filter((descriptor) => descriptor.id === action.descriptorId)
337+
if (current.length !== 1) throw new Error(`Adaptive descriptor must resolve exactly once, resolved ${current.length}: ${action.descriptorId}`)
338+
currentSelector = current[0]?.selector
339+
}
333340
for (const step of action.steps) {
334-
if (!step.selector) throw new Error(`Adaptive ${step.kind} action requires a unique selector.`)
335-
const locator = frame.locator(step.selector)
341+
const selector = currentSelector ?? step.selector
342+
if (!selector) throw new Error(`Adaptive ${step.kind} action requires a unique selector.`)
343+
const locator = frame.locator(selector)
336344
const count = await locator.count()
337-
if (count !== 1) throw new Error(`Adaptive selector must resolve exactly once, resolved ${count}: ${step.selector}`)
345+
if (count !== 1) throw new Error(`Adaptive selector must resolve exactly once, resolved ${count}: ${selector}`)
338346
if (step.kind === "click") await locator.click({ timeout })
339347
else if (step.kind === "fill") await locator.fill(String(step.value ?? ""), { timeout })
340348
else if (step.kind === "select") await locator.selectOption(Array.isArray(step.values) ? step.values : String(step.value ?? ""), { timeout })
@@ -486,7 +494,7 @@ function frameById(page: Page, id: string): Frame | undefined {
486494
}
487495

488496
function stableDescriptor(descriptor: BrowserActionCorpusDescriptor): Record<string, unknown> {
489-
return { id: descriptor.id, frameId: descriptor.frameId, kind: descriptor.kind, selector: descriptor.selector, label: descriptor.label, name: descriptor.name, role: descriptor.role, type: descriptor.type, formId: descriptor.formId, href: descriptor.href, optionValues: descriptor.optionValues }
497+
return { id: descriptor.id, frameId: descriptor.frameId, kind: descriptor.kind, label: descriptor.label, name: descriptor.name, role: descriptor.role, type: descriptor.type, href: descriptor.href, optionValues: descriptor.optionValues }
490498
}
491499

492500
function rejectedTransition(state: BrowserAdaptiveState, action: BrowserAdaptiveAction, destinationUrl: string, code: string, message: string): BrowserAdaptiveTransition {

tests/browser-adaptive-exploration.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,47 @@ test("identical seed and DOM produce deterministic state and action graph identi
160160
assert.deepEqual(stableGraph(second.result), stableGraph(first.result))
161161
})
162162

163+
test("volatile generated ids preserve semantic replay and distinct control identity", async () => {
164+
let render = 0
165+
const server = createServer((_request, response) => {
166+
render += 1
167+
const suffix = render.toString(16).padStart(10, "0")
168+
response.setHeader("content-type", "text/html")
169+
response.end(`<!doctype html>
170+
<style>button { display:block; width:180px; height:30px; margin:8px; }</style>
171+
<button id="choice-primary-dm${suffix}">Choose</button>
172+
<button id="choice-secondary-dm${suffix}">Choose</button>
173+
<output>Waiting</output>
174+
<script>
175+
document.querySelectorAll('button').forEach((button, index) => button.addEventListener('click', () => {
176+
document.querySelector('output').textContent = index === 0 ? 'Primary selected' : 'Secondary selected';
177+
}));
178+
</script>`)
179+
})
180+
const startUrl = await listenLocalHttpServer(server)
181+
try {
182+
const run = await runUrlFixture(startUrl, {
183+
seed: "volatile-generated-ids",
184+
failOnFinding: false,
185+
budgets: { maxActions: 12, maxStates: 8, maxTransitions: 8, maxDurationMs: 15_000 },
186+
actionFamilies: ["click"],
187+
})
188+
const initial = run.result.states[0]!
189+
const choices = initial.descriptors.filter((descriptor) => descriptor.label === "Choose")
190+
assert.equal(choices.length, 2)
191+
assert.equal(new Set(choices.map((descriptor) => descriptor.id)).size, 2, "semantic occurrence keeps equivalent controls distinct")
192+
assert.equal(new Set(choices.map((descriptor) => descriptor.selector)).size, 2, "execution retains each concrete selector")
193+
const choiceTransitions = run.result.transitions.filter((transition) => transition.sourceDigest === initial.digest && transition.action.descriptorId && choices.some((descriptor) => descriptor.id === transition.action.descriptorId))
194+
assert.equal(new Set(choiceTransitions.map((transition) => transition.destinationDigest)).size, 2, "distinct controls reach distinct semantic states")
195+
assert(!run.result.transitions.some((transition) => transition.diagnostic?.code === "browser_adaptive_source_state_not_reproduced"))
196+
assert(!run.result.diagnostics.some((diagnostic) => diagnostic.code === "browser_adaptive_reset_replay_failed"))
197+
assert.notEqual(run.result.summary.budgetExhausted, "maxDurationMs")
198+
assert(render > 2, "the fixture must generate fresh ids across resets")
199+
} finally {
200+
await closeHttpServer(server)
201+
}
202+
})
203+
163204
test("partially failed actions retain evidence without creating unreplayable frontier paths", async () => {
164205
const input = {
165206
seed: "partial-repeat",
@@ -310,6 +351,26 @@ async function runFixture(html: string, input: Record<string, unknown>, signal?:
310351
}
311352
}
312353

354+
async function runUrlFixture(startUrl: string, input: Record<string, unknown>) {
355+
const browser = await chromium.launch({ headless: true })
356+
const page = await browser.newPage()
357+
const consoleMessages: object[] = []
358+
const errors: object[] = []
359+
const network: object[] = []
360+
page.on("console", (message) => consoleMessages.push({ type: message.type(), text: message.text() }))
361+
page.on("pageerror", (error) => errors.push({ message: error.message }))
362+
page.on("request", (request) => network.push({ url: request.url(), method: request.method() }))
363+
await page.addInitScript("globalThis.__name = value => value")
364+
await page.goto(startUrl, { waitUntil: "load" })
365+
const contract = browserAdaptiveExplorationContract({ startUrl, stabilization: { pollIntervalMs: 25, quietWindowMs: 100, maxWaitMs: 1500, maxMutationRecords: 40 }, ...input })
366+
try {
367+
const result = await exploreAdaptiveBrowserStateMachine({ page, baseUrl: startUrl, contract, observations: { consoleMessages, errors, network } })
368+
return { result, contract }
369+
} finally {
370+
await browser.close()
371+
}
372+
}
373+
313374
async function runRoutedFixture(topology: ReturnType<typeof browserPreviewTopology>, effectiveOrigin: string, initialHtml?: string) {
314375
const browser = await chromium.launch({ headless: true })
315376
const context = await browser.newContext()

0 commit comments

Comments
 (0)