From 0c8d6f1f9c6c18069d65cc29a1d30999e2024a2d Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 24 Aug 2026 14:07:41 -0700 Subject: [PATCH 1/3] feat(mcp): add start/stop recording commands Fixes: https://github.com/microsoft/playwright/issues/42338 --- .../src/client/browserContext.ts | 7 +- .../playwright-core/src/server/recorder.ts | 3 + .../src/server/recorder/recorderApp.ts | 9 +- .../recorder/recorderSignalProcessor.ts | 8 +- .../src/tools/backend/codegen.ts | 10 +- .../src/tools/backend/context.ts | 46 +++- .../src/tools/backend/recorder.ts | 68 ++++++ .../src/tools/backend/response.ts | 4 +- .../src/tools/backend/tools.ts | 2 + .../src/tools/cli-daemon/commands.ts | 18 ++ .../src/tools/skills/playwright-cli/SKILL.md | 3 + tests/library/inspector/recorder-api.spec.ts | 16 ++ tests/mcp/capabilities.spec.ts | 2 + tests/mcp/cli-core.spec.ts | 19 ++ tests/mcp/recorder.spec.ts | 196 ++++++++++++++++++ 15 files changed, 397 insertions(+), 14 deletions(-) create mode 100644 packages/playwright-core/src/tools/backend/recorder.ts create mode 100644 tests/mcp/recorder.spec.ts diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 5b79e670d975a..1bad4ea702863 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -542,8 +542,11 @@ export class BrowserContext extends ChannelOwner } async _disableRecorder() { - this._onRecorderEventSink = undefined; - await this._channel.disableRecorder({}, kNoTimeout); + try { + await this._channel.disableRecorder({}, kNoTimeout); + } finally { + this._onRecorderEventSink = undefined; + } } async _exposeConsoleApi() { diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 5080e11dac0d3..d0b006e3a14a9 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -134,6 +134,7 @@ export class Recorder extends EventEmitter implements Instrume }); context.on(BrowserContext.Events.BeforeClose, () => { + this._signalProcessor.flush(); this.emit(RecorderEvent.ContextClosed); }); this._listeners.push(eventsHelper.addEventListener(process, 'exit', () => { @@ -472,6 +473,8 @@ export class Recorder extends EventEmitter implements Instrume } private _setEnabled(enabled: boolean) { + if (this._enabled && !enabled) + this._signalProcessor.flush(); this._enabled = enabled; } diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index e0ec3f1eccdea..c0ed4eb0b8949 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -362,6 +362,7 @@ export class ProgrammaticRecorderApp { constructor(inspectedContext: BrowserContext, recorder: Recorder, params: channels.BrowserContextEnableRecorderParams) { let lastAction: actions.ActionInContext | undefined; + let lastActionPage: Page | undefined; const languages = [...languageSet()]; const languageGeneratorOptions = { @@ -379,6 +380,7 @@ export class ProgrammaticRecorderApp { if (!page) return; lastAction = actionInContext; + lastActionPage = page; const code = languageGenerator.generateAction(actionInContext, languageGeneratorOptions); inspectedContext.emit(BrowserContext.Events.RecorderEvent, { event: 'actionAdded', data: actionInContext.action, page, code }); }), @@ -386,10 +388,13 @@ export class ProgrammaticRecorderApp { const page = findPageByGuid(inspectedContext, signalInContext.pageGuid); if (!page) return; + let code = ''; // The signal belongs to the last action, so re-generate its code with the signal // included (e.g. a popup or download wait around the action). - lastAction?.signals.push(signalInContext.signal); - const code = lastAction ? languageGenerator.generateAction(lastAction, languageGeneratorOptions) : ''; + if (lastAction && page === lastActionPage) { + lastAction.signals.push(signalInContext.signal); + code = languageGenerator.generateAction(lastAction, languageGeneratorOptions); + } inspectedContext.emit(BrowserContext.Events.RecorderEvent, { event: 'signalAdded', data: signalInContext.signal, page, code }); }), ]; diff --git a/packages/playwright-core/src/server/recorder/recorderSignalProcessor.ts b/packages/playwright-core/src/server/recorder/recorderSignalProcessor.ts index 8624cf18632c5..8e35748e67124 100644 --- a/packages/playwright-core/src/server/recorder/recorderSignalProcessor.ts +++ b/packages/playwright-core/src/server/recorder/recorderSignalProcessor.ts @@ -51,7 +51,7 @@ export class RecorderSignalProcessor { this._resetPendingTimeout(); return; } - this._flushPendingAction(); + this.flush(); } if (this._shouldBuffer(actionInContext)) { @@ -59,7 +59,7 @@ export class RecorderSignalProcessor { actionInContext, receivedAt: timestamp, signals: [], - timeout: setTimeout(() => this._flushPendingAction(), kActionBufferTimeout), + timeout: setTimeout(() => this.flush(), kActionBufferTimeout), }; return; } @@ -107,7 +107,7 @@ export class RecorderSignalProcessor { if (!this._pendingAction) return; clearTimeout(this._pendingAction.timeout); - this._pendingAction.timeout = setTimeout(() => this._flushPendingAction(), kActionBufferTimeout); + this._pendingAction.timeout = setTimeout(() => this.flush(), kActionBufferTimeout); } private _emitAction(actionInContext: actions.ActionInContext, timestamp: number) { @@ -116,7 +116,7 @@ export class RecorderSignalProcessor { this._delegate.addAction(actionInContext); } - private _flushPendingAction() { + flush() { const pending = this._pendingAction; if (!pending) return; diff --git a/packages/playwright-core/src/tools/backend/codegen.ts b/packages/playwright-core/src/tools/backend/codegen.ts index 852926ec45d62..e3ceb56673aa6 100644 --- a/packages/playwright-core/src/tools/backend/codegen.ts +++ b/packages/playwright-core/src/tools/backend/codegen.ts @@ -47,6 +47,14 @@ export function renderCode(items: CodeItem[], language: CodegenLanguage): string return lines; } +export function languageGeneratorId(language: CodegenLanguage): string { + return createGenerator(language).id; +} + +export function codeframeForLanguage(language: CodegenLanguage): 'js' | 'python' | 'java' | 'csharp' { + return language === 'typescript' ? 'js' : language; +} + export function secretCode(language: CodegenLanguage, secretName: string): string { switch (language) { case 'typescript': return `process.env['${secretName}']`; @@ -78,7 +86,7 @@ function createGenerator(language: CodegenLanguage): LanguageGenerator { } } -function dedent(text: string): string { +export function dedent(text: string): string { const lines = text.split('\n'); const indents = lines.filter(line => line.trim()).map(line => line.length - line.trimStart().length); const indent = indents.length ? Math.min(...indents) : 0; diff --git a/packages/playwright-core/src/tools/backend/context.ts b/packages/playwright-core/src/tools/backend/context.ts index 5d7e41ad31513..00b3212f629dc 100644 --- a/packages/playwright-core/src/tools/backend/context.ts +++ b/packages/playwright-core/src/tools/backend/context.ts @@ -25,9 +25,10 @@ import { eventsHelper } from '@utils/eventsHelper'; import { isPathInside, isSystemDirectory, isWritable } from '@utils/fileUtils'; import { playwright } from '../../inprocess'; -import { secretCode } from './codegen'; +import { dedent, languageGeneratorId, secretCode } from './codegen'; import { Tab } from './tab'; +import type { CodegenLanguage } from './codegen'; import type * as playwrightTypes from '../../..'; import type { SessionLog } from './sessionLog'; import type { Disposable } from '@isomorphic/disposable'; @@ -106,6 +107,7 @@ export class Context { fileNames: string[]; fileName: string; } | undefined; + private _recordedActions: string[] | undefined; private _disposables: Disposable[] = []; private _runningToolName: string | undefined; @@ -128,6 +130,7 @@ export class Context { async dispose() { process.off('unhandledRejection', this._onUnhandledRejection); + await this.stopRecording(); await disposeAll(this._disposables); for (const tab of this._tabs) await tab.dispose(); @@ -233,6 +236,44 @@ export class Context { return [...video.fileNames]; } + async startRecording() { + if (this._recordedActions) + throw new Error('Recording is already in progress.'); + const browserContext = await this.ensureBrowserContext(); + const recordedActions: string[] = []; + // eslint-disable-next-line no-restricted-syntax -- internal api, not in public types. + await (browserContext as any)._enableRecorder({ + mode: 'recording', + recorderMode: 'api', + omitCallTracking: true, + language: languageGeneratorId(this.codegenLanguage()), + }, { + actionAdded: (page: playwrightTypes.Page, action: unknown, code: string) => { + recordedActions.push(code); + }, + signalAdded: (page: playwrightTypes.Page, signal: unknown, code: string) => { + if (recordedActions.length && code) + recordedActions[recordedActions.length - 1] = code; + }, + }); + this._recordedActions = recordedActions; + } + + async stopRecording(): Promise { + const recordedActions = this._recordedActions; + if (!recordedActions) + return undefined; + this._recordedActions = undefined; + // eslint-disable-next-line no-restricted-syntax -- internal api, not in public types. + await (this._rawBrowserContext as any)._disableRecorder().catch(() => {}); + return recordedActions.filter(code => code.trim()).map(dedent); + } + + codegenLanguage(): CodegenLanguage { + const codegen = this.config.codegen ?? 'typescript'; + return codegen === 'none' ? 'typescript' : codegen; + } + private async _startPageVideo(page: playwrightTypes.Page) { if (!this._video) return; @@ -352,10 +393,9 @@ export class Context { lookupSecret(secretName: string): { value: string, code: string, isSecret: boolean } { if (!this.config.secrets?.[secretName]) return { value: secretName, code: escapeWithQuotes(secretName, '\''), isSecret: false }; - const codegen = this.config.codegen ?? 'typescript'; return { value: this.config.secrets[secretName]!, - code: secretCode(codegen === 'none' ? 'typescript' : codegen, secretName), + code: secretCode(this.codegenLanguage(), secretName), isSecret: true, }; } diff --git a/packages/playwright-core/src/tools/backend/recorder.ts b/packages/playwright-core/src/tools/backend/recorder.ts new file mode 100644 index 0000000000000..6e64d2d031764 --- /dev/null +++ b/packages/playwright-core/src/tools/backend/recorder.ts @@ -0,0 +1,68 @@ +/** + * 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 * as z from 'zod'; +import { codeframeForLanguage } from './codegen'; +import { defineTool } from './tool'; + +const startRecording = defineTool({ + capability: 'core', + + schema: { + name: 'browser_start_recording', + title: 'Start recording user actions', + description: 'Start recording actions that the user performs in the browser as Playwright code. Use it when the user wants to demonstrate a flow manually. Call browser_stop_recording when the user says they are done to retrieve the recorded actions.', + inputSchema: z.object({}), + type: 'readOnly', + }, + + handle: async (context, params, response) => { + const tab = await context.ensureTab(); + await context.startRecording(); + await tab.page.bringToFront(); + response.addTextResult(`Recording started. Call ${stopRecording.schema.name} to retrieve the recorded actions.`); + }, +}); + +const stopRecording = defineTool({ + capability: 'core', + + schema: { + name: 'browser_stop_recording', + title: 'Stop recording user actions', + description: 'Stop the recording started with browser_start_recording and return the recorded actions as Playwright code.', + inputSchema: z.object({}), + type: 'readOnly', + }, + + handle: async (context, params, response) => { + const recordedActions = await context.stopRecording(); + if (!recordedActions) + throw new Error(`No recording in progress, use ${startRecording.schema.name} to start one.`); + if (!recordedActions.length) { + response.addTextResult('Recording stopped. No actions were recorded.'); + } else { + const codeframe = codeframeForLanguage(context.codegenLanguage()); + response.addTextResult(`Recording stopped. Recorded actions:\n\n\`\`\`${codeframe}\n${recordedActions.join('\n')}\n\`\`\``); + } + response.setIncludeSnapshot(); + }, +}); + +export default [ + startRecording, + stopRecording, +]; diff --git a/packages/playwright-core/src/tools/backend/response.ts b/packages/playwright-core/src/tools/backend/response.ts index eb1ed1f0dabb2..d6b53bdefb295 100644 --- a/packages/playwright-core/src/tools/backend/response.ts +++ b/packages/playwright-core/src/tools/backend/response.ts @@ -18,7 +18,7 @@ import fs from 'fs'; import path from 'path'; import debug from 'debug'; -import { actionInContext, renderCode, substituteSecrets } from './codegen'; +import { actionInContext, codeframeForLanguage, renderCode, substituteSecrets } from './codegen'; import { renderModalStates } from './tab'; import { scaleImageToFitMessage } from './screenshot'; @@ -285,7 +285,7 @@ export class Response { const codegen = this._context.config.codegen ?? 'typescript'; if (codegen !== 'none' && this._code.length) { const code = substituteSecrets(renderCode(this._code, codegen), codegen, Object.keys(this._context.config.secrets ?? {})); - addSection('Ran Playwright code', code, codegen === 'typescript' ? 'js' : codegen); + addSection('Ran Playwright code', code, codeframeForLanguage(codegen)); } // Render tab titles upon changes or when more than one tab. diff --git a/packages/playwright-core/src/tools/backend/tools.ts b/packages/playwright-core/src/tools/backend/tools.ts index d9b4e7d64f923..439a0a2ecdf30 100644 --- a/packages/playwright-core/src/tools/backend/tools.ts +++ b/packages/playwright-core/src/tools/backend/tools.ts @@ -30,6 +30,7 @@ import mouse from './mouse'; import navigate from './navigate'; import network from './network'; import pdf from './pdf'; +import recorder from './recorder'; import route from './route'; import runCode from './runCode'; import snapshot from './snapshot'; @@ -61,6 +62,7 @@ export const browserTools: Tool[] = [ ...navigate, ...network, ...pdf, + ...recorder, ...route, ...runCode, ...screenshot, diff --git a/packages/playwright-core/src/tools/cli-daemon/commands.ts b/packages/playwright-core/src/tools/cli-daemon/commands.ts index f1312ee5f958c..2877ad810e172 100644 --- a/packages/playwright-core/src/tools/cli-daemon/commands.ts +++ b/packages/playwright-core/src/tools/cli-daemon/commands.ts @@ -932,6 +932,22 @@ const networkResponseBody = declareCommand({ toolParams: ({ index, filename }) => ({ index, part: 'response-body', filename }), }); +const recordingStart = declareCommand({ + name: 'recording-start', + description: 'Start recording user actions', + category: 'core', + toolName: 'browser_start_recording', + toolParams: () => ({}), +}); + +const recordingStop = declareCommand({ + name: 'recording-stop', + description: 'Stop recording user actions and print them as Playwright code', + category: 'core', + toolName: 'browser_stop_recording', + toolParams: () => ({}), +}); + const tracingStart = declareCommand({ name: 'tracing-start', description: 'Start trace recording', @@ -1174,6 +1190,8 @@ const commandsArray: AnyCommandSchema[] = [ dialogDismiss, resize, runCode, + recordingStart, + recordingStop, deleteData, // navigation category diff --git a/packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md b/packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md index 4f7970aa0d3f7..159c6bd53c96f 100644 --- a/packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md +++ b/packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md @@ -61,6 +61,9 @@ playwright-cli dialog-accept playwright-cli dialog-accept "confirmation text" playwright-cli dialog-dismiss playwright-cli resize 1920 1080 +# record user actions in the browser, print them as Playwright code on stop +playwright-cli recording-start +playwright-cli recording-stop playwright-cli close ``` diff --git a/tests/library/inspector/recorder-api.spec.ts b/tests/library/inspector/recorder-api.spec.ts index 80382952a2e2a..3b362a47f4734 100644 --- a/tests/library/inspector/recorder-api.spec.ts +++ b/tests/library/inspector/recorder-api.spec.ts @@ -128,6 +128,22 @@ test('should send updated code with the signal', async ({ context, server }) => expect(normalizeCode(code)).toContain(`const page1 = await page1Promise;`); }); +test('should not amend the last action with a signal from another page', async ({ context }) => { + const recorder = await startRecording(context); + const page1 = await context.newPage(); + await page1.setContent(``); + const page2 = await context.newPage(); + await page2.setContent(`
Second page
`); + + await page1.getByRole('button', { name: 'Submit' }).click(); + await expect.poll(() => recorder.action('click').length).toBe(1); + + // Dialog on page2 must not attach to the click on page1. + void page2.evaluate(() => alert('hello')).catch(() => {}); + await expect.poll(() => recorder.signals().map(s => s.signal.name)).toContain('dialog'); + expect(recorder.signals().find(s => s.signal.name === 'dialog')!.code).toBe(''); +}); + test('should type', async ({ context }) => { const log = await startRecording(context); const page = await context.newPage(); diff --git a/tests/mcp/capabilities.spec.ts b/tests/mcp/capabilities.spec.ts index e3d0c9e45aa42..58782f8681a58 100644 --- a/tests/mcp/capabilities.spec.ts +++ b/tests/mcp/capabilities.spec.ts @@ -40,6 +40,8 @@ test('test snapshot tool list', async ({ client }) => { 'browser_resize', 'browser_run_code_unsafe', 'browser_snapshot', + 'browser_start_recording', + 'browser_stop_recording', 'browser_tabs', 'browser_take_screenshot', 'browser_wait_for', diff --git a/tests/mcp/cli-core.spec.ts b/tests/mcp/cli-core.spec.ts index 790ca85d6f4c4..3de747b364a6b 100644 --- a/tests/mcp/cli-core.spec.ts +++ b/tests/mcp/cli-core.spec.ts @@ -54,6 +54,25 @@ await page.getByRole('button', { name: 'Submit' }).click(); \`\`\``); }); +test('recording-start-stop', async ({ cli, server }) => { + server.setContent('/', `Title`, 'text/html'); + + const { snapshot } = await cli('open', server.PREFIX); + expect(snapshot).toContain(`- button "Submit" [ref=e2]`); + + const { output } = await cli('recording-start'); + expect(output).toContain('Recording started'); + + await cli('click', 'e2'); + + const { output: stopOutput } = await cli('recording-stop'); + expect(stopOutput).toContain(`Recording stopped. Recorded actions: + +\`\`\`js +await page.getByRole('button', { name: 'Submit' }).click(); +\`\`\``); +}); + test('click link', async ({ cli, server, mcpBrowser }) => { server.setContent('/', `Hello, world!`, 'text/html'); diff --git a/tests/mcp/recorder.spec.ts b/tests/mcp/recorder.spec.ts new file mode 100644 index 0000000000000..8538accc59fc1 --- /dev/null +++ b/tests/mcp/recorder.spec.ts @@ -0,0 +1,196 @@ +/** + * 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 { test, expect } from './fixtures'; + +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import type { TestServer } from '../config/testserver'; + +async function navigateToForm(client: Client, server: TestServer) { + server.setContent('/', ` + Title + + + `, 'text/html'); + return await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); +} + +test('record actions between start and stop', async ({ client, server }) => { + await navigateToForm(client, server); + + expect(await client.callTool({ + name: 'browser_start_recording', + })).toHaveResponse({ + result: expect.stringContaining('Recording started'), + }); + + await client.callTool({ + name: 'browser_click', + arguments: { element: 'Submit button', target: 'e2' }, + }); + await client.callTool({ + name: 'browser_type', + arguments: { element: 'textbox', target: 'e3', text: 'Hello world' }, + }); + + expect(await client.callTool({ + name: 'browser_stop_recording', + })).toHaveResponse({ + result: expect.stringContaining([ + 'Recording stopped. Recorded actions:', + '', + '```js', + `await page.getByRole('button', { name: 'Submit' }).click();`, + `await page.getByRole('textbox').fill('Hello world');`, + '```', + ].join('\n')), + }); +}); + +test('record navigation', async ({ client, server }) => { + await navigateToForm(client, server); + server.setContent('/page2', `Page 2`, 'text/html'); + + await client.callTool({ name: 'browser_start_recording' }); + + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX + '/page2' }, + }); + + expect(await client.callTool({ + name: 'browser_stop_recording', + })).toHaveResponse({ + result: expect.stringContaining(`await page.goto('${server.PREFIX}/page2');`), + }); +}); + +test('stop with no actions recorded', async ({ client, server }) => { + await navigateToForm(client, server); + await client.callTool({ name: 'browser_start_recording' }); + expect(await client.callTool({ + name: 'browser_stop_recording', + })).toHaveResponse({ + result: expect.stringContaining('No actions were recorded.'), + }); +}); + +test('restarted recording only contains new actions', async ({ client, server }) => { + server.setContent('/', ` + Title + + + `, 'text/html'); + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + + await client.callTool({ name: 'browser_start_recording' }); + await client.callTool({ + name: 'browser_click', + arguments: { element: 'Alpha button', target: 'e2' }, + }); + expect(await client.callTool({ + name: 'browser_stop_recording', + })).toHaveResponse({ + result: expect.stringContaining(`await page.getByRole('button', { name: 'Alpha' }).click();`), + }); + + await client.callTool({ name: 'browser_start_recording' }); + await client.callTool({ + name: 'browser_click', + arguments: { element: 'Beta button', target: 'e3' }, + }); + // Matching the entire block asserts no duplicate or stale actions. + expect(await client.callTool({ + name: 'browser_stop_recording', + })).toHaveResponse({ + result: expect.stringContaining([ + '```js', + `await page.getByRole('button', { name: 'Beta' }).click();`, + '```', + ].join('\n')), + }); +}); + +test('actions performed while not recording are not included', async ({ client, server }) => { + await navigateToForm(client, server); + + await client.callTool({ + name: 'browser_click', + arguments: { element: 'Submit button', target: 'e2' }, + }); + + await client.callTool({ name: 'browser_start_recording' }); + await client.callTool({ + name: 'browser_type', + arguments: { element: 'textbox', target: 'e3', text: 'Hello world' }, + }); + + expect(await client.callTool({ + name: 'browser_stop_recording', + })).toHaveResponse({ + result: expect.stringContaining([ + '```js', + `await page.getByRole('textbox').fill('Hello world');`, + '```', + ].join('\n')), + }); +}); + +test('record actions in python', async ({ startClient, server }) => { + const { client } = await startClient({ args: ['--codegen=python'] }); + await navigateToForm(client, server); + + await client.callTool({ name: 'browser_start_recording' }); + await client.callTool({ + name: 'browser_click', + arguments: { element: 'Submit button', target: 'e2' }, + }); + + expect(await client.callTool({ + name: 'browser_stop_recording', + })).toHaveResponse({ + result: expect.stringContaining([ + '```python', + `page.get_by_role("button", name="Submit").click()`, + ].join('\n')), + }); +}); + +test('start twice is an error', async ({ client, server }) => { + await navigateToForm(client, server); + await client.callTool({ name: 'browser_start_recording' }); + expect(await client.callTool({ + name: 'browser_start_recording', + })).toHaveResponse({ + isError: true, + error: expect.stringContaining('Recording is already in progress'), + }); +}); + +test('stop without start is an error', async ({ client }) => { + expect(await client.callTool({ + name: 'browser_stop_recording', + })).toHaveResponse({ + isError: true, + error: expect.stringContaining('No recording in progress'), + }); +}); From 6f5423a33a184fd72138387982eeb17ae312a6d3 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 24 Aug 2026 15:48:03 -0700 Subject: [PATCH 2/3] chore(mcp): address recording review comments - move recording tool and cli commands to the devtools capability - declare the internal BrowserContext api in tools instead of casting to any --- .../src/tools/backend/browserContextEx.ts | 35 +++++++++++++++++++ .../src/tools/backend/context.ts | 13 ++++--- .../src/tools/backend/recorder.ts | 4 +-- .../src/tools/cli-daemon/commands.ts | 8 ++--- .../src/tools/skills/playwright-cli/SKILL.md | 8 +++-- tests/library/inspector/recorder-api.spec.ts | 11 ++++++ tests/mcp/capabilities.spec.ts | 2 -- tests/mcp/cli-core.spec.ts | 19 ---------- tests/mcp/cli-devtools.spec.ts | 19 ++++++++++ tests/mcp/recorder.spec.ts | 2 ++ 10 files changed, 84 insertions(+), 37 deletions(-) create mode 100644 packages/playwright-core/src/tools/backend/browserContextEx.ts diff --git a/packages/playwright-core/src/tools/backend/browserContextEx.ts b/packages/playwright-core/src/tools/backend/browserContextEx.ts new file mode 100644 index 0000000000000..031f4c26c931c --- /dev/null +++ b/packages/playwright-core/src/tools/backend/browserContextEx.ts @@ -0,0 +1,35 @@ +/** + * 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 type * as actions from '@isomorphic/codegen/actions'; +import type * as playwrightTypes from '../../..'; + +export type RecorderEventSink = { + actionAdded?(page: playwrightTypes.Page, action: actions.Action, code: string): void; + signalAdded?(page: playwrightTypes.Page, signal: actions.Signal, code: string): void; +}; + +export type BrowserContextInternalApi = { + _enableRecorder(params: { + language?: string, + mode?: 'inspecting' | 'recording', + recorderMode?: 'default' | 'api', + omitCallTracking?: boolean, + }, eventSink?: RecorderEventSink): Promise; + _disableRecorder(): Promise; +}; + +export type BrowserContextEx = playwrightTypes.BrowserContext & BrowserContextInternalApi; diff --git a/packages/playwright-core/src/tools/backend/context.ts b/packages/playwright-core/src/tools/backend/context.ts index 00b3212f629dc..867774989118b 100644 --- a/packages/playwright-core/src/tools/backend/context.ts +++ b/packages/playwright-core/src/tools/backend/context.ts @@ -28,6 +28,7 @@ import { playwright } from '../../inprocess'; import { dedent, languageGeneratorId, secretCode } from './codegen'; import { Tab } from './tab'; +import type { BrowserContextEx } from './browserContextEx'; import type { CodegenLanguage } from './codegen'; import type * as playwrightTypes from '../../..'; import type { SessionLog } from './sessionLog'; @@ -239,19 +240,18 @@ export class Context { async startRecording() { if (this._recordedActions) throw new Error('Recording is already in progress.'); - const browserContext = await this.ensureBrowserContext(); + const browserContext = await this.ensureBrowserContext() as BrowserContextEx; const recordedActions: string[] = []; - // eslint-disable-next-line no-restricted-syntax -- internal api, not in public types. - await (browserContext as any)._enableRecorder({ + await browserContext._enableRecorder({ mode: 'recording', recorderMode: 'api', omitCallTracking: true, language: languageGeneratorId(this.codegenLanguage()), }, { - actionAdded: (page: playwrightTypes.Page, action: unknown, code: string) => { + actionAdded: (page, action, code) => { recordedActions.push(code); }, - signalAdded: (page: playwrightTypes.Page, signal: unknown, code: string) => { + signalAdded: (page, signal, code) => { if (recordedActions.length && code) recordedActions[recordedActions.length - 1] = code; }, @@ -264,8 +264,7 @@ export class Context { if (!recordedActions) return undefined; this._recordedActions = undefined; - // eslint-disable-next-line no-restricted-syntax -- internal api, not in public types. - await (this._rawBrowserContext as any)._disableRecorder().catch(() => {}); + await (this._rawBrowserContext as BrowserContextEx)._disableRecorder(); return recordedActions.filter(code => code.trim()).map(dedent); } diff --git a/packages/playwright-core/src/tools/backend/recorder.ts b/packages/playwright-core/src/tools/backend/recorder.ts index 6e64d2d031764..1edba0eb8c87d 100644 --- a/packages/playwright-core/src/tools/backend/recorder.ts +++ b/packages/playwright-core/src/tools/backend/recorder.ts @@ -19,7 +19,7 @@ import { codeframeForLanguage } from './codegen'; import { defineTool } from './tool'; const startRecording = defineTool({ - capability: 'core', + capability: 'devtools', schema: { name: 'browser_start_recording', @@ -38,7 +38,7 @@ const startRecording = defineTool({ }); const stopRecording = defineTool({ - capability: 'core', + capability: 'devtools', schema: { name: 'browser_stop_recording', diff --git a/packages/playwright-core/src/tools/cli-daemon/commands.ts b/packages/playwright-core/src/tools/cli-daemon/commands.ts index 2877ad810e172..83ccbe93ebd32 100644 --- a/packages/playwright-core/src/tools/cli-daemon/commands.ts +++ b/packages/playwright-core/src/tools/cli-daemon/commands.ts @@ -935,7 +935,7 @@ const networkResponseBody = declareCommand({ const recordingStart = declareCommand({ name: 'recording-start', description: 'Start recording user actions', - category: 'core', + category: 'devtools', toolName: 'browser_start_recording', toolParams: () => ({}), }); @@ -943,7 +943,7 @@ const recordingStart = declareCommand({ const recordingStop = declareCommand({ name: 'recording-stop', description: 'Stop recording user actions and print them as Playwright code', - category: 'core', + category: 'devtools', toolName: 'browser_stop_recording', toolParams: () => ({}), }); @@ -1190,8 +1190,6 @@ const commandsArray: AnyCommandSchema[] = [ dialogDismiss, resize, runCode, - recordingStart, - recordingStop, deleteData, // navigation category @@ -1259,6 +1257,8 @@ const commandsArray: AnyCommandSchema[] = [ installBrowser, // devtools category + recordingStart, + recordingStop, tracingStart, tracingStop, videoStart, diff --git a/packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md b/packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md index 159c6bd53c96f..ec4f96042439e 100644 --- a/packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md +++ b/packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md @@ -61,9 +61,6 @@ playwright-cli dialog-accept playwright-cli dialog-accept "confirmation text" playwright-cli dialog-dismiss playwright-cli resize 1920 1080 -# record user actions in the browser, print them as Playwright code on stop -playwright-cli recording-start -playwright-cli recording-stop playwright-cli close ``` @@ -168,6 +165,11 @@ playwright-cli run-code "async page => await page.context().grantPermissions(['g playwright-cli run-code --filename=script.js playwright-cli tracing-start playwright-cli tracing-stop + +# record user actions in the browser, print them as Playwright code on stop +playwright-cli recording-start +playwright-cli recording-stop + playwright-cli video-start video.webm playwright-cli video-chapter "Chapter Title" --description="Details" --duration=2000 playwright-cli video-stop diff --git a/tests/library/inspector/recorder-api.spec.ts b/tests/library/inspector/recorder-api.spec.ts index 3b362a47f4734..a5a7737a21fa2 100644 --- a/tests/library/inspector/recorder-api.spec.ts +++ b/tests/library/inspector/recorder-api.spec.ts @@ -18,6 +18,7 @@ import { test, expect } from './inspectorTest'; import type { Page } from '@playwright/test'; import type * as actions from '@isomorphic/codegen/actions'; +import type { BrowserContextInternalApi } from '../../../packages/playwright-core/src/tools/backend/browserContextEx'; class RecorderLog { actions: { action: actions.Action, code: string }[] = []; @@ -48,6 +49,16 @@ function normalizeCode(code: string): string { return code.replace(/\s+/g, ' ').trim(); } +test('context should implement the internal api used by the tools', async ({ context }) => { + // Listing a method here is enforced by the type, so adding one to the interface breaks compilation until it is covered. + const methods: Record = { + _enableRecorder: true, + _disableRecorder: true, + }; + for (const method of Object.keys(methods)) + expect(typeof context[method], method).toBe('function'); +}); + test('should click', async ({ context, browserName, platform, channel }) => { const log = await startRecording(context); const page = await context.newPage(); diff --git a/tests/mcp/capabilities.spec.ts b/tests/mcp/capabilities.spec.ts index 58782f8681a58..e3d0c9e45aa42 100644 --- a/tests/mcp/capabilities.spec.ts +++ b/tests/mcp/capabilities.spec.ts @@ -40,8 +40,6 @@ test('test snapshot tool list', async ({ client }) => { 'browser_resize', 'browser_run_code_unsafe', 'browser_snapshot', - 'browser_start_recording', - 'browser_stop_recording', 'browser_tabs', 'browser_take_screenshot', 'browser_wait_for', diff --git a/tests/mcp/cli-core.spec.ts b/tests/mcp/cli-core.spec.ts index 3de747b364a6b..790ca85d6f4c4 100644 --- a/tests/mcp/cli-core.spec.ts +++ b/tests/mcp/cli-core.spec.ts @@ -54,25 +54,6 @@ await page.getByRole('button', { name: 'Submit' }).click(); \`\`\``); }); -test('recording-start-stop', async ({ cli, server }) => { - server.setContent('/', `Title`, 'text/html'); - - const { snapshot } = await cli('open', server.PREFIX); - expect(snapshot).toContain(`- button "Submit" [ref=e2]`); - - const { output } = await cli('recording-start'); - expect(output).toContain('Recording started'); - - await cli('click', 'e2'); - - const { output: stopOutput } = await cli('recording-stop'); - expect(stopOutput).toContain(`Recording stopped. Recorded actions: - -\`\`\`js -await page.getByRole('button', { name: 'Submit' }).click(); -\`\`\``); -}); - test('click link', async ({ cli, server, mcpBrowser }) => { server.setContent('/', `Hello, world!`, 'text/html'); diff --git a/tests/mcp/cli-devtools.spec.ts b/tests/mcp/cli-devtools.spec.ts index 4be4836af79c2..7fc023f5e35a2 100644 --- a/tests/mcp/cli-devtools.spec.ts +++ b/tests/mcp/cli-devtools.spec.ts @@ -196,6 +196,25 @@ function escapeRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +test('recording-start-stop', async ({ cli, server }) => { + server.setContent('/', `Title`, 'text/html'); + + const { snapshot } = await cli('open', server.PREFIX); + expect(snapshot).toContain(`- button "Submit" [ref=e2]`); + + const { output } = await cli('recording-start'); + expect(output).toContain('Recording started'); + + await cli('click', 'e2'); + + const { output: stopOutput } = await cli('recording-stop'); + expect(stopOutput).toContain(`Recording stopped. Recorded actions: + +\`\`\`js +await page.getByRole('button', { name: 'Submit' }).click(); +\`\`\``); +}); + test('tracing-start-stop', async ({ cli, server }, testInfo) => { await cli('open', server.HELLO_WORLD); const { output } = await cli('tracing-start'); diff --git a/tests/mcp/recorder.spec.ts b/tests/mcp/recorder.spec.ts index 8538accc59fc1..b3867c5ea47c9 100644 --- a/tests/mcp/recorder.spec.ts +++ b/tests/mcp/recorder.spec.ts @@ -19,6 +19,8 @@ import { test, expect } from './fixtures'; import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; import type { TestServer } from '../config/testserver'; +test.use({ mcpCaps: ['devtools'] }); + async function navigateToForm(client: Client, server: TestServer) { server.setContent('/', ` Title From 9fcf3a3546d3094a21f21ce2296a0c01a2579a2b Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 24 Aug 2026 15:51:18 -0700 Subject: [PATCH 3/3] chore(mcp): explicit error when recorder api is missing --- packages/playwright-core/src/tools/backend/context.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/playwright-core/src/tools/backend/context.ts b/packages/playwright-core/src/tools/backend/context.ts index 867774989118b..7ebf88a39df98 100644 --- a/packages/playwright-core/src/tools/backend/context.ts +++ b/packages/playwright-core/src/tools/backend/context.ts @@ -241,6 +241,8 @@ export class Context { if (this._recordedActions) throw new Error('Recording is already in progress.'); const browserContext = await this.ensureBrowserContext() as BrowserContextEx; + if (typeof browserContext._enableRecorder !== 'function') + throw new Error('Recording requires a newer version of Playwright, please upgrade.'); const recordedActions: string[] = []; await browserContext._enableRecorder({ mode: 'recording',