chore(codegen): move action update into the recorder app (#36523)
infra / docs & lint (push) Waiting to run Details
infra / Lint snippets (push) Waiting to run Details
components / ${{ matrix.os }} - Node.js ${{ matrix.node-version }} (18, macos-latest) (push) Waiting to run Details
components / ${{ matrix.os }} - Node.js ${{ matrix.node-version }} (18, ubuntu-latest) (push) Waiting to run Details
components / ${{ matrix.os }} - Node.js ${{ matrix.node-version }} (18, windows-latest) (push) Waiting to run Details
components / ${{ matrix.os }} - Node.js ${{ matrix.node-version }} (20, ubuntu-latest) (push) Waiting to run Details
components / ${{ matrix.os }} - Node.js ${{ matrix.node-version }} (22, ubuntu-latest) (push) Waiting to run Details
tests others / Stress - ${{ matrix.os }} (macos-latest) (push) Waiting to run Details
tests others / Stress - ${{ matrix.os }} (ubuntu-latest) (push) Waiting to run Details
tests others / Stress - ${{ matrix.os }} (windows-latest) (push) Waiting to run Details
tests others / WebView2 (push) Waiting to run Details
tests others / time library - ${{ matrix.clock }} (frozen) (push) Waiting to run Details
tests others / time library - ${{ matrix.clock }} (realtime) (push) Waiting to run Details
tests others / time test runner - ${{ matrix.clock }} (frozen) (push) Waiting to run Details
tests others / time test runner - ${{ matrix.clock }} (realtime) (push) Waiting to run Details
tests others / legacy progress timeouts (push) Waiting to run Details
tests others / Electron - ${{ matrix.os }} (macos-latest) (push) Waiting to run Details
tests others / Electron - ${{ matrix.os }} (ubuntu-latest) (push) Waiting to run Details
tests others / Electron - ${{ matrix.os }} (windows-latest) (push) Waiting to run Details
tests 1 / ${{ matrix.os }} (${{ matrix.browser }} - Node.js ${{ matrix.node-version }}) (chromium, 18, ubuntu-22.04) (push) Waiting to run Details
tests 1 / ${{ matrix.os }} (${{ matrix.browser }} - Node.js ${{ matrix.node-version }}) (chromium, 20, ubuntu-22.04) (push) Waiting to run Details
tests 1 / ${{ matrix.os }} (${{ matrix.browser }} - Node.js ${{ matrix.node-version }}) (chromium, 22, ubuntu-22.04) (push) Waiting to run Details
tests 1 / ${{ matrix.os }} (${{ matrix.browser }} - Node.js ${{ matrix.node-version }}) (chromium, 24, ubuntu-22.04) (push) Waiting to run Details
tests 1 / ${{ matrix.os }} (${{ matrix.browser }} - Node.js ${{ matrix.node-version }}) (firefox, 18, ubuntu-22.04) (push) Waiting to run Details
tests 1 / ${{ matrix.os }} (${{ matrix.browser }} - Node.js ${{ matrix.node-version }}) (webkit, 18, ubuntu-22.04) (push) Waiting to run Details
tests 1 / ${{ matrix.os }} (chromium tip-of-tree) (ubuntu-22.04) (push) Waiting to run Details
tests 1 / Test Runner (18, macos-latest, 1, 2) (push) Waiting to run Details
tests 1 / Test Runner (18, macos-latest, 2, 2) (push) Waiting to run Details
tests 1 / Test Runner (18, ubuntu-latest, 1, 2) (push) Waiting to run Details
tests 1 / Test Runner (18, ubuntu-latest, 2, 2) (push) Waiting to run Details
tests 1 / Test Runner (18, windows-latest, 1, 2) (push) Waiting to run Details
tests 1 / Test Runner (18, windows-latest, 2, 2) (push) Waiting to run Details
tests 1 / Test Runner (20, ubuntu-latest, 1, 2) (push) Waiting to run Details
tests 1 / Test Runner (20, ubuntu-latest, 2, 2) (push) Waiting to run Details
tests 1 / Test Runner (22, ubuntu-latest, 1, 2) (push) Waiting to run Details
tests 1 / Test Runner (22, ubuntu-latest, 2, 2) (push) Waiting to run Details
tests 1 / Test Runner (24, ubuntu-latest, 1, 2) (push) Waiting to run Details
tests 1 / Test Runner (24, ubuntu-latest, 2, 2) (push) Waiting to run Details
tests 1 / Web Components (push) Waiting to run Details
tests 1 / VSCode Extension (push) Waiting to run Details
tests 1 / Installation Test ${{ matrix.os }} (macos-latest) (push) Waiting to run Details
tests 1 / Installation Test ${{ matrix.os }} (ubuntu-latest) (push) Waiting to run Details
tests 1 / Installation Test ${{ matrix.os }} (windows-latest) (push) Waiting to run Details
tests 2 / ${{ matrix.os }} (${{ matrix.browser }}) (chromium, ubuntu-24.04) (push) Waiting to run Details
tests 2 / ${{ matrix.os }} (${{ matrix.browser }}) (firefox, ubuntu-24.04) (push) Waiting to run Details
tests 2 / ${{ matrix.os }} (${{ matrix.browser }}) (webkit, ubuntu-24.04) (push) Waiting to run Details
tests 2 / ${{ matrix.os }} (${{ matrix.browser }}) (chromium, macos-13-large) (push) Waiting to run Details
tests 2 / ${{ matrix.os }} (${{ matrix.browser }}) (chromium, macos-13-xlarge) (push) Waiting to run Details
tests 2 / ${{ matrix.os }} (${{ matrix.browser }}) (chromium, macos-14-large) (push) Waiting to run Details
tests 2 / ${{ matrix.os }} (${{ matrix.browser }}) (chromium, macos-14-xlarge) (push) Waiting to run Details
tests 2 / ${{ matrix.os }} (${{ matrix.browser }}) (firefox, macos-13-large) (push) Waiting to run Details
tests 2 / ${{ matrix.os }} (${{ matrix.browser }}) (firefox, macos-13-xlarge) (push) Waiting to run Details
tests 2 / ${{ matrix.os }} (${{ matrix.browser }}) (firefox, macos-14-large) (push) Waiting to run Details
tests 2 / ${{ matrix.os }} (${{ matrix.browser }}) (firefox, macos-14-xlarge) (push) Waiting to run Details
tests 2 / ${{ matrix.os }} (${{ matrix.browser }}) (webkit, macos-13-large) (push) Waiting to run Details
tests 2 / ${{ matrix.os }} (${{ matrix.browser }}) (webkit, macos-13-xlarge) (push) Waiting to run Details
tests 2 / ${{ matrix.os }} (${{ matrix.browser }}) (webkit, macos-14-large) (push) Waiting to run Details
tests 2 / ${{ matrix.os }} (${{ matrix.browser }}) (webkit, macos-14-xlarge) (push) Waiting to run Details
tests 2 / ${{ matrix.os }} (${{ matrix.browser }}) (webkit, macos-15-large) (push) Waiting to run Details
tests 2 / ${{ matrix.os }} (${{ matrix.browser }}) (webkit, macos-15-xlarge) (push) Waiting to run Details
tests 2 / Windows (chromium) (push) Waiting to run Details
tests 2 / Windows (firefox) (push) Waiting to run Details
tests 2 / Windows (webkit) (push) Waiting to run Details
tests 2 / Installation Test ${{ matrix.os }} (${{ matrix.node_version }}) (20, ubuntu-latest) (push) Waiting to run Details
tests 2 / Installation Test ${{ matrix.os }} (${{ matrix.node_version }}) (22, ubuntu-latest) (push) Waiting to run Details
tests 2 / Installation Test ${{ matrix.os }} (${{ matrix.node_version }}) (24, ubuntu-latest) (push) Waiting to run Details
tests 2 / headed ${{ matrix.browser }} (${{ matrix.os }}) (chromium, macos-14-xlarge) (push) Waiting to run Details
tests 2 / headed ${{ matrix.browser }} (${{ matrix.os }}) (chromium, ubuntu-24.04) (push) Waiting to run Details
tests 2 / headed ${{ matrix.browser }} (${{ matrix.os }}) (chromium, windows-latest) (push) Waiting to run Details
tests 2 / headed ${{ matrix.browser }} (${{ matrix.os }}) (firefox, macos-14-xlarge) (push) Waiting to run Details
tests 2 / headed ${{ matrix.browser }} (${{ matrix.os }}) (firefox, ubuntu-24.04) (push) Waiting to run Details
tests 2 / headed ${{ matrix.browser }} (${{ matrix.os }}) (firefox, windows-latest) (push) Waiting to run Details
tests 2 / headed ${{ matrix.browser }} (${{ matrix.os }}) (webkit, macos-14-xlarge) (push) Waiting to run Details
tests 2 / headed ${{ matrix.browser }} (${{ matrix.os }}) (webkit, ubuntu-22.04) (push) Waiting to run Details
tests 2 / headed ${{ matrix.browser }} (${{ matrix.os }}) (webkit, ubuntu-24.04) (push) Waiting to run Details
tests 2 / headed ${{ matrix.browser }} (${{ matrix.os }}) (webkit, windows-latest) (push) Waiting to run Details
tests 2 / Transport (driver) (push) Waiting to run Details
tests 2 / Transport (service) (push) Waiting to run Details
tests 2 / Tracing ${{ matrix.browser }} ${{ matrix.channel }} (chromium, chromium-tip-of-tree, ubuntu-22.04) (push) Waiting to run Details
tests 2 / Tracing ${{ matrix.browser }} ${{ matrix.channel }} (chromium, ubuntu-22.04) (push) Waiting to run Details
tests 2 / Tracing ${{ matrix.browser }} ${{ matrix.channel }} (firefox, ubuntu-22.04) (push) Waiting to run Details
tests 2 / Tracing ${{ matrix.browser }} ${{ matrix.channel }} (webkit, ubuntu-24.04) (push) Waiting to run Details
tests 2 / Test ${{ matrix.channel }} on ${{ matrix.runs-on }} (chrome, macos-latest) (push) Waiting to run Details
tests 2 / Test ${{ matrix.channel }} on ${{ matrix.runs-on }} (chrome, ubuntu-22.04) (push) Waiting to run Details
tests 2 / Test ${{ matrix.channel }} on ${{ matrix.runs-on }} (chrome, windows-latest) (push) Waiting to run Details
tests 2 / Test ${{ matrix.channel }} on ${{ matrix.runs-on }} (chrome-beta, macos-latest) (push) Waiting to run Details
tests 2 / Test ${{ matrix.channel }} on ${{ matrix.runs-on }} (chrome-beta, ubuntu-22.04) (push) Waiting to run Details
tests 2 / Test ${{ matrix.channel }} on ${{ matrix.runs-on }} (chrome-beta, windows-latest) (push) Waiting to run Details
tests 2 / Test ${{ matrix.channel }} on ${{ matrix.runs-on }} (msedge, macos-latest) (push) Waiting to run Details
tests 2 / Test ${{ matrix.channel }} on ${{ matrix.runs-on }} (msedge, ubuntu-22.04) (push) Waiting to run Details
tests 2 / Test ${{ matrix.channel }} on ${{ matrix.runs-on }} (msedge, windows-latest) (push) Waiting to run Details
tests 2 / Test ${{ matrix.channel }} on ${{ matrix.runs-on }} (msedge-beta, macos-latest) (push) Waiting to run Details
tests 2 / Test ${{ matrix.channel }} on ${{ matrix.runs-on }} (msedge-beta, ubuntu-22.04) (push) Waiting to run Details
tests 2 / Test ${{ matrix.channel }} on ${{ matrix.runs-on }} (msedge-beta, windows-latest) (push) Waiting to run Details
tests 2 / Test ${{ matrix.channel }} on ${{ matrix.runs-on }} (msedge-dev, macos-latest) (push) Waiting to run Details
tests 2 / Test ${{ matrix.channel }} on ${{ matrix.runs-on }} (msedge-dev, ubuntu-22.04) (push) Waiting to run Details
tests 2 / Test ${{ matrix.channel }} on ${{ matrix.runs-on }} (msedge-dev, windows-latest) (push) Waiting to run Details
tests 2 / Chromium tip-of-tree ${{ matrix.os }}${{ matrix.headed }} (, macos-13) (push) Waiting to run Details
tests 2 / Chromium tip-of-tree ${{ matrix.os }}${{ matrix.headed }} (, windows-latest) (push) Waiting to run Details
tests 2 / Chromium tip-of-tree ${{ matrix.os }}${{ matrix.headed }} (--headed, macos-13) (push) Waiting to run Details
tests 2 / Chromium tip-of-tree ${{ matrix.os }}${{ matrix.headed }} (--headed, ubuntu-22.04) (push) Waiting to run Details
tests 2 / Chromium tip-of-tree ${{ matrix.os }}${{ matrix.headed }} (--headed, windows-latest) (push) Waiting to run Details
tests 2 / Chromium tip-of-tree headless-shell-${{ matrix.os }} (ubuntu-22.04) (push) Waiting to run Details
tests 2 / Firefox Beta ${{ matrix.os }} (macos-latest) (push) Waiting to run Details
tests 2 / Firefox Beta ${{ matrix.os }} (ubuntu-22.04) (push) Waiting to run Details
tests 2 / Firefox Beta ${{ matrix.os }} (windows-latest) (push) Waiting to run Details
tests 2 / build-playwright-driver (push) Waiting to run Details
tests 2 / Test channel=chromium (macos-latest) (push) Waiting to run Details
tests 2 / Test channel=chromium (ubuntu-latest) (push) Waiting to run Details
tests 2 / Test channel=chromium (windows-latest) (push) Waiting to run Details
tests Video / Video Linux (chromium, ubuntu-22.04) (push) Waiting to run Details
tests Video / Video Linux (chromium, ubuntu-24.04) (push) Waiting to run Details
tests Video / Video Linux (firefox, ubuntu-22.04) (push) Waiting to run Details
tests Video / Video Linux (firefox, ubuntu-24.04) (push) Waiting to run Details
tests Video / Video Linux (webkit, ubuntu-22.04) (push) Waiting to run Details
tests Video / Video Linux (webkit, ubuntu-24.04) (push) Waiting to run Details
Internal Tests / trigger (push) Waiting to run Details

This commit is contained in:
Pavel Feldman 2025-07-02 17:18:45 -07:00 committed by GitHub
parent 04d1c083b1
commit fc0b770d0c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 282 additions and 205 deletions

View File

@ -71,7 +71,14 @@ commandWithOpenOptions('codegen [url]', 'open page and generate code for user ac
['--target <language>', `language to generate, one of javascript, playwright-test, python, python-async, python-pytest, csharp, csharp-mstest, csharp-nunit, java, java-junit`, codegenId()], ['--target <language>', `language to generate, one of javascript, playwright-test, python, python-async, python-pytest, csharp, csharp-mstest, csharp-nunit, java, java-junit`, codegenId()],
['--test-id-attribute <attributeName>', 'use the specified attribute to generate data test ID selectors'], ['--test-id-attribute <attributeName>', 'use the specified attribute to generate data test ID selectors'],
]).action(function(url, options) { ]).action(function(url, options) {
codegen(options, url).catch(logErrorAndExit); codegen(options, url).catch(error => {
if (process.env.PWTEST_CLI_AUTO_EXIT_WHEN) {
// Tests with PWTEST_CLI_AUTO_EXIT_WHEN might close page too fast, resulting
// in a stray navigation aborted error. We should ignore it.
} else {
throw error;
}
});
}).addHelpText('afterAll', ` }).addHelpText('afterAll', `
Examples: Examples:
@ -480,8 +487,12 @@ async function launchContext(options: Options, extraOptions: LaunchOptions): Pro
process.stdout.write(text); process.stdout.write(text);
process.stdout.write('\n-------------8<-------------\n'); process.stdout.write('\n-------------8<-------------\n');
const autoExitCondition = process.env.PWTEST_CLI_AUTO_EXIT_WHEN; const autoExitCondition = process.env.PWTEST_CLI_AUTO_EXIT_WHEN;
if (autoExitCondition && text.includes(autoExitCondition)) if (autoExitCondition && text.includes(autoExitCondition)) {
closeBrowser(); // Firefox needs a break here
setTimeout(() => {
closeBrowser();
}, 1000);
}
}; };
// Make sure we exit abnormally when browser crashes. // Make sure we exit abnormally when browser crashes.
const logs: string[] = []; const logs: string[] = [];
@ -615,14 +626,7 @@ async function openPage(context: BrowserContext, url: string | undefined): Promi
url = 'file://' + path.resolve(url); url = 'file://' + path.resolve(url);
else if (!url.startsWith('http') && !url.startsWith('file://') && !url.startsWith('about:') && !url.startsWith('data:')) else if (!url.startsWith('http') && !url.startsWith('file://') && !url.startsWith('about:') && !url.startsWith('data:'))
url = 'http://' + url; url = 'http://' + url;
await page.goto(url).catch(error => { await page.goto(url);
if (process.env.PWTEST_CLI_AUTO_EXIT_WHEN) {
// Tests with PWTEST_CLI_AUTO_EXIT_WHEN might close page too fast, resulting
// in a stray navigation aborted error. We should ignore it.
} else {
throw error;
}
});
} }
return page; return page;
} }

