Skip to content

Commit 3c87737

Browse files
authored
feat: add replay log (#5452)
1 parent 6326d6f commit 3c87737

19 files changed

+413
-143
lines changed

src/server/supplements/injected/recorder.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ declare global {
2727
_playwrightRecorderRecordAction: (action: actions.Action) => Promise<void>;
2828
_playwrightRecorderCommitAction: () => Promise<void>;
2929
_playwrightRecorderState: () => Promise<UIState>;
30-
_playwrightRecorderPrintSelector: (text: string) => Promise<void>;
3130
_playwrightResume: () => Promise<void>;
3231
}
3332
}
@@ -226,10 +225,8 @@ export class Recorder {
226225

227226
private _onClick(event: MouseEvent) {
228227
if (this._mode === 'inspecting') {
229-
if (this._hoveredModel) {
228+
if (this._hoveredModel)
230229
copy(this._hoveredModel.selector);
231-
window._playwrightRecorderPrintSelector(this._hoveredModel.selector);
232-
}
233230
}
234231
if (this._shouldIgnoreMouseEvent(event))
235232
return;

src/server/supplements/inspectorController.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,20 +49,24 @@ export class InspectorController implements InstrumentationListener {
4949
}
5050

5151
async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
52-
if (!sdkObject.attribution.page)
52+
if (!sdkObject.attribution.context)
5353
return;
5454
const recorder = await this._recorders.get(sdkObject.attribution.context!);
55-
await recorder?.onAfterCall(sdkObject, metadata);
55+
await recorder?.onAfterCall(metadata);
5656
}
5757

5858
async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
5959
if (!sdkObject.attribution.page)
6060
return;
6161
const recorder = await this._recorders.get(sdkObject.attribution.context!);
62-
await recorder?.onBeforeInputAction(sdkObject, metadata);
62+
await recorder?.onBeforeInputAction(metadata);
6363
}
6464

65-
onCallLog(logName: string, message: string): void {
65+
async onCallLog(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
6666
debugLogger.log(logName as any, message);
67+
if (!sdkObject.attribution.page)
68+
return;
69+
const recorder = await this._recorders.get(sdkObject.attribution.context!);
70+
await recorder?.updateCallLog([metadata]);
6771
}
6872
}

src/server/supplements/recorder/recorderApp.ts

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { ProgressController } from '../../progress';
2323
import { createPlaywright } from '../../playwright';
2424
import { EventEmitter } from 'events';
2525
import { internalCallMetadata } from '../../instrumentation';
26-
import type { EventData, Mode, PauseDetails, Source } from './recorderTypes';
26+
import type { CallLog, EventData, Mode, Source } from './recorderTypes';
2727
import { BrowserContext } from '../../browserContext';
2828
import { isUnderTest } from '../../../utils/utils';
2929

@@ -32,8 +32,9 @@ const readFileAsync = util.promisify(fs.readFile);
3232
declare global {
3333
interface Window {
3434
playwrightSetMode: (mode: Mode) => void;
35-
playwrightSetPaused: (details: PauseDetails | null) => void;
36-
playwrightSetSource: (source: Source) => void;
35+
playwrightSetPaused: (paused: boolean) => void;
36+
playwrightSetSources: (sources: Source[]) => void;
37+
playwrightUpdateLogs: (callLogs: CallLog[]) => void;
3738
dispatch(data: EventData): Promise<void>;
3839
}
3940
}
@@ -117,27 +118,33 @@ export class RecorderApp extends EventEmitter {
117118
}).toString(), true, mode, 'main').catch(() => {});
118119
}
119120

