Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 25 additions & 12 deletions packages/playwright-core/src/client/browserContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
private _closingStatus: 'none' | 'closing' | 'closed' = 'none';
private _closeReason: string | undefined;
private _harRouters: HarRouter[] = [];
private _onRecorderEventSink: RecorderEventSink | undefined;
private _recorderEventSinks = new Set<RecorderEventSink>();


static from(context: channels.BrowserContextChannel): BrowserContext {
Expand Down Expand Up @@ -169,12 +169,14 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
this._channel.on('requestFinished', params => this._onRequestFinished(params));
this._channel.on('response', ({ response, page }) => this._onResponse(network.Response.from(response), Page.fromNullable(page)));
this._channel.on('recorderEvent', ({ event, data, page, code }) => {
if (event === 'actionAdded')
this._onRecorderEventSink?.actionAdded?.(Page.from(page), data as actions.Action, code);
else if (event === 'actionUpdated')
this._onRecorderEventSink?.actionUpdated?.(Page.from(page), data as actions.Action, code);
else if (event === 'signalAdded')
this._onRecorderEventSink?.signalAdded?.(Page.from(page), data as actions.Signal, code);
for (const sink of this._recorderEventSinks) {
if (event === 'actionAdded')
sink.actionAdded?.(Page.from(page), data as actions.Action, code);
else if (event === 'actionUpdated')
sink.actionUpdated?.(Page.from(page), data as actions.Action, code);
else if (event === 'signalAdded')
sink.signalAdded?.(Page.from(page), data as actions.Signal, code);
}
});
this._closedPromise = new Promise(f => this.once(Events.BrowserContext.Close, f));

Expand Down Expand Up @@ -540,15 +542,26 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>

async _enableRecorder(params: channels.BrowserContextEnableRecorderParams, eventSink?: RecorderEventSink) {
if (eventSink)
this._onRecorderEventSink = eventSink;
await this._channel.enableRecorder(params, kNoTimeout);
this._recorderEventSinks.add(eventSink);
try {
await this._channel.enableRecorder(params, kNoTimeout);
} catch (error) {
if (eventSink)
this._recorderEventSinks.delete(eventSink);
throw error;
}
}

async _disableRecorder() {
async _disableRecorder(eventSink?: RecorderEventSink) {
const count = eventSink ? 1 : Math.max(1, this._recorderEventSinks.size);
try {
await this._channel.disableRecorder({}, kNoTimeout);
for (let i = 0; i < count; i++)
await this._channel.disableRecorder({}, kNoTimeout);
} finally {
this._onRecorderEventSink = undefined;
if (eventSink)
this._recorderEventSinks.delete(eventSink);
else
this._recorderEventSinks.clear();
}
}

Expand Down
4 changes: 4 additions & 0 deletions packages/playwright-core/src/server/recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,10 @@ export class Recorder extends EventEmitter<RecorderEventMap> implements Instrume
this._debugger.resume();
}

flush() {
this._signalProcessor.flush();
}

async hideHighlightedSelector() {
await this._updateHighlightedSelector(undefined);
}
Expand Down
18 changes: 14 additions & 4 deletions packages/playwright-core/src/server/recorder/recorderApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,23 +182,31 @@ export class RecorderApp {
if (process.env.PW_CODEGEN_NO_INSPECTOR)
return;
const recorder = await Recorder.forContext(context, params);
if (!(context as any)[recorderAppSymbol]) {
let entry = (context as any)[recorderAppSymbol] as RecorderAppEntry | undefined;
if (!entry) {
const app = params.recorderMode === 'api'
? new ProgrammaticRecorderApp(context, recorder, params)
: await RecorderApp._show(recorder, context, params);
(context as any)[recorderAppSymbol] = app;
entry = { app, clients: 0 };
(context as any)[recorderAppSymbol] = entry;
}
entry.clients++;
if (params.mode)
await recorder.setMode(params.mode);
}

static async disable(context: BrowserContext) {
const recorder = await Recorder.existingForContext(context);
const entry = (context as any)[recorderAppSymbol] as RecorderAppEntry | undefined;
if (entry && entry.clients > 1) {
entry.clients--;
recorder?.flush();
return;
}
if (recorder)
await recorder.setMode('none');
const app = (context as any)[recorderAppSymbol] as RecorderApp | ProgrammaticRecorderApp | undefined;
delete (context as any)[recorderAppSymbol];
await app?.close();
await entry?.app.close();
}

async close() {
Expand Down Expand Up @@ -432,3 +440,5 @@ function createRecorderFrontend(page: Page): RecorderFrontend {
}

const recorderAppSymbol = Symbol('recorderApp');

type RecorderAppEntry = { app: RecorderApp | ProgrammaticRecorderApp, clients: number };
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export type BrowserContextInternalApi = {
recorderMode?: 'default' | 'api',
omitCallTracking?: boolean,
}, eventSink?: RecorderEventSink): Promise<void>;
_disableRecorder(): Promise<void>;
_disableRecorder(eventSink?: RecorderEventSink): Promise<void>;
};

export type BrowserContextEx = playwrightTypes.BrowserContext & BrowserContextInternalApi;
33 changes: 17 additions & 16 deletions packages/playwright-core/src/tools/backend/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { playwright } from '../../inprocess';
import { dedent, languageGeneratorId, secretCode } from './codegen';
import { Tab } from './tab';

import type { BrowserContextEx } from './browserContextEx';
import type { BrowserContextEx, RecorderEventSink } from './browserContextEx';
import type { CodegenLanguage } from './codegen';
import type * as playwrightTypes from '../../..';
import type { SessionLog } from './sessionLog';
Expand Down Expand Up @@ -109,7 +109,7 @@ export class Context {
fileNames: string[];
fileName: string;
} | undefined;
private _recordedActions: string[] | undefined;
private _recording: { recordedActions: string[], eventSink: RecorderEventSink } | undefined;
private _disposables: Disposable[] = [];

private _runningToolName: string | undefined;
Expand Down Expand Up @@ -239,18 +239,13 @@ export class Context {
}

async startRecording() {
if (this._recordedActions)
if (this._recording)
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',
recorderMode: 'api',
omitCallTracking: true,
language: languageGeneratorId(this.codegenLanguage()),
}, {
const eventSink: RecorderEventSink = {
actionAdded: (page, action, code) => {
recordedActions.push(code);
},
Expand All @@ -264,17 +259,23 @@ export class Context {
if (recordedActions.length && code)
recordedActions[recordedActions.length - 1] = code;
},
});
this._recordedActions = recordedActions;
};
await browserContext._enableRecorder({
mode: 'recording',
recorderMode: 'api',
omitCallTracking: true,
language: languageGeneratorId(this.codegenLanguage()),
}, eventSink);
this._recording = { recordedActions, eventSink };
}

