UNPKG

chrome-devtools-frontend

Version:
275 lines (245 loc) • 11 kB
// Copyright 2024 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import * as Protocol from '../../generated/protocol.js'; import type * as ScopesCodec from '../../third_party/source-map-scopes-codec/source-map-scopes-codec.js'; import * as i18n from '../i18n/i18n.js'; import type {CallFrame, LocationRange, ScopeChainEntry} from './DebuggerModel.js'; import {type GetPropertiesResult, type RemoteObject, RemoteObjectImpl, RemoteObjectProperty} from './RemoteObject.js'; import {findExpression} from './SourceMapScopesInfo.js'; const UIStrings = { /** * @description Title of a section in the debugger showing local JavaScript variables. */ local: 'Local', /** * @description Text that refers to closure as a programming term. */ closure: 'Closure', /** * @description Noun that represents a section or block of code in the Debugger Model. Shown in the Sources tab, while paused on a breakpoint. */ block: 'Block', /** * @description Title of a section in the debugger showing JavaScript variables from the global scope. */ global: 'Global', /** * @description Text in Scope Chain section of the Sources panel. */ exception: 'Exception', /** * @description Text in Scope Chain section of the Sources panel. */ returnValue: 'Return value', } as const; const str_ = i18n.i18n.registerUIStrings('core/sdk/SourceMapScopeChainEntry.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); export class SourceMapScopeChainEntry implements ScopeChainEntry { readonly #callFrame: CallFrame; readonly #scope: ScopesCodec.OriginalScope; readonly #range?: ScopesCodec.GeneratedRange; readonly #isInnerMostFunction: boolean; readonly #returnValue?: RemoteObject; readonly #scopeNumber?: number; #object?: SourceMapScopeRemoteObject; /** * @param isInnerMostFunction If `scope` is the innermost 'function' scope. Only used for labeling as we name the * scope of the paused function 'Local', while other outer 'function' scopes are named 'Closure'. * @param scopeNumber The V8 scope in which `scope`s binding expressions must be evaluated. Defaults to the * inner-most scope. */ constructor(callFrame: CallFrame, scope: ScopesCodec.OriginalScope, range: ScopesCodec.GeneratedRange|undefined, isInnerMostFunction: boolean, returnValue: RemoteObject|undefined, scopeNumber?: number) { this.#callFrame = callFrame; this.#scope = scope; this.#range = range; this.#isInnerMostFunction = isInnerMostFunction; this.#returnValue = returnValue; this.#scopeNumber = scopeNumber; } originalScope(): ScopesCodec.OriginalScope { return this.#scope; } extraProperties(): RemoteObjectProperty[] { const extraProperties = []; if (this.#isInnerMostFunction && this.#callFrame.exception) { extraProperties.push(new RemoteObjectProperty(i18nString(UIStrings.exception), this.#callFrame.exception, undefined, undefined, undefined, undefined, undefined, /* synthetic */ true)); } if (this.#returnValue) { extraProperties.push(new RemoteObjectProperty( i18nString(UIStrings.returnValue), this.#returnValue, undefined, undefined, undefined, undefined, undefined, /* synthetic */ true, this.#callFrame.setReturnValue.bind(this.#callFrame))); } return extraProperties; } callFrame(): CallFrame { return this.#callFrame; } type(): string { if (this.#scope.isStackFrame) { return this.#isInnerMostFunction ? Protocol.Debugger.ScopeType.Local : Protocol.Debugger.ScopeType.Closure; } // `kind` is a free-form label. The spec encourages 'Global'/'Block' but doesn't mandate the casing. switch (this.#scope.kind?.toLowerCase()) { case 'global': return Protocol.Debugger.ScopeType.Global; case 'block': return Protocol.Debugger.ScopeType.Block; } return this.#scope.kind ?? ''; } typeName(): string { if (this.#scope.isStackFrame) { return this.#isInnerMostFunction ? i18nString(UIStrings.local) : i18nString(UIStrings.closure); } switch (this.#scope.kind?.toLowerCase()) { case 'global': return i18nString(UIStrings.global); case 'block': return i18nString(UIStrings.block); } return this.#scope.kind ?? ''; } name(): string|undefined { return this.#scope.name; } range(): LocationRange|null { return null; } object(): RemoteObject { if (!this.#object) { this.#object = new SourceMapScopeRemoteObject(this.#callFrame, this.#scope, this.#range, this.#scopeNumber); } return this.#object; } description(): string { return ''; } icon(): string|undefined { return undefined; } } class SourceMapScopeRemoteObject extends RemoteObjectImpl { readonly #callFrame: CallFrame; readonly #scope: ScopesCodec.OriginalScope; readonly #range?: ScopesCodec.GeneratedRange; readonly #scopeNumber?: number; #propertiesPromise?: Promise<GetPropertiesResult>; #cachedWithPreview = false; constructor(callFrame: CallFrame, scope: ScopesCodec.OriginalScope, range: ScopesCodec.GeneratedRange|undefined, scopeNumber: number|undefined) { super( callFrame.debuggerModel.runtimeModel(), /* objectId */ undefined, 'object', /* sub type */ undefined, /* value */ null); this.#callFrame = callFrame; this.#scope = scope; this.#range = range; this.#scopeNumber = scopeNumber; } override async doGetProperties(_ownProperties: boolean, accessorPropertiesOnly: boolean, _nonIndexedPropertiesOnly: boolean, generatePreview: boolean): Promise<GetPropertiesResult> { if (accessorPropertiesOnly) { return {properties: [], internalProperties: []}; } if (!this.#propertiesPromise || (generatePreview && !this.#cachedWithPreview)) { this.#cachedWithPreview = generatePreview; this.#propertiesPromise = this.#evaluateProperties(generatePreview); } return await this.#propertiesPromise; } async #evaluateProperties(generatePreview: boolean): Promise<GetPropertiesResult> { if (this.#scope.variables.length === 0) { return {properties: [], internalProperties: []}; } const expressions = this.#scope.variables.map((_, index) => this.#findExpression(index)); const values = await this.#evaluateAsBatch(expressions, generatePreview) ?? await this.#evaluateSeparately(expressions, generatePreview); const properties = this.#scope.variables.map((variable, index) => { const value = values[index]; if (value === null) { return SourceMapScopeRemoteObject.#unavailableProperty(variable); } return new RemoteObjectProperty(variable, value, /* enumerable */ false, /* writable */ false, /* isOwn */ true, /* wasThrown */ false); }); return {properties, internalProperties: []}; } /** * Evaluates all binding expressions of this scope with a single `evaluateOnCallFrame` call. * * We build an object literal that spreads in one `{index: value}` object per binding, each produced by * its own arrow function wrapped in `try`/`catch`. A binding that throws contributes nothing, which is * how we tell it apart from one that legitimately evaluates to `undefined`, and it doesn't take the * rest of the scope down with it. * * The expressions are inlined rather than passed to `eval`. `eval` in the evaluated code is the page's * `eval`, which a `script-src` CSP without `'unsafe-eval'` blocks. `Runtime.evaluate` can opt out of * that via `allowUnsafeEvalBlockedByCSP`, but `Debugger.evaluateOnCallFrame` has no such option. * Inlining also means we don't introduce bindings of our own that could shadow the names a binding * expression refers to, and arrow functions keep `this` pointing at the paused frame's receiver. * * @returns The value for each expression, or null if the batch failed as a whole. The latter happens * when a binding expression doesn't parse, since that takes out the entire object literal. */ async #evaluateAsBatch(expressions: Array<string|null>, generatePreview: boolean): Promise<Array<RemoteObject|null>|null> { const spreads: string[] = []; for (const [index, expression] of expressions.entries()) { if (expression !== null) { spreads.push(`...(() => { try { return {${index}: (${expression})}; } catch {} })()`); } } if (spreads.length === 0) { return expressions.map(() => null); } const result = await this.#callFrame.evaluate({ expression: `({__proto__: null, ${spreads.join(', ')}})`, // The wrapper object is a throw-away. We only need previews for the values inside of it. generatePreview: false, scopeNumber: this.#scopeNumber, }); if ('error' in result || result.exceptionDetails || !result.object) { return null; } const {properties} = await result.object.getOwnProperties(generatePreview); result.object.release(); const valueByIndex = new Map(properties?.map(({name, value}) => [name, value] as const)); return expressions.map((_, index) => valueByIndex.get(String(index)) ?? null); } /** * Fallback for when {@link #evaluateAsBatch} fails as a whole, so that a single binding expression * that doesn't parse only costs us that one variable. */ async #evaluateSeparately(expressions: Array<string|null>, generatePreview: boolean): Promise<Array<RemoteObject|null>> { const values: Array<RemoteObject|null> = []; for (const expression of expressions) { if (expression === null) { values.push(null); continue; } const result = await this.#callFrame.evaluate({expression, generatePreview, scopeNumber: this.#scopeNumber}); if ('error' in result || result.exceptionDetails) { // TODO(crbug.com/40277685): Make these errors user-visible to aid tooling developers. // E.g. show the error on hover or expose it in the developer resources panel. values.push(null); } else { values.push(result.object); } } return values; } /** @returns null if the variable is unavailable at the current paused location */ #findExpression(index: number): string|null { const pausedPosition = this.#callFrame.location(); return findExpression(this.#range, index, pausedPosition?.lineNumber, pausedPosition?.columnNumber); } static #unavailableProperty(name: string): RemoteObjectProperty { return new RemoteObjectProperty( name, null, /* enumerable */ false, /* writeable */ false, /* isOwn */ true, /* wasThrown */ false); } }