Browse Source

feat: add replay log (#5452)

pull/5454/head
Pavel Feldman 2 months ago
committed by GitHub
parent
commit
3c877374c7
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 413 additions and 143 deletions
  1. +1
    -4
      src/server/supplements/injected/recorder.ts
  2. +8
    -4
      src/server/supplements/inspectorController.ts
  3. +19
    -12
      src/server/supplements/recorder/recorderApp.ts
  4. +23
    -9
      src/server/supplements/recorder/recorderTypes.ts
  5. +105
    -53
      src/server/supplements/recorderSupplement.ts
  6. +4
    -1
      src/web/common.css
  7. +1
    -1
      src/web/components/source.css
  8. +21
    -16
      src/web/components/source.tsx
  9. +42
    -0
      src/web/components/splitView.css
  10. +45
    -0
      src/web/components/splitView.tsx
  11. +3
    -2
      src/web/components/toolbar.css
  12. +1
    -1
      src/web/components/toolbarButton.css
  13. +1
    -1
      src/web/recorder/index.html
  14. +49
    -0
      src/web/recorder/recorder.css
  15. +69
    -19
      src/web/recorder/recorder.tsx
  16. +1
    -1
      src/web/recorder/webpack.config.js
  17. +3
    -3
      test/cli/cli-codegen-2.spec.ts
  18. +15
    -15
      test/cli/cli.fixtures.ts
  19. +2
    -1
      tsconfig.json

+ 1
- 4
src/server/supplements/injected/recorder.ts View File

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


+ 8
- 4
src/server/supplements/inspectorController.ts View File

@ -49,20 +49,24 @@ export class InspectorController implements InstrumentationListener {
}
async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
if (!sdkObject.attribution.page)
if (!sdkObject.attribution.context)
return;
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> {
if (!sdkObject.attribution.page)
return;
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);
if (!sdkObject.attribution.page)
return;
const recorder = await this._recorders.get(sdkObject.attribution.context!);
await recorder?.updateCallLog([metadata]);
}
}

+ 19
- 12
src/server/supplements/recorder/recorderApp.ts View File

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


+ 23
- 9
src/server/supplements/recorder/recorderTypes.ts View File

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

+ 105
- 53
src/server/supplements/recorderSupplement.ts View File

