From b67bc9a32be99c1550d95ab85f2d5d878679b45b Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 26 Jan 2026 13:23:46 -0500 Subject: [PATCH 1/3] feat: improve e2e test infrastructure and add persistence tests - Add ContainerOptions struct with HostAccess for tests needing host.docker.internal access - Add runContainerWithOptions() to support new container options - Add getContainerLogs() helper to retrieve container logs for debugging - Add waitHTTPOrExitWithLogs() that captures container logs on failure and periodically logs container status during wait - Auto-inject --no-sandbox flag for CI environments (Ubuntu 24.04 GA) - Fix tmpfs mode to 1777 for proper /dev/shm permissions - Run tests sequentially (-p 1) to avoid port conflicts - Add cookie and IndexedDB persistence tests These improvements were developed in kernel-images-private and contributed back to the public upstream. --- .github/workflows/server-test.yaml | 4 +- server/Makefile | 4 +- server/e2e/e2e_chromium_restart_bench_test.go | 2 +- server/e2e/e2e_chromium_test.go | 114 ++- server/e2e/e2e_persist_login_test.go | 819 ++++++++++++++++++ server/e2e/e2e_playwright_test.go | 4 +- 6 files changed, 932 insertions(+), 15 deletions(-) create mode 100644 server/e2e/e2e_persist_login_test.go diff --git a/.github/workflows/server-test.yaml b/.github/workflows/server-test.yaml index 620b8fb7..f65ae214 100644 --- a/.github/workflows/server-test.yaml +++ b/.github/workflows/server-test.yaml @@ -60,5 +60,5 @@ jobs: run: make test working-directory: server env: - # Prefer explicit images if passed from the caller; otherwise use the commit short sha - E2E_IMAGE_TAG: ${{ steps.vars.outputs.short_sha }} + E2E_CHROMIUM_HEADFUL_IMAGE: onkernel/chromium-headful:${{ steps.vars.outputs.short_sha }} + E2E_CHROMIUM_HEADLESS_IMAGE: onkernel/chromium-headless:${{ steps.vars.outputs.short_sha }} diff --git a/server/Makefile b/server/Makefile index 83e78c96..d9952048 100644 --- a/server/Makefile +++ b/server/Makefile @@ -35,7 +35,9 @@ dev: build $(RECORDING_DIR) test: go vet ./... - go test -v -race ./... + # Run tests sequentially (-p 1) to avoid port conflicts in e2e tests + # (all e2e tests bind to the same ports: 10001, 9222) + go test -v -race -p 1 ./... clean: @rm -rf $(BIN_DIR) diff --git a/server/e2e/e2e_chromium_restart_bench_test.go b/server/e2e/e2e_chromium_restart_bench_test.go index 1878490d..fa07eba9 100644 --- a/server/e2e/e2e_chromium_restart_bench_test.go +++ b/server/e2e/e2e_chromium_restart_bench_test.go @@ -316,7 +316,7 @@ func TestChromiumRestartTiming(t *testing.T) { defer cancel() t.Logf("Waiting for API...") - require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready") + require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh, name), "api not ready") t.Logf("Waiting for DevTools...") require.NoError(t, waitTCP(ctx, "127.0.0.1:9222"), "DevTools not ready") diff --git a/server/e2e/e2e_chromium_test.go b/server/e2e/e2e_chromium_test.go index 1c08f78c..2dd98def 100644 --- a/server/e2e/e2e_chromium_test.go +++ b/server/e2e/e2e_chromium_test.go @@ -112,7 +112,7 @@ func TestDisplayResolutionChange(t *testing.T) { defer cancel() logger.Info("[setup]", "action", "waiting for API", "url", apiBaseURL+"/spec.yaml") - require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready: %v", err) + require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh, name), "api not ready: %v", err) client, err := apiClient() require.NoError(t, err, "failed to create API client: %v", err) @@ -212,7 +212,7 @@ func TestExtensionUploadAndActivation(t *testing.T) { ctx, cancel := context.WithTimeout(baseCtx, 3*time.Minute) defer cancel() - require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready: %v", err) + require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh, name), "api not ready: %v", err) // Wait for DevTools _, err = waitDevtoolsWS(ctx) @@ -306,7 +306,7 @@ func TestScreenshotHeadless(t *testing.T) { ctx, cancel := context.WithTimeout(baseCtx, 2*time.Minute) defer cancel() - require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready: %v", err) + require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh, name), "api not ready: %v", err) client, err := apiClient() require.NoError(t, err) @@ -357,7 +357,7 @@ func TestScreenshotHeadful(t *testing.T) { ctx, cancel := context.WithTimeout(baseCtx, 2*time.Minute) defer cancel() - require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready: %v", err) + require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh, name), "api not ready: %v", err) client, err := apiClient() require.NoError(t, err) @@ -402,7 +402,7 @@ func TestInputEndpointsSmoke(t *testing.T) { ctx, cancel := context.WithTimeout(baseCtx, 2*time.Minute) defer cancel() - require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready: %v", err) + require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh, name), "api not ready: %v", err) client, err := apiClient() require.NoError(t, err) @@ -445,17 +445,46 @@ 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", + "-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) @@ -515,6 +544,73 @@ func stopContainer(ctx context.Context, name string) error { 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) @@ -756,7 +852,7 @@ func TestCDPTargetCreation(t *testing.T) { defer cancel() logger.Info("[test]", "action", "waiting for API") - require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready") + require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh, name), "api not ready") // Wait for CDP endpoint to be ready (via the devtools proxy) logger.Info("[test]", "action", "waiting for CDP endpoint") @@ -830,7 +926,7 @@ func TestWebBotAuthInstallation(t *testing.T) { defer cancel() logger.Info("[setup]", "action", "waiting for API", "url", apiBaseURL+"/spec.yaml") - require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready: %v", err) + require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh, name), "api not ready: %v", err) // Build mock web-bot-auth extension zip in-memory extDir := t.TempDir() diff --git a/server/e2e/e2e_persist_login_test.go b/server/e2e/e2e_persist_login_test.go new file mode 100644 index 00000000..548af5d6 --- /dev/null +++ b/server/e2e/e2e_persist_login_test.go @@ -0,0 +1,819 @@ +package e2e + +// These persistence tests rely on our patched Chromium (kernel-browser) which has two key modifications: +// +// 1. Session Cookie Persistence: By default, Chromium does not persist session cookies (cookies without +// an expiration date) to disk. Our patch changes `persist_session_cookies_` to `true` in +// `net/cookies/cookie_monster.h`, allowing session cookies like GitHub's `_gh_sess` to be saved. +// +// 2. Faster Cookie Flush: Stock Chromium only flushes cookies to SQLite every 30 seconds and after +// 512 cookie changes. Our patch reduces `kCommitInterval` to 1 second and `kCommitAfterBatchSize` +// to 50 in `net/extras/sqlite/sqlite_persistent_cookie_store.cc`, ensuring cookies are written +// to disk almost immediately. +// +// Without these patches, the cookie persistence tests would fail because: +// - Session cookies would never be written to the Cookies SQLite database +// - Even persistent cookies might not be flushed before we copy the user-data directory +// +// The patched Chromium is built as kernel-browser and included in the Docker images. + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os/exec" + "strings" + "testing" + "time" + + logctx "github.com/onkernel/kernel-images/server/lib/logger" + instanceoapi "github.com/onkernel/kernel-images/server/lib/oapi" + "github.com/stretchr/testify/require" +) + +const ( + testCookieName = "test_session" + testCookieValue = "abc123xyz" + testServerPort = 18080 +) + +// testCookieServer is a simple HTTP server for testing cookie persistence +type testCookieServer struct { + server *http.Server + port int +} + +func newTestCookieServer(port int) *testCookieServer { + mux := http.NewServeMux() + + // /set-cookie sets a cookie + mux.HandleFunc("/set-cookie", func(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: testCookieName, + Value: testCookieValue, + Path: "/", + MaxAge: 86400, // 1 day + HttpOnly: false, + SameSite: http.SameSiteLaxMode, + }) + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, "

Cookie Set!

Cookie %s=%s has been set.

