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.
This commit is contained in:
Dmitry Gozman 2020-05-19 08:40:45 -07:00 committed by GitHub
parent 0bc4906196
commit 8957c86837
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 179 additions and 33 deletions

12
package-lock.json generated
View File

@ -4720,6 +4720,12 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
"dev": true
},
"source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
"integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=",
"dev": true
}
}
},
@ -4800,12 +4806,6 @@
"integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==",
"dev": true
},
"source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
"integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=",
"dev": true
},
"source-map-resolve": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz",

View File

@ -18,8 +18,7 @@
import { CRSession } from './crConnection';
import { assert, helper, RegisteredListener } from '../helper';
import { Protocol } from './protocol';
import { EVALUATION_SCRIPT_URL } from './crExecutionContext';
import * as js from '../javascript';
import * as types from '../types';
import { logError, InnerLogger } from '../logger';
@ -126,7 +125,7 @@ class JSCoverage {
async _onScriptParsed(event: Protocol.Debugger.scriptParsedPayload) {
// Ignore playwright-injected scripts
if (event.url === EVALUATION_SCRIPT_URL)
if (js.isPlaywrightSourceUrl(event.url))
return;
this._scriptIds.add(event.scriptId);
// Ignore other anonymous scripts unless the reportAnonymousScripts option is true.

View File

@ -21,9 +21,6 @@ import { valueFromRemoteObject, getExceptionMessage, releaseObject } from './crP
import { Protocol } from './protocol';
import * as js from '../javascript';
export const EVALUATION_SCRIPT_URL = '__playwright_evaluation_script__';
const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m;
export class CRExecutionContext implements js.ExecutionContextDelegate {
_client: CRSession;
_contextId: number;
@ -34,14 +31,11 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
}
async evaluate(context: js.ExecutionContext, returnByValue: boolean, pageFunction: Function | string, ...args: any[]): Promise<any> {
const suffix = `//# sourceURL=${EVALUATION_SCRIPT_URL}`;
if (helper.isString(pageFunction)) {
const contextId = this._contextId;
const expression: string = pageFunction;
const expressionWithSourceUrl = SOURCE_URL_REGEX.test(expression) ? expression : expression + '\n' + suffix;
const {exceptionDetails, result: remoteObject} = await this._client.send('Runtime.evaluate', {
expression: expressionWithSourceUrl,
expression: js.ensureSourceUrl(expression),
contextId,
returnByValue,
awaitPromise: true,
@ -79,7 +73,7 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
try {
const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.callFunctionOn', {
functionDeclaration: functionText + '\n' + suffix + '\n',
functionDeclaration: functionText,
executionContextId: this._contextId,
arguments: [
...values.map(value => ({ value })),

View File

@ -21,7 +21,7 @@ import * as frames from '../frames';
import { helper, RegisteredListener, assert } from '../helper';
import * as network from '../network';
import { CRSession, CRConnection, CRSessionEvents } from './crConnection';
import { EVALUATION_SCRIPT_URL, CRExecutionContext } from './crExecutionContext';
import { CRExecutionContext } from './crExecutionContext';
import { CRNetworkManager } from './crNetworkManager';
import { Page, Worker, PageBinding } from '../page';
import { Protocol } from './protocol';
@ -418,7 +418,7 @@ class FrameSession {
lifecycleEventsEnabled = this._client.send('Page.setLifecycleEventsEnabled', { enabled: true }),
this._client.send('Runtime.enable', {}),
this._client.send('Page.addScriptToEvaluateOnNewDocument', {
source: `//# sourceURL=${EVALUATION_SCRIPT_URL}`,
source: js.generateSourceUrl(),
worldName: UTILITY_WORLD_NAME,
}),
this._networkManager.initialize(),

View File

@ -32,7 +32,7 @@ export class FFExecutionContext implements js.ExecutionContextDelegate {
async evaluate(context: js.ExecutionContext, returnByValue: boolean, pageFunction: Function | string, ...args: any[]): Promise<any> {
if (helper.isString(pageFunction)) {
const payload = await this._session.send('Runtime.evaluate', {
expression: pageFunction.trim(),
expression: js.ensureSourceUrl(pageFunction),
returnByValue,
executionContextId: this._executionContextId,
}).catch(rewriteError);
@ -117,7 +117,7 @@ export class FFExecutionContext implements js.ExecutionContextDelegate {
const simpleValue = await this._session.send('Runtime.callFunction', {
executionContextId: this._executionContextId,
returnByValue: true,
functionDeclaration: ((e: any) => e).toString(),
functionDeclaration: ((e: any) => e).toString() + js.generateSourceUrl(),
args: [this._toCallArgument(payload)],
});
return deserializeValue(simpleValue.result!);

View File

@ -22,6 +22,9 @@ import * as util from 'util';
import { TimeoutError } from './errors';
import * as types from './types';
// NOTE: update this to point to playwright/lib when moving this file.
const PLAYWRIGHT_LIB_PATH = __dirname;
export type RegisteredListener = {
emitter: EventEmitter;
eventName: (string | symbol);
@ -370,6 +373,38 @@ export function logPolitely(toBeLogged: string) {
console.log(toBeLogged); // eslint-disable-line no-console
}
export function getCallerFilePath(ignorePrefix = PLAYWRIGHT_LIB_PATH): string | null {
const error = new Error();
const stackFrames = (error.stack || '').split('\n').slice(1);
// Find first stackframe that doesn't point to ignorePrefix.
for (let frame of stackFrames) {
frame = frame.trim();
if (!frame.startsWith('at '))
return null;
if (frame.endsWith(')')) {
const from = frame.indexOf('(');
frame = frame.substring(from + 1, frame.length - 1);
} else {
frame = frame.substring('at '.length);
}
const match = frame.match(/^(?:async )?(.*):(\d+):(\d+)$/);
if (!match)
return null;
const filePath = match[1];
if (filePath.startsWith(ignorePrefix))
continue;
return filePath;
}
return null;
}
let debugMode: boolean | undefined;
export function isDebugMode(): boolean {
if (debugMode === undefined)
debugMode = !!getFromENV('PLAYWRIGHT_DEBUG_UI');
return debugMode;
}
const escapeGlobChars = new Set(['/', '$', '^', '+', '.', '(', ')', '=', '!', '|']);
export const helper = Helper;

View File

@ -16,7 +16,9 @@
import * as types from './types';
import * as dom from './dom';
import { helper } from './helper';
import * as fs from 'fs';
import * as util from 'util';
import { helper, getCallerFilePath, isDebugMode } from './helper';
import { InnerLogger } from './logger';
export interface ExecutionContextDelegate {
@ -125,7 +127,8 @@ export async function prepareFunctionCall<T>(
args: any[],
toCallArgumentIfNeeded: (value: any) => { handle?: T, value?: any }): Promise<{ functionText: string, values: any[], handles: T[], dispose: () => void }> {
let functionText = pageFunction.toString();
const originalText = pageFunction.toString();
let functionText = originalText;
try {
new Function('(' + functionText + ')');
} catch (e1) {
@ -199,10 +202,13 @@ export async function prepareFunctionCall<T>(
if (error)
throw new Error(error);
if (!guids.length)
if (!guids.length) {
const sourceMapUrl = await generateSourceMapUrl(originalText, { line: 0, column: 0 });
functionText += sourceMapUrl;
return { functionText, values: args, handles: [], dispose: () => {} };
}
functionText = `(...__playwright__args__) => {
const wrappedFunctionText = `(...__playwright__args__) => {
return (${functionText})(...(() => {
const args = __playwright__args__;
__playwright__args__ = undefined;
@ -226,6 +232,8 @@ export async function prepareFunctionCall<T>(
return result;
})());
}`;
const compiledPosition = findPosition(wrappedFunctionText, wrappedFunctionText.indexOf(functionText));
functionText = wrappedFunctionText;
const resolved = await Promise.all(handles);
const resultHandles: T[] = [];
@ -242,5 +250,120 @@ export async function prepareFunctionCall<T>(
const dispose = () => {
toDispose.map(handlePromise => handlePromise.then(handle => handle.dispose()));
};
const sourceMapUrl = await generateSourceMapUrl(originalText, compiledPosition);
functionText += sourceMapUrl;
return { functionText, values: [ args.length, ...args, guids.length, ...guids ], handles: resultHandles, dispose };
}
let sourceUrlCounter = 0;
const playwrightSourceUrlPrefix = '__playwright_evaluation_script__';
const sourceUrlRegex = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m;
export function generateSourceUrl(): string {
return `\n//# sourceURL=${playwrightSourceUrlPrefix}${sourceUrlCounter++}\n`;
}
export function isPlaywrightSourceUrl(s: string): boolean {
return s.startsWith(playwrightSourceUrlPrefix);
}
export function ensureSourceUrl(expression: string): string {
return sourceUrlRegex.test(expression) ? expression : expression + generateSourceUrl();
}
type Position = {
line: number;
column: number;
};
async function generateSourceMapUrl(functionText: string, compiledPosition: Position): Promise<string> {
if (!isDebugMode())
return generateSourceUrl();
const filePath = getCallerFilePath();
if (!filePath)
return generateSourceUrl();
try {
const source = await util.promisify(fs.readFile)(filePath, 'utf8');
const index = source.indexOf(functionText);
if (index === -1)
return generateSourceUrl();
const sourcePosition = findPosition(source, index);
const delta = findPosition(functionText, functionText.length);
const sourceMap = generateSourceMap(filePath, sourcePosition, compiledPosition, delta);
return `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(sourceMap).toString('base64')}\n`;
} catch (e) {
return generateSourceUrl();
}
}
const VLQ_BASE_SHIFT = 5;
const VLQ_BASE = 1 << VLQ_BASE_SHIFT;
const VLQ_BASE_MASK = VLQ_BASE - 1;
const VLQ_CONTINUATION_BIT = VLQ_BASE;
const BASE64_DIGITS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
function base64VLQ(value: number): string {
if (value < 0)
value = ((-value) << 1) | 1;
else
value <<= 1;
let result = '';
do {
let digit = value & VLQ_BASE_MASK;
value >>>= VLQ_BASE_SHIFT;
if (value > 0)
digit |= VLQ_CONTINUATION_BIT;
result += BASE64_DIGITS[digit];
} while (value > 0);
return result;
}
function generateSourceMap(filePath: string, sourcePosition: Position, compiledPosition: Position, delta: Position): any {
const mappings = [];
let lastCompiled = { line: 0, column: 0 };
let lastSource = { line: 0, column: 0 };
for (let line = 0; line < delta.line; line++) {
// We need at least a mapping per line. This will yield an execution line at the start of each line.
// Note: for more granular mapping, we can do word-by-word.
const source = advancePosition(sourcePosition, { line, column: 0 });
const compiled = advancePosition(compiledPosition, { line, column: 0 });
while (lastCompiled.line < compiled.line) {
mappings.push(';');
lastCompiled.line++;
lastCompiled.column = 0;
}
mappings.push(base64VLQ(compiled.column - lastCompiled.column));
mappings.push(base64VLQ(0)); // Source index.
mappings.push(base64VLQ(source.line - lastSource.line));
mappings.push(base64VLQ(source.column - lastSource.column));
lastCompiled = compiled;
lastSource = source;
}
return JSON.stringify({
version: 3,
sources: ['file://' + filePath],
names: [],
mappings: mappings.join(''),
});
}
function findPosition(source: string, offset: number): Position {
const result: Position = { line: 0, column: 0 };
let index = 0;
while (true) {
const newline = source.indexOf('\n', index);
if (newline === -1 || newline >= offset)
break;
result.line++;
index = newline + 1;
}
result.column = offset - index;
return result;
}
function advancePosition(position: Position, delta: Position) {
return {
line: position.line + delta.line,
column: delta.column + (delta.line ? 0 : position.column),
};
}

View File

@ -21,9 +21,6 @@ import { valueFromRemoteObject, releaseObject } from './wkProtocolHelper';
import { Protocol } from './protocol';
import * as js from '../javascript';
export const EVALUATION_SCRIPT_URL = '__playwright_evaluation_script__';
const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m;
type MaybeCallArgument = Protocol.Runtime.CallArgument | { unserializable: any };
export class WKExecutionContext implements js.ExecutionContextDelegate {
@ -75,9 +72,8 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
if (helper.isString(pageFunction)) {
const contextId = this._contextId;
const expression: string = pageFunction;
const expressionWithSourceUrl = SOURCE_URL_REGEX.test(expression) ? expression : expression + '\n' + suffix;
return await this._session.send('Runtime.evaluate', {
expression: expressionWithSourceUrl,
expression: js.ensureSourceUrl(expression),
contextId,
returnByValue: false,
emulateUserGesture: true
@ -105,7 +101,7 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
const callParams = this._serializeFunctionAndArguments(functionText, values, handles);
const thisObjectId = await this._contextGlobalObjectId();
return await this._session.send('Runtime.callFunctionOn', {
functionDeclaration: callParams.functionText + '\n' + suffix + '\n',
functionDeclaration: callParams.functionText,
objectId: thisObjectId,
arguments: callParams.callArguments,
returnByValue: false,
@ -170,7 +166,7 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
try {
const serializeResponse = await this._session.send('Runtime.callFunctionOn', {
// Serialize object using standard JSON implementation to correctly pass 'undefined'.
functionDeclaration: 'function(){return this}\n' + suffix + '\n',
functionDeclaration: 'function(){return this}\n' + js.generateSourceUrl(),
objectId: objectId,
returnByValue: true
});
@ -231,7 +227,6 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
}
}
const suffix = `//# sourceURL=${EVALUATION_SCRIPT_URL}`;
const contextDestroyedResult = {
wasThrown: true,
result: {