@@ -13,14 +13,15 @@ const generation = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee';
1313const accepted = { choice : 'accepted' , generation, expiresAt : Date . now ( ) + 60_000 , revision : 10 } ;
1414const rejected = { choice : 'rejected' , generation : 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' , expiresAt : Date . now ( ) + 60_000 , revision : 11 } ;
1515const envelope = { protocol : 'registrystack-tracking' , version : 2 } ;
16+ const localRefusalKey = 'registrystack.tracking-local-refusal.v2' ;
1617
1718function fixture ( options = { } ) {
18- const { factory = createSharedTrackingClient , ...clientOptions } = options ;
19+ const { factory = createSharedTrackingClient , initialValues = [ ] , ...clientOptions } = options ;
1920 const listeners = new Map ( ) ;
2021 const documentListeners = new Map ( ) ;
2122 const sent = [ ] ;
2223 const frames = [ ] ;
23- const values = new Map ( ) ;
24+ const values = new Map ( initialValues ) ;
2425 const storage = {
2526 getItem : key => values . get ( key ) ?? null ,
2627 setItem : ( key , value ) => values . set ( key , value ) ,
@@ -457,6 +458,115 @@ test('a same-origin outage refusal gates an already-open tab and is relayed to t
457458 f . client . close ( ) ;
458459} ) ;
459460
461+ test ( 'a retained local refusal reads the shared revision before replaying rejection' , async ( t ) => {
462+ const f = fixture ( { initialValues : [ [ localRefusalKey , 'rejected' ] ] } ) ;
463+ t . after ( ( ) => f . client . close ( ) ) ;
464+ const recovery = f . client . read ( ) ;
465+ const outcome = recovery . then ( value => ( { value } ) , error => ( { error } ) ) ;
466+ await new Promise ( resolve => setImmediate ( resolve ) ) ;
467+
468+ const readRequest = f . sent [ 0 ] . data ;
469+ assert . equal ( readRequest . type , 'read' ) ;
470+ f . message ( { ...envelope , id : readRequest . id , ok : true , state : accepted } ) ;
471+ await new Promise ( resolve => setImmediate ( resolve ) ) ;
472+
473+ assert . equal ( f . client . state . choice , 'rejected' ) ;
474+ await assert . rejects ( f . client . pageview ( {
475+ page_url : 'https://docs.registrystack.org/' , page_title : 'Docs' , page_referrer : '' ,
476+ } ) , / t r a c k i n g / i) ;
477+ assert . equal ( f . sent . some ( ( { data } ) => data . type === 'pageview' ) , false ) ;
478+ const replay = f . sent [ 1 ] . data ;
479+ assert . equal ( replay . type , 'choose' ) ;
480+ assert . equal ( replay . choice , 'rejected' ) ;
481+ assert . equal ( replay . expectedRevision , accepted . revision ) ;
482+ f . message ( { ...envelope , id : replay . id , ok : true , state : rejected } ) ;
483+
484+ assert . deepEqual ( await outcome , { value : rejected } ) ;
485+ assert . equal ( f . values . has ( localRefusalKey ) , false ) ;
486+ assert . equal ( f . failures . length , 0 ) ;
487+ } ) ;
488+
489+ test ( 'an explicit acceptance supersedes a delayed startup refusal replay' , async ( t ) => {
490+ const f = fixture ( { initialValues : [ [ localRefusalKey , 'rejected' ] ] } ) ;
491+ t . after ( ( ) => f . client . close ( ) ) ;
492+ const startup = f . client . read ( ) ;
493+ const startupOutcome = startup . then ( value => ( { value } ) , error => ( { error } ) ) ;
494+ await new Promise ( resolve => setImmediate ( resolve ) ) ;
495+ const readRequest = f . sent [ 0 ] . data ;
496+ assert . equal ( readRequest . type , 'read' ) ;
497+
498+ const explicit = await nextRequest ( ( ) => f . client . choose ( 'accepted' ) ) ;
499+ const acceptRequest = f . sent [ 1 ] . data ;
500+ assert . equal ( acceptRequest . type , 'choose' ) ;
501+ assert . equal ( acceptRequest . choice , 'accepted' ) ;
502+ f . message ( { ...envelope , id : acceptRequest . id , ok : true , state : accepted } ) ;
503+ assert . deepEqual ( await explicit . promise , accepted ) ;
504+
505+ f . message ( { ...envelope , id : readRequest . id , ok : true , state : accepted } ) ;
506+ assert . deepEqual ( await startupOutcome , { value : accepted } ) ;
507+ await new Promise ( resolve => setImmediate ( resolve ) ) ;
508+ assert . equal (
509+ f . sent . filter ( ( { data } ) => data . type === 'choose' && data . choice === 'rejected' ) . length ,
510+ 0 ,
511+ ) ;
512+ assert . equal ( f . values . has ( localRefusalKey ) , false ) ;
513+ assert . equal ( f . failures . length , 0 ) ;
514+ } ) ;
515+
516+ test ( 'an explicit acceptance reconciles a stale revision while a retained refusal masks startup' , async ( t ) => {
517+ const f = fixture ( { initialValues : [ [ localRefusalKey , 'rejected' ] ] } ) ;
518+ t . after ( ( ) => f . client . close ( ) ) ;
519+ const startup = f . client . read ( ) ;
520+ const startupOutcome = startup . then ( value => ( { value } ) , error => ( { error } ) ) ;
521+ await new Promise ( resolve => setImmediate ( resolve ) ) ;
522+ const startupRead = f . sent [ 0 ] . data ;
523+
524+ const explicit = await nextRequest ( ( ) => f . client . choose ( 'accepted' ) ) ;
525+ const explicitOutcome = explicit . promise . then ( value => ( { value } ) , error => ( { error } ) ) ;
526+ const acceptRequest = f . sent [ 1 ] . data ;
527+ assert . equal ( acceptRequest . expectedRevision , 0 ) ;
528+ f . message ( { ...envelope , id : acceptRequest . id , ok : false , error : 'unavailable' } ) ;
529+ await new Promise ( resolve => setImmediate ( resolve ) ) ;
530+ const recoveryRead = f . sent [ 2 ] . data ;
531+ assert . equal ( recoveryRead . type , 'read' ) ;
532+ assert . equal ( f . client . state . choice , 'rejected' ) ;
533+ f . message ( { ...envelope , id : recoveryRead . id , ok : true , state : accepted } ) ;
534+
535+ assert . deepEqual ( await explicitOutcome , { value : accepted } ) ;
536+ f . message ( { ...envelope , id : startupRead . id , ok : true , state : accepted } ) ;
537+ assert . deepEqual ( await startupOutcome , { value : accepted } ) ;
538+ assert . equal ( f . sent . filter ( ( { data } ) => data . type === 'choose' ) . length , 1 ) ;
539+ assert . equal ( f . values . has ( localRefusalKey ) , false ) ;
540+ assert . equal ( f . failures . length , 0 ) ;
541+ } ) ;
542+
543+ test ( 'a later refusal survives an older acceptance reconciliation and a failed rejection request' , async ( t ) => {
544+ const f = fixture ( { initialValues : [ [ localRefusalKey , 'rejected' ] ] } ) ;
545+ t . after ( ( ) => f . client . close ( ) ) ;
546+ const acceptance = await nextRequest ( ( ) => f . client . choose ( 'accepted' ) ) ;
547+ const acceptanceOutcome = acceptance . promise . then ( value => ( { value } ) , error => ( { error } ) ) ;
548+ f . message ( { ...envelope , id : f . sent [ 0 ] . data . id , ok : false , error : 'unavailable' } ) ;
549+ await new Promise ( resolve => setImmediate ( resolve ) ) ;
550+ const recoveryRead = f . sent [ 1 ] . data ;
551+ assert . equal ( recoveryRead . type , 'read' ) ;
552+
553+ const refusal = await nextRequest ( ( ) => f . client . choose ( 'rejected' ) ) ;
554+ const refusalOutcome = refusal . promise . then ( value => ( { value } ) , error => ( { error } ) ) ;
555+ const rejectionRequest = f . sent [ 2 ] . data ;
556+ f . message ( { ...envelope , id : recoveryRead . id , ok : true , state : accepted } ) ;
557+ assert . equal ( ( await acceptanceOutcome ) . value ?. choice , 'rejected' ) ;
558+ assert . equal ( f . values . get ( localRefusalKey ) , 'rejected' ) ;
559+
560+ f . message ( { ...envelope , id : rejectionRequest . id , ok : false , error : 'unavailable' } ) ;
561+ assert . match ( ( await refusalOutcome ) . error ?. message , / t r a c k i n g / i) ;
562+ assert . equal ( f . client . state . choice , 'rejected' ) ;
563+ assert . equal ( f . values . get ( localRefusalKey ) , 'rejected' ) ;
564+ await assert . rejects ( f . client . pageview ( {
565+ page_url : 'https://docs.registrystack.org/' , page_title : 'Docs' , page_referrer : '' ,
566+ } ) , / t r a c k i n g / i) ;
567+ assert . equal ( f . sent . some ( ( { data } ) => data . type === 'pageview' ) , false ) ;
568+ } ) ;
569+
460570test ( 'page data preserves external acquisition and removes identity, search, and internal handoff parameters' , ( ) => {
461571 const page = buildMauticPageview ( {
462572 canonicalUrl : 'https://docs.registrystack.org/tutorials/first-registry/?search=private#record' ,
0 commit comments