From d52d591155522c50a51a9aa8aa19494962947ef3 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sat, 22 Nov 2025 11:43:52 +0100 Subject: [PATCH] better test. spawn the extension with playwright --- package.json | 5 +- playwriter/package.json | 2 +- playwriter/src/mcp.test.ts | 187 ++++++++++++++++++++++++++++++++++++- pnpm-lock.yaml | 4 + 4 files changed, 195 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 79b0514..30c4824 100644 --- a/package.json +++ b/package.json @@ -18,5 +18,8 @@ }, "repository": "https://github.com/remorses/", "author": "remorses ", - "license": "" + "license": "", + "dependencies": { + "mcp-extension": "workspace:*" + } } diff --git a/playwriter/package.json b/playwriter/package.json index 2c90002..1117136 100644 --- a/playwriter/package.json +++ b/playwriter/package.json @@ -12,7 +12,7 @@ "watch": "tsc -w", "typecheck": "tsc --noEmit", "mcp": "vite-node src/mcp.ts", - "test": "vitest run", + "test": "vitest run -u", "test:watch": "vitest" }, "bin": "./bin.js", diff --git a/playwriter/src/mcp.test.ts b/playwriter/src/mcp.test.ts index ce6b73c..b5ed470 100644 --- a/playwriter/src/mcp.test.ts +++ b/playwriter/src/mcp.test.ts @@ -2,6 +2,12 @@ import { createMCPClient } from './mcp-client.js' import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { exec } from 'node:child_process' import { promisify } from 'node:util' +import { chromium } from 'playwright-core' +import path from 'node:path' +import fs from 'node:fs' +import os from 'node:os' + +import { spawn } from 'node:child_process' const execAsync = promisify(exec) @@ -29,15 +35,94 @@ async function killProcessOnPort(port: number): Promise { describe('MCP Server Tests', () => { let client: Awaited>['client'] let cleanup: (() => Promise) | null = null + let browserContext: Awaited> | null = null + let userDataDir: string + let relayServerProcess: any beforeAll(async () => { await killProcessOnPort(19988) + + // Build extension + console.log('Building extension...') + await execAsync('pnpm build', { cwd: '../extension' }) + console.log('Extension built') + + // Start Relay Server manually + relayServerProcess = spawn('pnpm', ['tsx', 'src/start-relay-server.ts'], { + cwd: process.cwd(), + stdio: 'inherit' + }) + + // Wait for port 19988 to be ready + await new Promise((resolve, reject) => { + let retries = 0 + const interval = setInterval(async () => { + try { + const { stdout } = await execAsync('lsof -ti:19988') + if (stdout.trim()) { + clearInterval(interval) + resolve() + } + } catch { + // ignore + } + retries++ + if (retries > 30) { + clearInterval(interval) + reject(new Error('Relay server failed to start')) + } + }, 1000) + }) + const result = await createMCPClient() client = result.client cleanup = result.cleanup - }) + + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pw-test-')) + const extensionPath = path.resolve('../extension/dist') + + browserContext = await chromium.launchPersistentContext(userDataDir, { + headless: false, + args: [ + `--disable-extensions-except=${extensionPath}`, + `--load-extension=${extensionPath}`, + ], + }) + + // Wait for service worker and connect + let serviceWorker = browserContext.serviceWorkers()[0] + if (!serviceWorker) { + serviceWorker = await browserContext.waitForEvent('serviceworker') + } + + // Create a page to attach to + const page = await browserContext.newPage() + await page.goto('about:blank') + + // Connect the tab + await serviceWorker.evaluate(async () => { + // @ts-ignore + await globalThis.toggleExtensionForActiveTab() + }) + + }, 120000) // 2 minutes timeout afterAll(async () => { + if (browserContext) { + await browserContext.close() + } + if (relayServerProcess) { + relayServerProcess.kill() + } + await killProcessOnPort(19988) + + if (userDataDir) { + try { + fs.rmSync(userDataDir, { recursive: true, force: true }) + } catch (e) { + console.error('Failed to cleanup user data dir:', e) + } + } if (cleanup) { await cleanup() cleanup = null @@ -174,6 +259,106 @@ describe('MCP Server Tests', () => { }) }) + + it('should handle new pages and toggling', async () => { + if (!browserContext) throw new Error('Browser not initialized') + + // Find the correct service worker by URL + const extensionPath = path.resolve('../extension/dist') + let serviceWorker = browserContext.serviceWorkers().find(sw => sw.url().startsWith('chrome-extension://')) + + if (!serviceWorker) { + serviceWorker = await browserContext.waitForEvent('serviceworker', { + predicate: (sw) => sw.url().startsWith('chrome-extension://') + }) + } + + // 1. Create a new page + const page = await browserContext.newPage() + const testUrl = 'https://example.com/' + await page.goto(testUrl) + await page.bringToFront() + + // 2. Enable extension on this new tab + // Since it's a new page, extension is not connected yet + const result = await serviceWorker.evaluate(async () => { + // @ts-ignore + return await globalThis.toggleExtensionForActiveTab() + }) + expect(result.isConnected).toBe(true) + + // 3. Verify we can connect via direct CDP and see the page + const randomId = Math.random().toString(36).substring(7) + const cdpUrl = `ws://localhost:19988/cdp/${randomId}` + let directBrowser = await chromium.connectOverCDP(cdpUrl) + let contexts = directBrowser.contexts() + let pages = contexts[0].pages() + + // Find our page + let foundPage = pages.find(p => p.url() === testUrl) + expect(foundPage).toBeDefined() + expect(foundPage?.url()).toBe(testUrl) + + await directBrowser.close() + + // 4. Disable extension on this tab + const resultDisabled = await serviceWorker.evaluate(async () => { + // @ts-ignore + return await globalThis.toggleExtensionForActiveTab() + }) + expect(resultDisabled.isConnected).toBe(false) + + // 5. Try to connect/use the page. + // connecting to relay will succeed, but listing pages should NOT show our page + // OR if we try to send command to the previous targetId (via session), it should fail + + // Connect to relay again + directBrowser = await chromium.connectOverCDP(cdpUrl) + contexts = directBrowser.contexts() + pages = contexts[0].pages() // this calls Target.getTargets internally via contexts() or pages()? + // Actually contexts() just returns what it knows. + // If relay sent detachedFromTarget, the page should be gone from the context. + + foundPage = pages.find(p => p.url() === testUrl) + expect(foundPage).toBeUndefined() + + // Also try to send a command to the old target ID if possible? + // Hard to do with high-level Playwright API on the browser object. + // We can try to use a raw CDPSession on the browser target if we knew how to route it. + // But checking it's gone is good enough for "connection disabled". + + await directBrowser.close() + + // 6. Re-enable extension + const resultEnabled = await serviceWorker.evaluate(async () => { + // @ts-ignore + return await globalThis.toggleExtensionForActiveTab() + }) + expect(resultEnabled.isConnected).toBe(true) + + // 7. Verify page is back + // Note: targetId might have changed! Fetch it again just in case, or rely on relay to update routing. + // But relay routing is based on `sessionId`. New session ID generated on attach. + // The URL path param `targetId` is just a label. + + directBrowser = await chromium.connectOverCDP(cdpUrl) + // Wait a bit for targets to populate + await new Promise(r => setTimeout(r, 500)) + + contexts = directBrowser.contexts() + // pages() might need a moment if target attached event comes in + if (contexts[0].pages().length === 0) { + await new Promise(r => setTimeout(r, 1000)) + } + pages = contexts[0].pages() + + foundPage = pages.find(p => p.url() === testUrl) + expect(foundPage).toBeDefined() + expect(foundPage?.url()).toBe(testUrl) + + await directBrowser.close() + await page.close() + }) }) function tryJsonParse(str: string) { try { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea12361..f0264b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + mcp-extension: + specifier: workspace:* + version: link:extension devDependencies: '@changesets/cli': specifier: ^2.29.7