Skip to content

Commit 9e20ff1

Browse files
authored
fix: keep failed adaptive actions off frontier (#2035)
1 parent f7be757 commit 9e20ff1

2 files changed

Lines changed: 77 additions & 8 deletions

File tree

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

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,12 @@ export async function exploreAdaptiveBrowserStateMachine({
120120
const fingerprints = [...new Set([...newConsoleErrors, ...newPageErrors].map((message) => browserAdaptiveDigest("oracle", message)))].sort()
121121
const existing = states.get(stabilized.state.digest)
122122
const newState = !existing
123-
if (newState && states.size < contract.budgets.maxStates) {
123+
const replayableDestination = !actionError
124+
if (replayableDestination && newState && states.size < contract.budgets.maxStates) {
124125
states.set(stabilized.state.digest, stabilized.state)
125-
} else if (newState) {
126+
} else if (replayableDestination && newState) {
126127
exhausted = "maxStates"
127-
} else {
128+
} else if (replayableDestination && existing) {
128129
revisits += 1
129130
existing.visits += 1
130131
}
@@ -157,14 +158,14 @@ export async function exploreAdaptiveBrowserStateMachine({
157158
}
158159
errors += newConsoleErrors.length + newPageErrors.length + (actionError ? 1 : 0)
159160
if (artifactBytes(states, [...transitions, transition], diagnostics, findings) > contract.budgets.maxArtifactBytes) {
160-
if (newState) states.delete(stabilized.state.digest)
161+
if (replayableDestination && newState) states.delete(stabilized.state.digest)
161162
exhausted = "maxArtifactBytes"
162163
break
163164
}
164165
transitions.push(transition)
165166

166167
const path = [...source.path, action]
167-
if (fingerprints.length > 0) {
168+
if (replayableDestination && fingerprints.length > 0) {
168169
const fingerprint = fingerprints[0] as string
169170
const finding: BrowserAdaptiveFinding = {
170171
fingerprint,
@@ -190,8 +191,8 @@ export async function exploreAdaptiveBrowserStateMachine({
190191
break
191192
}
192193
if (errors >= contract.budgets.maxErrors) { exhausted = "maxErrors"; break }
193-
if (newState && states.size <= contract.budgets.maxStates) frontier.push({ state: destination, path })
194-
if (!newState && destination.visits < contract.revisitPolicy.maxStateVisits) frontier.push({ state: destination, path })
194+
if (replayableDestination && newState && states.size <= contract.budgets.maxStates) frontier.push({ state: destination, path })
195+
if (replayableDestination && !newState && destination.visits < contract.revisitPolicy.maxStateVisits) frontier.push({ state: destination, path })
195196
if (contract.resetPolicy.mode === "none") break
196197
}
197198
}

tests/browser-adaptive-exploration.test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,27 @@ document.querySelector('#configure').addEventListener('click', () => setTimeout(
3838
}, 80));
3939
</script>`
4040

41+
const interceptedRepeatFixture = `<!doctype html>
42+
<style>
43+
button { display:block; width:180px; height:30px; margin:8px; }
44+
#overlay { position:fixed; inset:0; z-index:10; background:white; }
45+
</style>
46+
<button id="open-overlay">Open overlay</button>
47+
<button id="continue">Continue <span id="count">0</span></button>
48+
<script>
49+
document.querySelector('#open-overlay').addEventListener('click', () => {
50+
if (document.querySelector('#overlay')) return;
51+
const overlay = document.createElement('div');
52+
overlay.id = 'overlay';
53+
overlay.innerHTML = '<button id="overlay-action">Overlay action</button>';
54+
document.body.append(overlay);
55+
});
56+
document.querySelector('#continue').addEventListener('click', () => {
57+
const count = document.querySelector('#count');
58+
count.textContent = String(Number(count.textContent) + 1);
59+
});
60+
</script>`
61+
4162
test("adaptive browser exploration is an additive public browser-actions mode", () => {
4263
const definition = getCommandDefinition("wordpress.browser-actions")
4364
const argument = definition?.acceptedArgs.find((candidate) => candidate.name === "adaptive-exploration-json")
@@ -136,6 +157,52 @@ test("identical seed and DOM produce deterministic state and action graph identi
136157
assert.deepEqual(stableGraph(second.result), stableGraph(first.result))
137158
})
138159

160+
test("partially failed actions retain evidence without creating unreplayable frontier paths", async () => {
161+
const input = {
162+
seed: "partial-repeat",
163+
failOnFinding: false,
164+
budgets: { maxActions: 8, maxStates: 8, maxTransitions: 8, maxDurationMs: 15_000, maxErrors: 4 },
165+
actionFamilies: ["repeat"],
166+
}
167+
const first = await runFixture(interceptedRepeatFixture, input)
168+
const second = await runFixture(interceptedRepeatFixture, input)
169+
const failedIndex = first.result.transitions.findIndex((transition) => transition.status === "error" && transition.action.steps[0]?.selector === "#open-overlay")
170+
const failed = first.result.transitions[failedIndex]
171+
assert(failed, "the intercepted second click must remain as error transition evidence")
172+
assert.equal(failed.action.steps.length, 2)
173+
assert.equal(failed.diagnostic?.code, "browser_adaptive_action_error")
174+
assert(!first.result.states.some((state) => state.digest === failed.destinationDigest), "the partially reached destination must not become a replayable state")
175+
assert(first.result.transitions.slice(failedIndex + 1).some((transition) => transition.status !== "error" && transition.action.steps[0]?.selector === "#continue"), "other actions from the replayable source must continue")
176+
assert(!first.result.diagnostics.some((diagnostic) => diagnostic.code === "browser_adaptive_reset_replay_failed" || diagnostic.code === "browser_adaptive_source_state_not_reproduced"))
177+
assert(!first.result.transitions.some((transition) => transition.diagnostic?.code === "browser_adaptive_source_state_not_reproduced"))
178+
assert.equal(first.result.findings.length, 0, "a failed full action must not produce an unreplayable finding or minimization path")
179+
assert.notEqual(first.result.summary.budgetExhausted, "maxDurationMs")
180+
181+
const stableGraph = (result: typeof first.result) => ({
182+
states: result.states.map((state) => state.digest),
183+
transitions: result.transitions.map((transition) => ({ source: transition.sourceDigest, destination: transition.destinationDigest, action: transition.action.id, status: transition.status, diagnostic: transition.diagnostic?.code })),
184+
summary: result.summary,
185+
})
186+
assert.deepEqual(stableGraph(second.result), stableGraph(first.result))
187+
})
188+
189+
test("cancellation during a partially failed action retains bounded non-replayable evidence", async () => {
190+
const controller = new AbortController()
191+
const run = await runFixture(interceptedRepeatFixture, {
192+
seed: "partial-repeat",
193+
failOnFinding: false,
194+
budgets: { maxActions: 8, maxStates: 8, maxTransitions: 8, maxDurationMs: 15_000, maxErrors: 4 },
195+
actionFamilies: ["repeat"],
196+
}, controller.signal, () => setTimeout(() => controller.abort("cancel during intercepted click"), 250))
197+
const failed = run.result.transitions.find((transition) => transition.status === "error")
198+
assert(failed)
199+
assert.equal(run.result.status, "incomplete")
200+
assert.equal(run.result.summary.budgetExhausted, "cancelled")
201+
assert(!run.result.states.some((state) => state.digest === failed.destinationDigest))
202+
assert.equal(run.result.findings.length, 0)
203+
assert(!run.result.diagnostics.some((diagnostic) => diagnostic.code === "browser_adaptive_reset_replay_failed"))
204+
})
205+
139206
test("loops, budgets, cancellation, frames, and partial evidence remain bounded", async () => {
140207
const loopFixture = `<!doctype html><style>button,input,a,iframe { display:block;width:100px;height:30px }</style><button id="toggle">Toggle</button><form id="first"><input name="query"></form><form id="second"><input name="query"></form><a id="external" href="https://example.test/outside">External</a><iframe id="same" srcdoc="<style>button{width:100px;height:30px}</style><button id='framed'>Framed</button>"></iframe><script>toggle.onclick=()=>document.body.classList.toggle('on')</script>`
141208
const bounded = await runFixture(loopFixture, {
@@ -179,7 +246,7 @@ test("no-reset exploration remains a truthful linear state chain", async () => {
179246
}
180247
})
181248

182-
async function runFixture(html: string, input: Record<string, unknown>, signal?: AbortSignal) {
249+
async function runFixture(html: string, input: Record<string, unknown>, signal?: AbortSignal, beforeExplore?: () => void) {
183250
const browser = await chromium.launch({ headless: true })
184251
const page = await browser.newPage()
185252
const consoleMessages: object[] = []
@@ -193,6 +260,7 @@ async function runFixture(html: string, input: Record<string, unknown>, signal?:
193260
await page.goto(startUrl, { waitUntil: "load" })
194261
const contract = browserAdaptiveExplorationContract({ startUrl, stabilization: { pollIntervalMs: 25, quietWindowMs: 100, maxWaitMs: 1500, maxMutationRecords: 40 }, ...input })
195262
try {
263+
beforeExplore?.()
196264
const result = await exploreAdaptiveBrowserStateMachine({ page, baseUrl: startUrl, contract, observations: { consoleMessages, errors, network }, signal })
197265
return { result, contract }
198266
} finally {

0 commit comments

Comments
 (0)