From d26a26d2e8e0c5e958d8ad0faa09d31f3d7f0bdf Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 12 Mar 2026 12:48:10 +0000 Subject: [PATCH 1/2] chore: move PWPAUSE=cli session to use open --attach (#39633) --- .../src/skill/references/playwright-tests.md | 7 +++-- .../src/tools/cli-client/program.ts | 2 +- .../src/tools/cli-daemon/program.ts | 6 ++-- packages/playwright/src/index.ts | 6 ++-- .../playwright/src/mcp/test/browserBackend.ts | 17 +++++------ tests/mcp/cli-session.spec.ts | 12 ++++++++ tests/mcp/cli-test.spec.ts | 29 ++++++++++--------- 7 files changed, 47 insertions(+), 32 deletions(-) diff --git a/packages/playwright-core/src/skill/references/playwright-tests.md b/packages/playwright-core/src/skill/references/playwright-tests.md index 81d131b72205c..1be8c70c24e91 100644 --- a/packages/playwright-core/src/skill/references/playwright-tests.md +++ b/packages/playwright-core/src/skill/references/playwright-tests.md @@ -16,7 +16,7 @@ To debug a failing test, run it with Playwright as usual, but set `PWPAUSE=cli` **IMPORTANT**: run the command in the background and check the output until "Debugging Instructions" is printed. -Once instructions are printed, use `playwright-cli` to explore the page. Debugging instructions include a session name that should be used in `playwright-cli` to connect to the page under test. Do not create a new `playwright-cli` session, make sure to connect to the test session instead. +Once instructions are printed, use `playwright-cli` to explore the page. Debugging instructions include a browser name that should be used in `playwright-cli` to attach to the page under test. ```bash # Run the test @@ -24,8 +24,9 @@ PLAYWRIGHT_HTML_OPEN=never PWPAUSE=cli npx playwright test # ... # Explore the page and interact if needed -playwright-cli --session=test-worker-abcdef snapshot -playwright-cli --session=test-worker-abcdef click e14 +playwright-cli --session=test open --attach=test-worker-abcdef +playwright-cli --session=test snapshot +playwright-cli --session=test click e14 ``` Keep the test running in the background while you explore and look for a fix. After fixing the test, stop the background test run. diff --git a/packages/playwright-core/src/tools/cli-client/program.ts b/packages/playwright-core/src/tools/cli-client/program.ts index 346ef1999b4fb..5b19dd7b9924a 100644 --- a/packages/playwright-core/src/tools/cli-client/program.ts +++ b/packages/playwright-core/src/tools/cli-client/program.ts @@ -248,7 +248,7 @@ async function ensureConfiguredBrowserInstalled() { } async function installBrowser() { - const { program } = require('../program'); + const { program } = require('../../cli/program'); const argv = process.argv.map(arg => arg === 'install-browser' ? 'install' : arg); program.parse(argv); } diff --git a/packages/playwright-core/src/tools/cli-daemon/program.ts b/packages/playwright-core/src/tools/cli-daemon/program.ts index 4ba6be398cce8..deca0cf292a4a 100644 --- a/packages/playwright-core/src/tools/cli-daemon/program.ts +++ b/packages/playwright-core/src/tools/cli-daemon/program.ts @@ -46,6 +46,8 @@ program.argument('[session-name]', 'name of the session to create or connect to' try { const browser = await createBrowser(mcpConfig, mcpClientInfo); const browserContext = mcpConfig.browser.isolated ? await browser.newContext(mcpConfig.browser.contextOptions) : browser.contexts()[0]; + if (!browserContext) + throw new Error('Error: unable to connect to a browser that does not have any contexts'); const persistent = options.persistent || options.profile || mcpConfig.browser.userDataDir ? true : undefined; const socketPath = await startCliDaemonServer(sessionName, browserContext, mcpConfig, clientInfo, { persistent, exitOnClose: true }); console.log(`### Success\nDaemon listening on ${socketPath}`); @@ -104,9 +106,9 @@ export async function resolveCLIConfig(clientInfo: ClientInfo, sessionName: stri result = configUtils.mergeConfig(result, envOverrides); if (result.browser.isolated === undefined) - result.browser.isolated = !options.profile && !options.persistent && !result.browser.userDataDir; + result.browser.isolated = !options.profile && !options.persistent && !result.browser.userDataDir && !result.browser.remoteEndpoint && !result.extension; - if (!result.extension && !result.browser.isolated && !result.browser.userDataDir) { + if (!result.extension && !result.browser.isolated && !result.browser.userDataDir && !result.browser.remoteEndpoint) { // No custom value provided, use the daemon data dir. const browserToken = result.browser.launchOptions?.channel ?? result.browser?.browserName; const userDataDir = path.resolve(clientInfo.daemonProfilesDir, `ud-${sessionName}-${browserToken}`); diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index add90f6df1ca4..88e5147d5c67f 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -22,7 +22,7 @@ import { setBoxedStackPrefixes, createGuid, currentZone, debugMode, jsonStringif import { currentTestInfo } from './common/globals'; import { rootTestType } from './common/testType'; -import { createCustomMessageHandler, runDaemonForContext } from './mcp/test/browserBackend'; +import { createCustomMessageHandler, runDaemonForBrowser } from './mcp/test/browserBackend'; import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, VideoMode } from '../types/test'; import type { ContextReuseMode } from './common/config'; @@ -432,7 +432,7 @@ const playwrightFixtures: Fixtures = ({ if (!_reuseContext) { const { context, close } = await _contextFactory(); testInfo._onCustomMessageCallback = createCustomMessageHandler(testInfo, context); - testInfo._onDidFinishTestFunctionCallbacks.add(() => runDaemonForContext(testInfo, context)); + testInfo._onDidFinishTestFunctionCallbacks.add(() => runDaemonForBrowser(testInfo, browser)); await use(context); await close(); return; @@ -440,7 +440,7 @@ const playwrightFixtures: Fixtures = ({ const context = await browserImpl._wrapApiCall(() => browserImpl._newContextForReuse(), { internal: true }); testInfo._onCustomMessageCallback = createCustomMessageHandler(testInfo, context); - testInfo._onDidFinishTestFunctionCallbacks.add(() => runDaemonForContext(testInfo, context)); + testInfo._onDidFinishTestFunctionCallbacks.add(() => runDaemonForBrowser(testInfo, browser)); await use(context); const closeReason = testInfo.status === 'timedOut' ? 'Test timeout of ' + testInfo.timeout + 'ms exceeded.' : 'Test ended.'; await browserImpl._wrapApiCall(() => browserImpl._disconnectFromReusedContext(closeReason), { internal: true }); diff --git a/packages/playwright/src/mcp/test/browserBackend.ts b/packages/playwright/src/mcp/test/browserBackend.ts index 196360cf8e09c..605c75fb33d3c 100644 --- a/packages/playwright/src/mcp/test/browserBackend.ts +++ b/packages/playwright/src/mcp/test/browserBackend.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import path from 'path'; import { createGuid } from 'playwright-core/lib/utils'; import * as tools from 'playwright-core/lib/tools/exports'; @@ -22,6 +21,7 @@ import { stripAnsiEscapes } from '../../util'; import type * as playwright from '../../../index'; import type { Page } from '../../../../playwright-core/src/client/page'; +import type { Browser } from '../../../../playwright-core/src/client/browser'; import type { TestInfoImpl } from '../../worker/testInfo'; export type BrowserMCPRequest = { @@ -111,17 +111,12 @@ async function generatePausedMessage(testInfo: TestInfoImpl, context: playwright return lines.join('\n'); } -export async function runDaemonForContext(testInfo: TestInfoImpl, context: playwright.BrowserContext): Promise { +export async function runDaemonForBrowser(testInfo: TestInfoImpl, browser: playwright.Browser): Promise { if (process.env.PWPAUSE !== 'cli') return; - const outputDir = path.join(testInfo.artifactsDir(), '.playwright-mcp'); - const sessionName = `test-worker-${createGuid().slice(0, 6)}`; - await tools.startCliDaemonServer(sessionName, context, { - outputMode: 'file', - snapshot: { mode: 'full' }, - outputDir, - }); + const browserTitle = `test-worker-${createGuid().slice(0, 6)}`; + await (browser as Browser)._startServer(browserTitle, { workspaceDir: testInfo.project.testDir }); const lines = ['']; if (testInfo.errors.length) { @@ -133,7 +128,9 @@ export async function runDaemonForContext(testInfo: TestInfoImpl, context: playw } lines.push( `### Debugging Instructions`, - `- Use "playwright-cli --session=${sessionName}" to explore the page and fix the problem.`, + `- Pick a session name, e.g. "test"`, + `- Run "playwright-cli --session= open --attach=${browserTitle}" to attach to this page`, + `- Use "playwright-cli --session=" to explore the page and fix the problem`, `- Stop this test run when finished. Restart if needed.`, ``, ); diff --git a/tests/mcp/cli-session.spec.ts b/tests/mcp/cli-session.spec.ts index ff51dc73531d4..dab5e7b6c4751 100644 --- a/tests/mcp/cli-session.spec.ts +++ b/tests/mcp/cli-session.spec.ts @@ -302,8 +302,11 @@ workspace1: const browserName = mcpBrowser.replace('chrome', 'chromium'); await using browser = await playwright[browserName].launch({ headless: true }); await (browser as any)._startServer('foobar', { workspaceDir: 'workspace1' }); + const page = await browser.newPage(); + await page.setContent('My Page'); const { output: openOutput } = await cli('open', '--attach=foobar'); expect(openOutput).toContain('### Browser `default` opened with pid'); + expect(openOutput).toContain('My Page'); const { output: listOutput } = await cli('list', '--all'); expect(listOutput).toBe(`### Browsers /: @@ -321,9 +324,18 @@ workspace1: - run \`playwright-cli open --attach "foobar"\` to attach`); }); + test('fail to attach to browser server without contexts', async ({ cli, mcpBrowser }) => { + const browserName = mcpBrowser.replace('chrome', 'chromium'); + await using browser = await playwright[browserName].launch({ headless: true }); + await (browser as any)._startServer('foobar', { workspaceDir: 'workspace1' }); + const { error } = await cli('open', '--attach=foobar'); + expect(error).toContain('Error: unable to connect to a browser that does not have any contexts'); + }); + test('detach from browser server', async ({ cli, mcpBrowser }) => { const browserName = mcpBrowser.replace('chrome', 'chromium'); await using browser = await playwright[browserName].launch({ headless: true }); + await browser.newPage(); await (browser as any)._startServer('foobar', { workspaceDir: 'workspace1' }); const { output: openOutput } = await cli('open', '--attach=foobar'); expect(openOutput).toContain('### Browser `default` opened with pid'); diff --git a/tests/mcp/cli-test.spec.ts b/tests/mcp/cli-test.spec.ts index fab825b0fba14..704db0b741993 100644 --- a/tests/mcp/cli-test.spec.ts +++ b/tests/mcp/cli-test.spec.ts @@ -22,39 +22,42 @@ const testEntrypoint = path.join(__dirname, '../../packages/playwright-test/cli. test('debug test and snapshot', async ({ cliEnv, cli, childProcess }) => { await writeFiles({ - 'a.test.ts': ` + 'subdir/a.test.ts': ` import { test, expect } from '@playwright/test'; test('example test', async ({ page }) => { - await page.setContent(''); + await page.setContent('My Page'); await expect(page.getByRole('button', { name: 'Missing' })).toBeVisible({ timeout: 1000 }); }); `, }); - const cwd = test.info().outputDir; - const testProcess = childProcess({ command: [process.argv[0], testEntrypoint, 'test'], - cwd, + cwd: test.info().outputPath('subdir'), env: { PWPAUSE: 'cli', ...cliEnv }, }); - await testProcess.waitForOutput('playwright-cli --session=test-worker'); + await testProcess.waitForOutput('playwright-cli --session= open --attach=test-worker'); - const listResult1 = await cli('list', { cwd }); - expect(listResult1.exitCode).toBe(0); - expect(listResult1.output).toContain('test-worker'); + const match = testProcess.output.match(/--attach=([a-zA-Z0-9-_]+)/); + const browserName = match[1]; - const match = testProcess.output.match(/--session=([a-zA-Z0-9-_]+)/); - const sessionName = match[1]; + const { output: openOutput } = await cli('open', `--session=test-session`, `--attach=${browserName}`); + expect(openOutput).toContain('My Page'); + + const listResult1 = await cli('list', '--all'); + expect(listResult1.exitCode).toBe(0); + expect(listResult1.output).toContain('test-session'); + expect(listResult1.output).toContain('subdir'); + expect(listResult1.output).toContain(`browser "${browserName}"`); - const snapshotResult = await cli(`--session=${sessionName}`, 'snapshot', { cwd }); + const snapshotResult = await cli(`--session=test-session`, 'snapshot'); expect(snapshotResult.exitCode).toBe(0); expect(snapshotResult.snapshot).toContain('button "Submit"'); await testProcess.kill('SIGINT'); - const listResult2 = await cli('list', { cwd }); + const listResult2 = await cli('list'); expect(listResult2.exitCode).toBe(0); expect(listResult2.output).toContain('(no browsers)'); }); From f026e4f76586b791508f57d5ff4411ba69d6680d Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 12 Mar 2026 09:44:39 -0700 Subject: [PATCH 2/2] test: skip pickLocator cancellation test on chromium headed macOS 14 (#39642) --- tests/library/inspector/recorder-api.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/library/inspector/recorder-api.spec.ts b/tests/library/inspector/recorder-api.spec.ts index 981066e391db4..3411d569d76bf 100644 --- a/tests/library/inspector/recorder-api.spec.ts +++ b/tests/library/inspector/recorder-api.spec.ts @@ -181,7 +181,8 @@ test('closing page should cancel ongoing pickLocator', async ({ page }) => { expect(await pickPromise).toContain('Target page, context or browser has been closed'); }); -test('page2.pickLocator() should cancel page1.pickLocator()', async ({ page, context }) => { +test('page2.pickLocator() should cancel page1.pickLocator()', async ({ page, context, browserName, headless, isMac, macVersion }) => { + test.fixme(browserName === 'chromium' && !headless && isMac && macVersion === 14, 'times out on chromium headed on macOS 14'); const pick1Promise = page.pickLocator().catch(e => e.message); const page2 = await context.newPage();