@@ -112,7 +112,7 @@ func TestDisplayResolutionChange(t *testing.T) {
112112 defer cancel ()
113113
114114 logger .Info ("[setup]" , "action" , "waiting for API" , "url" , apiBaseURL + "/spec.yaml" )
115- require .NoError (t , waitHTTPOrExit (ctx , apiBaseURL + "/spec.yaml" , exitCh ), "api not ready: %v" , err )
115+ require .NoError (t , waitHTTPOrExitWithLogs (ctx , apiBaseURL + "/spec.yaml" , exitCh , name ), "api not ready: %v" , err )
116116
117117 client , err := apiClient ()
118118 require .NoError (t , err , "failed to create API client: %v" , err )
@@ -212,7 +212,7 @@ func TestExtensionUploadAndActivation(t *testing.T) {
212212 ctx , cancel := context .WithTimeout (baseCtx , 3 * time .Minute )
213213 defer cancel ()
214214
215- require .NoError (t , waitHTTPOrExit (ctx , apiBaseURL + "/spec.yaml" , exitCh ), "api not ready: %v" , err )
215+ require .NoError (t , waitHTTPOrExitWithLogs (ctx , apiBaseURL + "/spec.yaml" , exitCh , name ), "api not ready: %v" , err )
216216
217217 // Wait for DevTools
218218 _ , err = waitDevtoolsWS (ctx )
@@ -306,7 +306,7 @@ func TestScreenshotHeadless(t *testing.T) {
306306 ctx , cancel := context .WithTimeout (baseCtx , 2 * time .Minute )
307307 defer cancel ()
308308
309- require .NoError (t , waitHTTPOrExit (ctx , apiBaseURL + "/spec.yaml" , exitCh ), "api not ready: %v" , err )
309+ require .NoError (t , waitHTTPOrExitWithLogs (ctx , apiBaseURL + "/spec.yaml" , exitCh , name ), "api not ready: %v" , err )
310310
311311 client , err := apiClient ()
312312 require .NoError (t , err )
@@ -357,7 +357,7 @@ func TestScreenshotHeadful(t *testing.T) {
357357 ctx , cancel := context .WithTimeout (baseCtx , 2 * time .Minute )
358358 defer cancel ()
359359
360- require .NoError (t , waitHTTPOrExit (ctx , apiBaseURL + "/spec.yaml" , exitCh ), "api not ready: %v" , err )
360+ require .NoError (t , waitHTTPOrExitWithLogs (ctx , apiBaseURL + "/spec.yaml" , exitCh , name ), "api not ready: %v" , err )
361361
362362 client , err := apiClient ()
363363 require .NoError (t , err )
@@ -402,7 +402,7 @@ func TestInputEndpointsSmoke(t *testing.T) {
402402 ctx , cancel := context .WithTimeout (baseCtx , 2 * time .Minute )
403403 defer cancel ()
404404
405- require .NoError (t , waitHTTPOrExit (ctx , apiBaseURL + "/spec.yaml" , exitCh ), "api not ready: %v" , err )
405+ require .NoError (t , waitHTTPOrExitWithLogs (ctx , apiBaseURL + "/spec.yaml" , exitCh , name ), "api not ready: %v" , err )
406406
407407 client , err := apiClient ()
408408 require .NoError (t , err )
@@ -445,17 +445,46 @@ func isPNG(data []byte) bool {
445445 return true
446446}
447447
448+ // ContainerOptions configures container startup behavior
449+ type ContainerOptions struct {
450+ // HostAccess adds --add-host=host.docker.internal:host-gateway for tests
451+ // that need to reach services on the host machine
452+ HostAccess bool
453+ }
454+
448455func runContainer (ctx context.Context , image , name string , env map [string ]string ) (* exec.Cmd , <- chan error , error ) {
456+ return runContainerWithOptions (ctx , image , name , env , ContainerOptions {})
457+ }
458+
459+ func runContainerWithOptions (ctx context.Context , image , name string , env map [string ]string , opts ContainerOptions ) (* exec.Cmd , <- chan error , error ) {
449460 logger := logctx .FromContext (ctx )
450461 args := []string {
451462 "run" ,
452463 "--name" , name ,
453464 "--privileged" ,
454465 "-p" , "10001:10001" , // API server
455- "-p" , "9222:9222" , // DevTools proxy
456- "--tmpfs" , "/dev/shm:size=2g" ,
466+ "-p" , "9222:9222" , // DevTools proxy
467+ "--tmpfs" , "/dev/shm:size=2g,mode=1777 " ,
457468 }
469+
470+ if opts .HostAccess {
471+ args = append (args , "--add-host=host.docker.internal:host-gateway" )
472+ }
473+
474+ // Ensure CHROMIUM_FLAGS includes --no-sandbox for CI environments where
475+ // unprivileged user namespaces may be disabled (e.g., Ubuntu 24.04 GitHub Actions)
476+ // Create a copy to avoid mutating the caller's map
477+ envCopy := make (map [string ]string )
458478 for k , v := range env {
479+ envCopy [k ] = v
480+ }
481+ if _ , ok := envCopy ["CHROMIUM_FLAGS" ]; ! ok {
482+ envCopy ["CHROMIUM_FLAGS" ] = "--no-sandbox"
483+ } else if ! strings .Contains (envCopy ["CHROMIUM_FLAGS" ], "--no-sandbox" ) {
484+ envCopy ["CHROMIUM_FLAGS" ] = envCopy ["CHROMIUM_FLAGS" ] + " --no-sandbox"
485+ }
486+
487+ for k , v := range envCopy {
459488 args = append (args , "-e" , fmt .Sprintf ("%s=%s" , k , v ))
460489 }
461490 args = append (args , image )
@@ -515,6 +544,73 @@ func stopContainer(ctx context.Context, name string) error {
515544 return nil
516545}
517546
547+ // getContainerLogs retrieves the last N lines of container logs for debugging.
548+ // Uses a fresh context with its own timeout to avoid issues when the parent context is cancelled.
549+ func getContainerLogs (_ context.Context , name string , tailLines int ) string {
550+ // Use a fresh context with generous timeout - the parent context may be cancelled
551+ logCtx , cancel := context .WithTimeout (context .Background (), 30 * time .Second )
552+ defer cancel ()
553+
554+ cmd := exec .CommandContext (logCtx , "docker" , "logs" , "--tail" , fmt .Sprintf ("%d" , tailLines ), name )
555+ output , err := cmd .CombinedOutput ()
556+ if err != nil {
557+ return fmt .Sprintf ("failed to get container logs: %v" , err )
558+ }
559+ return string (output )
560+ }
561+
562+ // waitHTTPOrExitWithLogs waits for HTTP endpoint and captures container logs on failure.
563+ // It also periodically logs container status during the wait for better visibility.
564+ func waitHTTPOrExitWithLogs (ctx context.Context , url string , exitCh <- chan error , containerName string ) error {
565+ logger := logctx .FromContext (ctx )
566+
567+ // Start a background goroutine to periodically show container status
568+ // Use a separate stopCh that we close to signal the goroutine to stop,
569+ // avoiding the race condition of sending to a potentially closed channel
570+ stopCh := make (chan struct {})
571+ doneCh := make (chan struct {})
572+ go func () {
573+ defer close (doneCh )
574+ ticker := time .NewTicker (15 * time .Second )
575+ defer ticker .Stop ()
576+ for {
577+ select {
578+ case <- ctx .Done ():
579+ return
580+ case <- stopCh :
581+ return
582+ case <- ticker .C :
583+ // Check if container is still running
584+ checkCtx , cancel := context .WithTimeout (context .Background (), 5 * time .Second )
585+ cmd := exec .CommandContext (checkCtx , "docker" , "inspect" , "--format" , "{{.State.Status}} (pid={{.State.Pid}}, started={{.State.StartedAt}})" , containerName )
586+ out , err := cmd .Output ()
587+ cancel ()
588+ if err == nil {
589+ logger .Info ("[container-status]" , "container" , containerName , "status" , strings .TrimSpace (string (out )))
590+ }
591+ // Also show last few log lines
592+ recentLogs := getContainerLogs (ctx , containerName , 10 )
593+ if recentLogs != "" && ! strings .Contains (recentLogs , "failed to get" ) {
594+ logger .Info ("[container-logs]" , "recent_output" , strings .TrimSpace (recentLogs ))
595+ }
596+ }
597+ }
598+ }()
599+
600+ err := waitHTTPOrExit (ctx , url , exitCh )
601+
602+ // Signal the status goroutine to stop and wait for it to finish
603+ close (stopCh )
604+ <- doneCh
605+
606+ if err != nil {
607+ // Capture container logs for debugging
608+ logs := getContainerLogs (ctx , containerName , 100 )
609+ return fmt .Errorf ("%w\n \n Container logs (last 100 lines):\n %s" , err , logs )
610+ }
611+ return nil
612+ }
613+
518614func waitHTTPOrExit (ctx context.Context , url string , exitCh <- chan error ) error {
519615 client := & http.Client {Timeout : 5 * time .Second }
520616 ticker := time .NewTicker (500 * time .Millisecond )
@@ -756,7 +852,7 @@ func TestCDPTargetCreation(t *testing.T) {
756852 defer cancel ()
757853
758854 logger .Info ("[test]" , "action" , "waiting for API" )
759- require .NoError (t , waitHTTPOrExit (ctx , apiBaseURL + "/spec.yaml" , exitCh ), "api not ready" )
855+ require .NoError (t , waitHTTPOrExitWithLogs (ctx , apiBaseURL + "/spec.yaml" , exitCh , name ), "api not ready" )
760856
761857 // Wait for CDP endpoint to be ready (via the devtools proxy)
762858 logger .Info ("[test]" , "action" , "waiting for CDP endpoint" )
@@ -830,7 +926,7 @@ func TestWebBotAuthInstallation(t *testing.T) {
830926 defer cancel ()
831927
832928 logger .Info ("[setup]" , "action" , "waiting for API" , "url" , apiBaseURL + "/spec.yaml" )
833- require .NoError (t , waitHTTPOrExit (ctx , apiBaseURL + "/spec.yaml" , exitCh ), "api not ready: %v" , err )
929+ require .NoError (t , waitHTTPOrExitWithLogs (ctx , apiBaseURL + "/spec.yaml" , exitCh , name ), "api not ready: %v" , err )
834930
835931 // Build mock web-bot-auth extension zip in-memory
836932 extDir := t .TempDir ()
0 commit comments