mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
chore: pause on input in pwdebug mode (#5427)
This commit is contained in:
parent
55614c7cc8
commit
aef052aecc
@ -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> {
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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`);
|
||||
|
@ -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',
|
||||
|
@ -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.`);
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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.
|
||||
{
|
||||
|
36
src/server/supplements/recorder/recorderTypes.ts
Normal file
36
src/server/supplements/recorder/recorderTypes.ts
Normal 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
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -24,6 +24,8 @@
|
||||
display: flex;
|
||||
color: #eee;
|
||||
background-color: #333;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
@ -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>;
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
});
|
||||
});
|
||||
|
@ -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 => {
|
||||
|
Loading…
x
Reference in New Issue
Block a user