diff --git a/server/e2e/e2e_chromium_test.go b/server/e2e/e2e_chromium_test.go index 2dd98def..7c61147f 100644 --- a/server/e2e/e2e_chromium_test.go +++ b/server/e2e/e2e_chromium_test.go @@ -9,7 +9,6 @@ import ( "fmt" "io" "mime/multipart" - "net" "net/http" "os" "os/exec" @@ -19,10 +18,7 @@ import ( "testing" "time" - "log/slog" - _ "github.com/glebarez/sqlite" - logctx "github.com/onkernel/kernel-images/server/lib/logger" instanceoapi "github.com/onkernel/kernel-images/server/lib/oapi" "github.com/samber/lo" "github.com/stretchr/testify/require" @@ -31,9 +27,6 @@ import ( const ( defaultHeadfulImage = "onkernel/chromium-headful-test:latest" defaultHeadlessImage = "onkernel/chromium-headless-test:latest" - containerName = "server-e2e-test" - // With host networking, the API listens on 10001 directly on the host - apiBaseURL = "http://127.0.0.1:10001" ) var ( @@ -84,49 +77,39 @@ func ensurePlaywrightDeps(t *testing.T) { } func TestDisplayResolutionChange(t *testing.T) { - image := headlessImage - name := containerName + "-display" - - logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo})) - baseCtx := logctx.AddToContext(context.Background(), logger) + t.Parallel() if _, err := exec.LookPath("docker"); err != nil { - require.NoError(t, err, "docker not available: %v", err) + t.Skipf("docker not available: %v", err) } - // Clean slate - _ = stopContainer(baseCtx, name) - - // Start with default resolution - env := map[string]string{ - "WIDTH": "1024", - "HEIGHT": "768", - } - - // Start container - _, exitCh, err := runContainer(baseCtx, image, name, env) - require.NoError(t, err, "failed to start container: %v", err) - defer stopContainer(baseCtx, name) - - ctx, cancel := context.WithTimeout(baseCtx, 3*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() - logger.Info("[setup]", "action", "waiting for API", "url", apiBaseURL+"/spec.yaml") - require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh, name), "api not ready: %v", err) + c := NewTestContainer(t, headlessImage) + require.NoError(t, c.Start(ctx, ContainerConfig{ + Env: map[string]string{ + "WIDTH": "1024", + "HEIGHT": "768", + }, + }), "failed to start container") + defer c.Stop(ctx) + + require.NoError(t, c.WaitReady(ctx), "api not ready") - client, err := apiClient() - require.NoError(t, err, "failed to create API client: %v", err) + client, err := c.APIClient() + require.NoError(t, err, "failed to create API client") // Get initial Xvfb resolution - logger.Info("[test]", "action", "getting initial Xvfb resolution") - initialWidth, initialHeight, err := getXvfbResolution(ctx) - require.NoError(t, err, "failed to get initial Xvfb resolution: %v", err) - logger.Info("[test]", "initial_resolution", fmt.Sprintf("%dx%d", initialWidth, initialHeight)) + t.Log("getting initial Xvfb resolution") + initialWidth, initialHeight, err := getXvfbResolution(ctx, c) + require.NoError(t, err, "failed to get initial Xvfb resolution") + t.Logf("initial_resolution: %dx%d", initialWidth, initialHeight) require.Equal(t, 1024, initialWidth, "expected initial width 1024") require.Equal(t, 768, initialHeight, "expected initial height 768") // Test first resolution change: 1920x1080 - logger.Info("[test]", "action", "changing resolution to 1920x1080") + t.Log("changing resolution to 1920x1080") width1 := 1920 height1 := 1080 req1 := instanceoapi.PatchDisplayJSONRequestBody{ @@ -134,7 +117,7 @@ func TestDisplayResolutionChange(t *testing.T) { Height: &height1, } rsp1, err := client.PatchDisplayWithResponse(ctx, req1) - require.NoError(t, err, "PATCH /display request failed: %v", err) + require.NoError(t, err, "PATCH /display request failed") require.Equal(t, http.StatusOK, rsp1.StatusCode(), "unexpected status: %s body=%s", rsp1.Status(), string(rsp1.Body)) require.NotNil(t, rsp1.JSON200, "expected JSON200 response, got nil") require.NotNil(t, rsp1.JSON200.Width, "expected width in response") @@ -143,19 +126,19 @@ func TestDisplayResolutionChange(t *testing.T) { require.Equal(t, height1, *rsp1.JSON200.Height, "expected height %d in response", height1) // Wait a bit for Xvfb to fully restart - logger.Info("[test]", "action", "waiting for Xvfb to stabilize") + t.Log("waiting for Xvfb to stabilize") time.Sleep(3 * time.Second) // Verify new resolution via ps aux - logger.Info("[test]", "action", "verifying new Xvfb resolution") - newWidth1, newHeight1, err := getXvfbResolution(ctx) - require.NoError(t, err, "failed to get new Xvfb resolution: %v", err) - logger.Info("[test]", "new_resolution", fmt.Sprintf("%dx%d", newWidth1, newHeight1)) + t.Log("verifying new Xvfb resolution") + newWidth1, newHeight1, err := getXvfbResolution(ctx, c) + require.NoError(t, err, "failed to get new Xvfb resolution") + t.Logf("new_resolution: %dx%d", newWidth1, newHeight1) require.Equal(t, width1, newWidth1, "expected Xvfb resolution %dx%d, got %dx%d", width1, height1, newWidth1, newHeight1) require.Equal(t, height1, newHeight1, "expected Xvfb resolution %dx%d, got %dx%d", width1, height1, newWidth1, newHeight1) // Test second resolution change: 1280x720 - logger.Info("[test]", "action", "changing resolution to 1280x720") + t.Log("changing resolution to 1280x720") width2 := 1280 height2 := 720 req2 := instanceoapi.PatchDisplayJSONRequestBody{ @@ -163,7 +146,7 @@ func TestDisplayResolutionChange(t *testing.T) { Height: &height2, } rsp2, err := client.PatchDisplayWithResponse(ctx, req2) - require.NoError(t, err, "PATCH /display request failed: %v", err) + require.NoError(t, err, "PATCH /display request failed") require.Equal(t, http.StatusOK, rsp2.StatusCode(), "unexpected status: %s body=%s", rsp2.Status(), string(rsp2.Body)) require.NotNil(t, rsp2.JSON200, "expected JSON200 response, got nil") require.NotNil(t, rsp2.JSON200.Width, "expected width in response") @@ -172,51 +155,37 @@ func TestDisplayResolutionChange(t *testing.T) { require.Equal(t, height2, *rsp2.JSON200.Height, "expected height %d in response", height2) // Wait a bit for Xvfb to fully restart - logger.Info("[test]", "action", "waiting for Xvfb to stabilize") + t.Log("waiting for Xvfb to stabilize") time.Sleep(3 * time.Second) // Verify second resolution change via ps aux - logger.Info("[test]", "action", "verifying second Xvfb resolution") - newWidth2, newHeight2, err := getXvfbResolution(ctx) - require.NoError(t, err, "failed to get second Xvfb resolution: %v", err) - logger.Info("[test]", "final_resolution", fmt.Sprintf("%dx%d", newWidth2, newHeight2)) + t.Log("verifying second Xvfb resolution") + newWidth2, newHeight2, err := getXvfbResolution(ctx, c) + require.NoError(t, err, "failed to get second Xvfb resolution") + t.Logf("final_resolution: %dx%d", newWidth2, newHeight2) require.Equal(t, width2, newWidth2, "expected Xvfb resolution %dx%d, got %dx%d", width2, height2, newWidth2, newHeight2) require.Equal(t, height2, newHeight2, "expected Xvfb resolution %dx%d, got %dx%d", width2, height2, newWidth2, newHeight2) - logger.Info("[test]", "result", "all resolution changes verified successfully") + t.Log("all resolution changes verified successfully") } func TestExtensionUploadAndActivation(t *testing.T) { + t.Parallel() ensurePlaywrightDeps(t) - image := headlessImage - name := containerName + "-ext" - - logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo})) - baseCtx := logctx.AddToContext(context.Background(), logger) if _, err := exec.LookPath("docker"); err != nil { - require.NoError(t, err, "docker not available: %v", err) + t.Skipf("docker not available: %v", err) } - // Clean slate - _ = stopContainer(baseCtx, name) - - env := map[string]string{} - // headless uses stealth defaults; no need to set CHROMIUM_FLAGS here - - // Start container - _, exitCh, err := runContainer(baseCtx, image, name, env) - require.NoError(t, err, "failed to start container: %v", err) - defer stopContainer(baseCtx, name) - - ctx, cancel := context.WithTimeout(baseCtx, 3*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() - require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh, name), "api not ready: %v", err) + c := NewTestContainer(t, headlessImage) + require.NoError(t, c.Start(ctx, ContainerConfig{}), "failed to start container") + defer c.Stop(ctx) - // Wait for DevTools - _, err = waitDevtoolsWS(ctx) - require.NoError(t, err, "devtools not ready: %v", err) + require.NoError(t, c.WaitReady(ctx), "api not ready") + require.NoError(t, c.WaitDevTools(ctx), "devtools not ready") // Build simple MV3 extension zip in-memory extDir := t.TempDir() @@ -237,17 +206,17 @@ func TestExtensionUploadAndActivation(t *testing.T) { ] }` contentScript := "document.title += \" -- Title updated by browser extension\";\n" - err = os.WriteFile(filepath.Join(extDir, "manifest.json"), []byte(manifest), 0600) - require.NoError(t, err, "write manifest: %v", err) + err := os.WriteFile(filepath.Join(extDir, "manifest.json"), []byte(manifest), 0600) + require.NoError(t, err, "write manifest") err = os.WriteFile(filepath.Join(extDir, "content-script.js"), []byte(contentScript), 0600) - require.NoError(t, err, "write content-script: %v", err) + require.NoError(t, err, "write content-script") extZip, err := zipDirToBytes(extDir) - require.NoError(t, err, "zip ext: %v", err) + require.NoError(t, err, "zip ext") // Use new API to upload extension and restart Chromium { - client, err := apiClient() + client, err := c.APIClient() require.NoError(t, err) var body bytes.Buffer w := multipart.NewWriter(&body) @@ -262,7 +231,7 @@ func TestExtensionUploadAndActivation(t *testing.T) { start := time.Now() rsp, err := client.UploadExtensionsAndRestartWithBodyWithResponse(ctx, w.FormDataContentType(), &body) elapsed := time.Since(start) - require.NoError(t, err, "uploadExtensionsAndRestart request error: %v", err) + require.NoError(t, err, "uploadExtensionsAndRestart request error") require.Equal(t, http.StatusCreated, rsp.StatusCode(), "unexpected status: %s body=%s", rsp.Status(), string(rsp.Body)) t.Logf("/chromium/upload-extensions-and-restart completed in %s (%d ms)", elapsed.String(), elapsed.Milliseconds()) } @@ -273,7 +242,7 @@ func TestExtensionUploadAndActivation(t *testing.T) { "verify-title-contains", "--url", "https://www.sfmoma.org/", "--substr", "Title updated by browser extension", - "--ws-url", "ws://127.0.0.1:9222/", + "--ws-url", c.CDPURL(), "--timeout", "45000", ) cmd.Dir = getPlaywrightPath() @@ -283,38 +252,28 @@ func TestExtensionUploadAndActivation(t *testing.T) { } func TestScreenshotHeadless(t *testing.T) { - image := headlessImage - name := containerName + "-screenshot-headless" - - logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo})) - baseCtx := logctx.AddToContext(context.Background(), logger) + t.Parallel() if _, err := exec.LookPath("docker"); err != nil { - require.NoError(t, err, "docker not available: %v", err) + t.Skipf("docker not available: %v", err) } - // Clean slate - _ = stopContainer(baseCtx, name) - - env := map[string]string{} - - // Start container - _, exitCh, err := runContainer(baseCtx, image, name, env) - require.NoError(t, err, "failed to start container: %v", err) - defer stopContainer(baseCtx, name) - - ctx, cancel := context.WithTimeout(baseCtx, 2*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh, name), "api not ready: %v", err) + c := NewTestContainer(t, headlessImage) + require.NoError(t, c.Start(ctx, ContainerConfig{}), "failed to start container") + defer c.Stop(ctx) - client, err := apiClient() + require.NoError(t, c.WaitReady(ctx), "api not ready") + + client, err := c.APIClient() require.NoError(t, err) // Whole-screen screenshot { rsp, err := client.TakeScreenshotWithResponse(ctx, instanceoapi.TakeScreenshotJSONRequestBody{}) - require.NoError(t, err, "screenshot request error: %v", err) + require.NoError(t, err, "screenshot request error") require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status for full screenshot: %s body=%s", rsp.Status(), string(rsp.Body)) require.True(t, isPNG(rsp.Body), "response is not PNG (len=%d)", len(rsp.Body)) } @@ -324,48 +283,40 @@ func TestScreenshotHeadless(t *testing.T) { region := instanceoapi.ScreenshotRegion{X: 0, Y: 0, Width: 50, Height: 50} req := instanceoapi.TakeScreenshotJSONRequestBody{Region: ®ion} rsp, err := client.TakeScreenshotWithResponse(ctx, req) - require.NoError(t, err, "region screenshot request error: %v", err) + require.NoError(t, err, "region screenshot request error") require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status for region screenshot: %s body=%s", rsp.Status(), string(rsp.Body)) require.True(t, isPNG(rsp.Body), "region response is not PNG (len=%d)", len(rsp.Body)) } } func TestScreenshotHeadful(t *testing.T) { - image := headfulImage - name := containerName + "-screenshot-headful" - - logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo})) - baseCtx := logctx.AddToContext(context.Background(), logger) + t.Parallel() if _, err := exec.LookPath("docker"); err != nil { - require.NoError(t, err, "docker not available: %v", err) - } - - // Clean slate - _ = stopContainer(baseCtx, name) - - env := map[string]string{ - "WIDTH": "1024", - "HEIGHT": "768", + t.Skipf("docker not available: %v", err) } - // Start container - _, exitCh, err := runContainer(baseCtx, image, name, env) - require.NoError(t, err, "failed to start container: %v", err) - defer stopContainer(baseCtx, name) - - ctx, cancel := context.WithTimeout(baseCtx, 2*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh, name), "api not ready: %v", err) + c := NewTestContainer(t, headfulImage) + require.NoError(t, c.Start(ctx, ContainerConfig{ + Env: map[string]string{ + "WIDTH": "1024", + "HEIGHT": "768", + }, + }), "failed to start container") + defer c.Stop(ctx) + + require.NoError(t, c.WaitReady(ctx), "api not ready") - client, err := apiClient() + client, err := c.APIClient() require.NoError(t, err) // Whole-screen screenshot { rsp, err := client.TakeScreenshotWithResponse(ctx, instanceoapi.TakeScreenshotJSONRequestBody{}) - require.NoError(t, err, "screenshot request error: %v", err) + require.NoError(t, err, "screenshot request error") require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status for full screenshot: %s body=%s", rsp.Status(), string(rsp.Body)) require.True(t, isPNG(rsp.Body), "response is not PNG (len=%d)", len(rsp.Body)) } @@ -375,42 +326,42 @@ func TestScreenshotHeadful(t *testing.T) { region := instanceoapi.ScreenshotRegion{X: 0, Y: 0, Width: 80, Height: 60} req := instanceoapi.TakeScreenshotJSONRequestBody{Region: ®ion} rsp, err := client.TakeScreenshotWithResponse(ctx, req) - require.NoError(t, err, "region screenshot request error: %v", err) + require.NoError(t, err, "region screenshot request error") require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status for region screenshot: %s body=%s", rsp.Status(), string(rsp.Body)) require.True(t, isPNG(rsp.Body), "region response is not PNG (len=%d)", len(rsp.Body)) } } func TestInputEndpointsSmoke(t *testing.T) { - image := headlessImage - name := containerName + "-input-smoke" - - logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo})) - baseCtx := logctx.AddToContext(context.Background(), logger) + t.Parallel() if _, err := exec.LookPath("docker"); err != nil { - require.NoError(t, err, "docker not available: %v", err) + t.Skipf("docker not available: %v", err) } - _ = stopContainer(baseCtx, name) - width, height := 1024, 768 - _, exitCh, err := runContainer(baseCtx, image, name, map[string]string{"WIDTH": strconv.Itoa(width), "HEIGHT": strconv.Itoa(height)}) - require.NoError(t, err, "failed to start container: %v", err) - defer stopContainer(baseCtx, name) - ctx, cancel := context.WithTimeout(baseCtx, 2*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh, name), "api not ready: %v", err) + c := NewTestContainer(t, headlessImage) + require.NoError(t, c.Start(ctx, ContainerConfig{ + Env: map[string]string{ + "WIDTH": strconv.Itoa(width), + "HEIGHT": strconv.Itoa(height), + }, + }), "failed to start container") + defer c.Stop(ctx) + + require.NoError(t, c.WaitReady(ctx), "api not ready") - client, err := apiClient() + client, err := c.APIClient() require.NoError(t, err) // press_key: tap Return { rsp, err := client.PressKeyWithResponse(ctx, instanceoapi.PressKeyJSONRequestBody{Keys: []string{"Return"}}) - require.NoError(t, err, "press_key request error: %v", err) + require.NoError(t, err, "press_key request error") require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status for press_key: %s body=%s", rsp.Status(), string(rsp.Body)) } @@ -418,7 +369,7 @@ func TestInputEndpointsSmoke(t *testing.T) { cx, cy := width/2, height/2 { rsp, err := client.ScrollWithResponse(ctx, instanceoapi.ScrollJSONRequestBody{X: cx, Y: cy, DeltaX: lo.ToPtr(2), DeltaY: lo.ToPtr(-3)}) - require.NoError(t, err, "scroll request error: %v", err) + require.NoError(t, err, "scroll request error") require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status for scroll: %s body=%s", rsp.Status(), string(rsp.Body)) } // drag_mouse: simple short drag path @@ -426,7 +377,7 @@ func TestInputEndpointsSmoke(t *testing.T) { rsp, err := client.DragMouseWithResponse(ctx, instanceoapi.DragMouseJSONRequestBody{ Path: [][]int{{cx - 10, cy - 10}, {cx + 10, cy + 10}}, }) - require.NoError(t, err, "drag_mouse request error: %v", err) + require.NoError(t, err, "drag_mouse request error") require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status for drag_mouse: %s body=%s", rsp.Status(), string(rsp.Body)) } } @@ -445,230 +396,6 @@ func isPNG(data []byte) bool { return true } -// ContainerOptions configures container startup behavior -type ContainerOptions struct { - // HostAccess adds --add-host=host.docker.internal:host-gateway for tests - // that need to reach services on the host machine - HostAccess bool -} - -func runContainer(ctx context.Context, image, name string, env map[string]string) (*exec.Cmd, <-chan error, error) { - return runContainerWithOptions(ctx, image, name, env, ContainerOptions{}) -} - -func runContainerWithOptions(ctx context.Context, image, name string, env map[string]string, opts ContainerOptions) (*exec.Cmd, <-chan error, error) { - logger := logctx.FromContext(ctx) - args := []string{ - "run", - "--name", name, - "--privileged", - "-p", "10001:10001", // API server - "-p", "9222:9222", // DevTools proxy - "--tmpfs", "/dev/shm:size=2g,mode=1777", - } - - if opts.HostAccess { - args = append(args, "--add-host=host.docker.internal:host-gateway") - } - - // Ensure CHROMIUM_FLAGS includes --no-sandbox for CI environments where - // unprivileged user namespaces may be disabled (e.g., Ubuntu 24.04 GitHub Actions) - // Create a copy to avoid mutating the caller's map - envCopy := make(map[string]string) - for k, v := range env { - envCopy[k] = v - } - if _, ok := envCopy["CHROMIUM_FLAGS"]; !ok { - envCopy["CHROMIUM_FLAGS"] = "--no-sandbox" - } else if !strings.Contains(envCopy["CHROMIUM_FLAGS"], "--no-sandbox") { - envCopy["CHROMIUM_FLAGS"] = envCopy["CHROMIUM_FLAGS"] + " --no-sandbox" - } - - for k, v := range envCopy { - args = append(args, "-e", fmt.Sprintf("%s=%s", k, v)) - } - args = append(args, image) - - logger.Info("[docker]", "action", "run", "args", strings.Join(args, " ")) - cmd := exec.Command("docker", args...) - if err := cmd.Start(); err != nil { - return nil, nil, err - } - - exitCh := make(chan error, 1) - go func() { - exitCh <- cmd.Wait() - }() - - return cmd, exitCh, nil -} - -func stopContainer(ctx context.Context, name string) error { - _ = exec.CommandContext(ctx, "docker", "kill", name).Run() - _ = exec.CommandContext(ctx, "docker", "rm", "-f", name).Run() - - // Wait loop to ensure the container is actually gone - const maxWait = 10 * time.Second - const pollInterval = 200 * time.Millisecond - deadline := time.Now().Add(maxWait) - var lastCheckErr error - for { - cmd := exec.CommandContext(ctx, "docker", "ps", "-a", "--filter", fmt.Sprintf("name=%s", name), "--format", "{{.Names}}") - out, err := cmd.Output() - if err != nil { - // If docker itself fails, break out (maybe docker is gone) - lastCheckErr = err - break - } - names := strings.Fields(string(out)) - found := false - for _, n := range names { - if n == name { - found = true - break - } - } - if !found { - break // container is gone - } - if time.Now().After(deadline) { - lastCheckErr = fmt.Errorf("timeout waiting for container %s to be removed", name) - break // give up after maxWait - } - time.Sleep(pollInterval) - } - - if lastCheckErr != nil { - return lastCheckErr - } - return nil -} - -// getContainerLogs retrieves the last N lines of container logs for debugging. -// Uses a fresh context with its own timeout to avoid issues when the parent context is cancelled. -func getContainerLogs(_ context.Context, name string, tailLines int) string { - // Use a fresh context with generous timeout - the parent context may be cancelled - logCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - cmd := exec.CommandContext(logCtx, "docker", "logs", "--tail", fmt.Sprintf("%d", tailLines), name) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Sprintf("failed to get container logs: %v", err) - } - return string(output) -} - -// waitHTTPOrExitWithLogs waits for HTTP endpoint and captures container logs on failure. -// It also periodically logs container status during the wait for better visibility. -func waitHTTPOrExitWithLogs(ctx context.Context, url string, exitCh <-chan error, containerName string) error { - logger := logctx.FromContext(ctx) - - // Start a background goroutine to periodically show container status - // Use a separate stopCh that we close to signal the goroutine to stop, - // avoiding the race condition of sending to a potentially closed channel - stopCh := make(chan struct{}) - doneCh := make(chan struct{}) - go func() { - defer close(doneCh) - ticker := time.NewTicker(15 * time.Second) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-stopCh: - return - case <-ticker.C: - // Check if container is still running - checkCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - cmd := exec.CommandContext(checkCtx, "docker", "inspect", "--format", "{{.State.Status}} (pid={{.State.Pid}}, started={{.State.StartedAt}})", containerName) - out, err := cmd.Output() - cancel() - if err == nil { - logger.Info("[container-status]", "container", containerName, "status", strings.TrimSpace(string(out))) - } - // Also show last few log lines - recentLogs := getContainerLogs(ctx, containerName, 10) - if recentLogs != "" && !strings.Contains(recentLogs, "failed to get") { - logger.Info("[container-logs]", "recent_output", strings.TrimSpace(recentLogs)) - } - } - } - }() - - err := waitHTTPOrExit(ctx, url, exitCh) - - // Signal the status goroutine to stop and wait for it to finish - close(stopCh) - <-doneCh - - if err != nil { - // Capture container logs for debugging - logs := getContainerLogs(ctx, containerName, 100) - return fmt.Errorf("%w\n\nContainer logs (last 100 lines):\n%s", err, logs) - } - return nil -} - -func waitHTTPOrExit(ctx context.Context, url string, exitCh <-chan error) error { - client := &http.Client{Timeout: 5 * time.Second} - ticker := time.NewTicker(500 * time.Millisecond) - defer ticker.Stop() - for { - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - resp, err := client.Do(req) - if err == nil && resp.StatusCode >= 200 && resp.StatusCode < 500 { - io.Copy(io.Discard, resp.Body) - resp.Body.Close() - return nil - } - if resp != nil && resp.Body != nil { - io.Copy(io.Discard, resp.Body) - resp.Body.Close() - } - select { - case <-ctx.Done(): - return ctx.Err() - case err := <-exitCh: - if err != nil { - return fmt.Errorf("container exited while waiting for %s: %w", url, err) - } - return fmt.Errorf("container exited while waiting for %s", url) - case <-ticker.C: - } - } -} - -func waitTCP(ctx context.Context, hostport string) error { - d := net.Dialer{Timeout: 2 * time.Second} - ticker := time.NewTicker(300 * time.Millisecond) - defer ticker.Stop() - for { - conn, err := d.DialContext(ctx, "tcp", hostport) - if err == nil { - conn.Close() - return nil - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-ticker.C: - } - } -} - -func waitDevtoolsWS(ctx context.Context) (string, error) { - if err := waitTCP(ctx, "127.0.0.1:9222"); err != nil { - return "", err - } - return "ws://127.0.0.1:9222/", nil -} - -func apiClient() (*instanceoapi.ClientWithResponses, error) { - return instanceoapi.NewClientWithResponses(apiBaseURL, instanceoapi.WithHTTPClient(http.DefaultClient)) -} - // RemoteExecError represents a non-zero exit from a remote exec, exposing exit code and combined output type RemoteExecError struct { Command string @@ -682,8 +409,8 @@ func (e *RemoteExecError) Error() string { } // execCombinedOutput runs a command via the remote API and returns combined stdout+stderr and an error if exit code != 0 -func execCombinedOutput(ctx context.Context, command string, args []string) (string, error) { - client, err := apiClient() +func execCombinedOutput(ctx context.Context, c *TestContainer, command string, args []string) (string, error) { + client, err := c.APIClient() if err != nil { return "", err } @@ -777,17 +504,13 @@ func zipDirToBytes(dir string) ([]byte, error) { // getXvfbResolution extracts the Xvfb resolution from the ps aux output // It looks for the Xvfb command line which contains "-screen 0 WIDTHxHEIGHTx24" -func getXvfbResolution(ctx context.Context) (width, height int, err error) { - logger := logctx.FromContext(ctx) - +func getXvfbResolution(ctx context.Context, c *TestContainer) (width, height int, err error) { // Get ps aux output - stdout, err := execCombinedOutput(ctx, "ps", []string{"aux"}) + stdout, err := execCombinedOutput(ctx, c, "ps", []string{"aux"}) if err != nil { return 0, 0, fmt.Errorf("failed to execute ps aux: %w, output: %s", err, stdout) } - logger.Info("[xvfb-resolution]", "action", "parsing ps aux output") - // Look for Xvfb line // Expected format: "root ... Xvfb :1 -screen 0 1920x1080x24 ..." lines := strings.Split(stdout, "\n") @@ -795,7 +518,6 @@ func getXvfbResolution(ctx context.Context) (width, height int, err error) { if !strings.Contains(line, "Xvfb") { continue } - logger.Info("[xvfb-resolution]", "line", line) // Parse the screen parameter // Look for pattern: "-screen 0 WIDTHxHEIGHTx24" @@ -804,7 +526,6 @@ func getXvfbResolution(ctx context.Context) (width, height int, err error) { if field == "-screen" && i+2 < len(fields) { // Next field should be "0", and the one after should be the resolution screenSpec := fields[i+2] - logger.Info("[xvfb-resolution]", "screen_spec", screenSpec) // Parse WIDTHxHEIGHTx24 parts := strings.Split(screenSpec, "x") @@ -817,7 +538,6 @@ func getXvfbResolution(ctx context.Context) (width, height int, err error) { if err != nil { return 0, 0, fmt.Errorf("failed to parse height from %q: %w", screenSpec, err) } - logger.Info("[xvfb-resolution]", "parsed", fmt.Sprintf("%dx%d", w, h)) return w, h, nil } } @@ -829,52 +549,43 @@ func getXvfbResolution(ctx context.Context) (width, height int, err error) { // TestCDPTargetCreation tests that headless browsers can create new targets via CDP. func TestCDPTargetCreation(t *testing.T) { - image := headlessImage - name := containerName + "-cdp-target" - - logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo})) - baseCtx := logctx.AddToContext(context.Background(), logger) + t.Parallel() if _, err := exec.LookPath("docker"); err != nil { - require.NoError(t, err, "docker not available: %v", err) + t.Skipf("docker not available: %v", err) } - // Clean slate - _ = stopContainer(baseCtx, name) - - // Start container width, height := 1024, 768 - _, exitCh, err := runContainer(baseCtx, image, name, map[string]string{"WIDTH": strconv.Itoa(width), "HEIGHT": strconv.Itoa(height)}) - require.NoError(t, err, "failed to start container: %v", err) - defer stopContainer(baseCtx, name) - ctx, cancel := context.WithTimeout(baseCtx, 2*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - logger.Info("[test]", "action", "waiting for API") - require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh, name), "api not ready") + c := NewTestContainer(t, headlessImage) + require.NoError(t, c.Start(ctx, ContainerConfig{ + Env: map[string]string{ + "WIDTH": strconv.Itoa(width), + "HEIGHT": strconv.Itoa(height), + }, + }), "failed to start container") + defer c.Stop(ctx) - // Wait for CDP endpoint to be ready (via the devtools proxy) - logger.Info("[test]", "action", "waiting for CDP endpoint") - require.NoError(t, waitTCP(ctx, "127.0.0.1:9222"), "CDP endpoint not ready") + require.NoError(t, c.WaitReady(ctx), "api not ready") + require.NoError(t, c.WaitDevTools(ctx), "CDP endpoint not ready") // Wait for Chromium to be fully initialized by checking if CDP responds - logger.Info("[test]", "action", "waiting for Chromium to be fully ready") - targets, err := listCDPTargets(ctx) - if err != nil { - logger.Error("[test]", "error", err.Error()) - require.Fail(t, "failed to list CDP targets") - } + t.Log("waiting for Chromium to be fully ready") + targets, err := listCDPTargets(ctx, c) + require.NoError(t, err, "failed to list CDP targets") // Use CDP HTTP API to list targets (avoids Playwright's implicit page creation) - logger.Info("[test]", "action", "listing initial targets via CDP HTTP API") + t.Log("listing initial targets via CDP HTTP API") initialPageCount := 0 for _, target := range targets { if targetType, ok := target["type"].(string); ok && targetType == "page" { initialPageCount++ } } - logger.Info("[test]", "initial_page_count", initialPageCount, "total_targets", len(targets)) + t.Logf("initial_page_count=%d, total_targets=%d", initialPageCount, len(targets)) // Headless browser should start with at least 1 page target. // If --no-startup-window is enabled, the browser will start with 0 pages, @@ -886,9 +597,9 @@ func TestCDPTargetCreation(t *testing.T) { } // listCDPTargets lists all CDP targets via the HTTP API (inside the container) -func listCDPTargets(ctx context.Context) ([]map[string]interface{}, error) { +func listCDPTargets(ctx context.Context, c *TestContainer) ([]map[string]interface{}, error) { // Use the internal CDP HTTP endpoint (port 9223) inside the container - stdout, err := execCombinedOutput(ctx, "curl", []string{"-s", "http://localhost:9223/json/list"}) + stdout, err := execCombinedOutput(ctx, c, "curl", []string{"-s", "http://localhost:9223/json/list"}) if err != nil { return nil, fmt.Errorf("curl failed: %w, output: %s", err, stdout) } @@ -902,31 +613,20 @@ func listCDPTargets(ctx context.Context) ([]map[string]interface{}, error) { } func TestWebBotAuthInstallation(t *testing.T) { - image := headlessImage - name := containerName + "-web-bot-auth" - - logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo})) - baseCtx := logctx.AddToContext(context.Background(), logger) + t.Parallel() if _, err := exec.LookPath("docker"); err != nil { - require.NoError(t, err, "docker not available: %v", err) + t.Skipf("docker not available: %v", err) } - // Clean slate - _ = stopContainer(baseCtx, name) - - env := map[string]string{} - - // Start container - _, exitCh, err := runContainer(baseCtx, image, name, env) - require.NoError(t, err, "failed to start container: %v", err) - defer stopContainer(baseCtx, name) - - ctx, cancel := context.WithTimeout(baseCtx, 3*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() - logger.Info("[setup]", "action", "waiting for API", "url", apiBaseURL+"/spec.yaml") - require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh, name), "api not ready: %v", err) + c := NewTestContainer(t, headlessImage) + require.NoError(t, c.Start(ctx, ContainerConfig{}), "failed to start container") + defer c.Stop(ctx) + + require.NoError(t, c.WaitReady(ctx), "api not ready") // Build mock web-bot-auth extension zip in-memory extDir := t.TempDir() @@ -941,10 +641,10 @@ func TestWebBotAuthInstallation(t *testing.T) { "host_permissions": []string{""}, } manifestJSON, err := json.MarshalIndent(manifest, "", " ") - require.NoError(t, err, "marshal manifest: %v", err) + require.NoError(t, err, "marshal manifest") err = os.WriteFile(filepath.Join(extDir, "manifest.json"), manifestJSON, 0600) - require.NoError(t, err, "write manifest: %v", err) + require.NoError(t, err, "write manifest") // Create update.xml required for enterprise policy updateXMLContent := ` @@ -955,18 +655,18 @@ func TestWebBotAuthInstallation(t *testing.T) { ` err = os.WriteFile(filepath.Join(extDir, "update.xml"), []byte(updateXMLContent), 0600) - require.NoError(t, err, "write update.xml: %v", err) + require.NoError(t, err, "write update.xml") // Create a minimal .crx file (just needs to exist for the test) err = os.WriteFile(filepath.Join(extDir, "web-bot-auth.crx"), []byte("mock crx content"), 0600) - require.NoError(t, err, "write .crx: %v", err) + require.NoError(t, err, "write .crx") extZip, err := zipDirToBytes(extDir) - require.NoError(t, err, "zip ext: %v", err) + require.NoError(t, err, "zip ext") // Upload extension using the API { - client, err := apiClient() + client, err := c.APIClient() require.NoError(t, err) var body bytes.Buffer w := multipart.NewWriter(&body) @@ -979,26 +679,26 @@ func TestWebBotAuthInstallation(t *testing.T) { err = w.Close() require.NoError(t, err) - logger.Info("[test]", "action", "uploading web-bot-auth extension") + t.Log("uploading web-bot-auth extension") start := time.Now() rsp, err := client.UploadExtensionsAndRestartWithBodyWithResponse(ctx, w.FormDataContentType(), &body) elapsed := time.Since(start) - require.NoError(t, err, "uploadExtensionsAndRestart request error: %v", err) + require.NoError(t, err, "uploadExtensionsAndRestart request error") require.Equal(t, http.StatusCreated, rsp.StatusCode(), "unexpected status: %s body=%s", rsp.Status(), string(rsp.Body)) - logger.Info("[test]", "action", "extension uploaded", "elapsed", elapsed.String()) + t.Logf("extension uploaded in %s", elapsed.String()) } // Verify the policy.json file contains the correct web-bot-auth configuration { - logger.Info("[test]", "action", "reading policy.json") - policyContent, err := execCombinedOutput(ctx, "cat", []string{"/etc/chromium/policies/managed/policy.json"}) - require.NoError(t, err, "failed to read policy.json: %v", err) + t.Log("reading policy.json") + policyContent, err := execCombinedOutput(ctx, c, "cat", []string{"/etc/chromium/policies/managed/policy.json"}) + require.NoError(t, err, "failed to read policy.json") - logger.Info("[test]", "policy_content", policyContent) + t.Logf("policy_content: %s", policyContent) var policy map[string]interface{} err = json.Unmarshal([]byte(policyContent), &policy) - require.NoError(t, err, "failed to parse policy.json: %v", err) + require.NoError(t, err, "failed to parse policy.json") // Check ExtensionInstallForcelist exists extensionInstallForcelist, ok := policy["ExtensionInstallForcelist"].([]interface{}) @@ -1022,22 +722,22 @@ func TestWebBotAuthInstallation(t *testing.T) { extensionID := parts[0] updateURL := parts[1] - logger.Info("[test]", "extension_id", extensionID, "update_url", updateURL) - logger.Info("[test]", "result", "web-bot-auth policy verified successfully") + t.Logf("extension_id=%s, update_url=%s", extensionID, updateURL) + t.Log("web-bot-auth policy verified successfully") } // Verify the extension directory exists { - logger.Info("[test]", "action", "checking extension directory") - dirList, err := execCombinedOutput(ctx, "ls", []string{"-la", "/home/kernel/extensions/web-bot-auth/"}) - require.NoError(t, err, "failed to list extension directory: %v", err) - logger.Info("[test]", "extension_directory_contents", dirList) + t.Log("checking extension directory") + dirList, err := execCombinedOutput(ctx, c, "ls", []string{"-la", "/home/kernel/extensions/web-bot-auth/"}) + require.NoError(t, err, "failed to list extension directory") + t.Logf("extension_directory_contents: %s", dirList) // Verify manifest.json exists (uploaded as part of the extension) - manifestContent, err := execCombinedOutput(ctx, "cat", []string{"/home/kernel/extensions/web-bot-auth/manifest.json"}) - require.NoError(t, err, "failed to read manifest.json: %v", err) + manifestContent, err := execCombinedOutput(ctx, c, "cat", []string{"/home/kernel/extensions/web-bot-auth/manifest.json"}) + require.NoError(t, err, "failed to read manifest.json") require.Contains(t, manifestContent, "Web Bot Auth Mock", "manifest.json should contain extension name") - logger.Info("[test]", "result", "extension directory verified successfully") + t.Log("extension directory verified successfully") } } diff --git a/server/e2e/e2e_combined_flow_test.go b/server/e2e/e2e_combined_flow_test.go index 5d0f6d08..1dc5cc05 100644 --- a/server/e2e/e2e_combined_flow_test.go +++ b/server/e2e/e2e_combined_flow_test.go @@ -87,7 +87,7 @@ func TestExtensionViewportThenCDPConnection(t *testing.T) { if err != nil { t.Logf("[test] step 3: CDP connection failed: %v", err) // Log additional diagnostics - logCDPDiagnostics(ctx, t) + logCDPDiagnostics(ctx, t, c) } require.NoError(t, err, "CDP connection failed after extension upload + viewport change") @@ -285,11 +285,11 @@ func waitForAPIHealth(ctx context.Context, apiBaseURL string, t *testing.T) erro } // logCDPDiagnostics logs diagnostic information when CDP connection fails. -func logCDPDiagnostics(ctx context.Context, t *testing.T) { +func logCDPDiagnostics(ctx context.Context, t *testing.T, c *TestContainer) { t.Helper() // Try to get the internal CDP endpoint status - stdout, err := execCombinedOutput(ctx, "curl", []string{"-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:9223/json/version"}) + stdout, err := execCombinedOutput(ctx, c, "curl", []string{"-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:9223/json/version"}) if err != nil { t.Logf("[diagnostics] internal CDP status: failed (%v)", err) } else { @@ -297,7 +297,7 @@ func logCDPDiagnostics(ctx context.Context, t *testing.T) { } // Check if Chromium process is running - psOutput, err := execCombinedOutput(ctx, "pgrep", []string{"-a", "chromium"}) + psOutput, err := execCombinedOutput(ctx, c, "pgrep", []string{"-a", "chromium"}) if err != nil { t.Logf("[diagnostics] chromium process: not found or error (%v)", err) } else { @@ -305,7 +305,7 @@ func logCDPDiagnostics(ctx context.Context, t *testing.T) { } // Check supervisord status - supervisorOutput, err := execCombinedOutput(ctx, "supervisorctl", []string{"-c", "/etc/supervisor/supervisord.conf", "status"}) + supervisorOutput, err := execCombinedOutput(ctx, c, "supervisorctl", []string{"-c", "/etc/supervisor/supervisord.conf", "status"}) if err != nil { t.Logf("[diagnostics] supervisor status: error (%v)", err) } else {