async stopRecording(): Promise<string[] | undefined> {
const recordedActions = this._recordedActions;
if (!recordedActions)
const recording = this._recording;
if (!recording)
return undefined;
this._recordedActions = undefined;
await (this._rawBrowserContext as BrowserContextEx)._disableRecorder();
return recordedActions.filter(code => code.trim()).map(dedent);
this._recording = undefined;
await (this._rawBrowserContext as BrowserContextEx)._disableRecorder(recording.eventSink);
return recording.recordedActions.filter(code => code.trim()).map(dedent);
}

codegenLanguage(): CodegenLanguage {
Expand Down
28 changes: 28 additions & 0 deletions tests/library/inspector/recorder-api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ async function startRecording(context) {
return {
action: (name: string) => log.actions.filter(a => a.action.name === name),
signals: () => log.signals,
stop: () => (context as any)._disableRecorder(log),
};
}

Expand Down Expand Up @@ -210,6 +211,33 @@ test('should record again after disable', async ({ context }) => {
expect(log2.action('click')).toHaveLength(1);
});

test('should keep recording for the remaining client after one stops', async ({ context }) => {
const page = await context.newPage();
await page.setContent(`<button>One</button><button>Two</button><button>Three</button>`);

const log1 = await startRecording(context);
await page.getByRole('button', { name: 'One' }).click();
await expect.poll(() => log1.action('click').length).toBe(1);

const log2 = await startRecording(context);
await page.getByRole('button', { name: 'Two' }).click();
await expect.poll(() => log2.action('click').length).toBe(1);

await log1.stop();
await page.getByRole('button', { name: 'Three' }).click();
await expect.poll(() => log2.action('click').length).toBe(2);
await log2.stop();

expect(log1.action('click').map(a => normalizeCode(a.code))).toEqual([
`await page.getByRole('button', { name: 'One' }).click();`,
`await page.getByRole('button', { name: 'Two' }).click();`,
]);
expect(log2.action('click').map(a => normalizeCode(a.code))).toEqual([
`await page.getByRole('button', { name: 'Two' }).click();`,
`await page.getByRole('button', { name: 'Three' }).click();`,
]);
});

test('disable should close the inspector window', async ({ context, openRecorder }) => {
const { recorder } = await openRecorder();
await (context as any)._disableRecorder();
Expand Down
59 changes: 59 additions & 0 deletions tests/mcp/http.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,65 @@ test('http transport browser lifecycle (persistent, multiclient)', async ({ serv
await client2.close();
});

test('http transport shared context keeps recordings per client', async ({ serverEndpoint, server }) => {
const { url } = await serverEndpoint({ args: ['--shared-browser-context', '--caps=devtools'] });
server.setContent('/', `
<title>Title</title>
<button>One</button>
<button>Two</button>
<button>Three</button>
`, 'text/html');

const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client1 = new Client({ name: 'test1', version: '1.0.0' });
await client1.connect(transport1);
const transport2 = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client2 = new Client({ name: 'test2', version: '1.0.0' });
await client2.connect(transport2);

await client1.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } });

await client1.callTool({ name: 'browser_start_recording' });
await client1.callTool({ name: 'browser_click', arguments: { element: 'One button', target: 'e2' } });

expect(await client2.callTool({ name: 'browser_stop_recording' })).toHaveResponse({
isError: true,
error: expect.stringContaining('No recording in progress'),
});

await client2.callTool({ name: 'browser_start_recording' });
await client2.callTool({ name: 'browser_click', arguments: { element: 'Two button', target: 'e3' } });

expect(await client1.callTool({ name: 'browser_stop_recording' })).toHaveResponse({
result: expect.stringContaining([
'Recording stopped. Recorded actions:',
'',
'```js',
`await page.getByRole('button', { name: 'One' }).click();`,
`await page.getByRole('button', { name: 'Two' }).click();`,
'```',
].join('\n')),
});

await client1.callTool({ name: 'browser_click', arguments: { element: 'Three button', target: 'e4' } });

expect(await client2.callTool({ name: 'browser_stop_recording' })).toHaveResponse({
result: expect.stringContaining([
'Recording stopped. Recorded actions:',
'',
'```js',
`await page.getByRole('button', { name: 'Two' }).click();`,
`await page.getByRole('button', { name: 'Three' }).click();`,
'```',
].join('\n')),
});

await transport1.terminateSession();
await client1.close();
await transport2.terminateSession();
await client2.close();
});

test('http transport shared context', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint({ args: ['--shared-browser-context'] });

Expand Down
Loading