@@ -103,7 +103,7 @@ test("adaptive contract normalizes every safety and exploration bound", () => {
103103 { id : "input" , kind : "input" , selector : "#field" , type : "text" , formId : "form" , frameId : "document" } ,
104104 { id : "select" , kind : "select" , selector : "#choice" , optionValues : [ "one" ] , formId : "form" , frameId : "document" } ,
105105 { id : "submit" , kind : "button" , selector : "#submit" , type : "submit" , formId : "form" , frameId : "document" } ,
106- { id : "link" , kind : "link" , selector : "#link" , frameId : "document" } ,
106+ { id : "link" , kind : "link" , selector : "#link" , href : "https://example.test/next" , frameId : "document" } ,
107107 ] ,
108108 frames : [ { id : "document" , url : "/fixture" , scope : "document" } ] ,
109109 visits : 1 ,
@@ -113,6 +113,39 @@ test("adaptive contract normalizes every safety and exploration bound", () => {
113113 assert . deepEqual ( new Set ( planned . map ( ( action ) => action . family ) ) , new Set ( [ "click" , "fill" , "select" , "submit" , "keyboard" , "back" , "reload" , "repeat" , "double-submit" ] ) )
114114} )
115115
116+ test ( "adaptive planner repeats in-place controls but not navigation anchors" , ( ) => {
117+ const contract = browserAdaptiveExplorationContract ( { seed : "navigation-semantics" , startUrl : "https://example.test/start" } )
118+ const descriptors = [
119+ { id : "internal" , kind : "link" as const , selector : "#internal" , href : "https://example.test/next" , frameId : "document" } ,
120+ { id : "external" , kind : "link" as const , selector : "#external" , href : "https://outside.example/next" , frameId : "document" } ,
121+ { id : "hash" , kind : "link" as const , selector : "#hash" , href : "https://example.test/start#tab" , role : "tab" , frameId : "document" } ,
122+ { id : "download" , kind : "link" as const , selector : "#download" , href : "https://example.test/report.pdf" , frameId : "document" } ,
123+ { id : "anchor-button" , kind : "link" as const , selector : "#anchor-button" , role : "button" , frameId : "document" } ,
124+ { id : "button" , kind : "button" as const , selector : "#button" , type : "button" , frameId : "document" } ,
125+ { id : "submit" , kind : "button" as const , selector : "#submit" , type : "submit" , formId : "form" , frameId : "document" } ,
126+ ]
127+ const state = {
128+ digest : "state" ,
129+ url : "https://example.test/start" ,
130+ historyLength : 1 ,
131+ historyStateDigest : "history" ,
132+ descriptorDigest : "descriptors" ,
133+ descriptors,
134+ frames : [ { id : "document" , url : "https://example.test/start" , scope : "document" as const } ] ,
135+ visits : 1 ,
136+ depth : 0 ,
137+ loadingIndicators : 0 ,
138+ }
139+ const planned = planBrowserAdaptiveStateActions ( state , contract )
140+ const familiesFor = ( id : string ) => planned . filter ( ( action ) => action . descriptorId === id ) . map ( ( action ) => action . family )
141+
142+ for ( const id of [ "internal" , "external" , "hash" , "download" ] ) assert . deepEqual ( familiesFor ( id ) , [ "click" ] , id )
143+ assert . deepEqual ( familiesFor ( "anchor-button" ) , [ "click" , "repeat" ] )
144+ assert . deepEqual ( familiesFor ( "button" ) , [ "click" , "repeat" ] )
145+ assert . deepEqual ( familiesFor ( "submit" ) , [ "click" , "repeat" , "submit" , "double-submit" ] )
146+ assert . deepEqual ( planBrowserAdaptiveStateActions ( state , contract ) , planned , "semantic filtering preserves deterministic action identity and order" )
147+ } )
148+
116149test ( "adaptive planner selects actions by HTML input type capability" , ( ) => {
117150 const fillableTypes = [ "text" , "search" , "tel" , "url" , "email" , "password" , "number" ]
118151 const toggleTypes = [ "checkbox" , "radio" ]
@@ -191,6 +224,80 @@ test("planned input actions execute safely in a real browser", async () => {
191224 }
192225} )
193226
227+ test ( "real-browser descriptors keep navigation single-click and in-place controls repeatable" , async ( ) => {
228+ const browser = await chromium . launch ( { headless : true } )
229+ const page = await browser . newPage ( )
230+ try {
231+ await page . addInitScript ( "globalThis.__name = value => value" )
232+ await page . setContent ( `<!doctype html>
233+ <style>a,button { display:block; width:180px; height:30px; margin:4px; }</style>
234+ <a id="internal" href="/next">Internal</a>
235+ <a id="external" href="https://outside.example/next">External</a>
236+ <a id="hash" href="#tab" role="tab">Hash tab</a>
237+ <a id="download" href="/report.pdf" download>Download</a>
238+ <button id="button" type="button">Increment</button>
239+ <form id="form"><button id="submit" type="submit">Submit</button></form>
240+ <output id="count">0</output>
241+ <script>
242+ document.querySelector('#button').addEventListener('click', () => { document.querySelector('#count').textContent = String(Number(document.querySelector('#count').textContent) + 1) });
243+ document.querySelector('#form').addEventListener('submit', (event) => event.preventDefault());
244+ </script>` )
245+ await page . evaluate ( "globalThis.__name = value => value" )
246+ const discovery = await discoverBrowserActionCorpusDescriptors ( page )
247+ const contract = browserAdaptiveExplorationContract ( { seed : "browser-navigation-semantics" , startUrl : page . url ( ) } )
248+ const planned = planBrowserAdaptiveStateActions ( {
249+ digest : "state" ,
250+ url : page . url ( ) ,
251+ historyLength : 1 ,
252+ historyStateDigest : "history" ,
253+ descriptorDigest : "descriptors" ,
254+ descriptors : discovery . descriptors ,
255+ frames : [ { id : "document" , url : page . url ( ) , scope : "document" } ] ,
256+ visits : 1 ,
257+ depth : 0 ,
258+ loadingIndicators : 0 ,
259+ } , contract )
260+ const actionFor = ( selector : string , family : string ) => planned . find ( ( action ) => action . steps [ 0 ] ?. selector === selector && action . family === family )
261+
262+ for ( const selector of [ "#internal" , "#external" , "#hash" , "#download" ] ) {
263+ assert ( actionFor ( selector , "click" ) , `${ selector } remains single-clickable` )
264+ assert ( ! actionFor ( selector , "repeat" ) , `${ selector } must not receive a repeat action` )
265+ }
266+ const repeat = actionFor ( "#button" , "repeat" )
267+ assert ( repeat )
268+ for ( const step of repeat . steps ) await executeBrowserInteractionStep ( page , step , page . url ( ) , 2_000 , async ( ) => ( { path : "unused" , isDefault : false } ) )
269+ assert . equal ( await page . locator ( "#count" ) . textContent ( ) , "2" )
270+ assert . equal ( actionFor ( "#submit" , "double-submit" ) ?. steps . length , 2 , "double-submit remains unchanged" )
271+ } finally {
272+ await browser . close ( )
273+ }
274+ } )
275+
276+ test ( "repeat-only exploration never schedules a detached navigation target" , async ( ) => {
277+ let destinationRequests = 0
278+ const server = createServer ( ( request , response ) => {
279+ const path = new URL ( request . url ?? "/" , "http://preview.invalid" ) . pathname
280+ response . setHeader ( "content-type" , "text/html" )
281+ if ( path === "/destination" ) destinationRequests += 1
282+ response . end ( path === "/" ? "<!doctype html><style>a{display:block;width:180px;height:30px}</style><a id='privacy' href='/destination'>Privacy Policy</a>" : "<!doctype html><h1>Destination</h1>" )
283+ } )
284+ const startUrl = await listenLocalHttpServer ( server )
285+ try {
286+ const run = await runUrlFixture ( startUrl , {
287+ seed : "detached-navigation-repeat" ,
288+ failOnFinding : false ,
289+ actionFamilies : [ "repeat" ] ,
290+ budgets : { maxActions : 4 , maxStates : 4 , maxTransitions : 4 , maxDurationMs : 10_000 } ,
291+ } )
292+ const privacy = run . result . states [ 0 ] ?. descriptors . find ( ( descriptor ) => descriptor . selector === "#privacy" )
293+ assert ( privacy ?. href . endsWith ( "/destination" ) )
294+ assert . equal ( run . result . transitions . length , 0 )
295+ assert . equal ( destinationRequests , 0 , "repeat planning must not issue a first or detached second navigation click" )
296+ } finally {
297+ await closeHttpServer ( server )
298+ }
299+ } )
300+
194301test ( "rediscovery finds, minimizes, and replays a defect revealed by a dynamic modal" , async ( ) => {
195302 const run = await runFixture ( modalFixture , {
196303 seed : "modal-seed" ,
0 commit comments