@ -32,7 +32,7 @@ import { BufferedOutput, FileOutput, OutputMultiplexer, RecorderOutput } from '.
import { RecorderApp } from './recorder/recorderApp';
import { CallMetadata, internalCallMetadata, SdkObject } from '../instrumentation';
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 };
@ -45,18 +45,17 @@ export class RecorderSupplement {
private _lastDialogOrdinal = 0;
private _timers = new Set<NodeJS.Timeout>();
private _context: BrowserContext;
private _resumeCallback: (() => void) | null = null;
private _mode: Mode;
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 _currentCallsMetadata = new Set<CallMetadata>();
private _pausedCallsMetadata = new Map<CallMetadata, () => void>();
private _pauseOnNextStatement = true;
private _sourceCache = new Map<string, string>();
private _sdkObject: SdkObject | null = null;
private _recorderSource: Source;
private _userSources = new Map<string, Source>();
static getOrCreate(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<RecorderSupplement> {
let recorderPromise = (context as any)[symbol] as Promise<RecorderSupplement>;
@ -73,7 +72,7 @@ export class RecorderSupplement {
this._params = params;
this._mode = params.startRecording ? 'recording' : 'none';
let languageGenerator: LanguageGenerator;
const language = params.language || context._options.sdkLanguage;
let language = params.language || context._options.sdkLanguage;
switch (language) {
case 'javascript': languageGenerator = new JavaScriptLanguageGenerator(); 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;
default: throw new Error(`Invalid target: '${params.language}'`);
}
let highlighterType = language;
if (highlighterType === 'python-async')
highlighterType = 'python';
if (language === 'python-async')
language = 'python';
this._highlighterType = highlighterType;
this._recorderSource = { file: '<recorder>', text: '', language, highlight: [] };
this._bufferedOutput = new BufferedOutput(async text => {
if (this._recorderApp)
this._recorderApp.setSource(text, highlighterType);
this._recorderSource.text = text;
this._recorderSource.revealLine = text.split('\n').length - 1;
this._pushAllSources();
});
const outputs: RecorderOutput[] = [ this._bufferedOutput ];
if (params.outputFile)
@ -136,8 +135,8 @@ export class RecorderSupplement {
await Promise.all([
recorderApp.setMode(this._mode),
recorderApp.setPaused(this._pauseDetails),
recorderApp.setSource(this._bufferedOutput.buffer(), this._highlighterType)
recorderApp.setPaused(!!this._pausedCallsMetadata.size),
this._pushAllSources()
]);
this._context.on(BrowserContext.Events.Page, page => this._onPage(page));
@ -168,8 +167,11 @@ export class RecorderSupplement {
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;
if (this._currentCallsMetadata.size) {
const metadata = this._currentCallsMetadata.values().next().value;
actionPoint = metadata.values().next().value;
actionSelector = metadata.params.selector;
}
}
const uiState: UIState = { mode: this._mode, actionPoint, actionSelector };
return uiState;
@ -185,19 +187,26 @@ export class RecorderSupplement {
(this._context as any).recorderAppForTest = recorderApp;
}
async pause() {
this._pauseDetails = { message: 'paused' };
this._recorderApp!.setPaused(this._pauseDetails);
return new Promise<void>(f => this._resumeCallback = f);
async pause(metadata: CallMetadata) {
const result = new Promise<void>(f => {
this._pausedCallsMetadata.set(metadata, f);
});
this._recorderApp!.setPaused(true);
this._updateUserSources();
this.updateCallLog([metadata]);
return result;
}
private async _resume(step: boolean) {
this._pauseOnNextStatement = step;
if (this._resumeCallback)
this._resumeCallback();
this._resumeCallback = null;
this._pauseDetails = null;
this._recorderApp?.setPaused(null);
for (const callback of this._pausedCallsMetadata.values())
callback();
this._pausedCallsMetadata.clear();
this._recorderApp?.setPaused(false);
this._updateUserSources();
this.updateCallLog([...this._currentCallsMetadata]);
}
private async _onPage(page: Page) {
@ -318,47 +327,90 @@ export class RecorderSupplement {
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);
this._currentCallsMetadata.add(metadata);
this._updateUserSources();
this.updateCallLog([metadata]);
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._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() {
// Remove old decorations.
for (const source of this._userSources.values()) {
source.highlight = [];
source.revealLine = undefined;
}
// Apply new decorations.
for (const metadata of this._currentCallsMetadata) {
if (!metadata.stack || !metadata.stack[0])
continue;
const { file, line } = metadata.stack[0];
let source = this._userSources.get(file);
if (!source) {
source = { file, text: this._readSource(file), highlight: [], language: languageForFile(file) };
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;
}
}
this._pushAllSources();
}
private _pushAllSources() {
this._recorderApp?.setSources([this._recorderSource, ...this._userSources.values()]);
}
async onBeforeInputAction(metadata: CallMetadata): Promise<void> {
if (this._pauseOnNextStatement)
await this.pause();
await this.pause(metadata);
}
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');
}
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 });
}
return { source, line };
this._recorderApp?.updateCallLogs(logs);
}
private _readAndCacheSource(fileName: string): string {
let source = this._sourceCache.get(fileName);
if (source)
return source;
private _readSource(fileName: string): string {
try {
source = fs.readFileSync(fileName, 'utf-8');
return fs.readFileSync(fileName, 'utf-8');
} catch (e) {
source = '// No source available';
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';
}

+ 4
- 1
src/web/common.css View File

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


+ 1
- 1
src/web/components/source.css View File

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


+ 21
- 16
src/web/components/source.tsx View File

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


+ 42
- 0
src/web/components/splitView.css 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;
}

+ 45
- 0
src/web/components/splitView.tsx 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>;
};

+ 3
- 2
src/web/components/toolbar.css View File

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


+ 1
- 1
src/web/components/toolbarButton.css View File

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


+ 1
- 1
src/web/recorder/index.html View File

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


+ 49
- 0
src/web/recorder/recorder.css View File

@ -29,3 +29,52 @@
flex: none;
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;
}

+ 69
- 19
src/web/recorder/recorder.tsx View File

@ -19,15 +19,17 @@ import * as React from 'react';
import { Toolbar } from '../components/toolbar';
import { ToolbarButton } from '../components/toolbarButton';
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 {
interface Window {
playwrightSetMode: (mode: Mode) => void;
playwrightSetPaused: (details: PauseDetails | null) => void;
playwrightSetSource: (source: Source) => void;
playwrightSetPaused: (paused: boolean) => void;
playwrightSetSources: (sources: Source[]) => void;
playwrightUpdateLogs: (callLogs: CallLog[]) => 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> = ({
}) => {
const [source, setSource] = React.useState<Source>({ language: 'javascript', text: '' });
const [paused, setPaused] = React.useState<PauseDetails | null>(null);
const [source, setSource] = React.useState<Source>({ file: '', language: 'javascript', text: '', highlight: [] });
const [paused, setPaused] = React.useState(false);
const [log, setLog] = React.useState(new Map<number, CallLog>());
const [mode, setMode] = React.useState<Mode>('none');
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;
if (window.playwrightSourceEchoForTest)
window.playwrightSourceEchoForTest(source.text).catch(e => {});
window.playwrightUpdateLogs = callLogs => {
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>
<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(() => { });
}}></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(() => { });
}}></ToolbarButton>
<ToolbarButton icon="files" title="Copy" disabled={!source.text} onClick={() => {
<ToolbarButton icon='files' title='Copy' disabled={!source.text} onClick={() => {
copy(source.text);
}}></ToolbarButton>
<ToolbarButton icon="debug-continue" title="Resume" disabled={!paused} onClick={() => {
<ToolbarButton icon='debug-continue' title='Resume' disabled={!paused} onClick={() => {
window.dispatch({ event: 'resume' }).catch(() => {});
}}></ToolbarButton>
<ToolbarButton icon="debug-pause" title="Pause" disabled={!!paused} onClick={() => {
<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={() => {
<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={() => {
<div style={{flex: 'auto'}}></div>
<ToolbarButton icon='clear-all' title='Clear' disabled={!source.text} onClick={() => {
window.dispatch({ event: 'clear' }).catch(() => {});
}}></ToolbarButton>
</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>;
};
@ -85,3 +126,12 @@ function copy(text: string) {
document.execCommand('copy');
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';
}
}

+ 1
- 1
src/web/recorder/webpack.config.js View File

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


+ 3
- 3
test/cli/cli-codegen-2.spec.ts View File

@ -21,12 +21,12 @@ import * as url from 'url';
const { it, describe, expect } = folio;
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');
}, () => {
it('should contain open page', async ({ recorder }) => {
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 }) => {
@ -111,7 +111,7 @@ describe('cli codegen', (suite, { mode, 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 }) => {
httpServer.setHandler((req: http.IncomingMessage, res: http.ServerResponse) => {
const pathName = url.parse(req.url!).path;


+ 15
- 15
test/cli/cli.fixtures.ts View File

@ -37,10 +37,7 @@ export const fixtures = baseFolio.extend<TestFixtures, WorkerFixtures>();
fixtures.recorder.init(async ({ page, recorderFrame }, runTest) => {
await (page.context() as any)._enableRecorder({ language: 'javascript', startRecording: true });
const recorderFrameInstance = await recorderFrame();
const recorder = new Recorder(page, recorderFrameInstance);
await recorderFrameInstance._page.context().exposeBinding('playwrightSourceEchoForTest', false,
(_: any, text: string) => recorder.setText(text));
await runTest(recorder);
await runTest(new Recorder(page, recorderFrameInstance));
});
fixtures.httpServer.init(async ({testWorkerIndex}, runTest) => {
@ -69,8 +66,7 @@ class Recorder {
_actionReporterInstalled: boolean
_actionPerformedCallback: Function
recorderFrame: any;
private _text: string;
private _waiters = [];
private _text: string = '';
constructor(page: Page, recorderFrame: any) {
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> {
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 {


+ 2
- 1
tsconfig.json View File

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


Loading…
Cancel
Save