Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add replay log #5452

Merged
merged 1 commit into from
Feb 13, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions src/server/supplements/injected/recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
}
}
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 8 additions & 4 deletions src/server/supplements/inspectorController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
}
31 changes: 19 additions & 12 deletions src/server/supplements/recorder/recorderApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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>;
}
}
Expand Down Expand Up @@ -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();
}
Expand Down
32 changes: 23 additions & 9 deletions src/server/supplements/recorder/recorderTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
158 changes: 105 additions & 53 deletions src/server/supplements/recorderSupplement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand All @@ -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>;
Expand All @@ -73,22 +72,22 @@ 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;
case 'python':
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)
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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';
}
Loading