feat: add replay log (#5452)

This commit is contained in:
Pavel Feldman 2021-02-12 18:53:46 -08:00 committed by GitHub
parent 6326d6f3ac
commit 3c877374c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 418 additions and 148 deletions

View File

@ -27,7 +27,6 @@ declare global {
_playwrightRecorderRecordAction: (action: actions.Action) => Promise<void>; _playwrightRecorderRecordAction: (action: actions.Action) => Promise<void>;
_playwrightRecorderCommitAction: () => Promise<void>; _playwrightRecorderCommitAction: () => Promise<void>;
_playwrightRecorderState: () => Promise<UIState>; _playwrightRecorderState: () => Promise<UIState>;
_playwrightRecorderPrintSelector: (text: string) => Promise<void>;
_playwrightResume: () => Promise<void>; _playwrightResume: () => Promise<void>;
} }
} }
@ -226,10 +225,8 @@ export class Recorder {
private _onClick(event: MouseEvent) { private _onClick(event: MouseEvent) {
if (this._mode === 'inspecting') { if (this._mode === 'inspecting') {
if (this._hoveredModel) { if (this._hoveredModel)
copy(this._hoveredModel.selector); copy(this._hoveredModel.selector);
window._playwrightRecorderPrintSelector(this._hoveredModel.selector);
}
} }
if (this._shouldIgnoreMouseEvent(event)) if (this._shouldIgnoreMouseEvent(event))
return; return;

View File

@ -49,20 +49,24 @@ export class InspectorController implements InstrumentationListener {
} }
async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> { async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
if (!sdkObject.attribution.page) if (!sdkObject.attribution.context)
return; return;
const recorder = await this._recorders.get(sdkObject.attribution.context!); const recorder = await this._recorders.get(sdkObject.attribution.context!);
await recorder?.onAfterCall(sdkObject, metadata); await recorder?.onAfterCall(metadata);
} }
async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> { async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
if (!sdkObject.attribution.page) if (!sdkObject.attribution.page)
return; return;
const recorder = await this._recorders.get(sdkObject.attribution.context!); const recorder = await this._recorders.get(sdkObject.attribution.context!);
await recorder?.onBeforeInputAction(sdkObject, metadata); await recorder?.onBeforeInputAction(metadata);
} }
onCallLog(logName: string, message: string): void { async onCallLog(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
debugLogger.log(logName as any, message); debugLogger.log(logName as any, message);
if (!sdkObject.attribution.page)
return;
const recorder = await this._recorders.get(sdkObject.attribution.context!);
await recorder?.updateCallLog([metadata]);
} }
} }

View File

@ -23,7 +23,7 @@ import { ProgressController } from '../../progress';
import { createPlaywright } from '../../playwright'; import { createPlaywright } from '../../playwright';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { internalCallMetadata } from '../../instrumentation'; import { internalCallMetadata } from '../../instrumentation';
import type { EventData, Mode, PauseDetails, Source } from './recorderTypes'; import type { CallLog, EventData, Mode, Source } from './recorderTypes';
import { BrowserContext } from '../../browserContext'; import { BrowserContext } from '../../browserContext';
import { isUnderTest } from '../../../utils/utils'; import { isUnderTest } from '../../../utils/utils';
@ -32,8 +32,9 @@ const readFileAsync = util.promisify(fs.readFile);
declare global { declare global {
interface Window { interface Window {
playwrightSetMode: (mode: Mode) => void; playwrightSetMode: (mode: Mode) => void;
playwrightSetPaused: (details: PauseDetails | null) => void; playwrightSetPaused: (paused: boolean) => void;
playwrightSetSource: (source: Source) => void; playwrightSetSources: (sources: Source[]) => void;
playwrightUpdateLogs: (callLogs: CallLog[]) => void;
dispatch(data: EventData): Promise<void>; dispatch(data: EventData): Promise<void>;
} }
} }
@ -117,27 +118,33 @@ export class RecorderApp extends EventEmitter {
}).toString(), true, mode, 'main').catch(() => {}); }).toString(), true, mode, 'main').catch(() => {});
} }
async setPaused(details: PauseDetails | null): Promise<void> { async setPaused(paused: boolean): Promise<void> {
await this._page.mainFrame()._evaluateExpression(((details: PauseDetails | null) => { await this._page.mainFrame()._evaluateExpression(((paused: boolean) => {
window.playwrightSetPaused(details); window.playwrightSetPaused(paused);
}).toString(), true, details, 'main').catch(() => {}); }).toString(), true, paused, 'main').catch(() => {});
} }
async setSource(text: string, language: string, highlightedLine?: number): Promise<void> { async setSources(sources: Source[]): Promise<void> {
await this._page.mainFrame()._evaluateExpression(((source: Source) => { await this._page.mainFrame()._evaluateExpression(((sources: Source[]) => {
window.playwrightSetSource(source); window.playwrightSetSources(sources);
}).toString(), true, { text, language, highlightedLine }, 'main').catch(() => {}); }).toString(), true, sources, 'main').catch(() => {});
// Testing harness for runCLI mode. // Testing harness for runCLI mode.
{ {
if (process.env.PWCLI_EXIT_FOR_TEST) { if (process.env.PWCLI_EXIT_FOR_TEST) {
process.stdout.write('\n-------------8<-------------\n'); process.stdout.write('\n-------------8<-------------\n');
process.stdout.write(text); process.stdout.write(sources[0].text);
process.stdout.write('\n-------------8<-------------\n'); process.stdout.write('\n-------------8<-------------\n');
} }
} }
} }
async updateCallLogs(callLogs: CallLog[]): Promise<void> {
await this._page.mainFrame()._evaluateExpression(((callLogs: CallLog[]) => {
window.playwrightUpdateLogs(callLogs);
}).toString(), true, callLogs, 'main').catch(() => {});
}
async bringToFront() { async bringToFront() {
await this._page.bringToFront(); await this._page.bringToFront();
} }