View File

@ -6,8 +6,7 @@
../utilsBundle.ts ../utilsBundle.ts
../zipBundle.ts ../zipBundle.ts
./ ./
./codegen/language.ts ./codegen/
./codegen/languages.ts
./isomorphic/ ./isomorphic/
./har/ ./har/
./recorder/ ./recorder/

View File

@ -31,7 +31,6 @@ import { SdkObject } from './instrumentation';
import * as network from './network'; import * as network from './network';
import { InitScript } from './page'; import { InitScript } from './page';
import { Page, PageBinding } from './page'; import { Page, PageBinding } from './page';
import { Recorder } from './recorder';
import { RecorderApp } from './recorder/recorderApp'; import { RecorderApp } from './recorder/recorderApp';
import { Selectors } from './selectors'; import { Selectors } from './selectors';
import { Tracing } from './trace/recorder/tracing'; import { Tracing } from './trace/recorder/tracing';
@ -129,15 +128,15 @@ export abstract class BrowserContext extends SdkObject {
// When PWDEBUG=1, show inspector for each context. // When PWDEBUG=1, show inspector for each context.
if (debugMode() === 'inspector') if (debugMode() === 'inspector')
await Recorder.show(this, RecorderApp.factory(this), { pauseOnNextStatement: true }); await RecorderApp.show(this, { pauseOnNextStatement: true });
// When paused, show inspector. // When paused, show inspector.
if (this._debugger.isPaused()) if (this._debugger.isPaused())
Recorder.showInspectorNoReply(this, RecorderApp.factory(this)); await RecorderApp.showInspectorNoReply(this);
this._debugger.on(Debugger.Events.PausedStateChanged, () => { this._debugger.on(Debugger.Events.PausedStateChanged, () => {
if (this._debugger.isPaused()) if (this._debugger.isPaused())
Recorder.showInspectorNoReply(this, RecorderApp.factory(this)); RecorderApp.showInspectorNoReply(this);
}); });
if (debugMode() === 'console') if (debugMode() === 'console')

View File

@ -22,16 +22,16 @@ import { PythonLanguageGenerator } from './python';
export function languageSet() { export function languageSet() {
return new Set([ return new Set([
new JavaLanguageGenerator('junit'),
new JavaLanguageGenerator('library'),
new JavaScriptLanguageGenerator(/* isPlaywrightTest */false),
new JavaScriptLanguageGenerator(/* isPlaywrightTest */true), new JavaScriptLanguageGenerator(/* isPlaywrightTest */true),
new JavaScriptLanguageGenerator(/* isPlaywrightTest */false),
new PythonLanguageGenerator(/* isAsync */false, /* isPytest */true), new PythonLanguageGenerator(/* isAsync */false, /* isPytest */true),
new PythonLanguageGenerator(/* isAsync */false, /* isPytest */false), new PythonLanguageGenerator(/* isAsync */false, /* isPytest */false),
new PythonLanguageGenerator(/* isAsync */true, /* isPytest */false), new PythonLanguageGenerator(/* isAsync */true, /* isPytest */false),
new CSharpLanguageGenerator('mstest'), new CSharpLanguageGenerator('mstest'),
new CSharpLanguageGenerator('nunit'), new CSharpLanguageGenerator('nunit'),
new CSharpLanguageGenerator('library'), new CSharpLanguageGenerator('library'),
new JavaLanguageGenerator('junit'),
new JavaLanguageGenerator('library'),
new JsonlLanguageGenerator(), new JsonlLanguageGenerator(),
]); ]);
} }

View File

@ -22,14 +22,18 @@ import { parseAriaSnapshotUnsafe } from '../utils/isomorphic/ariaSnapshot';
import { yaml } from '../utilsBundle'; import { yaml } from '../utilsBundle';
import { EmptyRecorderApp } from './recorder/recorderApp'; import { EmptyRecorderApp } from './recorder/recorderApp';
import { unsafeLocatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser'; import { unsafeLocatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser';
import { generateCode } from './codegen/language';
import { collapseActions } from './recorder/recorderUtils';
import { JavaScriptLanguageGenerator } from './codegen/javascript';
import type { Language } from '../utils'; import type { Language } from '../utils';
import type { Browser } from './browser'; import type { Browser } from './browser';
import type { BrowserContext } from './browserContext'; import type { BrowserContext } from './browserContext';
import type { InstrumentationListener } from './instrumentation'; import type { InstrumentationListener } from './instrumentation';
import type { Playwright } from './playwright'; import type { Playwright } from './playwright';
import type { ElementInfo, Mode, Source } from '@recorder/recorderTypes'; import type { ElementInfo, Mode } from '@recorder/recorderTypes';
import type { Progress } from '@protocol/progress'; import type { Progress } from '@protocol/progress';
import type * as actions from '@recorder/actions';
export class DebugController extends SdkObject { export class DebugController extends SdkObject {
static Events = { static Events = {
@ -43,7 +47,6 @@ export class DebugController extends SdkObject {
private _trackHierarchyListener: InstrumentationListener | undefined; private _trackHierarchyListener: InstrumentationListener | undefined;
private _playwright: Playwright; private _playwright: Playwright;
_sdkLanguage: Language = 'javascript'; _sdkLanguage: Language = 'javascript';
_codegenId: string = 'playwright-test';
constructor(playwright: Playwright) { constructor(playwright: Playwright) {
super({ attribution: { isInternalPlaywright: true }, instrumentation: createInstrumentation() } as any, undefined, 'DebugController'); super({ attribution: { isInternalPlaywright: true }, instrumentation: createInstrumentation() } as any, undefined, 'DebugController');
@ -51,7 +54,6 @@ export class DebugController extends SdkObject {
} }
initialize(codegenId: string, sdkLanguage: Language) { initialize(codegenId: string, sdkLanguage: Language) {
this._codegenId = codegenId;
this._sdkLanguage = sdkLanguage; this._sdkLanguage = sdkLanguage;
} }
@ -86,8 +88,7 @@ export class DebugController extends SdkObject {
await p.mainFrame().goto(progress, url); await p.mainFrame().goto(progress, url);
} }
async setRecorderMode(progress: Progress, params: { mode: Mode, file?: string, testIdAttributeName?: string }) { async setRecorderMode(progress: Progress, params: { mode: Mode, testIdAttributeName?: string }) {
// TODO: |file| is only used in the legacy mode.
await progress.race(this._closeBrowsersWithoutPages()); await progress.race(this._closeBrowsersWithoutPages());
if (params.mode === 'none') { if (params.mode === 'none') {
@ -115,8 +116,6 @@ export class DebugController extends SdkObject {
// Toggle the mode. // Toggle the mode.
for (const recorder of await progress.race(this._allRecorders())) { for (const recorder of await progress.race(this._allRecorders())) {
recorder.hideHighlightedSelector(); recorder.hideHighlightedSelector();
if (params.mode !== 'inspecting')
recorder.setOutput(this._codegenId, params.file);
recorder.setMode(params.mode); recorder.setMode(params.mode);
} }
} }
@ -188,10 +187,13 @@ export class DebugController extends SdkObject {
class InspectingRecorderApp extends EmptyRecorderApp { class InspectingRecorderApp extends EmptyRecorderApp {
private _debugController: DebugController; private _debugController: DebugController;
private _actions: actions.ActionInContext[] = [];
private _languageGenerator: JavaScriptLanguageGenerator;
constructor(debugController: DebugController) { constructor(debugController: DebugController) {
super(); super();
this._debugController = debugController; this._debugController = debugController;
this._languageGenerator = new JavaScriptLanguageGenerator(/* isPlaywrightTest */true);
} }
override async elementPicked(elementInfo: ElementInfo): Promise<void> { override async elementPicked(elementInfo: ElementInfo): Promise<void> {
@ -199,12 +201,6 @@ class InspectingRecorderApp extends EmptyRecorderApp {
this._debugController.emit(DebugController.Events.InspectRequested, { selector: elementInfo.selector, locator, ariaSnapshot: elementInfo.ariaSnapshot }); this._debugController.emit(DebugController.Events.InspectRequested, { selector: elementInfo.selector, locator, ariaSnapshot: elementInfo.ariaSnapshot });
} }
override async setSources(sources: Source[]): Promise<void> {
const source = sources.find(s => s.id === this._debugController._codegenId);
const { text, header, footer, actions } = source || { text: '' };
this._debugController.emit(DebugController.Events.SourceChanged, { text, header, footer, actions });
}
override async setPaused(paused: boolean) { override async setPaused(paused: boolean) {
this._debugController.emit(DebugController.Events.Paused, { paused }); this._debugController.emit(DebugController.Events.Paused, { paused });
} }
@ -212,4 +208,26 @@ class InspectingRecorderApp extends EmptyRecorderApp {
override async setMode(mode: Mode) { override async setMode(mode: Mode) {
this._debugController.emit(DebugController.Events.SetModeRequested, { mode }); this._debugController.emit(DebugController.Events.SetModeRequested, { mode });
} }
override async actionAdded(action: actions.ActionInContext): Promise<void> {
this._actions.push(action);
this._actionsChanged();
}
override async signalAdded(signal: actions.Signal): Promise<void> {
const lastAction = this._actions[this._actions.length - 1];
if (lastAction)
lastAction.action.signals.push(signal);
this._actionsChanged();
}
private _actionsChanged() {
const actions = collapseActions(this._actions);
const { header, footer, text, actionTexts } = generateCode(actions, this._languageGenerator, {
browserName: 'chromium',
launchOptions: {},
contextOptions: {},
});
this._debugController.emit(DebugController.Events.SourceChanged, { text, header, footer, actions: actionTexts });
}
} }

View File

@ -29,7 +29,6 @@ import { APIRequestContextDispatcher, RequestDispatcher, ResponseDispatcher, Rou
import { BindingCallDispatcher, PageDispatcher, WorkerDispatcher } from './pageDispatcher'; import { BindingCallDispatcher, PageDispatcher, WorkerDispatcher } from './pageDispatcher';
import { CRBrowserContext } from '../chromium/crBrowser'; import { CRBrowserContext } from '../chromium/crBrowser';
import { serializeError } from '../errors'; import { serializeError } from '../errors';
import { Recorder } from '../recorder';
import { TracingDispatcher } from './tracingDispatcher'; import { TracingDispatcher } from './tracingDispatcher';
import { WebSocketRouteDispatcher } from './webSocketRouteDispatcher'; import { WebSocketRouteDispatcher } from './webSocketRouteDispatcher';
import { WritableStreamDispatcher } from './writableStreamDispatcher'; import { WritableStreamDispatcher } from './writableStreamDispatcher';
@ -332,7 +331,7 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
} }
async enableRecorder(params: channels.BrowserContextEnableRecorderParams, progress: Progress): Promise<void> { async enableRecorder(params: channels.BrowserContextEnableRecorderParams, progress: Progress): Promise<void> {
await Recorder.show(this._context, RecorderApp.factory(this._context), params); await RecorderApp.show(this._context, params);
} }
async pause(params: channels.BrowserContextPauseParams, progress: Progress) { async pause(params: channels.BrowserContextPauseParams, progress: Progress) {

View File

@ -27,16 +27,12 @@ import { serverSideCallMetadata } from './instrumentation';
import { RecorderSignalProcessor } from './recorder/recorderSignalProcessor'; import { RecorderSignalProcessor } from './recorder/recorderSignalProcessor';
import * as rawRecorderSource from './../generated/pollingRecorderSource'; import * as rawRecorderSource from './../generated/pollingRecorderSource';
import { eventsHelper, monotonicTime } from './../utils'; import { eventsHelper, monotonicTime } from './../utils';
import { languageSet } from './codegen/languages';
import { Frame } from './frames'; import { Frame } from './frames';
import { Page } from './page'; import { Page } from './page';
import { ThrottledFile } from './recorder/throttledFile';
import { generateCode } from './codegen/language';
import { performAction } from './recorder/recorderRunner'; import { performAction } from './recorder/recorderRunner';
import { collapseActions } from './recorder/recorderUtils';
import type { Language, LanguageGenerator, LanguageGeneratorOptions } from './codegen/types'; import type { Language } from './codegen/types';
import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation'; import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation';
import type { IRecorder, IRecorderApp, IRecorderAppFactory } from './recorder/recorderFrontend'; import type { IRecorder, IRecorderApp, IRecorderAppFactory } from './recorder/recorderFrontend';
import type { Point } from '../utils/isomorphic/types'; import type { Point } from '../utils/isomorphic/types';
@ -60,11 +56,10 @@ export class Recorder implements InstrumentationListener, IRecorder {
private _overlayState: OverlayState = { offsetX: 0 }; private _overlayState: OverlayState = { offsetX: 0 };
private _recorderApp: IRecorderApp | null = null; private _recorderApp: IRecorderApp | null = null;
private _currentCallsMetadata = new Map<CallMetadata, SdkObject>(); private _currentCallsMetadata = new Map<CallMetadata, SdkObject>();
private _recorderSources: Source[] = [];
private _userSources = new Map<string, Source>(); private _userSources = new Map<string, Source>();
private _debugger: Debugger; private _debugger: Debugger;
private _omitCallTracking = false; private _omitCallTracking = false;
private _currentLanguage: Language; private _currentLanguage: Language = 'javascript';
private _recorderMode: 'record' | 'perform'; private _recorderMode: 'record' | 'perform';
private _signalProcessor: RecorderSignalProcessor; private _signalProcessor: RecorderSignalProcessor;
@ -72,23 +67,13 @@ export class Recorder implements InstrumentationListener, IRecorder {
private _lastPopupOrdinal = 0; private _lastPopupOrdinal = 0;
private _lastDialogOrdinal = -1; private _lastDialogOrdinal = -1;
private _lastDownloadOrdinal = -1; private _lastDownloadOrdinal = -1;
private _throttledOutputFile: ThrottledFile | null = null;
private _orderedLanguages: LanguageGenerator[] = [];
private _listeners: RegisteredListener[] = []; private _listeners: RegisteredListener[] = [];
private _actions: actions.ActionInContext[] = [];
private _languageGeneratorOptions: LanguageGeneratorOptions;
private _enabled: boolean = false; private _enabled: boolean = false;
static async showInspector(context: BrowserContext, params: channels.BrowserContextEnableRecorderParams, recorderAppFactory: IRecorderAppFactory) { static async showInspector(context: BrowserContext, params: channels.BrowserContextEnableRecorderParams, recorderAppFactory: IRecorderAppFactory) {
if (isUnderTest())
params.language = process.env.TEST_INSPECTOR_LANGUAGE;
return await Recorder.show(context, recorderAppFactory, params); return await Recorder.show(context, recorderAppFactory, params);
} }
static showInspectorNoReply(context: BrowserContext, recorderAppFactory: IRecorderAppFactory) {
Recorder.showInspector(context, {}, recorderAppFactory).catch(() => {});
}
static show(context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextEnableRecorderParams): Promise<Recorder> { static show(context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextEnableRecorderParams): Promise<Recorder> {
let recorderPromise = (context as any)[recorderSymbol] as Promise<Recorder>; let recorderPromise = (context as any)[recorderSymbol] as Promise<Recorder>;
if (!recorderPromise) { if (!recorderPromise) {
@ -102,6 +87,7 @@ export class Recorder implements InstrumentationListener, IRecorder {
const recorder = new Recorder(context, params); const recorder = new Recorder(context, params);
const recorderApp = await recorderAppFactory(recorder); const recorderApp = await recorderAppFactory(recorder);
await recorder._install(recorderApp); await recorder._install(recorderApp);
recorderApp.start();
return recorder; return recorder;
} }
@ -112,46 +98,28 @@ export class Recorder implements InstrumentationListener, IRecorder {
this._recorderMode = params.recorderMode ?? 'perform'; this._recorderMode = params.recorderMode ?? 'perform';
this.handleSIGINT = params.handleSIGINT; this.handleSIGINT = params.handleSIGINT;
// Make a copy of options to modify them later.
this._languageGeneratorOptions = {
browserName: context._browser.options.name,
launchOptions: { headless: false, ...params.launchOptions, tracesDir: undefined },
contextOptions: { ...params.contextOptions },
deviceName: params.device,
saveStorage: params.saveStorage,
};
this._signalProcessor = new RecorderSignalProcessor(); this._signalProcessor = new RecorderSignalProcessor();
this._signalProcessor.on('action', (actionInContext: actions.ActionInContext) => { this._signalProcessor.on('action', (actionInContext: actions.ActionInContext) => {
if (!this._enabled) if (this._enabled)
return; this._recorderApp?.actionAdded(actionInContext);
this._actions.push(actionInContext);
this._updateActions();
}); });
this._signalProcessor.on('signal', (signal: Signal) => { this._signalProcessor.on('signal', (signal: Signal) => {
if (!this._enabled) if (this._enabled)
return; this._recorderApp?.signalAdded(signal);
const lastAction = this._actions[this._actions.length - 1];
if (lastAction)
lastAction.action.signals.push(signal);
this._updateActions();
}); });
context.on(BrowserContext.Events.BeforeClose, () => { context.on(BrowserContext.Events.BeforeClose, () => {
this._throttledOutputFile?.flush(); this._recorderApp?.flushOutput().catch(() => {});
}); });
this._listeners.push(eventsHelper.addEventListener(process, 'exit', () => { this._listeners.push(eventsHelper.addEventListener(process, 'exit', () => {
this._throttledOutputFile?.flush(); this._recorderApp?.flushOutput().catch(() => {});
})); }));
const language = params.language || context._browser.sdkLanguage();
this._innerSetOutput(language, params.outputFile);
this._setEnabled(params.mode === 'recording'); this._setEnabled(params.mode === 'recording');
this._omitCallTracking = !!params.omitCallTracking; this._omitCallTracking = !!params.omitCallTracking;
this._debugger = context.debugger(); this._debugger = context.debugger();
context.instrumentation.addListener(this, context); context.instrumentation.addListener(this, context);
this._currentLanguage = this._languageName();
if (isUnderTest()) { if (isUnderTest()) {
// Most of our tests put elements at the top left, so get out of the way. // Most of our tests put elements at the top left, so get out of the way.
@ -181,8 +149,8 @@ export class Recorder implements InstrumentationListener, IRecorder {
this._debugger.resume(true); this._debugger.resume(true);
return; return;
} }
if (data.event === 'fileChanged') { if (data.event === 'languageChanged') {
this._currentLanguage = this._languageName(data.params.file); this._currentLanguage = data.params.language;
this._refreshOverlay(); this._refreshOverlay();
return; return;
} }
@ -203,7 +171,7 @@ export class Recorder implements InstrumentationListener, IRecorder {
await Promise.all([ await Promise.all([
recorderApp.setMode(this._mode), recorderApp.setMode(this._mode),
recorderApp.setPaused(this._debugger.isPaused()), recorderApp.setPaused(this._debugger.isPaused()),
this._pushAllSources() this._pushUserSources()
]); ]);
this._context.once(BrowserContext.Events.Close, () => { this._context.once(BrowserContext.Events.Close, () => {
@ -361,21 +329,6 @@ export class Recorder implements InstrumentationListener, IRecorder {
} }
} }
setOutput(codegenId: string, outputFile: string | undefined) {
this._innerSetOutput(codegenId, outputFile);
this._resetActions();
}
private _innerSetOutput(codegenId: string, outputFile: string | undefined) {
const languages = languageSet();
const primaryLanguage = [...languages].find(l => l.id === codegenId);
if (!primaryLanguage)
throw new Error(`\n===============================\nUnsupported language: '${codegenId}'\n===============================\n`);
languages.delete(primaryLanguage);
this._orderedLanguages = [primaryLanguage, ...languages];
this._throttledOutputFile = outputFile ? new ThrottledFile(outputFile) : null;
}
private _refreshOverlay() { private _refreshOverlay() {
for (const page of this._context.pages()) { for (const page of this._context.pages()) {
for (const frame of page.frames()) for (const frame of page.frames())
@ -406,37 +359,33 @@ export class Recorder implements InstrumentationListener, IRecorder {
private _updateUserSources() { private _updateUserSources() {
// Remove old decorations. // Remove old decorations.
const timestamp = monotonicTime();
for (const source of this._userSources.values()) { for (const source of this._userSources.values()) {
source.highlight = []; source.highlight = [];
source.revealLine = undefined; source.revealLine = undefined;
} }
// Apply new decorations. // Apply new decorations.
let fileToSelect = undefined;
for (const metadata of this._currentCallsMetadata.keys()) { for (const metadata of this._currentCallsMetadata.keys()) {
if (!metadata.location) if (!metadata.location)
continue; continue;
const { file, line } = metadata.location; const { file, line } = metadata.location;
let source = this._userSources.get(file); let source = this._userSources.get(file);
if (!source) { if (!source) {
source = { isRecorded: false, label: file, id: file, text: this._readSource(file), highlight: [], language: languageForFile(file) }; source = { isPrimary: false, isRecorded: false, label: file, id: file, text: this._readSource(file), highlight: [], language: languageForFile(file), timestamp };
this._userSources.set(file, source); this._userSources.set(file, source);
} }
if (line) { if (line) {
const paused = this._debugger.isPaused(metadata); const paused = this._debugger.isPaused(metadata);
source.highlight.push({ line, type: metadata.error ? 'error' : (paused ? 'paused' : 'running') }); source.highlight.push({ line, type: metadata.error ? 'error' : (paused ? 'paused' : 'running') });
source.revealLine = line; source.revealLine = line;
fileToSelect = source.id;
} }
} }
this._pushAllSources(); this._pushUserSources();
if (fileToSelect)
this._recorderApp?.setRunningFile(fileToSelect);
} }
private _pushAllSources() { private _pushUserSources() {
const primaryPage: Page | undefined = this._context.pages()[0]; this._recorderApp?.userSourcesChanged([...this._userSources.values()]);
this._recorderApp?.setSources([...this._recorderSources, ...this._userSources.values()], primaryPage?.mainFrame().url());
} }
async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata) { async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata) {
@ -475,48 +424,6 @@ export class Recorder implements InstrumentationListener, IRecorder {
} }
} }
private _resetActions() {
this._actions = [];
this._updateActions();
}
private _updateActions() {
const actions = collapseActions(this._actions);
const recorderSources = [];
for (const languageGenerator of this._orderedLanguages) {
const { header, footer, actionTexts, text } = generateCode(actions, languageGenerator, this._languageGeneratorOptions);
const source: Source = {
isRecorded: true,
label: languageGenerator.name,
group: languageGenerator.groupName,
id: languageGenerator.id,
text,
header,
footer,
actions: actionTexts,
language: languageGenerator.highlighter,
highlight: []
};
source.revealLine = text.split('\n').length - 1;
recorderSources.push(source);
if (languageGenerator === this._orderedLanguages[0])
this._throttledOutputFile?.setContent(source.text);
}
this._recorderSources = recorderSources;
this._recorderApp?.setActions(actions, recorderSources);
this._recorderApp?.setRunningFile(undefined);
this._pushAllSources();
}
private _languageName(id?: string): Language {
for (const lang of this._orderedLanguages) {
if (!id || lang.id === id)
return lang.highlighter;
}
return 'javascript';
}
private _setEnabled(enabled: boolean) { private _setEnabled(enabled: boolean) {
this._enabled = enabled; this._enabled = enabled;
} }
@ -534,10 +441,13 @@ export class Recorder implements InstrumentationListener, IRecorder {
startTime: monotonicTime() startTime: monotonicTime()
}); });
this._pageAliases.delete(page); this._pageAliases.delete(page);
this._filePrimaryURLChanged();
}); });
frame.on(Frame.Events.InternalNavigation, event => { frame.on(Frame.Events.InternalNavigation, event => {
if (event.isPublic) if (event.isPublic) {
this._onFrameNavigated(frame, page); this._onFrameNavigated(frame, page);
this._filePrimaryURLChanged();
}
}); });
page.on(Page.Events.Download, () => this._onDownload(page)); page.on(Page.Events.Download, () => this._onDownload(page));
const suffix = this._pageAliases.size ? String(++this._lastPopupOrdinal) : ''; const suffix = this._pageAliases.size ? String(++this._lastPopupOrdinal) : '';
@ -557,10 +467,15 @@ export class Recorder implements InstrumentationListener, IRecorder {
startTime: monotonicTime() startTime: monotonicTime()
}); });
} }
this._filePrimaryURLChanged();
}
private _filePrimaryURLChanged() {
const page = this._context.pages()[0];
this._recorderApp?.pageNavigated(page?.mainFrame().url());
} }
private _clearScript(): void { private _clearScript(): void {
this._resetActions();
if (this._params.mode === 'recording') { if (this._params.mode === 'recording') {
for (const page of this._context.pages()) for (const page of this._context.pages())
this._onFrameNavigated(page.mainFrame(), page); this._onFrameNavigated(page.mainFrame(), page);

View File

@ -24,40 +24,63 @@ import { serverSideCallMetadata } from '../instrumentation';
import { syncLocalStorageWithSettings } from '../launchApp'; import { syncLocalStorageWithSettings } from '../launchApp';
import { launchApp } from '../launchApp'; import { launchApp } from '../launchApp';
import { ProgressController } from '../progress'; import { ProgressController } from '../progress';
import { ThrottledFile } from './throttledFile';
import { languageSet } from '../codegen/languages';
import { collapseActions } from './recorderUtils';
import { generateCode } from '../codegen/language';
import { Recorder } from '../recorder';
import { monotonicTime } from '../../utils/isomorphic/time';
import type { BrowserContext } from '../browserContext'; import type { BrowserContext } from '../browserContext';
import type { Page } from '../page'; import type { Page } from '../page';
import type { IRecorder, IRecorderApp, IRecorderAppFactory } from './recorderFrontend'; import type { IRecorder, IRecorderApp, IRecorderAppFactory, RecorderAppParams } from './recorderFrontend';
import type * as actions from '@recorder/actions'; import type * as actions from '@recorder/actions';
import type { CallLog, ElementInfo, Mode, Source } from '@recorder/recorderTypes'; import type { CallLog, ElementInfo, Mode, Source } from '@recorder/recorderTypes';
import type { LanguageGeneratorOptions } from '../codegen/types';
import type * as channels from '@protocol/channels';
export class EmptyRecorderApp extends EventEmitter implements IRecorderApp { export class EmptyRecorderApp extends EventEmitter implements IRecorderApp {
wsEndpointForTest: undefined; wsEndpointForTest: undefined;
async close(): Promise<void> {} async close(): Promise<void> {}
async setPaused(paused: boolean): Promise<void> {} async setPaused(paused: boolean): Promise<void> {}
async setMode(mode: Mode): Promise<void> {} async setMode(mode: Mode): Promise<void> {}
async setRunningFile(file: string | undefined): Promise<void> {}
async elementPicked(elementInfo: ElementInfo, userGesture?: boolean): Promise<void> {} async elementPicked(elementInfo: ElementInfo, userGesture?: boolean): Promise<void> {}
async updateCallLogs(callLogs: CallLog[]): Promise<void> {} async updateCallLogs(callLogs: CallLog[]): Promise<void> {}
async setSources(sources: Source[], primaryPageURL: string | undefined): Promise<void> {} async userSourcesChanged(sources: Source[]): Promise<void> {}
async setActions(actions: actions.ActionInContext[], sources: Source[]): Promise<void> {} async start() {}
async actionAdded(action: actions.ActionInContext): Promise<void> {}
async signalAdded(signal: actions.Signal): Promise<void> {}
async pageNavigated(url: string): Promise<void> {}
async flushOutput(): Promise<void> {}
} }
export class RecorderApp extends EventEmitter implements IRecorderApp { export class RecorderApp extends EventEmitter implements IRecorderApp {
private _page: Page; private _page: Page;
readonly wsEndpointForTest: string | undefined; readonly wsEndpointForTest: string | undefined;
private _recorder: IRecorder; private _languageGeneratorOptions: LanguageGeneratorOptions;
private _throttledOutputFile: ThrottledFile | null = null;
private _actions: actions.ActionInContext[] = [];
private _userSources: Source[] = [];
private _recorderSources: Source[] = [];
private _primaryLanguage: string;
constructor(recorder: IRecorder, page: Page, wsEndpoint: string | undefined) { constructor(params: RecorderAppParams, page: Page, wsEndpointForTest: string | undefined) {
super(); super();
this.setMaxListeners(0); this.setMaxListeners(0);
this._recorder = recorder;
this._page = page; this._page = page;
this.wsEndpointForTest = wsEndpoint; this.wsEndpointForTest = wsEndpointForTest;
}
async close() { // Make a copy of options to modify them later.
await this._page.browserContext.close({ reason: 'Recorder window closed' }); this._languageGeneratorOptions = {
browserName: params.browserName,
launchOptions: { headless: false, ...params.launchOptions, tracesDir: undefined },
contextOptions: { ...params.contextOptions },
deviceName: params.device,
saveStorage: params.saveStorage,
};
this._throttledOutputFile = params.outputFile ? new ThrottledFile(params.outputFile) : null;
this._primaryLanguage = process.env.TEST_INSPECTOR_LANGUAGE || params.language || params.sdkLanguage;
} }
private async _init() { private async _init() {
@ -85,7 +108,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
}); });
}); });
await this._page.exposeBinding(progress, 'dispatch', false, (_, data: any) => this.emit('event', data)); await this._page.exposeBinding(progress, 'dispatch', false, (_, data: any) => this._handleUIEvent(data));
this._page.once('close', () => { this._page.once('close', () => {
this.emit('close'); this.emit('close');
@ -96,15 +119,78 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
}); });
} }
static factory(context: BrowserContext): IRecorderAppFactory { start() {
this._updateActions(true);
}
async actionAdded(action: actions.ActionInContext): Promise<void> {
this._actions.push(action);
this._updateActions();
}
async signalAdded(signal: actions.Signal): Promise<void> {
const lastAction = this._actions[this._actions.length - 1];
if (lastAction)
lastAction.action.signals.push(signal);
this._updateActions();
}
async pageNavigated(url: string): Promise<void> {
await this._page.mainFrame().evaluateExpression((({ url }: { url: string }) => {
window.playwrightSetPageURL(url);
}).toString(), { isFunction: true }, { url }).catch(() => {});
}
private _selectedFileChanged(fileId: string) {
const source = [...this._recorderSources, ...this._userSources].find(s => s.id === fileId);
if (source)
this.emit('event', { event: 'languageChanged', params: { language: source.language } });
}
async close() {
await this._page.browserContext.close({ reason: 'Recorder window closed' });
}
private _handleUIEvent(data: any) {
if (data.event === 'clear') {
this._actions = [];
this._updateActions();
this.emit('clear');
return;
}
if (data.event === 'fileChanged') {
this._selectedFileChanged(data.params.fileId);
return;
}
// Pass through events.
this.emit('event', data);
}
static async show(context: BrowserContext, params: channels.BrowserContextEnableRecorderParams) {
const factory = RecorderApp._factory(context, params);
await Recorder.show(context, factory, params);
}
static showInspectorNoReply(context: BrowserContext) {
Recorder.showInspector(context, {}, RecorderApp._factory(context, {})).catch(() => {});
}
private static _factory(context: BrowserContext, params: channels.BrowserContextEnableRecorderParams): IRecorderAppFactory {
const appParams = {
browserName: context._browser.options.name,
sdkLanguage: context._browser.sdkLanguage(),
wsEndpointForTest: context._browser.options.wsEndpoint,
...params,
};
return async recorder => { return async recorder => {
if (process.env.PW_CODEGEN_NO_INSPECTOR) if (process.env.PW_CODEGEN_NO_INSPECTOR)
return new EmptyRecorderApp(); return new EmptyRecorderApp();
return await RecorderApp._open(recorder, context); return await RecorderApp._open(appParams, recorder, context);
}; };
} }
private static async _open(recorder: IRecorder, inspectedContext: BrowserContext): Promise<IRecorderApp> { private static async _open(params: RecorderAppParams, recorder: IRecorder, inspectedContext: BrowserContext): Promise<IRecorderApp> {
const sdkLanguage = inspectedContext._browser.sdkLanguage(); const sdkLanguage = inspectedContext._browser.sdkLanguage();
const headed = !!inspectedContext._browser.options.headful; const headed = !!inspectedContext._browser.options.headful;
const recorderPlaywright = (require('../playwright').createPlaywright as typeof import('../playwright').createPlaywright)({ sdkLanguage: 'javascript', isInternalPlaywright: true }); const recorderPlaywright = (require('../playwright').createPlaywright as typeof import('../playwright').createPlaywright)({ sdkLanguage: 'javascript', isInternalPlaywright: true });
@ -116,7 +202,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
noDefaultViewport: true, noDefaultViewport: true,
headless: !!process.env.PWTEST_CLI_HEADLESS || (isUnderTest() && !headed), headless: !!process.env.PWTEST_CLI_HEADLESS || (isUnderTest() && !headed),
cdpPort: isUnderTest() ? 0 : undefined, cdpPort: isUnderTest() ? 0 : undefined,
handleSIGINT: recorder.handleSIGINT, handleSIGINT: params.handleSIGINT,
executablePath: inspectedContext._browser.options.isChromium ? inspectedContext._browser.options.customExecutablePath : undefined, executablePath: inspectedContext._browser.options.isChromium ? inspectedContext._browser.options.customExecutablePath : undefined,
// Use the same channel as the inspected context to guarantee that the browser is installed. // Use the same channel as the inspected context to guarantee that the browser is installed.
channel: inspectedContext._browser.options.isChromium ? inspectedContext._browser.options.channel : undefined, channel: inspectedContext._browser.options.isChromium ? inspectedContext._browser.options.channel : undefined,
@ -127,7 +213,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
await context._browser._defaultContext!._loadDefaultContextAsIs(progress); await context._browser._defaultContext!._loadDefaultContextAsIs(progress);
}); });
const result = new RecorderApp(recorder, page, context._browser.options.wsEndpoint); const result = new RecorderApp(params, page, context._browser.options.wsEndpoint);
await result._init(); await result._init();
return result; return result;
} }
@ -138,33 +224,33 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
}).toString(), { isFunction: true }, mode).catch(() => {}); }).toString(), { isFunction: true }, mode).catch(() => {});
} }
async setRunningFile(file: string | undefined): Promise<void> {
await this._page.mainFrame().evaluateExpression(((file: string) => {
window.playwrightSetRunningFile(file);
}).toString(), { isFunction: true }, file).catch(() => {});
}
async setPaused(paused: boolean): Promise<void> { async setPaused(paused: boolean): Promise<void> {
await this._page.mainFrame().evaluateExpression(((paused: boolean) => { await this._page.mainFrame().evaluateExpression(((paused: boolean) => {
window.playwrightSetPaused(paused); window.playwrightSetPaused(paused);
}).toString(), { isFunction: true }, paused).catch(() => {}); }).toString(), { isFunction: true }, paused).catch(() => {});
} }
async setSources(sources: Source[], primaryPageURL: string | undefined): Promise<void> { async userSourcesChanged(sources: Source[]): Promise<void> {
await this._page.mainFrame().evaluateExpression((({ sources, primaryPageURL }: { sources: Source[], primaryPageURL: string | undefined }) => { if (!sources.length && !this._userSources.length)
window.playwrightSetSources(sources, primaryPageURL); return;
}).toString(), { isFunction: true }, { sources, primaryPageURL }).catch(() => {}); this._userSources = sources;
this._pushAllSources();
}
private async _pushAllSources() {
const sources = [...this._userSources, ...this._recorderSources];
this._page.mainFrame().evaluateExpression((({ sources }: { sources: Source[] }) => {
window.playwrightSetSources(sources);
}).toString(), { isFunction: true }, { sources }).catch(() => {});
// Testing harness for runCLI mode. // Testing harness for runCLI mode.
if (process.env.PWTEST_CLI_IS_UNDER_TEST && sources.length) { if (process.env.PWTEST_CLI_IS_UNDER_TEST && sources.length) {
if ((process as any)._didSetSourcesForTest(sources[0].text)) const primarySource = sources.find(s => s.isPrimary);
if ((process as any)._didSetSourcesForTest(primarySource?.text ?? ''))
this.close(); this.close();
} }
} }
async setActions(actions: actions.ActionInContext[], sources: Source[]): Promise<void> {
}
async elementPicked(elementInfo: ElementInfo, userGesture?: boolean): Promise<void> { async elementPicked(elementInfo: ElementInfo, userGesture?: boolean): Promise<void> {
if (userGesture) if (userGesture)
this._page.bringToFront(); this._page.bringToFront();
@ -178,4 +264,39 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
window.playwrightUpdateLogs(callLogs); window.playwrightUpdateLogs(callLogs);
}).toString(), { isFunction: true }, callLogs).catch(() => {}); }).toString(), { isFunction: true }, callLogs).catch(() => {});
} }
async flushOutput(): Promise<void> {
this._throttledOutputFile?.flush();
}
private _updateActions(initial: boolean = false) {
const timestamp = initial ? 0 : monotonicTime();
const recorderSources = [];
const actions = collapseActions(this._actions);
for (const languageGenerator of languageSet()) {
const { header, footer, actionTexts, text } = generateCode(actions, languageGenerator, this._languageGeneratorOptions);
const source: Source = {
isPrimary: languageGenerator.id === this._primaryLanguage,
timestamp,
isRecorded: true,
label: languageGenerator.name,
group: languageGenerator.groupName,
id: languageGenerator.id,
text,
header,
footer,
actions: actionTexts,
language: languageGenerator.highlighter,
highlight: []
};
source.revealLine = text.split('\n').length - 1;
recorderSources.push(source);
if (languageGenerator.id === this._primaryLanguage)
this._throttledOutputFile?.setContent(source.text);
}
this._recorderSources = recorderSources;
this._pushAllSources();
}
} }

