diff --git a/package-lock.json b/package-lock.json index ae3b0d16ef..a468055873 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src/chromium/crCoverage.ts b/src/chromium/crCoverage.ts index aea6d16661..616ef1f885 100644 --- a/src/chromium/crCoverage.ts +++ b/src/chromium/crCoverage.ts @@ -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. diff --git a/src/chromium/crExecutionContext.ts b/src/chromium/crExecutionContext.ts index 90cf79b174..9a32521864 100644 --- a/src/chromium/crExecutionContext.ts +++ b/src/chromium/crExecutionContext.ts @@ -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 { - 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 })), diff --git a/src/chromium/crPage.ts b/src/chromium/crPage.ts index 31cd76b617..858c677e08 100644 --- a/src/chromium/crPage.ts +++ b/src/chromium/crPage.ts @@ -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(), diff --git a/src/firefox/ffExecutionContext.ts b/src/firefox/ffExecutionContext.ts index 768636b35e..a432ef5504 100644 --- a/src/firefox/ffExecutionContext.ts +++ b/src/firefox/ffExecutionContext.ts @@ -32,7 +32,7 @@ export class FFExecutionContext implements js.ExecutionContextDelegate { async evaluate(context: js.ExecutionContext, returnByValue: boolean, pageFunction: Function | string, ...args: any[]): Promise { 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!); diff --git a/src/helper.ts b/src/helper.ts index 6c82e6cd01..3a117c96f0 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -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; diff --git a/src/javascript.ts b/src/javascript.ts index 45fcf076c1..09f3a92d74 100644 --- a/src/javascript.ts +++ b/src/javascript.ts @@ -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( 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( 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( 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( 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 { + 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), + }; +} diff --git a/src/webkit/wkExecutionContext.ts b/src/webkit/wkExecutionContext.ts index 0af7d2c37f..06c5548fe6 100644 --- a/src/webkit/wkExecutionContext.ts +++ b/src/webkit/wkExecutionContext.ts @@ -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: {