View File

@ -19,18 +19,32 @@ import { Point } from '../../../common/types';
export type Mode = 'inspecting' | 'recording' | 'none'; export type Mode = 'inspecting' | 'recording' | 'none';
export type EventData = { export type EventData = {
event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode', event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode';
params: any params: any;
}; };
export type PauseDetails = {
message: string;
};
export type Source = { text: string, language: string, highlightedLine?: number };
export type UIState = { export type UIState = {
mode: Mode, mode: Mode;
actionPoint?: Point, actionPoint?: Point;
actionSelector?: string actionSelector?: string;
};
export type CallLog = {
id: number;
title: string;
messages: string[];
status: 'in-progress' | 'done' | 'error' | 'paused';
};
export type SourceHighlight = {
line: number;
type: 'running' | 'paused';
};
export type Source = {
file: string;
text: string;
language: string;
highlight: SourceHighlight[];
revealLine?: number;
}; };

View File

@ -32,7 +32,7 @@ import { BufferedOutput, FileOutput, OutputMultiplexer, RecorderOutput } from '.
import { RecorderApp } from './recorder/recorderApp'; import { RecorderApp } from './recorder/recorderApp';
import { CallMetadata, internalCallMetadata, SdkObject } from '../instrumentation'; import { CallMetadata, internalCallMetadata, SdkObject } from '../instrumentation';
import { Point } from '../../common/types'; import { Point } from '../../common/types';
import { EventData, Mode, PauseDetails, UIState } from './recorder/recorderTypes'; import { CallLog, EventData, Mode, Source, UIState } from './recorder/recorderTypes';
type BindingSource = { frame: Frame, page: Page }; type BindingSource = { frame: Frame, page: Page };
@ -45,18 +45,17 @@ export class RecorderSupplement {
private _lastDialogOrdinal = 0; private _lastDialogOrdinal = 0;
private _timers = new Set<NodeJS.Timeout>(); private _timers = new Set<NodeJS.Timeout>();
private _context: BrowserContext; private _context: BrowserContext;
private _resumeCallback: (() => void) | null = null;
private _mode: Mode; private _mode: Mode;
private _pauseDetails: PauseDetails | null = null;
private _output: OutputMultiplexer; private _output: OutputMultiplexer;
private _bufferedOutput: BufferedOutput; private _bufferedOutput: BufferedOutput;
private _recorderApp: RecorderApp | null = null; private _recorderApp: RecorderApp | null = null;
private _highlighterType: string;
private _params: channels.BrowserContextRecorderSupplementEnableParams; private _params: channels.BrowserContextRecorderSupplementEnableParams;
private _callMetadata: CallMetadata | null = null; private _currentCallsMetadata = new Set<CallMetadata>();
private _pausedCallsMetadata = new Map<CallMetadata, () => void>();
private _pauseOnNextStatement = true; private _pauseOnNextStatement = true;
private _sourceCache = new Map<string, string>();
private _sdkObject: SdkObject | null = null; private _sdkObject: SdkObject | null = null;
private _recorderSource: Source;
private _userSources = new Map<string, Source>();
static getOrCreate(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<RecorderSupplement> { static getOrCreate(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<RecorderSupplement> {
let recorderPromise = (context as any)[symbol] as Promise<RecorderSupplement>; let recorderPromise = (context as any)[symbol] as Promise<RecorderSupplement>;
@ -73,7 +72,7 @@ export class RecorderSupplement {
this._params = params; this._params = params;
this._mode = params.startRecording ? 'recording' : 'none'; this._mode = params.startRecording ? 'recording' : 'none';
let languageGenerator: LanguageGenerator; let languageGenerator: LanguageGenerator;
const language = params.language || context._options.sdkLanguage; let language = params.language || context._options.sdkLanguage;
switch (language) { switch (language) {
case 'javascript': languageGenerator = new JavaScriptLanguageGenerator(); break; case 'javascript': languageGenerator = new JavaScriptLanguageGenerator(); break;
case 'csharp': languageGenerator = new CSharpLanguageGenerator(); break; case 'csharp': languageGenerator = new CSharpLanguageGenerator(); break;
@ -81,14 +80,14 @@ export class RecorderSupplement {
case 'python-async': languageGenerator = new PythonLanguageGenerator(params.language === 'python-async'); break; case 'python-async': languageGenerator = new PythonLanguageGenerator(params.language === 'python-async'); break;
default: throw new Error(`Invalid target: '${params.language}'`); default: throw new Error(`Invalid target: '${params.language}'`);
} }
let highlighterType = language; if (language === 'python-async')
if (highlighterType === 'python-async') language = 'python';
highlighterType = 'python';
this._highlighterType = highlighterType; this._recorderSource = { file: '<recorder>', text: '', language, highlight: [] };
this._bufferedOutput = new BufferedOutput(async text => { this._bufferedOutput = new BufferedOutput(async text => {
if (this._recorderApp) this._recorderSource.text = text;
this._recorderApp.setSource(text, highlighterType); this._recorderSource.revealLine = text.split('\n').length - 1;
this._pushAllSources();
}); });
const outputs: RecorderOutput[] = [ this._bufferedOutput ]; const outputs: RecorderOutput[] = [ this._bufferedOutput ];
if (params.outputFile) if (params.outputFile)
@ -136,8 +135,8 @@ export class RecorderSupplement {
await Promise.all([ await Promise.all([
recorderApp.setMode(this._mode), recorderApp.setMode(this._mode),
recorderApp.setPaused(this._pauseDetails), recorderApp.setPaused(!!this._pausedCallsMetadata.size),
recorderApp.setSource(this._bufferedOutput.buffer(), this._highlighterType) this._pushAllSources()
]); ]);
this._context.on(BrowserContext.Events.Page, page => this._onPage(page)); this._context.on(BrowserContext.Events.Page, page => this._onPage(page));
@ -168,8 +167,11 @@ export class RecorderSupplement {
let actionPoint: Point | undefined = undefined; let actionPoint: Point | undefined = undefined;
let actionSelector: string | undefined = undefined; let actionSelector: string | undefined = undefined;
if (source.page === this._sdkObject?.attribution?.page) { if (source.page === this._sdkObject?.attribution?.page) {
actionPoint = this._callMetadata?.point; if (this._currentCallsMetadata.size) {
actionSelector = this._callMetadata?.params.selector; const metadata = this._currentCallsMetadata.values().next().value;
actionPoint = metadata.values().next().value;
actionSelector = metadata.params.selector;
}
} }
const uiState: UIState = { mode: this._mode, actionPoint, actionSelector }; const uiState: UIState = { mode: this._mode, actionPoint, actionSelector };
return uiState; return uiState;
@ -185,19 +187,26 @@ export class RecorderSupplement {
(this._context as any).recorderAppForTest = recorderApp; (this._context as any).recorderAppForTest = recorderApp;
} }
async pause() { async pause(metadata: CallMetadata) {
this._pauseDetails = { message: 'paused' }; const result = new Promise<void>(f => {
this._recorderApp!.setPaused(this._pauseDetails); this._pausedCallsMetadata.set(metadata, f);
return new Promise<void>(f => this._resumeCallback = f); });
this._recorderApp!.setPaused(true);
this._updateUserSources();
this.updateCallLog([metadata]);
return result;
} }
private async _resume(step: boolean) { private async _resume(step: boolean) {
this._pauseOnNextStatement = step; this._pauseOnNextStatement = step;
if (this._resumeCallback)
this._resumeCallback(); for (const callback of this._pausedCallsMetadata.values())
this._resumeCallback = null; callback();
this._pauseDetails = null; this._pausedCallsMetadata.clear();
this._recorderApp?.setPaused(null);
this._recorderApp?.setPaused(false);
this._updateUserSources();
this.updateCallLog([...this._currentCallsMetadata]);
} }
private async _onPage(page: Page) { private async _onPage(page: Page) {
@ -318,47 +327,90 @@ export class RecorderSupplement {
async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> { async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
this._sdkObject = sdkObject; this._sdkObject = sdkObject;
this._callMetadata = metadata; this._currentCallsMetadata.add(metadata);
const { source, line } = this._source(metadata); this._updateUserSources();
this._recorderApp?.setSource(source, 'javascript', line); this.updateCallLog([metadata]);
if (metadata.method === 'pause' || (this._pauseOnNextStatement && metadata.method === 'goto')) if (metadata.method === 'pause' || (this._pauseOnNextStatement && metadata.method === 'goto'))
await this.pause(); await this.pause(metadata);
} }
async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> { async onAfterCall(metadata: CallMetadata): Promise<void> {
this._sdkObject = null; this._sdkObject = null;
this._callMetadata = null; this._currentCallsMetadata.delete(metadata);
this._pausedCallsMetadata.delete(metadata);
this._updateUserSources();
this.updateCallLog([metadata]);
} }
async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> { private _updateUserSources() {
if (this._pauseOnNextStatement) // Remove old decorations.
await this.pause(); for (const source of this._userSources.values()) {
} source.highlight = [];
source.revealLine = undefined;
}
private _source(metadata: CallMetadata): { source: string, line: number | undefined } { // Apply new decorations.
let source = '// No source available'; for (const metadata of this._currentCallsMetadata) {
let line: number | undefined = undefined; if (!metadata.stack || !metadata.stack[0])
if (metadata.stack && metadata.stack.length) { continue;
try { const { file, line } = metadata.stack[0];
source = this._readAndCacheSource(metadata.stack[0].file); let source = this._userSources.get(file);
line = metadata.stack[0].line ? metadata.stack[0].line - 1 : undefined; if (!source) {
} catch (e) { source = { file, text: this._readSource(file), highlight: [], language: languageForFile(file) };
source = metadata.stack.join('\n'); this._userSources.set(file, source);
}
if (line) {
const paused = this._pausedCallsMetadata.has(metadata);
source.highlight.push({ line, type: paused ? 'paused' : 'running' });
if (paused)
source.revealLine = line;
} }
} }
return { source, line }; this._pushAllSources();
} }
private _readAndCacheSource(fileName: string): string { private _pushAllSources() {
let source = this._sourceCache.get(fileName); this._recorderApp?.setSources([this._recorderSource, ...this._userSources.values()]);
if (source) }
return source;
try { async onBeforeInputAction(metadata: CallMetadata): Promise<void> {
source = fs.readFileSync(fileName, 'utf-8'); if (this._pauseOnNextStatement)
} catch (e) { await this.pause(metadata);
source = '// No source available'; }
async updateCallLog(metadatas: CallMetadata[]): Promise<void> {
const logs: CallLog[] = [];
for (const metadata of metadatas) {
if (!metadata.method)
continue;
const title = metadata.stack?.[0]?.function || metadata.method;
let status: 'done' | 'in-progress' | 'paused' | 'error' = 'done';
if (this._currentCallsMetadata.has(metadata))
status = 'in-progress';
if (this._pausedCallsMetadata.has(metadata))
status = 'paused';
if (metadata.error)
status = 'error';
logs.push({ id: metadata.id, messages: metadata.log, title, status });
}
this._recorderApp?.updateCallLogs(logs);
}
private _readSource(fileName: string): string {
try {
return fs.readFileSync(fileName, 'utf-8');
} catch (e) {
return '// No source available';
} }
this._sourceCache.set(fileName, source);
return source;
} }
} }
function languageForFile(file: string) {
if (file.endsWith('.py'))
return 'python';
if (file.endsWith('.java'))
return 'java';
if (file.endsWith('.cs'))
return 'csharp';
return 'javascript';
}

View File

@ -15,6 +15,9 @@
*/ */
:root { :root {
--toolbar-bg-color: #fafafa;
--toolbar-color: #777;
--light-background: #f3f2f1; --light-background: #f3f2f1;
--background: #edebe9; --background: #edebe9;
--active-background: #333333; --active-background: #333333;
@ -79,7 +82,7 @@ body {
} }
.codicon { .codicon {
color: #C5C5C5; color: var(--toolbar-color);
} }
svg { svg {

View File

@ -44,7 +44,7 @@
flex: none; flex: none;
} }
.source-line-highlighted { .source-line-running {
background-color: #6fa8dc7f; background-color: #6fa8dc7f;
z-index: 2; z-index: 2;
} }

View File

@ -19,18 +19,24 @@ import * as React from 'react';
import * as highlightjs from '../../third_party/highlightjs/highlightjs'; import * as highlightjs from '../../third_party/highlightjs/highlightjs';
import '../../third_party/highlightjs/highlightjs/tomorrow.css'; import '../../third_party/highlightjs/highlightjs/tomorrow.css';
export type SourceHighlight = {
line: number;
type: 'running' | 'paused';
};
export interface SourceProps { export interface SourceProps {
text: string, text: string;
language: string, language: string;
highlightedLine?: number, // 1-based
paused?: boolean highlight?: SourceHighlight[];
revealLine?: number;
} }
export const Source: React.FC<SourceProps> = ({ export const Source: React.FC<SourceProps> = ({
text, text,
language, language,
paused = false, highlight = [],
highlightedLine = -1 revealLine
}) => { }) => {
const lines = React.useMemo<string[]>(() => { const lines = React.useMemo<string[]>(() => {
const result = []; const result = [];
@ -43,20 +49,19 @@ export const Source: React.FC<SourceProps> = ({
return result; return result;
}, [text]); }, [text]);
const revealedLineRef = React.createRef<HTMLDivElement>();
const highlightedLineRef = React.createRef<HTMLDivElement>();
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
if (highlightedLine && highlightedLineRef.current) if (typeof revealLine === 'number' && revealedLineRef.current)
highlightedLineRef.current.scrollIntoView({ block: 'center', inline: 'nearest' }); revealedLineRef.current.scrollIntoView({ block: 'center', inline: 'nearest' });
}, [highlightedLineRef]); }, [revealedLineRef]);
return <div className='source'>{ return <div className='source'>{
lines.map((markup, index) => { lines.map((markup, index) => {
const isHighlighted = index === highlightedLine; const lineNumber = index + 1;
const highlightType = paused && isHighlighted ? 'source-line-paused' : 'source-line-highlighted'; const lineHighlight = highlight.find(h => h.line === lineNumber);
const className = isHighlighted ? `source-line ${highlightType}` : 'source-line'; const lineClass = lineHighlight ? `source-line source-line-${lineHighlight.type}` : 'source-line';
return <div key={index} className={className} ref={isHighlighted ? highlightedLineRef : null}> return <div key={lineNumber} className={lineClass} ref={revealLine === lineNumber ? revealedLineRef : null}>
<div className='source-line-number'>{index + 1}</div> <div className='source-line-number'>{lineNumber}</div>
<div className='source-code' dangerouslySetInnerHTML={{ __html: markup }}></div> <div className='source-code' dangerouslySetInnerHTML={{ __html: markup }}></div>
</div>; </div>;
}) })

View File

@ -0,0 +1,42 @@
/*
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.
*/
.split-view {
display: flex;
flex: auto;
flex-direction: column;
}
.split-view-main {
display: flex;
flex: auto;
}
.split-view-sidebar {
display: flex;
flex: none;
border-top: 1px solid #ddd;
}
.split-view-resizer {
position: absolute;
left: 0;
right: 0;
height: 12px;
cursor: resize;
cursor: ns-resize;
z-index: 100;
}

View File

@ -0,0 +1,45 @@
/*
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 './splitView.css';
import * as React from 'react';
export interface SplitViewProps {
sidebarSize: number,
}
export const SplitView: React.FC<SplitViewProps> = ({
sidebarSize,
children
}) => {
let [size, setSize] = React.useState<number>(sidebarSize);
const [resizing, setResizing] = React.useState<{ offsetY: number } | null>(null);
if (size < 50)
size = 50;
const childrenArray = React.Children.toArray(children);
return <div className='split-view'>
<div className='split-view-main'>{childrenArray[0]}</div>
<div style={{flexBasis: size}} className='split-view-sidebar'>{childrenArray[1]}</div>
<div
style={{bottom: resizing ? 0 : size - 32, top: resizing ? 0 : undefined, height: resizing ? 'initial' : 32 }}
className='split-view-resizer'
onMouseDown={event => setResizing({ offsetY: event.clientY - (event.target as HTMLElement).getBoundingClientRect().y })}
onMouseUp={() => setResizing(null)}
onMouseMove={event => resizing ? setSize((event.target as HTMLElement).clientHeight - event.clientY + resizing.offsetY) : 0}
></div>
</div>;
};

View File

@ -16,12 +16,13 @@
.toolbar { .toolbar {
display: flex; display: flex;
box-shadow: rgba(0, 0, 0, 0.1) 0px -1px 0px 0px inset; box-shadow: var(--box-shadow);
background: rgb(255, 255, 255); background-color: var(--toolbar-bg-color);
height: 40px; height: 40px;
align-items: center; align-items: center;
padding-right: 10px; padding-right: 10px;
flex: none; flex: none;
z-index: 2;
} }
.toolbar-linewrap { .toolbar-linewrap {

View File

@ -17,7 +17,7 @@
.toolbar-button { .toolbar-button {
border: none; border: none;
outline: none; outline: none;
color: #777; color: var(--toolbar-color);
background: transparent; background: transparent;
padding: 0; padding: 0;
margin-left: 10px; margin-left: 10px;

View File

@ -19,7 +19,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Playwright Recorder</title> <title>Playwright Inspector</title>
</head> </head>
<body> <body>
<div id=root></div> <div id=root></div>

View File

@ -29,3 +29,52 @@
flex: none; flex: none;
white-space: nowrap; white-space: nowrap;
} }
.recorder-log {
display: flex;
flex-direction: column;
flex: auto;
line-height: 20px;
white-space: pre;
background: white;
overflow: auto;
}
.recorder-log-message {
flex: none;
padding: 3px 12px;
display: flex;
align-items: center;
}
.recorder-log-message-sub-level {
padding-left: 40px;
}
.recorder-log-header {
color: var(--toolbar-color);
box-shadow: var(--box-shadow);
background-color: var(--toolbar-bg-color);
height: 32px;
display: flex;
align-items: center;
padding: 0 9px;
z-index: 10;
}
.recorder-log-call {
color: var(--toolbar-color);
background-color: var(--toolbar-bg-color);
border-top: 1px solid #ddd;
border-bottom: 1px solid #eee;
height: 24px;
display: flex;
align-items: center;
padding: 0 9px;
margin-bottom: 3px;
z-index: 2;
}
.recorder-log-call .codicon {
padding: 0 4px;
}

View File

@ -19,15 +19,17 @@ import * as React from 'react';
import { Toolbar } from '../components/toolbar'; import { Toolbar } from '../components/toolbar';
import { ToolbarButton } from '../components/toolbarButton'; import { ToolbarButton } from '../components/toolbarButton';
import { Source as SourceView } from '../components/source'; import { Source as SourceView } from '../components/source';
import type { Mode, PauseDetails, Source } from '../../server/supplements/recorder/recorderTypes'; import type { CallLog, Mode, Source } from '../../server/supplements/recorder/recorderTypes';
import { SplitView } from '../components/splitView';
declare global { declare global {
interface Window { interface Window {
playwrightSetMode: (mode: Mode) => void; playwrightSetMode: (mode: Mode) => void;
playwrightSetPaused: (details: PauseDetails | null) => void; playwrightSetPaused: (paused: boolean) => void;
playwrightSetSource: (source: Source) => void; playwrightSetSources: (sources: Source[]) => void;
playwrightUpdateLogs: (callLogs: CallLog[]) => void;
dispatch(data: any): Promise<void>; dispatch(data: any): Promise<void>;
playwrightSourceEchoForTest?: (text: string) => Promise<void>; playwrightSourceEchoForTest: string;
} }
} }
@ -36,42 +38,81 @@ export interface RecorderProps {
export const Recorder: React.FC<RecorderProps> = ({ export const Recorder: React.FC<RecorderProps> = ({
}) => { }) => {
const [source, setSource] = React.useState<Source>({ language: 'javascript', text: '' }); const [source, setSource] = React.useState<Source>({ file: '', language: 'javascript', text: '', highlight: [] });
const [paused, setPaused] = React.useState<PauseDetails | null>(null); const [paused, setPaused] = React.useState(false);
const [log, setLog] = React.useState(new Map<number, CallLog>());
const [mode, setMode] = React.useState<Mode>('none'); const [mode, setMode] = React.useState<Mode>('none');
window.playwrightSetMode = setMode; window.playwrightSetMode = setMode;
window.playwrightSetSource = setSource; window.playwrightSetSources = sources => {
let s = sources.find(s => s.revealLine);
if (!s)
s = sources.find(s => s.file === source.file);
if (!s)
s = sources[0];
setSource(s);
};
window.playwrightSetPaused = setPaused; window.playwrightSetPaused = setPaused;
if (window.playwrightSourceEchoForTest) window.playwrightUpdateLogs = callLogs => {
window.playwrightSourceEchoForTest(source.text).catch(e => {}); const newLog = new Map<number, CallLog>(log);
for (const callLog of callLogs)
newLog.set(callLog.id, callLog);
setLog(newLog);
};
return <div className="recorder"> window.playwrightSourceEchoForTest = source.text;
const messagesEndRef = React.createRef<HTMLDivElement>();
React.useLayoutEffect(() => {
messagesEndRef.current?.scrollIntoView({ block: 'center', inline: 'nearest' });
}, [messagesEndRef]);
return <div className='recorder'>
<Toolbar> <Toolbar>
<ToolbarButton icon="record" title="Record" toggled={mode == 'recording'} onClick={() => { <ToolbarButton icon='record' title='Record' toggled={mode == 'recording'} onClick={() => {
window.dispatch({ event: 'setMode', params: { mode: mode === 'recording' ? 'none' : 'recording' }}).catch(() => { }); window.dispatch({ event: 'setMode', params: { mode: mode === 'recording' ? 'none' : 'recording' }}).catch(() => { });
}}></ToolbarButton> }}></ToolbarButton>
<ToolbarButton icon="question" title="Inspect" toggled={mode == 'inspecting'} onClick={() => { <ToolbarButton icon='question' title='Inspect' toggled={mode == 'inspecting'} onClick={() => {
window.dispatch({ event: 'setMode', params: { mode: mode === 'inspecting' ? 'none' : 'inspecting' }}).catch(() => { }); window.dispatch({ event: 'setMode', params: { mode: mode === 'inspecting' ? 'none' : 'inspecting' }}).catch(() => { });
}}></ToolbarButton> }}></ToolbarButton>
<ToolbarButton icon="files" title="Copy" disabled={!source.text} onClick={() => { <ToolbarButton icon='files' title='Copy' disabled={!source.text} onClick={() => {
copy(source.text); copy(source.text);
}}></ToolbarButton> }}></ToolbarButton>
<ToolbarButton icon="debug-continue" title="Resume" disabled={!paused} onClick={() => { <ToolbarButton icon='debug-continue' title='Resume' disabled={!paused} onClick={() => {
window.dispatch({ event: 'resume' }).catch(() => {}); window.dispatch({ event: 'resume' }).catch(() => {});
}}></ToolbarButton> }}></ToolbarButton>
<ToolbarButton icon="debug-pause" title="Pause" disabled={!!paused} onClick={() => { <ToolbarButton icon='debug-pause' title='Pause' disabled={paused} onClick={() => {
window.dispatch({ event: 'pause' }).catch(() => {}); window.dispatch({ event: 'pause' }).catch(() => {});
}}></ToolbarButton> }}></ToolbarButton>
<ToolbarButton icon="debug-step-over" title="Step over" disabled={!paused} onClick={() => { <ToolbarButton icon='debug-step-over' title='Step over' disabled={!paused} onClick={() => {
window.dispatch({ event: 'step' }).catch(() => {}); window.dispatch({ event: 'step' }).catch(() => {});
}}></ToolbarButton> }}></ToolbarButton>
<div style={{flex: "auto"}}></div> <div style={{flex: 'auto'}}></div>
<ToolbarButton icon="clear-all" title="Clear" disabled={!source.text} onClick={() => { <ToolbarButton icon='clear-all' title='Clear' disabled={!source.text} onClick={() => {
window.dispatch({ event: 'clear' }).catch(() => {}); window.dispatch({ event: 'clear' }).catch(() => {});
}}></ToolbarButton> }}></ToolbarButton>
</Toolbar> </Toolbar>
<SourceView text={source.text} language={source.language} highlightedLine={source.highlightedLine} paused={!!paused}></SourceView> <SplitView sidebarSize={200}>
<SourceView text={source.text} language={source.language} highlight={source.highlight} revealLine={source.revealLine}></SourceView>
<div className='vbox'>
<div className='recorder-log-header' style={{flex: 'none'}}>Log</div>
<div className='recorder-log' style={{flex: 'auto'}}>
{[...log.values()].map(callLog => {
return <div className='vbox' style={{flex: 'none'}} key={callLog.id}>
<div className='recorder-log-call'>
<span className={'codicon ' + iconClass(callLog)}></span>{ callLog.title }
</div>
{ callLog.messages.map((message, i) => {
return <div className='recorder-log-message' key={i}>
{ message }
</div>;
})}
</div>
})}
<div ref={messagesEndRef}></div>
</div>
</div>
</SplitView>
</div>; </div>;
}; };
@ -85,3 +126,12 @@ function copy(text: string) {
document.execCommand('copy'); document.execCommand('copy');
textArea.remove(); textArea.remove();
} }
function iconClass(callLog: CallLog): string {
switch (callLog.status) {
case 'done': return 'codicon-check';
case 'in-progress': return 'codicon-clock';
case 'paused': return 'codicon-debug-pause';
case 'error': return 'codicon-error';
}
}

View File

@ -34,7 +34,7 @@ module.exports = {
}, },
plugins: [ plugins: [
new HtmlWebPackPlugin({ new HtmlWebPackPlugin({
title: 'Playwright Recorder', title: 'Playwright Inspector',
template: path.join(__dirname, 'index.html'), template: path.join(__dirname, 'index.html'),
}) })
] ]

View File

@ -21,12 +21,12 @@ import * as url from 'url';
const { it, describe, expect } = folio; const { it, describe, expect } = folio;
describe('cli codegen', (suite, { mode, browserName, headful }) => { describe('cli codegen', (suite, { mode, browserName, headful }) => {
suite.fixme(browserName === 'firefox' && headful, 'Focus is off'); // suite.fixme(browserName === 'firefox' && headful, 'Focus is off');
suite.skip(mode !== 'default'); suite.skip(mode !== 'default');
}, () => { }, () => {
it('should contain open page', async ({ recorder }) => { it('should contain open page', async ({ recorder }) => {
await recorder.setContentAndWait(``); await recorder.setContentAndWait(``);
expect(recorder.output()).toContain(`const page = await context.newPage();`); await recorder.waitForOutput(`const page = await context.newPage();`);
}); });
it('should contain second page', async ({ context, recorder }) => { it('should contain second page', async ({ context, recorder }) => {
@ -111,7 +111,7 @@ describe('cli codegen', (suite, { mode, browserName, headful }) => {
}); });
it('should download files', (test, {browserName, headful}) => { it('should download files', (test, {browserName, headful}) => {
test.fixme(browserName === 'webkit', 'Generated page.waitForNavigation next to page.waitForEvent(download)'); test.fixme(browserName === 'webkit' || browserName === 'firefox', 'Generated page.waitForNavigation next to page.waitForEvent(download)');
}, async ({ page, recorder, httpServer }) => { }, async ({ page, recorder, httpServer }) => {
httpServer.setHandler((req: http.IncomingMessage, res: http.ServerResponse) => { httpServer.setHandler((req: http.IncomingMessage, res: http.ServerResponse) => {
const pathName = url.parse(req.url!).path; const pathName = url.parse(req.url!).path;

View File

@ -37,10 +37,7 @@ export const fixtures = baseFolio.extend<TestFixtures, WorkerFixtures>();
fixtures.recorder.init(async ({ page, recorderFrame }, runTest) => { fixtures.recorder.init(async ({ page, recorderFrame }, runTest) => {
await (page.context() as any)._enableRecorder({ language: 'javascript', startRecording: true }); await (page.context() as any)._enableRecorder({ language: 'javascript', startRecording: true });
const recorderFrameInstance = await recorderFrame(); const recorderFrameInstance = await recorderFrame();
const recorder = new Recorder(page, recorderFrameInstance); await runTest(new Recorder(page, recorderFrameInstance));
await recorderFrameInstance._page.context().exposeBinding('playwrightSourceEchoForTest', false,
(_: any, text: string) => recorder.setText(text));
await runTest(recorder);
}); });
fixtures.httpServer.init(async ({testWorkerIndex}, runTest) => { fixtures.httpServer.init(async ({testWorkerIndex}, runTest) => {
@ -69,8 +66,7 @@ class Recorder {
_actionReporterInstalled: boolean _actionReporterInstalled: boolean
_actionPerformedCallback: Function _actionPerformedCallback: Function
recorderFrame: any; recorderFrame: any;
private _text: string; private _text: string = '';
private _waiters = [];
constructor(page: Page, recorderFrame: any) { constructor(page: Page, recorderFrame: any) {
this.page = page; this.page = page;
@ -101,16 +97,20 @@ class Recorder {
]); ]);
} }
setText(text: string) {
this._text = text;
for (const waiter of this._waiters) {
if (text.includes(waiter.text))
waiter.fulfill();
}
}
async waitForOutput(text: string): Promise<void> { async waitForOutput(text: string): Promise<void> {
return new Promise(fulfill => this._waiters.push({ text, fulfill })); this._text = await this.recorderFrame._evaluateExpression(((text: string) => {
const w = window as any;
return new Promise(f => {
const poll = () => {
if (w.playwrightSourceEchoForTest && w.playwrightSourceEchoForTest.includes(text)) {
f(w.playwrightSourceEchoForTest);
return;
}
setTimeout(poll, 300);
};
setTimeout(poll);
});
}).toString(), true, text, 'main');
} }
output(): string { output(): string {

View File

@ -10,7 +10,8 @@
"strict": true, "strict": true,
"allowJs": true, "allowJs": true,
"declaration": false, "declaration": false,
"jsx": "react" "jsx": "react",
"downlevelIteration": true,
}, },
"compileOnSave": true, "compileOnSave": true,
"include": ["src/**/*.ts", "src/**/*.js"], "include": ["src/**/*.ts", "src/**/*.js"],