Skip to content
Merged
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
7 changes: 5 additions & 2 deletions packages/playwright-core/src/client/browserContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,8 +542,11 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
}

async _disableRecorder() {
this._onRecorderEventSink = undefined;
await this._channel.disableRecorder({}, kNoTimeout);
try {
await this._channel.disableRecorder({}, kNoTimeout);
} finally {
this._onRecorderEventSink = undefined;
}
}

async _exposeConsoleApi() {
Expand Down
3 changes: 3 additions & 0 deletions packages/playwright-core/src/server/recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export class Recorder extends EventEmitter<RecorderEventMap> implements Instrume
});

context.on(BrowserContext.Events.BeforeClose, () => {
this._signalProcessor.flush();
this.emit(RecorderEvent.ContextClosed);
});
this._listeners.push(eventsHelper.addEventListener(process, 'exit', () => {
Expand Down Expand Up @@ -472,6 +473,8 @@ export class Recorder extends EventEmitter<RecorderEventMap> implements Instrume
}

private _setEnabled(enabled: boolean) {
if (this._enabled && !enabled)
this._signalProcessor.flush();
this._enabled = enabled;
}

Expand Down
9 changes: 7 additions & 2 deletions packages/playwright-core/src/server/recorder/recorderApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -379,17 +380,21 @@ 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 });
}),
eventsHelper.addEventListener(recorder, RecorderEvent.SignalAdded, signalInContext => {
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 });
}),
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,15 @@ export class RecorderSignalProcessor {
this._resetPendingTimeout();
return;
}
this._flushPendingAction();
this.flush();
}

if (this._shouldBuffer(actionInContext)) {
this._pendingAction = {
actionInContext,
receivedAt: timestamp,
signals: [],
timeout: setTimeout(() => this._flushPendingAction(), kActionBufferTimeout),
timeout: setTimeout(() => this.flush(), kActionBufferTimeout),
};
return;
}
Expand Down Expand Up @@ -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) {
Expand All @@ -116,7 +116,7 @@ export class RecorderSignalProcessor {
this._delegate.addAction(actionInContext);
}

private _flushPendingAction() {
flush() {
const pending = this._pendingAction;
if (!pending)
return;
Expand Down
35 changes: 35 additions & 0 deletions packages/playwright-core/src/tools/backend/browserContextEx.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
_disableRecorder(): Promise<void>;
};

export type BrowserContextEx = playwrightTypes.BrowserContext & BrowserContextInternalApi;
10 changes: 9 additions & 1 deletion packages/playwright-core/src/tools/backend/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}']`;
Expand Down Expand Up @@ -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;
Expand Down
47 changes: 44 additions & 3 deletions packages/playwright-core/src/tools/backend/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ 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 { BrowserContextEx } from './browserContextEx';
import type { CodegenLanguage } from './codegen';
import type * as playwrightTypes from '../../..';
import type { SessionLog } from './sessionLog';
import type { Disposable } from '@isomorphic/disposable';
Expand Down Expand Up @@ -106,6 +108,7 @@ export class Context {
fileNames: string[];
fileName: string;
} | undefined;
private _recordedActions: string[] | undefined;
private _disposables: Disposable[] = [];

private _runningToolName: string | undefined;
Expand All @@ -128,6 +131,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();
Expand Down Expand Up @@ -233,6 +237,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() 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()),
}, {
actionAdded: (page, action, code) => {
recordedActions.push(code);
},
signalAdded: (page, signal, code) => {
if (recordedActions.length && code)
recordedActions[recordedActions.length - 1] = code;
},
});
this._recordedActions = recordedActions;
}

async stopRecording(): Promise<string[] | undefined> {
const recordedActions = this._recordedActions;
if (!recordedActions)
return undefined;
this._recordedActions = undefined;
await (this._rawBrowserContext as BrowserContextEx)._disableRecorder();
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;
Expand Down Expand Up @@ -352,10 +394,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,
};
}
Expand Down
68 changes: 68 additions & 0 deletions packages/playwright-core/src/tools/backend/recorder.ts
Original file line number Diff line number Diff line change
@@ -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: 'devtools',

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: 'devtools',

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,
];
4 changes: 2 additions & 2 deletions packages/playwright-core/src/tools/backend/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions packages/playwright-core/src/tools/backend/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -61,6 +62,7 @@ export const browserTools: Tool<any>[] = [
...navigate,
...network,
...pdf,
...recorder,
...route,
...runCode,
...screenshot,
Expand Down
18 changes: 18 additions & 0 deletions packages/playwright-core/src/tools/cli-daemon/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: 'devtools',
toolName: 'browser_start_recording',
toolParams: () => ({}),
});

const recordingStop = declareCommand({
name: 'recording-stop',
description: 'Stop recording user actions and print them as Playwright code',
category: 'devtools',
toolName: 'browser_stop_recording',
toolParams: () => ({}),
});

const tracingStart = declareCommand({
name: 'tracing-start',
description: 'Start trace recording',
Expand Down Expand Up @@ -1241,6 +1257,8 @@ const commandsArray: AnyCommandSchema[] = [
installBrowser,

// devtools category
recordingStart,
recordingStop,
tracingStart,
tracingStop,
videoStart,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,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
Expand Down
Loading