Skip to content

Commit 8957c86

Browse files
authored
feat(debug): add source maps to evaluates in debug mode (#2267)
When PLAYWRIGHT_DEBUG_UI is set, we try to find the source of the function in the current file and source map it.
1 parent 0bc4906 commit 8957c86

File tree

8 files changed

+179
-33
lines changed

8 files changed

+179
-33
lines changed

package-lock.json

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/chromium/crCoverage.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@
1818
import { CRSession } from './crConnection';
1919
import { assert, helper, RegisteredListener } from '../helper';
2020
import { Protocol } from './protocol';
21-
22-
import { EVALUATION_SCRIPT_URL } from './crExecutionContext';
21+
import * as js from '../javascript';
2322
import * as types from '../types';
2423
import { logError, InnerLogger } from '../logger';
2524

@@ -126,7 +125,7 @@ class JSCoverage {
126125

127126
async _onScriptParsed(event: Protocol.Debugger.scriptParsedPayload) {
128127
// Ignore playwright-injected scripts
129-
if (event.url === EVALUATION_SCRIPT_URL)
128+
if (js.isPlaywrightSourceUrl(event.url))
130129
return;
131130
this._scriptIds.add(event.scriptId);
132131
// Ignore other anonymous scripts unless the reportAnonymousScripts option is true.

src/chromium/crExecutionContext.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,6 @@ import { valueFromRemoteObject, getExceptionMessage, releaseObject } from './crP
2121
import { Protocol } from './protocol';
2222
import * as js from '../javascript';
2323

24-
export const EVALUATION_SCRIPT_URL = '__playwright_evaluation_script__';
25-
const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m;
26-
2724
export class CRExecutionContext implements js.ExecutionContextDelegate {
2825
_client: CRSession;
2926
_contextId: number;
@@ -34,14 +31,11 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
3431
}
3532

3633
async evaluate(context: js.ExecutionContext, returnByValue: boolean, pageFunction: Function | string, ...args: any[]): Promise<any> {
37-
const suffix = `//# sourceURL=${EVALUATION_SCRIPT_URL}`;
38-
3934
if (helper.isString(pageFunction)) {
4035
const contextId = this._contextId;
4136
const expression: string = pageFunction;
42-
const expressionWithSourceUrl = SOURCE_URL_REGEX.test(expression) ? expression : expression + '\n' + suffix;
4337
const {exceptionDetails, result: remoteObject} = await this._client.send('Runtime.evaluate', {
44-
expression: expressionWithSourceUrl,
38+
expression: js.ensureSourceUrl(expression),
4539
contextId,
4640
returnByValue,
4741
awaitPromise: true,
@@ -79,7 +73,7 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
7973

8074
try {
8175
const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.callFunctionOn', {
82-
functionDeclaration: functionText + '\n' + suffix + '\n',
76+
functionDeclaration: functionText,
8377
executionContextId: this._contextId,
8478
arguments: [
8579
...values.map(value => ({ value })),

src/chromium/crPage.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import * as frames from '../frames';
2121
import { helper, RegisteredListener, assert } from '../helper';
2222
import * as network from '../network';
2323
import { CRSession, CRConnection, CRSessionEvents } from './crConnection';
24-
import { EVALUATION_SCRIPT_URL, CRExecutionContext } from './crExecutionContext';
24+
import { CRExecutionContext } from './crExecutionContext';
2525
import { CRNetworkManager } from './crNetworkManager';
2626
import { Page, Worker, PageBinding } from '../page';
2727
import { Protocol } from './protocol';
@@ -418,7 +418,7 @@ class FrameSession {
418418
lifecycleEventsEnabled = this._client.send('Page.setLifecycleEventsEnabled', { enabled: true }),
419419
this._client.send('Runtime.enable', {}),
420420
this._client.send('Page.addScriptToEvaluateOnNewDocument', {
421-
source: `//# sourceURL=${EVALUATION_SCRIPT_URL}`,
421+
source: js.generateSourceUrl(),
422422
worldName: UTILITY_WORLD_NAME,
423423
}),
424424
this._networkManager.initialize(),

src/firefox/ffExecutionContext.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export class FFExecutionContext implements js.ExecutionContextDelegate {
3232
async evaluate(context: js.ExecutionContext, returnByValue: boolean, pageFunction: Function | string, ...args: any[]): Promise<any> {
3333
if (helper.isString(pageFunction)) {
3434
const payload = await this._session.send('Runtime.evaluate', {
35-
expression: pageFunction.trim(),
35+
expression: js.ensureSourceUrl(pageFunction),
3636
returnByValue,
3737
executionContextId: this._executionContextId,
3838
}).catch(rewriteError);
@@ -117,7 +117,7 @@ export class FFExecutionContext implements js.ExecutionContextDelegate {
117117
const simpleValue = await this._session.send('Runtime.callFunction', {
118118
executionContextId: this._executionContextId,
119119
returnByValue: true,
120-
functionDeclaration: ((e: any) => e).toString(),
120+
functionDeclaration: ((e: any) => e).toString() + js.generateSourceUrl(),
121121
args: [this._toCallArgument(payload)],
122122
});
123123
return deserializeValue(simpleValue.result!);

src/helper.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import * as util from 'util';
2222
import { TimeoutError } from './errors';
2323
import * as types from './types';
2424

25+
// NOTE: update this to point to playwright/lib when moving this file.
26+
const PLAYWRIGHT_LIB_PATH = __dirname;
27+
2528
export type RegisteredListener = {
2629
emitter: EventEmitter;
2730
eventName: (string | symbol);
@@ -370,6 +373,38 @@ export function logPolitely(toBeLogged: string) {
370373
console.log(toBeLogged); // eslint-disable-line no-console
371374
}
372375

376+
export function getCallerFilePath(ignorePrefix = PLAYWRIGHT_LIB_PATH): string | null {
377+
const error = new Error();
378+
const stackFrames = (error.stack || '').split('\n').slice(1);
379+
// Find first stackframe that doesn't point to ignorePrefix.
380+
for (let frame of stackFrames) {
381+
frame = frame.trim();
382+
if (!frame.startsWith('at '))
383+
return null;
384+
if (frame.endsWith(')')) {
385+
const from = frame.indexOf('(');
386+
frame = frame.substring(from + 1, frame.length - 1);
387+
} else {
388+
frame = frame.substring('at '.length);
389+
}
390+
const match = frame.match(/^(?:async )?(.*):(\d+):(\d+)$/);
391+
if (!match)
392+
return null;
393+
const filePath = match[1];
394+
if (filePath.startsWith(ignorePrefix))
395+
continue;
396+
return filePath;
397+
}
398+
return null;
399+
}
400+
401+
let debugMode: boolean | undefined;
402+
export function isDebugMode(): boolean {
403+
if (debugMode === undefined)
404+
debugMode = !!getFromENV('PLAYWRIGHT_DEBUG_UI');
405+
return debugMode;
406+
}
407+
373408
const escapeGlobChars = new Set(['/', '$', '^', '+', '.', '(', ')', '=', '!', '|']);
374409

375410
export const helper = Helper;

src/javascript.ts

Lines changed: 127 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616

1717
import * as types from './types';
1818
import * as dom from './dom';
19-
import { helper } from './helper';
19+
import * as fs from 'fs';
20+
import * as util from 'util';
21+
import { helper, getCallerFilePath, isDebugMode } from './helper';
2022
import { InnerLogger } from './logger';
2123

2224
export interface ExecutionContextDelegate {
@@ -125,7 +127,8 @@ export async function prepareFunctionCall<T>(
125127
args: any[],
126128
toCallArgumentIfNeeded: (value: any) => { handle?: T, value?: any }): Promise<{ functionText: string, values: any[], handles: T[], dispose: () => void }> {
127129

128-
let functionText = pageFunction.toString();
130+
const originalText = pageFunction.toString();
131+
let functionText = originalText;
129132
try {
130133
new Function('(' + functionText + ')');
131134
} catch (e1) {
@@ -199,10 +202,13 @@ export async function prepareFunctionCall<T>(
199202
if (error)
200203
throw new Error(error);
201204

202-
if (!guids.length)
205+
if (!guids.length) {
206+
const sourceMapUrl = await generateSourceMapUrl(originalText, { line: 0, column: 0 });
207+
functionText += sourceMapUrl;
203208
return { functionText, values: args, handles: [], dispose: () => {} };
209+
}
204210

205-
functionText = `(...__playwright__args__) => {
211+
const wrappedFunctionText = `(...__playwright__args__) => {
206212
return (${functionText})(...(() => {
207213
const args = __playwright__args__;
208214
__playwright__args__ = undefined;
@@ -226,6 +232,8 @@ export async function prepareFunctionCall<T>(
226232
return result;
227233
})());
228234
}`;
235+
const compiledPosition = findPosition(wrappedFunctionText, wrappedFunctionText.indexOf(functionText));
236+
functionText = wrappedFunctionText;
229237

230238
const resolved = await Promise.all(handles);
231239
const resultHandles: T[] = [];
@@ -242,5 +250,120 @@ export async function prepareFunctionCall<T>(
242250
const dispose = () => {
243251
toDispose.map(handlePromise => handlePromise.then(handle => handle.dispose()));
244252
};
253+
254+
const sourceMapUrl = await generateSourceMapUrl(originalText, compiledPosition);
255+
functionText += sourceMapUrl;
245256
return { functionText, values: [ args.length, ...args, guids.length, ...guids ], handles: resultHandles, dispose };
246257
}
258+
259+
let sourceUrlCounter = 0;
260+
const playwrightSourceUrlPrefix = '__playwright_evaluation_script__';
261+
const sourceUrlRegex = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m;
262+
export function generateSourceUrl(): string {
263+
return `\n//# sourceURL=${playwrightSourceUrlPrefix}${sourceUrlCounter++}\n`;
264+
}
265+
266+
export function isPlaywrightSourceUrl(s: string): boolean {
267+
return s.startsWith(playwrightSourceUrlPrefix);
268+
}
269+
270+
export function ensureSourceUrl(expression: string): string {
271+
return sourceUrlRegex.test(expression) ? expression : expression + generateSourceUrl();
272+
}
273+
274+
type Position = {
275+
line: number;
276+
column: number;
277+
};
278+
279+
async function generateSourceMapUrl(functionText: string, compiledPosition: Position): Promise<string> {
280+
if (!isDebugMode())
281+
return generateSourceUrl();
282+
const filePath = getCallerFilePath();
283+
if (!filePath)
284+
return generateSourceUrl();
285+
try {
286+
const source = await util.promisify(fs.readFile)(filePath, 'utf8');
287+
const index = source.indexOf(functionText);
288+
if (index === -1)
289+
return generateSourceUrl();
290+
const sourcePosition = findPosition(source, index);
291+
const delta = findPosition(functionText, functionText.length);
292+
const sourceMap = generateSourceMap(filePath, sourcePosition, compiledPosition, delta);
293+
return `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(sourceMap).toString('base64')}\n`;
294+
} catch (e) {
295+
return generateSourceUrl();
296+
}
297+
}
298+
299+
const VLQ_BASE_SHIFT = 5;
300+
const VLQ_BASE = 1 << VLQ_BASE_SHIFT;
301+
const VLQ_BASE_MASK = VLQ_BASE - 1;
302+
const VLQ_CONTINUATION_BIT = VLQ_BASE;
303+
const BASE64_DIGITS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
304+
305+
function base64VLQ(value: number): string {
306+
if (value < 0)
307+
value = ((-value) << 1) | 1;
308+
else
309+
value <<= 1;
310+
let result = '';
311+
do {
312+
let digit = value & VLQ_BASE_MASK;
313+
value >>>= VLQ_BASE_SHIFT;
314+
if (value > 0)
315+
digit |= VLQ_CONTINUATION_BIT;
316+
result += BASE64_DIGITS[digit];
317+
} while (value > 0);
318+
return result;
319+
}
320+
321+
function generateSourceMap(filePath: string, sourcePosition: Position, compiledPosition: Position, delta: Position): any {
322+
const mappings = [];
323+
let lastCompiled = { line: 0, column: 0 };
324+
let lastSource = { line: 0, column: 0 };
325+
for (let line = 0; line < delta.line; line++) {
326+
// We need at least a mapping per line. This will yield an execution line at the start of each line.
327+
// Note: for more granular mapping, we can do word-by-word.
328+
const source = advancePosition(sourcePosition, { line, column: 0 });
329+
const compiled = advancePosition(compiledPosition, { line, column: 0 });
330+
while (lastCompiled.line < compiled.line) {
331+
mappings.push(';');
332+
lastCompiled.line++;
333+
lastCompiled.column = 0;
334+
}
335+
mappings.push(base64VLQ(compiled.column - lastCompiled.column));
336+
mappings.push(base64VLQ(0)); // Source index.
337+
mappings.push(base64VLQ(source.line - lastSource.line));
338+
mappings.push(base64VLQ(source.column - lastSource.column));
339+
lastCompiled = compiled;
340+
lastSource = source;
341+
}
342+
return JSON.stringify({
343+
version: 3,
344+
sources: ['file://' + filePath],
345+
names: [],
346+
mappings: mappings.join(''),
347+
});
348+
}
349+
350+
function findPosition(source: string, offset: number): Position {
351+
const result: Position = { line: 0, column: 0 };
352+
let index = 0;
353+
while (true) {
354+
const newline = source.indexOf('\n', index);
355+
if (newline === -1 || newline >= offset)
356+
break;
357+
result.line++;
358+
index = newline + 1;
359+
}
360+
result.column = offset - index;
361+
return result;
362+
}
363+
364+
function advancePosition(position: Position, delta: Position) {
365+
return {
366+
line: position.line + delta.line,
367+
column: delta.column + (delta.line ? 0 : position.column),
368+
};
369+
}

src/webkit/wkExecutionContext.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,6 @@ import { valueFromRemoteObject, releaseObject } from './wkProtocolHelper';
2121
import { Protocol } from './protocol';
2222
import * as js from '../javascript';
2323

24-
export const EVALUATION_SCRIPT_URL = '__playwright_evaluation_script__';
25-
const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m;
26-
2724
type MaybeCallArgument = Protocol.Runtime.CallArgument | { unserializable: any };
2825

2926
export class WKExecutionContext implements js.ExecutionContextDelegate {
@@ -75,9 +72,8 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
7572
if (helper.isString(pageFunction)) {
7673
const contextId = this._contextId;
7774
const expression: string = pageFunction;
78-
const expressionWithSourceUrl = SOURCE_URL_REGEX.test(expression) ? expression : expression + '\n' + suffix;
7975
return await this._session.send('Runtime.evaluate', {
80-
expression: expressionWithSourceUrl,
76+
expression: js.ensureSourceUrl(expression),
8177
contextId,
8278
returnByValue: false,
8379
emulateUserGesture: true
@@ -105,7 +101,7 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
105101
const callParams = this._serializeFunctionAndArguments(functionText, values, handles);
106102
const thisObjectId = await this._contextGlobalObjectId();
107103
return await this._session.send('Runtime.callFunctionOn', {
108-
functionDeclaration: callParams.functionText + '\n' + suffix + '\n',
104+
functionDeclaration: callParams.functionText,
109105
objectId: thisObjectId,
110106
arguments: callParams.callArguments,
111107
returnByValue: false,
@@ -170,7 +166,7 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
170166
try {
171167
const serializeResponse = await this._session.send('Runtime.callFunctionOn', {
172168
// Serialize object using standard JSON implementation to correctly pass 'undefined'.
173-
functionDeclaration: 'function(){return this}\n' + suffix + '\n',
169+
functionDeclaration: 'function(){return this}\n' + js.generateSourceUrl(),
174170
objectId: objectId,
175171
returnByValue: true
176172
});
@@ -231,7 +227,6 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
231227
}
232228
}
233229

234-
const suffix = `//# sourceURL=${EVALUATION_SCRIPT_URL}`;
235230
const contextDestroyedResult = {
236231
wasThrown: true,
237232
result: {

0 commit comments

Comments
 (0)