", testCookieName, testCookieValue) + }) + + // /get-cookie returns cookies as JSON + mux.HandleFunc("/get-cookie", func(w http.ResponseWriter, r *http.Request) { + cookies := r.Cookies() + cookieMap := make(map[string]string) + for _, c := range cookies { + cookieMap[c.Name] = c.Value + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(cookieMap) + }) + + // /set-indexeddb returns an HTML page with JavaScript that sets IndexedDB data + mux.HandleFunc("/set-indexeddb", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, ` + +Set IndexedDB + +

IndexedDB Test

+
Setting IndexedDB...
+ + +`) + }) + + // /get-indexeddb returns an HTML page with JavaScript that reads IndexedDB data + mux.HandleFunc("/get-indexeddb", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, ` + +Get IndexedDB + +

IndexedDB Read Test

+
Reading IndexedDB...
+
+ + +`) + }) + + server := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: mux, + } + + return &testCookieServer{ + server: server, + port: port, + } +} + +func (s *testCookieServer) Start() error { + go func() { + if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + // Log error but don't fail - this runs in a goroutine + fmt.Printf("Test server error: %v\n", err) + } + }() + // Give server time to start + time.Sleep(100 * time.Millisecond) + return nil +} + +func (s *testCookieServer) Stop(ctx context.Context) error { + return s.server.Shutdown(ctx) +} + +func (s *testCookieServer) URL() string { + return fmt.Sprintf("http://host.docker.internal:%d", s.port) +} + + +// TestCookiePersistenceHeadless tests that cookies persist across container restarts for headless image +func TestCookiePersistenceHeadless(t *testing.T) { + testCookiePersistence(t, headlessImage, containerName+"-cookie-persist-headless") +} + +// TestCookiePersistenceHeadful tests that cookies persist across container restarts for headful image +func TestCookiePersistenceHeadful(t *testing.T) { + testCookiePersistence(t, headfulImage, containerName+"-cookie-persist-headful") +} + +func testCookiePersistence(t *testing.T, image, name string) { + 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) + } + + // Start test HTTP server + testServer := newTestCookieServer(testServerPort) + require.NoError(t, testServer.Start(), "failed to start test server") + defer testServer.Stop(baseCtx) + + logger.Info("[setup]", "action", "test server started", "url", testServer.URL()) + + // Clean slate + _ = stopContainer(baseCtx, name) + + env := map[string]string{} + + // Start first container + logger.Info("[test]", "phase", "1", "action", "starting first container") + _, exitCh, err := runContainerWithOptions(baseCtx, image, name, env, ContainerOptions{HostAccess: true}) + require.NoError(t, err, "failed to start container: %v", err) + + ctx, cancel := context.WithTimeout(baseCtx, 5*time.Minute) + defer cancel() + + require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh, name), "api not ready") + + client, err := apiClient() + require.NoError(t, err) + + // Step 1: Verify no cookies initially + logger.Info("[test]", "phase", "1", "action", "checking initial cookies (should be empty)") + cookies := getCookiesViaPlaywright(t, ctx, client, testServer.URL()+"/get-cookie", logger) + require.Empty(t, cookies, "expected no cookies initially, got: %v", cookies) + + // Step 2: Set cookie + logger.Info("[test]", "phase", "1", "action", "setting cookie") + setResult := navigateAndGetResult(t, ctx, client, testServer.URL()+"/set-cookie", logger) + require.Contains(t, setResult, "Cookie Set", "expected cookie set confirmation, got: %s", setResult) + + // Step 3: Verify cookie is set + logger.Info("[test]", "phase", "1", "action", "verifying cookie is set") + cookies = getCookiesViaPlaywright(t, ctx, client, testServer.URL()+"/get-cookie", logger) + require.Equal(t, testCookieValue, cookies[testCookieName], "expected cookie %s=%s, got: %v", testCookieName, testCookieValue, cookies) + + // Step 4: Wait for cookies to flush to disk (1-2 seconds with patched Chromium) + logger.Info("[test]", "phase", "1", "action", "waiting for cookie flush to disk") + time.Sleep(3 * time.Second) + + // Step 5: Download user-data directory + logger.Info("[test]", "phase", "1", "action", "downloading user-data directory") + userDataZip := downloadUserDataDir(t, ctx, client, logger) + require.NotEmpty(t, userDataZip, "user data zip should not be empty") + + // Log what we got in the zip + logZipContents(t, userDataZip, logger) + + // Step 6: Stop first container + logger.Info("[test]", "phase", "1", "action", "stopping first container") + require.NoError(t, stopContainer(ctx, name), "failed to stop container") + + // Step 7: Start second container + logger.Info("[test]", "phase", "2", "action", "starting second container") + _, exitCh2, err := runContainerWithOptions(ctx, image, name, env, ContainerOptions{HostAccess: true}) + require.NoError(t, err, "failed to start second container: %v", err) + + require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh2, name), "api not ready for second container") + defer stopContainer(baseCtx, name) + + client2, err := apiClient() + require.NoError(t, err) + + // Step 8: Verify no cookies in fresh container + logger.Info("[test]", "phase", "2", "action", "verifying no cookies in fresh container") + cookies = getCookiesViaPlaywright(t, ctx, client2, testServer.URL()+"/get-cookie", logger) + require.Empty(t, cookies, "expected no cookies in fresh container, got: %v", cookies) + + // Step 9: Restore user-data directory + logger.Info("[test]", "phase", "2", "action", "restoring user-data directory") + restoreUserDataDir(t, ctx, client2, userDataZip, logger) + + // Step 10: Restart Chromium via supervisorctl + logger.Info("[test]", "phase", "2", "action", "restarting chromium") + restartChromium(t, ctx, client2, logger) + + // Wait for Chromium to be ready + time.Sleep(3 * time.Second) + + // Step 11: Verify cookies are restored + logger.Info("[test]", "phase", "2", "action", "verifying cookies are restored") + cookies = getCookiesViaPlaywright(t, ctx, client2, testServer.URL()+"/get-cookie", logger) + require.Equal(t, testCookieValue, cookies[testCookieName], "expected restored cookie %s=%s, got: %v", testCookieName, testCookieValue, cookies) + + logger.Info("[test]", "result", "cookie persistence test PASSED") +} + +// TestIndexedDBPersistenceHeadless tests that IndexedDB data persists across container restarts for headless image +func TestIndexedDBPersistenceHeadless(t *testing.T) { + testIndexedDBPersistence(t, headlessImage, containerName+"-idb-persist-headless") +} + +// TestIndexedDBPersistenceHeadful tests that IndexedDB data persists across container restarts for headful image +func TestIndexedDBPersistenceHeadful(t *testing.T) { + testIndexedDBPersistence(t, headfulImage, containerName+"-idb-persist-headful") +} + +func testIndexedDBPersistence(t *testing.T, image, name string) { + 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) + } + + // Start test HTTP server + testServer := newTestCookieServer(testServerPort) + require.NoError(t, testServer.Start(), "failed to start test server") + defer testServer.Stop(baseCtx) + + logger.Info("[setup]", "action", "test server started", "url", testServer.URL()) + + // Clean slate + _ = stopContainer(baseCtx, name) + + env := map[string]string{} + + // Start first container + logger.Info("[test]", "phase", "1", "action", "starting first container") + _, exitCh, err := runContainerWithOptions(baseCtx, image, name, env, ContainerOptions{HostAccess: true}) + require.NoError(t, err, "failed to start container: %v", err) + + ctx, cancel := context.WithTimeout(baseCtx, 5*time.Minute) + defer cancel() + + require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh, name), "api not ready") + + client, err := apiClient() + require.NoError(t, err) + + // Step 1: Verify IndexedDB is empty initially + logger.Info("[test]", "phase", "1", "action", "checking initial IndexedDB (should be empty)") + idbResult := getIndexedDBViaPlaywright(t, ctx, client, testServer.URL()+"/get-indexeddb", logger) + require.Nil(t, idbResult, "expected no IndexedDB data initially, got: %v", idbResult) + + // Step 2: Set IndexedDB data + logger.Info("[test]", "phase", "1", "action", "setting IndexedDB data") + setIndexedDBViaPlaywright(t, ctx, client, testServer.URL()+"/set-indexeddb", logger) + + // Step 3: Verify IndexedDB data is set + logger.Info("[test]", "phase", "1", "action", "verifying IndexedDB data is set") + idbResult = getIndexedDBViaPlaywright(t, ctx, client, testServer.URL()+"/get-indexeddb", logger) + require.NotNil(t, idbResult, "expected IndexedDB data to be set") + idbMap, ok := idbResult.(map[string]interface{}) + require.True(t, ok, "expected IndexedDB result to be a map, got: %T", idbResult) + require.Equal(t, "Hello from IndexedDB!", idbMap["message"], "expected message in IndexedDB data") + + // Step 4: Wait for IndexedDB to flush to disk + logger.Info("[test]", "phase", "1", "action", "waiting for IndexedDB flush to disk") + time.Sleep(3 * time.Second) + + // Step 5: Download user-data directory + logger.Info("[test]", "phase", "1", "action", "downloading user-data directory") + userDataZip := downloadUserDataDir(t, ctx, client, logger) + require.NotEmpty(t, userDataZip, "user data zip should not be empty") + + // Step 6: Stop first container + logger.Info("[test]", "phase", "1", "action", "stopping first container") + require.NoError(t, stopContainer(ctx, name), "failed to stop container") + + // Step 7: Start second container + logger.Info("[test]", "phase", "2", "action", "starting second container") + _, exitCh2, err := runContainerWithOptions(ctx, image, name, env, ContainerOptions{HostAccess: true}) + require.NoError(t, err, "failed to start second container: %v", err) + + require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh2, name), "api not ready for second container") + defer stopContainer(baseCtx, name) + + client2, err := apiClient() + require.NoError(t, err) + + // Step 8: Verify IndexedDB is empty in fresh container + logger.Info("[test]", "phase", "2", "action", "verifying IndexedDB is empty in fresh container") + idbResult = getIndexedDBViaPlaywright(t, ctx, client2, testServer.URL()+"/get-indexeddb", logger) + require.Nil(t, idbResult, "expected no IndexedDB data in fresh container, got: %v", idbResult) + + // Step 9: Restore user-data directory + logger.Info("[test]", "phase", "2", "action", "restoring user-data directory") + restoreUserDataDir(t, ctx, client2, userDataZip, logger) + + // Step 10: Restart Chromium via supervisorctl + logger.Info("[test]", "phase", "2", "action", "restarting chromium") + restartChromium(t, ctx, client2, logger) + + // Wait for Chromium to be ready + time.Sleep(3 * time.Second) + + // Step 11: Verify IndexedDB data is restored + logger.Info("[test]", "phase", "2", "action", "verifying IndexedDB data is restored") + idbResult = getIndexedDBViaPlaywright(t, ctx, client2, testServer.URL()+"/get-indexeddb", logger) + require.NotNil(t, idbResult, "expected IndexedDB data to be restored") + idbMap, ok = idbResult.(map[string]interface{}) + require.True(t, ok, "expected IndexedDB result to be a map, got: %T", idbResult) + require.Equal(t, "Hello from IndexedDB!", idbMap["message"], "expected message in restored IndexedDB data") + + logger.Info("[test]", "result", "IndexedDB persistence test PASSED") +} + +// getCookiesViaPlaywright navigates to a URL and returns the cookies as a map +func getCookiesViaPlaywright(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses, url string, logger *slog.Logger) map[string]string { + code := fmt.Sprintf(` + await page.goto('%s'); + const content = await page.textContent('body'); + return content; + `, url) + + req := instanceoapi.ExecutePlaywrightCodeJSONRequestBody{Code: code} + rsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, req) + require.NoError(t, err, "playwright execute request error") + require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status: %s body=%s", rsp.Status(), string(rsp.Body)) + require.NotNil(t, rsp.JSON200, "expected JSON200 response") + + if !rsp.JSON200.Success { + var errorMsg string + if rsp.JSON200.Error != nil { + errorMsg = *rsp.JSON200.Error + } + t.Fatalf("playwright execution failed: %s", errorMsg) + } + + // Parse the JSON result + resultStr, ok := rsp.JSON200.Result.(string) + if !ok { + // Try to marshal and unmarshal + resultBytes, _ := json.Marshal(rsp.JSON200.Result) + resultStr = string(resultBytes) + // Remove quotes if present + resultStr = strings.Trim(resultStr, "\"") + } + + logger.Info("[playwright]", "raw_result", resultStr) + + var cookies map[string]string + if err := json.Unmarshal([]byte(resultStr), &cookies); err != nil { + // If it's not valid JSON, return empty + logger.Info("[playwright]", "parse_error", err.Error()) + return make(map[string]string) + } + + return cookies +} + +// navigateAndGetResult navigates to a URL and returns the page content +func navigateAndGetResult(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses, url string, logger *slog.Logger) string { + code := fmt.Sprintf(` + await page.goto('%s'); + const content = await page.textContent('body'); + return content; + `, url) + + req := instanceoapi.ExecutePlaywrightCodeJSONRequestBody{Code: code} + rsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, req) + require.NoError(t, err, "playwright execute request error") + require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status: %s body=%s", rsp.Status(), string(rsp.Body)) + require.NotNil(t, rsp.JSON200, "expected JSON200 response") + require.True(t, rsp.JSON200.Success, "expected success=true") + + resultStr, ok := rsp.JSON200.Result.(string) + if !ok { + resultBytes, _ := json.Marshal(rsp.JSON200.Result) + resultStr = string(resultBytes) + } + + return resultStr +} + +// getIndexedDBViaPlaywright navigates to a page and reads IndexedDB data +func getIndexedDBViaPlaywright(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses, url string, logger *slog.Logger) interface{} { + // Navigate to the page and read IndexedDB directly via page.evaluate + code := fmt.Sprintf(` + await page.goto('%s', { waitUntil: 'domcontentloaded' }); + + // Directly read IndexedDB in the page context + const result = await page.evaluate(async () => { + return new Promise((resolve) => { + const dbName = 'testPersistDB'; + const storeName = 'testStore'; + const testKey = 'testKey'; + + const request = indexedDB.open(dbName, 1); + + request.onupgradeneeded = function(event) { + // If we need to upgrade, the data doesn't exist + event.target.transaction.abort(); + resolve(null); + }; + + request.onsuccess = function(event) { + const db = event.target.result; + + if (!db.objectStoreNames.contains(storeName)) { + db.close(); + resolve(null); + return; + } + + const transaction = db.transaction([storeName], 'readonly'); + const store = transaction.objectStore(storeName); + const getRequest = store.get(testKey); + + getRequest.onsuccess = function() { + db.close(); + resolve(getRequest.result || null); + }; + + getRequest.onerror = function() { + db.close(); + resolve(null); + }; + }; + + request.onerror = function() { + resolve(null); + }; + + // Timeout after 5 seconds + setTimeout(() => { + resolve(null); + }, 5000); + }); + }); + + return result; + `, url) + + req := instanceoapi.ExecutePlaywrightCodeJSONRequestBody{Code: code} + rsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, req) + require.NoError(t, err, "playwright execute request error") + require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status: %s body=%s", rsp.Status(), string(rsp.Body)) + require.NotNil(t, rsp.JSON200, "expected JSON200 response") + + if !rsp.JSON200.Success { + var errMsg string + if rsp.JSON200.Error != nil { + errMsg = *rsp.JSON200.Error + } + logger.Info("[getIndexedDB]", "error", errMsg) + } + + require.True(t, rsp.JSON200.Success, "expected success=true") + + logger.Info("[getIndexedDB]", "result", rsp.JSON200.Result) + return rsp.JSON200.Result +} + +// setIndexedDBViaPlaywright navigates to the IndexedDB set page and waits for completion +func setIndexedDBViaPlaywright(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses, url string, logger *slog.Logger) { + // Navigate to the page and set IndexedDB directly via page.evaluate + // Use a unique timestamp-based version to ensure onupgradeneeded is called + code := fmt.Sprintf(` + await page.goto('%s', { waitUntil: 'domcontentloaded' }); + + // Check if IndexedDB is available + const idbAvailable = await page.evaluate(() => !!window.indexedDB); + if (!idbAvailable) { + return { success: false, error: 'IndexedDB not available' }; + } + + // Directly execute IndexedDB operations in the page context + const result = await page.evaluate(() => { + return new Promise((resolve) => { + const dbName = 'testPersistDB'; + const storeName = 'testStore'; + const testKey = 'testKey'; + const testValue = { message: 'Hello from IndexedDB!', timestamp: Date.now() }; + + // First, delete any existing database to ensure clean state + const deleteRequest = indexedDB.deleteDatabase(dbName); + + deleteRequest.onsuccess = function() { + // Now create fresh database with object store + const openRequest = indexedDB.open(dbName, 1); + + openRequest.onupgradeneeded = function(event) { + try { + const db = event.target.result; + db.createObjectStore(storeName); + } catch (e) { + resolve({ success: false, error: 'onupgradeneeded error: ' + e.toString() }); + } + }; + + openRequest.onsuccess = function(event) { + try { + const db = event.target.result; + const transaction = db.transaction([storeName], 'readwrite'); + const store = transaction.objectStore(storeName); + const putRequest = store.put(testValue, testKey); + + putRequest.onsuccess = function() { + db.close(); + resolve({ success: true, message: 'IndexedDB put succeeded' }); + }; + + putRequest.onerror = function(e) { + db.close(); + resolve({ success: false, error: 'Put error: ' + (e.target?.error?.toString() || 'unknown') }); + }; + } catch (e) { + resolve({ success: false, error: 'onsuccess error: ' + e.toString() }); + } + }; + + openRequest.onerror = function(event) { + resolve({ success: false, error: 'Open error: ' + (event.target?.error?.toString() || 'unknown') }); + }; + }; + + deleteRequest.onerror = function() { + resolve({ success: false, error: 'Delete error' }); + }; + + // Timeout after 5 seconds + setTimeout(() => { + resolve({ success: false, error: 'Timeout waiting for IndexedDB' }); + }, 5000); + }); + }); + + return result; + `, url) + + req := instanceoapi.ExecutePlaywrightCodeJSONRequestBody{Code: code} + rsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, req) + require.NoError(t, err, "playwright execute request error") + require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status: %s body=%s", rsp.Status(), string(rsp.Body)) + require.NotNil(t, rsp.JSON200, "expected JSON200 response") + + if !rsp.JSON200.Success { + var errMsg string + if rsp.JSON200.Error != nil { + errMsg = *rsp.JSON200.Error + } + var stderr string + if rsp.JSON200.Stderr != nil { + stderr = *rsp.JSON200.Stderr + } + logger.Info("[setIndexedDB]", "error", errMsg, "stderr", stderr) + require.True(t, rsp.JSON200.Success, "expected success=true, error: %s", errMsg) + } + + logger.Info("[setIndexedDB]", "result", rsp.JSON200.Result) + + // The result should be an object with success and message + if resultMap, ok := rsp.JSON200.Result.(map[string]interface{}); ok { + if success, ok := resultMap["success"].(bool); ok { + require.True(t, success, "expected IndexedDB set to succeed, got error: %v", resultMap["error"]) + } + } +} + +// downloadUserDataDir downloads the user-data directory as a zip +func downloadUserDataDir(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses, logger *slog.Logger) []byte { + params := &instanceoapi.DownloadDirZipParams{ + Path: "/home/kernel/user-data", + } + + rsp, err := client.DownloadDirZipWithResponse(ctx, params) + require.NoError(t, err, "download dir zip request error") + require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status: %s", rsp.Status()) + + logger.Info("[download]", "size_bytes", len(rsp.Body)) + return rsp.Body +} + +// logZipContents logs the contents of a zip file for debugging +func logZipContents(t *testing.T, zipData []byte, logger *slog.Logger) { + reader, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData))) + if err != nil { + logger.Info("[zip]", "error", "failed to read zip", "err", err.Error()) + return + } + + var files []string + for _, f := range reader.File { + files = append(files, f.Name) + } + + logger.Info("[zip]", "contents", strings.Join(files, ", ")) +} + +// restoreUserDataDir uploads and extracts user-data directory from a zip +func restoreUserDataDir(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses, zipData []byte, logger *slog.Logger) { + // First, we need to extract the zip and upload files individually + // The API has WriteFile but not a direct "upload zip and extract" endpoint + // We'll use ProcessExec to extract after uploading + + // Upload the zip file to a temp location + zipPath := "/tmp/user-data-restore.zip" + params := &instanceoapi.WriteFileParams{ + Path: zipPath, + } + + rsp, err := client.WriteFileWithBodyWithResponse(ctx, params, "application/octet-stream", bytes.NewReader(zipData)) + require.NoError(t, err, "write file request error") + require.Equal(t, http.StatusCreated, rsp.StatusCode(), "unexpected status: %s body=%s", rsp.Status(), string(rsp.Body)) + + logger.Info("[restore]", "action", "uploaded zip", "path", zipPath) + + // Extract the zip using unzip command + args := []string{"-o", zipPath, "-d", "/home/kernel/user-data"} + req := instanceoapi.ProcessExecJSONRequestBody{ + Command: "unzip", + Args: &args, + } + + execRsp, err := client.ProcessExecWithResponse(ctx, req) + require.NoError(t, err, "process exec request error") + require.Equal(t, http.StatusOK, execRsp.StatusCode(), "unexpected status: %s body=%s", execRsp.Status(), string(execRsp.Body)) + + if execRsp.JSON200.ExitCode != nil && *execRsp.JSON200.ExitCode != 0 { + logger.Info("[restore]", "unzip_exit_code", *execRsp.JSON200.ExitCode) + } + + logger.Info("[restore]", "action", "extracted zip to user-data") + + // Remove lock files that prevent Chromium from starting with restored profile + lockFiles := []string{ + "/home/kernel/user-data/SingletonLock", + "/home/kernel/user-data/SingletonSocket", + "/home/kernel/user-data/SingletonCookie", + } + for _, lockFile := range lockFiles { + rmArgs := []string{"-f", lockFile} + rmReq := instanceoapi.ProcessExecJSONRequestBody{ + Command: "rm", + Args: &rmArgs, + } + _, _ = client.ProcessExecWithResponse(ctx, rmReq) + } + logger.Info("[restore]", "action", "removed lock files") + + // Fix permissions + chownArgs := []string{"-R", "kernel:kernel", "/home/kernel/user-data"} + chownReq := instanceoapi.ProcessExecJSONRequestBody{ + Command: "chown", + Args: &chownArgs, + } + _, _ = client.ProcessExecWithResponse(ctx, chownReq) + + logger.Info("[restore]", "action", "fixed permissions") +} + +// restartChromium restarts Chromium via supervisorctl and waits for it to be ready +func restartChromium(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses, logger *slog.Logger) { + args := []string{"-c", "/etc/supervisor/supervisord.conf", "restart", "chromium"} + req := instanceoapi.ProcessExecJSONRequestBody{ + Command: "supervisorctl", + Args: &args, + } + + rsp, err := client.ProcessExecWithResponse(ctx, req) + require.NoError(t, err, "supervisorctl restart request error") + require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status: %s body=%s", rsp.Status(), string(rsp.Body)) + + logger.Info("[restart]", "action", "chromium restarted via supervisorctl") + + // Wait for CDP endpoint to be ready again by checking the internal CDP endpoint + logger.Info("[restart]", "action", "waiting for CDP endpoint to be ready") + for i := 0; i < 30; i++ { + checkArgs := []string{"-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:9223/json/version"} + checkReq := instanceoapi.ProcessExecJSONRequestBody{ + Command: "curl", + Args: &checkArgs, + } + checkRsp, err := client.ProcessExecWithResponse(ctx, checkReq) + if err == nil && checkRsp.JSON200 != nil && checkRsp.JSON200.ExitCode != nil && *checkRsp.JSON200.ExitCode == 0 { + logger.Info("[restart]", "action", "CDP endpoint is ready") + return + } + time.Sleep(500 * time.Millisecond) + } + + logger.Info("[restart]", "warning", "CDP endpoint may not be fully ready after 15 seconds") +} diff --git a/server/e2e/e2e_playwright_test.go b/server/e2e/e2e_playwright_test.go index 76c3645a..866fbacc 100644 --- a/server/e2e/e2e_playwright_test.go +++ b/server/e2e/e2e_playwright_test.go @@ -36,7 +36,7 @@ func TestPlaywrightExecuteAPI(t *testing.T) { ctx, cancel := context.WithTimeout(baseCtx, 2*time.Minute) defer cancel() - require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready: %v", err) + require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh, name), "api not ready: %v", err) client, err := apiClient() require.NoError(t, err) @@ -114,7 +114,7 @@ func TestPlaywrightDaemonRecovery(t *testing.T) { ctx, cancel := context.WithTimeout(baseCtx, 3*time.Minute) defer cancel() - require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready: %v", err) + require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh, name), "api not ready: %v", err) client, err := apiClient() require.NoError(t, err) From 1bafb8ecfa9e6f2dde0fea3c793c4560b0bd0670 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 26 Jan 2026 13:41:51 -0500 Subject: [PATCH 2/3] fix: address PR review feedback - Move defer stopContainer immediately after runContainerWithOptions to ensure container cleanup even if wait fails - Fail test if unzip exits with non-zero code instead of just logging - Check HTTP status code (200) in restartChromium, not just curl exit - Fail test with clear message if CDP doesn't become ready after 15s --- server/e2e/e2e_persist_login_test.go | 34 +++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/server/e2e/e2e_persist_login_test.go b/server/e2e/e2e_persist_login_test.go index 548af5d6..85c19ee7 100644 --- a/server/e2e/e2e_persist_login_test.go +++ b/server/e2e/e2e_persist_login_test.go @@ -21,6 +21,7 @@ import ( "archive/zip" "bytes" "context" + "encoding/base64" "encoding/json" "fmt" "log/slog" @@ -298,9 +299,9 @@ func testCookiePersistence(t *testing.T, image, name string) { logger.Info("[test]", "phase", "2", "action", "starting second container") _, exitCh2, err := runContainerWithOptions(ctx, image, name, env, ContainerOptions{HostAccess: true}) require.NoError(t, err, "failed to start second container: %v", err) + defer stopContainer(baseCtx, name) require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh2, name), "api not ready for second container") - defer stopContainer(baseCtx, name) client2, err := apiClient() require.NoError(t, err) @@ -406,9 +407,9 @@ func testIndexedDBPersistence(t *testing.T, image, name string) { logger.Info("[test]", "phase", "2", "action", "starting second container") _, exitCh2, err := runContainerWithOptions(ctx, image, name, env, ContainerOptions{HostAccess: true}) require.NoError(t, err, "failed to start second container: %v", err) + defer stopContainer(baseCtx, name) require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh2, name), "api not ready for second container") - defer stopContainer(baseCtx, name) client2, err := apiClient() require.NoError(t, err) @@ -753,7 +754,18 @@ func restoreUserDataDir(t *testing.T, ctx context.Context, client *instanceoapi. require.Equal(t, http.StatusOK, execRsp.StatusCode(), "unexpected status: %s body=%s", execRsp.Status(), string(execRsp.Body)) if execRsp.JSON200.ExitCode != nil && *execRsp.JSON200.ExitCode != 0 { - logger.Info("[restore]", "unzip_exit_code", *execRsp.JSON200.ExitCode) + var stdout, stderr string + if execRsp.JSON200.StdoutB64 != nil { + if b, decErr := base64.StdEncoding.DecodeString(*execRsp.JSON200.StdoutB64); decErr == nil { + stdout = string(b) + } + } + if execRsp.JSON200.StderrB64 != nil { + if b, decErr := base64.StdEncoding.DecodeString(*execRsp.JSON200.StderrB64); decErr == nil { + stderr = string(b) + } + } + require.Fail(t, "unzip failed", "exit_code=%d stdout=%s stderr=%s", *execRsp.JSON200.ExitCode, stdout, stderr) } logger.Info("[restore]", "action", "extracted zip to user-data") @@ -802,6 +814,7 @@ func restartChromium(t *testing.T, ctx context.Context, client *instanceoapi.Cli // Wait for CDP endpoint to be ready again by checking the internal CDP endpoint logger.Info("[restart]", "action", "waiting for CDP endpoint to be ready") for i := 0; i < 30; i++ { + // Use curl to check the CDP endpoint and capture the HTTP status code checkArgs := []string{"-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:9223/json/version"} checkReq := instanceoapi.ProcessExecJSONRequestBody{ Command: "curl", @@ -809,11 +822,20 @@ func restartChromium(t *testing.T, ctx context.Context, client *instanceoapi.Cli } checkRsp, err := client.ProcessExecWithResponse(ctx, checkReq) if err == nil && checkRsp.JSON200 != nil && checkRsp.JSON200.ExitCode != nil && *checkRsp.JSON200.ExitCode == 0 { - logger.Info("[restart]", "action", "CDP endpoint is ready") - return + // Decode stdout to get the HTTP status code + if checkRsp.JSON200.StdoutB64 != nil { + if b, decErr := base64.StdEncoding.DecodeString(*checkRsp.JSON200.StdoutB64); decErr == nil { + httpCode := strings.TrimSpace(string(b)) + if httpCode == "200" { + logger.Info("[restart]", "action", "CDP endpoint is ready", "http_code", httpCode) + return + } + logger.Info("[restart]", "action", "CDP endpoint returned non-200", "http_code", httpCode) + } + } } time.Sleep(500 * time.Millisecond) } - logger.Info("[restart]", "warning", "CDP endpoint may not be fully ready after 15 seconds") + require.Fail(t, "Chromium restart timed out", "CDP endpoint did not become ready after 15 seconds") } From 156f0d3a978592de5d65a8d8b33d52966fd10f3c Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 26 Jan 2026 14:12:23 -0500 Subject: [PATCH 3/3] fix: remove persistence tests that require patched Chromium The cookie and IndexedDB persistence tests require kernel-browser (patched Chromium with session cookie persistence and faster flush). These patches only exist in the private images, so these tests cannot run on the public kernel-images repo. --- server/e2e/e2e_persist_login_test.go | 841 --------------------------- 1 file changed, 841 deletions(-) delete mode 100644 server/e2e/e2e_persist_login_test.go diff --git a/server/e2e/e2e_persist_login_test.go b/server/e2e/e2e_persist_login_test.go deleted file mode 100644 index 85c19ee7..00000000 --- a/server/e2e/e2e_persist_login_test.go +++ /dev/null @@ -1,841 +0,0 @@ -package e2e - -// These persistence tests rely on our patched Chromium (kernel-browser) which has two key modifications: -// -// 1. Session Cookie Persistence: By default, Chromium does not persist session cookies (cookies without -// an expiration date) to disk. Our patch changes `persist_session_cookies_` to `true` in -// `net/cookies/cookie_monster.h`, allowing session cookies like GitHub's `_gh_sess` to be saved. -// -// 2. Faster Cookie Flush: Stock Chromium only flushes cookies to SQLite every 30 seconds and after -// 512 cookie changes. Our patch reduces `kCommitInterval` to 1 second and `kCommitAfterBatchSize` -// to 50 in `net/extras/sqlite/sqlite_persistent_cookie_store.cc`, ensuring cookies are written -// to disk almost immediately. -// -// Without these patches, the cookie persistence tests would fail because: -// - Session cookies would never be written to the Cookies SQLite database -// - Even persistent cookies might not be flushed before we copy the user-data directory -// -// The patched Chromium is built as kernel-browser and included in the Docker images. - -import ( - "archive/zip" - "bytes" - "context" - "encoding/base64" - "encoding/json" - "fmt" - "log/slog" - "net/http" - "os/exec" - "strings" - "testing" - "time" - - logctx "github.com/onkernel/kernel-images/server/lib/logger" - instanceoapi "github.com/onkernel/kernel-images/server/lib/oapi" - "github.com/stretchr/testify/require" -) - -const ( - testCookieName = "test_session" - testCookieValue = "abc123xyz" - testServerPort = 18080 -) - -// testCookieServer is a simple HTTP server for testing cookie persistence -type testCookieServer struct { - server *http.Server - port int -} - -func newTestCookieServer(port int) *testCookieServer { - mux := http.NewServeMux() - - // /set-cookie sets a cookie - mux.HandleFunc("/set-cookie", func(w http.ResponseWriter, r *http.Request) { - http.SetCookie(w, &http.Cookie{ - Name: testCookieName, - Value: testCookieValue, - Path: "/", - MaxAge: 86400, // 1 day - HttpOnly: false, - SameSite: http.SameSiteLaxMode, - }) - w.Header().Set("Content-Type", "text/html") - fmt.Fprintf(w, "

