@@ -15,6 +15,7 @@ import {
1515 type Locator ,
1616 type Page ,
1717 type Request ,
18+ type Route ,
1819} from '@playwright/test'
1920import { pageSnapshotSchema } from '@sim/browser-extension-protocol'
2021import { assertDisposableTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
@@ -529,7 +530,7 @@ try {
529530 buffer : Buffer . from ( 'This synthetic file belongs only to extension QA.' ) ,
530531 } )
531532 )
532- const attachScreenshot = async ( target : Locator ) => {
533+ const screenshotFile = async ( ) => {
533534 const name = 'drop-fixture.png'
534535 const png = resolve ( temporary , name )
535536 await writeFile (
@@ -539,8 +540,146 @@ try {
539540 'base64'
540541 )
541542 )
543+ return { name, png }
544+ }
545+ const attachScreenshot = async ( target : Locator ) => {
546+ const { name, png } = await screenshotFile ( )
542547 return uploadFile ( name , ( ) => dropOnChat ( shell , target , { files : [ png ] } ) )
543548 }
549+ const verifyPendingCaptureDrop = async ( scope : 'workspace' | 'organization' | 'assistant' ) => {
550+ const app = frame ( )
551+ const input = app . getByRole ( 'textbox' , {
552+ name : scope === 'workspace' ? 'Message' : 'Ask Sim' ,
553+ exact : true ,
554+ } )
555+ await expect ( input ) . toBeVisible ( )
556+ await expect ( attachments ( ) ) . toHaveCount ( 0 )
557+ const greeting = app . getByRole ( 'heading' , { name : 'How can I help?' , exact : true } )
558+ await expect ( greeting ) . toBeVisible ( )
559+ const priorPreference = await worker . evaluate ( ( ) =>
560+ chrome . storage . local . get ( 'referenceCurrentPage' )
561+ )
562+ // Hold the real transport result so the body drop overlaps pending submission deterministically.
563+ const gate = await app . evaluateHandle ( ( parentOrigin ) => {
564+ let held : MessageEvent < unknown > | undefined
565+ let released = false
566+ const receive = ( event : MessageEvent < unknown > ) => {
567+ const message = event . data
568+ if (
569+ released ||
570+ event . source !== window . parent ||
571+ event . origin !== parentOrigin ||
572+ typeof message !== 'object' ||
573+ message === null ||
574+ ! ( 'channel' in message ) ||
575+ message . channel !== 'sim-extension' ||
576+ ! ( 'type' in message ) ||
577+ message . type !== 'send-context'
578+ )
579+ return
580+ held = event
581+ event . stopImmediatePropagation ( )
582+ }
583+ window . addEventListener ( 'message' , receive , true )
584+ return {
585+ held : ( ) => held !== undefined ,
586+ release : ( ) => {
587+ released = true
588+ if ( held ) window . dispatchEvent ( held )
589+ held = undefined
590+ } ,
591+ dispose : ( ) => window . removeEventListener ( 'message' , receive , true ) ,
592+ }
593+ } , `chrome-extension://${ extensionId } ` )
594+ const endpoint = `${ base . origin } /api/mothership/chat`
595+ const uploadEndpoint = `${ base . origin } /api/files/uploads`
596+ const submitted : Record < string , unknown > [ ] = [ ]
597+ let uploadAttempts = 0
598+ const interceptChat = ( route : Route ) => {
599+ if ( route . request ( ) . method ( ) !== 'POST' ) return route . continue ( )
600+ submitted . push ( toRecord ( route . request ( ) . postDataJSON ( ) ) )
601+ injectedRequests . add ( route . request ( ) )
602+ return route . fulfill ( { status : 403 , json : { error : 'QA pending capture admission check' } } )
603+ }
604+ const interceptUpload = ( route : Route ) => {
605+ if ( route . request ( ) . method ( ) !== 'POST' ) return route . continue ( )
606+ uploadAttempts ++
607+ injectedRequests . add ( route . request ( ) )
608+ return route . fulfill ( { status : 409 , json : { error : 'QA unexpected pending upload' } } )
609+ }
610+ await browserContext . route ( endpoint , interceptChat )
611+ await browserContext . route ( uploadEndpoint , interceptUpload )
612+ try {
613+ await app . getByRole ( 'button' , { name : 'Add resources' , exact : true } ) . press ( 'Enter' )
614+ const preference = app . getByRole ( 'menuitemcheckbox' , { name : 'Reference current page' } )
615+ await expect ( preference ) . toBeVisible ( )
616+ if ( ( await preference . getAttribute ( 'aria-checked' ) ) !== 'true' )
617+ await preference . press ( 'Enter' )
618+ else await preference . press ( 'Escape' )
619+ await expect (
620+ app . getByRole ( 'button' , { name : 'Attach current page' , exact : true } )
621+ ) . toHaveText ( 'Current page' )
622+ const draft = `Preserve the original ${ scope } send during a screenshot drop.`
623+ await input . fill ( draft )
624+ await source . bringToFront ( )
625+ await input . press ( 'Enter' )
626+ await expect . poll ( ( ) => gate . evaluate ( ( capture ) => capture . held ( ) ) ) . toBe ( true )
627+ const preparing = ( ) =>
628+ input . evaluate ( ( element ) => Boolean ( element . closest ( '[inert][aria-busy="true"]' ) ) )
629+ await expect . poll ( preparing ) . toBe ( true )
630+ const { name, png } = await screenshotFile ( )
631+ const pageCount = browserContext . pages ( ) . length
632+ const previousUrl = app . url ( )
633+ await dropOnChat ( shell , greeting , { files : [ png ] } )
634+ await app . evaluate (
635+ ( ) => new Promise < void > ( ( resolve ) => requestAnimationFrame ( ( ) => resolve ( ) ) )
636+ )
637+ await screenshot ( `pending-capture-drop-${ scope } ` )
638+ assert . equal ( uploadAttempts , 0 , `${ scope } : a body drop started uploading during capture` )
639+ await expect ( input ) . toHaveValue ( draft )
640+ await expect ( app . getByRole ( 'button' , { name : `Remove ${ name } ` , exact : true } ) ) . toHaveCount ( 0 )
641+ assert . equal ( submitted . length , 0 , `${ scope } : the held capture committed early` )
642+ assert . equal ( app . url ( ) , previousUrl )
643+ assert . equal ( browserContext . pages ( ) . length , pageCount )
644+ await expect . poll ( preparing ) . toBe ( true )
645+ await gate . evaluate ( ( capture ) => capture . release ( ) )
646+ await expect . poll ( ( ) => submitted . length ) . toBe ( 1 )
647+ const body = submitted [ 0 ]
648+ assert ( String ( body . message ) . startsWith ( draft ) )
649+ assert . equal (
650+ body [ scope === 'workspace' ? 'workspaceId' : 'organizationId' ] ,
651+ scope === 'workspace' ? alphaId : organizationId
652+ )
653+ if ( scope === 'assistant' ) assert . equal ( body . mode , 'assistant' )
654+ const pages = submittedPages ( body )
655+ assert . equal ( pages . length , 1 )
656+ assert ( pages [ 0 ] . text . includes ( 'ORCHID-482' ) )
657+ assert . equal ( pages [ 0 ] . url , `${ fixtureOrigin } /page` )
658+ assert . equal ( body . fileAttachments , undefined )
659+ await expect . poll ( preparing ) . toBe ( false )
660+ await expect (
661+ app . getByText ( 'Your draft changed while the page was being read. Send again to continue.' , {
662+ exact : true ,
663+ } )
664+ ) . toHaveCount ( 0 )
665+ assert . equal ( uploadAttempts , 0 )
666+ await browserContext . unroute ( uploadEndpoint , interceptUpload )
667+ await attachScreenshot ( greeting )
668+ await screenshot ( `pending-capture-drop-${ scope } -recovered` )
669+ await app . getByRole ( 'button' , { name : `Remove ${ name } ` , exact : true } ) . click ( )
670+ assert . equal ( submitted . length , 1 , `${ scope } : recovering drops duplicated the turn` )
671+ } finally {
672+ await gate . evaluate ( ( capture ) => capture . release ( ) ) . catch ( ( ) => { } )
673+ await gate . evaluate ( ( capture ) => capture . dispose ( ) ) . catch ( ( ) => { } )
674+ await gate . dispose ( )
675+ await browserContext . unroute ( endpoint , interceptChat )
676+ await browserContext . unroute ( uploadEndpoint , interceptUpload )
677+ await worker . evaluate ( async ( previous ) => {
678+ if ( 'referenceCurrentPage' in previous ) await chrome . storage . local . set ( previous )
679+ else await chrome . storage . local . remove ( 'referenceCurrentPage' )
680+ } , priorPreference )
681+ }
682+ }
544683 await checkNative (
545684 'Capture, preview, removal, and the three-page cap use the real composer' ,
546685 async ( ) => {
@@ -1703,6 +1842,22 @@ try {
17031842 }
17041843 }
17051844 )
1845+ await checkNative (
1846+ 'Pending page reference freezes body file drops in workspace and organization chat' ,
1847+ async ( ) => {
1848+ for ( const scope of [ 'workspace' , 'organization' ] as const ) {
1849+ await shell . close ( )
1850+ panel = await openPanel ( )
1851+ shell = panel
1852+ await shell . goto ( `chrome-extension://${ extensionId } /sidepanel.html` )
1853+ await observeCaptureReceipts ( )
1854+ await navigate (
1855+ `${ base . origin } /extension/${ scope === 'workspace' ? alphaId : `o/${ organizationId } ` } `
1856+ )
1857+ await verifyPendingCaptureDrop ( scope )
1858+ }
1859+ }
1860+ )
17061861 await checkNative (
17071862 'A screenshot dropped on the workspace or organization chat body uploads once and can be sent by itself' ,
17081863 async ( ) => {
@@ -2228,6 +2383,20 @@ try {
22282383 } else await shell . unroute ( '**/api/mothership/chat' )
22292384 }
22302385 )
2386+ await checkAccess (
2387+ 'Restricted Assistant freezes body file drops while a page reference is pending' ,
2388+ async ( ) => {
2389+ await shell . close ( )
2390+ panel = await openPanel ( )
2391+ shell = panel
2392+ if ( ! liveChat ) await shell . clock . install ( )
2393+ await shell . goto ( `chrome-extension://${ extensionId } /sidepanel.html` )
2394+ await observeCaptureReceipts ( )
2395+ await navigate ( `${ base . origin } /extension/o/${ organizationId } ` )
2396+ await expect ( frame ( ) . getByRole ( 'button' , { name : 'Attach image' , exact : true } ) ) . toBeVisible ( )
2397+ await verifyPendingCaptureDrop ( 'assistant' )
2398+ }
2399+ )
22312400 await checkAccess (
22322401 'Revoking organization membership removes the live composer and pending captures' ,
22332402 async ( ) => {
0 commit comments