/** * 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/promises'; import { test, testWithOldExtensionVersion, expect, extensionId, clickAllowAndSelect, connectAndNavigate, startWithExtensionFlag } from './extension-fixtures'; import { utils } from '../../packages/playwright-core/lib/coreBundle'; const { defaultUserDataDirForChannel } = utils; test(`navigate with extension`, async ({ startExtensionClient, server }) => { const { browserContext, client } = await startExtensionClient(); const confirmationPagePromise = browserContext.waitForEvent('page', page => { return page.url().startsWith(`chrome-extension://${extensionId}/connect.html`); }); const navigateResponse = client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }); const selectorPage = await confirmationPagePromise; await clickAllowAndSelect(selectorPage, 'Welcome'); expect(await navigateResponse).toHaveResponse({ snapshot: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), }); }); test(`connect.html protocolVersion search param matches fixture option`, async ({ startExtensionClient, server, protocolVersion }) => { const { browserContext, client } = await startExtensionClient(); const confirmationPagePromise = browserContext.waitForEvent('page', page => { return page.url().startsWith(`chrome-extension://${extensionId}/connect.html`); }); client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }).catch(() => {}); const selectorPage = await confirmationPagePromise; const url = new URL(selectorPage.url()); expect(url.searchParams.get('protocolVersion')).toBe(String(protocolVersion)); }); test(`protocolVersion defaults to 2`, async ({ startExtensionClient, server, protocolVersion }) => { const saved = process.env.PLAYWRIGHT_EXTENSION_PROTOCOL; delete process.env.PLAYWRIGHT_EXTENSION_PROTOCOL; const { browserContext, client } = await startExtensionClient(); const confirmationPagePromise = browserContext.waitForEvent('page', page => { return page.url().startsWith(`chrome-extension://${extensionId}/connect.html`); }); client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }).catch(() => {}); const selectorPage = await confirmationPagePromise; const url = new URL(selectorPage.url()); expect(url.searchParams.get('protocolVersion')).toBe('2'); process.env.PLAYWRIGHT_EXTENSION_PROTOCOL = saved; }); test(`browser_run_code_unsafe can evaluate in a web worker`, async ({ startExtensionClient, server, protocolVersion }) => { test.skip(protocolVersion === 1, 'Multi-tab not supported in protocol v1'); server.setContent('/worker.js', ` self.onmessage = (e) => self.postMessage('echo:' + e.data); self.workerName = 'mcp-worker'; `, 'application/javascript'); server.setContent('/worker-page', ` WorkerPage `, 'text/html'); const { browserContext, client } = await startExtensionClient(); const confirmationPagePromise = browserContext.waitForEvent('page', page => { return page.url().startsWith(`chrome-extension://${extensionId}/connect.html`); }); const navigateResponse = client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX + '/worker-page' }, }); const selectorPage = await confirmationPagePromise; await clickAllowAndSelect(selectorPage, 'Welcome'); await navigateResponse; const runCodeResponse = await client.callTool({ name: 'browser_run_code_unsafe', arguments: { code: `async (page) => { const worker = page.workers().length ? page.workers()[0] : await page.waitForEvent('worker'); return await worker.evaluate(() => self.workerName); }`, }, }); expect(runCodeResponse).toHaveResponse({ result: expect.stringContaining('mcp-worker'), }); // Open a second page with its own worker via browser_tabs new and verify // that worker eval works in that tab too. This exercises child CDP sessions // (the worker session) on a non-first tab — the relay must route them to // the correct tab rather than always falling back to the first one. server.setContent('/worker2.js', ` self.workerName = 'mcp-worker-2'; `, 'application/javascript'); server.setContent('/worker-page-2', ` WorkerPage2 `, 'text/html'); await client.callTool({ name: 'browser_tabs', arguments: { action: 'new', url: server.PREFIX + '/worker-page-2' }, }); const runCodeResponse2 = await client.callTool({ name: 'browser_run_code_unsafe', arguments: { code: `async (page) => { const worker = page.workers().length ? page.workers()[0] : await page.waitForEvent('worker'); return await worker.evaluate(() => self.workerName); }`, }, }); expect(runCodeResponse2).toHaveResponse({ result: expect.stringContaining('mcp-worker-2'), }); }); test(`snapshot of an existing page`, async ({ browserWithExtension, startClient, server }) => { const browserContext = await browserWithExtension.launch(); const page = await browserContext.newPage(); await page.goto(server.HELLO_WORLD); // Another empty page. await browserContext.newPage(); expect(browserContext.pages()).toHaveLength(3); const client = await startWithExtensionFlag(browserWithExtension, startClient); expect(browserContext.pages()).toHaveLength(3); const confirmationPagePromise = browserContext.waitForEvent('page', page => { return page.url().startsWith(`chrome-extension://${extensionId}/connect.html`); }); const navigateResponse = client.callTool({ name: 'browser_snapshot', arguments: { }, }); const selectorPage = await confirmationPagePromise; expect(browserContext.pages()).toHaveLength(4); await clickAllowAndSelect(selectorPage, 'Title'); expect(await navigateResponse).toHaveResponse({ inlineSnapshot: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), }); }); testWithOldExtensionVersion(`works with old extension version`, async ({ startExtensionClient, server }) => { // Prelaunch the browser, so that it is properly closed after the test. const { browserContext, client } = await startExtensionClient(); const confirmationPagePromise = browserContext.waitForEvent('page', page => { return page.url().startsWith(`chrome-extension://${extensionId}/connect.html`); }); const navigateResponse = client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }); const selectorPage = await confirmationPagePromise; await clickAllowAndSelect(selectorPage, 'Welcome'); expect(await navigateResponse).toHaveResponse({ snapshot: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), }); }); test(`extension needs update`, async ({ startExtensionClient, server }) => { // Prelaunch the browser, so that it is properly closed after the test. const { browserContext, client } = await startExtensionClient({ PLAYWRIGHT_EXTENSION_PROTOCOL: '1000' }); const confirmationPagePromise = browserContext.waitForEvent('page', page => { return page.url().startsWith(`chrome-extension://${extensionId}/connect.html`); }); // The call hangs as MCP server never connects to the extension. client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }).catch(() => {}); const confirmationPage = await confirmationPagePromise; await expect(confirmationPage.locator('.status-banner')).toContainText(`Playwright client trying to connect requires newer extension version`); }); test(`custom executablePath skips local extension check`, { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/1590' }, }, async ({ startClient, server }) => { const executablePath = test.info().outputPath('echo.sh'); await fs.writeFile(executablePath, '#!/bin/bash\necho "Custom exec args: $@" > "$(dirname "$0")/output.txt"', { mode: 0o755 }); // Empty profile would normally fail the extension-installed check; it is skipped when executablePath is set. const { client } = await startClient({ args: [`--extension`, `--executable-path=${executablePath}`], env: { PWTEST_EXTENSION_USER_DATA_DIR: test.info().outputPath('empty-profile') }, }); client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }).catch(() => {}); await expect(async () => { const output = await fs.readFile(test.info().outputPath('output.txt'), 'utf8'); expect(output).toMatch(new RegExp(`Custom exec args.*chrome-extension://${extensionId}/connect\\.html\\?`)); }).toPass(); }); test(`fails when extension is missing in custom userDataDir`, async ({ startClient, server }) => { const userDataDir = test.info().outputPath('empty-profile'); const { client } = await startClient({ args: [`--extension`], env: { PWTEST_EXTENSION_USER_DATA_DIR: userDataDir }, }); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, })).toHaveResponse({ error: expect.stringContaining(`Playwright Extension not found in "${userDataDir}"`), isError: true, }); }); test(`--browser selects channel-specific userDataDir`, { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/1589' }, }, async ({ startClient, server }) => { const expectedUserDataDir = defaultUserDataDirForChannel('chrome-dev')!; const { client } = await startClient({ args: [`--extension`, `--browser=chrome-dev`], omitArgs: [`--browser=chromium`], }); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, })).toHaveResponse({ error: expect.stringContaining(`Playwright Extension not found in "${expectedUserDataDir}"`), isError: true, }); }); test(`browser_cookie_list and browser_cookie_set work in extension mode`, { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/40687' }, }, async ({ browserWithExtension, startClient, server }) => { const browserContext = await browserWithExtension.launch(); const { client } = await startClient({ args: [`--extension`], config: { capabilities: ['storage'] }, env: { PWTEST_EXTENSION_USER_DATA_DIR: browserWithExtension.userDataDir, }, }); await connectAndNavigate(browserContext, client, server.HELLO_WORLD); // Storage.setCookie / Storage.getCookies are browser-level CDP commands // (no sessionId) and used to be rejected outright by the v2 relay. await client.callTool({ name: 'browser_cookie_set', arguments: { name: 'extCookie', value: 'extValue' }, }); const result = await client.callTool({ name: 'browser_cookie_list', arguments: {}, }); expect(result).toHaveResponse({ result: expect.stringContaining('extCookie=extValue'), }); }); test(`bypass connection dialog with token`, async ({ browserWithExtension, startClient, server }) => { const browserContext = await browserWithExtension.launch(); const page = await browserContext.newPage(); await page.goto(`chrome-extension://${extensionId}/status.html`); const token = await page.locator('.auth-token-code').textContent(); const [, value] = token?.split('=') || []; const { client } = await startClient({ args: [`--extension`], env: { PLAYWRIGHT_MCP_EXTENSION_TOKEN: value, PWTEST_EXTENSION_USER_DATA_DIR: browserWithExtension.userDataDir, }, }); const navigateResponse = await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }); expect(await navigateResponse).toHaveResponse({ snapshot: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), }); }); test(`pending connection closed when client disconnects`, async ({ startExtensionClient, server, protocolVersion }) => { // v2 does not open a WS to the relay before the user clicks Allow, so there // is no pending connection to tear down when the client dies pre-Allow. test.skip(protocolVersion === 2, 'v2 defers the relay connection until Allow'); const { browserContext, client } = await startExtensionClient(); const confirmationPagePromise = browserContext.waitForEvent('page', page => { return page.url().startsWith(`chrome-extension://${extensionId}/connect.html`); }); client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }).catch(() => {}); const selectorPage = await confirmationPagePromise; // Wait for the tab list to appear so we know the relay connection is established. await selectorPage.locator('.tab-item').first().waitFor(); // Close the MCP client, which tears down the relay WebSocket. await client.close(); await expect(selectorPage.locator('.status-banner')).toContainText('Pending client connection closed.'); await expect(selectorPage).toHaveTitle('Playwright Extension'); // The connect tab should be removed from the Playwright group. await expect.poll(async () => { return selectorPage.evaluate(async () => { const chrome = (window as any).chrome; const tab = await chrome.tabs.getCurrent(); return tab?.groupId ?? -1; }); }).toBe(-1); });