Cookie Set!

Cookie %s=%s has been set.

", testCookieName, testCookieValue) - }) - - // /get-cookie returns cookies as JSON - mux.HandleFunc("/get-cookie", func(w http.ResponseWriter, r *http.Request) { - cookies := r.Cookies() - cookieMap := make(map[string]string) - for _, c := range cookies { - cookieMap[c.Name] = c.Value - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(cookieMap) - }) - - // /set-indexeddb returns an HTML page with JavaScript that sets IndexedDB data - mux.HandleFunc("/set-indexeddb", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html") - fmt.Fprint(w, ` - -Set IndexedDB - -

IndexedDB Test

-
Setting IndexedDB...
- - -`) - }) - - // /get-indexeddb returns an HTML page with JavaScript that reads IndexedDB data - mux.HandleFunc("/get-indexeddb", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html") - fmt.Fprint(w, ` - -Get IndexedDB - -

IndexedDB Read Test

-
Reading IndexedDB...
-
- - -`) - }) - - server := &http.Server{ - Addr: fmt.Sprintf(":%d", port), - Handler: mux, - } - - return &testCookieServer{ - server: server, - port: port, - } -} - -func (s *testCookieServer) Start() error { - go func() { - if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - // Log error but don't fail - this runs in a goroutine - fmt.Printf("Test server error: %v\n", err) - } - }() - // Give server time to start - time.Sleep(100 * time.Millisecond) - return nil -} - -func (s *testCookieServer) Stop(ctx context.Context) error { - return s.server.Shutdown(ctx) -} - -func (s *testCookieServer) URL() string { - return fmt.Sprintf("http://host.docker.internal:%d", s.port) -} - - -// TestCookiePersistenceHeadless tests that cookies persist across container restarts for headless image -func TestCookiePersistenceHeadless(t *testing.T) { - testCookiePersistence(t, headlessImage, containerName+"-cookie-persist-headless") -} - -// TestCookiePersistenceHeadful tests that cookies persist across container restarts for headful image -func TestCookiePersistenceHeadful(t *testing.T) { - testCookiePersistence(t, headfulImage, containerName+"-cookie-persist-headful") -} - -func testCookiePersistence(t *testing.T, image, name string) { - 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) - } - - // Start test HTTP server - testServer := newTestCookieServer(testServerPort) - require.NoError(t, testServer.Start(), "failed to start test server") - defer testServer.Stop(baseCtx) - - logger.Info("[setup]", "action", "test server started", "url", testServer.URL()) - - // Clean slate - _ = stopContainer(baseCtx, name) - - env := map[string]string{} - - // Start first container - logger.Info("[test]", "phase", "1", "action", "starting first container") - _, exitCh, err := runContainerWithOptions(baseCtx, image, name, env, ContainerOptions{HostAccess: true}) - require.NoError(t, err, "failed to start container: %v", err) - - ctx, cancel := context.WithTimeout(baseCtx, 5*time.Minute) - defer cancel() - - require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh, name), "api not ready") - - client, err := apiClient() - require.NoError(t, err) - - // Step 1: Verify no cookies initially - logger.Info("[test]", "phase", "1", "action", "checking initial cookies (should be empty)") - cookies := getCookiesViaPlaywright(t, ctx, client, testServer.URL()+"/get-cookie", logger) - require.Empty(t, cookies, "expected no cookies initially, got: %v", cookies) - - // Step 2: Set cookie - logger.Info("[test]", "phase", "1", "action", "setting cookie") - setResult := navigateAndGetResult(t, ctx, client, testServer.URL()+"/set-cookie", logger) - require.Contains(t, setResult, "Cookie Set", "expected cookie set confirmation, got: %s", setResult) - - // Step 3: Verify cookie is set - logger.Info("[test]", "phase", "1", "action", "verifying cookie is set") - cookies = getCookiesViaPlaywright(t, ctx, client, testServer.URL()+"/get-cookie", logger) - require.Equal(t, testCookieValue, cookies[testCookieName], "expected cookie %s=%s, got: %v", testCookieName, testCookieValue, cookies) - - // Step 4: Wait for cookies to flush to disk (1-2 seconds with patched Chromium) - logger.Info("[test]", "phase", "1", "action", "waiting for cookie flush to disk") - time.Sleep(3 * time.Second) - - // Step 5: Download user-data directory - logger.Info("[test]", "phase", "1", "action", "downloading user-data directory") - userDataZip := downloadUserDataDir(t, ctx, client, logger) - require.NotEmpty(t, userDataZip, "user data zip should not be empty") - - // Log what we got in the zip - logZipContents(t, userDataZip, logger) - - // Step 6: Stop first container - logger.Info("[test]", "phase", "1", "action", "stopping first container") - require.NoError(t, stopContainer(ctx, name), "failed to stop container") - - // Step 7: Start second container - logger.Info("[test]", "phase", "2", "action", "starting second container") - _, exitCh2, err := runContainerWithOptions(ctx, image, name, env, ContainerOptions{HostAccess: true}) - require.NoError(t, err, "failed to start second container: %v", err) - defer stopContainer(baseCtx, name) - - require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh2, name), "api not ready for second container") - - client2, err := apiClient() - require.NoError(t, err) - - // Step 8: Verify no cookies in fresh container - logger.Info("[test]", "phase", "2", "action", "verifying no cookies in fresh container") - cookies = getCookiesViaPlaywright(t, ctx, client2, testServer.URL()+"/get-cookie", logger) - require.Empty(t, cookies, "expected no cookies in fresh container, got: %v", cookies) - - // Step 9: Restore user-data directory - logger.Info("[test]", "phase", "2", "action", "restoring user-data directory") - restoreUserDataDir(t, ctx, client2, userDataZip, logger) - - // Step 10: Restart Chromium via supervisorctl - logger.Info("[test]", "phase", "2", "action", "restarting chromium") - restartChromium(t, ctx, client2, logger) - - // Wait for Chromium to be ready - time.Sleep(3 * time.Second) - - // Step 11: Verify cookies are restored - logger.Info("[test]", "phase", "2", "action", "verifying cookies are restored") - cookies = getCookiesViaPlaywright(t, ctx, client2, testServer.URL()+"/get-cookie", logger) - require.Equal(t, testCookieValue, cookies[testCookieName], "expected restored cookie %s=%s, got: %v", testCookieName, testCookieValue, cookies) - - logger.Info("[test]", "result", "cookie persistence test PASSED") -} - -// TestIndexedDBPersistenceHeadless tests that IndexedDB data persists across container restarts for headless image -func TestIndexedDBPersistenceHeadless(t *testing.T) { - testIndexedDBPersistence(t, headlessImage, containerName+"-idb-persist-headless") -} - -// TestIndexedDBPersistenceHeadful tests that IndexedDB data persists across container restarts for headful image -func TestIndexedDBPersistenceHeadful(t *testing.T) { - testIndexedDBPersistence(t, headfulImage, containerName+"-idb-persist-headful") -} - -func testIndexedDBPersistence(t *testing.T, image, name string) { - 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) - } - - // Start test HTTP server - testServer := newTestCookieServer(testServerPort) - require.NoError(t, testServer.Start(), "failed to start test server") - defer testServer.Stop(baseCtx) - - logger.Info("[setup]", "action", "test server started", "url", testServer.URL()) - - // Clean slate - _ = stopContainer(baseCtx, name) - - env := map[string]string{} - - // Start first container - logger.Info("[test]", "phase", "1", "action", "starting first container") - _, exitCh, err := runContainerWithOptions(baseCtx, image, name, env, ContainerOptions{HostAccess: true}) - require.NoError(t, err, "failed to start container: %v", err) - - ctx, cancel := context.WithTimeout(baseCtx, 5*time.Minute) - defer cancel() - - require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh, name), "api not ready") - - client, err := apiClient() - require.NoError(t, err) - - // Step 1: Verify IndexedDB is empty initially - logger.Info("[test]", "phase", "1", "action", "checking initial IndexedDB (should be empty)") - idbResult := getIndexedDBViaPlaywright(t, ctx, client, testServer.URL()+"/get-indexeddb", logger) - require.Nil(t, idbResult, "expected no IndexedDB data initially, got: %v", idbResult) - - // Step 2: Set IndexedDB data - logger.Info("[test]", "phase", "1", "action", "setting IndexedDB data") - setIndexedDBViaPlaywright(t, ctx, client, testServer.URL()+"/set-indexeddb", logger) - - // Step 3: Verify IndexedDB data is set - logger.Info("[test]", "phase", "1", "action", "verifying IndexedDB data is set") - idbResult = getIndexedDBViaPlaywright(t, ctx, client, testServer.URL()+"/get-indexeddb", logger) - require.NotNil(t, idbResult, "expected IndexedDB data to be set") - idbMap, ok := idbResult.(map[string]interface{}) - require.True(t, ok, "expected IndexedDB result to be a map, got: %T", idbResult) - require.Equal(t, "Hello from IndexedDB!", idbMap["message"], "expected message in IndexedDB data") - - // Step 4: Wait for IndexedDB to flush to disk - logger.Info("[test]", "phase", "1", "action", "waiting for IndexedDB flush to disk") - time.Sleep(3 * time.Second) - - // Step 5: Download user-data directory - logger.Info("[test]", "phase", "1", "action", "downloading user-data directory") - userDataZip := downloadUserDataDir(t, ctx, client, logger) - require.NotEmpty(t, userDataZip, "user data zip should not be empty") - - // Step 6: Stop first container - logger.Info("[test]", "phase", "1", "action", "stopping first container") - require.NoError(t, stopContainer(ctx, name), "failed to stop container") - - // Step 7: Start second container - logger.Info("[test]", "phase", "2", "action", "starting second container") - _, exitCh2, err := runContainerWithOptions(ctx, image, name, env, ContainerOptions{HostAccess: true}) - require.NoError(t, err, "failed to start second container: %v", err) - defer stopContainer(baseCtx, name) - - require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh2, name), "api not ready for second container") - - client2, err := apiClient() - require.NoError(t, err) - - // Step 8: Verify IndexedDB is empty in fresh container - logger.Info("[test]", "phase", "2", "action", "verifying IndexedDB is empty in fresh container") - idbResult = getIndexedDBViaPlaywright(t, ctx, client2, testServer.URL()+"/get-indexeddb", logger) - require.Nil(t, idbResult, "expected no IndexedDB data in fresh container, got: %v", idbResult) - - // Step 9: Restore user-data directory - logger.Info("[test]", "phase", "2", "action", "restoring user-data directory") - restoreUserDataDir(t, ctx, client2, userDataZip, logger) - - // Step 10: Restart Chromium via supervisorctl - logger.Info("[test]", "phase", "2", "action", "restarting chromium") - restartChromium(t, ctx, client2, logger) - - // Wait for Chromium to be ready - time.Sleep(3 * time.Second) - - // Step 11: Verify IndexedDB data is restored - logger.Info("[test]", "phase", "2", "action", "verifying IndexedDB data is restored") - idbResult = getIndexedDBViaPlaywright(t, ctx, client2, testServer.URL()+"/get-indexeddb", logger) - require.NotNil(t, idbResult, "expected IndexedDB data to be restored") - idbMap, ok = idbResult.(map[string]interface{}) - require.True(t, ok, "expected IndexedDB result to be a map, got: %T", idbResult) - require.Equal(t, "Hello from IndexedDB!", idbMap["message"], "expected message in restored IndexedDB data") - - logger.Info("[test]", "result", "IndexedDB persistence test PASSED") -} - -// getCookiesViaPlaywright navigates to a URL and returns the cookies as a map -func getCookiesViaPlaywright(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses, url string, logger *slog.Logger) map[string]string { - code := fmt.Sprintf(` - await page.goto('%s'); - const content = await page.textContent('body'); - return content; - `, url) - - req := instanceoapi.ExecutePlaywrightCodeJSONRequestBody{Code: code} - rsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, req) - require.NoError(t, err, "playwright execute request error") - require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status: %s body=%s", rsp.Status(), string(rsp.Body)) - require.NotNil(t, rsp.JSON200, "expected JSON200 response") - - if !rsp.JSON200.Success { - var errorMsg string - if rsp.JSON200.Error != nil { - errorMsg = *rsp.JSON200.Error - } - t.Fatalf("playwright execution failed: %s", errorMsg) - } - - // Parse the JSON result - resultStr, ok := rsp.JSON200.Result.(string) - if !ok { - // Try to marshal and unmarshal - resultBytes, _ := json.Marshal(rsp.JSON200.Result) - resultStr = string(resultBytes) - // Remove quotes if present - resultStr = strings.Trim(resultStr, "\"") - } - - logger.Info("[playwright]", "raw_result", resultStr) - - var cookies map[string]string - if err := json.Unmarshal([]byte(resultStr), &cookies); err != nil { - // If it's not valid JSON, return empty - logger.Info("[playwright]", "parse_error", err.Error()) - return make(map[string]string) - } - - return cookies -} - -// navigateAndGetResult navigates to a URL and returns the page content -func navigateAndGetResult(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses, url string, logger *slog.Logger) string { - code := fmt.Sprintf(` - await page.goto('%s'); - const content = await page.textContent('body'); - return content; - `, url) - - req := instanceoapi.ExecutePlaywrightCodeJSONRequestBody{Code: code} - rsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, req) - require.NoError(t, err, "playwright execute request error") - require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status: %s body=%s", rsp.Status(), string(rsp.Body)) - require.NotNil(t, rsp.JSON200, "expected JSON200 response") - require.True(t, rsp.JSON200.Success, "expected success=true") - - resultStr, ok := rsp.JSON200.Result.(string) - if !ok { - resultBytes, _ := json.Marshal(rsp.JSON200.Result) - resultStr = string(resultBytes) - } - - return resultStr -} - -// getIndexedDBViaPlaywright navigates to a page and reads IndexedDB data -func getIndexedDBViaPlaywright(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses, url string, logger *slog.Logger) interface{} { - // Navigate to the page and read IndexedDB directly via page.evaluate - code := fmt.Sprintf(` - await page.goto('%s', { waitUntil: 'domcontentloaded' }); - - // Directly read IndexedDB in the page context - const result = await page.evaluate(async () => { - return new Promise((resolve) => { - const dbName = 'testPersistDB'; - const storeName = 'testStore'; - const testKey = 'testKey'; - - const request = indexedDB.open(dbName, 1); - - request.onupgradeneeded = function(event) { - // If we need to upgrade, the data doesn't exist - event.target.transaction.abort(); - resolve(null); - }; - - request.onsuccess = function(event) { - const db = event.target.result; - - if (!db.objectStoreNames.contains(storeName)) { - db.close(); - resolve(null); - return; - } - - const transaction = db.transaction([storeName], 'readonly'); - const store = transaction.objectStore(storeName); - const getRequest = store.get(testKey); - - getRequest.onsuccess = function() { - db.close(); - resolve(getRequest.result || null); - }; - - getRequest.onerror = function() { - db.close(); - resolve(null); - }; - }; - - request.onerror = function() { - resolve(null); - }; - - // Timeout after 5 seconds - setTimeout(() => { - resolve(null); - }, 5000); - }); - }); - - return result; - `, url) - - req := instanceoapi.ExecutePlaywrightCodeJSONRequestBody{Code: code} - rsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, req) - require.NoError(t, err, "playwright execute request error") - require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status: %s body=%s", rsp.Status(), string(rsp.Body)) - require.NotNil(t, rsp.JSON200, "expected JSON200 response") - - if !rsp.JSON200.Success { - var errMsg string - if rsp.JSON200.Error != nil { - errMsg = *rsp.JSON200.Error - } - logger.Info("[getIndexedDB]", "error", errMsg) - } - - require.True(t, rsp.JSON200.Success, "expected success=true") - - logger.Info("[getIndexedDB]", "result", rsp.JSON200.Result) - return rsp.JSON200.Result -} - -// setIndexedDBViaPlaywright navigates to the IndexedDB set page and waits for completion -func setIndexedDBViaPlaywright(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses, url string, logger *slog.Logger) { - // Navigate to the page and set IndexedDB directly via page.evaluate - // Use a unique timestamp-based version to ensure onupgradeneeded is called - code := fmt.Sprintf(` - await page.goto('%s', { waitUntil: 'domcontentloaded' }); - - // Check if IndexedDB is available - const idbAvailable = await page.evaluate(() => !!window.indexedDB); - if (!idbAvailable) { - return { success: false, error: 'IndexedDB not available' }; - } - - // Directly execute IndexedDB operations in the page context - const result = await page.evaluate(() => { - return new Promise((resolve) => { - const dbName = 'testPersistDB'; - const storeName = 'testStore'; - const testKey = 'testKey'; - const testValue = { message: 'Hello from IndexedDB!', timestamp: Date.now() }; - - // First, delete any existing database to ensure clean state - const deleteRequest = indexedDB.deleteDatabase(dbName); - - deleteRequest.onsuccess = function() { - // Now create fresh database with object store - const openRequest = indexedDB.open(dbName, 1); - - openRequest.onupgradeneeded = function(event) { - try { - const db = event.target.result; - db.createObjectStore(storeName); - } catch (e) { - resolve({ success: false, error: 'onupgradeneeded error: ' + e.toString() }); - } - }; - - openRequest.onsuccess = function(event) { - try { - const db = event.target.result; - const transaction = db.transaction([storeName], 'readwrite'); - const store = transaction.objectStore(storeName); - const putRequest = store.put(testValue, testKey); - - putRequest.onsuccess = function() { - db.close(); - resolve({ success: true, message: 'IndexedDB put succeeded' }); - }; - - putRequest.onerror = function(e) { - db.close(); - resolve({ success: false, error: 'Put error: ' + (e.target?.error?.toString() || 'unknown') }); - }; - } catch (e) { - resolve({ success: false, error: 'onsuccess error: ' + e.toString() }); - } - }; - - openRequest.onerror = function(event) { - resolve({ success: false, error: 'Open error: ' + (event.target?.error?.toString() || 'unknown') }); - }; - }; - - deleteRequest.onerror = function() { - resolve({ success: false, error: 'Delete error' }); - }; - - // Timeout after 5 seconds - setTimeout(() => { - resolve({ success: false, error: 'Timeout waiting for IndexedDB' }); - }, 5000); - }); - }); - - return result; - `, url) - - req := instanceoapi.ExecutePlaywrightCodeJSONRequestBody{Code: code} - rsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, req) - require.NoError(t, err, "playwright execute request error") - require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status: %s body=%s", rsp.Status(), string(rsp.Body)) - require.NotNil(t, rsp.JSON200, "expected JSON200 response") - - if !rsp.JSON200.Success { - var errMsg string - if rsp.JSON200.Error != nil { - errMsg = *rsp.JSON200.Error - } - var stderr string - if rsp.JSON200.Stderr != nil { - stderr = *rsp.JSON200.Stderr - } - logger.Info("[setIndexedDB]", "error", errMsg, "stderr", stderr) - require.True(t, rsp.JSON200.Success, "expected success=true, error: %s", errMsg) - } - - logger.Info("[setIndexedDB]", "result", rsp.JSON200.Result) - - // The result should be an object with success and message - if resultMap, ok := rsp.JSON200.Result.(map[string]interface{}); ok { - if success, ok := resultMap["success"].(bool); ok { - require.True(t, success, "expected IndexedDB set to succeed, got error: %v", resultMap["error"]) - } - } -} - -// downloadUserDataDir downloads the user-data directory as a zip -func downloadUserDataDir(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses, logger *slog.Logger) []byte { - params := &instanceoapi.DownloadDirZipParams{ - Path: "/home/kernel/user-data", - } - - rsp, err := client.DownloadDirZipWithResponse(ctx, params) - require.NoError(t, err, "download dir zip request error") - require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status: %s", rsp.Status()) - - logger.Info("[download]", "size_bytes", len(rsp.Body)) - return rsp.Body -} - -// logZipContents logs the contents of a zip file for debugging -func logZipContents(t *testing.T, zipData []byte, logger *slog.Logger) { - reader, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData))) - if err != nil { - logger.Info("[zip]", "error", "failed to read zip", "err", err.Error()) - return - } - - var files []string - for _, f := range reader.File { - files = append(files, f.Name) - } - - logger.Info("[zip]", "contents", strings.Join(files, ", ")) -} - -// restoreUserDataDir uploads and extracts user-data directory from a zip -func restoreUserDataDir(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses, zipData []byte, logger *slog.Logger) { - // First, we need to extract the zip and upload files individually - // The API has WriteFile but not a direct "upload zip and extract" endpoint - // We'll use ProcessExec to extract after uploading - - // Upload the zip file to a temp location - zipPath := "/tmp/user-data-restore.zip" - params := &instanceoapi.WriteFileParams{ - Path: zipPath, - } - - rsp, err := client.WriteFileWithBodyWithResponse(ctx, params, "application/octet-stream", bytes.NewReader(zipData)) - require.NoError(t, err, "write file request error") - require.Equal(t, http.StatusCreated, rsp.StatusCode(), "unexpected status: %s body=%s", rsp.Status(), string(rsp.Body)) - - logger.Info("[restore]", "action", "uploaded zip", "path", zipPath) - - // Extract the zip using unzip command - args := []string{"-o", zipPath, "-d", "/home/kernel/user-data"} - req := instanceoapi.ProcessExecJSONRequestBody{ - Command: "unzip", - Args: &args, - } - - execRsp, err := client.ProcessExecWithResponse(ctx, req) - require.NoError(t, err, "process exec request error") - require.Equal(t, http.StatusOK, execRsp.StatusCode(), "unexpected status: %s body=%s", execRsp.Status(), string(execRsp.Body)) - - if execRsp.JSON200.ExitCode != nil && *execRsp.JSON200.ExitCode != 0 { - var stdout, stderr string - if execRsp.JSON200.StdoutB64 != nil { - if b, decErr := base64.StdEncoding.DecodeString(*execRsp.JSON200.StdoutB64); decErr == nil { - stdout = string(b) - } - } - if execRsp.JSON200.StderrB64 != nil { - if b, decErr := base64.StdEncoding.DecodeString(*execRsp.JSON200.StderrB64); decErr == nil { - stderr = string(b) - } - } - require.Fail(t, "unzip failed", "exit_code=%d stdout=%s stderr=%s", *execRsp.JSON200.ExitCode, stdout, stderr) - } - - logger.Info("[restore]", "action", "extracted zip to user-data") - - // Remove lock files that prevent Chromium from starting with restored profile - lockFiles := []string{ - "/home/kernel/user-data/SingletonLock", - "/home/kernel/user-data/SingletonSocket", - "/home/kernel/user-data/SingletonCookie", - } - for _, lockFile := range lockFiles { - rmArgs := []string{"-f", lockFile} - rmReq := instanceoapi.ProcessExecJSONRequestBody{ - Command: "rm", - Args: &rmArgs, - } - _, _ = client.ProcessExecWithResponse(ctx, rmReq) - } - logger.Info("[restore]", "action", "removed lock files") - - // Fix permissions - chownArgs := []string{"-R", "kernel:kernel", "/home/kernel/user-data"} - chownReq := instanceoapi.ProcessExecJSONRequestBody{ - Command: "chown", - Args: &chownArgs, - } - _, _ = client.ProcessExecWithResponse(ctx, chownReq) - - logger.Info("[restore]", "action", "fixed permissions") -} - -// restartChromium restarts Chromium via supervisorctl and waits for it to be ready -func restartChromium(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses, logger *slog.Logger) { - args := []string{"-c", "/etc/supervisor/supervisord.conf", "restart", "chromium"} - req := instanceoapi.ProcessExecJSONRequestBody{ - Command: "supervisorctl", - Args: &args, - } - - rsp, err := client.ProcessExecWithResponse(ctx, req) - require.NoError(t, err, "supervisorctl restart request error") - require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status: %s body=%s", rsp.Status(), string(rsp.Body)) - - logger.Info("[restart]", "action", "chromium restarted via supervisorctl") - - // Wait for CDP endpoint to be ready again by checking the internal CDP endpoint - logger.Info("[restart]", "action", "waiting for CDP endpoint to be ready") - for i := 0; i < 30; i++ { - // Use curl to check the CDP endpoint and capture the HTTP status code - checkArgs := []string{"-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:9223/json/version"} - checkReq := instanceoapi.ProcessExecJSONRequestBody{ - Command: "curl", - Args: &checkArgs, - } - checkRsp, err := client.ProcessExecWithResponse(ctx, checkReq) - if err == nil && checkRsp.JSON200 != nil && checkRsp.JSON200.ExitCode != nil && *checkRsp.JSON200.ExitCode == 0 { - // Decode stdout to get the HTTP status code - if checkRsp.JSON200.StdoutB64 != nil { - if b, decErr := base64.StdEncoding.DecodeString(*checkRsp.JSON200.StdoutB64); decErr == nil { - httpCode := strings.TrimSpace(string(b)) - if httpCode == "200" { - logger.Info("[restart]", "action", "CDP endpoint is ready", "http_code", httpCode) - return - } - logger.Info("[restart]", "action", "CDP endpoint returned non-200", "http_code", httpCode) - } - } - } - time.Sleep(500 * time.Millisecond) - } - - require.Fail(t, "Chromium restart timed out", "CDP endpoint did not become ready after 15 seconds") -}