@@ -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+
4162test ( "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+
139206test ( "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