diff --git a/src/dispatchers/browserContextDispatcher.ts b/src/dispatchers/browserContextDispatcher.ts index fbd1cc4a13..e8441b6a28 100644 --- a/src/dispatchers/browserContextDispatcher.ts +++ b/src/dispatchers/browserContextDispatcher.ts @@ -23,7 +23,6 @@ import { CRBrowserContext } from '../server/chromium/crBrowser'; import { CDPSessionDispatcher } from './cdpSessionDispatcher'; import { RecorderSupplement } from '../server/supplements/recorderSupplement'; import { CallMetadata } from '../server/instrumentation'; -import { isUnderTest } from '../utils/utils'; export class BrowserContextDispatcher extends Dispatcher implements channels.BrowserContextChannel { private _context: BrowserContext; @@ -132,11 +131,8 @@ export class BrowserContextDispatcher extends Dispatcher { diff --git a/src/dispatchers/dispatcher.ts b/src/dispatchers/dispatcher.ts index 333a05b731..37f16a1eb2 100644 --- a/src/dispatchers/dispatcher.ts +++ b/src/dispatchers/dispatcher.ts @@ -190,6 +190,7 @@ export class DispatcherConnection { } const callMetadata: CallMetadata = { + id, ...validMetadata, startTime: monotonicTime(), endTime: 0, @@ -199,9 +200,10 @@ export class DispatcherConnection { log: [], }; + const sdkObject = dispatcher._object instanceof SdkObject ? dispatcher._object : undefined; try { - if (dispatcher instanceof SdkObject) - await dispatcher.instrumentation.onBeforeCall(dispatcher, callMetadata); + if (sdkObject) + await sdkObject.instrumentation.onBeforeCall(sdkObject, callMetadata); const result = await (dispatcher as any)[method](validParams, callMetadata); this.onmessage({ id, result: this._replaceDispatchersWithGuids(result) }); } catch (e) { @@ -210,8 +212,8 @@ export class DispatcherConnection { rewriteErrorMessage(e, e.message + formatLogRecording(callMetadata.log) + kLoggingNote); this.onmessage({ id, error: serializeError(e) }); } finally { - if (dispatcher instanceof SdkObject) - await dispatcher.instrumentation.onAfterCall(dispatcher, callMetadata); + if (sdkObject) + await sdkObject.instrumentation.onAfterCall(sdkObject, callMetadata); } } diff --git a/src/server/dom.ts b/src/server/dom.ts index 03719859c1..5197b22a2e 100644 --- a/src/server/dom.ts +++ b/src/server/dom.ts @@ -382,6 +382,7 @@ export class ElementHandle extends js.JSHandle { if (options && options.modifiers) restoreModifiers = await this._page.keyboard._ensureModifiers(options.modifiers); progress.log(` performing ${actionName} action`); + progress.metadata.point = point; await progress.beforeInputAction(); await action(point); progress.log(` ${actionName} action done`); diff --git a/src/server/instrumentation.ts b/src/server/instrumentation.ts index fbefbe8484..2b63c06e68 100644 --- a/src/server/instrumentation.ts +++ b/src/server/instrumentation.ts @@ -15,7 +15,7 @@ */ import { EventEmitter } from 'events'; -import { StackFrame } from '../common/types'; +import { Point, StackFrame } from '../common/types'; import type { Browser } from './browser'; import type { BrowserContext } from './browserContext'; import type { BrowserType } from './browserType'; @@ -31,6 +31,7 @@ export type Attribution = { }; export type CallMetadata = { + id: number; startTime: number; endTime: number; type: string; @@ -39,6 +40,7 @@ export type CallMetadata = { stack?: StackFrame[]; log: string[]; error?: Error; + point?: Point; }; export class SdkObject extends EventEmitter { @@ -92,6 +94,7 @@ export function multiplexInstrumentation(listeners: InstrumentationListener[]): export function internalCallMetadata(): CallMetadata { return { + id: 0, startTime: 0, endTime: 0, type: 'Internal', diff --git a/src/server/progress.ts b/src/server/progress.ts index f20aee2c9c..47a8a00155 100644 --- a/src/server/progress.ts +++ b/src/server/progress.ts @@ -27,6 +27,7 @@ export interface Progress { throwIfAborted(): void; beforeInputAction(): Promise; afterInputAction(): Promise; + metadata: CallMetadata; } export class ProgressController { @@ -92,6 +93,7 @@ export class ProgressController { afterInputAction: async () => { await this.instrumentation.onAfterInputAction(this.sdkObject, this.metadata); }, + metadata: this.metadata }; const timeoutError = new TimeoutError(`Timeout ${this._timeout}ms exceeded.`); diff --git a/src/server/supplements/injected/recorder.ts b/src/server/supplements/injected/recorder.ts index e5f8aa8169..a3a478d39e 100644 --- a/src/server/supplements/injected/recorder.ts +++ b/src/server/supplements/injected/recorder.ts @@ -16,20 +16,17 @@ import type * as actions from '../recorder/recorderActions'; import type InjectedScript from '../../injected/injectedScript'; -import { generateSelector } from './selectorGenerator'; +import { generateSelector, querySelector } from './selectorGenerator'; import { html } from './html'; - -type Mode = 'inspecting' | 'recording' | 'none'; -type State = { - mode: Mode, -}; +import type { Point } from '../../../common/types'; +import type { UIState } from '../recorder/recorderTypes'; declare global { interface Window { _playwrightRecorderPerformAction: (action: actions.Action) => Promise; _playwrightRecorderRecordAction: (action: actions.Action) => Promise; _playwrightRecorderCommitAction: () => Promise; - _playwrightRecorderState: () => Promise; + _playwrightRecorderState: () => Promise; _playwrightRecorderPrintSelector: (text: string) => Promise; _playwrightResume: () => Promise; } @@ -52,6 +49,9 @@ export class Recorder { private _expectProgrammaticKeyUp = false; private _pollRecorderModeTimer: NodeJS.Timeout | undefined; private _mode: 'none' | 'inspecting' | 'recording' = 'none'; + private _actionPointElement: HTMLElement; + private _actionPoint: Point | undefined; + private _actionSelector: string | undefined; constructor(injectedScript: InjectedScript) { this._injectedScript = injectedScript; @@ -69,6 +69,7 @@ export class Recorder { `; this._tooltipElement = html``; + this._actionPointElement = html``; this._innerGlassPaneElement = html` @@ -78,6 +79,7 @@ export class Recorder { // Use a closed shadow root to prevent selectors matching our internal previews. this._glassPaneShadow = this._outerGlassPaneElement.attachShadow({ mode: 'closed' }); this._glassPaneShadow.appendChild(this._innerGlassPaneElement); + this._glassPaneShadow.appendChild(this._actionPointElement); this._glassPaneShadow.appendChild(html` `); this._refreshListenersIfNeeded(); @@ -114,11 +129,6 @@ export class Recorder { this._pollRecorderMode().catch(e => console.log(e)); // eslint-disable-line no-console } - private _setMode(mode: Mode): void { - this._clearHighlight(); - this._mode = mode; - } - private _refreshListenersIfNeeded() { if ((document.documentElement as any)[scriptSymbol]) return; @@ -136,6 +146,7 @@ export class Recorder { addEventListener(document, 'focus', () => this._onFocus(), true), addEventListener(document, 'scroll', () => { this._hoveredModel = null; + this._actionPointElement.hidden = true; this._updateHighlight(); }, true), ]; @@ -152,11 +163,35 @@ export class Recorder { return; } - const { mode } = state; + const { mode, actionPoint, actionSelector } = state; if (mode !== this._mode) { this._mode = mode; this._clearHighlight(); } + if (actionPoint && this._actionPoint && actionPoint.x === this._actionPoint.x && actionPoint.y === this._actionPoint.y) { + // All good. + } else if (!actionPoint && !this._actionPoint) { + // All good. + } else { + if (actionPoint) { + this._actionPointElement.style.top = actionPoint.y + 'px'; + this._actionPointElement.style.left = actionPoint.x + 'px'; + this._actionPointElement.hidden = false; + } else { + this._actionPointElement.hidden = true; + } + this._actionPoint = actionPoint; + } + + // Race or scroll. + if (this._actionSelector && !this._hoveredModel?.elements.length) + this._actionSelector = undefined; + + if (actionSelector !== this._actionSelector) { + this._hoveredModel = actionSelector ? querySelector(this._injectedScript, actionSelector, document) : null; + this._updateHighlight(); + this._actionSelector = actionSelector; + } this._pollRecorderModeTimer = setTimeout(() => this._pollRecorderMode(), pollPeriod); } diff --git a/src/server/supplements/injected/selectorGenerator.ts b/src/server/supplements/injected/selectorGenerator.ts index 537fe0be27..37f72f1801 100644 --- a/src/server/supplements/injected/selectorGenerator.ts +++ b/src/server/supplements/injected/selectorGenerator.ts @@ -26,6 +26,14 @@ type SelectorToken = { const cacheAllowText = new Map(); const cacheDisallowText = new Map(); +export function querySelector(injectedScript: InjectedScript, selector: string, ownerDocument: Document): { selector: string, elements: Element[] } { + const parsedSelector = injectedScript.parseSelector(selector); + return { + selector, + elements: injectedScript.querySelectorAll(parsedSelector, ownerDocument) + }; +} + export function generateSelector(injectedScript: InjectedScript, targetElement: Element): { selector: string, elements: Element[] } { injectedScript._evaluator.begin(); try { diff --git a/src/server/supplements/inspectorController.ts b/src/server/supplements/inspectorController.ts index 51b623b89a..a3741098ee 100644 --- a/src/server/supplements/inspectorController.ts +++ b/src/server/supplements/inspectorController.ts @@ -15,15 +15,51 @@ */ import { BrowserContext } from '../browserContext'; -import { isDebugMode } from '../../utils/utils'; import { RecorderSupplement } from './recorderSupplement'; -import { InstrumentationListener } from '../instrumentation'; import { debugLogger } from '../../utils/debugLogger'; +import { CallMetadata, InstrumentationListener, SdkObject } from '../instrumentation'; +import { isDebugMode, isUnderTest } from '../../utils/utils'; export class InspectorController implements InstrumentationListener { + private _recorders = new Map>(); + async onContextCreated(context: BrowserContext): Promise { if (isDebugMode()) - RecorderSupplement.getOrCreate(context); + this._recorders.set(context, RecorderSupplement.getOrCreate(context)); + } + + async onContextDidDestroy(context: BrowserContext): Promise { + this._recorders.delete(context); + } + + async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise { + const context = sdkObject.attribution.context; + if (!context) + return; + + if (metadata.method === 'pause') { + // Force create recorder on pause. + if (!context._browser.options.headful && !isUnderTest()) + return; + this._recorders.set(context, RecorderSupplement.getOrCreate(context)); + } + + const recorder = await this._recorders.get(context); + await recorder?.onBeforeCall(sdkObject, metadata); + } + + async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise { + if (!sdkObject.attribution.page) + return; + const recorder = await this._recorders.get(sdkObject.attribution.context!); + await recorder?.onAfterCall(sdkObject, metadata); + } + + async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise { + if (!sdkObject.attribution.page) + return; + const recorder = await this._recorders.get(sdkObject.attribution.context!); + await recorder?.onBeforeInputAction(sdkObject, metadata); } onCallLog(logName: string, message: string): void { diff --git a/src/server/supplements/recorder/recorderApp.ts b/src/server/supplements/recorder/recorderApp.ts index 6e6172e4cd..925f790042 100644 --- a/src/server/supplements/recorder/recorderApp.ts +++ b/src/server/supplements/recorder/recorderApp.ts @@ -23,22 +23,17 @@ import { ProgressController } from '../../progress'; import { createPlaywright } from '../../playwright'; import { EventEmitter } from 'events'; import { internalCallMetadata } from '../../instrumentation'; -import { isUnderTest } from '../../../utils/utils'; +import type { EventData, Mode, PauseDetails, Source } from './recorderTypes'; import { BrowserContext } from '../../browserContext'; +import { isUnderTest } from '../../../utils/utils'; const readFileAsync = util.promisify(fs.readFile); -export type Mode = 'inspecting' | 'recording' | 'none'; -export type EventData = { - event: 'clear' | 'resume' | 'setMode', - params: any -}; - declare global { interface Window { playwrightSetMode: (mode: Mode) => void; - playwrightSetPaused: (paused: boolean) => void; - playwrightSetSource: (params: { text: string, language: string }) => void; + playwrightSetPaused: (details: PauseDetails | null) => void; + playwrightSetSource: (source: Source) => void; dispatch(data: EventData): Promise; } } @@ -102,7 +97,7 @@ export class RecorderApp extends EventEmitter { '--window-position=1280,10', ], noDefaultViewport: true, - headless: isUnderTest() + headless: isUnderTest() && !inspectedContext._browser.options.headful }); const controller = new ProgressController(internalCallMetadata(), context._browser); @@ -122,16 +117,16 @@ export class RecorderApp extends EventEmitter { }).toString(), true, mode, 'main').catch(() => {}); } - async setPaused(paused: boolean): Promise { - await this._page.mainFrame()._evaluateExpression(((paused: boolean) => { - window.playwrightSetPaused(paused); - }).toString(), true, paused, 'main').catch(() => {}); + async setPaused(details: PauseDetails | null): Promise { + await this._page.mainFrame()._evaluateExpression(((details: PauseDetails | null) => { + window.playwrightSetPaused(details); + }).toString(), true, details, 'main').catch(() => {}); } - async setSource(text: string, language: string): Promise { - await this._page.mainFrame()._evaluateExpression(((param: { text: string, language: string }) => { - window.playwrightSetSource(param); - }).toString(), true, { text, language }, 'main').catch(() => {}); + async setSource(text: string, language: string, highlightedLine?: number): Promise { + await this._page.mainFrame()._evaluateExpression(((source: Source) => { + window.playwrightSetSource(source); + }).toString(), true, { text, language, highlightedLine }, 'main').catch(() => {}); // Testing harness for runCLI mode. { diff --git a/src/server/supplements/recorder/recorderTypes.ts b/src/server/supplements/recorder/recorderTypes.ts new file mode 100644 index 0000000000..6c5e8bceef --- /dev/null +++ b/src/server/supplements/recorder/recorderTypes.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Point } from '../../../common/types'; + +export type Mode = 'inspecting' | 'recording' | 'none'; + +export type EventData = { + event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode', + params: any +}; + +export type PauseDetails = { + message: string; +}; + +export type Source = { text: string, language: string, highlightedLine?: number }; + +export type UIState = { + mode: Mode, + actionPoint?: Point, + actionSelector?: string +}; diff --git a/src/server/supplements/recorderSupplement.ts b/src/server/supplements/recorderSupplement.ts index d2081558a8..14f5eb0e6d 100644 --- a/src/server/supplements/recorderSupplement.ts +++ b/src/server/supplements/recorderSupplement.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import * as fs from 'fs'; import * as actions from './recorder/recorderActions'; import type * as channels from '../../protocol/channels'; import { CodeGenerator, ActionInContext } from './recorder/codeGenerator'; @@ -28,8 +29,10 @@ import { PythonLanguageGenerator } from './recorder/python'; import * as recorderSource from '../../generated/recorderSource'; import * as consoleApiSource from '../../generated/consoleApiSource'; import { BufferedOutput, FileOutput, OutputMultiplexer, RecorderOutput } from './recorder/outputs'; -import { EventData, Mode, RecorderApp } from './recorder/recorderApp'; -import { internalCallMetadata } from '../instrumentation'; +import { RecorderApp } from './recorder/recorderApp'; +import { CallMetadata, internalCallMetadata, SdkObject } from '../instrumentation'; +import { Point } from '../../common/types'; +import { EventData, Mode, PauseDetails, UIState } from './recorder/recorderTypes'; type BindingSource = { frame: Frame, page: Page }; @@ -44,12 +47,16 @@ export class RecorderSupplement { private _context: BrowserContext; private _resumeCallback: (() => void) | null = null; private _mode: Mode; - private _paused = false; + private _pauseDetails: PauseDetails | null = null; private _output: OutputMultiplexer; private _bufferedOutput: BufferedOutput; private _recorderApp: RecorderApp | null = null; private _highlighterType: string; private _params: channels.BrowserContextRecorderSupplementEnableParams; + private _callMetadata: CallMetadata | null = null; + private _pauseOnNextStatement = true; + private _sourceCache = new Map(); + private _sdkObject: SdkObject | null = null; static getOrCreate(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { let recorderPromise = (context as any)[symbol] as Promise; @@ -78,13 +85,12 @@ export class RecorderSupplement { if (highlighterType === 'python-async') highlighterType = 'python'; - const outputs: RecorderOutput[] = []; this._highlighterType = highlighterType; this._bufferedOutput = new BufferedOutput(async text => { if (this._recorderApp) this._recorderApp.setSource(text, highlighterType); }); - outputs.push(this._bufferedOutput); + const outputs: RecorderOutput[] = [ this._bufferedOutput ]; if (params.outputFile) outputs.push(new FileOutput(params.outputFile)); this._output = new OutputMultiplexer(outputs); @@ -110,8 +116,16 @@ export class RecorderSupplement { this._context.pages()[0].bringToFront().catch(() => {}); return; } + if (data.event === 'step') { + this._resume(true); + return; + } if (data.event === 'resume') { - this._resume(); + this._resume(false); + return; + } + if (data.event === 'pause') { + this._pauseOnNextStatement = true; return; } if (data.event === 'clear') { @@ -122,7 +136,7 @@ export class RecorderSupplement { await Promise.all([ recorderApp.setMode(this._mode), - recorderApp.setPaused(this._paused), + recorderApp.setPaused(this._pauseDetails), recorderApp.setSource(this._bufferedOutput.buffer(), this._highlighterType) ]); @@ -150,12 +164,19 @@ export class RecorderSupplement { await this._context.exposeBinding('_playwrightRecorderCommitAction', false, (source: BindingSource, action: actions.Action) => this._generator.commitLastAction()); - await this._context.exposeBinding('_playwrightRecorderState', false, () => { - return { mode: this._mode }; + await this._context.exposeBinding('_playwrightRecorderState', false, source => { + let actionPoint: Point | undefined = undefined; + let actionSelector: string | undefined = undefined; + if (source.page === this._sdkObject?.attribution?.page) { + actionPoint = this._callMetadata?.point; + actionSelector = this._callMetadata?.params.selector; + } + const uiState: UIState = { mode: this._mode, actionPoint, actionSelector }; + return uiState; }); await this._context.exposeBinding('_playwrightResume', false, () => { - this._resume().catch(() => {}); + this._resume(false).catch(() => {}); }); await this._context.extendInjectedScript(recorderSource.source); @@ -165,18 +186,18 @@ export class RecorderSupplement { } async pause() { - this._paused = true; - this._recorderApp!.setPaused(true); + this._pauseDetails = { message: 'paused' }; + this._recorderApp!.setPaused(this._pauseDetails); return new Promise(f => this._resumeCallback = f); } - private async _resume() { + private async _resume(step: boolean) { + this._pauseOnNextStatement = step; if (this._resumeCallback) this._resumeCallback(); this._resumeCallback = null; - this._paused = false; - if (this._recorderApp) - this._recorderApp.setPaused(this._paused); + this._pauseDetails = null; + this._recorderApp?.setPaused(null); } private async _onPage(page: Page) { @@ -294,4 +315,50 @@ export class RecorderSupplement { const pageAlias = this._pageAliases.get(page)!; this._generator.signal(pageAlias, page.mainFrame(), { name: 'dialog', dialogAlias: String(++this._lastDialogOrdinal) }); } + + async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise { + this._sdkObject = sdkObject; + this._callMetadata = metadata; + const { source, line } = this._source(metadata); + this._recorderApp?.setSource(source, 'javascript', line); + if (metadata.method === 'pause' || (this._pauseOnNextStatement && metadata.method === 'goto')) + await this.pause(); + } + + async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise { + this._sdkObject = null; + this._callMetadata = null; + } + + async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise { + if (this._pauseOnNextStatement) + await this.pause(); + } + + private _source(metadata: CallMetadata): { source: string, line: number | undefined } { + let source = '// No source available'; + let line: number | undefined = undefined; + if (metadata.stack && metadata.stack.length) { + try { + source = this._readAndCacheSource(metadata.stack[0].file); + line = metadata.stack[0].line ? metadata.stack[0].line - 1 : undefined; + } catch (e) { + source = metadata.stack.join('\n'); + } + } + return { source, line }; + } + + private _readAndCacheSource(fileName: string): string { + let source = this._sourceCache.get(fileName); + if (source) + return source; + try { + source = fs.readFileSync(fileName, 'utf-8'); + } catch (e) { + source = '// No source available'; + } + this._sourceCache.set(fileName, source); + return source; + } } diff --git a/src/utils/stackTrace.ts b/src/utils/stackTrace.ts index adcb539b4a..da7d7b151f 100644 --- a/src/utils/stackTrace.ts +++ b/src/utils/stackTrace.ts @@ -17,6 +17,7 @@ import path from 'path'; import { StackFrame } from '../common/types'; import StackUtils from 'stack-utils'; +import { isUnderTest } from './utils'; const stackUtils = new StackUtils(); @@ -50,6 +51,8 @@ export function captureStackTrace(): { stack: string, frames: StackFrame[] } { // for tests. if (fileName.includes(path.join('playwright', 'src'))) continue; + if (isUnderTest() && fileName.includes(path.join('playwright', 'test', 'coverage.js'))) + continue; frames.push({ file: fileName, line: frame.line, diff --git a/src/web/components/source.css b/src/web/components/source.css index 70968ace74..6abc9d3505 100644 --- a/src/web/components/source.css +++ b/src/web/components/source.css @@ -45,5 +45,12 @@ } .source-line-highlighted { + background-color: #6fa8dc7f; + z-index: 2; +} + +.source-line-paused { background-color: #ffc0cb7f; -} \ No newline at end of file + outline: 1px solid red; + z-index: 2; +} diff --git a/src/web/components/source.tsx b/src/web/components/source.tsx index 0dba2e1fbc..53c7b34743 100644 --- a/src/web/components/source.tsx +++ b/src/web/components/source.tsx @@ -22,12 +22,14 @@ import '../../third_party/highlightjs/highlightjs/tomorrow.css'; export interface SourceProps { text: string, language: string, - highlightedLine?: number + highlightedLine?: number, + paused?: boolean } export const Source: React.FC = ({ text, language, + paused = false, highlightedLine = -1 }) => { const lines = React.useMemo(() => { @@ -51,7 +53,8 @@ export const Source: React.FC = ({ return
{ lines.map((markup, index) => { const isHighlighted = index === highlightedLine; - const className = isHighlighted ? 'source-line source-line-highlighted' : 'source-line'; + const highlightType = paused && isHighlighted ? 'source-line-paused' : 'source-line-highlighted'; + const className = isHighlighted ? `source-line ${highlightType}` : 'source-line'; return
{index + 1}
diff --git a/src/web/components/toolbarButton.css b/src/web/components/toolbarButton.css index a2df752d9d..778acf1f42 100644 --- a/src/web/components/toolbarButton.css +++ b/src/web/components/toolbarButton.css @@ -41,10 +41,12 @@ color: #fd1e1e; } -.toolbar-button.codicon-run { - color: #4bfd1e; +.toolbar-button.codicon-debug-continue, +.toolbar-button.codicon-debug-step-over { + color: #01bb01; } -.toolbar-button.codicon-run:hover { - color: #0f0; +.toolbar-button.codicon-debug-continue:hover, +.toolbar-button.codicon-debug-step-over:hover { + color: #41ca1e; } diff --git a/src/web/recorder/recorder.css b/src/web/recorder/recorder.css index 873b20f411..83f01c778f 100644 --- a/src/web/recorder/recorder.css +++ b/src/web/recorder/recorder.css @@ -24,6 +24,8 @@ display: flex; color: #eee; background-color: #333; - height: 24px; + line-height: 24px; align-items: center; + flex: none; + white-space: nowrap; } diff --git a/src/web/recorder/recorder.tsx b/src/web/recorder/recorder.tsx index 7fdb5275de..200aebe093 100644 --- a/src/web/recorder/recorder.tsx +++ b/src/web/recorder/recorder.tsx @@ -18,15 +18,14 @@ import './recorder.css'; import * as React from 'react'; import { Toolbar } from '../components/toolbar'; import { ToolbarButton } from '../components/toolbarButton'; -import { Source } from '../components/source'; - -type Mode = 'inspecting' | 'recording' | 'none'; +import { Source as SourceView } from '../components/source'; +import type { Mode, PauseDetails, Source } from '../../server/supplements/recorder/recorderTypes'; declare global { interface Window { playwrightSetMode: (mode: Mode) => void; - playwrightSetPaused: (paused: boolean) => void; - playwrightSetSource: (params: { text: string, language: string }) => void; + playwrightSetPaused: (details: PauseDetails | null) => void; + playwrightSetSource: (source: Source) => void; dispatch(data: any): Promise; playwrightSourceEchoForTest?: (text: string) => Promise; } @@ -37,8 +36,8 @@ export interface RecorderProps { export const Recorder: React.FC = ({ }) => { - const [source, setSource] = React.useState({ language: 'javascript', text: '' }); - const [paused, setPaused] = React.useState(false); + const [source, setSource] = React.useState({ language: 'javascript', text: '' }); + const [paused, setPaused] = React.useState(null); const [mode, setMode] = React.useState('none'); window.playwrightSetMode = setMode; @@ -58,19 +57,21 @@ export const Recorder: React.FC = ({ { copy(source.text); }}> + { + window.dispatch({ event: 'resume' }).catch(() => {}); + }}> + { + window.dispatch({ event: 'pause' }).catch(() => {}); + }}> + { + window.dispatch({ event: 'step' }).catch(() => {}); + }}>
{ window.dispatch({ event: 'clear' }).catch(() => {}); }}> - - +
; }; diff --git a/test/pause.spec.ts b/test/pause.spec.ts index 835840a600..4045be1926 100644 --- a/test/pause.spec.ts +++ b/test/pause.spec.ts @@ -14,8 +14,9 @@ * limitations under the License. */ +import { expect } from 'folio'; import { folio } from './recorder.fixtures'; -const { it, expect, describe} = folio; +const { it, describe} = folio; describe('pause', (suite, { mode }) => { suite.skip(mode !== 'default'); @@ -36,16 +37,6 @@ describe('pause', (suite, { mode }) => { ]); }); - it('should pause through a navigation', async ({page, server, recorderClick}) => { - let resolved = false; - const resumePromise = page.pause().then(() => resolved = true); - expect(resolved).toBe(false); - await page.goto(server.EMPTY_PAGE); - await recorderClick('[title=Resume]'); - await resumePromise; - expect(resolved).toBe(true); - }); - it('should pause after a navigation', async ({page, server, recorderClick}) => { await page.goto(server.EMPTY_PAGE); await Promise.all([ @@ -53,4 +44,16 @@ describe('pause', (suite, { mode }) => { recorderClick('[title=Resume]') ]); }); + + it('should show source', async ({page, server, recorderClick, recorderFrame}) => { + await page.goto(server.EMPTY_PAGE); + const pausePromise = page.pause(); + const frame = await recorderFrame(); + const source = await frame._evaluateExpression((() => { + return document.querySelector('.source-line-paused .source-code').textContent; + }).toString(), true, undefined, 'main'); + expect(source).toContain('page.pause()'); + await recorderClick('[title=Resume]'); + await pausePromise; + }); }); diff --git a/utils/check_deps.js b/utils/check_deps.js index 2af2567fa6..9f57cc87cb 100644 --- a/utils/check_deps.js +++ b/utils/check_deps.js @@ -155,7 +155,8 @@ DEPS['src/service.ts'] = ['src/remote/']; // CLI should only use client-side features. DEPS['src/cli/'] = ['src/cli/**', 'src/client/**', 'src/install/**', 'src/generated/', 'src/server/injected/', 'src/debug/injected/', 'src/trace/**', 'src/utils/**']; -DEPS['src/server/supplements/recorder/recorderApp.ts'] = ['src/utils/', 'src/server/', 'src/server/chromium/']; +DEPS['src/server/supplements/recorder/recorderApp.ts'] = ['src/common/', 'src/utils/', 'src/server/', 'src/server/chromium/']; +DEPS['src/web/recorder/recorder.tsx'] = ['src/server/supplements/recorder/recorderTypes.ts']; DEPS['src/utils/'] = ['src/common/']; checkDeps().catch(e => {