2026-10-14 22:03:21 -07:00
import assert from "node:assert/strict" ;
// Fresh isolated Chromium only. All REST, sockets and PushManager provider calls
// are mocked. The real /sw.js and browser IndexedDB/notification APIs execute.
// PLAYWRIGHT_MODULE=/path/playwright-core/index.mjs CHROME_EXECUTABLE=/path/chrome
// WEB_TEST_URL=http://127.0.0.1:5197 node tests/agent-push.browser.mjs
const { chromium } = await import ( process . env . PLAYWRIGHT_MODULE || "playwright-core" );
const target = new URL ( process . env . WEB_TEST_URL || "http://127.0.0.1:5197/" );
assert . ok ( target . protocol === "http:" && [ "127.0.0.1" , "localhost" , "[::1]" ]. includes ( target . hostname ));
const browser = await chromium . launch ({ executablePath : process . env . CHROME_EXECUTABLE || undefined , headless : true ,
...( process . env . BROWSER_NO_SANDBOX === "1" ? { args : [ "--no-sandbox" ] } : {}) });
const context = await browser . newContext ({ viewport : { width : 1200 , height : 900 }, permissions : [ "notifications" ] });
const sourceId = "4599d348-e053-4937-987d-1392746fb2ce" , requests = [], sockets = [], errors = [], blocked = [];
const replacementSourceId = "22222222-2222-4222-8222-222222222222" ;
const config = { version : 1 , enabled : true , webPushAvailable : true , apnsAvailable : false , sourceId : null , quarantined : false ,
vapidPublicKey : "B" . repeat ( 87 ), policy : { presenceHeartbeatMs : 15000 , presenceLeaseMs : 60000 } };
const idle = { state : "idle" , isThinking : false , queued : 0 , backgroundWorkers : 0 , tools : [], queuedMessages : [] };
let readCursor = "0" , readSourceId = sourceId , failDelete = false ;
await context . addInitScript (() => {
if ( ! localStorage . getItem ( "fixture-bootstrapped" )) {
localStorage . setItem ( "fixture-bootstrapped" , "true" ); localStorage . setItem ( "sybil_admin_token" , "browser-fixture-token" );
}
window . __pushCalls = [];
window . __fixturePermission = localStorage . getItem ( "fixture-subscription" ) ? "granted" : "default" ;
window . __fixtureDecision = "denied" ;
Object . defineProperty ( Notification , "permission" , { configurable : true , get : () => window . __fixturePermission });
Notification . requestPermission = () => {
window . __pushCalls . push ({ type : "permission" , gesture : navigator . userActivation . isActive });
window . __fixturePermission = window . __fixtureDecision ; return Promise . resolve ( window . __fixturePermission );
};
const subscription = () => ({
toJSON : () => ({ endpoint : "https://fcm.googleapis.com/fcm/send/browser-fixture" , expirationTime : null , keys : { p256dh : "B" . repeat ( 87 ), auth : "A" . repeat ( 22 ) } }),
unsubscribe : async () => { window . __pushCalls . push ({ type : "unsubscribe" }); localStorage . removeItem ( "fixture-subscription" ); return true ; },
});
PushManager . prototype . getSubscription = async () => localStorage . getItem ( "fixture-subscription" ) ? subscription () : null ;
PushManager . prototype . subscribe = async options => {
window . __pushCalls . push ({ type : "subscribe" , visible : options . userVisibleOnly , keyLength : options . applicationServerKey . byteLength });
localStorage . setItem ( "fixture-subscription" , "true" ); return subscription ();
};
});
await context . route ( "**/*" , async route => {
const request = route . request (), url = new URL ( request . url ());
if ( url . origin !== target . origin ) { blocked . push ( url . origin ); await route . abort (); return ; }
if ( ! url . pathname . startsWith ( "/api/" )) { await route . continue (); return ; }
const method = request . method (), path = url . pathname ;
const body = request . postData () ? request . postDataJSON () : undefined ;
requests . push ({ path , method , body , authorization : request . headers (). authorization });
assert . ok ([ "Bearer browser-fixture-token" , "Bearer browser-fixture-rotated-token" ]. includes ( request . headers (). authorization ));
let value ;
if ( path . endsWith ( "/push/config" )) value = config ;
else if ( path . includes ( "/push/installations/" )) {
if ( method === "DELETE" ) { await route . fulfill ({ status : failDelete ? 503 : 204 , ...( failDelete ? { json : { message : "offline" } } : {}) }); return ; }
value = { installationId : path . split ( "/" ). at ( - 1 ), transport : "web" , enabled : true , version : 1 };
} else if ( path . includes ( "/presence/" )) {
if ( method === "DELETE" ) { await route . fulfill ({ status : 204 }); return ; }
value = { expiresAt : new Date ( Date . now () + 60000 ). toISOString () };
} else if ( path . endsWith ( "/read-state" )) {
if ( method === "PUT" ) readCursor = body . cursor ;
value = { cursor : readCursor , sourceId : readSourceId , updatedAt : null };
} else if ( path . endsWith ( "/agent/messages" )) value = { conversationId : "1" , messages : [], cursor : "0" , beforeCursor : null , hasMore : false };
else if ( path . endsWith ( "/agent" )) value = { id : "1" , title : "Sybil Agent" , activity : idle , protocol : "pi-room.v1" , websocket : "/v1/agent/ws" };
else if ( path . includes ( "session" )) value = { mode : "token" , authenticated : true };
else if ( path . endsWith ( "/tools" ) || path . endsWith ( "/chat-tools" )) value = { tools : [] };
else if ( path . endsWith ( "/models" )) value = { models : [], providers : {} };
else value = [];
await route . fulfill ({ json : value });
});
await context . routeWebSocket ( "**/api/v1/agent/ws" , socket => {
socket . onMessage ( raw => {
const frame = JSON . parse ( String ( raw ));
if ( frame . type === "auth" ) { sockets . push ({ socket , frame }); socket . send ( JSON . stringify ({ type : "ready" , conversationId : "1" })); }
});
});
const page = await context . newPage (); page . on ( "pageerror" , error => errors . push ( error . message ));
const expectEventually = async predicate => { for ( let i = 0 ; i < 100 ; i ++ ) { if ( await predicate ()) return ; await new Promise ( resolve => setTimeout ( resolve , 50 )); } assert . fail ( "Condition did not settle" ); };
try {
await page . goto ( target . href , { waitUntil : "networkidle" });
await page . getByText ( "Agent notifications" , { exact : true }). click ();
await page . getByText ( "Notifications are not configured on this server yet." , { exact : true }). waitFor ();
assert . equal ( await page . getByRole ( "button" , { name : "Enable notifications" , exact : true }). isDisabled (), true , "unknown source cannot opt in" );
assert . deepEqual ( await page . evaluate (() => window . __pushCalls ), []);
config . sourceId = sourceId ;
await page . getByRole ( "button" , { name : "Refresh" , exact : true }). click ();
await expectEventually (() => page . getByRole ( "button" , { name : "Enable notifications" , exact : true }). isEnabled ());
assert . equal ( requests . filter ( request => request . path . includes ( "/push/installations/" )). length , 0 );
await page . getByRole ( "button" , { name : "Enable notifications" , exact : true }). click ();
await page . getByText ( /Notifications were not allowed/ ). waitFor ();
assert . equal ( requests . filter ( request => request . path . includes ( "/push/installations/" )). length , 0 );
assert . deepEqual ( await page . evaluate (() => window . __pushCalls ), [{ type : "permission" , gesture : true }]);
await page . evaluate (() => { window . __fixturePermission = "default" ; window . __fixtureDecision = "granted" ; });
await page . getByRole ( "button" , { name : "Refresh" , exact : true }). click ();
await expectEventually (() => page . getByRole ( "button" , { name : "Enable notifications" , exact : true }). isEnabled ());
await page . getByRole ( "button" , { name : "Enable notifications" , exact : true }). click ();
await page . getByRole ( "button" , { name : "Turn off notifications" , exact : true }). waitFor ();
const registrations = requests . filter ( request => request . path . includes ( "/push/installations/" ) && request . method === "PUT" );
assert . equal ( registrations . length , 1 ); assert . equal ( registrations [ 0 ]. body . transport , "web" );
assert . deepEqual ( await page . evaluate (() => window . __pushCalls . filter ( call => call . type === "subscribe" )), [{ type : "subscribe" , visible : true , keyLength : 65 }]);
await expectEventually (() => sockets . length === 1 );
const firstSession = sockets [ 0 ]. frame . presenceSessionId ;
assert . equal ( sockets [ 0 ]. frame . clientKind , "web" );
assert . ok ( requests . some ( request => request . path . endsWith ( firstSession ) && request . body ? . visibility === "visible" ));
// /search has no Agent pane or room socket but must still report attention.
const second = await context . newPage (); second . on ( "pageerror" , error => errors . push ( error . message ));
await second . goto ( new URL ( "/search" , target ). href , { waitUntil : "networkidle" });
await second . bringToFront ();
await expectEventually (() => new Set ( requests . filter ( request => request . path . includes ( "/presence/" )). map ( request => request . path )). size >= 2 );
const sessionPaths = [... new Set ( requests . filter ( request => request . path . includes ( "/presence/" )). map ( request => request . path ))];
assert . equal ( sessionPaths . length , 2 );
const secondPath = sessionPaths . find ( path => ! path . endsWith ( firstSession ));
await expectEventually (() => requests . some ( request => request . path === secondPath && request . body ? . focused === true ));
2026-10-14 23:06:34 -07:00
// Playwright forces every target focused/visible. Supply controlled DOM state
// to exercise the production reporter's blur/visibility wiring deterministically.
const beforeBlur = requests . length ;
await page . evaluate (() => {
Object . defineProperty ( document , "hasFocus" , { configurable : true , value : () => false });
window . dispatchEvent ( new Event ( "blur" ));
});
await expectEventually (() => requests . slice ( beforeBlur ). some ( request => request . path . endsWith ( firstSession ) && request . body ? . visibility === "visible" && request . body ? . focused === false ));
await page . evaluate (() => {
Object . defineProperty ( document , "hidden" , { configurable : true , value : true });
document . dispatchEvent ( new Event ( "visibilitychange" ));
});
await expectEventually (() => requests . slice ( beforeBlur ). some ( request => request . path . endsWith ( firstSession ) && request . body ? . visibility === "hidden" && request . body ? . focused === false ));
await page . evaluate (() => { delete document . hasFocus ; delete document . hidden ; window . dispatchEvent ( new Event ( "focus" )); });
2026-10-14 22:03:21 -07:00
await second . getByRole ( "textbox" ). first (). click ();
await second . getByRole ( "textbox" ). first (). pressSequentially ( "interaction" );
await expectEventually (() => requests . some ( request => request . path === secondPath && request . body ? . interacted === true ));
for ( const path of sessionPaths ) {
const sequences = requests . filter ( request => request . path === path && request . method === "PUT" ). map ( request => request . body . sequence );
assert . deepEqual ( sequences , [... new Set ( sequences )]. sort (( a , b ) => a - b ));
}
await page . bringToFront ();
2026-10-14 23:06:34 -07:00
const beforeHide = requests . length ;
await page . evaluate (() => window . dispatchEvent ( new Event ( "pagehide" )));
await expectEventually (() => requests . slice ( beforeHide ). some ( request => request . path . endsWith ( firstSession ) && request . body ? . visibility === "hidden" && request . body ? . focused === false ));
await page . evaluate (() => window . dispatchEvent ( new Event ( "pageshow" )));
await expectEventually (() => requests . slice ( beforeHide ). some ( request => request . path . endsWith ( firstSession ) && request . body ? . visibility === "visible" && request . body ? . focused === true ));
2026-10-14 22:03:21 -07:00
const worker = context . serviceWorkers (). find ( worker => new URL ( worker . url ()). pathname === "/sw.js" );
assert . ok ( worker , "actual service worker activated" );
const push = async cursor => worker . evaluate ( async ({ cursor , sourceId }) => {
const value = { version : 1 , kind : "message" , sourceId , cursor , messageId : `outbound: ${ cursor } ` , title : "Sybil Agent" , body : "You have a new message." , url : "/?agent=root" , tag : `sybil-agent: ${ sourceId } ` };
const event = new Event ( "push" ), waits = [];
Object . defineProperties ( event , { data : { value : { text : () => JSON . stringify ( value ) } }, waitUntil : { value : promise => waits . push ( promise ) } });
self . dispatchEvent ( event ); await Promise . all ( waits );
return ( await self . registration . getNotifications ()). map ( notification => ({ title : notification . title , body : notification . body , tag : notification . tag , data : notification . data }));
}, { cursor , sourceId });
const notification = await push ( "8" );
assert . equal ( notification . length , 1 ); assert . equal ( notification [ 0 ]. title , "Sybil Agent" ); assert . equal ( notification [ 0 ]. body , "You have a new message." );
assert . equal ( notification [ 0 ]. data . cursor , "8" );
assert . equal ( requests . filter ( request => request . method === "PUT" && request . path . endsWith ( "/read-state" )). length , 0 , "push reception never marks read" );
readCursor = "7" ;
sockets [ 0 ]. socket . send ( JSON . stringify ({ type : "read_state" , sourceId , cursor : readCursor , updatedAt : null }));
await new Promise ( resolve => setTimeout ( resolve , 200 ));
assert . equal ( await worker . evaluate ( async () => ( await self . registration . getNotifications ()). length ), 1 );
readCursor = "8" ;
sockets [ 0 ]. socket . send ( JSON . stringify ({ type : "read_state" , sourceId , cursor : readCursor , updatedAt : null }));
await expectEventually ( async () => await worker . evaluate ( async () => ( await self . registration . getNotifications ()). length ) === 0 );
const dbRows = await worker . evaluate ( async () => {
const db = await new Promise ( resolve => { const request = indexedDB . open ( "sybil-agent-notifications-v1" ); request . onsuccess = () => resolve ( request . result ); });
const rows = await new Promise ( resolve => { const request = db . transaction ( "sources" ). objectStore ( "sources" ). getAll (); request . onsuccess = () => resolve ( request . result ); });
db . close (); return rows ;
});
assert . equal ( dbRows [ 0 ]. readCursor , "8" ); assert . doesNotMatch ( JSON . stringify ( dbRows ), /browser-fixture-token|fcm.googleapis/ );
// The rendered room belongs to source A. A B read frame must stop the room,
// preserving A's cursor instead of relabeling old queued reads with B.
await second . close ();
await page . locator ( '[data-testid="agent-sidebar-item"]' ). click ();
sockets [ 0 ]. socket . send ( JSON . stringify ({ type : "read_state" , sourceId : replacementSourceId , cursor : "0" , updatedAt : null }));
await page . getByText ( "Agent history changed. Reload the page before marking messages read." , { exact : true }). waitFor ();
await page . evaluate (() => window . dispatchEvent ( new Event ( "focus" )));
await new Promise ( resolve => setTimeout ( resolve , 250 ));
assert . equal ( requests . filter ( request => request . method === "PUT" && request . path . endsWith ( "/read-state" )). length , 0 , "source mismatch cannot produce relabeled writes or a write loop" );
// Replacing the source at the same backend/token invalidates persisted push
// consent. The new source requires another gesture and another installation.
const oldConsent = await page . evaluate (() => JSON . parse ( localStorage . getItem ( "sybil.agent.push.v1" )));
const registrationCount = requests . filter ( request => request . path . includes ( "/push/installations/" ) && request . method === "PUT" ). length ;
config . sourceId = replacementSourceId ; readSourceId = replacementSourceId ; readCursor = "0" ;
await page . getByRole ( "button" , { name : "Refresh" , exact : true }). click ();
await expectEventually (() => page . getByRole ( "button" , { name : "Enable notifications" , exact : true }). isEnabled ());
const retiredConsent = await page . evaluate (() => JSON . parse ( localStorage . getItem ( "sybil.agent.push.v1" )));
assert . equal ( retiredConsent . enabled , false ); assert . equal ( retiredConsent . sourceId , sourceId );
assert . equal ( requests . filter ( request => request . path . includes ( "/push/installations/" ) && request . method === "PUT" ). length , registrationCount );
assert . ok ( requests . some ( request => request . path . endsWith ( oldConsent . installationId ) && request . method === "DELETE" ));
await page . getByRole ( "button" , { name : "Enable notifications" , exact : true }). click ();
await page . getByRole ( "button" , { name : "Turn off notifications" , exact : true }). waitFor ();
const replacementConsent = await page . evaluate (() => JSON . parse ( localStorage . getItem ( "sybil.agent.push.v1" )));
assert . equal ( replacementConsent . enabled , true ); assert . equal ( replacementConsent . sourceId , replacementSourceId );
assert . equal ( replacementConsent . scope , oldConsent . scope ); assert . notEqual ( replacementConsent . installationId , oldConsent . installationId );
assert . ok (( await page . evaluate (() => window . __pushCalls . filter ( call => call . type === "permission" ))). every ( call => call . gesture === true ));
const socketCount = sockets . length ;
await page . getByRole ( "button" , { name : "Retry" , exact : true }). click ();
await expectEventually (() => sockets . length > socketCount );
assert . equal ( await page . getByText ( "Agent history changed. Reload the page before marking messages read." , { exact : true }). count (), 0 , "explicit reload binds a fresh room to B" );
await page . getByText ( "Agent notifications" , { exact : true }). click ();
await page . getByRole ( "button" , { name : "Turn off notifications" , exact : true }). waitFor ();
// Credential rotation is a new authority scope. The old UUID remains queued
// locally even if this server would return204 for an unknown DELETE.
const originalAuthority = await page . evaluate (() => JSON . parse ( localStorage . getItem ( "sybil.agent.push.v1" )));
await page . evaluate (() => localStorage . setItem ( "sybil_admin_token" , "browser-fixture-rotated-token" ));
await page . reload ({ waitUntil : "networkidle" });
await page . getByText ( "Agent notifications" , { exact : true }). click ();
await expectEventually (() => page . getByRole ( "button" , { name : "Enable notifications" , exact : true }). isEnabled ());
const rotatedOff = await page . evaluate (() => JSON . parse ( localStorage . getItem ( "sybil.agent.push.v1" )));
assert . deepEqual ( rotatedOff . pendingRevocations , [{ installationId : originalAuthority . installationId , scope : originalAuthority . scope }]);
assert . ok ( ! requests . some ( request => request . method === "DELETE" && request . path . endsWith ( originalAuthority . installationId )
&& request . authorization === "Bearer browser-fixture-rotated-token" ), "rotated credentials cannot falsely acknowledge old-authority cleanup" );
await page . evaluate (() => { window . __fixtureDecision = "granted" ; });
await page . getByRole ( "button" , { name : "Enable notifications" , exact : true }). click ();
await page . getByRole ( "button" , { name : "Turn off notifications" , exact : true }). waitFor ();
const rotatedOn = await page . evaluate (() => JSON . parse ( localStorage . getItem ( "sybil.agent.push.v1" )));
assert . equal ( rotatedOn . enabled , true ); assert . notEqual ( rotatedOn . scope , originalAuthority . scope );
assert . notEqual ( rotatedOn . installationId , originalAuthority . installationId ); assert . deepEqual ( rotatedOn . pendingRevocations , rotatedOff . pendingRevocations );
await page . evaluate (() => localStorage . setItem ( "sybil_admin_token" , "browser-fixture-token" ));
await page . reload ({ waitUntil : "networkidle" });
await page . getByText ( "Agent notifications" , { exact : true }). click ();
await expectEventually (() => page . getByRole ( "button" , { name : "Enable notifications" , exact : true }). isEnabled ());
const returned = await page . evaluate (() => JSON . parse ( localStorage . getItem ( "sybil.agent.push.v1" )));
assert . deepEqual ( returned . pendingRevocations , [{ installationId : rotatedOn . installationId , scope : rotatedOn . scope }]);
assert . ok ( requests . some ( request => request . method === "DELETE" && request . path . endsWith ( originalAuthority . installationId )
&& request . authorization === "Bearer browser-fixture-token" ));
await page . evaluate (() => { window . __fixtureDecision = "granted" ; });
await page . getByRole ( "button" , { name : "Enable notifications" , exact : true }). click ();
await page . getByRole ( "button" , { name : "Turn off notifications" , exact : true }). waitFor ();
// Offline sign-out still removes the local subscription and leaves a tombstone.
failDelete = true ;
await page . getByRole ( "button" , { name : "Sign out" , exact : true }). click ();
await page . getByRole ( "button" , { name : /Sign in/i }). waitFor ();
const local = await page . evaluate (() => ({ token : localStorage . getItem ( "sybil_admin_token" ), subscription : localStorage . getItem ( "fixture-subscription" ), consent : JSON . parse ( localStorage . getItem ( "sybil.agent.push.v1" )) }));
assert . equal ( local . token , null ); assert . equal ( local . subscription , null ); assert . equal ( local . consent . enabled , false ); assert . equal ( local . consent . revocationPending , true );
assert . ok ( requests . some ( request => request . method === "DELETE" && request . path . includes ( "/push/installations/" ) && request . authorization === "Bearer browser-fixture-token" ));
// Existing app fonts are blocked as well; nothing leaves the loopback fixture.
assert . ok ( blocked . every ( origin => origin === "https://fonts.googleapis.com" )); assert . deepEqual ( errors , []);
console . log ( "PASS real worker activation/showNotification/IndexedDB/read close; unknown-source/gesture denial/enable; two-tab + standalone search presence; auth-frame binding; source-replacement read quiescence and explicit re-opt-in/reload; authority-scoped cleanup/credential rotation/return retry; offline logout; no provider network" );
} finally { await context . close (); await browser . close (); }