120-
async setPaused(details: PauseDetails | null): Promise<void> {
121-
await this._page.mainFrame()._evaluateExpression(((details: PauseDetails | null) => {
122-
window.playwrightSetPaused(details);
123-
}).toString(), true, details, 'main').catch(() => {});
121+
async setPaused(paused: boolean): Promise<void> {
122+
await this._page.mainFrame()._evaluateExpression(((paused: boolean) => {
123+
window.playwrightSetPaused(paused);
124+
}).toString(), true, paused, 'main').catch(() => {});
124125
}
125126

126-
async setSource(text: string, language: string, highlightedLine?: number): Promise<void> {
127-
await this._page.mainFrame()._evaluateExpression(((source: Source) => {
128-
window.playwrightSetSource(source);
129-
}).toString(), true, { text, language, highlightedLine }, 'main').catch(() => {});
127+
async setSources(sources: Source[]): Promise<void> {
128+
await this._page.mainFrame()._evaluateExpression(((sources: Source[]) => {
129+
window.playwrightSetSources(sources);
130+
}).toString(), true, sources, 'main').catch(() => {});
130131

131132
// Testing harness for runCLI mode.
132133
{
133134
if (process.env.PWCLI_EXIT_FOR_TEST) {
134135
process.stdout.write('\n-------------8<-------------\n');
135-
process.stdout.write(text);
136+
process.stdout.write(sources[0].text);
136137
process.stdout.write('\n-------------8<-------------\n');
137138
}
138139
}
139140
}
140141

142+
async updateCallLogs(callLogs: CallLog[]): Promise<void> {
143+
await this._page.mainFrame()._evaluateExpression(((callLogs: CallLog[]) => {
144+
window.playwrightUpdateLogs(callLogs);
145+
}).toString(), true, callLogs, 'main').catch(() => {});
146+
}
147+
141148
async bringToFront() {
142149
await this._page.bringToFront();
143150
}

src/server/supplements/recorder/recorderTypes.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,32 @@ import { Point } from '../../../common/types';
1919
export type Mode = 'inspecting' | 'recording' | 'none';
2020

2121
export type EventData = {
22-
event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode',
23-
params: any
22+
event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode';
23+
params: any;
2424
};
2525

26-
export type PauseDetails = {
27-
message: string;
26+
export type UIState = {
27+
mode: Mode;
28+
actionPoint?: Point;
29+
actionSelector?: string;
2830
};
2931

30-
export type Source = { text: string, language: string, highlightedLine?: number };
32+
export type CallLog = {
33+
id: number;
34+
title: string;
35+
messages: string[];
36+
status: 'in-progress' | 'done' | 'error' | 'paused';
37+
};
3138

32-
export type UIState = {
33-
mode: Mode,
34-
actionPoint?: Point,
35-
actionSelector?: string
39+
export type SourceHighlight = {
40+
line: number;
41+
type: 'running' | 'paused';
42+
};
43+
44+
export type Source = {
45+
file: string;
46+
text: string;
47+
language: string;
48+
highlight: SourceHighlight[];
49+
revealLine?: number;
3650
};

src/server/supplements/recorderSupplement.ts

Lines changed: 105 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { BufferedOutput, FileOutput, OutputMultiplexer, RecorderOutput } from '.
3232
import { RecorderApp } from './recorder/recorderApp';
3333
import { CallMetadata, internalCallMetadata, SdkObject } from '../instrumentation';
3434
import { Point } from '../../common/types';
35-
import { EventData, Mode, PauseDetails, UIState } from './recorder/recorderTypes';
35+
import { CallLog, EventData, Mode, Source, UIState } from './recorder/recorderTypes';
3636

3737
type BindingSource = { frame: Frame, page: Page };
3838

@@ -45,18 +45,17 @@ export class RecorderSupplement {
4545
private _lastDialogOrdinal = 0;
4646
private _timers = new Set<NodeJS.Timeout>();
4747
private _context: BrowserContext;
48-
private _resumeCallback: (() => void) | null = null;
4948
private _mode: Mode;
50-
private _pauseDetails: PauseDetails | null = null;
5149
private _output: OutputMultiplexer;
5250
private _bufferedOutput: BufferedOutput;
5351
private _recorderApp: RecorderApp | null = null;
54-
private _highlighterType: string;
5552
private _params: channels.BrowserContextRecorderSupplementEnableParams;
56-
private _callMetadata: CallMetadata | null = null;
53+
private _currentCallsMetadata = new Set<CallMetadata>();
54+
private _pausedCallsMetadata = new Map<CallMetadata, () => void>();
5755
private _pauseOnNextStatement = true;
58-
private _sourceCache = new Map<string, string>();
5956
private _sdkObject: SdkObject | null = null;
57+
private _recorderSource: Source;
58+
private _userSources = new Map<string, Source>();
6059

6160
static getOrCreate(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<RecorderSupplement> {
6261
let recorderPromise = (context as any)[symbol] as Promise<RecorderSupplement>;
@@ -73,22 +72,22 @@ export class RecorderSupplement {
7372
this._params = params;
7473
this._mode = params.startRecording ? 'recording' : 'none';
7574
let languageGenerator: LanguageGenerator;
76-
const language = params.language || context._options.sdkLanguage;
75+
let language = params.language || context._options.sdkLanguage;
7776
switch (language) {
7877
case 'javascript': languageGenerator = new JavaScriptLanguageGenerator(); break;
7978
case 'csharp': languageGenerator = new CSharpLanguageGenerator(); break;
8079
case 'python':
8180
case 'python-async': languageGenerator = new PythonLanguageGenerator(params.language === 'python-async'); break;
8281
default: throw new Error(`Invalid target: '${params.language}'`);
8382
}
84-
let highlighterType = language;
85-
if (highlighterType === 'python-async')
86-
highlighterType = 'python';
83+
if (language === 'python-async')
84+
language = 'python';
8785

88-
this._highlighterType = highlighterType;
86+
this._recorderSource = { file: '<recorder>', text: '', language, highlight: [] };
8987
this._bufferedOutput = new BufferedOutput(async text => {
90-
if (this._recorderApp)
91-
this._recorderApp.setSource(text, highlighterType);
88+
this._recorderSource.text = text;
89+
this._recorderSource.revealLine = text.split('\n').length - 1;
90+
this._pushAllSources();
9291
});
9392
const outputs: RecorderOutput[] = [ this._bufferedOutput ];
9493
if (params.outputFile)
@@ -136,8 +135,8 @@ export class RecorderSupplement {
136135

137136
await Promise.all([
138137
recorderApp.setMode(this._mode),
139-
recorderApp.setPaused(this._pauseDetails),
140-
recorderApp.setSource(this._bufferedOutput.buffer(), this._highlighterType)
138+
recorderApp.setPaused(!!this._pausedCallsMetadata.size),
139+
this._pushAllSources()
141140
]);
142141

143142
this._context.on(BrowserContext.Events.Page, page => this._onPage(page));
@@ -168,8 +167,11 @@ export class RecorderSupplement {
168167
let actionPoint: Point | undefined = undefined;
169168
let actionSelector: string | undefined = undefined;
170169
if (source.page === this._sdkObject?.attribution?.page) {
171-
actionPoint = this._callMetadata?.point;
172-
actionSelector = this._callMetadata?.params.selector;
170+
if (this._currentCallsMetadata.size) {
171+
const metadata = this._currentCallsMetadata.values().next().value;
172+
actionPoint = metadata.values().next().value;
173+
actionSelector = metadata.params.selector;
174+
}
173175
}
174176
const uiState: UIState = { mode: this._mode, actionPoint, actionSelector };
175177
return uiState;
@@ -185,19 +187,26 @@ export class RecorderSupplement {
185187
(this._context as any).recorderAppForTest = recorderApp;
186188
}
187189

188-
async pause() {
189-
this._pauseDetails = { message: 'paused' };
190-
this._recorderApp!.setPaused(this._pauseDetails);
191-
return new Promise<void>(f => this._resumeCallback = f);
190+
async pause(metadata: CallMetadata) {
191+
const result = new Promise<void>(f => {
192+
this._pausedCallsMetadata.set(metadata, f);
193+
});
194+
this._recorderApp!.setPaused(true);
195+
this._updateUserSources();
196+
this.updateCallLog([metadata]);
197+
return result;
192198
}
193199

194200
private async _resume(step: boolean) {
195201
this._pauseOnNextStatement = step;
196-
if (this._resumeCallback)
197-
this._resumeCallback();
198-
this._resumeCallback = null;
199-
this._pauseDetails = null;
200-
this._recorderApp?.setPaused(null);
202+
203+
for (const callback of this._pausedCallsMetadata.values())
204+
callback();
205+
this._pausedCallsMetadata.clear();
206+
207+
this._recorderApp?.setPaused(false);
208+
this._updateUserSources();
209+
this.updateCallLog([...this._currentCallsMetadata]);
201210
}
202211

203212
private async _onPage(page: Page) {
@@ -318,47 +327,90 @@ export class RecorderSupplement {
318327

319328
async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
320329
this._sdkObject = sdkObject;
321-
this._callMetadata = metadata;
322-
const { source, line } = this._source(metadata);
323-
this._recorderApp?.setSource(source, 'javascript', line);
330+
this._currentCallsMetadata.add(metadata);
331+
this._updateUserSources();
332+
this.updateCallLog([metadata]);
324333
if (metadata.method === 'pause' || (this._pauseOnNextStatement && metadata.method === 'goto'))
325-
await this.pause();
334+
await this.pause(metadata);
326335
}
327336

328-
async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
337+
async onAfterCall(metadata: CallMetadata): Promise<void> {
329338
this._sdkObject = null;
330-
this._callMetadata = null;
339+
this._currentCallsMetadata.delete(metadata);
340+
this._pausedCallsMetadata.delete(metadata);
341+
this._updateUserSources();
342+
this.updateCallLog([metadata]);
331343
}
332344

333-
async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
345+
private _updateUserSources() {
346+
// Remove old decorations.
347+
for (const source of this._userSources.values()) {
348+
source.highlight = [];
349+
source.revealLine = undefined;
350+
}
351+
352+
// Apply new decorations.
353+
for (const metadata of this._currentCallsMetadata) {
354+
if (!metadata.stack || !metadata.stack[0])
355+
continue;
356+
const { file, line } = metadata.stack[0];
357+
let source = this._userSources.get(file);
358+
if (!source) {
359+
source = { file, text: this._readSource(file), highlight: [], language: languageForFile(file) };
360+
this._userSources.set(file, source);
361+
}
362+
if (line) {
363+
const paused = this._pausedCallsMetadata.has(metadata);
364+
source.highlight.push({ line, type: paused ? 'paused' : 'running' });
365+
if (paused)
366+
source.revealLine = line;
367+
}
368+
}
369+
this._pushAllSources();
370+
}
371+
372+
private _pushAllSources() {
373+
this._recorderApp?.setSources([this._recorderSource, ...this._userSources.values()]);
374+
}
375+
376+
async onBeforeInputAction(metadata: CallMetadata): Promise<void> {
334377
if (this._pauseOnNextStatement)
335-
await this.pause();
378+
await this.pause(metadata);
336379
}
337380

338-
private _source(metadata: CallMetadata): { source: string, line: number | undefined } {
339-
let source = '// No source available';
340-
let line: number | undefined = undefined;
341-
if (metadata.stack && metadata.stack.length) {
342-
try {
343-
source = this._readAndCacheSource(metadata.stack[0].file);
344-
line = metadata.stack[0].line ? metadata.stack[0].line - 1 : undefined;
345-
} catch (e) {
346-
source = metadata.stack.join('\n');
347-
}
381+
async updateCallLog(metadatas: CallMetadata[]): Promise<void> {
382+
const logs: CallLog[] = [];
383+
for (const metadata of metadatas) {
384+
if (!metadata.method)
385+
continue;
386+
const title = metadata.stack?.[0]?.function || metadata.method;
387+
let status: 'done' | 'in-progress' | 'paused' | 'error' = 'done';
388+
if (this._currentCallsMetadata.has(metadata))
389+
status = 'in-progress';
390+
if (this._pausedCallsMetadata.has(metadata))
391+
status = 'paused';
392+
if (metadata.error)
393+
status = 'error';
394+
logs.push({ id: metadata.id, messages: metadata.log, title, status });
348395
}
349-
return { source, line };
396+
this._recorderApp?.updateCallLogs(logs);
350397
}
351398

352-
private _readAndCacheSource(fileName: string): string {
353-
let source = this._sourceCache.get(fileName);
354-
if (source)
355-
return source;
399+
private _readSource(fileName: string): string {
356400
try {
357-
source = fs.readFileSync(fileName, 'utf-8');
401+
return fs.readFileSync(fileName, 'utf-8');
358402
} catch (e) {
359-
source = '// No source available';
403+
return '// No source available';
360404
}
361-
this._sourceCache.set(fileName, source);
362-
return source;
363405
}
364406
}
407+
408+
function languageForFile(file: string) {
409+
if (file.endsWith('.py'))
410+
return 'python';
411+
if (file.endsWith('.java'))
412+
return 'java';
413+
if (file.endsWith('.cs'))
414+
return 'csharp';
415+
return 'javascript';
416+
}

0 commit comments

Comments
 (0)