diff --git a/server/cmd/chromium-launcher/main.go b/server/cmd/chromium-launcher/main.go index f0a8cd50..e606d322 100644 --- a/server/cmd/chromium-launcher/main.go +++ b/server/cmd/chromium-launcher/main.go @@ -27,6 +27,12 @@ func main() { _ = os.Remove("/home/kernel/user-data/SingletonSocket") _ = os.Remove("/home/kernel/user-data/SingletonCookie") + // Kill any existing chromium processes to ensure clean restart. + // This is necessary because supervisord's stopwaitsecs=0 doesn't wait for + // the old process to fully die before starting the new one, which can cause + // the new process to fall back to IPv6 while the old one holds IPv4. + killExistingChromium() + // Inputs internalPort := strings.TrimSpace(os.Getenv("INTERNAL_PORT")) if internalPort == "" { @@ -158,3 +164,26 @@ func waitForPort(port string, timeout time.Duration) { } // Timeout reached, proceed anyway and let chromium report the error } + +// killExistingChromium kills any existing chromium browser processes and waits for them to die. +// This ensures a clean restart where the new process can bind to both IPv4 and IPv6. +// Note: We use -x for exact match to avoid killing chromium-launcher itself. +func killExistingChromium() { + // Kill chromium processes by exact name match. + // Using -x prevents matching "chromium-launcher" which would kill this process. + _ = exec.Command("pkill", "-9", "-x", "chromium").Run() + + // Wait up to 2 seconds for processes to fully terminate + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + // Check if any chromium browser processes are still running (exact match) + output, err := exec.Command("pgrep", "-x", "chromium").Output() + if err != nil || len(strings.TrimSpace(string(output))) == 0 { + // No processes found, we're done + return + } + time.Sleep(100 * time.Millisecond) + } + // Timeout - processes may still exist but we continue anyway + fmt.Fprintf(os.Stderr, "warning: chromium processes may still be running after kill attempt\n") +} diff --git a/server/e2e/e2e_combined_flow_test.go b/server/e2e/e2e_combined_flow_test.go new file mode 100644 index 00000000..69852085 --- /dev/null +++ b/server/e2e/e2e_combined_flow_test.go @@ -0,0 +1,336 @@ +package e2e + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "mime/multipart" + "net/http" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/coder/websocket" + logctx "github.com/onkernel/kernel-images/server/lib/logger" + instanceoapi "github.com/onkernel/kernel-images/server/lib/oapi" + "github.com/stretchr/testify/require" +) + +// TestExtensionViewportThenCDPConnection tests that CDP connections work correctly +// after back-to-back Chromium restarts triggered by extension upload and viewport change. +// +// This reproduces the race condition where profile loading fails to connect to CDP +// after the sequence: extension upload (restart) -> viewport change (restart) -> CDP connect. +func TestExtensionViewportThenCDPConnection(t *testing.T) { + image := headlessImage + name := containerName + "-combined-flow" + + 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) + } + + // Clean slate + _ = stopContainer(baseCtx, name) + + // Start with specific resolution to verify viewport change works + 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) + 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") + + // Wait for DevTools to be ready initially + _, err = waitDevtoolsWS(ctx) + require.NoError(t, err, "devtools not ready initially") + + client, err := apiClient() + require.NoError(t, err, "failed to create API client") + + // Step 1: Upload extension (triggers Chromium restart) + logger.Info("[test]", "step", 1, "action", "uploading extension") + uploadExtension(t, ctx, client, logger) + + // Wait briefly for the system to stabilize after extension upload restart + // The extension upload waits for DevTools, but the API may need a moment + logger.Info("[test]", "action", "verifying API is still responsive after extension upload") + err = waitForAPIHealth(ctx, logger) + require.NoError(t, err, "API not healthy after extension upload") + + // Create a fresh API client to avoid connection reuse issues after restart + // The previous client's connection may have been closed by the server + client, err = apiClientNoKeepAlive() + require.NoError(t, err, "failed to create fresh API client") + + // Step 2: Change viewport (triggers another Chromium restart) + logger.Info("[test]", "step", 2, "action", "changing viewport to 1920x1080") + changeViewport(t, ctx, client, 1920, 1080, logger) + + // Wait for API to be healthy after viewport change + logger.Info("[test]", "action", "verifying API is still responsive after viewport change") + err = waitForAPIHealth(ctx, logger) + require.NoError(t, err, "API not healthy after viewport change") + + // Step 3: Immediately attempt CDP connection (this may fail due to race condition) + logger.Info("[test]", "step", 3, "action", "attempting CDP connection immediately after restarts") + + // Try connecting without any delay - this is the most aggressive test case + err = attemptCDPConnection(ctx, logger) + if err != nil { + logger.Error("[test]", "step", 3, "result", "CDP connection failed", "error", err.Error()) + // Log additional diagnostics + logCDPDiagnostics(ctx, logger) + } + require.NoError(t, err, "CDP connection failed after extension upload + viewport change") + + logger.Info("[test]", "result", "CDP connection successful after back-to-back restarts") +} + +// TestMultipleCDPConnectionsAfterRestart tests that multiple rapid CDP connections +// work correctly after Chromium restart. +func TestMultipleCDPConnectionsAfterRestart(t *testing.T) { + image := headlessImage + name := containerName + "-multi-cdp" + + 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) + } + + // 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) + defer cancel() + + logger.Info("[setup]", "action", "waiting for API") + require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready") + + _, err = waitDevtoolsWS(ctx) + require.NoError(t, err, "devtools not ready initially") + + client, err := apiClient() + require.NoError(t, err, "failed to create API client") + + // Upload extension to trigger a restart + logger.Info("[test]", "action", "uploading extension to trigger restart") + uploadExtension(t, ctx, client, logger) + + // Rapidly attempt multiple CDP connections in sequence + logger.Info("[test]", "action", "attempting 5 rapid CDP connections") + for i := 1; i <= 5; i++ { + logger.Info("[test]", "connection_attempt", i) + err := attemptCDPConnection(ctx, logger) + require.NoError(t, err, "CDP connection %d failed", i) + logger.Info("[test]", "connection_attempt", i, "result", "success") + } + + logger.Info("[test]", "result", "all CDP connections successful") +} + +// uploadExtension uploads a simple MV3 extension and waits for Chromium to restart. +func uploadExtension(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses, logger *slog.Logger) { + t.Helper() + + // Build simple MV3 extension zip in-memory + extDir := t.TempDir() + manifest := `{ + "manifest_version": 3, + "version": "1.0", + "name": "Test Extension for Combined Flow", + "description": "Minimal extension for testing CDP connections after restart" +}` + err := os.WriteFile(filepath.Join(extDir, "manifest.json"), []byte(manifest), 0600) + require.NoError(t, err, "write manifest") + + extZip, err := zipDirToBytes(extDir) + require.NoError(t, err, "zip ext") + + // Upload extension + var body bytes.Buffer + w := multipart.NewWriter(&body) + fw, err := w.CreateFormFile("extensions.zip_file", "ext.zip") + require.NoError(t, err) + _, err = io.Copy(fw, bytes.NewReader(extZip)) + require.NoError(t, err) + err = w.WriteField("extensions.name", "combined-flow-test-ext") + require.NoError(t, err) + err = w.Close() + require.NoError(t, err) + + start := time.Now() + rsp, err := client.UploadExtensionsAndRestartWithBodyWithResponse(ctx, w.FormDataContentType(), &body) + elapsed := time.Since(start) + 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("[extension]", "action", "uploaded", "elapsed", elapsed.String()) +} + +// changeViewport changes the display resolution, which triggers Chromium restart. +func changeViewport(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses, width, height int, logger *slog.Logger) { + t.Helper() + + req := instanceoapi.PatchDisplayJSONRequestBody{ + Width: &width, + Height: &height, + } + start := time.Now() + rsp, err := client.PatchDisplayWithResponse(ctx, req) + elapsed := time.Since(start) + require.NoError(t, err, "PATCH /display request failed") + 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") + logger.Info("[viewport]", "action", "changed", "width", width, "height", height, "elapsed", elapsed.String()) +} + +// attemptCDPConnection tries to establish a CDP WebSocket connection and run a simple command. +func attemptCDPConnection(ctx context.Context, logger *slog.Logger) error { + wsURL := "ws://127.0.0.1:9222/" + + // Set a timeout for the connection attempt + connCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + logger.Info("[cdp]", "action", "connecting", "url", wsURL) + + // Establish WebSocket connection to CDP proxy + conn, _, err := websocket.Dial(connCtx, wsURL, nil) + if err != nil { + return fmt.Errorf("failed to dial CDP WebSocket: %w", err) + } + defer conn.Close(websocket.StatusNormalClosure, "") + + logger.Info("[cdp]", "action", "connected", "url", wsURL) + + // Send a simple CDP command: Browser.getVersion + // This validates that the proxy can communicate with the browser + cdpRequest := map[string]any{ + "id": 1, + "method": "Browser.getVersion", + } + reqBytes, err := json.Marshal(cdpRequest) + if err != nil { + return fmt.Errorf("failed to marshal CDP request: %w", err) + } + + logger.Info("[cdp]", "action", "sending Browser.getVersion") + + if err := conn.Write(connCtx, websocket.MessageText, reqBytes); err != nil { + return fmt.Errorf("failed to send CDP command: %w", err) + } + + // Read response + _, respBytes, err := conn.Read(connCtx) + if err != nil { + return fmt.Errorf("failed to read CDP response: %w", err) + } + + var cdpResponse map[string]any + if err := json.Unmarshal(respBytes, &cdpResponse); err != nil { + return fmt.Errorf("failed to unmarshal CDP response: %w", err) + } + + // Check for error in response + if errField, ok := cdpResponse["error"]; ok { + return fmt.Errorf("CDP command returned error: %v", errField) + } + + // Verify we got a result + result, ok := cdpResponse["result"].(map[string]any) + if !ok { + return fmt.Errorf("CDP response missing result field: %v", cdpResponse) + } + + // Log some version info for debugging + if product, ok := result["product"].(string); ok { + logger.Info("[cdp]", "action", "version received", "product", product) + } + + logger.Info("[cdp]", "action", "command successful") + return nil +} + +// apiClientNoKeepAlive creates an API client that doesn't reuse connections. +// This is useful after server restarts where existing connections may be stale. +func apiClientNoKeepAlive() (*instanceoapi.ClientWithResponses, error) { + transport := &http.Transport{ + DisableKeepAlives: true, + } + httpClient := &http.Client{Transport: transport} + return instanceoapi.NewClientWithResponses(apiBaseURL, instanceoapi.WithHTTPClient(httpClient)) +} + +// waitForAPIHealth waits until the API server is responsive. +func waitForAPIHealth(ctx context.Context, logger *slog.Logger) error { + client := &http.Client{Timeout: 5 * time.Second} + maxAttempts := 30 + for i := 0; i < maxAttempts; i++ { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, apiBaseURL+"/spec.yaml", nil) + resp, err := client.Do(req) + if err == nil && resp.StatusCode == http.StatusOK { + resp.Body.Close() + logger.Info("[health]", "action", "API healthy", "attempts", i+1) + return nil + } + if resp != nil && resp.Body != nil { + resp.Body.Close() + } + if i < maxAttempts-1 { + time.Sleep(500 * time.Millisecond) + } + } + return fmt.Errorf("API not healthy after %d attempts", maxAttempts) +} + +// logCDPDiagnostics logs diagnostic information when CDP connection fails. +func logCDPDiagnostics(ctx context.Context, logger *slog.Logger) { + // 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"}) + if err != nil { + logger.Info("[diagnostics]", "internal_cdp_status", "failed", "error", err.Error()) + } else { + logger.Info("[diagnostics]", "internal_cdp_status", stdout) + } + + // Check if Chromium process is running + psOutput, err := execCombinedOutput(ctx, "pgrep", []string{"-a", "chromium"}) + if err != nil { + logger.Info("[diagnostics]", "chromium_process", "not found or error", "error", err.Error()) + } else { + logger.Info("[diagnostics]", "chromium_process", psOutput) + } + + // Check supervisord status + supervisorOutput, err := execCombinedOutput(ctx, "supervisorctl", []string{"-c", "/etc/supervisor/supervisord.conf", "status"}) + if err != nil { + logger.Info("[diagnostics]", "supervisor_status", "error", "error", err.Error()) + } else { + logger.Info("[diagnostics]", "supervisor_status", supervisorOutput) + } +} diff --git a/server/e2e/e2e_mv3_service_worker_test.go b/server/e2e/e2e_mv3_service_worker_test.go new file mode 100644 index 00000000..38491361 --- /dev/null +++ b/server/e2e/e2e_mv3_service_worker_test.go @@ -0,0 +1,122 @@ +package e2e + +import ( + "bytes" + "context" + "io" + "log/slog" + "mime/multipart" + "net/http" + "os/exec" + "path/filepath" + "testing" + "time" + + logctx "github.com/onkernel/kernel-images/server/lib/logger" + "github.com/stretchr/testify/require" +) + +// TestMV3ServiceWorkerRegistration tests that MV3 extensions with service workers +// are properly loaded and their service workers are active and responsive. +// +// This test verifies: +// 1. Extension can be uploaded and Chromium restarts successfully +// 2. Extension appears in chrome://extensions with an active service worker +// 3. Service worker responds to messages from the popup +func TestMV3ServiceWorkerRegistration(t *testing.T) { + ensurePlaywrightDeps(t) + + image := headlessImage + name := containerName + "-mv3-sw" + + 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) + } + + // 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) + 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") + + // Wait for DevTools to be ready + _, err = waitDevtoolsWS(ctx) + require.NoError(t, err, "devtools not ready") + + // Upload the MV3 test extension + logger.Info("[test]", "action", "uploading MV3 service worker test extension") + uploadMV3TestExtension(t, ctx, logger) + + // Run playwright script to verify service worker + logger.Info("[test]", "action", "verifying MV3 service worker via playwright") + verifyMV3ServiceWorker(t, ctx, logger) + + logger.Info("[test]", "result", "MV3 service worker test passed") +} + +// uploadMV3TestExtension uploads the test extension from test-extension directory. +func uploadMV3TestExtension(t *testing.T, ctx context.Context, logger *slog.Logger) { + t.Helper() + + client, err := apiClient() + require.NoError(t, err, "failed to create API client") + + // Get the path to the test extension + // The test extension is in server/e2e/test-extension + extDir, err := filepath.Abs("test-extension") + require.NoError(t, err, "failed to get absolute path to test-extension") + + // Create zip of the extension + extZip, err := zipDirToBytes(extDir) + require.NoError(t, err, "failed to zip test extension") + + // Upload extension + var body bytes.Buffer + w := multipart.NewWriter(&body) + fw, err := w.CreateFormFile("extensions.zip_file", "mv3-test-ext.zip") + require.NoError(t, err) + _, err = io.Copy(fw, bytes.NewReader(extZip)) + require.NoError(t, err) + err = w.WriteField("extensions.name", "mv3-service-worker-test") + require.NoError(t, err) + err = w.Close() + require.NoError(t, err) + + start := time.Now() + rsp, err := client.UploadExtensionsAndRestartWithBodyWithResponse(ctx, w.FormDataContentType(), &body) + elapsed := time.Since(start) + 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("[extension]", "action", "uploaded", "elapsed", elapsed.String()) +} + +// verifyMV3ServiceWorker runs the playwright script to verify the service worker. +func verifyMV3ServiceWorker(t *testing.T, ctx context.Context, logger *slog.Logger) { + t.Helper() + + cmd := exec.CommandContext(ctx, "pnpm", "exec", "tsx", "index.ts", + "verify-mv3-service-worker", + "--ws-url", "ws://127.0.0.1:9222/", + "--timeout", "60000", + ) + cmd.Dir = getPlaywrightPath() + out, err := cmd.CombinedOutput() + if err != nil { + logger.Error("[playwright]", "output", string(out)) + } + require.NoError(t, err, "MV3 service worker verification failed: %v\noutput=%s", err, string(out)) + logger.Info("[playwright]", "output", string(out)) +} diff --git a/server/e2e/playwright/index.ts b/server/e2e/playwright/index.ts index b36a6571..93fcd72a 100644 --- a/server/e2e/playwright/index.ts +++ b/server/e2e/playwright/index.ts @@ -313,6 +313,125 @@ class CDPClient { } } + async verifyMV3ServiceWorker(options: CommandOptions): Promise { + if (!this.page) throw new Error('Not connected to browser'); + + const { timeout = 60000 } = options; + + try { + console.log('[cdp] action: verify-mv3-service-worker'); + this.page.setDefaultTimeout(timeout); + + // Step 1: Navigate to chrome://extensions + console.log('[cdp] navigating to chrome://extensions'); + await this.page.goto('chrome://extensions'); + await this.page.waitForTimeout(2000); + + // Step 2: Enable developer mode by clicking the toggle + console.log('[cdp] enabling developer mode'); + const devMode = this.page.getByRole('button', { name: 'Developer mode' }); + await devMode.click(); + await this.page.waitForTimeout(1000); + + // Step 3: Find the extension and extract the ID + // chrome://extensions uses shadow DOM, so we need to use evaluate to pierce it + console.log('[cdp] checking for MV3 Service Worker Test extension'); + + const extensionInfo = await this.page.evaluate(() => { + // Get the extensions-manager element + const manager = document.querySelector('extensions-manager'); + if (!manager || !manager.shadowRoot) return null; + + // Get the item list + const itemList = manager.shadowRoot.querySelector('extensions-item-list'); + if (!itemList || !itemList.shadowRoot) return null; + + // Find all extension items + const items = itemList.shadowRoot.querySelectorAll('extensions-item'); + + for (const item of items) { + if (!item.shadowRoot) continue; + + // Get the extension name + const nameEl = item.shadowRoot.querySelector('#name'); + const name = nameEl?.textContent?.trim() || ''; + + if (name === 'MV3 Service Worker Test') { + // Get the extension ID from the item's id attribute + const id = item.getAttribute('id'); + + // Check if service worker is inactive + const inspectViews = item.shadowRoot.querySelector('#inspect-views'); + const isInactive = inspectViews?.textContent?.includes('(Inactive)') || false; + + // Check if service worker link exists + const hasServiceWorker = inspectViews?.textContent?.includes('service worker') || false; + + return { id, name, isInactive, hasServiceWorker }; + } + } + + return null; + }); + + if (!extensionInfo) { + await this.captureScreenshot({ filename: 'mv3-extension-not-found.png' }); + throw new Error('MV3 Service Worker Test extension not found on chrome://extensions'); + } + + console.log(`[cdp] found extension: ${extensionInfo.name} (ID: ${extensionInfo.id})`); + console.log(`[cdp] has service worker: ${extensionInfo.hasServiceWorker}, inactive: ${extensionInfo.isInactive}`); + + if (!extensionInfo.hasServiceWorker) { + await this.captureScreenshot({ filename: 'mv3-no-service-worker.png' }); + throw new Error('Extension does not have a service worker registered'); + } + + if (extensionInfo.isInactive) { + await this.captureScreenshot({ filename: 'mv3-service-worker-inactive.png' }); + throw new Error('Service worker is marked as (Inactive)'); + } + + console.log('[cdp] service worker is active'); + + // Step 4: Navigate to the extension's popup + const extensionId = extensionInfo.id; + const popupUrl = `chrome-extension://${extensionId}/popup.html`; + console.log(`[cdp] navigating to popup: ${popupUrl}`); + await this.page.goto(popupUrl); + await this.page.waitForTimeout(1000); + + // Step 5: Click the "Ping Service Worker" button + console.log('[cdp] clicking Ping Service Worker button'); + const pingButton = this.page.getByRole('button', { name: 'Ping Service Worker' }); + await pingButton.click(); + await this.page.waitForTimeout(2000); + + // Step 6: Verify the status shows success + const statusElement = this.page.locator('#status'); + const statusText = await statusElement.textContent(); + console.log(`[cdp] status text: ${statusText}`); + + if (!statusText || !statusText.includes('SUCCESS')) { + await this.captureScreenshot({ filename: 'mv3-ping-failed.png' }); + throw new Error(`Expected status to show SUCCESS, got: ${statusText}`); + } + + if (!statusText.includes('Service worker is alive')) { + await this.captureScreenshot({ filename: 'mv3-wrong-message.png' }); + throw new Error(`Expected status to include "Service worker is alive", got: ${statusText}`); + } + + console.log('[cdp] MV3 service worker verification successful!'); + await this.captureScreenshot({ filename: 'mv3-success.png' }); + + } catch (error) { + console.error('[cdp] MV3 service worker verification failed:', error); + await this.captureScreenshot({ filename: 'mv3-verification-error.png' }).catch(console.error); + throw error; + } + } + async disconnect(): Promise { // Note: We don't close the browser since it's an existing instance // We just disconnect from it @@ -426,6 +545,14 @@ async function main(): Promise { break; } + case 'verify-mv3-service-worker': { + await client.verifyMV3ServiceWorker({ + wsURL, + timeout: options.timeout ? parseInt(options.timeout, 10) : undefined, + }); + break; + } + default: throw new Error(`Unknown command: ${command}`); } diff --git a/server/e2e/playwright/tsconfig.json b/server/e2e/playwright/tsconfig.json index b1d823c5..8342950a 100644 --- a/server/e2e/playwright/tsconfig.json +++ b/server/e2e/playwright/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES2022", "module": "commonjs", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, diff --git a/server/e2e/test-extension/manifest.json b/server/e2e/test-extension/manifest.json new file mode 100644 index 00000000..95f4f830 --- /dev/null +++ b/server/e2e/test-extension/manifest.json @@ -0,0 +1,14 @@ +{ + "manifest_version": 3, + "name": "MV3 Service Worker Test", + "version": "1.0.0", + "description": "Test extension to verify MV3 service worker functionality", + "background": { + "service_worker": "service-worker.js" + }, + "permissions": ["storage"], + "action": { + "default_popup": "popup.html", + "default_title": "MV3 Test" + } +} diff --git a/server/e2e/test-extension/popup.html b/server/e2e/test-extension/popup.html new file mode 100644 index 00000000..6b15cdc7 --- /dev/null +++ b/server/e2e/test-extension/popup.html @@ -0,0 +1,18 @@ + + + + + + +

MV3 Service Worker Test

+ +
Click button to test service worker
+ + + diff --git a/server/e2e/test-extension/popup.js b/server/e2e/test-extension/popup.js new file mode 100644 index 00000000..5fdc0fba --- /dev/null +++ b/server/e2e/test-extension/popup.js @@ -0,0 +1,18 @@ +document.getElementById('pingBtn').addEventListener('click', async () => { + const statusEl = document.getElementById('status'); + statusEl.textContent = 'Sending ping to service worker...'; + statusEl.className = ''; + try { + const response = await chrome.runtime.sendMessage({ type: 'ping' }); + if (response) { + statusEl.textContent = `SUCCESS: ${response.message} (timestamp: ${response.timestamp})`; + statusEl.className = 'success'; + } else { + statusEl.textContent = 'ERROR: No response from service worker'; + statusEl.className = 'error'; + } + } catch (error) { + statusEl.textContent = `ERROR: ${error.message}`; + statusEl.className = 'error'; + } +}); diff --git a/server/e2e/test-extension/service-worker.js b/server/e2e/test-extension/service-worker.js new file mode 100644 index 00000000..883c201a --- /dev/null +++ b/server/e2e/test-extension/service-worker.js @@ -0,0 +1,17 @@ +// MV3 Service Worker Test +console.log('[MV3 Test] Service worker starting...'); + +chrome.runtime.onInstalled.addListener((details) => { + console.log('[MV3 Test] Extension installed:', details.reason); + chrome.storage.local.set({ installTime: Date.now(), reason: details.reason }); +}); + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + console.log('[MV3 Test] Received message:', message); + if (message.type === 'ping') { + sendResponse({ status: 'pong', timestamp: Date.now(), message: 'Service worker is alive!' }); + } + return true; +}); + +console.log('[MV3 Test] Service worker initialized at', new Date().toISOString());