@@ -5,6 +5,7 @@ import { chromium, type Page } from "playwright"
55
66import {
77 BROWSER_ADAPTIVE_EXPLORATION_SCHEMA ,
8+ browserAdaptiveDigest ,
89 browserAdaptiveExplorationContract ,
910 planBrowserAdaptiveStateActions ,
1011} from "../packages/runtime-core/src/browser-adaptive-exploration.js"
@@ -506,6 +507,28 @@ test("callers can opt policy blocks back into deterministic findings and replay"
506507 assert . equal ( finding . replay . expectedFingerprint , finding . fingerprint )
507508} )
508509
510+ test ( "URL-less policy-block console records correlate before promotion and preserve canonical fingerprints" , async ( ) => {
511+ const evidenceOnly = await runNetworkOracleFixture ( "block" , "blocked" , { } , true )
512+ assert . equal ( evidenceOnly . result . findings . length , 0 )
513+ assert . deepEqual ( evidenceOnly . result . transitions [ 0 ] ?. observations . oracleFingerprints , [ ] )
514+ assert . equal ( evidenceOnly . result . transitions [ 0 ] ?. observations . networkFailureSummary ?. policyBlocks , 1 )
515+
516+ const input = { oraclePolicy : { policyBlocks : "finding" } }
517+ const attributed = await runNetworkOracleFixture ( "block" , "blocked" , input )
518+ const urlLess = await runNetworkOracleFixture ( "block" , "blocked" , input , true )
519+ const message = urlLess . result . transitions [ 0 ] ?. observations . consoleErrors [ 0 ]
520+ assert ( message )
521+ assert . equal ( urlLess . result . transitions [ 0 ] ?. observations . oracleFingerprints . length , 1 )
522+ assert . equal ( urlLess . result . findings [ 0 ] ?. fingerprint , browserAdaptiveDigest ( "oracle" , message ) )
523+ assert . equal ( urlLess . result . findings [ 0 ] ?. fingerprint , attributed . result . findings [ 0 ] ?. fingerprint , "removing console location must not change the historical console fingerprint" )
524+
525+ const mixed = await runNetworkOracleFixture ( "block" , "mixed" , { } , true )
526+ assert . equal ( mixed . result . findings . length , 1 )
527+ assert . equal ( mixed . result . transitions [ 0 ] ?. observations . oracleFingerprints . length , 1 )
528+ assert . equal ( mixed . result . transitions [ 0 ] ?. observations . networkFailureSummary ?. policyBlocks , 1 )
529+ assert . equal ( mixed . result . transitions [ 0 ] ?. observations . networkFailureSummary ?. oracleFindings , 1 )
530+ } )
531+
509532test ( "allow and record policies do not explain unexpected same-origin request failures" , async ( ) => {
510533 for ( const mode of [ "allow" , "record" ] as const ) {
511534 const run = await runNetworkOracleFixture ( mode , "unexpected" )
@@ -536,6 +559,31 @@ test("page exceptions remain findings alongside policy-aware network classificat
536559 assert . deepEqual ( run . result . findings [ 0 ] ?. replay . actions , run . result . findings [ 0 ] ?. minimizedPath )
537560} )
538561
562+ test ( "policy-block floods retain bounded deterministic evidence without stopping later findings" , async ( ) => {
563+ const [ first , second ] = await runPolicyBlockFloodFixture ( )
564+ for ( const result of [ first , second ] ) {
565+ const flood = result . transitions . find ( ( transition ) => transition . observations . networkFailureSummary ?. total === 80 )
566+ assert ( flood )
567+ assert . deepEqual ( flood . observations . networkFailureSummary , { total : 80 , retained : 2 , policyBlocks : 80 , oracleFindings : 0 , truncated : true } )
568+ assert . equal ( flood . observations . networkFailures ?. length , 2 )
569+ assert . deepEqual ( flood . observations . oracleFingerprints , [ ] )
570+ assert . equal ( result . status , "findings" )
571+ assert . equal ( result . summary . errors , 1 , "policy blocks must not consume the product error budget" )
572+ assert . notEqual ( result . summary . budgetExhausted , "maxArtifactBytes" )
573+ assert . equal ( result . findings . length , 1 )
574+ assert . equal ( result . findings [ 0 ] ?. originalPath . length , 2 )
575+ assert . deepEqual ( result . findings [ 0 ] ?. minimizedPath , result . findings [ 0 ] ?. originalPath )
576+ assert . deepEqual ( result . findings [ 0 ] ?. replay . actions , result . findings [ 0 ] ?. minimizedPath )
577+ }
578+ const stable = ( result : typeof first ) => ( {
579+ status : result . status ,
580+ summary : result . summary ,
581+ transitions : result . transitions . map ( ( transition ) => ( { action : transition . action . id , observations : transition . observations , status : transition . status } ) ) ,
582+ findings : result . findings ,
583+ } )
584+ assert . deepEqual ( stable ( second ) , stable ( first ) )
585+ } )
586+
539587test ( "loops, budgets, cancellation, frames, and partial evidence remain bounded" , async ( ) => {
540588 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>`
541589 const bounded = await runFixture ( loopFixture , {
@@ -651,7 +699,7 @@ async function runRoutedFixture(topology: ReturnType<typeof browserPreviewTopolo
651699 }
652700}
653701
654- async function runNetworkOracleFixture ( mode : "allow" | "block" | "record" , behavior : "blocked" | "unexpected" | "mixed" | "exception" , input : Record < string , unknown > = { } ) {
702+ async function runNetworkOracleFixture ( mode : "allow" | "block" | "record" , behavior : "blocked" | "unexpected" | "mixed" | "exception" , input : Record < string , unknown > = { } , urlLessConsole = false ) {
655703 const server = createServer ( ( request , response ) => {
656704 const path = new URL ( request . url ?? "/" , "http://preview.invalid" ) . pathname
657705 if ( path === "/failed.png" ) {
@@ -677,7 +725,8 @@ async function runNetworkOracleFixture(mode: "allow" | "block" | "record", behav
677725 const consoleMessages : Record < string , unknown > [ ] = [ ]
678726 const errors : Record < string , unknown > [ ] = [ ]
679727 const network : Record < string , unknown > [ ] = [ ]
680- attachBrowserCaptureListeners ( { captureConsole : true , captureErrors : true , captureNetwork : true , captureWebSocket : false , consoleMessages, errors, network, page } )
728+ attachBrowserCaptureListeners ( { captureConsole : ! urlLessConsole , captureErrors : true , captureNetwork : true , captureWebSocket : false , consoleMessages, errors, network, page } )
729+ if ( urlLessConsole ) page . on ( "console" , ( message ) => consoleMessages . push ( { type : message . type ( ) , text : message . text ( ) } ) )
681730 await page . addInitScript ( "globalThis.__name = value => value" )
682731 await page . goto ( startUrl , { waitUntil : "load" } )
683732 const contract = browserAdaptiveExplorationContract ( {
@@ -699,6 +748,59 @@ async function runNetworkOracleFixture(mode: "allow" | "block" | "record", behav
699748 }
700749}
701750
751+ async function runPolicyBlockFloodFixture ( ) {
752+ const server = createServer ( ( _request , response ) => {
753+ response . setHeader ( "content-type" , "text/html" )
754+ response . end ( `<!doctype html><style>button{display:block;width:180px;height:30px}</style><button id="trigger">Load assets</button><script>
755+ document.querySelector('#trigger').addEventListener('click', () => {
756+ for (let index = 0; index < 80; index += 1) {
757+ const image = document.createElement('img');
758+ image.src = 'http://assets.example.invalid/tile-' + String(index).padStart(3, '0') + '.png';
759+ document.body.append(image);
760+ }
761+ const fail = document.createElement('button');
762+ fail.id = 'fail';
763+ fail.textContent = 'Reveal defect';
764+ fail.addEventListener('click', () => setTimeout(() => { throw new Error('post-flood defect'); }, 0));
765+ document.body.append(fail);
766+ });
767+ </script>` )
768+ } )
769+ const startUrl = await listenLocalHttpServer ( server )
770+ const topology = browserPreviewTopology ( [ "network-policy=block" ] , undefined , startUrl )
771+ const run = async ( ) => {
772+ const browser = await chromium . launch ( { headless : true } )
773+ const context = await browser . newContext ( )
774+ await routeBrowserPreviewContextNetwork ( context , topology . networkPolicy , startUrl )
775+ const page = await context . newPage ( )
776+ const consoleMessages : Record < string , unknown > [ ] = [ ]
777+ const errors : Record < string , unknown > [ ] = [ ]
778+ const network : Record < string , unknown > [ ] = [ ]
779+ attachBrowserCaptureListeners ( { captureConsole : true , captureErrors : true , captureNetwork : true , captureWebSocket : false , consoleMessages, errors, network, page } )
780+ await page . addInitScript ( "globalThis.__name = value => value" )
781+ await page . goto ( startUrl , { waitUntil : "load" } )
782+ const contract = browserAdaptiveExplorationContract ( {
783+ seed : "policy-block-flood" ,
784+ startUrl,
785+ failOnFinding : false ,
786+ actionFamilies : [ "click" ] ,
787+ budgets : { maxActions : 20 , maxStates : 6 , maxTransitions : 10 , maxDurationMs : 20_000 , maxArtifactBytes : 40_000 , maxErrors : 2 } ,
788+ descriptorLimits : { maxPerState : 10 , maxDiagnostics : 3 , maxTextLength : 500 } ,
789+ stabilization : { pollIntervalMs : 25 , quietWindowMs : 100 , maxWaitMs : 1_500 , maxMutationRecords : 100 } ,
790+ } )
791+ try {
792+ return await exploreAdaptiveBrowserStateMachine ( { page, baseUrl : startUrl , contract, observations : { consoleMessages, errors, network } , navigationScope : topology . navigationScope , networkPolicy : topology . networkPolicy } )
793+ } finally {
794+ await browser . close ( )
795+ }
796+ }
797+ try {
798+ return [ await run ( ) , await run ( ) ] as const
799+ } finally {
800+ await closeHttpServer ( server )
801+ }
802+ }
803+
702804function stableAdaptiveEvidence ( result : Awaited < ReturnType < typeof runRoutedFixture > > ) {
703805 return {
704806 states : result . states . map ( ( state ) => ( { digest : state . digest , url : state . url , descriptors : state . descriptors . map ( ( descriptor ) => descriptor . id ) } ) ) ,
0 commit comments