@@ -16,6 +16,7 @@ import { stableJson } from "@automattic/wp-codebox-core/internals"
1616import type { Frame , Page } from "playwright"
1717
1818import { discoverBrowserActionCorpusDescriptors } from "./browser-action-discovery.js"
19+ import type { BrowserPreviewNavigationScope } from "./browser-preview-routing.js"
1920
2021interface AdaptiveObservationSources {
2122 consoleMessages : object [ ]
@@ -48,13 +49,15 @@ export async function exploreAdaptiveBrowserStateMachine({
4849 observations,
4950 signal,
5051 now = Date . now ,
52+ navigationScope,
5153} : {
5254 page : Page
5355 baseUrl : string
5456 contract : BrowserAdaptiveExplorationContract
5557 observations : AdaptiveObservationSources
5658 signal ?: AbortSignal
5759 now ?: ( ) => number
60+ navigationScope ?: BrowserPreviewNavigationScope
5861} ) : Promise < BrowserAdaptiveExplorationResult > {
5962 const started = now ( )
6063 const states = new Map < string , BrowserAdaptiveState > ( )
@@ -67,7 +70,7 @@ export async function exploreAdaptiveBrowserStateMachine({
6770 let revisits = 0
6871 let exhausted : BrowserAdaptiveExplorationResult [ "summary" ] [ "budgetExhausted" ]
6972
70- const initial = await captureAdaptiveState ( page , contract , 0 )
73+ const initial = await captureAdaptiveState ( page , contract , 0 , navigationScope )
7174 appendDiagnostics ( diagnostics , initial . diagnostics , contract . descriptorLimits . maxDiagnostics )
7275 states . set ( initial . state . digest , initial . state )
7376 const frontier : FrontierEntry [ ] = [ { state : initial . state , path : [ ] } ]
@@ -90,7 +93,7 @@ export async function exploreAdaptiveBrowserStateMachine({
9093
9194 if ( contract . resetPolicy . mode === "start-url" && ( transitions . length > 0 || source . path . length > 0 ) ) {
9295 if ( actions + source . path . length >= contract . budgets . maxActions ) { exhausted = "maxActions" ; break }
93- const restored = await restoreAdaptivePath ( page , baseUrl , contract , source . path , signal )
96+ const restored = await restoreAdaptivePath ( page , baseUrl , contract , source . path , signal , navigationScope )
9497 actions += restored . executed
9598 appendDiagnostics ( diagnostics , restored . diagnostics , contract . descriptorLimits . maxDiagnostics )
9699 if ( ! restored . state || restored . state . digest !== source . state . digest ) {
@@ -113,8 +116,9 @@ export async function exploreAdaptiveBrowserStateMachine({
113116 actionError = error instanceof Error ? error . message : String ( error )
114117 }
115118 actions += 1
116- const stabilized = await stabilizeAdaptiveState ( page , contract , source . path . length + 1 , now )
119+ const stabilized = await stabilizeAdaptiveState ( page , contract , source . path . length + 1 , now , navigationScope )
117120 appendDiagnostics ( diagnostics , stabilized . diagnostics , contract . descriptorLimits . maxDiagnostics )
121+ const scopeRejection = stabilized . diagnostics . find ( ( diagnostic ) => diagnostic . code === "browser_adaptive_redirect_scope_escape_rejected" )
118122 const newConsoleErrors = consoleErrorMessages ( observations . consoleMessages . slice ( beforeConsole ) )
119123 const newPageErrors = errorMessages ( observations . errors . slice ( beforeErrors ) )
120124 const fingerprints = [ ...new Set ( [ ...newConsoleErrors , ...newPageErrors ] . map ( ( message ) => browserAdaptiveDigest ( "oracle" , message ) ) ) ] . sort ( )
@@ -153,8 +157,8 @@ export async function exploreAdaptiveBrowserStateMachine({
153157 loadingAfter : stabilized . loading ,
154158 oracleFingerprints : fingerprints ,
155159 } ,
156- status : actionError ? "error" : newState ? "ok" : "revisited" ,
157- ...( actionError ? { diagnostic : { code : "browser_adaptive_action_error" , message : actionError } } : { } ) ,
160+ status : actionError ? "error" : scopeRejection ? "rejected" : newState ? "ok" : "revisited" ,
161+ ...( actionError ? { diagnostic : { code : "browser_adaptive_action_error" , message : actionError } } : scopeRejection ? { diagnostic : { code : scopeRejection . code , message : scopeRejection . message } } : { } ) ,
158162 }
159163 errors += newConsoleErrors . length + newPageErrors . length + ( actionError ? 1 : 0 )
160164 if ( artifactBytes ( states , [ ...transitions , transition ] , diagnostics , findings ) > contract . budgets . maxArtifactBytes ) {
@@ -199,7 +203,7 @@ export async function exploreAdaptiveBrowserStateMachine({
199203
200204 if ( findings . length > 0 && ! signal ?. aborted ) {
201205 for ( const finding of findings ) {
202- const minimized = await minimizeAdaptiveFinding ( page , baseUrl , contract , finding , observations , Math . max ( 0 , contract . budgets . maxActions - actions ) , started + contract . budgets . maxDurationMs , signal , now )
206+ const minimized = await minimizeAdaptiveFinding ( page , baseUrl , contract , finding , observations , Math . max ( 0 , contract . budgets . maxActions - actions ) , started + contract . budgets . maxDurationMs , signal , now , navigationScope )
203207 actions += minimized . executed
204208 finding . minimizedPath = minimized . path
205209 finding . replay . actions = finding . minimizedPath
@@ -223,8 +227,9 @@ export async function exploreAdaptiveBrowserStateMachine({
223227 }
224228}
225229
226- async function captureAdaptiveState ( page : Page , contract : BrowserAdaptiveExplorationContract , depth : number ) : Promise < CapturedAdaptiveState > {
230+ async function captureAdaptiveState ( page : Page , contract : BrowserAdaptiveExplorationContract , depth : number , navigationScope ?: BrowserPreviewNavigationScope ) : Promise < CapturedAdaptiveState > {
227231 const diagnostics : BrowserAdaptiveExplorationResult [ "diagnostics" ] = [ ]
232+ diagnostics . push ( ...( navigationScope ?. drainDiagnostics ( ) ?? [ ] ) )
228233 const mainOrigin = origin ( page . url ( ) )
229234 const frames = frameIdentities ( page , contract . descriptorLimits . maxPerState )
230235 const descriptors : BrowserActionCorpusDescriptor [ ] = [ ]
@@ -242,9 +247,15 @@ async function captureAdaptiveState(page: Page, contract: BrowserAdaptiveExplora
242247 const discovery = await discoverBrowserActionCorpusDescriptors ( frame )
243248 diagnostics . push ( ...discovery . diagnostics . map ( ( diagnostic ) => ( { ...diagnostic , metadata : { ...diagnostic . metadata , frameId : identity . id } } ) ) )
244249 const scopedDescriptors = discovery . descriptors . filter ( ( descriptor ) => {
245- const hrefOrigin = descriptor . href ? origin ( descriptor . href ) : undefined
246- if ( ! hrefOrigin || ! mainOrigin || hrefOrigin === mainOrigin ) return true
247- diagnostics . push ( { code : "browser_adaptive_cross_origin_action_rejected" , message : "A link leaving the exploration origin was excluded from the adaptive action frontier." , metadata : { frameId : identity . id , descriptorId : descriptor . id , href : descriptor . href } } )
250+ if ( ! descriptor . href ) return true
251+ const decision = navigationScope ?. resolve ( descriptor . href , frame . url ( ) )
252+ const hrefOrigin = origin ( descriptor . href )
253+ if ( decision ?. allowed ) {
254+ if ( decision . routeDecision === "routed-preview" ) diagnostics . push ( { code : "browser_adaptive_routed_action_allowed" , message : "A link using a declared preview route was included in the adaptive action frontier." , metadata : { frameId : identity . id , descriptorId : descriptor . id , rawHrefOrigin : decision . rawOrigin , effectiveOrigin : decision . effectiveOrigin , routeDecision : decision . routeDecision , reason : decision . reason } } )
255+ return true
256+ }
257+ if ( ! decision && ( ! hrefOrigin || ! mainOrigin || hrefOrigin === mainOrigin ) ) return true
258+ diagnostics . push ( { code : "browser_adaptive_cross_origin_action_rejected" , message : "A link leaving the declared preview navigation scope was excluded from the adaptive action frontier." , metadata : { frameId : identity . id , descriptorId : descriptor . id , rawHrefOrigin : decision ?. rawOrigin ?? hrefOrigin , effectiveOrigin : decision ?. effectiveOrigin ?? mainOrigin , routeDecision : decision ?. routeDecision ?? "external" , reason : decision ?. reason ?? "origin-mismatch" } } )
248259 return false
249260 } )
250261 descriptors . push ( ...scopedDescriptors . slice ( 0 , contract . descriptorLimits . maxPerState ) . map ( ( descriptor ) => ( { ...descriptor , frameId : identity . id } ) ) )
@@ -332,22 +343,35 @@ async function executeAdaptiveAction(page: Page, action: BrowserAdaptiveAction,
332343 }
333344}
334345
335- async function stabilizeAdaptiveState ( page : Page , contract : BrowserAdaptiveExplorationContract , depth : number , now : ( ) => number ) : Promise < StabilizationResult > {
346+ async function stabilizeAdaptiveState ( page : Page , contract : BrowserAdaptiveExplorationContract , depth : number , now : ( ) => number , navigationScope ?: BrowserPreviewNavigationScope ) : Promise < StabilizationResult > {
336347 const started = now ( )
348+ const diagnostics : BrowserAdaptiveExplorationResult [ "diagnostics" ] = [ ]
349+ const diagnosticKeys = new Set < string > ( )
350+ const retainDiagnostics = ( incoming : BrowserAdaptiveExplorationResult [ "diagnostics" ] ) => {
351+ for ( const diagnostic of incoming ) {
352+ const key = stableJson ( diagnostic )
353+ if ( ! diagnosticKeys . has ( key ) ) {
354+ diagnosticKeys . add ( key )
355+ diagnostics . push ( diagnostic )
356+ }
357+ }
358+ }
337359 let polls = 0
338360 let quietSince = started
339361 let previous : CapturedAdaptiveState | undefined
340362 while ( now ( ) - started < contract . stabilization . maxWaitMs ) {
341363 await page . waitForTimeout ( contract . stabilization . pollIntervalMs )
342- const current = await captureAdaptiveState ( page , contract , depth )
364+ const current = await captureAdaptiveState ( page , contract , depth , navigationScope )
365+ retainDiagnostics ( current . diagnostics )
343366 polls += 1
344367 if ( ! previous || current . state . digest !== previous . state . digest ) quietSince = now ( )
345368 previous = current
346369 if ( current . loading === 0 && now ( ) - quietSince >= contract . stabilization . quietWindowMs ) break
347370 }
348- const state = previous ?? await captureAdaptiveState ( page , contract , depth )
371+ const state = previous ?? await captureAdaptiveState ( page , contract , depth , navigationScope )
372+ if ( ! previous ) retainDiagnostics ( state . diagnostics )
349373 const mutations = await readMutationObservers ( page , contract )
350- return { ...state , waitedMs : Math . max ( 0 , now ( ) - started ) , polls, mutationRecords : mutations . count , mutationEvidenceTruncated : mutations . truncated }
374+ return { ...state , diagnostics , waitedMs : Math . max ( 0 , now ( ) - started ) , polls, mutationRecords : mutations . count , mutationEvidenceTruncated : mutations . truncated }
351375}
352376
353377async function installMutationObservers ( page : Page , contract : BrowserAdaptiveExplorationContract ) : Promise < void > {
@@ -386,26 +410,26 @@ async function readMutationObservers(page: Page, contract: BrowserAdaptiveExplor
386410 return { count, truncated }
387411}
388412
389- async function restoreAdaptivePath ( page : Page , baseUrl : string , contract : BrowserAdaptiveExplorationContract , path : BrowserAdaptiveAction [ ] , signal ?: AbortSignal ) : Promise < { state ?: BrowserAdaptiveState ; executed : number ; diagnostics : BrowserAdaptiveExplorationResult [ "diagnostics" ] } > {
413+ async function restoreAdaptivePath ( page : Page , baseUrl : string , contract : BrowserAdaptiveExplorationContract , path : BrowserAdaptiveAction [ ] , signal ?: AbortSignal , navigationScope ?: BrowserPreviewNavigationScope ) : Promise < { state ?: BrowserAdaptiveState ; executed : number ; diagnostics : BrowserAdaptiveExplorationResult [ "diagnostics" ] } > {
390414 const diagnostics : BrowserAdaptiveExplorationResult [ "diagnostics" ] = [ ]
391415 let executed = 0
392416 try {
393417 await page . goto ( resolveUrl ( contract . startUrl , baseUrl ) , { waitUntil : "domcontentloaded" , timeout : contract . stabilization . maxWaitMs } )
394- await stabilizeAdaptiveState ( page , contract , 0 , Date . now )
418+ await stabilizeAdaptiveState ( page , contract , 0 , Date . now , navigationScope )
395419 for ( const action of path ) {
396420 if ( signal ?. aborted ) return { executed, diagnostics }
397421 executed += 1
398422 await executeAdaptiveAction ( page , action , contract . stabilization . maxWaitMs )
399- await stabilizeAdaptiveState ( page , contract , executed , Date . now )
423+ await stabilizeAdaptiveState ( page , contract , executed , Date . now , navigationScope )
400424 }
401- return { state : ( await captureAdaptiveState ( page , contract , path . length ) ) . state , executed, diagnostics }
425+ return { state : ( await captureAdaptiveState ( page , contract , path . length , navigationScope ) ) . state , executed, diagnostics }
402426 } catch ( error ) {
403427 diagnostics . push ( { code : "browser_adaptive_reset_replay_failed" , message : "The start-URL reset path could not reproduce a queued state." , metadata : { reason : error instanceof Error ? error . message : String ( error ) } } )
404428 return { executed, diagnostics }
405429 }
406430}
407431
408- async function minimizeAdaptiveFinding ( page : Page , baseUrl : string , contract : BrowserAdaptiveExplorationContract , finding : BrowserAdaptiveFinding , observations : AdaptiveObservationSources , maximumActions : number , deadline : number , signal ?: AbortSignal , now : ( ) => number = Date . now ) : Promise < { path : BrowserAdaptiveAction [ ] ; executed : number ; exhausted ?: "maxActions" | "maxDurationMs" | "cancelled" } > {
432+ async function minimizeAdaptiveFinding ( page : Page , baseUrl : string , contract : BrowserAdaptiveExplorationContract , finding : BrowserAdaptiveFinding , observations : AdaptiveObservationSources , maximumActions : number , deadline : number , signal ?: AbortSignal , now : ( ) => number = Date . now , navigationScope ?: BrowserPreviewNavigationScope ) : Promise < { path : BrowserAdaptiveAction [ ] ; executed : number ; exhausted ?: "maxActions" | "maxDurationMs" | "cancelled" } > {
409433 let current = [ ...finding . originalPath ]
410434 let chunk = Math . max ( 1 , Math . floor ( current . length / 2 ) )
411435 let executed = 0
@@ -419,7 +443,7 @@ async function minimizeAdaptiveFinding(page: Page, baseUrl: string, contract: Br
419443 if ( executed + candidate . length > maximumActions ) { exhausted = "maxActions" ; break }
420444 const beforeConsole = observations . consoleMessages . length
421445 const beforeErrors = observations . errors . length
422- const replay = await restoreAdaptivePath ( page , baseUrl , contract , candidate , signal )
446+ const replay = await restoreAdaptivePath ( page , baseUrl , contract , candidate , signal , navigationScope )
423447 executed += replay . executed
424448 const fingerprints = [ ...consoleErrorMessages ( observations . consoleMessages . slice ( beforeConsole ) ) , ...errorMessages ( observations . errors . slice ( beforeErrors ) ) ] . map ( ( message ) => browserAdaptiveDigest ( "oracle" , message ) )
425449 if ( replay . state && fingerprints . includes ( finding . fingerprint ) && ( ! finding . stateDigest || replay . state . digest === finding . stateDigest ) ) {
0 commit comments