diff --git a/src/chrome/breakOnLoadHelper.ts b/src/chrome/breakOnLoadHelper.ts new file mode 100644 index 000000000..6bdcacfac --- /dev/null +++ b/src/chrome/breakOnLoadHelper.ts @@ -0,0 +1,263 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import {logger} from 'vscode-debugadapter'; +import {ISetBreakpointResult, BreakOnLoadStrategy} from '../debugAdapterInterfaces'; + +import Crdp from '../../crdp/crdp'; +import {ChromeDebugAdapter} from './chromeDebugAdapter'; +import * as ChromeUtils from './chromeUtils'; + +export class BreakOnLoadHelper { + + public userBreakpointOnLine1Col1: boolean = false; + private _instrumentationBreakpointSet: boolean = false; + + // Break on load: Store some mapping between the requested file names, the regex for the file, and the chrome breakpoint id to perform lookup operations efficiently + private _stopOnEntryBreakpointIdToRequestedFileName = new Map]>(); + private _stopOnEntryRequestedFileNameToBreakpointId = new Map(); + private _stopOnEntryRegexToBreakpointId = new Map(); + + private _chromeDebugAdapter: ChromeDebugAdapter; + private _breakOnLoadStrategy: BreakOnLoadStrategy; + + public constructor(chromeDebugAdapter: ChromeDebugAdapter, breakOnLoadStrategy: BreakOnLoadStrategy) { + this._chromeDebugAdapter = chromeDebugAdapter; + this._breakOnLoadStrategy = breakOnLoadStrategy; + } + + public get stopOnEntryRequestedFileNameToBreakpointId(): Map { + return this._stopOnEntryRequestedFileNameToBreakpointId; + } + + public get stopOnEntryBreakpointIdToRequestedFileName(): Map]> { + return this._stopOnEntryBreakpointIdToRequestedFileName; + } + + private get instrumentationBreakpointSet(): boolean { + return this._instrumentationBreakpointSet; + } + + /** + * Checks and resolves the pending breakpoints of a script given it's source. If any breakpoints were resolved returns true, else false. + * Used when break on load active, either through Chrome's Instrumentation Breakpoint API or the regex approach + */ + private async resolvePendingBreakpoints(source: string): Promise { + const pendingBreakpoints = this._chromeDebugAdapter.pendingBreakpointsByUrl.get(source); + // If the file has unbound breakpoints, resolve them and return true + if (pendingBreakpoints !== undefined) { + await this._chromeDebugAdapter.resolvePendingBreakpoint(pendingBreakpoints); + this._chromeDebugAdapter.pendingBreakpointsByUrl.delete(source); + return true; + } else { + // If no pending breakpoints, return false + return false; + } + } + + /** + * Checks and resolves the pending breakpoints given a script Id. If any breakpoints were resolved returns true, else false. + * Used when break on load active, either through Chrome's Instrumentation Breakpoint API or the regex approach + */ + private async resolvePendingBreakpointsOfPausedScript(scriptId: string): Promise { + const pausedScriptUrl = this._chromeDebugAdapter.scriptsById.get(scriptId).url; + const sourceMapUrl = this._chromeDebugAdapter.scriptsById.get(scriptId).sourceMapURL; + const mappedUrl = await this._chromeDebugAdapter.pathTransformer.scriptParsed(pausedScriptUrl); + let breakpointsResolved = false; + + let sources = await this._chromeDebugAdapter.sourceMapTransformer.scriptParsed(mappedUrl, sourceMapUrl); + + // If user breakpoint was put in a typescript file, pendingBreakpoints would store the typescript file in the mapping, so we need to hit those + if (sources) { + for (let source of sources) { + let anySourceBPResolved = await this.resolvePendingBreakpoints(source); + // If any of the source files had breakpoints resolved, we should return true + breakpointsResolved = breakpointsResolved || anySourceBPResolved; + } + } + // If sources is not present or user breakpoint was put in a compiled javascript file + let scriptBPResolved = await this.resolvePendingBreakpoints(mappedUrl); + breakpointsResolved = breakpointsResolved || scriptBPResolved; + + return breakpointsResolved; + } + + /** + * Handles the onpaused event. + * Checks if the event is caused by a stopOnEntry breakpoint of using the regex approach, or the paused event due to the Chrome's instrument approach + * Returns whether we should continue or not on this paused event + */ + public async handleOnPaused(notification: Crdp.Debugger.PausedEvent): Promise { + if (notification.hitBreakpoints && notification.hitBreakpoints.length) { + // If breakOnLoadStrategy is set to regex, we may have hit a stopOnEntry breakpoint we put. + // So we need to resolve all the pending breakpoints in this script and then decide to continue or not + if (this._breakOnLoadStrategy === 'regex') { + let shouldContinue = await this.handleStopOnEntryBreakpointAndContinue(notification); + return shouldContinue; + } + } else if (notification.reason === 'EventListener' && notification.data.eventName === "instrumentation:scriptFirstStatement" ) { + // This is fired when Chrome stops on the first line of a script when using the setInstrumentationBreakpoint API + + const pausedScriptId = notification.callFrames[0].location.scriptId; + // Now we should resolve all the pending breakpoints and then continue + await this.resolvePendingBreakpointsOfPausedScript(pausedScriptId); + return true; + } + return false; + } + + /** + * Returns whether we should continue on hitting a stopOnEntry breakpoint + * Only used when using regex approach for break on load + */ + private async shouldContinueOnStopOnEntryBreakpoint(scriptId: string): Promise { + // If the file has no unbound breakpoints or none of the resolved breakpoints are at (1,1), we should continue after hitting the stopOnEntry breakpoint + let shouldContinue = true; + let anyPendingBreakpointsResolved = await this.resolvePendingBreakpointsOfPausedScript(scriptId); + + // If there were any pending breakpoints resolved and any of them was at (1,1) we shouldn't continue + if (anyPendingBreakpointsResolved && this.userBreakpointOnLine1Col1) { + // Here we need to store this information per file, but since we can safely assume that scriptParsed would immediately be followed by onPaused event + // for the breakonload files, this implementation should be fine + this.userBreakpointOnLine1Col1 = false; + shouldContinue = false; + } + + return shouldContinue; + } + + /** + * Handles a script with a stop on entry breakpoint and returns whether we should continue or not on hitting that breakpoint + * Only used when using regex approach for break on load + */ + private async handleStopOnEntryBreakpointAndContinue(notification: Crdp.Debugger.PausedEvent): Promise { + const hitBreakpoints = notification.hitBreakpoints; + let allStopOnEntryBreakpoints = true; + + // If there is a breakpoint which is not a stopOnEntry breakpoint, we appear as if we hit that one + // This is particularly done for cases when we end up with a user breakpoint and a stopOnEntry breakpoint on the same line + hitBreakpoints.forEach(bp => { + if (!this._stopOnEntryBreakpointIdToRequestedFileName.has(bp)) { + notification.hitBreakpoints = [bp]; + allStopOnEntryBreakpoints = false; + } + }); + + // If all the breakpoints on this point are stopOnEntry breakpoints + // This will be true in cases where it's a single breakpoint and it's a stopOnEntry breakpoint + // This can also be true when we have multiple breakpoints and all of them are stopOnEntry breakpoints, for example in cases like index.js and index.bin.js + // Suppose user puts breakpoints in both index.js and index.bin.js files, when the setBreakpoints function is called for index.js it will set a stopOnEntry + // breakpoint on index.* files which will also match index.bin.js. Now when setBreakpoints is called for index.bin.js it will again put a stopOnEntry breakpoint + // in itself. So when the file is actually loaded, we would have 2 stopOnEntry breakpoints */ + + if (allStopOnEntryBreakpoints) { + const pausedScriptId = notification.callFrames[0].location.scriptId; + let shouldContinue = await this.shouldContinueOnStopOnEntryBreakpoint(pausedScriptId); + if (shouldContinue) { + return true; + } + } + return false; + } + + /** + * Adds a stopOnEntry breakpoint for the given script url + * Only used when using regex approach for break on load + */ + private async addStopOnEntryBreakpoint(url: string): Promise { + let responsePs: ISetBreakpointResult[]; + // Check if file already has a stop on entry breakpoint + if (!this._stopOnEntryRequestedFileNameToBreakpointId.has(url)) { + + // Generate regex we need for the file + const urlRegex = ChromeUtils.getUrlRegexForBreakOnLoad(url); + + // Check if we already have a breakpoint for this regexp since two different files like script.ts and script.js may have the same regexp + let breakpointId: string; + breakpointId = this._stopOnEntryRegexToBreakpointId.get(urlRegex); + + // If breakpointId is undefined it means the breakpoint doesn't exist yet so we add it + if (breakpointId === undefined) { + let result; + try { + result = await this.setStopOnEntryBreakpoint(urlRegex); + } catch (e) { + logger.log(`Exception occured while trying to set stop on entry breakpoint ${e.message}.`); + } + if (result) { + breakpointId = result.breakpointId; + this._stopOnEntryRegexToBreakpointId.set(urlRegex, breakpointId); + } else { + logger.log(`BreakpointId was null when trying to set on urlregex ${urlRegex}. This normally happens if the breakpoint already exists.`); + } + responsePs = [result]; + } else { + responsePs = []; + } + + // Store the new breakpointId and the file name in the right mappings + this._stopOnEntryRequestedFileNameToBreakpointId.set(url, breakpointId); + + let regexAndFileNames = this._stopOnEntryBreakpointIdToRequestedFileName.get(breakpointId); + + // If there already exists an entry for the breakpoint Id, we add this file to the list of file mappings + if (regexAndFileNames !== undefined) { + regexAndFileNames[1].add(url); + } else { // else create an entry for this breakpoint id + const fileSet = new Set(); + fileSet.add(url); + this._stopOnEntryBreakpointIdToRequestedFileName.set(breakpointId, [urlRegex, fileSet]); + } + } else { + responsePs = []; + } + return Promise.all(responsePs); + } + + /** + * Handles the AddBreakpoints request when break on load is active + * Takes the action based on the strategy + */ + public async handleAddBreakpoints(url: string): Promise { + // If the strategy is set to regex, we try to match the file where user put the breakpoint through a regex and tell Chrome to put a stop on entry breakpoint there + if (this._breakOnLoadStrategy === 'regex') { + return this.addStopOnEntryBreakpoint(url); + } else if (this._breakOnLoadStrategy === 'instrument') { + // Else if strategy is to use Chrome's experimental instrumentation API, we stop on all the scripts at the first statement before execution + if (!this.instrumentationBreakpointSet) { + await this.setInstrumentationBreakpoint(); + } + return []; + } + return undefined; + } + + /** + * Tells Chrome to set instrumentation breakpoint to stop on all the scripts before execution + * Only used when using instrument approach for break on load + */ + private async setInstrumentationBreakpoint(): Promise { + this._chromeDebugAdapter.chrome.DOMDebugger.setInstrumentationBreakpoint({eventName: "scriptFirstStatement"}); + this._instrumentationBreakpointSet = true; + } + + // Sets a breakpoint on (0,0) for the files matching the given regex + private async setStopOnEntryBreakpoint(urlRegex: string): Promise { + let result = await this._chromeDebugAdapter.chrome.Debugger.setBreakpointByUrl({ urlRegex, lineNumber: 0, columnNumber: 0 }); + return result; + } + + /** + * Checks if we need to call resolvePendingBPs on scriptParsed event + * If break on load is active and we are using the regex approach, only call the resolvePendingBreakpoint function for files where we do not + * set break on load breakpoints. For those files, it is called from onPaused function. + * For the default Chrome's API approach, we don't need to call resolvePendingBPs from inside scriptParsed + */ + public shouldResolvePendingBPs(mappedUrl: string): boolean { + if (this._breakOnLoadStrategy === 'regex' && !this.stopOnEntryRequestedFileNameToBreakpointId.has(mappedUrl)) { + return true; + } + return false; + } +} \ No newline at end of file diff --git a/src/chrome/chromeDebugAdapter.ts b/src/chrome/chromeDebugAdapter.ts index db88c7e7c..d9feba76f 100644 --- a/src/chrome/chromeDebugAdapter.ts +++ b/src/chrome/chromeDebugAdapter.ts @@ -9,7 +9,7 @@ import {ICommonRequestArgs, ILaunchRequestArgs, ISetBreakpointsArgs, ISetBreakpo IAttachRequestArgs, IScopesResponseBody, IVariablesResponseBody, ISourceResponseBody, IThreadsResponseBody, IEvaluateResponseBody, ISetVariableResponseBody, IDebugAdapter, ICompletionsResponseBody, IToggleSkipFileStatusArgs, IInternalStackTraceResponseBody, IGetLoadedSourcesResponseBody, - IExceptionInfoResponseBody, ISetBreakpointResult, TimeTravelRuntime, IRestartRequestArgs, IInitializeRequestArgs} from '../debugAdapterInterfaces'; + IExceptionInfoResponseBody, ISetBreakpointResult, TimeTravelRuntime, IRestartRequestArgs, IInitializeRequestArgs, BreakOnLoadStrategy} from '../debugAdapterInterfaces'; import {IChromeDebugAdapterOpts, ChromeDebugSession} from './chromeDebugSession'; import {ChromeConnection} from './chromeConnection'; import * as ChromeUtils from './chromeUtils'; @@ -29,6 +29,7 @@ import {RemotePathTransformer} from '../transformers/remotePathTransformer'; import {BaseSourceMapTransformer} from '../transformers/baseSourceMapTransformer'; import {EagerSourceMapTransformer} from '../transformers/eagerSourceMapTransformer'; import {FallbackToClientPathTransformer} from '../transformers/fallbackToClientPathTransformer'; +import {BreakOnLoadHelper} from './breakOnLoadHelper'; import * as path from 'path'; @@ -53,7 +54,7 @@ export interface ISourceContainer { mappedPath?: string; } -interface IPendingBreakpoint { +export interface IPendingBreakpoint { args: ISetBreakpointsArgs; ids: number[]; requestSeq: number; @@ -67,7 +68,7 @@ interface IHitConditionBreakpoint { export type VariableContext = 'variables' | 'watch' | 'repl' | 'hover'; -type CrdpScript = Crdp.Debugger.ScriptParsedEvent; +export type CrdpScript = Crdp.Debugger.ScriptParsedEvent; export type CrdpDomain = keyof Crdp.CrdpClient; @@ -131,6 +132,9 @@ export abstract class ChromeDebugAdapter implements IDebugAdapter { private _lastPauseState: { expecting: ReasonType; event: Crdp.Debugger.PausedEvent }; + private _breakOnLoadHelper: BreakOnLoadHelper; + private _breakOnLoadStrategy: BreakOnLoadStrategy = 'none'; + public constructor({ chromeConnection, lineColTransformer, sourceMapTransformer, pathTransformer, targetFilter, enableSourceMapCaching }: IChromeDebugAdapterOpts, session: ChromeDebugSession) { telemetry.setupEventHandler(e => session.sendEvent(e)); this._session = session; @@ -150,10 +154,26 @@ export abstract class ChromeDebugAdapter implements IDebugAdapter { this.clearTargetContext(); } - protected get chrome(): Crdp.CrdpClient { + public get chrome(): Crdp.CrdpClient { return this._chromeConnection.api; } + public get scriptsById(): Map { + return this._scriptsById; + } + + public get pathTransformer(): BasePathTransformer { + return this._pathTransformer; + } + + public get pendingBreakpointsByUrl(): Map { + return this._pendingBreakpointsByUrl; + } + + public get sourceMapTransformer(): BaseSourceMapTransformer{ + return this._sourceMapTransformer; + } + /** * Called on 'clearEverything' or on a navigation/refresh */ @@ -236,6 +256,11 @@ export abstract class ChromeDebugAdapter implements IDebugAdapter { this._sourceMapTransformer.launch(args); this._pathTransformer.launch(args); + if (args.breakOnLoadStrategy) { + this._breakOnLoadStrategy = args.breakOnLoadStrategy; + this._breakOnLoadHelper = new BreakOnLoadHelper(this, args.breakOnLoadStrategy); + } + if (!args.__restart) { /* __GDPR__ "debugStarted" : { @@ -437,13 +462,26 @@ export abstract class ChromeDebugAdapter implements IDebugAdapter { this.clearTargetContext()); } - protected onPaused(notification: Crdp.Debugger.PausedEvent, expectingStopReason = this._expectingStopReason): void { + protected async onPaused(notification: Crdp.Debugger.PausedEvent, expectingStopReason = this._expectingStopReason): Promise { this._variableHandles.onPaused(); this._frameHandles.reset(); this._exception = undefined; this._lastPauseState = { event: notification, expecting: expectingStopReason }; this._currentPauseNotification = notification; + // If break on load is active, we pass the notification object to breakonload helper + // If it returns true, we continue and return + if (this._breakOnLoadStrategy !== 'none') { + let shouldContinue = await this._breakOnLoadHelper.handleOnPaused(notification); + if (shouldContinue) { + this.chrome.Debugger.resume() + .catch(e => { + logger.error("Failed to resume due to exception: " + e.message); + }); + return; + } + } + // We can tell when we've broken on an exception. Otherwise if hitBreakpoints is set, assume we hit a // breakpoint. If not set, assume it was a step. We can't tell the difference between step and 'break on anything'. let reason: ReasonType; @@ -605,15 +643,23 @@ export abstract class ChromeDebugAdapter implements IDebugAdapter { }; const mappedUrl = await this._pathTransformer.scriptParsed(script.url); + const sourceMapsP = this._sourceMapTransformer.scriptParsed(mappedUrl, script.sourceMapURL).then(sources => { if (this._hasTerminated) { return undefined; } if (sources) { - sources - .filter(source => source !== mappedUrl) // Tools like babel-register will produce sources with the same path as the generated script - .forEach(resolvePendingBPs); + // If break on load is active, check whether we should call resolvePendingBPs + if (this._breakOnLoadStrategy !== "none") { + sources + .filter(source => source !== mappedUrl && this._breakOnLoadHelper.shouldResolvePendingBPs(source)) // Tools like babel-register will produce sources with the same path as the generated script + .forEach(resolvePendingBPs); + } else { + sources + .filter(source => source !== mappedUrl) // Tools like babel-register will produce sources with the same path as the generated script + .forEach(resolvePendingBPs); + } } if (script.url === mappedUrl && this._pendingBreakpointsByUrl.has(mappedUrl) && this._pendingBreakpointsByUrl.get(mappedUrl).bpsSet) { @@ -621,7 +667,10 @@ export abstract class ChromeDebugAdapter implements IDebugAdapter { // to be resolved in this loaded script, and remove the pendingBP. this._pendingBreakpointsByUrl.delete(mappedUrl); } else { - resolvePendingBPs(mappedUrl); + // If break on load is active, check whether we should call resolvePendingBPs + if (this._breakOnLoadStrategy === 'none' || (this._breakOnLoadHelper && !sources && this._breakOnLoadHelper.shouldResolvePendingBPs(mappedUrl))) { + resolvePendingBPs(mappedUrl); + } } return this.resolveSkipFiles(script, mappedUrl, sources); @@ -841,10 +890,14 @@ export abstract class ChromeDebugAdapter implements IDebugAdapter { return { sources: sources.sort((a, b) => a.path.localeCompare(b.path)) }; } - private resolvePendingBreakpoint(pendingBP: IPendingBreakpoint): Promise { + public resolvePendingBreakpoint(pendingBP: IPendingBreakpoint): Promise { return this.setBreakpoints(pendingBP.args, pendingBP.requestSeq, pendingBP.ids).then(response => { response.breakpoints.forEach((bp, i) => { bp.id = pendingBP.ids[i]; + // If any of the unbound breakpoints in this file is on (1,1), we set userBreakpointOnLine1Col1 to true + if (bp.line === 1 && bp.column === 1 && this._breakOnLoadHelper) { + this._breakOnLoadHelper.userBreakpointOnLine1Col1 = true; + } this._session.sendEvent(new BreakpointEvent('changed', bp)); }); }); @@ -857,6 +910,11 @@ export abstract class ChromeDebugAdapter implements IDebugAdapter { return; } + // If the breakpoint resolved is a stopOnEntry breakpoint, we just return since we don't need to send it to client + if (this._breakOnLoadHelper && this._breakOnLoadHelper.stopOnEntryBreakpointIdToRequestedFileName.has(params.breakpointId)) { + return; + } + const committedBps = this._committedBreakpointsByUrl.get(script.url) || []; committedBps.push(params.breakpointId); this._committedBreakpointsByUrl.set(script.url, committedBps); @@ -868,6 +926,7 @@ export abstract class ChromeDebugAdapter implements IDebugAdapter { column: params.location.columnNumber }; const scriptPath = this._pathTransformer.breakpointResolved(bp, script.url); + if (this._pendingBreakpointsByUrl.has(scriptPath)) { // If we set these BPs before the script was loaded, remove from the pending list this._pendingBreakpointsByUrl.delete(scriptPath); @@ -999,6 +1058,8 @@ export abstract class ChromeDebugAdapter implements IDebugAdapter { return this.validateBreakpointsPath(args) .then(() => { + // Deep copy args to originalArgs + const originalArgs: ISetBreakpointsArgs = JSON.parse(JSON.stringify(args)); this._lineColTransformer.setBreakpoints(args); this._sourceMapTransformer.setBreakpoints(args, requestSeq); this._pathTransformer.setBreakpoints(args); @@ -1042,9 +1103,9 @@ export abstract class ChromeDebugAdapter implements IDebugAdapter { // Return the setBP request, no matter how long it takes. It may take awhile in Node 7.5 - 7.7, see https://github.com/nodejs/node/issues/11589 return setBreakpointsPFailOnError.then(body => { if (body.breakpoints.every(bp => !bp.verified)) { - return this.unverifiedBpResponseForBreakpoints(args, requestSeq, body.breakpoints, localize('bp.fail.unbound', "Breakpoints set but not yet bound"), true); + // We need to send the original args to avoid adjusting the line and column numbers twice here + return this.unverifiedBpResponseForBreakpoints(originalArgs, requestSeq, body.breakpoints, localize('bp.fail.unbound', "Breakpoints set but not yet bound"), true); } - this._sourceMapTransformer.setBreakpointsResponse(body, requestSeq); this._lineColTransformer.setBreakpointsResponse(body); return body; @@ -1073,7 +1134,13 @@ export abstract class ChromeDebugAdapter implements IDebugAdapter { protected validateBreakpointsPath(args: ISetBreakpointsArgs): Promise { if (!args.source.path || args.source.sourceReference) return Promise.resolve(); + // When break on load is active, we don't need to validate the path, so return + if (this._breakOnLoadStrategy !== 'none') { + return Promise.resolve(); + } + return this._sourceMapTransformer.getGeneratedPathFromAuthoredPath(args.source.path).then(mappedPath => { + if (!mappedPath) { return utils.errP(localize('validateBP.sourcemapFail', "Breakpoint ignored because generated code not found (source map problem?).")); } @@ -1137,7 +1204,7 @@ export abstract class ChromeDebugAdapter implements IDebugAdapter { * Responses from setBreakpointByUrl are transformed to look like the response from setBreakpoint, so they can be * handled the same. */ - protected addBreakpoints(url: string, breakpoints: DebugProtocol.SourceBreakpoint[]): Promise { + protected async addBreakpoints(url: string, breakpoints: DebugProtocol.SourceBreakpoint[]): Promise { let responsePs: Promise[]; if (ChromeUtils.isEvalScript(url)) { // eval script with no real url - use debugger_setBreakpoint @@ -1148,10 +1215,18 @@ export abstract class ChromeDebugAdapter implements IDebugAdapter { // after refreshing the page. This is the only way to allow hitting breakpoints in code that runs immediately when // the page loads. const script = this.getScriptByUrl(url); - const urlRegex = utils.pathToRegex(url, this._caseSensitivePaths); - responsePs = breakpoints.map(({ line, column = 0, condition }, i) => { - return this.addOneBreakpointByUrl(script && script.scriptId, urlRegex, line, column, condition); - }); + + // If script has been parsed, script object won't be undefined and we would have the mapping file on the disk and we can directly set breakpoint using that + if (this._breakOnLoadStrategy === 'none' || script) { + const urlRegex = utils.pathToRegex(url, this._caseSensitivePaths); + responsePs = breakpoints.map(({ line, column = 0, condition }, i) => { + return this.addOneBreakpointByUrl(script && script.scriptId, urlRegex, line, column, condition); + }); + } else { // Else if script hasn't been parsed and break on load is active, we need to do extra processing + if (this._breakOnLoadHelper) { + return this._breakOnLoadHelper.handleAddBreakpoints(url); + } + } } // Join all setBreakpoint requests to a single promise diff --git a/src/chrome/chromeUtils.ts b/src/chrome/chromeUtils.ts index aeea26dce..6d4fcbadc 100644 --- a/src/chrome/chromeUtils.ts +++ b/src/chrome/chromeUtils.ts @@ -287,3 +287,14 @@ export const EVAL_NAME_PREFIX = 'VM'; export function isEvalScript(scriptPath: string): boolean { return scriptPath.startsWith(EVAL_NAME_PREFIX); } + +/* Constructs the regex for files to enable break on load +For example, for a file index.js the regex will match urls containing index.js, index.ts, abc/index.ts, index.bin.js etc +It won't match index100.js, indexabc.ts etc */ +export function getUrlRegexForBreakOnLoad(url: string): string { + const fileNameWithoutFullPath = path.parse(url).base; + const fileNameWithoutExtension = path.parse(fileNameWithoutFullPath).name; + const escapedFileName = fileNameWithoutExtension.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); + + return ".*[\\\\\\/]" + escapedFileName + "([^A-z^0-9].*)?$"; +} diff --git a/src/debugAdapterInterfaces.d.ts b/src/debugAdapterInterfaces.d.ts index 7e9e613eb..a9fac18b6 100644 --- a/src/debugAdapterInterfaces.d.ts +++ b/src/debugAdapterInterfaces.d.ts @@ -11,6 +11,8 @@ import Crdp from '../crdp/crdp'; export type ISourceMapPathOverrides = { [pattern: string]: string }; +export type BreakOnLoadStrategy = 'regex' | 'instrument' | 'none'; + /** * Properties valid for both Launch and Attach */ @@ -49,6 +51,9 @@ export interface IRestartRequestArgs { */ export interface ILaunchRequestArgs extends DebugProtocol.LaunchRequestArguments, ICommonRequestArgs { __restart?: IRestartRequestArgs; + + /** Private undocumented property for enabling break on load */ + breakOnLoadStrategy: BreakOnLoadStrategy; } export interface IAttachRequestArgs extends DebugProtocol.AttachRequestArguments, ICommonRequestArgs { diff --git a/test/chrome/chromeUtils.test.ts b/test/chrome/chromeUtils.test.ts index 2678eb9e5..164f95c85 100644 --- a/test/chrome/chromeUtils.test.ts +++ b/test/chrome/chromeUtils.test.ts @@ -381,4 +381,27 @@ suite('ChromeUtils', () => { assert.equal(chromeUtils.getEvaluateName('obj', 'a-b'), 'obj["a-b"]'); }); }); + + suite('getUrlRegexForBreakOnLoad', () => { + + test('Works with a base file path', () => { + assert.deepEqual(getChromeUtils().getUrlRegexForBreakOnLoad('index.js'), '.*[\\\\\\/]index([^A-z^0-9].*)?$'); + assert.deepEqual(getChromeUtils().getUrlRegexForBreakOnLoad('index123.js'), '.*[\\\\\\/]index123([^A-z^0-9].*)?$'); + }); + + test('Strips the nested file path', () => { + assert.deepEqual(getChromeUtils().getUrlRegexForBreakOnLoad('C:\\Folder\\Subfolder\\index.js'), '.*[\\\\\\/]index([^A-z^0-9].*)?$'); + assert.deepEqual(getChromeUtils().getUrlRegexForBreakOnLoad('C:\\Folder\\index123.ts'), '.*[\\\\\\/]index123([^A-z^0-9].*)?$'); + }); + + test('Works case sensitive', () => { + assert.deepEqual(getChromeUtils().getUrlRegexForBreakOnLoad('C:\\Folder\\Subfolder\\inDex.js'), '.*[\\\\\\/]inDex([^A-z^0-9].*)?$'); + assert.deepEqual(getChromeUtils().getUrlRegexForBreakOnLoad('C:\\Folder\\INDex123.ts'), '.*[\\\\\\/]INDex123([^A-z^0-9].*)?$'); + }); + + test('Escapes special characters', () => { + assert.deepEqual(getChromeUtils().getUrlRegexForBreakOnLoad('C:\\Folder\\Subfolder\\inDex?abc.js'), '.*[\\\\\\/]inDex\\?abc([^A-z^0-9].*)?$'); + assert.deepEqual(getChromeUtils().getUrlRegexForBreakOnLoad('C:\\Folder\\IN+De*x123.ts'), '.*[\\\\\\/]IN\\+De\\*x123([^A-z^0-9].*)?$'); + }); + }); }); diff --git a/testSupport/src/debugClient.ts b/testSupport/src/debugClient.ts index ae2912dd7..8e861a6df 100644 --- a/testSupport/src/debugClient.ts +++ b/testSupport/src/debugClient.ts @@ -153,20 +153,23 @@ export class ExtendedDebugClient extends DebugClient { source: { path: location.path } }); }).then(response => { - const bp = response.body.breakpoints[0]; - - if (typeof location.verified === 'boolean') { - assert.equal(bp.verified, location.verified, 'breakpoint verification mismatch: verified'); - } - if (bp.source && bp.source.path) { - this.assertPath(bp.source.path, location.path, 'breakpoint verification mismatch: path'); - } - if (typeof bp.line === 'number') { - assert.equal(bp.line, location.line, 'breakpoint verification mismatch: line'); - } - if (typeof location.column === 'number' && typeof bp.column === 'number') { - assert.equal(bp.column, location.column, 'breakpoint verification mismatch: column'); + if (response.body.breakpoints.length > 0) { + const bp = response.body.breakpoints[0]; + + if (typeof location.verified === 'boolean') { + assert.equal(bp.verified, location.verified, 'breakpoint verification mismatch: verified'); + } + if (bp.source && bp.source.path) { + this.assertPath(bp.source.path, location.path, 'breakpoint verification mismatch: path'); + } + if (typeof bp.line === 'number') { + assert.equal(bp.line, location.line, 'breakpoint verification mismatch: line'); + } + if (typeof location.column === 'number' && typeof bp.column === 'number') { + assert.equal(bp.column, location.column, 'breakpoint verification mismatch: column'); + } } + return this.configurationDoneRequest(); }),