View File

@ -17,11 +17,12 @@
import type * as actions from '@recorder/actions'; import type * as actions from '@recorder/actions';
import type { CallLog, ElementInfo, Mode, Source } from '@recorder/recorderTypes'; import type { CallLog, ElementInfo, Mode, Source } from '@recorder/recorderTypes';
import type { EventEmitter } from 'events'; import type { EventEmitter } from 'events';
import type * as channels from '@protocol/channels';
import type { Language } from '../codegen/types';
export interface IRecorder { export interface IRecorder {
setMode(mode: Mode): void; setMode(mode: Mode): void;
mode(): Mode; mode(): Mode;
readonly handleSIGINT: boolean | undefined;
} }
export interface IRecorderApp extends EventEmitter { export interface IRecorderApp extends EventEmitter {
@ -29,11 +30,19 @@ export interface IRecorderApp extends EventEmitter {
close(): Promise<void>; close(): Promise<void>;
setPaused(paused: boolean): Promise<void>; setPaused(paused: boolean): Promise<void>;
setMode(mode: Mode): Promise<void>; setMode(mode: Mode): Promise<void>;
setRunningFile(file: string | undefined): Promise<void>;
elementPicked(elementInfo: ElementInfo, userGesture?: boolean): Promise<void>; elementPicked(elementInfo: ElementInfo, userGesture?: boolean): Promise<void>;
updateCallLogs(callLogs: CallLog[]): Promise<void>; updateCallLogs(callLogs: CallLog[]): Promise<void>;
setSources(sources: Source[], primaryPageURL: string | undefined): Promise<void>; userSourcesChanged(sources: Source[]): Promise<void>;
setActions(actions: actions.ActionInContext[], sources: Source[]): Promise<void>; start(): void;
actionAdded(action: actions.ActionInContext): Promise<void>;
signalAdded(signal: actions.Signal): Promise<void>;
pageNavigated(url: string): Promise<void>;
flushOutput(): Promise<void>;
} }
export type RecorderAppParams = channels.BrowserContextEnableRecorderParams & {
browserName: string;
sdkLanguage: Language;
};
export type IRecorderAppFactory = (recorder: IRecorder) => Promise<IRecorderApp>; export type IRecorderAppFactory = (recorder: IRecorder) => Promise<IRecorderApp>;

View File

@ -27,11 +27,13 @@ export const Main: React.FC = ({}) => {
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
window.playwrightSetMode = setMode; window.playwrightSetMode = setMode;
window.playwrightSetSources = (sources, primaryPageURL) => { window.playwrightSetSources = sources => {
setSources(sources); setSources(sources);
window.playwrightSourcesEchoForTest = sources; window.playwrightSourcesEchoForTest = sources;
document.title = primaryPageURL };
? `Playwright Inspector - ${primaryPageURL}` window.playwrightSetPageURL = url => {
document.title = url
? `Playwright Inspector - ${url}`
: `Playwright Inspector`; : `Playwright Inspector`;
}; };
window.playwrightSetPaused = setPaused; window.playwrightSetPaused = setPaused;

View File

@ -45,21 +45,30 @@ export const Recorder: React.FC<RecorderProps> = ({
mode, mode,
}) => { }) => {
const [selectedFileId, setSelectedFileId] = React.useState<string | undefined>(); const [selectedFileId, setSelectedFileId] = React.useState<string | undefined>();
const [runningFileId, setRunningFileId] = React.useState<string | undefined>();
const [selectedTab, setSelectedTab] = useSetting<string>('recorderPropertiesTab', 'log'); const [selectedTab, setSelectedTab] = useSetting<string>('recorderPropertiesTab', 'log');
const [ariaSnapshot, setAriaSnapshot] = React.useState<string | undefined>(); const [ariaSnapshot, setAriaSnapshot] = React.useState<string | undefined>();
const [ariaSnapshotErrors, setAriaSnapshotErrors] = React.useState<SourceHighlight[]>(); const [ariaSnapshotErrors, setAriaSnapshotErrors] = React.useState<SourceHighlight[]>();
const fileId = selectedFileId || runningFileId || sources[0]?.id; React.useEffect(() => {
if (!sources.length)
return;
const selectedSource = sources.find(s => s.id === selectedFileId);
const newestSource = sources.sort((a, b) => b.timestamp - a.timestamp)[0];
if (!selectedSource || newestSource.isRecorded !== selectedSource.isRecorded) {
// Debugger kicked in, or recording resumed. Switch selection to the newest source.
setSelectedFileId(newestSource.id);
}
}, [sources, selectedFileId]);
const source = React.useMemo(() => { const source = React.useMemo(() => {
if (fileId) { const source = sources.find(s => s.id === selectedFileId);
const source = sources.find(s => s.id === fileId); if (source)
if (source) return source;
return source; const primarySource = sources.find(s => s.isPrimary);
} if (primarySource)
return primarySource;
return emptySource(); return emptySource();
}, [sources, fileId]); }, [sources, selectedFileId]);
const [locator, setLocator] = React.useState(''); const [locator, setLocator] = React.useState('');
window.playwrightElementPicked = (elementInfo: ElementInfo, userGesture?: boolean) => { window.playwrightElementPicked = (elementInfo: ElementInfo, userGesture?: boolean) => {
@ -77,8 +86,6 @@ export const Recorder: React.FC<RecorderProps> = ({
} }
}; };
window.playwrightSetRunningFile = setRunningFileId;
const messagesEndRef = React.useRef<HTMLDivElement>(null); const messagesEndRef = React.useRef<HTMLDivElement>(null);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
messagesEndRef.current?.scrollIntoView({ block: 'center', inline: 'nearest' }); messagesEndRef.current?.scrollIntoView({ block: 'center', inline: 'nearest' });
@ -179,9 +186,9 @@ export const Recorder: React.FC<RecorderProps> = ({
}}></ToolbarButton> }}></ToolbarButton>
<div style={{ flex: 'auto' }}></div> <div style={{ flex: 'auto' }}></div>
<div>Target:</div> <div>Target:</div>
<SourceChooser fileId={fileId} sources={sources} setFileId={fileId => { <SourceChooser fileId={source.id} sources={sources} setFileId={fileId => {
setSelectedFileId(fileId); setSelectedFileId(fileId);
window.dispatch({ event: 'fileChanged', params: { file: fileId } }); window.dispatch({ event: 'fileChanged', params: { fileId } });
}} /> }} />
<ToolbarButton icon='clear-all' title='Clear' disabled={!source || !source.text} onClick={() => { <ToolbarButton icon='clear-all' title='Clear' disabled={!source || !source.text} onClick={() => {
window.dispatch({ event: 'clear' }); window.dispatch({ event: 'clear' });

View File

@ -43,7 +43,7 @@ export type EventData = {
| 'pause' | 'pause'
| 'setMode' | 'setMode'
| 'highlightRequested' | 'highlightRequested'
| 'fileChanged'; | 'languageChanged';
params: any; params: any;
}; };
@ -84,12 +84,14 @@ export type SourceHighlight = {
}; };
export type Source = { export type Source = {
isPrimary: boolean;
isRecorded: boolean; isRecorded: boolean;
id: string; id: string;
label: string; label: string;
text: string; text: string;
language: Language; language: Language;
highlight: SourceHighlight[]; highlight: SourceHighlight[];
timestamp: number;
revealLine?: number; revealLine?: number;
// used to group the language generators // used to group the language generators
group?: string; group?: string;
@ -102,10 +104,10 @@ declare global {
interface Window { interface Window {
playwrightSetMode: (mode: Mode) => void; playwrightSetMode: (mode: Mode) => void;
playwrightSetPaused: (paused: boolean) => void; playwrightSetPaused: (paused: boolean) => void;
playwrightSetSources: (sources: Source[], primaryPageURL: string | undefined) => void; playwrightSetSources: (sources: Source[]) => void;
playwrightSetPageURL: (url: string | undefined) => void;
playwrightSetOverlayVisible: (visible: boolean) => void; playwrightSetOverlayVisible: (visible: boolean) => void;
playwrightUpdateLogs: (callLogs: CallLog[]) => void; playwrightUpdateLogs: (callLogs: CallLog[]) => void;
playwrightSetRunningFile: (file: string | undefined) => void;
playwrightElementPicked: (elementInfo: ElementInfo, userGesture?: boolean) => void; playwrightElementPicked: (elementInfo: ElementInfo, userGesture?: boolean) => void;
playwrightSourcesEchoForTest: Source[]; playwrightSourcesEchoForTest: Source[];
dispatch(data: any): Promise<void>; dispatch(data: any): Promise<void>;

View File

@ -53,6 +53,8 @@ function renderSourceOptions(sources: Source[]): React.ReactNode {
export function emptySource(): Source { export function emptySource(): Source {
return { return {
id: 'default', id: 'default',
timestamp: 0,
isPrimary: false,
isRecorded: false, isRecorded: false,
text: '', text: '',
language: 'javascript', language: 'javascript',

View File

@ -462,6 +462,9 @@ await page1.GotoAsync("about:blank?foo");`);
const harFileName = testInfo.outputPath('har.har'); const harFileName = testInfo.outputPath('har.har');
const cli = runCLI([`--save-storage=${storageFileName}`, `--save-har=${harFileName}`]); const cli = runCLI([`--save-storage=${storageFileName}`, `--save-har=${harFileName}`]);
await cli.waitFor(`import { test, expect } from '@playwright/test'`); await cli.waitFor(`import { test, expect } from '@playwright/test'`);
// Since our interrupt is non-graceful, we need to wait for the process to settle.
// This test should be fixed.
await new Promise(resolve => setTimeout(resolve, 2000));
await cli.process.kill('SIGINT'); await cli.process.kill('SIGINT');
const { exitCode, signal } = await cli.process.exited; const { exitCode, signal } = await cli.process.exited;
if (exitCode !== null) { if (exitCode !== null) {

View File

@ -447,6 +447,7 @@ it.describe('pause', () => {
})(); })();
const recorderPage = await recorderPageGetter(); const recorderPage = await recorderPageGetter();
await recorderPage.getByRole('combobox', { name: 'Source chooser' }).selectOption('csharp');
const box1Promise = waitForTestLog<BoundingBox>(page, 'Highlight box for test: '); const box1Promise = waitForTestLog<BoundingBox>(page, 'Highlight box for test: ');
await recorderPage.getByText('Locator', { exact: true }).click(); await recorderPage.getByText('Locator', { exact: true }).click();
await recorderPage.locator('.tabbed-pane .CodeMirror').click(); await recorderPage.locator('.tabbed-pane .CodeMirror').click();
@ -541,7 +542,7 @@ it.describe('pause', () => {
await recorder.hoverOverElement('body', { omitTooltip: true }); await recorder.hoverOverElement('body', { omitTooltip: true });
await recorder.trustedClick(); await recorder.trustedClick();
await expect(recorderPage.getByRole('combobox', { name: 'Source chooser' })).toHaveValue('javascript'); await expect(recorderPage.getByRole('combobox', { name: 'Source chooser' })).toHaveValue('playwright-test');
await expect(recorderPage.locator('.cm-wrapper')).toContainText(`await page.locator('body').click();`); await expect(recorderPage.locator('.cm-wrapper')).toContainText(`await page.locator('body').click();`);
await recorderPage.getByRole('button', { name: 'Resume' }).click(); await recorderPage.getByRole('button', { name: 'Resume' }).click();
await scriptPromise; await scriptPromise;

View File

@ -70,15 +70,11 @@ test('should update primary page URL when original primary closes', async ({
); );
await recorder.page.close(); await recorder.page.close();
// URL will not update without performing some action
await page3.getByRole('checkbox').click();
await expect(recorder.recorderPage).toHaveTitle( await expect(recorder.recorderPage).toHaveTitle(
`Playwright Inspector - ${server.PREFIX}/dom.html`, `Playwright Inspector - ${server.PREFIX}/dom.html`,
); );
await page3.close(); await page3.close();
// URL will not update without performing some action
await page4.locator('div').first().click();
await expect(recorder.recorderPage).toHaveTitle( await expect(recorder.recorderPage).toHaveTitle(
`Playwright Inspector - ${server.PREFIX}/grid.html`, `Playwright Inspector - ${server.PREFIX}/grid.html`,
); );