@@ -269,70 +269,76 @@ try {
269269 // Only the synthetic page is pregranted; native permission prompts are a separate acceptance check.
270270 manifest . host_permissions . push ( `${ fixtureOrigin } /*` )
271271 await writeFile ( resolve ( extensionPath , 'manifest.json' ) , JSON . stringify ( manifest ) )
272- context = await chromium . launchPersistentContext ( resolve ( temporary , 'profile' ) , {
273- channel : 'chromium' ,
274- headless : process . env . EXTENSION_APP_E2E_HEADED !== '1' ,
275- viewport : { width : 390 , height : 844 } ,
276- args : [
277- `--disable-extensions-except=${ extensionPath } ` ,
278- `--load-extension=${ extensionPath } ` ,
279- '--use-fake-device-for-media-stream' ,
280- ] ,
281- } )
282- const browserContext = context
272+ const launchBrowserContext = ( ) =>
273+ chromium . launchPersistentContext ( resolve ( temporary , 'profile' ) , {
274+ channel : 'chromium' ,
275+ headless : process . env . EXTENSION_APP_E2E_HEADED !== '1' ,
276+ viewport : { width : 390 , height : 844 } ,
277+ args : [
278+ `--disable-extensions-except=${ extensionPath } ` ,
279+ `--load-extension=${ extensionPath } ` ,
280+ '--use-fake-device-for-media-stream' ,
281+ ] ,
282+ } )
283+ context = await launchBrowserContext ( )
284+ let browserContext = context
283285 context . setDefaultTimeout ( 30_000 )
284286 context . setDefaultNavigationTimeout ( 120_000 )
285- const worker = context . serviceWorkers ( ) [ 0 ] ?? ( await context . waitForEvent ( 'serviceworker' ) )
287+ let worker = context . serviceWorkers ( ) [ 0 ] ?? ( await context . waitForEvent ( 'serviceworker' ) )
286288 const extensionId = worker . url ( ) . split ( '/' ) [ 2 ]
287- panel = await context . newPage ( )
288- const shell = panel
289- await shell . route ( '**/api/settings/voice' , ( route ) =>
290- route . fulfill ( { json : { sttAvailable : true } } )
291- )
292- panel . on ( 'pageerror' , ( error ) => browserErrors . push ( error . message ) )
293- panel . on ( 'request' , ( request ) => pendingRequests . add ( request ) )
294- panel . on ( 'requestfinished' , ( request ) => pendingRequests . delete ( request ) )
295- panel . on ( 'requestfailed' , ( request ) => {
296- pendingRequests . delete ( request )
297- failedRequests . push ( {
298- path : new URL ( request . url ( ) ) . pathname ,
299- error : request . failure ( ) ?. errorText ?? null ,
289+ const openPanel = async ( ) => {
290+ const page = await browserContext . newPage ( )
291+ await page . route ( '**/api/settings/voice' , ( route ) =>
292+ route . fulfill ( { json : { sttAvailable : true } } )
293+ )
294+ page . on ( 'pageerror' , ( error ) => browserErrors . push ( error . message ) )
295+ page . on ( 'request' , ( request ) => pendingRequests . add ( request ) )
296+ page . on ( 'requestfinished' , ( request ) => pendingRequests . delete ( request ) )
297+ page . on ( 'requestfailed' , ( request ) => {
298+ pendingRequests . delete ( request )
299+ failedRequests . push ( {
300+ path : new URL ( request . url ( ) ) . pathname ,
301+ error : request . failure ( ) ?. errorText ?? null ,
302+ } )
300303 } )
301- } )
302- panel . on ( 'response' , ( response ) => {
303- const url = new URL ( response . url ( ) )
304- if ( url . origin === base . origin && url . pathname === '/api/mothership/chat' ) {
305- const entry : ( typeof chatResponses ) [ number ] = {
306- status : response . status ( ) ,
307- chatId : response . headers ( ) [ MOTHERSHIP_CHAT_ID_HEADER . toLowerCase ( ) ] ?? null ,
308- events : [ ] ,
304+ page . on ( 'response' , ( response ) => {
305+ const url = new URL ( response . url ( ) )
306+ if ( url . origin === base . origin && url . pathname === '/api/mothership/chat' ) {
307+ const entry : ( typeof chatResponses ) [ number ] = {
308+ status : response . status ( ) ,
309+ chatId : response . headers ( ) [ MOTHERSHIP_CHAT_ID_HEADER . toLowerCase ( ) ] ?? null ,
310+ events : [ ] ,
311+ }
312+ chatResponses . push ( entry )
313+ responseDiagnostics . push (
314+ response
315+ . text ( )
316+ . then ( ( body ) => {
317+ for ( const line of body . split ( '\n' ) ) {
318+ if ( ! line . startsWith ( 'data: ' ) ) continue
319+ const event = toRecord ( JSON . parse ( line . slice ( 6 ) ) )
320+ entry . events . push ( {
321+ type : toStringOrNull ( event . type ) ,
322+ seq : toNumberOrNull ( event . seq ) ,
323+ } )
324+ }
325+ } )
326+ . catch ( ( error ) => {
327+ entry . error = getErrorMessage ( error )
328+ } )
329+ )
309330 }
310- chatResponses . push ( entry )
311- responseDiagnostics . push (
312- response
313- . text ( )
314- . then ( ( body ) => {
315- for ( const line of body . split ( '\n' ) ) {
316- if ( ! line . startsWith ( 'data: ' ) ) continue
317- const event = toRecord ( JSON . parse ( line . slice ( 6 ) ) )
318- entry . events . push ( {
319- type : toStringOrNull ( event . type ) ,
320- seq : toNumberOrNull ( event . seq ) ,
321- } )
322- }
323- } )
324- . catch ( ( error ) => {
325- entry . error = getErrorMessage ( error )
326- } )
327- )
328- }
329- if ( url . origin === base . origin && response . status ( ) >= 400 )
330- httpErrors . push ( {
331- path : url . pathname ,
332- status : response . status ( ) ,
333- injected : injectedRequests . has ( response . request ( ) ) ,
334- } )
335- } )
331+ if ( url . origin === base . origin && response . status ( ) >= 400 )
332+ httpErrors . push ( {
333+ path : url . pathname ,
334+ status : response . status ( ) ,
335+ injected : injectedRequests . has ( response . request ( ) ) ,
336+ } )
337+ } )
338+ return page
339+ }
340+ panel = await openPanel ( )
341+ let shell = panel
336342 await panel . goto ( `chrome-extension://${ extensionId } /sidepanel.html` )
337343 const frame = ( ) => {
338344 const app = shell . frames ( ) . find ( ( candidate ) => candidate . url ( ) . startsWith ( base . origin ) )
@@ -390,7 +396,7 @@ try {
390396 timeout : 120_000 ,
391397 } )
392398 } )
393- const source = await context . newPage ( )
399+ let source = await context . newPage ( )
394400 await source . goto ( `${ fixtureOrigin } /page?private=QUERY_SECRET#FRAGMENT_SECRET` )
395401 const observeCaptureReceipts = ( ) =>
396402 shell . evaluate ( ( origin ) => {
@@ -1143,6 +1149,81 @@ try {
11431149 await expect ( attachments ( ) ) . toHaveCount ( 0 )
11441150 await screenshot ( 'shared-workspace-organization-navigation' )
11451151 } )
1152+ await check (
1153+ 'A first-open toolbar capture survives authenticated organization landing' ,
1154+ async ( ) => {
1155+ await source . bringToFront ( )
1156+ await shell . goto ( 'about:blank' )
1157+ const intent = await worker . evaluate ( async ( ) => {
1158+ const [ tab ] = await chrome . tabs . query ( { active : true , currentWindow : true } )
1159+ if ( tab . id === undefined || ! tab . url ) throw new Error ( 'Missing source tab' )
1160+ const digest = await crypto . subtle . digest ( 'SHA-256' , new TextEncoder ( ) . encode ( tab . url ) )
1161+ const [ result ] = await chrome . scripting . executeScript ( {
1162+ target : { tabId : tab . id , frameIds : [ 0 ] } ,
1163+ func : ( ) => null ,
1164+ } )
1165+ return {
1166+ tabId : tab . id ,
1167+ windowId : tab . windowId ,
1168+ kind : 'page' ,
1169+ createdAt : Date . now ( ) ,
1170+ documentId : result . documentId ,
1171+ urlDigest : Array . from ( new Uint8Array ( digest ) , ( byte ) =>
1172+ byte . toString ( 16 ) . padStart ( 2 , '0' )
1173+ ) . join ( '' ) ,
1174+ }
1175+ } )
1176+ await worker . evaluate ( async ( pending ) => {
1177+ await chrome . storage . session . set ( {
1178+ [ `captureIntent:${ pending . windowId } :first-open` ] : pending ,
1179+ } )
1180+ } , intent )
1181+ await shell . goto ( `chrome-extension://${ extensionId } /sidepanel.html` )
1182+ await observeCaptureReceipts ( )
1183+ await expect
1184+ . poll ( ( ) => new URL ( frame ( ) . url ( ) ) . pathname )
1185+ . toBe ( `/extension/o/${ organizationId } ` )
1186+ await expect ( attachments ( ) ) . toHaveCount ( 1 )
1187+ await screenshot ( 'organization-first-open-capture' )
1188+ }
1189+ )
1190+ if ( liveChat ) {
1191+ await check (
1192+ 'Organization live admission preserves queued pages, files, and the admitted conversation mode' ,
1193+ ( ) =>
1194+ verifyLiveChat ( {
1195+ name : 'organization' ,
1196+ url : `${ base . origin } /extension/o/${ organizationId } ` ,
1197+ inputLabel : 'Ask Sim' ,
1198+ draftKey : `extension:${ userId } :organization:${ organizationId } :home` ,
1199+ organizationId,
1200+ } )
1201+ )
1202+ }
1203+ if ( ! liveChat ) {
1204+ // Install timers before app code in a fresh context; live provider checks keep native timing.
1205+ await browserContext . close ( )
1206+ context = await launchBrowserContext ( )
1207+ browserContext = context
1208+ browserContext . setDefaultTimeout ( 30_000 )
1209+ browserContext . setDefaultNavigationTimeout ( 120_000 )
1210+ worker =
1211+ browserContext . serviceWorkers ( ) [ 0 ] ?? ( await browserContext . waitForEvent ( 'serviceworker' ) )
1212+ assert . equal ( worker . url ( ) . split ( '/' ) [ 2 ] , extensionId )
1213+ panel = await openPanel ( )
1214+ shell = panel
1215+ await shell . clock . install ( )
1216+ source = await browserContext . newPage ( )
1217+ await source . goto ( `${ fixtureOrigin } /page?private=QUERY_SECRET#FRAGMENT_SECRET` )
1218+ await shell . goto ( `chrome-extension://${ extensionId } /sidepanel.html` )
1219+ await observeCaptureReceipts ( )
1220+ }
1221+ const advanceAccessRevalidation = async ( ) => {
1222+ if ( liveChat ) return
1223+ await shell . clock . fastForward ( ACCESS_REVALIDATION_TIMEOUT_MS )
1224+ // Capture timestamps come from Chromium's isolated world, outside Playwright's clock.
1225+ await shell . clock . setSystemTime ( Date . now ( ) )
1226+ }
11461227 for ( const allowRequests of [ false , true ] ) {
11471228 await check (
11481229 `Workspace Chat policy revocation and regrant work with access requests ${ allowRequests ? 'enabled' : 'disabled' } ` ,
@@ -1173,11 +1254,13 @@ try {
11731254 )
11741255 assert . equal ( policy . status ( ) , 200 )
11751256 assert . equal ( toRecord ( toRecord ( await policy . json ( ) ) . config ) . hideCopilot , true )
1257+ await advanceAccessRevalidation ( )
11761258 await expect ( input ) . toHaveCount ( 0 , { timeout : ACCESS_REVALIDATION_TIMEOUT_MS } )
11771259 await expect ( attachments ( ) ) . toHaveCount ( 0 )
11781260 await expect ( frame ( ) . getByText ( 'Access required' , { exact : true } ) ) . toBeVisible ( )
11791261 await screenshot ( `workspace-chat-revoked-requests-${ allowRequests } ` )
11801262 await sql `delete from permission_group where id=${ permissionGroupId } `
1263+ await advanceAccessRevalidation ( )
11811264 await expect ( input ) . toBeVisible ( { timeout : ACCESS_REVALIDATION_TIMEOUT_MS } )
11821265 await expect ( input ) . toHaveValue (
11831266 'Keep text, but discard the page when Chat access is revoked.'
@@ -1205,57 +1288,6 @@ try {
12051288 }
12061289 )
12071290 }
1208- await check (
1209- 'A first-open toolbar capture survives authenticated organization landing' ,
1210- async ( ) => {
1211- await source . bringToFront ( )
1212- await shell . goto ( 'about:blank' )
1213- const intent = await worker . evaluate ( async ( ) => {
1214- const [ tab ] = await chrome . tabs . query ( { active : true , currentWindow : true } )
1215- if ( tab . id === undefined || ! tab . url ) throw new Error ( 'Missing source tab' )
1216- const digest = await crypto . subtle . digest ( 'SHA-256' , new TextEncoder ( ) . encode ( tab . url ) )
1217- const [ result ] = await chrome . scripting . executeScript ( {
1218- target : { tabId : tab . id , frameIds : [ 0 ] } ,
1219- func : ( ) => null ,
1220- } )
1221- return {
1222- tabId : tab . id ,
1223- windowId : tab . windowId ,
1224- kind : 'page' ,
1225- createdAt : Date . now ( ) ,
1226- documentId : result . documentId ,
1227- urlDigest : Array . from ( new Uint8Array ( digest ) , ( byte ) =>
1228- byte . toString ( 16 ) . padStart ( 2 , '0' )
1229- ) . join ( '' ) ,
1230- }
1231- } )
1232- await worker . evaluate ( async ( pending ) => {
1233- await chrome . storage . session . set ( {
1234- [ `captureIntent:${ pending . windowId } :first-open` ] : pending ,
1235- } )
1236- } , intent )
1237- await shell . goto ( `chrome-extension://${ extensionId } /sidepanel.html` )
1238- await observeCaptureReceipts ( )
1239- await expect
1240- . poll ( ( ) => new URL ( frame ( ) . url ( ) ) . pathname )
1241- . toBe ( `/extension/o/${ organizationId } ` )
1242- await expect ( attachments ( ) ) . toHaveCount ( 1 )
1243- await screenshot ( 'organization-first-open-capture' )
1244- }
1245- )
1246- if ( liveChat ) {
1247- await check (
1248- 'Organization live admission preserves queued pages, files, and the admitted conversation mode' ,
1249- ( ) =>
1250- verifyLiveChat ( {
1251- name : 'organization' ,
1252- url : `${ base . origin } /extension/o/${ organizationId } ` ,
1253- inputLabel : 'Ask Sim' ,
1254- draftKey : `extension:${ userId } :organization:${ organizationId } :home` ,
1255- organizationId,
1256- } )
1257- )
1258- }
12591291 await check (
12601292 'Organization permission changes switch a new chat to Assistant without losing its draft' ,
12611293 async ( ) => {
@@ -1271,6 +1303,7 @@ try {
12711303 await sql `insert into permission_group (id, organization_id, name, config, created_by, is_default)
12721304 values (${ generateId ( ) } , ${ organizationId } , 'Search only',
12731305 ${ sql . json ( { disableWorkspaceCreation : true } ) } , ${ userId } , true)`
1306+ await advanceAccessRevalidation ( )
12741307 await expect ( frame ( ) . getByRole ( 'button' , { name : 'Attach image' , exact : true } ) ) . toBeVisible ( {
12751308 timeout : ACCESS_REVALIDATION_TIMEOUT_MS ,
12761309 } )
@@ -1348,7 +1381,9 @@ try {
13481381 [ 401 , 403 , 404 ] . includes ( response . status ( ) ) ,
13491382 { timeout : ACCESS_REVALIDATION_TIMEOUT_MS }
13501383 ) ,
1351- sql `delete from member where organization_id=${ organizationId } and user_id=${ userId } ` ,
1384+ sql `delete from member where organization_id=${ organizationId } and user_id=${ userId } ` . then (
1385+ advanceAccessRevalidation
1386+ ) ,
13521387 ] )
13531388 await expect ( frame ( ) . getByText ( 'This organization is unavailable.' ) ) . toBeVisible ( )
13541389 await expect ( frame ( ) . getByRole ( 'textbox' , { name : 'Ask Sim' , exact : true } ) ) . toHaveCount ( 0 )
@@ -1373,7 +1408,9 @@ try {
13731408 response . status ( ) === 403 ,
13741409 { timeout : ACCESS_REVALIDATION_TIMEOUT_MS }
13751410 ) ,
1376- sql `delete from permissions where user_id=${ userId } and entity_type='workspace' and entity_id=${ alphaId } ` ,
1411+ sql `delete from permissions where user_id=${ userId } and entity_type='workspace' and entity_id=${ alphaId } ` . then (
1412+ advanceAccessRevalidation
1413+ ) ,
13771414 ] )
13781415 await expect ( frame ( ) . getByText ( 'This workspace is unavailable.' ) ) . toBeVisible ( )
13791416 await expect ( frame ( ) . getByRole ( 'textbox' , { name : 'Message' , exact : true } ) ) . toHaveCount ( 0 )
@@ -1386,7 +1423,6 @@ try {
13861423 await expect ( frame ( ) . getByRole ( 'textbox' , { name : 'Message' , exact : true } ) ) . toBeVisible ( )
13871424 }
13881425 )
1389- // Clock APIs replace animation timing, so native streaming checks must run first.
13901426 const reconnect = async ( ) => {
13911427 await frame ( ) . evaluate ( ( ) => {
13921428 window . dispatchEvent ( new Event ( 'offline' ) )
0 commit comments