Skip to content

Commit

Permalink
feat: add replay log
Browse files Browse the repository at this point in the history
  • Loading branch information
pavelfeldman committed Feb 13, 2021
1 parent 529e398 commit 4f6c221
Show file tree
Hide file tree
Showing 19 changed files with 411 additions and 141 deletions.
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

0 comments on commit 4f6c221

Please sign in to comment.