diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25103c1..3ad890d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Playwright install + run: npx playwright install --with-deps + - name: Run linting run: npm run lint diff --git a/src/context.ts b/src/context.ts index 64b56f6..50eb518 100644 --- a/src/context.ts +++ b/src/context.ts @@ -54,7 +54,7 @@ export class Context { currentTab(): Tab { if (!this._currentTab) - throw new Error('Navigate to a location to create a tab'); + throw new Error('No current snapshot available. Capture a snapshot of navigate to a new location first.'); return this._currentTab; } @@ -236,8 +236,8 @@ class Tab { }); } - async runAndWaitWithSnapshot(callback: (tab: Tab) => Promise, options?: RunOptions): Promise { - return await this.run(callback, { + async runAndWaitWithSnapshot(callback: (snapshot: PageSnapshot) => Promise, options?: RunOptions): Promise { + return await this.run(tab => callback(tab.lastSnapshot()), { captureSnapshot: true, waitForCompletion: true, ...options, diff --git a/src/tools/screen.ts b/src/tools/screen.ts index f8b9a40..c184475 100644 --- a/src/tools/screen.ts +++ b/src/tools/screen.ts @@ -28,7 +28,7 @@ const screenshot: Tool = { }, handle: async context => { - const tab = context.currentTab(); + const tab = await context.ensureTab(); const screenshot = await tab.page.screenshot({ type: 'jpeg', quality: 50, scale: 'css' }); return { content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: 'image/jpeg' }], diff --git a/src/tools/snapshot.ts b/src/tools/snapshot.ts index 565a9cc..123e249 100644 --- a/src/tools/snapshot.ts +++ b/src/tools/snapshot.ts @@ -29,7 +29,8 @@ const snapshot: Tool = { }, handle: async context => { - return await context.currentTab().run(async () => {}, { captureSnapshot: true }); + const tab = await context.ensureTab(); + return await tab.run(async () => {}, { captureSnapshot: true }); }, }; @@ -48,8 +49,8 @@ const click: Tool = { handle: async (context, params) => { const validatedParams = elementSchema.parse(params); - return await context.currentTab().runAndWaitWithSnapshot(async tab => { - const locator = tab.lastSnapshot().refLocator(validatedParams.ref); + return await context.currentTab().runAndWaitWithSnapshot(async snapshot => { + const locator = snapshot.refLocator(validatedParams.ref); await locator.click(); }, { status: `Clicked "${validatedParams.element}"`, @@ -74,9 +75,9 @@ const drag: Tool = { handle: async (context, params) => { const validatedParams = dragSchema.parse(params); - return await context.currentTab().runAndWaitWithSnapshot(async tab => { - const startLocator = tab.lastSnapshot().refLocator(validatedParams.startRef); - const endLocator = tab.lastSnapshot().refLocator(validatedParams.endRef); + return await context.currentTab().runAndWaitWithSnapshot(async snapshot => { + const startLocator = snapshot.refLocator(validatedParams.startRef); + const endLocator = snapshot.refLocator(validatedParams.endRef); await startLocator.dragTo(endLocator); }, { status: `Dragged "${validatedParams.startElement}" to "${validatedParams.endElement}"`, @@ -94,8 +95,8 @@ const hover: Tool = { handle: async (context, params) => { const validatedParams = elementSchema.parse(params); - return await context.currentTab().runAndWaitWithSnapshot(async tab => { - const locator = tab.lastSnapshot().refLocator(validatedParams.ref); + return await context.currentTab().runAndWaitWithSnapshot(async snapshot => { + const locator = snapshot.refLocator(validatedParams.ref); await locator.hover(); }, { status: `Hovered over "${validatedParams.element}"`, @@ -119,8 +120,8 @@ const type: Tool = { handle: async (context, params) => { const validatedParams = typeSchema.parse(params); - return await context.currentTab().runAndWaitWithSnapshot(async tab => { - const locator = tab.lastSnapshot().refLocator(validatedParams.ref); + return await context.currentTab().runAndWaitWithSnapshot(async snapshot => { + const locator = snapshot.refLocator(validatedParams.ref); if (validatedParams.slowly) await locator.pressSequentially(validatedParams.text); else @@ -147,8 +148,8 @@ const selectOption: Tool = { handle: async (context, params) => { const validatedParams = selectOptionSchema.parse(params); - return await context.currentTab().runAndWaitWithSnapshot(async tab => { - const locator = tab.lastSnapshot().refLocator(validatedParams.ref); + return await context.currentTab().runAndWaitWithSnapshot(async snapshot => { + const locator = snapshot.refLocator(validatedParams.ref); await locator.selectOption(validatedParams.values); }, { status: `Selected option in "${validatedParams.element}"`, diff --git a/src/tools/tabs.ts b/src/tools/tabs.ts index 16d15da..ed5281b 100644 --- a/src/tools/tabs.ts +++ b/src/tools/tabs.ts @@ -89,7 +89,7 @@ const closeTab: ToolFactory = captureSnapshot => ({ handle: async (context, params) => { const validatedParams = closeTabSchema.parse(params); await context.closeTab(validatedParams.index); - const currentTab = await context.currentTab(); + const currentTab = context.currentTab(); if (currentTab) return await currentTab.run(async () => {}, { captureSnapshot }); return { diff --git a/tests/cdp.spec.ts b/tests/cdp.spec.ts index 89013c5..1dbf049 100644 --- a/tests/cdp.spec.ts +++ b/tests/cdp.spec.ts @@ -35,3 +35,27 @@ Navigated to data:text/html,TitleHello, world! ` ); }); + +test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => { + const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] }); + + expect(await client.callTool({ + name: 'browser_click', + arguments: { + element: 'Hello, world!', + ref: 'f0', + }, + })).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot of navigate to a new location first.`); + + expect(await client.callTool({ + name: 'browser_snapshot', + arguments: {}, + })).toHaveTextContent(` +- Page URL: data:text/html,hello world +- Page Title: +- Page Snapshot +\`\`\`yaml +- text: hello world +\`\`\` +`); +}); diff --git a/tests/fixtures.ts b/tests/fixtures.ts index 9caab25..f8b4ee0 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -20,6 +20,7 @@ import { chromium } from 'playwright'; import { test as baseTest, expect as baseExpect } from '@playwright/test'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { spawn } from 'child_process'; type Fixtures = { client: Client; @@ -68,12 +69,25 @@ export const test = baseTest.extend({ cdpEndpoint: async ({ }, use, testInfo) => { const port = 3200 + (+process.env.TEST_PARALLEL_INDEX!); - const browser = await chromium.launchPersistentContext(testInfo.outputPath('user-data-dir'), { - channel: 'chrome', - args: [`--remote-debugging-port=${port}`], + const executablePath = chromium.executablePath(); + const browserProcess = spawn(executablePath, [ + `--user-data-dir=${testInfo.outputPath('user-data-dir')}`, + `--remote-debugging-port=${port}`, + `--no-first-run`, + `--no-sandbox`, + `--headless`, + `data:text/html,hello world`, + ], { + stdio: 'pipe', + }); + await new Promise(resolve => { + browserProcess.stderr.on('data', data => { + if (data.toString().includes('DevTools listening on ')) + resolve(); + }); }); await use(`http://localhost:${port}`); - await browser.close(); + browserProcess.kill(); }, });