@@ -5,11 +5,14 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
55import { tmpdir } from 'node:os'
66import { dirname , resolve } from 'node:path'
77import { fileURLToPath } from 'node:url'
8+ import { createElement } from 'react'
89import { type BrowserContext , chromium , expect , type Frame , type Page } from '@playwright/test'
910import { pageSnapshotSchema } from '@sim/browser-extension-protocol'
11+ import { SecretReveal } from '@sim/emcn'
1012import { createLogger } from '@sim/logger'
1113import { getErrorMessage } from '@sim/utils/errors'
1214import { generateId } from '@sim/utils/id'
15+ import { renderToStaticMarkup } from 'react-dom/server'
1316
1417const root = resolve ( dirname ( fileURLToPath ( import . meta. url ) ) , '..' )
1518const logger = createLogger ( 'BrowserExtensionE2E' )
@@ -18,6 +21,9 @@ if (!reportPath) throw new Error('Set EXTENSION_REPORT_PATH to the output JSON r
1821const output = resolve ( root , reportPath )
1922const checks : { name : string ; status : 'passed' | 'failed' ; durationMs : number ; error ?: string } [ ] =
2023 [ ]
24+ const secretReveal = renderToStaticMarkup (
25+ createElement ( SecretReveal , { value : 'SECRET_REVEAL_SENTINEL' } )
26+ )
2127const fixture = Bun . serve ( {
2228 hostname : '127.0.0.1' ,
2329 port : 0 ,
@@ -55,6 +61,7 @@ const fixture = Bun.serve({
5561 <input type="password" value="PASSWORD_SENTINEL"/><textarea>TEXTAREA_SENTINEL</textarea>
5662 <div contenteditable="true" id="editable">EDITABLE_SENTINEL</div><div hidden>HIDDEN_SENTINEL</div>
5763 <div style="display:none">CSS_HIDDEN_SENTINEL</div><div aria-hidden="true">ARIA_SENTINEL</div>
64+ <section id="secret-reveal"><p>Credential instructions</p>${ secretReveal } <code>visible-code-control</code></section>
5865 <script>const secret='SCRIPT_SENTINEL'</script>
5966 </body></html>` ,
6067 { headers : { 'content-type' : 'text/html' } }
@@ -129,12 +136,17 @@ try {
129136 await signIn . close ( )
130137 const panel : Page = await context . newPage ( )
131138 // Observe dispatch before the bridge so background metadata queries stay outside each action.
132- await panel . addInitScript ( ( ) => {
139+ const observeDispatch = ( ) => {
133140 window . addEventListener ( 'message' , ( ) => {
134141 document . documentElement . dataset . queryCountAtDispatch =
135142 document . documentElement . dataset . browserQueryCount ?? '0'
136143 } )
137- } )
144+ }
145+ const panelBundle = resolve ( extensionPath , 'sidepanel.js' )
146+ await writeFile (
147+ panelBundle ,
148+ `(${ observeDispatch . toString ( ) } )();\n${ await readFile ( panelBundle , 'utf8' ) } `
149+ )
138150 await panel . goto ( `chrome-extension://${ extensionId } /sidepanel.html` )
139151 await panel . getByTitle ( 'Sim chat' ) . waitFor ( )
140152 const frame : Frame = panel . frames ( ) [ 1 ] ?? ( await panel . waitForEvent ( 'frameattached' ) )
@@ -231,9 +243,11 @@ try {
231243 'A typed sim/ link opens the Sim link resolver on the configured origin' ,
232244 async ( ) => {
233245 const tab = await page . context ( ) . newPage ( )
234- for ( const path of [ 'handbook' , 'jira/123?view=board' ] ) {
235- await tab . goto ( `http://sim/${ path } ` )
236- assert ( tab . url ( ) === `${ origin } /sim/${ path } ` , `sim/${ path } reached ${ tab . url ( ) } ` )
246+ for ( const scheme of [ 'http' , 'https' ] ) {
247+ for ( const path of [ 'handbook' , 'jira/123?view=board' , 'jira/a%2Fb?view=board%20view' ] ) {
248+ await tab . goto ( `${ scheme } ://sim/${ path } ` )
249+ assert ( tab . url ( ) === `${ origin } /sim/${ path } ` , `sim/${ path } reached ${ tab . url ( ) } ` )
250+ }
237251 }
238252 await tab . close ( )
239253 }
@@ -310,6 +324,7 @@ try {
310324 await check ( 'Page extraction excludes secrets and strips URL query and fragment' , async ( ) => {
311325 const [ snapshot ] = await snapshots ( )
312326 assert ( snapshot . text . includes ( 'visible-reference-482' ) , 'Visible page content missing' )
327+ assert ( snapshot . text . includes ( 'visible-code-control' ) , 'Ordinary code was excluded' )
313328 for ( const sentinel of [
314329 'PASSWORD_SENTINEL' ,
315330 'TEXTAREA_SENTINEL' ,
@@ -318,6 +333,7 @@ try {
318333 'CSS_HIDDEN_SENTINEL' ,
319334 'ARIA_SENTINEL' ,
320335 'SCRIPT_SENTINEL' ,
336+ 'SECRET_REVEAL_SENTINEL' ,
321337 ] ) {
322338 assert ( ! snapshot . text . includes ( sentinel ) , `Captured ${ sentinel } ` )
323339 }
@@ -643,8 +659,17 @@ try {
643659 expression : `parent.postMessage(${ JSON . stringify ( action ) } , ${ JSON . stringify ( `chrome-extension://${ extensionId } ` ) } )` ,
644660 userGesture : false ,
645661 } )
646- await expect ( panel . locator ( 'body' ) ) . toHaveAttribute ( 'data-observed-actions' , '1' )
647- await expect ( panel . locator ( 'body' ) ) . toHaveAttribute ( 'data-action-queries' , '0' )
662+ await expect
663+ . poll ( async ( ) => {
664+ const { result } = await parentCdp . send ( 'Runtime.evaluate' , {
665+ expression :
666+ '[document.body.dataset.observedActions, document.body.dataset.actionQueries]' ,
667+ returnByValue : true ,
668+ userGesture : false ,
669+ } )
670+ return result . value
671+ } )
672+ . toEqual ( [ '1' , '0' ] )
648673 assert ( ( await snapshots ( ) ) . length === before , 'Captured without a user gesture' )
649674 await app . evaluate ( ( { action, targetOrigin } ) => parent . postMessage ( action , targetOrigin ) , {
650675 action : { ...action , sessionId : generateId ( ) } ,
@@ -668,6 +693,87 @@ try {
668693 await parentCdp . detach ( )
669694 }
670695 )
696+ await check ( 'Ordinary Sim and encoded remote pages attach without sensitive fields' , async ( ) => {
697+ for ( const url of [
698+ `${ origin } /workspace/example` ,
699+ `${ origin } /extension-notes` ,
700+ `${ origin } /workspace/%E2%9C%93` ,
701+ `${ pageOrigin } /ticket/100%done` ,
702+ ] ) {
703+ const before = ( await snapshots ( ) ) . length
704+ await page . goto ( `${ url } ?token=URL_SECRET#FRAGMENT_SECRET` )
705+ await page . bringToFront ( )
706+ await capture ( )
707+ await expect . poll ( async ( ) => ( await snapshots ( ) ) . length ) . toBe ( before + 1 )
708+ const snapshot = ( await snapshots ( ) ) . at ( - 1 )
709+ assert ( snapshot ?. url === url , 'Page URL retained private parameters' )
710+ assert ( snapshot . text . includes ( 'visible-reference-482' ) , 'Visible page content is missing' )
711+ assert ( ! snapshot . text . includes ( 'SENTINEL' ) , 'Sensitive page content was captured' )
712+ }
713+ } )
714+ await check ( 'Embedded chat and malformed Sim paths fail closed' , async ( ) => {
715+ for ( const path of [
716+ '/extension' ,
717+ '/extension/workspace' ,
718+ '/extension/o/organization' ,
719+ '/%65xtension' ,
720+ '/%65xtension/workspace' ,
721+ '/extension%2fworkspace' ,
722+ '/extension%ZZ/workspace' ,
723+ ] ) {
724+ await page . goto ( `${ origin } /workspace/example` )
725+ await page . bringToFront ( )
726+ const previous = ( await snapshots ( ) ) . length
727+ await capture ( )
728+ await expect . poll ( async ( ) => ( await snapshots ( ) ) . length ) . toBe ( previous + 1 )
729+ await expect ( panel . getByRole ( 'status' ) ) . toHaveText ( '' )
730+ const before = ( await snapshots ( ) ) . length
731+ await page . goto ( `${ origin } ${ path } ` )
732+ await page . bringToFront ( )
733+ await capture ( )
734+ await panel . getByRole ( 'status' ) . filter ( { hasText : 'This page cannot be attached.' } ) . waitFor ( )
735+ assert ( ( await snapshots ( ) ) . length === before , 'An excluded Sim path was captured' )
736+ }
737+ } )
738+ await check (
739+ 'Revealed credentials stay excluded from exact and surrounding selections' ,
740+ async ( ) => {
741+ await page . goto ( `${ origin } /workspace/example` )
742+ await page . bringToFront ( )
743+ const before = ( await snapshots ( ) ) . length
744+ await capture ( )
745+ await expect . poll ( async ( ) => ( await snapshots ( ) ) . length ) . toBe ( before + 1 )
746+ await expect ( panel . getByRole ( 'status' ) ) . toHaveText ( '' )
747+ await page
748+ . locator ( '#secret-reveal code' )
749+ . first ( )
750+ . evaluate ( ( element ) => {
751+ const range = document . createRange ( )
752+ range . selectNodeContents ( element )
753+ window . getSelection ( ) ?. removeAllRanges ( )
754+ window . getSelection ( ) ?. addRange ( range )
755+ } )
756+ await capture ( 'Selection' )
757+ await expect ( panel . getByRole ( 'status' ) ) . toContainText ( 'could not be read' )
758+ assert ( ( await snapshots ( ) ) . length === before + 1 , 'A revealed credential was attached' )
759+ await page . locator ( '#secret-reveal' ) . evaluate ( ( element ) => {
760+ const range = document . createRange ( )
761+ range . selectNodeContents ( element )
762+ window . getSelection ( ) ?. removeAllRanges ( )
763+ window . getSelection ( ) ?. addRange ( range )
764+ } )
765+ await capture ( 'Selection' )
766+ await expect . poll ( async ( ) => ( await snapshots ( ) ) . length ) . toBe ( before + 2 )
767+ const snapshot = ( await snapshots ( ) ) . at ( - 1 )
768+ assert ( snapshot , 'Missing surrounding selection snapshot' )
769+ assert ( snapshot . text . includes ( 'Credential instructions' ) , 'Public selection text is missing' )
770+ assert ( snapshot . text . includes ( 'visible-code-control' ) , 'Ordinary selected code was excluded' )
771+ assert (
772+ ! snapshot . text . includes ( 'SECRET_REVEAL_SENTINEL' ) ,
773+ 'A surrounding selection leaked the credential'
774+ )
775+ }
776+ )
671777 await check (
672778 'Site access stays modal without violating the packaged security policy' ,
673779 async ( ) => {
0 commit comments