mirror of
https://github.com/microsoft/playwright.git
synced 2025-01-05 19:04:43 +03:00
feat: add replay log (#5452)
This commit is contained in:
parent
6326d6f3ac
commit
3c877374c7
@ -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;
|
||||
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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 Source = { text: string, language: string, highlightedLine?: number };
|
||||
|
||||
export type UIState = {
|
||||
mode: Mode,
|
||||
actionPoint?: Point,
|
||||
actionSelector?: string
|
||||
mode: Mode;
|
||||
actionPoint?: Point;
|
||||
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;
|
||||
};
|
||||
|
@ -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> {
|
||||
if (this._pauseOnNextStatement)
|
||||
await this.pause();
|
||||
}
|
||||
private _updateUserSources() {
|
||||
// Remove old decorations.
|
||||
for (const source of this._userSources.values()) {
|
||||
source.highlight = [];
|
||||
source.revealLine = undefined;
|
||||
}
|
||||
|
||||
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');
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
return { source, line };
|
||||
this._pushAllSources();
|
||||
}
|
||||
|
||||
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';
|
||||
private _pushAllSources() {
|
||||
this._recorderApp?.setSources([this._recorderSource, ...this._userSources.values()]);
|
||||
}
|
||||
|
||||
async onBeforeInputAction(metadata: CallMetadata): Promise<void> {
|
||||
if (this._pauseOnNextStatement)
|
||||
await this.pause(metadata);
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
@ -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 {
|
||||
|
@ -44,7 +44,7 @@
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.source-line-highlighted {
|
||||
.source-line-running {
|
||||
background-color: #6fa8dc7f;
|
||||
z-index: 2;
|
||||
}
|
||||
|
@ -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
src/web/components/splitView.css
Normal file
42
src/web/components/splitView.css
Normal 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
src/web/components/splitView.tsx
Normal file
45
src/web/components/splitView.tsx
Normal 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>;
|
||||
};
|
@ -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 {
|
||||
|
@ -17,7 +17,7 @@
|
||||
.toolbar-button {
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #777;
|
||||
color: var(--toolbar-color);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin-left: 10px;
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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';
|
||||
}
|
||||
}
|
@ -34,7 +34,7 @@ module.exports = {
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebPackPlugin({
|
||||
title: 'Playwright Recorder',
|
||||
title: 'Playwright Inspector',
|
||||
template: path.join(__dirname, 'index.html'),
|
||||
})
|
||||
]
|
||||
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -10,7 +10,8 @@
|
||||
"strict": true,
|
||||
"allowJs": true,
|
||||
"declaration": false,
|
||||
"jsx": "react"
|
||||
"jsx": "react",
|
||||
"downlevelIteration": true,
|
||||
},
|
||||
"compileOnSave": true,
|
||||
"include": ["src/**/*.ts", "src/**/*.js"],
|
||||
|
Loading…
Reference in New Issue
Block a user