diff --git a/src/server/supplements/injected/recorder.ts b/src/server/supplements/injected/recorder.ts index 9cf4fb0268..60c1724a2f 100644 --- a/src/server/supplements/injected/recorder.ts +++ b/src/server/supplements/injected/recorder.ts @@ -17,20 +17,24 @@ import type * as actions from '../recorder/recorderActions'; import type InjectedScript from '../../injected/injectedScript'; import { generateSelector } from './selectorGenerator'; -import { html } from './html'; +import { Element$, html } from './html'; +import type { State, SetUIState } from '../recorder/state'; declare global { interface Window { playwrightRecorderPerformAction: (action: actions.Action) => Promise; playwrightRecorderRecordAction: (action: actions.Action) => Promise; playwrightRecorderCommitAction: () => Promise; - playwrightRecorderState: () => Promise<{ state: any, paused: boolean, app: 'codegen' | 'debug' | 'pause' }>; - playwrightRecorderSetState: (state: any) => Promise; + playwrightRecorderState: () => Promise; + playwrightRecorderSetUIState: (state: SetUIState) => Promise; playwrightRecorderResume: () => Promise; + playwrightRecorderClearScript: () => Promise; } } const scriptSymbol = Symbol('scriptSymbol'); +const pressRecordMessageElement = html`Press to start recording`; +const performActionsMessageElement = html`Perform actions to record`; export class Recorder { private _injectedScript: InjectedScript; @@ -46,17 +50,24 @@ export class Recorder { private _activeModel: HighlightModel | null = null; private _expectProgrammaticKeyUp = false; private _pollRecorderModeTimer: NodeJS.Timeout | undefined; - private _toolbarElement: HTMLElement; - private _inspectElement: HTMLElement; - private _recordElement: HTMLElement; - private _resumeElement: HTMLElement; - private _mode: 'inspecting' | 'recording' | 'none' = 'none'; - private _app: 'codegen' | 'debug' | 'pause' = 'debug'; - private _paused = false; + private _outerToolbarElement: HTMLElement; + private _outerDrawerElement: HTMLElement; + private _toolbar: Element$; + private _drawer: Element$; + private _drawerTimeout: NodeJS.Timeout | undefined; + private _state: State = { + codegenScript: '', + canResume: false, + uiState: { + mode: 'none', + drawerVisible: false + }, + isController: true, + isPaused: false + }; constructor(injectedScript: InjectedScript) { this._injectedScript = injectedScript; - this._outerGlassPaneElement = html` @@ -108,108 +119,143 @@ export class Recorder { `); - this._toolbarElement = html` - `; + this._toolbar = html` + + ${commonStyles()} + + + + + + + + +
+
+
+
+
+ + + + + + +
`; - this._inspectElement = html` - - - `; - this._recordElement = html` - - - `; - this._resumeElement = html` - `; + this._outerToolbarElement = html``; + const toolbarShadow = this._outerToolbarElement.attachShadow({ mode: 'open' }); + toolbarShadow.appendChild(this._toolbar); - this._populateToolbar(); - this._pollRecorderMode(); + this._drawer = html` + + ${commonStyles()} + ${highlighterStyles()} + + ${pressRecordMessageElement} + + + + + + + + + + + + `; + this._outerDrawerElement = html``; + const drawerShadow = this._outerDrawerElement.attachShadow({ mode: 'open' }); + drawerShadow.appendChild(this._drawer); + this._hydrate(); + this._refreshListenersIfNeeded(); setInterval(() => { this._refreshListenersIfNeeded(); + if ((window as any)._recorderScriptReadyForTest) + (window as any)._recorderScriptReadyForTest(); }, 500); + this._pollRecorderMode(true).catch(e => {}); } - private _populateToolbar() { - const toolbarShadow = this._toolbarElement.attachShadow({ mode: 'open' }); - toolbarShadow.appendChild(html` - `); - - const iconElement = html``; - iconElement.style.backgroundImage = `url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMTM2IDIyMmMtMTIgMy0yMSAxMC0yNiAxNiA1LTUgMTItOSAyMi0xMiAxMC0yIDE4LTIgMjUtMXYtNmMtNiAwLTEzIDAtMjEgM3ptLTI3LTQ2bC00OCAxMiAzIDMgNDAtMTBzMCA3LTUgMTRjOS03IDEwLTE5IDEwLTE5em00MCAxMTJDODIgMzA2IDQ2IDIyOCAzNSAxODhhMjI3IDIyNyAwIDAxLTctNDVjLTQgMS02IDItNSA4IDAgOSAyIDIzIDcgNDIgMTEgNDAgNDcgMTE4IDExNCAxMDAgMTUtNCAyNi0xMSAzNC0yMC03IDctMTcgMTItMjkgMTV6bTEzLTE2MHY1aDI2bC0yLTVoLTI0eiIgZmlsbD0iIzJENDU1MiIvPjxwYXRoIGQ9Ik0xOTQgMTY4YzEyIDMgMTggMTEgMjEgMTlsMTQgM3MtMi0yNS0yNS0zMmMtMjItNi0zNiAxMi0zNyAxNCA2LTQgMTUtOCAyNy00em0xMDUgMTljLTIxLTYtMzUgMTItMzYgMTQgNi00IDE1LTggMjctNSAxMiA0IDE4IDEyIDIxIDE5bDE0IDRzLTItMjYtMjYtMzJ6bS0xMyA2OGwtMTEwLTMxczEgNiA2IDE0bDkzIDI2IDExLTl6bS03NiA2NmMtODctMjMtNzctMTM0LTYzLTE4NyA2LTIyIDEyLTM4IDE3LTQ5LTMgMC01IDEtOCA2LTUgMTEtMTIgMjgtMTggNTItMTQgNTMtMjUgMTY0IDYyIDE4OCA0MSAxMSA3My02IDk3LTMyYTkwIDkwIDAgMDEtODcgMjJ6IiBmaWxsPSIjMkQ0NTUyIi8+PHBhdGggZD0iTTE2MiAyNjJ2LTIybC02MyAxOHM1LTI3IDM3LTM2YzEwLTMgMTktMyAyNi0ydi05MmgzMWwtMTAtMjRjLTQtOS05LTMtMTkgNi04IDYtMjcgMTktNTUgMjctMjkgOC01MiA2LTYxIDQtMTQtMi0yMS01LTIwIDUgMCA5IDIgMjMgNyA0MiAxMSA0MCA0NyAxMTggMTE0IDEwMCAxOC00IDMwLTE0IDM5LTI2aC0yNnpNNjEgMTg4bDQ4LTEycy0xIDE4LTE5IDIzLTI5LTExLTI5LTExeiIgZmlsbD0iI0UyNTc0QyIvPjxwYXRoIGQ9Ik0zNDIgMTI5Yy0xMyAyLTQzIDUtNzktNS0zNy0xMC02Mi0yNy03MS0zNS0xNC0xMi0yMC0yMC0yNi04LTUgMTEtMTIgMjktMTkgNTMtMTQgNTMtMjQgMTY0IDYzIDE4N3MxMzQtNzggMTQ4LTEzMWM2LTI0IDktNDIgMTAtNTQgMS0xNC05LTEwLTI2LTd6bS0xNzYgNDRzMTQtMjIgMzgtMTVjMjMgNyAyNSAzMiAyNSAzMmwtNjMtMTd6bTU3IDk2Yy00MS0xMi00Ny00NS00Ny00NWwxMTAgMzFzLTIyIDI2LTYzIDE0em0zOS02OHMxNC0yMSAzNy0xNGMyNCA2IDI2IDMyIDI2IDMybC02My0xOHoiIGZpbGw9IiMyRUFEMzMiLz48cGF0aCBkPSJNMTQwIDI0NmwtNDEgMTJzNS0yNiAzNS0zNmwtMjMtODYtMiAxYy0yOSA4LTUyIDYtNjEgNC0xNC0yLTIxLTUtMjAgNSAwIDkgMiAyMyA3IDQyIDExIDQwIDQ3IDExOCAxMTQgMTAwaDJsLTExLTQyem0tNzktNThsNDgtMTJzLTEgMTgtMTkgMjMtMjktMTEtMjktMTF6IiBmaWxsPSIjRDY1MzQ4Ii8+PHBhdGggZD0iTTIyNSAyNjloLTJjLTQxLTEyLTQ3LTQ1LTQ3LTQ1bDU3IDE2IDMwLTExNmMtMzctMTAtNjItMjctNzEtMzUtMTQtMTItMjAtMjAtMjYtOC01IDExLTEyIDI5LTE5IDUzLTE0IDUzLTI0IDE2NCA2MyAxODdsMiAxIDEzLTUzem0tNTktOTZzMTQtMjIgMzgtMTVjMjMgNyAyNSAzMiAyNSAzMmwtNjMtMTd6IiBmaWxsPSIjMUQ4RDIyIi8+PHBhdGggZD0iTTE0MiAyNDVsLTExIDRjMyAxNCA3IDI4IDE0IDQwbDQtMSA5LTNjLTgtMTItMTMtMjUtMTYtNDB6bS00LTEwMmMtNiAyMS0xMSA1MS0xMCA4MWw4LTIgMi0xYTI3MyAyNzMgMCAwMTE0LTEwM2wtOCA1LTYgMjB6IiBmaWxsPSIjQzA0QjQxIi8+PC9zdmc+')`; - toolbarShadow.appendChild(iconElement); - toolbarShadow.appendChild(this._inspectElement); - toolbarShadow.appendChild(this._recordElement); - toolbarShadow.appendChild(this._resumeElement); - - this._inspectElement.addEventListener('click', () => { - if (this._inspectElement.classList.contains('disabled')) + private _hydrate() { + this._toolbar.$('#pw-button-inspect').addEventListener('click', () => { + if (this._toolbar.$('#pw-button-inspect').classList.contains('disabled')) return; - this._inspectElement.classList.toggle('toggled'); - this._setMode(this._inspectElement.classList.contains('toggled') ? 'inspecting' : 'none'); + this._toolbar.$('#pw-button-inspect').classList.toggle('toggled'); + this._updateUIState({ + mode: this._toolbar.$('#pw-button-inspect').classList.contains('toggled') ? 'inspecting' : 'none' + }); }); - this._recordElement.addEventListener('click', () => { - if (this._recordElement.classList.contains('disabled')) + this._toolbar.$('#pw-button-record').addEventListener('click', () => this._toggleRecording()); + this._toolbar.$('#pw-button-resume').addEventListener('click', () => { + if (this._toolbar.$('#pw-button-resume').classList.contains('disabled')) return; - this._recordElement.classList.toggle('toggled'); - this._setMode(this._recordElement.classList.contains('toggled') ? 'recording' : 'none'); + this._updateUIState({ mode: 'none' }); + window.playwrightRecorderResume().catch(() => {}); }); - this._resumeElement.addEventListener('click', () => { - if (!this._resumeElement.classList.contains('disabled')) { - this._setMode('none'); - window.playwrightRecorderResume().catch(e => {}); - } + this._toolbar.$('#pw-button-drawer').addEventListener('click', () => { + if (this._toolbar.$('#pw-button-drawer').classList.contains('disabled')) + return; + this._toolbar.$('#pw-button-drawer').classList.toggle('toggled'); + this._updateUIState({ drawerVisible: this._toolbar.$('#pw-button-drawer').classList.contains('toggled') }); }); + this._drawer.$('#pw-button-copy').addEventListener('click', () => { + if (this._drawer.$('#pw-button-copy').classList.contains('disabled')) + return; + copy(this._drawer.$('x-pw-code').textContent || ''); + }); + this._drawer.$('#pw-button-clear').addEventListener('click', () => { + window.playwrightRecorderClearScript().catch(() => {}); + }); + this._drawer.$('#pw-button-close').addEventListener('click', () => { + this._toolbar.$('#pw-button-drawer').classList.toggle('toggled', false); + this._updateUIState({ drawerVisible: false }); + }); + this._drawer.$('x-pw-code span').addEventListener('click', () => this._toggleRecording()); } private _refreshListenersIfNeeded() { @@ -233,42 +279,96 @@ export class Recorder { }, true), ]; document.documentElement.appendChild(this._outerGlassPaneElement); - document.documentElement.appendChild(this._toolbarElement); - if ((window as any)._recorderScriptReadyForTest) - (window as any)._recorderScriptReadyForTest(); + document.documentElement.appendChild(this._outerToolbarElement); + document.documentElement.appendChild(this._outerDrawerElement); } - private async _setMode(mode: 'inspecting' | 'recording' | 'paused' | 'none') { - window.playwrightRecorderSetState({ mode }).then(() => this._pollRecorderMode()); + private _toggleRecording() { + this._toolbar.$('#pw-button-record').classList.toggle('toggled'); + this._updateUIState({ + ...this._state.uiState, + mode: this._toolbar.$('#pw-button-record').classList.contains('toggled') ? 'recording' : 'none', + }); } - private async _pollRecorderMode() { + private async _updateUIState(uiState: SetUIState) { + window.playwrightRecorderSetUIState(uiState).then(() => this._pollRecorderMode()); + } + + private async _pollRecorderMode(skipAnimations: boolean = false) { if (this._pollRecorderModeTimer) clearTimeout(this._pollRecorderModeTimer); - const result = await window.playwrightRecorderState().catch(e => null); - if (result) { - const { state, paused, app } = result; - if (state && state.mode !== this._mode) { - this._mode = state.mode as any; - this._inspectElement.classList.toggle('toggled', this._mode === 'inspecting'); - this._recordElement.classList.toggle('toggled', this._mode === 'recording'); - this._inspectElement.classList.toggle('disabled', this._mode === 'recording'); - this._resumeElement.classList.toggle('disabled', this._mode === 'recording'); - this._clearHighlight(); - } - if (paused !== this._paused) { - this._paused = paused; - this._resumeElement.classList.toggle('hidden', false); - this._resumeElement.classList.toggle('disabled', !this._paused); - } - if (app !== this._app) { - this._app = app; - this._resumeElement.classList.toggle('hidden', this._app !== 'pause'); + const state = await window.playwrightRecorderState().catch(e => null); + if (!state) { + this._pollRecorderModeTimer = setTimeout(() => this._pollRecorderMode(), 250); + return; + } + + const { canResume, isController, isPaused, uiState, codegenScript } = state; + if (uiState.mode !== this._state.uiState.mode) { + this._state.uiState.mode = uiState.mode; + this._toolbar.$('#pw-button-inspect').classList.toggle('toggled', uiState.mode === 'inspecting'); + this._toolbar.$('#pw-button-record').classList.toggle('toggled', uiState.mode === 'recording'); + this._toolbar.$('#pw-button-resume').classList.toggle('disabled', uiState.mode === 'recording'); + this._updateDrawerMessage(); + this._clearHighlight(); + } + + if (isController !== this._state.isController) + this._toolbar.$('#pw-button-drawer-group').classList.toggle('hidden', !isController); + + if (isController && uiState.drawerVisible !== this._state.uiState.drawerVisible) { + this._state.uiState.drawerVisible = uiState.drawerVisible; + this._toolbar.$('#pw-button-drawer').classList.toggle('toggled', uiState.drawerVisible); + if (this._drawerTimeout) + clearTimeout(this._drawerTimeout); + if (uiState.drawerVisible) { + this._outerDrawerElement.style.display = 'flex'; + const show = () => this._outerDrawerElement.style.transform = 'translateX(0)'; + if (skipAnimations) + show(); + else + window.requestAnimationFrame(show); + } else { + this._outerDrawerElement.style.transform = 'translateX(400px)'; + if (!skipAnimations) { + this._drawerTimeout = setTimeout(() => { + this._outerDrawerElement.style.display = 'none'; + }, 300); + } } } + if (isPaused !== this._state.isPaused) { + this._state.isPaused = isPaused; + this._toolbar.$('#pw-button-resume-group').classList.toggle('hidden', false); + this._toolbar.$('#pw-button-resume').classList.toggle('disabled', !isPaused); + } + + if (canResume !== this._state.canResume) { + this._state.canResume = canResume; + this._toolbar.$('#pw-button-resume-group').classList.toggle('hidden', !canResume); + } + + if (codegenScript !== this._state.codegenScript) { + this._state.codegenScript = codegenScript; + this._updateDrawerMessage(); + } + this._state = state; this._pollRecorderModeTimer = setTimeout(() => this._pollRecorderMode(), 250); } + private _updateDrawerMessage() { + if (!this._state.codegenScript) { + this._drawer.$('x-pw-code').textContent = ''; + if (this._state.uiState.mode === 'recording') + this._drawer.$('x-pw-code').appendChild(performActionsMessageElement); + else + this._drawer.$('x-pw-code').appendChild(pressRecordMessageElement); + } else { + this._drawer.$('x-pw-code').innerHTML = this._state.codegenScript; + } + } + private _clearHighlight() { this._hoveredModel = null; this._activeModel = null; @@ -299,8 +399,10 @@ export class Recorder { } private _onClick(event: MouseEvent) { - if (this._mode === 'inspecting' && !this._isInToolbar(event.target as HTMLElement)) - console.log(this._hoveredModel ? this._hoveredModel.selector : ''); // eslint-disable-line no-console + if (this._state.uiState.mode === 'inspecting' && !this._isInToolbar(event.target as HTMLElement)) { + if (this._hoveredModel) + copy(this._hoveredModel.selector); + } if (this._shouldIgnoreMouseEvent(event)) return; if (this._actionInProgress(event)) @@ -330,6 +432,8 @@ export class Recorder { } private _isInToolbar(element: Element | undefined | null): boolean { + if (element && element.parentElement && element.parentElement.nodeName.toLowerCase().startsWith('x-pw-')) + return true; return !!element && element.nodeName.toLowerCase().startsWith('x-pw-'); } @@ -337,9 +441,9 @@ export class Recorder { const target = this._deepEventTarget(event); if (this._isInToolbar(target)) return true; - if (this._mode === 'none') + if (this._state.uiState.mode === 'none') return true; - if (this._mode === 'inspecting') { + if (this._state.uiState.mode === 'inspecting') { consumeEvent(event); return true; } @@ -367,7 +471,7 @@ export class Recorder { } private _onMouseMove(event: MouseEvent) { - if (this._mode === 'none') + if (this._state.uiState.mode === 'none') return; const target = this._deepEventTarget(event); if (this._isInToolbar(target)) @@ -487,7 +591,7 @@ export class Recorder { } private _onInput(event: Event) { - if (this._mode !== 'recording') + if (this._state.uiState.mode !== 'recording') return true; const target = this._deepEventTarget(event); if (['INPUT', 'TEXTAREA'].includes(target.nodeName)) { @@ -558,11 +662,11 @@ export class Recorder { } private _onKeyDown(event: KeyboardEvent) { - if (this._mode === 'inspecting') { + if (this._state.uiState.mode === 'inspecting') { consumeEvent(event); return; } - if (this._mode !== 'recording') + if (this._state.uiState.mode !== 'recording') return true; if (!this._shouldGenerateKeyPressFor(event)) return; @@ -667,13 +771,6 @@ function asCheckbox(node: Node | null): HTMLInputElement | null { return inputElement.type === 'checkbox' ? inputElement : null; } -type RegisteredListener = { - target: EventTarget; - eventName: string; - listener: EventListener; - useCapture?: boolean; -}; - function addEventListener(target: EventTarget, eventName: string, listener: EventListener, useCapture?: boolean): () => void { target.addEventListener(eventName, listener, useCapture); const remove = () => { @@ -688,4 +785,165 @@ function removeEventListeners(listeners: (() => void)[]) { listeners.splice(0, listeners.length); } +function copy(text: string) { + const input = html`` as any as HTMLInputElement; + input.value = text; + document.body.appendChild(input); + input.select(); + document.execCommand('copy'); + input.remove(); +} + +function commonStyles() { + return html` +`; +} + +function highlighterStyles() { + return html` +`; +} + export default Recorder; diff --git a/src/server/supplements/recorder/codeGenerator.ts b/src/server/supplements/recorder/codeGenerator.ts index c9da6ce253..8b5cc029df 100644 --- a/src/server/supplements/recorder/codeGenerator.ts +++ b/src/server/supplements/recorder/codeGenerator.ts @@ -35,11 +35,12 @@ export interface CodeGeneratorOutput { } export class CodeGenerator { - private _currentAction: ActionInContext | undefined; - private _lastAction: ActionInContext | undefined; + private _currentAction: ActionInContext | null = null; + private _lastAction: ActionInContext | null = null; private _lastActionText: string | undefined; private _languageGenerator: LanguageGenerator; private _output: CodeGeneratorOutput; + private _headerText = ''; private _footerText = ''; constructor(browserName: string, generateHeaders: boolean, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, output: CodeGeneratorOutput, languageGenerator: LanguageGenerator, deviceName: string | undefined, saveStorage: string | undefined) { @@ -48,9 +49,17 @@ export class CodeGenerator { launchOptions = { headless: false, ...launchOptions }; if (generateHeaders) { - const header = this._languageGenerator.generateHeader(browserName, launchOptions, contextOptions, deviceName); - this._output.printLn(header); + this._headerText = this._languageGenerator.generateHeader(browserName, launchOptions, contextOptions, deviceName); this._footerText = '\n' + this._languageGenerator.generateFooter(saveStorage); + } + this.restart(); + } + + restart() { + this._currentAction = null; + this._lastAction = null; + if (this._headerText) { + this._output.printLn(this._headerText); this._output.printLn(this._footerText); } } @@ -66,7 +75,7 @@ export class CodeGenerator { performedActionFailed(action: ActionInContext) { if (this._currentAction === action) - this._currentAction = undefined; + this._currentAction = null; } didPerformAction(actionInContext: ActionInContext) { @@ -109,7 +118,7 @@ export class CodeGenerator { if (eraseLastAction && this._lastActionText) this._output.popLn(this._lastActionText); const performingAction = !!this._currentAction; - this._currentAction = undefined; + this._currentAction = null; this._lastAction = actionInContext; this._lastActionText = this._languageGenerator.generateAction(actionInContext, performingAction); this._output.printLn(this._lastActionText); diff --git a/src/server/supplements/recorder/outputs.ts b/src/server/supplements/recorder/outputs.ts index 4052a5d6eb..47f4c0beee 100644 --- a/src/server/supplements/recorder/outputs.ts +++ b/src/server/supplements/recorder/outputs.ts @@ -61,23 +61,44 @@ export class OutputMultiplexer implements RecorderOutput { } } -export class BufferOutput { - lines: string[] = []; +export class BufferedOutput implements RecorderOutput { + private _lines: string[] = []; + private _buffer: string | null = null; + private _language: string | null = null; + + constructor(language?: string) { + this._language = language || null; + } printLn(text: string) { - this.lines.push(...text.trimEnd().split('\n')); + this._buffer = null; + this._lines.push(...text.trimEnd().split('\n')); } popLn(text: string) { - this.lines.length -= text.trimEnd().split('\n').length; + this._buffer = null; + this._lines.length -= text.trimEnd().split('\n').length; } buffer(): string { - return this.lines.join('\n'); + if (this._buffer === null) { + this._buffer = this._lines.join('\n'); + if (this._language) + this._buffer = hljs.highlight(this._language, this._buffer).value; + } + return this._buffer; + } + + clear() { + this._lines = []; + this._buffer = null; + } + + flush() { } } -export class FileOutput extends BufferOutput implements RecorderOutput { +export class FileOutput extends BufferedOutput implements RecorderOutput { private _fileName: string; constructor(fileName: string) { @@ -147,7 +168,7 @@ export class TerminalOutput implements RecorderOutput { flush() {} } -export class FlushingTerminalOutput extends BufferOutput implements RecorderOutput { +export class FlushingTerminalOutput extends BufferedOutput implements RecorderOutput { private _output: Writable constructor(output: Writable) { diff --git a/src/server/supplements/recorder/state.ts b/src/server/supplements/recorder/state.ts new file mode 100644 index 0000000000..c69ff615b5 --- /dev/null +++ b/src/server/supplements/recorder/state.ts @@ -0,0 +1,33 @@ +/** + * 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. + */ + +export type UIState = { + mode: 'inspecting' | 'recording' | 'none', + drawerVisible: boolean +} + +export type SetUIState = { + mode?: 'inspecting' | 'recording' | 'none', + drawerVisible?: boolean +} + +export type State = { + canResume: boolean, + isController: boolean, + isPaused: boolean, + codegenScript: string, + uiState: UIState, +} diff --git a/src/server/supplements/recorderSupplement.ts b/src/server/supplements/recorderSupplement.ts index 7b7d4a4c4d..72ce01ab02 100644 --- a/src/server/supplements/recorderSupplement.ts +++ b/src/server/supplements/recorderSupplement.ts @@ -28,11 +28,11 @@ import { PythonLanguageGenerator } from './recorder/python'; import { ProgressController } from '../progress'; import * as recorderSource from '../../generated/recorderSource'; import * as consoleApiSource from '../../generated/consoleApiSource'; -import { FileOutput, FlushingTerminalOutput, OutputMultiplexer, RecorderOutput, TerminalOutput, Writable } from './recorder/outputs'; +import { BufferedOutput, FileOutput, FlushingTerminalOutput, OutputMultiplexer, RecorderOutput, TerminalOutput, Writable } from './recorder/outputs'; +import type { State, UIState } from './recorder/state'; type BindingSource = { frame: Frame, page: Page }; type App = 'codegen' | 'debug' | 'pause'; -type Mode = 'inspecting' | 'recording' | 'none'; const symbol = Symbol('RecorderSupplement'); @@ -45,10 +45,11 @@ export class RecorderSupplement { private _timers = new Set(); private _context: BrowserContext; private _resumeCallback: (() => void) | null = null; - private _recorderState: { mode: Mode }; + private _recorderUIState: UIState; private _paused = false; private _app: App; private _output: OutputMultiplexer; + private _bufferedOutput: BufferedOutput; static getOrCreate(context: BrowserContext, app: App, params: channels.BrowserContextRecorderSupplementEnableParams): Promise { let recorderPromise = (context as any)[symbol] as Promise; @@ -63,7 +64,10 @@ export class RecorderSupplement { constructor(context: BrowserContext, app: App, params: channels.BrowserContextRecorderSupplementEnableParams) { this._context = context; this._app = app; - this._recorderState = { mode: app === 'codegen' ? 'recording' : 'none' }; + this._recorderUIState = { + mode: app === 'codegen' ? 'recording' : 'none', + drawerVisible: false + }; let languageGenerator: LanguageGenerator; switch (params.language) { case 'javascript': languageGenerator = new JavaScriptLanguageGenerator(); break; @@ -80,6 +84,8 @@ export class RecorderSupplement { write: (text: string) => context.emit(BrowserContext.Events.StdOut, text) }; const outputs: RecorderOutput[] = [params.terminal ? new TerminalOutput(writable, highlighterType) : new FlushingTerminalOutput(writable)]; + this._bufferedOutput = new BufferedOutput(highlighterType); + outputs.push(this._bufferedOutput); if (params.outputFile) outputs.push(new FileOutput(params.outputFile)); this._output = new OutputMultiplexer(outputs); @@ -114,16 +120,33 @@ export class RecorderSupplement { await this._context.exposeBinding('playwrightRecorderCommitAction', false, (source: BindingSource, action: actions.Action) => this._generator.commitLastAction()); - await this._context.exposeBinding('playwrightRecorderState', false, () => { - return { - state: this._recorderState, - app: this._app, - paused: this._paused + await this._context.exposeBinding('playwrightRecorderClearScript', false, + (source: BindingSource, action: actions.Action) => { + this._bufferedOutput.clear(); + this._generator.restart(); + if (this._app === 'codegen') { + for (const page of this._context.pages()) + this._onFrameNavigated(page.mainFrame(), page); + } + }); + + await this._context.exposeBinding('playwrightRecorderState', false, ({ page }) => { + const state: State = { + isController: page === this._context.pages()[0], + uiState: this._recorderUIState, + canResume: this._app === 'pause', + isPaused: this._paused, + codegenScript: this._bufferedOutput.buffer() }; + return state; }); - await this._context.exposeBinding('playwrightRecorderSetState', false, (source, state) => { - this._recorderState = state; + await this._context.exposeBinding('playwrightRecorderSetUIState', false, (source, state: UIState) => { + const isController = source.page === this._context.pages()[0]; + if (isController) + this._recorderUIState = { ...this._recorderUIState, ...state }; + else + this._recorderUIState = { ...this._recorderUIState, mode: state.mode }; this._output.setEnabled(state.mode === 'recording'); }); diff --git a/test/pause.spec.ts b/test/pause.spec.ts index 7d4da352fa..01f75da2cc 100644 --- a/test/pause.spec.ts +++ b/test/pause.spec.ts @@ -27,7 +27,7 @@ it('should pause and resume the script', async ({page}) => { const resumePromise = (page as any)._pause().then(() => resolved = true); await new Promise(x => setTimeout(x, 0)); expect(resolved).toBe(false); - await page.click('.playwright-resume'); + await page.click('#pw-button-resume'); await resumePromise; expect(resolved).toBe(true); }); @@ -38,7 +38,7 @@ it('should pause through a navigation', async ({page, server}) => { await new Promise(x => setTimeout(x, 0)); expect(resolved).toBe(false); await page.goto(server.EMPTY_PAGE); - await page.click('.playwright-resume'); + await page.click('#pw-button-resume'); await resumePromise; expect(resolved).toBe(true); }); @@ -50,7 +50,7 @@ it('should pause after a navigation', async ({page, server}) => { const resumePromise = (page as any)._pause().then(() => resolved = true); await new Promise(x => setTimeout(x, 0)); expect(resolved).toBe(false); - await page.click('.playwright-resume'); + await page.click('#pw-button-resume'); await resumePromise; expect(resolved).toBe(true); });