Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
107 changes: 107 additions & 0 deletions packages/cli/e2e/test-connect-replay.js
Original file line number Diff line number Diff line change
@@ -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);
});
3 changes: 2 additions & 1 deletion packages/cli/src/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1207,7 +1207,8 @@ export async function startRepl(opts: ReplOpts = {}): Promise<void> {
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...');
Expand Down
177 changes: 177 additions & 0 deletions packages/extension/e2e/relay/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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, 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<CommandResult> {
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<CommandResult> {
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<CommandResult>; runScript: (script: string, language: 'pw' | 'javascript') => Promise<CommandResult> };
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<void>(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);
},
});
Loading
Loading