diff --git a/README.md b/README.md index dd1db2d..dd0403d 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,8 @@ Playwright MCP server supports following arguments. They can be provided in the example ".com,chromium.org,.domain.com" --proxy-server specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080" + --save-trace Whether to save the Playwright Trace of the + session into the output directory. --storage-state path to the storage state file for isolated sessions. --user-agent specify user agent string diff --git a/config.d.ts b/config.d.ts index 68bed4e..8b38e6d 100644 --- a/config.d.ts +++ b/config.d.ts @@ -94,6 +94,11 @@ export type Config = { */ vision?: boolean; + /** + * Whether to save the Playwright trace of the session into the output directory. + */ + saveTrace?: boolean; + /** * The directory to save output files. */ diff --git a/src/config.ts b/src/config.ts index 92deec4..fe7a6ec 100644 --- a/src/config.ts +++ b/src/config.ts @@ -44,6 +44,7 @@ export type CLIOptions = { port?: number; proxyBypass?: string; proxyServer?: string; + saveTrace?: boolean; storageState?: string; userAgent?: string; userDataDir?: string; @@ -67,7 +68,7 @@ const defaultConfig: FullConfig = { allowedOrigins: undefined, blockedOrigins: undefined, }, - outputDir: path.join(os.tmpdir(), 'playwright-mcp-output'), + outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())), }; type BrowserUserConfig = NonNullable; @@ -91,7 +92,8 @@ export async function resolveCLIConfig(cliOptions: CLIOptions): Promise racingAction?.()) ?? undefined; + actionResult = await waitForCompletion(this, tab, async () => racingAction?.()) ?? undefined; else actionResult = await racingAction?.() ?? undefined; } finally { @@ -193,7 +193,7 @@ ${code.join('\n')} result.push( `- Page URL: ${tab.page.url()}`, - `- Page Title: ${await tab.page.title()}` + `- Page Title: ${await tab.title()}` ); if (captureSnapshot && tab.hasSnapshot()) @@ -213,10 +213,14 @@ ${code.join('\n')} } async waitForTimeout(time: number) { - if (this._currentTab && !this._javaScriptBlocked()) - await this._currentTab.page.evaluate(() => new Promise(f => setTimeout(f, 1000))); - else + if (!this._currentTab || this._javaScriptBlocked()) { await new Promise(f => setTimeout(f, time)); + return; + } + + await callOnPageNoTrace(this._currentTab.page, page => { + return page.evaluate(() => new Promise(f => setTimeout(f, 1000))); + }); } private async _raceAgainstModalDialogs(action: () => Promise): Promise { @@ -288,6 +292,8 @@ ${code.join('\n')} this._browserContextPromise = undefined; await promise.then(async ({ browserContext, browser }) => { + if (this.config.saveTrace) + await browserContext.tracing.stop(); await browserContext.close().then(async () => { await browser?.close(); }).catch(() => {}); @@ -324,6 +330,14 @@ ${code.join('\n')} for (const page of browserContext.pages()) this._onPageCreated(page); browserContext.on('page', page => this._onPageCreated(page)); + if (this.config.saveTrace) { + await browserContext.tracing.start({ + name: 'trace', + screenshots: false, + snapshots: true, + sources: false, + }); + } return { browser, browserContext }; } @@ -394,9 +408,5 @@ async function createUserDataDir(browserConfig: FullConfig['browser']) { return result; } -export async function generateLocator(locator: playwright.Locator): Promise { - return (locator as any)._generateLocatorString(); -} - const __filename = url.fileURLToPath(import.meta.url); export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8')); diff --git a/src/pageSnapshot.ts b/src/pageSnapshot.ts index c2cb2cb..c7850d5 100644 --- a/src/pageSnapshot.ts +++ b/src/pageSnapshot.ts @@ -15,6 +15,11 @@ */ import * as playwright from 'playwright'; +import { callOnPageNoTrace } from './tools/utils.js'; + +type PageEx = playwright.Page & { + _snapshotForAI: () => Promise; +}; export class PageSnapshot { private _page: playwright.Page; @@ -35,11 +40,11 @@ export class PageSnapshot { } private async _build() { - const yamlDocument = await (this._page as any)._snapshotForAI(); + const snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI()); this._text = [ `- Page Snapshot`, '```yaml', - yamlDocument.toString({ indentSeq: false }).trim(), + snapshot, '```', ].join('\n'); } diff --git a/src/program.ts b/src/program.ts index f8e18f5..59fb566 100644 --- a/src/program.ts +++ b/src/program.ts @@ -44,6 +44,7 @@ program .option('--port ', 'port to listen on for SSE transport.') .option('--proxy-bypass ', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"') .option('--proxy-server ', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"') + .option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.') .option('--storage-state ', 'path to the storage state file for isolated sessions.') .option('--user-agent ', 'specify user agent string') .option('--user-data-dir ', 'path to the user data directory. If not specified, a temporary directory will be created.') diff --git a/src/tab.ts b/src/tab.ts index 511702f..5d4e93a 100644 --- a/src/tab.ts +++ b/src/tab.ts @@ -19,6 +19,7 @@ import * as playwright from 'playwright'; import { PageSnapshot } from './pageSnapshot.js'; import type { Context } from './context.js'; +import { callOnPageNoTrace } from './tools/utils.js'; export class Tab { readonly context: Context; @@ -61,10 +62,18 @@ export class Tab { this._onPageClose(this); } + async title(): Promise { + return await callOnPageNoTrace(this.page, page => page.title()); + } + + async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise { + await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(() => {})); + } + async navigate(url: string) { this._clearCollectedArtifacts(); - const downloadEvent = this.page.waitForEvent('download').catch(() => {}); + const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(() => {})); try { await this.page.goto(url, { waitUntil: 'domcontentloaded' }); } catch (_e: unknown) { @@ -85,7 +94,7 @@ export class Tab { } // Cap load event to 5 seconds, the page is operational at this point. - await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {}); + await this.waitForLoadState('load', { timeout: 5000 }); } hasSnapshot(): boolean { diff --git a/src/tools/utils.ts b/src/tools/utils.ts index 4a0a2a4..24206e7 100644 --- a/src/tools/utils.ts +++ b/src/tools/utils.ts @@ -16,8 +16,9 @@ import type * as playwright from 'playwright'; import type { Context } from '../context.js'; +import type { Tab } from '../tab.js'; -export async function waitForCompletion(context: Context, page: playwright.Page, callback: () => Promise): Promise { +export async function waitForCompletion(context: Context, tab: Tab, callback: () => Promise): Promise { const requests = new Set(); let frameNavigated = false; let waitCallback: () => void = () => {}; @@ -36,9 +37,7 @@ export async function waitForCompletion(context: Context, page: playwright.Pa frameNavigated = true; dispose(); clearTimeout(timeout); - void frame.waitForLoadState('load').then(() => { - waitCallback(); - }); + void tab.waitForLoadState('load').then(waitCallback); }; const onTimeout = () => { @@ -46,15 +45,15 @@ export async function waitForCompletion(context: Context, page: playwright.Pa waitCallback(); }; - page.on('request', requestListener); - page.on('requestfinished', requestFinishedListener); - page.on('framenavigated', frameNavigateListener); + tab.page.on('request', requestListener); + tab.page.on('requestfinished', requestFinishedListener); + tab.page.on('framenavigated', frameNavigateListener); const timeout = setTimeout(onTimeout, 10000); const dispose = () => { - page.off('request', requestListener); - page.off('requestfinished', requestFinishedListener); - page.off('framenavigated', frameNavigateListener); + tab.page.off('request', requestListener); + tab.page.off('requestfinished', requestFinishedListener); + tab.page.off('framenavigated', frameNavigateListener); clearTimeout(timeout); }; @@ -79,5 +78,9 @@ export function sanitizeForFilePath(s: string) { } export async function generateLocator(locator: playwright.Locator): Promise { - return (locator as any)._generateLocatorString(); + return (locator as any)._frame._wrapApiCall(() => (locator as any)._generateLocatorString(), true); +} + +export async function callOnPageNoTrace(page: playwright.Page, callback: (page: playwright.Page) => Promise): Promise { + return await (page as any)._wrapApiCall(() => callback(page), true); } diff --git a/tests/files.spec.ts b/tests/files.spec.ts index 8280bb0..47e337b 100644 --- a/tests/files.spec.ts +++ b/tests/files.spec.ts @@ -126,7 +126,12 @@ test('clicking on download link emits download', async ({ startClient, localOutp - Downloaded file test.txt to ${path.join(outputDir, 'test.txt')}`); }); -test('navigating to download link emits download', async ({ client, server, mcpBrowser }) => { +test('navigating to download link emits download', async ({ startClient, localOutputPath, mcpBrowser, server }) => { + const outputDir = localOutputPath('output'); + const client = await startClient({ + args: ['--output-dir', outputDir], + }); + test.skip(mcpBrowser === 'webkit' && process.platform === 'linux', 'https://github.com/microsoft/playwright/blob/8e08fdb52c27bb75de9bf87627bf740fadab2122/tests/library/download.spec.ts#L436'); server.route('/download', (req, res) => { res.writeHead(200, { diff --git a/tests/pdf.spec.ts b/tests/pdf.spec.ts index 84d26c2..224b026 100644 --- a/tests/pdf.spec.ts +++ b/tests/pdf.spec.ts @@ -30,7 +30,12 @@ test('save as pdf unavailable', async ({ startClient, server }) => { })).toHaveTextContent(/Tool \"browser_pdf_save\" not found/); }); -test('save as pdf', async ({ client, mcpBrowser, server }) => { +test('save as pdf', async ({ startClient, mcpBrowser, server, localOutputPath }) => { + const outputDir = localOutputPath('output'); + const client = await startClient({ + config: { outputDir }, + }); + test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.'); expect(await client.callTool({ diff --git a/tests/screenshot.spec.ts b/tests/screenshot.spec.ts index 3f8c851..ffc30d6 100644 --- a/tests/screenshot.spec.ts +++ b/tests/screenshot.spec.ts @@ -18,7 +18,11 @@ import fs from 'fs'; import { test, expect } from './fixtures.js'; -test('browser_take_screenshot (viewport)', async ({ client, server }) => { +test('browser_take_screenshot (viewport)', async ({ startClient, server, localOutputPath }) => { + const outputDir = localOutputPath('output'); + const client = await startClient({ + args: ['--output-dir', outputDir], + }); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, @@ -41,7 +45,11 @@ test('browser_take_screenshot (viewport)', async ({ client, server }) => { }); }); -test('browser_take_screenshot (element)', async ({ client, server }) => { +test('browser_take_screenshot (element)', async ({ startClient, server, localOutputPath }) => { + const outputDir = localOutputPath('output'); + const client = await startClient({ + args: ['--output-dir', outputDir], + }); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, @@ -166,9 +174,10 @@ test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient, expect(files[0]).toMatch(/^output\.jpeg$/); }); -test('browser_take_screenshot (noImageResponses)', async ({ startClient, server }) => { +test('browser_take_screenshot (noImageResponses)', async ({ startClient, server, localOutputPath }) => { const client = await startClient({ config: { + outputDir: localOutputPath('output'), noImageResponses: true, }, }); @@ -194,8 +203,12 @@ test('browser_take_screenshot (noImageResponses)', async ({ startClient, server }); }); -test('browser_take_screenshot (cursor)', async ({ startClient, server }) => { - const client = await startClient({ clientName: 'cursor:vscode' }); +test('browser_take_screenshot (cursor)', async ({ startClient, server, localOutputPath }) => { + const outputDir = localOutputPath('output'); + const client = await startClient({ + clientName: 'cursor:vscode', + config: { outputDir }, + }); expect(await client.callTool({ name: 'browser_navigate', diff --git a/tests/sse.spec.ts b/tests/sse.spec.ts index b85c282..6c02107 100644 --- a/tests/sse.spec.ts +++ b/tests/sse.spec.ts @@ -65,11 +65,17 @@ test('streamable http transport', async ({ serverEndpoint }) => { expect(transport.sessionId, 'has session support').toBeDefined(); }); -test('sse transport via public API', async ({ server }) => { +test('sse transport via public API', async ({ server, localOutputPath }) => { + const userDataDir = localOutputPath('user-data-dir'); const sessions = new Map(); const mcpServer = http.createServer(async (req, res) => { if (req.method === 'GET') { - const connection = await createConnection({ browser: { launchOptions: { headless: true } } }); + const connection = await createConnection({ + browser: { + userDataDir, + launchOptions: { headless: true } + }, + }); const transport = new SSEServerTransport('/sse', res); sessions.set(transport.sessionId, transport); await connection.connect(transport); diff --git a/tests/trace.spec.ts b/tests/trace.spec.ts new file mode 100644 index 0000000..1ca75bb --- /dev/null +++ b/tests/trace.spec.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs'; +import path from 'path'; + +import { test, expect } from './fixtures.js'; + +test('check that trace is saved', async ({ startClient, server, localOutputPath }) => { + const outputDir = localOutputPath('output'); + const client = await startClient({ + args: ['--save-trace', `--output-dir=${outputDir}`], + }); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toContainTextContent(`Navigate to http://localhost`); + + expect(fs.existsSync(path.join(outputDir, 'traces', 'trace.trace'))).toBeTruthy(); +});