@@ -529,6 +529,23 @@ test("URL-less policy-block console records correlate before promotion and prese
529529 assert . equal ( mixed . result . transitions [ 0 ] ?. observations . networkFailureSummary ?. oracleFindings , 1 )
530530} )
531531
532+ test ( "same-URL product errors require a matching failure token before policy correlation" , async ( ) => {
533+ const productMessage = "same-URL product defect"
534+ const productFingerprint = browserAdaptiveDigest ( "oracle" , productMessage )
535+ const evidence = await runNetworkOracleFixture ( "block" , "blocked" , { } , false , true )
536+ assert . deepEqual ( evidence . result . transitions [ 0 ] ?. observations . oracleFingerprints , [ productFingerprint ] )
537+ assert . equal ( evidence . result . findings [ 0 ] ?. fingerprint , productFingerprint )
538+
539+ const finding = await runNetworkOracleFixture ( "block" , "blocked" , { oraclePolicy : { policyBlocks : "finding" } } , false , true )
540+ const fingerprints = finding . result . transitions [ 0 ] ?. observations . oracleFingerprints ?? [ ]
541+ const blockMessage = finding . result . transitions [ 0 ] ?. observations . consoleErrors . find ( ( message ) => message . includes ( "ERR_BLOCKED_BY_CLIENT" ) )
542+ assert ( blockMessage )
543+ assert . equal ( fingerprints . length , 2 )
544+ assert ( fingerprints . includes ( productFingerprint ) )
545+ assert ( fingerprints . includes ( browserAdaptiveDigest ( "oracle" , blockMessage ) ) )
546+ assert . equal ( finding . result . transitions [ 0 ] ?. observations . networkFailureSummary ?. policyBlocks , 1 )
547+ } )
548+
532549test ( "allow and record policies do not explain unexpected same-origin request failures" , async ( ) => {
533550 for ( const mode of [ "allow" , "record" ] as const ) {
534551 const run = await runNetworkOracleFixture ( mode , "unexpected" )
@@ -562,16 +579,20 @@ test("page exceptions remain findings alongside policy-aware network classificat
562579test ( "policy-block floods retain bounded deterministic evidence without stopping later findings" , async ( ) => {
563580 const [ first , second ] = await runPolicyBlockFloodFixture ( )
564581 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 , [ ] )
582+ const floods = result . transitions . filter ( ( transition ) => transition . observations . networkFailureSummary ?. total === 80 )
583+ assert . equal ( floods . length , 3 )
584+ assert . equal ( floods . reduce ( ( total , transition ) => total + ( transition . observations . networkFailures ?. length ?? 0 ) , 0 ) , 2 , "retained failures are bounded across the artifact" )
585+ assert . deepEqual ( floods . map ( ( transition ) => transition . observations . networkFailureSummary ) , [
586+ { total : 80 , retained : 2 , policyBlocks : 80 , oracleFindings : 0 , truncated : true } ,
587+ { total : 80 , retained : 0 , policyBlocks : 80 , oracleFindings : 0 , truncated : true } ,
588+ { total : 80 , retained : 0 , policyBlocks : 80 , oracleFindings : 0 , truncated : true } ,
589+ ] )
590+ assert ( floods . every ( ( transition ) => transition . observations . oracleFingerprints . length === 0 ) )
570591 assert . equal ( result . status , "findings" )
571592 assert . equal ( result . summary . errors , 1 , "policy blocks must not consume the product error budget" )
572593 assert . notEqual ( result . summary . budgetExhausted , "maxArtifactBytes" )
573594 assert . equal ( result . findings . length , 1 )
574- assert . equal ( result . findings [ 0 ] ?. originalPath . length , 2 )
595+ assert . equal ( result . findings [ 0 ] ?. originalPath . length , 4 )
575596 assert . deepEqual ( result . findings [ 0 ] ?. minimizedPath , result . findings [ 0 ] ?. originalPath )
576597 assert . deepEqual ( result . findings [ 0 ] ?. replay . actions , result . findings [ 0 ] ?. minimizedPath )
577598 }
@@ -699,7 +720,7 @@ async function runRoutedFixture(topology: ReturnType<typeof browserPreviewTopolo
699720 }
700721}
701722
702- async function runNetworkOracleFixture ( mode : "allow" | "block" | "record" , behavior : "blocked" | "unexpected" | "mixed" | "exception" , input : Record < string , unknown > = { } , urlLessConsole = false ) {
723+ async function runNetworkOracleFixture ( mode : "allow" | "block" | "record" , behavior : "blocked" | "unexpected" | "mixed" | "exception" , input : Record < string , unknown > = { } , urlLessConsole = false , sameUrlProductError = false ) {
703724 const server = createServer ( ( request , response ) => {
704725 const path = new URL ( request . url ?? "/" , "http://preview.invalid" ) . pathname
705726 if ( path === "/failed.png" ) {
@@ -725,8 +746,12 @@ async function runNetworkOracleFixture(mode: "allow" | "block" | "record", behav
725746 const consoleMessages : Record < string , unknown > [ ] = [ ]
726747 const errors : Record < string , unknown > [ ] = [ ]
727748 const network : Record < string , unknown > [ ] = [ ]
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 ( ) } ) )
749+ attachBrowserCaptureListeners ( { captureConsole : ! urlLessConsole && ! sameUrlProductError , captureErrors : true , captureNetwork : true , captureWebSocket : false , consoleMessages, errors, network, page } )
750+ if ( urlLessConsole || sameUrlProductError ) page . on ( "console" , ( message ) => {
751+ const location = message . location ( )
752+ if ( sameUrlProductError && message . text ( ) . includes ( "ERR_BLOCKED_BY_CLIENT" ) ) consoleMessages . push ( { type : "error" , text : "same-URL product defect" , location } )
753+ consoleMessages . push ( { type : message . type ( ) , text : message . text ( ) , ...( urlLessConsole ? { } : { location } ) } )
754+ } )
730755 await page . addInitScript ( "globalThis.__name = value => value" )
731756 await page . goto ( startUrl , { waitUntil : "load" } )
732757 const contract = browserAdaptiveExplorationContract ( {
@@ -753,11 +778,16 @@ async function runPolicyBlockFloodFixture() {
753778 response . setHeader ( "content-type" , "text/html" )
754779 response . end ( `<!doctype html><style>button{display:block;width:180px;height:30px}</style><button id="trigger">Load assets</button><script>
755780 document.querySelector('#trigger').addEventListener('click', () => {
781+ const stage = Number(document.querySelector('#trigger').dataset.stage || '0') + 1;
782+ document.querySelector('#trigger').dataset.stage = String(stage);
783+ document.querySelector('#trigger').textContent = 'Load assets stage ' + String(stage);
756784 for (let index = 0; index < 80; index += 1) {
757785 const image = document.createElement('img');
758- image.src = 'http://assets.example.invalid/tile-' + String(index).padStart(3, '0') + '.png';
786+ image.src = 'http://assets.example.invalid/tile-' + String(stage) + '-' + String( index).padStart(3, '0') + '.png';
759787 document.body.append(image);
760788 }
789+ if (stage < 3) return;
790+ document.querySelector('#trigger').disabled = true;
761791 const fail = document.createElement('button');
762792 fail.id = 'fail';
763793 fail.textContent = 'Reveal defect';
@@ -784,7 +814,7 @@ async function runPolicyBlockFloodFixture() {
784814 startUrl,
785815 failOnFinding : false ,
786816 actionFamilies : [ "click" ] ,
787- budgets : { maxActions : 20 , maxStates : 6 , maxTransitions : 10 , maxDurationMs : 20_000 , maxArtifactBytes : 40_000 , maxErrors : 2 } ,
817+ budgets : { maxActions : 40 , maxStates : 8 , maxTransitions : 20 , maxDurationMs : 30_000 , maxArtifactBytes : 40_000 , maxErrors : 2 } ,
788818 descriptorLimits : { maxPerState : 10 , maxDiagnostics : 3 , maxTextLength : 500 } ,
789819 stabilization : { pollIntervalMs : 25 , quietWindowMs : 100 , maxWaitMs : 1_500 , maxMutationRecords : 100 } ,
790820 } )
0 commit comments