From a4eb7a530dad4a2ecd8083372c03e4c2efb14842 Mon Sep 17 00:00:00 2001 From: Steve Zhang Date: Mon, 11 May 2026 23:30:37 -0400 Subject: [PATCH 1/2] fix: restore CDP relay WebSocket bridge in extension (#903) #880 removed all bridge code from the extension, breaking --connect and MCP --relay modes. Restore relay-only functionality: - Add relayPort setting (default 9877) + preferences UI - Restore CDP relay WebSocket client in offscreen document - Restore relay handlers in background.ts (auto-launch, alarm, storage) - Wire opts.port to relay.start(port) in CLI - Restore unit tests (get-relay-port, relay-port-changed) - Add relay E2E tests (35 tests) and connect mode CI test - Add explicit --relay flag to CI CLI E2E steps Closes #903 Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/test.yml | 8 +- packages/cli/e2e/test-connect-replay.js | 107 +++++++ packages/cli/src/repl.ts | 3 +- packages/extension/e2e/relay/fixtures.ts | 177 +++++++++++ packages/extension/e2e/relay/relay.spec.ts | 288 ++++++++++++++++++ packages/extension/e2e/relay/test-page.html | 22 ++ packages/extension/src/background.ts | 51 +++- packages/extension/src/offscreen/offscreen.ts | 98 ++++++ packages/extension/src/panel/lib/settings.ts | 5 +- .../src/preferences/PreferencesForm.tsx | 18 +- packages/extension/test/background.test.ts | 23 ++ 11 files changed, 784 insertions(+), 16 deletions(-) create mode 100644 packages/cli/e2e/test-connect-replay.js create mode 100644 packages/extension/e2e/relay/fixtures.ts create mode 100644 packages/extension/e2e/relay/relay.spec.ts create mode 100644 packages/extension/e2e/relay/test-page.html diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f2666f21..1153975e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -135,11 +135,15 @@ jobs: working-directory: packages/cli - name: Run CLI E2E tests (relay mode, .pw scripts) - run: node dist/playwright-repl.js --headless --replay examples/ + run: node dist/playwright-repl.js --relay --headless --replay examples/ working-directory: packages/cli - name: Run CLI E2E tests (relay mode, JS scripts) - run: node dist/playwright-repl.js --headless --replay examples-js/ + run: node dist/playwright-repl.js --relay --headless --replay examples-js/ + working-directory: packages/cli + + - name: Run CLI E2E tests (connect mode via extension) + run: node e2e/test-connect-replay.js working-directory: packages/cli extension: diff --git a/packages/cli/e2e/test-connect-replay.js b/packages/cli/e2e/test-connect-replay.js new file mode 100644 index 00000000..34b29658 --- /dev/null +++ b/packages/cli/e2e/test-connect-replay.js @@ -0,0 +1,107 @@ +#!/usr/bin/env node +/** + * E2E test for --connect --replay: spawns the CLI, launches Chrome with + * the extension, connects them via CDP relay, and verifies the CLI exits + * with code 0. + * + * Usage: node packages/cli/e2e/test-connect-replay.js + * node packages/cli/e2e/test-connect-replay.js --headed + */ + +import { chromium } from 'playwright'; +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const headed = process.argv.includes('--headed'); +const EXTENSION_PATH = path.resolve(__dirname, '../../extension/dist'); +const CLI_PATH = path.resolve(__dirname, '../dist/playwright-repl.js'); +const EXAMPLES_DIR = path.resolve(__dirname, '../examples'); +const RELAY_PORT = 19877; + +// video-start/video-stop and tracing-start/tracing-stop require chrome.tabCapture +// and chrome.debugger tracing APIs — only available inside the extension context, +// not through CDP relay. +const SKIP_FILES = ['10-video-recording.pw', '11-tracing.pw']; + +async function main() { + // Collect .pw files, excluding extension-only examples + const replayFiles = fs.readdirSync(EXAMPLES_DIR) + .filter(f => f.endsWith('.pw') && !SKIP_FILES.includes(f)) + .map(f => path.join(EXAMPLES_DIR, f)); + + // 1. Spawn CLI — starts CDPRelayServer and waits for extension to connect + const cli = spawn('node', [ + CLI_PATH, '--connect', '--port', String(RELAY_PORT), + '--replay', ...replayFiles, + ]); + + let stdout = ''; + let stderr = ''; + cli.stdout.on('data', (chunk) => { stdout += chunk; process.stdout.write(chunk); }); + cli.stderr.on('data', (chunk) => { stderr += chunk; process.stderr.write(chunk); }); + + // Wait for CLI to start its relay server + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`CLI didn't start relay server.\nstdout: ${stdout}\nstderr: ${stderr}`)), + 15_000, + ); + const check = () => { + if (stdout.includes('CDP relay listening')) { clearTimeout(timer); resolve(undefined); } + else setTimeout(check, 100); + }; + check(); + }); + + // 2. Launch browser with extension + const context = await chromium.launchPersistentContext('', { + channel: 'chromium', + headless: !headed, + args: [ + `--disable-extensions-except=${EXTENSION_PATH}`, + `--load-extension=${EXTENSION_PATH}`, + '--no-first-run', + '--no-default-browser-check', + ], + }); + + // 3. Get extension ID from service worker + let sw = context.serviceWorkers()[0]; + if (!sw) sw = await context.waitForEvent('serviceworker'); + const extensionId = sw.url().split('/')[2]; + + // 4. Tell extension to connect to CLI's relay port + const [page] = context.pages(); + await page.goto(`chrome-extension://${extensionId}/panel/panel.html`); + await page.evaluate((p) => chrome.storage.local.set({ relayPort: p }), RELAY_PORT); + await page.goto('about:blank'); + await page.bringToFront(); + + // Small delay for chrome.tabs.query to register the active tab + await new Promise(r => setTimeout(r, 500)); + + // 5. Wait for CLI to finish replaying + const exitCode = await new Promise((resolve) => { + cli.on('close', (code) => resolve(code ?? 1)); + }); + + // 6. Cleanup (persistent context with extensions may hang on close on Windows/macOS) + const timeout = new Promise(r => setTimeout(r, 3000)); + await Promise.race([context.close(), timeout]).catch(() => {}); + + // 7. Report + if (exitCode === 0) { + console.log('\n\u2705 Connect replay test passed'); + } else { + console.error(`\n\u274C Connect replay test failed (exit code ${exitCode})`); + } + process.exit(exitCode); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/cli/src/repl.ts b/packages/cli/src/repl.ts index 92369b8e..bc1ce487 100644 --- a/packages/cli/src/repl.ts +++ b/packages/cli/src/repl.ts @@ -1207,7 +1207,8 @@ export async function startRepl(opts: ReplOpts = {}): Promise { if (opts.connect) { // Connect mode: attach to existing Chrome via extension + CDP relay relay = new CDPRelayServer(); - await relay.start(); + const relayPort = opts.port ?? (typeof opts.connect === 'number' ? opts.connect : undefined); + await relay.start(relayPort); log(`CDP relay listening on ${relay.cdpEndpoint()}`); log(`Extension endpoint: ${relay.relayEndpoint()}`); log('Waiting for extension to connect...'); diff --git a/packages/extension/e2e/relay/fixtures.ts b/packages/extension/e2e/relay/fixtures.ts new file mode 100644 index 00000000..5be8e690 --- /dev/null +++ b/packages/extension/e2e/relay/fixtures.ts @@ -0,0 +1,177 @@ +/** + * Relay E2E test fixtures. + * + * Launches Chromium directly (no extension) with a real Playwright page. + * Commands are executed via resolveCommand → AsyncFunction — same path + * as the CLI relay mode and VS Code relay mode. + * + * A local HTTP server serves test-page.html to eliminate network latency. + */ + +import { test as base, chromium, expect, type Browser, type BrowserContext, type Page } from '@playwright/test'; +import { resolveCommand, UPDATE_COMMANDS, COMMANDS, CATEGORIES } from '../../../core/dist/index.js'; +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export { expect }; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const TEST_PAGE_PATH = path.resolve(__dirname, 'test-page.html'); + +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + +type RelayContext = { + browser: Browser; + context: BrowserContext; + page: Page; + testUrl: string; +}; + +type CommandResult = { text?: string; isError?: boolean; image?: string }; + +/** + * Execute a command via relay mode — keyword or JavaScript. + * Same execution path as BrowserManager._execExpr / relayExec in CLI. + */ +async function relayRun( + command: string, + page: Page, + context: BrowserContext, + expectFn: typeof expect, +): Promise { + const trimmed = command.trim(); + + // Help commands — handled locally (not a Playwright MCP command) + if (trimmed === 'help') { + const lines = Object.entries(CATEGORIES).map(([cat, cmds]) => ` ${cat}: ${(cmds as string[]).join(', ')}`); + return { text: `Available commands:\n${lines.join('\n')}`, isError: false }; + } + if (trimmed.startsWith('help ')) { + const cmd = trimmed.slice(5).trim(); + const info = COMMANDS[cmd] as { desc?: string; usage?: string; examples?: string[] } | undefined; + if (!info) return { text: `Unknown command: "${cmd}"`, isError: true }; + const parts = [`${cmd} — ${info.desc || ''}`]; + if (info.usage) parts.push(`Usage: ${info.usage}`); + return { text: parts.join('\n'), isError: false }; + } + + // Keyword command → resolveCommand → jsExpr + const resolved = resolveCommand(trimmed); + if (resolved) { + try { + const fn = new AsyncFunction('page', 'context', 'expect', resolved.jsExpr); + const result = await fn(page, context, expectFn); + return formatResult(result); + } catch (e: unknown) { + return { text: e instanceof Error ? e.message : String(e), isError: true }; + } + } + + // JavaScript — wrap single expressions with return + const isSingleExpr = !trimmed.includes('\n') && !trimmed.replace(/;$/, '').includes(';') + && !/^(const |let |var |if |for |while |switch |try |class |function )/.test(trimmed); + const script = isSingleExpr ? `return ${trimmed.replace(/;$/, '')}` : trimmed; + try { + const fn = new AsyncFunction('page', 'context', 'expect', script); + const result = await fn(page, context, expectFn); + return formatResult(result); + } catch (e: unknown) { + return { text: e instanceof Error ? e.message : String(e), isError: true }; + } +} + +async function relayRunScript( + script: string, + language: 'pw' | 'javascript', + page: Page, + context: BrowserContext, + expectFn: typeof expect, +): Promise { + if (language === 'javascript') { + try { + const fn = new AsyncFunction('page', 'context', 'expect', script); + const result = await fn(page, context, expectFn); + return formatResult(result); + } catch (e: unknown) { + return { text: e instanceof Error ? e.message : String(e), isError: true }; + } + } + // .pw — line by line + const lines = script.split('\n').filter(l => l.trim() && !l.trim().startsWith('#')); + const results: string[] = []; + for (const line of lines) { + const r = await relayRun(line.trim(), page, context, expectFn); + const status = r.isError ? '\u2717' : '\u2713'; + results.push(`${status} ${line.trim()}${r.isError && r.text ? ` \u2014 ${r.text}` : ''}`); + if (r.isError) return { text: results.join('\n'), isError: true }; + } + return { text: results.join('\n'), isError: false }; +} + +function formatResult(value: unknown): CommandResult { + if (value === undefined || value === null) return { text: 'Done', isError: false }; + if (typeof value === 'string') { + try { + const obj = JSON.parse(value); + if (obj && typeof obj === 'object' && '__image' in obj) + return { text: '', isError: false, image: `data:${obj.mimeType};base64,${obj.__image}` }; + } catch { /* not JSON */ } + return { text: value, isError: false }; + } + if (typeof value === 'object' && value !== null && '__image' in value) { + const img = value as { __image: string; mimeType: string }; + return { text: '', isError: false, image: `data:${img.mimeType};base64,${img.__image}` }; + } + if (typeof value === 'number' || typeof value === 'boolean') return { text: String(value), isError: false }; + try { return { text: JSON.stringify(value, null, 2), isError: false }; } + catch { return { text: String(value), isError: false }; } +} + +export const test = base.extend< + { + relay: { run: (cmd: string) => Promise; runScript: (script: string, language: 'pw' | 'javascript') => Promise }; + testUrl: string; + }, + { relayContext: RelayContext } +>({ + // Worker-scoped: browser + HTTP server, reused across all tests in a worker + relayContext: [async ({}, use) => { + // Start local HTTP server for test pages + const html = fs.readFileSync(TEST_PAGE_PATH, 'utf-8'); + const httpServer = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(html); + }); + await new Promise(resolve => httpServer.listen(0, resolve)); + const httpPort = (httpServer.address() as { port: number }).port; + const testUrl = `http://localhost:${httpPort}`; + + // Launch browser directly — same as relay mode + const browser = await chromium.launch({ + headless: !process.env.HEADED, + args: ['--no-first-run', '--no-default-browser-check'], + }); + const context = await browser.newContext(); + const page = await context.newPage(); + + await use({ browser, context, page, testUrl }); + + await browser.close(); + httpServer.close(); + }, { scope: 'worker' }], + + // Test-scoped relay runner + relay: async ({ relayContext }, use) => { + const { page, context } = relayContext; + await use({ + run: (cmd: string) => relayRun(cmd, page, context, expect), + runScript: (script: string, language: 'pw' | 'javascript') => relayRunScript(script, language, page, context, expect), + }); + }, + + testUrl: async ({ relayContext }, use) => { + await use(relayContext.testUrl); + }, +}); diff --git a/packages/extension/e2e/relay/relay.spec.ts b/packages/extension/e2e/relay/relay.spec.ts new file mode 100644 index 00000000..2fd1cc0b --- /dev/null +++ b/packages/extension/e2e/relay/relay.spec.ts @@ -0,0 +1,288 @@ +/** + * Relay E2E tests — verify every command returns meaningful results via direct Playwright. + * + * Commands flow: resolveCommand → AsyncFunction('page','context','expect', jsExpr) → result. + * Same execution path as CLI relay mode and VS Code relay mode. + * + * Every test is self-contained — beforeEach navigates to a fresh page. + */ + +import { test, expect } from './fixtures.js'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +type Result = { text?: string; isError?: boolean; image?: string }; + +function expectOk(r: Result) { + expect(r.isError, `Expected OK but got error: ${r.text}`).toBeFalsy(); +} + +function expectText(r: Result, substring: string) { + expectOk(r); + expect(r.text).toContain(substring); +} + +// ─── Navigation & Page ─────────────────────────────────────────────────────── + +test.describe("Relay command tests", () => { + test.describe('Navigation & Page', () => { + test('goto navigates to URL', async ({ relay, testUrl }) => { + const r = await relay.run(`goto ${testUrl}`); + expectOk(r); + }); + + test('snapshot returns accessibility tree with refs', async ({ relay, testUrl }) => { + await relay.run(`goto ${testUrl}`); + const r = await relay.run('snapshot'); + expectOk(r); + expect(r.text).toMatch(/\[ref=e\d+\]/); + expect(r.text).toContain('todos'); + }); + + test('screenshot returns base64 image', async ({ relay, testUrl }) => { + await relay.run(`goto ${testUrl}`); + const r = await relay.run('screenshot'); + expectOk(r); + expect(r.image).toMatch(/^data:image\/(jpeg|png);base64,/); + }); + + test('go-back and go-forward navigate history', async ({ relay, testUrl }) => { + await relay.run(`goto ${testUrl}?page=1`); + await relay.run(`goto ${testUrl}?page=2`); + const r1 = await relay.run('go-back'); + expectOk(r1); + const r2 = await relay.run('go-forward'); + expectOk(r2); + }); + + test('reload reloads page', async ({ relay, testUrl }) => { + await relay.run(`goto ${testUrl}`); + const r = await relay.run('reload'); + expectOk(r); + }); + }); + + // ─── Help ─────────────────────────────────────────────────────────────────── + + test.describe('Help', () => { + test('help returns command categories', async ({ relay }) => { + const r = await relay.run('help'); + expectOk(r); + expect(r.text).toContain('Navigation'); + expect(r.text).toContain('Interaction'); + expect(r.text).toContain('Assertions'); + }); + + test('help returns command details', async ({ relay }) => { + const r = await relay.run('help click'); + expectOk(r); + expect(r.text).toContain('click'); + expect(r.text).toContain('Click'); + }); + + test('help returns error', async ({ relay }) => { + const r = await relay.run('help nonexistent_cmd'); + expect(r.isError).toBe(true); + expect(r.text).toContain('Unknown command'); + }); + }); + + // ─── Interaction ───────────────────────────────────────────────────────────── + + test.describe('Interaction', () => { + test.beforeEach(async ({ relay, testUrl }) => { + await relay.run(`goto ${testUrl}`); + }); + + test('fill types into input field', async ({ relay }) => { + const r = await relay.run('fill "What needs to be done?" "Relay todo"'); + expectOk(r); + }); + + test('press submits with Enter', async ({ relay }) => { + await relay.run('fill "What needs to be done?" "press test"'); + const r = await relay.run('press Enter'); + expectOk(r); + }); + + test('click clicks an element by text', async ({ relay }) => { + await relay.run('fill "What needs to be done?" "click me"'); + await relay.run('press Enter'); + const r = await relay.run('click "click me"'); + expectOk(r); + }); + + test('hover hovers over element', async ({ relay }) => { + await relay.run('fill "What needs to be done?" "hover me"'); + await relay.run('press Enter'); + const r = await relay.run('hover "hover me"'); + expectOk(r); + }); + + test('type types text key by key', async ({ relay }) => { + await relay.run('click "What needs to be done?"'); + const r = await relay.run('type "hello world"'); + expectOk(r); + }); + + test('eval executes JavaScript and returns result', async ({ relay }) => { + const r = await relay.run('eval document.title'); + expectText(r, 'Relay Test'); + }); + }); + + // ─── Verification ──────────────────────────────────────────────────────────── + + test.describe('Verification', () => { + test.beforeEach(async ({ relay, testUrl }) => { + await relay.run(`goto ${testUrl}`); + }); + + test('verify-text passes for visible text', async ({ relay }) => { + await relay.run('fill "What needs to be done?" "verify item"'); + await relay.run('press Enter'); + const r = await relay.run('verify-text "verify item"'); + expectOk(r); + expect(r.text).toBeTruthy(); + }); + + test('verify-no-text passes for absent text', async ({ relay }) => { + const r = await relay.run('verify-no-text "nonexistent xyz text"'); + expectOk(r); + }); + + test('verify-title passes when title matches', async ({ relay }) => { + const r = await relay.run('verify-title "Relay Test"'); + expectOk(r); + }); + + test('verify-url passes when URL matches', async ({ relay }) => { + const r = await relay.run('verify-url "localhost"'); + expectOk(r); + }); + + test('verify-element passes when element exists', async ({ relay }) => { + const r = await relay.run('verify-element heading "todos"'); + expectOk(r); + }); + + test('toMatchAriaSnapshot passes for matching snapshot', async ({ relay }) => { + const r = await relay.run(`await expect(page.locator('h1')).toMatchAriaSnapshot(\`- heading "todos" [level=1]\`)`); + expectOk(r); + }); + + test('toMatchAriaSnapshot fails for non-matching snapshot', async ({ relay }) => { + const r = await relay.run(`await expect(page.locator('h1')).toMatchAriaSnapshot(\`- heading "wrong"\`, { timeout: 1000 })`); + expect(r.isError).toBe(true); + expect(r.text).toContain('toMatchAriaSnapshot'); + }); + + test('not.toMatchAriaSnapshot passes for non-matching snapshot', async ({ relay }) => { + const r = await relay.run(`await expect(page.locator('h1')).not.toMatchAriaSnapshot(\`- heading "wrong"\`)`); + expectOk(r); + }); + + test('not.toMatchAriaSnapshot fails for matching snapshot', async ({ relay }) => { + const r = await relay.run(`await expect(page.locator('h1')).not.toMatchAriaSnapshot(\`- heading "todos" [level=1]\`, { timeout: 1000 })`); + expect(r.isError).toBe(true); + expect(r.text).toContain('toMatchAriaSnapshot'); + }); + }); + + // ─── Script execution ───────────────────────────────────────────────────── + + test.describe('Script execution', () => { + test('runScript executes multi-line pw commands with checkmarks', async ({ relay, testUrl }) => { + const script = [ + `goto ${testUrl}`, + 'fill "What needs to be done?" "Script todo"', + 'press Enter', + 'verify-text "Script todo"', + ].join('\n'); + const r = await relay.runScript(script, 'pw'); + expectOk(r); + expect(r.text).toContain('\u2713'); // ✓ checkmark + }); + + test('runScript with javascript language executes JS', async ({ relay, testUrl }) => { + await relay.run(`goto ${testUrl}`); + const r = await relay.runScript('return await page.title()', 'javascript'); + expectOk(r); + expect(r.text).toContain('Relay Test'); + }); + }); + + // ─── JavaScript expressions ───────────────────────────────────────────────── + + test.describe('JavaScript expressions', () => { + test.beforeEach(async ({ relay, testUrl }) => { + await relay.run(`goto ${testUrl}`); + }); + + test('page.title() returns title string', async ({ relay }) => { + const r = await relay.run('await page.title()'); + expectOk(r); + expect(r.text).toContain('Relay Test'); + }); + + test('page.url() returns URL string', async ({ relay }) => { + const r = await relay.run('await page.url()'); + expectOk(r); + expect(r.text).toContain('localhost'); + }); + + test('page.locator().textContent() returns element text', async ({ relay }) => { + const r = await relay.run("await page.locator('h1').textContent()"); + expectOk(r); + expect(r.text).toContain('todos'); + }); + + test('page.locator().count() returns a number', async ({ relay }) => { + await relay.run('fill "What needs to be done?" "JS test item"'); + await relay.run('press Enter'); + const r = await relay.run("await page.locator('.todo-list li').count()"); + expectOk(r); + expect(Number(r.text)).toBeGreaterThan(0); + }); + + test('page.locator().getAttribute() returns attribute value', async ({ relay }) => { + const r = await relay.run("await page.locator('input.new-todo').getAttribute('placeholder')"); + expectOk(r); + expect(r.text).toContain('What needs to be done?'); + }); + + test('page.locator().isVisible() returns boolean', async ({ relay }) => { + const r = await relay.run("await page.locator('h1').isVisible()"); + expectOk(r); + expect(r.text).toMatch(/true|false/); + }); + + test('page.evaluate() returns evaluated result', async ({ relay }) => { + const r = await relay.run("await page.evaluate(() => window.location.hostname)"); + expectOk(r); + expect(r.text).toContain('localhost'); + }); + + test('page.locator().click() executes without error', async ({ relay }) => { + const r = await relay.run("await page.locator('h1').click()"); + expectOk(r); + }); + + test('arithmetic expression returns result', async ({ relay }) => { + const r = await relay.run('1 + 2 + 3'); + expectOk(r); + expect(r.text).toContain('6'); + }); + }); + + // ─── Error handling ────────────────────────────────────────────────────────── + + test.describe('Error handling', () => { + test('unknown command returns error', async ({ relay, testUrl }) => { + await relay.run(`goto ${testUrl}`); + const r = await relay.run('nonexistent_cmd_xyz'); + expect(r.isError).toBe(true); + expect(r.text).toBeTruthy(); + }); + }); +}); diff --git a/packages/extension/e2e/relay/test-page.html b/packages/extension/e2e/relay/test-page.html new file mode 100644 index 00000000..82780082 --- /dev/null +++ b/packages/extension/e2e/relay/test-page.html @@ -0,0 +1,22 @@ + + + + + Relay Test + + +

todos

+ +
    + + + diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 33c0330c..a2fa883f 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -93,6 +93,37 @@ const patchedExpect: typeof expect = Object.assign( import { installFramework } from './test-framework'; installFramework(); +// ─── Offscreen Document (CDP Relay) ────────────────────────────────────────── + +async function ensureOffscreenRelay() { + if (await chrome.offscreen.hasDocument()) return; + await chrome.offscreen.createDocument({ + url: 'offscreen/offscreen.html', + reasons: [chrome.offscreen.Reason.BLOBS, chrome.offscreen.Reason.USER_MEDIA], + justification: 'CDP relay WebSocket connection and video capture (getUserMedia/MediaRecorder)', + }); +} + +// Web Store installs: always create offscreen doc (auto-connect to relay). +// Development installs (--load-extension): only create if relayPort was previously +// configured, to avoid connecting to a relay during CLI/tests/VS Code. +chrome.management.getSelf().then(async (info) => { + if (info.installType === 'normal') { + ensureOffscreenRelay().catch(e => console.warn('[pw-repl] offscreen document creation failed:', e)); + } else { + const { relayPort } = await chrome.storage.local.get(['relayPort']); + if (relayPort) ensureOffscreenRelay().catch(e => console.warn('[pw-repl] offscreen document creation failed:', e)); + } +}); + +// Re-check offscreen doc periodically — Chrome may kill it after idle. +chrome.alarms.create('ensure-offscreen', { periodInMinutes: 1 }); +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === 'ensure-offscreen') { + ensureOffscreenRelay().catch(() => {}); + } +}); + // ─── Settings + Action (sidepanel / popup) ─────────────────────────────────── // Disable auto-open so action.onClicked fires (Chrome persists this across reloads) @@ -105,6 +136,11 @@ chrome.storage.onChanged.addListener((changes, area) => { if (area === 'local' && changes.openAs) { cachedSettings.openAs = changes.openAs.newValue; } + if (area === 'local' && changes.relayPort) { + ensureOffscreenRelay().then(() => { + chrome.runtime.sendMessage({ type: 'relay-port-changed', port: changes.relayPort.newValue }).catch(() => {}); + }).catch(() => {}); + } }); chrome.action.onClicked.addListener(async (tab) => { @@ -308,15 +344,6 @@ chrome.tabs.onRemoved.addListener((tabId) => { // ─── Video Capture ────────────────────────────────────────────────────────── -async function ensureOffscreen() { - if (await chrome.offscreen.hasDocument()) return; - await chrome.offscreen.createDocument({ - url: 'offscreen/offscreen.html', - reasons: [chrome.offscreen.Reason.BLOBS, chrome.offscreen.Reason.USER_MEDIA], - justification: 'Video capture requires getUserMedia and MediaRecorder (unavailable in service workers)', - }); -} - let videoRecording = false; let videoStartTime = 0; @@ -327,7 +354,7 @@ async function startVideoCapture(): Promise<{ ok: boolean; error?: string }> { if (!tabId) return { ok: false, error: 'No active tab' }; try { - await ensureOffscreen(); + await ensureOffscreenRelay(); const streamId = await chrome.tabCapture.getMediaStreamId({ targetTabId: tabId }); @@ -855,6 +882,10 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { return false; } if (msg.type === 'ping') { sendResponse({ pong: true }); return false; } + if (msg.type === 'get-relay-port') { + chrome.storage.local.get(['relayPort']).then(s => sendResponse((s.relayPort as number) || 9877)); + return true; + } // ── Handoff: side panel ↔ popup state transfer (#820) ── if (msg.type === 'handoff-save') { handoffState = msg.state; sendResponse({ ok: true }); return false; } diff --git a/packages/extension/src/offscreen/offscreen.ts b/packages/extension/src/offscreen/offscreen.ts index ba888849..18f16e10 100644 --- a/packages/extension/src/offscreen/offscreen.ts +++ b/packages/extension/src/offscreen/offscreen.ts @@ -58,6 +58,87 @@ async function stopVideoCapture(): Promise<{ blobUrl: string; size: number }> { }); } +// ─── CDP Relay WebSocket ──────────────────────────────────────────────────── +// Connects to the CDPRelayServer started by CLI --connect or MCP --relay. +// Translates between the relay protocol and chrome.runtime messages. + +let relayWs: WebSocket | null = null; +let relayPort = 9877; +let relayRetryCount = 0; +let relayReconnectTimer: ReturnType | null = null; + +function scheduleRelayReconnect() { + if (relayReconnectTimer) clearTimeout(relayReconnectTimer); + const delay = Math.min(3000 * Math.pow(2, relayRetryCount), 30000); + relayReconnectTimer = setTimeout(() => connectRelay(relayPort), delay); + relayRetryCount++; +} + +function connectRelay(port: number) { + if (relayWs && relayWs.readyState === WebSocket.OPEN) return; + if (relayWs && relayWs.readyState === WebSocket.CONNECTING) { + relayWs.onclose = null; + relayWs.onerror = null; + relayWs.close(); + relayWs = null; + } + + const url = `ws://127.0.0.1:${port}/relay`; + try { + relayWs = new WebSocket(url); + + relayWs.onopen = () => { + console.debug(`[pw-repl] CDP relay connected to ${url}`); + relayRetryCount = 0; + }; + + relayWs.onmessage = async (e) => { + const msg = JSON.parse(e.data as string) as { id: number; method: string; params: unknown }; + + try { + const result = await chrome.runtime.sendMessage({ + type: msg.method === 'attachToTab' ? 'cdp-attach-tab' : 'cdp-command', + ...(msg.method === 'attachToTab' ? {} : (msg.params as Record)), + }); + + if (relayWs?.readyState === WebSocket.OPEN) { + if (result?.error) relayWs.send(JSON.stringify({ id: msg.id, error: result.error })); + else relayWs.send(JSON.stringify({ id: msg.id, result: result?.result ?? {} })); + } + } catch (err) { + if (relayWs?.readyState === WebSocket.OPEN) { + relayWs.send(JSON.stringify({ id: msg.id, error: String(err) })); + } + } + }; + + relayWs.onclose = () => { + console.debug('[pw-repl] CDP relay disconnected'); + relayWs = null; + scheduleRelayReconnect(); + }; + + relayWs.onerror = () => {}; + } catch { + scheduleRelayReconnect(); + } +} + +// Periodic health check — reconnect if relay WebSocket is not open +setInterval(() => { + if (relayPort && (!relayWs || relayWs.readyState !== WebSocket.OPEN)) { + connectRelay(relayPort); + } +}, 10000); + +// Auto-connect on load +chrome.runtime.sendMessage({ type: 'get-relay-port' }).then((port: number) => { + relayPort = port || 9877; + connectRelay(relayPort); +}).catch(() => { + connectRelay(relayPort); +}); + // ─── Message routing from background SW ───────────────────────────────────── chrome.runtime.onMessage.addListener((msg: { type: string; streamId?: string }, _sender, sendResponse) => { @@ -76,4 +157,21 @@ chrome.runtime.onMessage.addListener((msg: { type: string; streamId?: string }, if (msg.type === 'video-revoke') { revokeLastBlob(); } + + // Relay port changed — reconnect with new port + if (msg.type === 'relay-port-changed') { + relayPort = (msg as { type: string; port: number }).port; + if (relayReconnectTimer) clearTimeout(relayReconnectTimer); + relayRetryCount = 0; + if (relayWs) { relayWs.onclose = null; relayWs.close(); relayWs = null; } + connectRelay(relayPort); + } + + // Forward chrome.debugger events from background → relay WebSocket + if (msg.type === 'cdp-event') { + const { method, params, sessionId } = msg as { type: string; method: string; params?: unknown; sessionId?: string }; + if (relayWs?.readyState === WebSocket.OPEN) { + relayWs.send(JSON.stringify({ method: 'forwardCDPEvent', params: { method, params, sessionId } })); + } + } }); diff --git a/packages/extension/src/panel/lib/settings.ts b/packages/extension/src/panel/lib/settings.ts index 921fbd4d..5ed64220 100644 --- a/packages/extension/src/panel/lib/settings.ts +++ b/packages/extension/src/panel/lib/settings.ts @@ -2,14 +2,15 @@ export type PwReplSettings = { openAs: 'sidepanel' | 'popup', + relayPort: number, languageMode: 'pw' | 'js', commandTimeout: number, }; -const DEFAULT: PwReplSettings = { openAs: 'sidepanel', languageMode: 'pw', commandTimeout: 15000 }; +const DEFAULT: PwReplSettings = { openAs: 'sidepanel', relayPort: 9877, languageMode: 'pw', commandTimeout: 15000 }; export async function loadSettings(): Promise { - const stored = await chrome.storage.local.get(['openAs', 'languageMode', 'commandTimeout']) as Partial; + const stored = await chrome.storage.local.get(['openAs', 'relayPort', 'languageMode', 'commandTimeout']) as Partial; return { ...DEFAULT, ...stored }; } diff --git a/packages/extension/src/preferences/PreferencesForm.tsx b/packages/extension/src/preferences/PreferencesForm.tsx index 33a9783f..969e0183 100644 --- a/packages/extension/src/preferences/PreferencesForm.tsx +++ b/packages/extension/src/preferences/PreferencesForm.tsx @@ -3,7 +3,7 @@ import { loadSettings, storeSettings } from '../panel/lib/settings'; import type { PwReplSettings } from '../panel/lib/settings'; export default function PreferencesForm() { - const [settings, setSettings] = useState({ openAs: 'sidepanel', languageMode: 'pw', commandTimeout: 15000 }); + const [settings, setSettings] = useState({ openAs: 'sidepanel', relayPort: 9877, languageMode: 'pw', commandTimeout: 15000 }); useEffect(() => { loadSettings().then(setSettings); @@ -46,6 +46,22 @@ export default function PreferencesForm() { Popup Window +
    + Relay Port: + { + const next = { ...settings, relayPort: Number(e.target.value) }; + setSettings(next); + storeSettings(next); + }} + style={{ width: '100px', padding: '4px 8px', fontSize: '14px' }} + /> +

    + Port for CLI --connect and MCP --relay mode (default: 9877). +

    +
    Language Mode: