@@ -9,6 +9,7 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
99import {
1010 dropdownMenuTriggerSelector ,
1111 dropdownMenuContentSelector ,
12+ projectSwitchSelector ,
1213 projectMenuTriggerSelector ,
1314 projectCloseMenuSelector ,
1415 projectWorkspacesToggleSelector ,
@@ -23,6 +24,16 @@ import {
2324 workspaceMenuTriggerSelector ,
2425} from "./selectors"
2526
27+ const phase = new WeakMap < Page , "test" | "cleanup" > ( )
28+
29+ export function setHealthPhase ( page : Page , value : "test" | "cleanup" ) {
30+ phase . set ( page , value )
31+ }
32+
33+ export function healthPhase ( page : Page ) {
34+ return phase . get ( page ) ?? "test"
35+ }
36+
2637export async function defocus ( page : Page ) {
2738 await page
2839 . evaluate ( ( ) => {
@@ -196,11 +207,51 @@ export async function closeDialog(page: Page, dialog: Locator) {
196207}
197208
198209export async function isSidebarClosed ( page : Page ) {
199- const button = page . getByRole ( "button" , { name : / t o g g l e s i d e b a r / i } ) . first ( )
200- await expect ( button ) . toBeVisible ( )
210+ const button = await waitSidebarButton ( page , "isSidebarClosed" )
201211 return ( await button . getAttribute ( "aria-expanded" ) ) !== "true"
202212}
203213
214+ async function errorBoundaryText ( page : Page ) {
215+ const title = page . getByRole ( "heading" , { name : / s o m e t h i n g w e n t w r o n g / i } ) . first ( )
216+ if ( ! ( await title . isVisible ( ) . catch ( ( ) => false ) ) ) return
217+
218+ const description = await page
219+ . getByText ( / a n e r r o r o c c u r r e d w h i l e l o a d i n g t h e a p p l i c a t i o n \. / i)
220+ . first ( )
221+ . textContent ( )
222+ . catch ( ( ) => "" )
223+ const detail = await page
224+ . getByRole ( "textbox" , { name : / e r r o r d e t a i l s / i } )
225+ . first ( )
226+ . inputValue ( )
227+ . catch ( async ( ) =>
228+ (
229+ ( await page
230+ . getByRole ( "textbox" , { name : / e r r o r d e t a i l s / i } )
231+ . first ( )
232+ . textContent ( )
233+ . catch ( ( ) => "" ) ) ?? ""
234+ ) . trim ( ) ,
235+ )
236+
237+ return [ title ? "Error boundary" : "" , description ?? "" , detail ?? "" ] . filter ( Boolean ) . join ( "\n" )
238+ }
239+
240+ export async function assertHealthy ( page : Page , context : string ) {
241+ const text = await errorBoundaryText ( page )
242+ if ( ! text ) return
243+ console . log ( `[e2e:error-boundary][${ context } ]\n${ text } ` )
244+ throw new Error ( `Error boundary during ${ context } \n${ text } ` )
245+ }
246+
247+ async function waitSidebarButton ( page : Page , context : string ) {
248+ const button = page . getByRole ( "button" , { name : / t o g g l e s i d e b a r / i } ) . first ( )
249+ const boundary = page . getByRole ( "heading" , { name : / s o m e t h i n g w e n t w r o n g / i } ) . first ( )
250+ await button . or ( boundary ) . first ( ) . waitFor ( { state : "visible" , timeout : 10_000 } )
251+ await assertHealthy ( page , context )
252+ return button
253+ }
254+
204255export async function toggleSidebar ( page : Page ) {
205256 await defocus ( page )
206257 await page . keyboard . press ( `${ modKey } +B` )
@@ -209,7 +260,7 @@ export async function toggleSidebar(page: Page) {
209260export async function openSidebar ( page : Page ) {
210261 if ( ! ( await isSidebarClosed ( page ) ) ) return
211262
212- const button = page . getByRole ( "button" , { name : / t o g g l e s i d e b a r / i } ) . first ( )
263+ const button = await waitSidebarButton ( page , "openSidebar" )
213264 await button . click ( )
214265
215266 const opened = await expect ( button )
@@ -226,7 +277,7 @@ export async function openSidebar(page: Page) {
226277export async function closeSidebar ( page : Page ) {
227278 if ( await isSidebarClosed ( page ) ) return
228279
229- const button = page . getByRole ( "button" , { name : / t o g g l e s i d e b a r / i } ) . first ( )
280+ const button = await waitSidebarButton ( page , "closeSidebar" )
230281 await button . click ( )
231282
232283 const closed = await expect ( button )
@@ -241,6 +292,7 @@ export async function closeSidebar(page: Page) {
241292}
242293
243294export async function openSettings ( page : Page ) {
295+ await assertHealthy ( page , "openSettings" )
244296 await defocus ( page )
245297
246298 const dialog = page . getByRole ( "dialog" )
@@ -253,6 +305,8 @@ export async function openSettings(page: Page) {
253305
254306 if ( opened ) return dialog
255307
308+ await assertHealthy ( page , "openSettings" )
309+
256310 await page . getByRole ( "button" , { name : "Settings" } ) . first ( ) . click ( )
257311 await expect ( dialog ) . toBeVisible ( )
258312 return dialog
@@ -314,10 +368,12 @@ export async function seedProjects(page: Page, input: { directory: string; extra
314368
315369export async function createTestProject ( ) {
316370 const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "opencode-e2e-project-" ) )
371+ const id = `e2e-${ path . basename ( root ) } `
317372
318- await fs . writeFile ( path . join ( root , "README.md" ) , " # e2e\n" )
373+ await fs . writeFile ( path . join ( root , "README.md" ) , ` # e2e\n\n ${ id } \n` )
319374
320375 execSync ( "git init" , { cwd : root , stdio : "ignore" } )
376+ await fs . writeFile ( path . join ( root , ".git" , "opencode" ) , id )
321377 execSync ( "git config core.fsmonitor false" , { cwd : root , stdio : "ignore" } )
322378 execSync ( "git add -A" , { cwd : root , stdio : "ignore" } )
323379 execSync ( 'git -c user.name="e2e" -c user.email="[email protected] " commit -m "init" --allow-empty' , { @@ -339,12 +395,24 @@ export function slugFromUrl(url: string) {
339395 return / \/ ( [ ^ / ] + ) \/ s e s s i o n (?: [ / ? # ] | $ ) / . exec ( url ) ?. [ 1 ] ?? ""
340396}
341397
398+ async function probeSession ( page : Page ) {
399+ return page
400+ . evaluate ( ( ) => {
401+ const win = window as E2EWindow
402+ const current = win . __opencode_e2e ?. model ?. current
403+ if ( ! current ) return null
404+ return { dir : current . dir , sessionID : current . sessionID }
405+ } )
406+ . catch ( ( ) => null as { dir ?: string ; sessionID ?: string } | null )
407+ }
408+
342409export async function waitSlug ( page : Page , skip : string [ ] = [ ] ) {
343410 let prev = ""
344411 let next = ""
345412 await expect
346413 . poll (
347- ( ) => {
414+ async ( ) => {
415+ await assertHealthy ( page , "waitSlug" )
348416 const slug = slugFromUrl ( page . url ( ) )
349417 if ( ! slug ) return ""
350418 if ( skip . includes ( slug ) ) return ""
@@ -374,6 +442,7 @@ export async function waitDir(page: Page, directory: string) {
374442 await expect
375443 . poll (
376444 async ( ) => {
445+ await assertHealthy ( page , "waitDir" )
377446 const slug = slugFromUrl ( page . url ( ) )
378447 if ( ! slug ) return ""
379448 return resolveSlug ( slug )
@@ -386,6 +455,69 @@ export async function waitDir(page: Page, directory: string) {
386455 return { directory : target , slug : base64Encode ( target ) }
387456}
388457
458+ export async function waitSession ( page : Page , input : { directory : string ; sessionID ?: string } ) {
459+ const target = await resolveDirectory ( input . directory )
460+ await expect
461+ . poll (
462+ async ( ) => {
463+ await assertHealthy ( page , "waitSession" )
464+ const slug = slugFromUrl ( page . url ( ) )
465+ if ( ! slug ) return false
466+ const resolved = await resolveSlug ( slug ) . catch ( ( ) => undefined )
467+ if ( ! resolved || resolved . directory !== target ) return false
468+ if ( input . sessionID && sessionIDFromUrl ( page . url ( ) ) !== input . sessionID ) return false
469+
470+ const state = await probeSession ( page )
471+ if ( input . sessionID && ( ! state || state . sessionID !== input . sessionID ) ) return false
472+ if ( state ?. dir ) {
473+ const dir = await resolveDirectory ( state . dir ) . catch ( ( ) => state . dir ?? "" )
474+ if ( dir !== target ) return false
475+ }
476+
477+ return page
478+ . locator ( promptSelector )
479+ . first ( )
480+ . isVisible ( )
481+ . catch ( ( ) => false )
482+ } ,
483+ { timeout : 45_000 } ,
484+ )
485+ . toBe ( true )
486+ return { directory : target , slug : base64Encode ( target ) }
487+ }
488+
489+ export async function waitSessionSaved ( directory : string , sessionID : string , timeout = 30_000 ) {
490+ const sdk = createSdk ( directory )
491+ const target = await resolveDirectory ( directory )
492+
493+ await expect
494+ . poll (
495+ async ( ) => {
496+ const data = await sdk . session
497+ . get ( { sessionID } )
498+ . then ( ( x ) => x . data )
499+ . catch ( ( ) => undefined )
500+ if ( ! data ?. directory ) return ""
501+ return resolveDirectory ( data . directory ) . catch ( ( ) => data . directory )
502+ } ,
503+ { timeout } ,
504+ )
505+ . toBe ( target )
506+
507+ await expect
508+ . poll (
509+ async ( ) => {
510+ const items = await sdk . session
511+ . messages ( { sessionID, limit : 20 } )
512+ . then ( ( x ) => x . data ?? [ ] )
513+ . catch ( ( ) => [ ] )
514+ return items . some ( ( item ) => item . info . role === "user" )
515+ } ,
516+ { timeout } ,
517+ )
518+ . toBe ( true )
519+ }
520+
389521export function sessionIDFromUrl ( url : string ) {
390522 const match = / \/ s e s s i o n \/ ( [ ^ / ? # ] + ) / . exec ( url )
391523 return match ?. [ 1 ]
@@ -797,8 +929,14 @@ export async function openStatusPopover(page: Page) {
797929}
798930
799931export async function openProjectMenu ( page : Page , projectSlug : string ) {
932+ await openSidebar ( page )
933+ const item = page . locator ( projectSwitchSelector ( projectSlug ) ) . first ( )
934+ await expect ( item ) . toBeVisible ( )
935+ await item . hover ( )
936+
800937 const trigger = page . locator ( projectMenuTriggerSelector ( projectSlug ) ) . first ( )
801938 await expect ( trigger ) . toHaveCount ( 1 )
939+ await expect ( trigger ) . toBeVisible ( )
802940
803941 const menu = page
804942 . locator ( dropdownMenuContentSelector )
@@ -807,7 +945,7 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
807945 const close = menu . locator ( projectCloseMenuSelector ( projectSlug ) ) . first ( )
808946
809947 const clicked = await trigger
810- . click ( { timeout : 1500 } )
948+ . click ( { force : true , timeout : 1500 } )
811949 . then ( ( ) => true )
812950 . catch ( ( ) => false )
813951
0 commit comments