chore: pause on input in pwdebug mode (#5427)

This commit is contained in:
Pavel Feldman 2021-02-12 10:11:30 -08:00 committed by GitHub
parent 55614c7cc8
commit aef052aecc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 299 additions and 96 deletions

View File

@ -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<BrowserContext, channels.BrowserContextInitializer> implements channels.BrowserContextChannel {
private _context: BrowserContext;
@ -132,11 +131,8 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
await RecorderSupplement.getOrCreate(this._context, params);
}
async pause() {
if (!this._context._browser.options.headful && !isUnderTest())
return;
const recorder = await RecorderSupplement.getOrCreate(this._context);
await recorder.pause();
async pause(params: channels.BrowserContextPauseParams, metadata: CallMetadata) {
// Inspector controller will take care of this.
}
async crNewCDPSession(params: channels.BrowserContextCrNewCDPSessionParams): Promise<channels.BrowserContextCrNewCDPSessionResult> {

View File

@ -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);
}
}

View File

@ -382,6 +382,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
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`);

View File

@ -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',

View File

@ -27,6 +27,7 @@ export interface Progress {
throwIfAborted(): void;
beforeInputAction(): Promise<void>;
afterInputAction(): Promise<void>;
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.`);

View File

@ -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<void>;
_playwrightRecorderRecordAction: (action: actions.Action) => Promise<void>;
_playwrightRecorderCommitAction: () => Promise<void>;
_playwrightRecorderState: () => Promise<State>;
_playwrightRecorderState: () => Promise<UIState>;
_playwrightRecorderPrintSelector: (text: string) => Promise<void>;
_playwrightResume: () => Promise<void>;
}
@ -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 {
</x-pw-glass>`;
this._tooltipElement = html`<x-pw-tooltip></x-pw-tooltip>`;
this._actionPointElement = html`<x-pw-action-point hidden=true></x-pw-action-point>`;
this._innerGlassPaneElement = html`
<x-pw-glass-inner style="flex: auto">
@ -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`
<style>
x-pw-tooltip {
@ -103,6 +105,19 @@ export class Recorder {
position: absolute;
top: 0;
}
x-pw-action-point {
position: absolute;
width: 20px;
height: 20px;
background: red;
border-radius: 10px;
pointer-events: none;
margin: -10px 0 0 -10px;
z-index: 2;
}
*[hidden] {
display: none !important;
}
</style>
`);
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);
}

View File

@ -26,6 +26,14 @@ type SelectorToken = {
const cacheAllowText = new Map<Element, SelectorToken[] | null>();
const cacheDisallowText = new Map<Element, SelectorToken[] | null>();
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 {

View File

@ -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<BrowserContext, Promise<RecorderSupplement>>();
async onContextCreated(context: BrowserContext): Promise<void> {
if (isDebugMode())
RecorderSupplement.getOrCreate(context);
this._recorders.set(context, RecorderSupplement.getOrCreate(context));
}
async onContextDidDestroy(context: BrowserContext): Promise<void> {
this._recorders.delete(context);
}
async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
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<void> {
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<void> {
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 {

View File

@ -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<void>;
}
}
@ -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<void> {
await this._page.mainFrame()._evaluateExpression(((paused: boolean) => {
window.playwrightSetPaused(paused);
}).toString(), true, paused, 'main').catch(() => {});
async setPaused(details: PauseDetails | null): Promise<void> {
await this._page.mainFrame()._evaluateExpression(((details: PauseDetails | null) => {
window.playwrightSetPaused(details);
}).toString(), true, details, 'main').catch(() => {});
}
async setSource(text: string, language: string): Promise<void> {
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<void> {
await this._page.mainFrame()._evaluateExpression(((source: Source) => {
window.playwrightSetSource(source);
}).toString(), true, { text, language, highlightedLine }, 'main').catch(() => {});
// Testing harness for runCLI mode.
{

View File

@ -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
};

View File

@ -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<string, string>();
private _sdkObject: SdkObject | null = null;
static getOrCreate(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<RecorderSupplement> {
let recorderPromise = (context as any)[symbol] as Promise<RecorderSupplement>;
@ -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<void>(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<void> {
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<void> {
this._sdkObject = null;
this._callMetadata = null;
}
async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
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;
}
}

View File

@ -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,

View File

@ -45,5 +45,12 @@
}
.source-line-highlighted {
background-color: #ffc0cb7f;
background-color: #6fa8dc7f;
z-index: 2;
}
.source-line-paused {
background-color: #ffc0cb7f;
outline: 1px solid red;
z-index: 2;
}

View File

@ -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<SourceProps> = ({
text,
language,
paused = false,
highlightedLine = -1
}) => {
const lines = React.useMemo<string[]>(() => {
@ -51,7 +53,8 @@ export const Source: React.FC<SourceProps> = ({
return <div className='source'>{
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 <div key={index} className={className} ref={isHighlighted ? highlightedLineRef : null}>
<div className='source-line-number'>{index + 1}</div>
<div className='source-code' dangerouslySetInnerHTML={{ __html: markup }}></div>

View File

@ -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;
}

View File

@ -24,6 +24,8 @@
display: flex;
color: #eee;
background-color: #333;
height: 24px;
line-height: 24px;
align-items: center;
flex: none;
white-space: nowrap;
}

View File

@ -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<void>;
playwrightSourceEchoForTest?: (text: string) => Promise<void>;
}
@ -37,8 +36,8 @@ export interface RecorderProps {
export const Recorder: React.FC<RecorderProps> = ({
}) => {
const [source, setSource] = React.useState({ language: 'javascript', text: '' });
const [paused, setPaused] = React.useState(false);
const [source, setSource] = React.useState<Source>({ language: 'javascript', text: '' });
const [paused, setPaused] = React.useState<PauseDetails | null>(null);
const [mode, setMode] = React.useState<Mode>('none');
window.playwrightSetMode = setMode;
@ -58,19 +57,21 @@ export const Recorder: React.FC<RecorderProps> = ({
<ToolbarButton icon="files" title="Copy" disabled={!source.text} onClick={() => {
copy(source.text);
}}></ToolbarButton>
<ToolbarButton icon="debug-continue" title="Resume" disabled={!paused} onClick={() => {
window.dispatch({ event: 'resume' }).catch(() => {});
}}></ToolbarButton>
<ToolbarButton icon="debug-pause" title="Pause" disabled={!!paused} onClick={() => {
window.dispatch({ event: 'pause' }).catch(() => {});
}}></ToolbarButton>
<ToolbarButton icon="debug-step-over" title="Step over" disabled={!paused} onClick={() => {
window.dispatch({ event: 'step' }).catch(() => {});
}}></ToolbarButton>
<div style={{flex: "auto"}}></div>
<ToolbarButton icon="clear-all" title="Clear" disabled={!source.text} onClick={() => {
window.dispatch({ event: 'clear' }).catch(() => {});
}}></ToolbarButton>
</Toolbar>
<div className="recorder-paused-infobar" hidden={!paused}>
<ToolbarButton icon="run" title="Resume" disabled={!paused} onClick={() => {
window.dispatch({ event: 'resume' }).catch(() => {});
}}></ToolbarButton>
<span style={{paddingLeft: 10}}>Paused due to <span className="code">page.pause()</span></span>
<div style={{flex: "auto"}}></div>
</div>
<Source text={source.text} language={source.language}></Source>
<SourceView text={source.text} language={source.language} highlightedLine={source.highlightedLine} paused={!!paused}></SourceView>
</div>;
};

View File

@ -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;
});
});

View File

@ -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 => {