diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index 3697baf81e..734e3eff95 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -604,6 +604,28 @@ MenuButtons--metaInfo--resymbolicate-profile = Re-symbolicate profile MenuButtons--metaInfo--symbolicate-profile = Symbolicate profile MenuButtons--metaInfo--attempting-resymbolicate = Attempting to re-symbolicate profile MenuButtons--metaInfo--currently-symbolicating = Currently symbolicating profile +MenuButtons--metaInfo--source-maps = Source maps: +# The trailing ellipsis indicates that clicking the button opens a file picker. +MenuButtons--metaInfo--apply-source-map = Apply source map… + .title = Load a .map file from disk to symbolicate a minified JavaScript bundle, to recover original function names and source locations. +# Shown when the uploaded map could match more than one source and the user has +# to choose which one it applies to. +MenuButtons--metaInfo--source-map-choose-bundle = Choose which bundle this source map applies to: +# Button to confirm the chosen source and apply the source map to it. +MenuButtons--metaInfo--source-map-apply = Apply +# Button to dismiss the source chooser without symbolicating. +MenuButtons--metaInfo--source-map-cancel = Cancel +# Shown after symbolication finished and original sources were resolved. +# Variable: +# $filename (String) - The bundle source the source map was applied to. +MenuButtons--metaInfo--source-map-success = Original sources resolved for { $filename }. +# Shown after symbolication finished but no stack positions matched the map. +# Variable: +# $filename (String) - The bundle source the source map was applied to. +MenuButtons--metaInfo--source-map-no-match = No stack positions in { $filename } matched this source map. +MenuButtons--metaInfo--source-map-error-invalid = The selected file is not a valid source map. +MenuButtons--metaInfo--source-map-error-no-eligible = This profile has no JS bundles with source map URLs. +MenuButtons--metaInfo--source-map-error-failed = Could not apply this source map to the profile. MenuButtons--metaInfo--cpu-model = CPU model: MenuButtons--metaInfo--cpu-cores = CPU cores: MenuButtons--metaInfo--main-memory = Main memory: diff --git a/src/actions/receive-profile.ts b/src/actions/receive-profile.ts index e197bee440..4bbf0226cf 100644 --- a/src/actions/receive-profile.ts +++ b/src/actions/receive-profile.ts @@ -77,6 +77,7 @@ import { hasUsefulSamples, } from 'firefox-profiler/profile-logic/profile-data'; import { doSourceMapSymbolication } from './source-map-symbolication'; +import type { SourceMapSymbolicationResult } from './source-map-symbolication'; import type { RawSourceMap } from 'source-map'; import type { @@ -254,7 +255,8 @@ export function finalizeProfileView( // funcs aren't in those sets), and the JS apply step reads current // shared state at dispatch time so it composes with whatever native // has committed by then. Requires WebChannel version 7+. - let sourceMapSymbolicationPromise: Promise | null = null; + let sourceMapSymbolicationPromise: Promise | null = + null; if (browserConnection !== null && browserConnection.supportsGetSourceMap) { sourceMapSymbolicationPromise = doResolveSourceMaps( profile, @@ -1156,7 +1158,7 @@ export function waitingForProfileFromFile(): Action { }; } -function _fileReader(input: File) { +export function _fileReader(input: File) { const reader = new FileReader(); const promise = new Promise((resolve, reject) => { // Flow's definition for FileReader doesn't handle the polymorphic nature of diff --git a/src/actions/source-map-symbolication.ts b/src/actions/source-map-symbolication.ts index 1ce7bdb9a6..009569d9e5 100644 --- a/src/actions/source-map-symbolication.ts +++ b/src/actions/source-map-symbolication.ts @@ -4,12 +4,18 @@ import { getRawProfileSharedData } from 'firefox-profiler/selectors'; import { applySourceMapSymbolicationResponse } from 'firefox-profiler/profile-logic/source-map-symbolication'; +import { + getSourcesWithSourceMapURL, + parseSourceMapFileContents, + matchSourceMapToSource, +} from 'firefox-profiler/profile-logic/source-map-matching'; import type { WorkerInput, WorkerOutput, } from 'firefox-profiler/profile-logic/source-map-worker-types'; import type { IndexIntoSourceTable, ThunkAction } from 'firefox-profiler/types'; +import type { EligibleSource } from 'firefox-profiler/profile-logic/source-map-matching'; import type { RawSourceMap } from 'source-map'; import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; @@ -26,13 +32,15 @@ import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; * text (fetched alongside the source maps). Used for scope-tree-based function * name resolution. */ +export type SourceMapSymbolicationResult = 'applied' | 'no-match' | 'error'; + export function doSourceMapSymbolication( resolvedSourceMaps: Map, compiledSources: Map -): ThunkAction> { +): ThunkAction> { return async (dispatch, getState) => { if (resolvedSourceMaps.size === 0) { - return; + return 'no-match'; } const shared = getRawProfileSharedData(getState()); @@ -61,7 +69,7 @@ export function doSourceMapSymbolication( ); if (applied === null) { dispatch({ type: 'SOURCE_MAP_SYMBOLICATION_FAILED' }); - break; + return 'no-match'; } dispatch({ type: 'BULK_SOURCE_MAP_SYMBOLICATION', @@ -71,21 +79,112 @@ export function doSourceMapSymbolication( newSources: applied.newSources, newStringArray: applied.newStringArray, }); - break; + return 'applied'; } case 'error': console.warn('Source map worker error:', result.message); dispatch({ type: 'SOURCE_MAP_SYMBOLICATION_FAILED' }); - break; + return 'error'; case 'no-op': dispatch({ type: 'SOURCE_MAP_SYMBOLICATION_FAILED' }); - break; + return 'no-match'; default: throw assertExhaustiveCheck(result); } }; } +/** + * The ways applying a user-supplied `.map` file can fail. + */ +export type ApplySourceMapError = + | 'invalid-source-map' + | 'no-eligible-sources' + | 'symbolication-failed'; + +/** + * The outcome of applying a user-supplied `.map` file to the profile. + */ +export type ApplySourceMapFileResult = + // `filename` is the resolved bundle source the map was applied to, so the UI + // can show which source was matched (auto-matched or user-picked). + | { type: 'applied'; filename: string } + | { type: 'no-match'; filename: string } + | { type: 'ambiguous'; candidates: EligibleSource[] } + | { type: 'error'; error: ApplySourceMapError }; + +/** + * Apply a source map file that the user selected from disk. Parses the file, + * auto-matches it to a bundle source (unless `sourceIndex` is provided, e.g. + * after the user disambiguated in the picker), and feeds it into the existing + * source map symbolication pipeline. + */ +export function applySourceMapFile( + fileName: string, + fileContents: string, + // Set when the user picked a bundle in the picker; skips auto-matching. + sourceIndex?: IndexIntoSourceTable +): ThunkAction> { + return async (dispatch, getState) => { + const map = parseSourceMapFileContents(fileContents); + if (map === null) { + return { type: 'error', error: 'invalid-source-map' }; + } + + const shared = getRawProfileSharedData(getState()); + + let targetSourceIndex: IndexIntoSourceTable; + if (sourceIndex !== undefined) { + targetSourceIndex = sourceIndex; + } else { + const eligible = getSourcesWithSourceMapURL( + shared.sources, + shared.stringArray + ); + const result = matchSourceMapToSource(map, fileName, eligible); + switch (result.type) { + case 'no-eligible-sources': + return { type: 'error', error: 'no-eligible-sources' }; + case 'ambiguous': + return { type: 'ambiguous', candidates: result.candidates }; + case 'match': + targetSourceIndex = result.sourceIndex; + break; + default: + throw assertExhaustiveCheck(result); + } + } + + const filename = + shared.stringArray[shared.sources.filename[targetSourceIndex]]; + + // Feed the bundle's stored content as the compiled source when we have it, + // which enables full scope-tree name resolution. + const compiledContent = shared.sources.content[targetSourceIndex]; + const compiledSources = + compiledContent !== null + ? new Map([[targetSourceIndex, compiledContent]]) + : new Map(); + + const outcome = await dispatch( + doSourceMapSymbolication( + new Map([[targetSourceIndex, map]]), + compiledSources + ) + ); + switch (outcome) { + case 'applied': + return { type: 'applied', filename }; + case 'no-match': + return { type: 'no-match', filename }; + case 'error': + return { type: 'error', error: 'symbolication-failed' }; + default: + throw assertExhaustiveCheck(outcome); + } + }; +} + /** * Spawn a one-shot source map worker, send it the input, and return the * output. The worker is terminated once a response is received or an error diff --git a/src/components/app/MenuButtons/ApplySourceMapButton.tsx b/src/components/app/MenuButtons/ApplySourceMapButton.tsx new file mode 100644 index 0000000000..021bc38196 --- /dev/null +++ b/src/components/app/MenuButtons/ApplySourceMapButton.tsx @@ -0,0 +1,327 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as React from 'react'; +import { Localized } from '@fluent/react'; + +import explicitConnect from 'firefox-profiler/utils/connect'; +import { + getSourcesWithSourceMaps, + getSourceMapSymbolicationStatus, +} from 'firefox-profiler/selectors/profile'; +import { applySourceMapFile } from 'firefox-profiler/actions/source-map-symbolication'; +import type { ApplySourceMapError } from 'firefox-profiler/actions/source-map-symbolication'; +import { _fileReader } from 'firefox-profiler/actions/receive-profile'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; +import type { + IndexIntoSourceTable, + SourceMapSymbolicationStatus, +} from 'firefox-profiler/types'; +import { basename } from 'firefox-profiler/profile-logic/source-map-matching'; +import type { EligibleSource } from 'firefox-profiler/profile-logic/source-map-matching'; +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; + +type OwnProps = Readonly<{ + // Injectable for tests to bypass the DOM APIs. + fileReader?: typeof _fileReader; +}>; + +type StateProps = Readonly<{ + candidates: EligibleSource[]; + symbolicationStatus: SourceMapSymbolicationStatus; +}>; + +type DispatchProps = Readonly<{ + applySourceMapFile: typeof applySourceMapFile; +}>; + +type Props = ConnectedProps; + +type ApplyState = + | { phase: 'idle' } + | { phase: 'working' } + | { + phase: 'choosing'; + fileName: string; + fileContents: string; + candidates: EligibleSource[]; + selectedSourceIndex: IndexIntoSourceTable; + } + // `applied` distinguishes a successful symbolication from a run that matched + // a source but changed nothing (no stack positions mapped). `filename` is the + // bundle source the map was applied to, shown so the user can confirm the + // match was correct. + | { phase: 'done'; applied: boolean; filename: string } + | { phase: 'error'; error: ApplySourceMapError }; + +/** + * A button in the Profile Info panel that lets the user pick a source map file + * from disk and run JS source map symbolication against it. This covers the + * case where a profile carries sourceMapURLs but was loaded without a browser + * connection (e.g. from a file), so nothing was fetched automatically. + */ +class ApplySourceMapButtonImpl extends React.PureComponent { + override state: ApplyState = { phase: 'idle' }; + + _fileInput: HTMLInputElement | null = null; + + _takeInputRef = (input: HTMLInputElement | null) => { + this._fileInput = input; + }; + + _onButtonClick = () => { + if (this._fileInput) { + this._fileInput.click(); + } + }; + + _onFileChange = async () => { + const file = this._fileInput?.files?.[0]; + if (this._fileInput) { + // Reset so selecting the same file again re-triggers onChange. + this._fileInput.value = ''; + } + if (!file) { + return; + } + + const fileReader = this.props.fileReader ?? _fileReader; + let text: string; + try { + text = await fileReader(file).asText(); + } catch (error) { + console.error('Failed to read the selected source map file:', error); + this.setState({ phase: 'error', error: 'invalid-source-map' }); + return; + } + + await this._run(file.name, text); + }; + + _onSelectChange = (event: React.ChangeEvent) => { + const selectedSourceIndex = Number(event.currentTarget.value); + this.setState((state) => + state.phase === 'choosing' ? { ...state, selectedSourceIndex } : state + ); + }; + + _onConfirmSource = async () => { + const state = this.state; + if (state.phase !== 'choosing') { + return; + } + await this._run( + state.fileName, + state.fileContents, + state.selectedSourceIndex + ); + }; + + _onCancel = () => { + this.setState({ phase: 'idle' }); + }; + + async _run( + fileName: string, + fileContents: string, + sourceIndex?: IndexIntoSourceTable + ) { + this.setState({ phase: 'working' }); + const result = await this.props.applySourceMapFile( + fileName, + fileContents, + sourceIndex + ); + switch (result.type) { + case 'applied': + this.setState({ + phase: 'done', + applied: true, + filename: result.filename, + }); + break; + case 'no-match': + this.setState({ + phase: 'done', + applied: false, + filename: result.filename, + }); + break; + case 'ambiguous': + this.setState({ + phase: 'choosing', + fileName, + fileContents, + candidates: result.candidates, + selectedSourceIndex: result.candidates[0].sourceIndex, + }); + break; + case 'error': + this.setState({ phase: 'error', error: result.error }); + break; + default: + throw assertExhaustiveCheck(result); + } + } + + _renderStatus() { + const state = this.state; + switch (state.phase) { + case 'idle': + case 'working': + return null; + case 'done': + return ( +
+ + +
+ ); + case 'error': + return ( +
+ + +
+ ); + case 'choosing': + return this._renderSourcePicker(state); + default: + throw assertExhaustiveCheck(state); + } + } + + _renderSourcePicker(state: Extract) { + return ( +
+ +
+ +
+ Choose which bundle this source map applies to: +
+
+ +
+ + + + + + +
+
+
+ ); + } + + override render() { + const { candidates, symbolicationStatus } = this.props; + if (candidates.length === 0) { + return null; + } + + const busy = + this.state.phase === 'working' || symbolicationStatus === 'SYMBOLICATING'; + + return ( + <> +
+ + + Source maps: + + + + + + +
+ {this._renderStatus()} + + ); + } +} + +function _sourceMapErrorL10nId(error: ApplySourceMapError): string { + switch (error) { + case 'invalid-source-map': + return 'MenuButtons--metaInfo--source-map-error-invalid'; + case 'no-eligible-sources': + return 'MenuButtons--metaInfo--source-map-error-no-eligible'; + case 'symbolication-failed': + return 'MenuButtons--metaInfo--source-map-error-failed'; + default: + throw assertExhaustiveCheck(error); + } +} + +export const ApplySourceMapButton = explicitConnect< + OwnProps, + StateProps, + DispatchProps +>({ + mapStateToProps: (state) => ({ + candidates: getSourcesWithSourceMaps(state), + symbolicationStatus: getSourceMapSymbolicationStatus(state), + }), + mapDispatchToProps: { + applySourceMapFile, + }, + component: ApplySourceMapButtonImpl, +}); diff --git a/src/components/app/MenuButtons/MetaInfo.css b/src/components/app/MenuButtons/MetaInfo.css index c00f0606f5..182ac82f8a 100644 --- a/src/components/app/MenuButtons/MetaInfo.css +++ b/src/components/app/MenuButtons/MetaInfo.css @@ -98,3 +98,52 @@ .moreInfoValue { overflow-x: scroll; } + +.metaInfoSourceMapRow { + margin-top: 8px; +} + +.metaInfoSourceMapFileInput { + display: none; +} + +.metaInfoSourceMapStatus { + display: flex; + color: var(--grey-50); + + /* Keep the (possibly long) filename wrapping within the value column instead + * of flowing back under the label on the second line. overflow-wrap is + * inherited, so it reaches the message text node too. */ + overflow-wrap: anywhere; +} + +.metaInfoSourceMapStatus .metaInfoLabel { + flex-shrink: 0; +} + +.metaInfoSourceMapError { + color: var(--red-60); +} + +.metaInfoSourceMapPickerBody { + display: inline-block; + min-width: 0; +} + +.metaInfoSourceMapPickerLabel { + margin-bottom: 4px; +} + +.metaInfoSourceMapPickerSelect { + width: 100%; +} + +.metaInfoSourceMapPickerButtons { + display: flex; + margin-top: 6px; + gap: 8px; +} + +.metaInfoSourceMapPickerButtons > button { + flex: 1; +} diff --git a/src/components/app/MenuButtons/MetaInfo.tsx b/src/components/app/MenuButtons/MetaInfo.tsx index 799980acf4..0fd507e63d 100644 --- a/src/components/app/MenuButtons/MetaInfo.tsx +++ b/src/components/app/MenuButtons/MetaInfo.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import { Localized } from '@fluent/react'; import { MetaOverheadStatistics } from './MetaOverheadStatistics'; +import { ApplySourceMapButton } from './ApplySourceMapButton'; import { getProfile, getSymbolicationStatus, @@ -313,6 +314,7 @@ class MetaInfoPanelImpl extends React.PureComponent { ) : null} {this.renderSymbolication()} + ); } diff --git a/src/profile-logic/source-map-matching.ts b/src/profile-logic/source-map-matching.ts new file mode 100644 index 0000000000..4390347df8 --- /dev/null +++ b/src/profile-logic/source-map-matching.ts @@ -0,0 +1,187 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { IndexIntoSourceTable, SourceTable } from 'firefox-profiler/types'; +import type { RawSourceMap } from 'source-map'; + +/** + * A source row that already carries a sourceMapURL, and is therefore eligible + * to have a user-supplied `.map` file applied to it. + */ +export type EligibleSource = { + sourceIndex: IndexIntoSourceTable; + filename: string; + sourceMapURL: string; +}; + +/** + * The outcome of trying to auto-match an uploaded source map file to a bundle + * source in the profile. + */ +export type SourceMapMatchResult = + | { type: 'match'; sourceIndex: IndexIntoSourceTable } + | { type: 'ambiguous'; candidates: EligibleSource[] } + | { type: 'no-eligible-sources' }; + +/** + * Return every source row that has a non-null sourceMapURL. Unlike the + * WebChannel fetch path, a UUID `id` is NOT required here: the user supplies + * the map file contents directly, so there is nothing to fetch. + */ +export function getSourcesWithSourceMapURL( + sources: SourceTable, + stringArray: string[] +): EligibleSource[] { + const eligible: EligibleSource[] = []; + for (let sourceIndex = 0; sourceIndex < sources.length; sourceIndex++) { + const sourceMapURLIndex = sources.sourceMapURL[sourceIndex]; + if (sourceMapURLIndex === null) { + continue; + } + eligible.push({ + sourceIndex, + filename: stringArray[sources.filename[sourceIndex]], + sourceMapURL: stringArray[sourceMapURLIndex], + }); + } + return eligible; +} + +/** + * Parse the text contents of a `.map` file into a RawSourceMap. Returns null + * for invalid JSON, for JSON that isn't a source map (missing version / + * mappings / sources), and for index maps (which carry a `sections` field and + * are not supported here). + */ +export function parseSourceMapFileContents(text: string): RawSourceMap | null { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + + if (parsed === null || typeof parsed !== 'object') { + return null; + } + + const map = parsed as Record; + + // Index maps use `sections` instead of top-level `mappings`. Reject them. + if ('sections' in map) { + return null; + } + + if ( + typeof map.version !== 'number' || + typeof map.mappings !== 'string' || + !Array.isArray(map.sources) + ) { + return null; + } + + return parsed as RawSourceMap; +} + +/** + * Strip a `?query` and `#hash` from a URL-ish string, then take the last path + * segment. Handles both `/` (URLs, POSIX) and `\` (Windows) separators. + */ +export function basename(urlOrPath: string): string { + let s = urlOrPath; + const queryIndex = s.search(/[?#]/); + if (queryIndex !== -1) { + s = s.slice(0, queryIndex); + } + const lastSlash = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\')); + return lastSlash === -1 ? s : s.slice(lastSlash + 1); +} + +// Remove `suffix` from the end of `s` if present. +function stripSuffix(s: string, suffix: string): string { + return s.endsWith(suffix) ? s.slice(0, -suffix.length) : s; +} + +/** + * Find the eligible sources whose given field basename matches `target`. Prefer + * an exact (case-sensitive) match; if there are none, fall back to a + * case-insensitive match. + */ +function matchByBasename( + target: string, + eligible: EligibleSource[], + getField: (source: EligibleSource) => string +): EligibleSource[] { + const exact = eligible.filter( + (source) => basename(getField(source)) === target + ); + if (exact.length > 0) { + return exact; + } + const targetLower = target.toLowerCase(); + return eligible.filter( + (source) => basename(getField(source)).toLowerCase() === targetLower + ); +} + +/** + * Given an uploaded `.map` file, decide which eligible bundle source it belongs + * to. + * + * All we have to go on is the uploaded file's name and its parsed contents, so + * matching is heuristic: we compare basenames across a series of increasingly + * lenient criteria (see `criteria` below) and take the first that lands on a + * single source. More than one hit is `ambiguous` (the caller asks the user to + * pick); no hit under any criterion falls through to `ambiguous` over all + * eligible sources. + * + * The two trivial cases short-circuit first: zero eligible sources, or exactly + * one, which we match unconditionally regardless of name. + */ +export function matchSourceMapToSource( + map: RawSourceMap, + uploadedFileName: string, + eligible: EligibleSource[] +): SourceMapMatchResult { + if (eligible.length === 0) { + return { type: 'no-eligible-sources' }; + } + + if (eligible.length === 1) { + return { type: 'match', sourceIndex: eligible[0].sourceIndex }; + } + + const uploadedBasename = basename(uploadedFileName); + // Browsers append ".json" when saving a map served as application/json, e.g. + // "index.js.map" -> "index.js.map.json"; strip it back off. + const uploadedBasenameNoJson = stripSuffix(uploadedBasename, '.json'); + // Also drop the ".map" to compare against the bundle's own filename, e.g. + // "bundle.js.map" -> "bundle.js". + const uploadedBasenameNoMap = stripSuffix(uploadedBasenameNoJson, '.map'); + + // Ordered [target basename, field extractor] pairs, most to least reliable. + // The first pair yielding exactly one hit wins. + const criteria: Array<[string, (source: EligibleSource) => string]> = [ + [uploadedBasename, (source) => source.sourceMapURL], + [uploadedBasenameNoJson, (source) => source.sourceMapURL], + [uploadedBasenameNoMap, (source) => source.filename], + ]; + + // Last resort: the map's own `file` field vs the bundle filename. + if (typeof map.file === 'string' && map.file !== '') { + criteria.push([basename(map.file), (source) => source.filename]); + } + + for (const [target, getField] of criteria) { + const hits = matchByBasename(target, eligible, getField); + if (hits.length === 1) { + return { type: 'match', sourceIndex: hits[0].sourceIndex }; + } + if (hits.length > 1) { + return { type: 'ambiguous', candidates: hits }; + } + } + + return { type: 'ambiguous', candidates: eligible }; +} diff --git a/src/selectors/profile.ts b/src/selectors/profile.ts index 819daabe65..bbc1dabec3 100644 --- a/src/selectors/profile.ts +++ b/src/selectors/profile.ts @@ -34,6 +34,8 @@ import { getDefaultCategories } from 'firefox-profiler/profile-logic/data-struct import * as CommittedRanges from '../profile-logic/committed-ranges'; import { defaultTableViewOptions } from '../reducers/profile-view'; import { StringTable } from '../utils/string-table'; +import { getSourcesWithSourceMapURL } from '../profile-logic/source-map-matching'; +import type { EligibleSource } from '../profile-logic/source-map-matching'; import type { TabSlug } from '../app-logic/tabs-handling'; import type { @@ -318,6 +320,11 @@ export const getMarkerSchema: Selector = createSelector( export const getMarkerSchemaByName: Selector = createSelector(getMarkerSchema, computeMarkerSchemaByName); +export const getSourcesWithSourceMaps: Selector = + createSelector(getRawProfileSharedData, ({ sources, stringArray }) => + getSourcesWithSourceMapURL(sources, stringArray) + ); + type CounterSelectors = ReturnType; const _counterSelectors: { [key: number]: CounterSelectors } = {}; diff --git a/src/test/components/ApplySourceMapButton.test.tsx b/src/test/components/ApplySourceMapButton.test.tsx new file mode 100644 index 0000000000..1a390c86cf --- /dev/null +++ b/src/test/components/ApplySourceMapButton.test.tsx @@ -0,0 +1,219 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { fireEvent, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; + +import { render } from 'firefox-profiler/test/fixtures/testing-library'; +import { ApplySourceMapButton } from 'firefox-profiler/components/app/MenuButtons/ApplySourceMapButton'; +import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; +import { storeWithProfile } from '../fixtures/stores'; + +import type { Profile } from 'firefox-profiler/types'; +import type { EligibleSource } from 'firefox-profiler/profile-logic/source-map-matching'; + +// Keep the rest of the module real (receive-profile imports +// doSourceMapSymbolication from here), but stub the action creator so we can +// assert the component wiring without spinning up the worker. +jest.mock('firefox-profiler/actions/source-map-symbolication', () => ({ + ...jest.requireActual('firefox-profiler/actions/source-map-symbolication'), + applySourceMapFile: jest.fn(), +})); +import { applySourceMapFile } from 'firefox-profiler/actions/source-map-symbolication'; + +type SourceDescriptor = { filename: string; sourceMapURL: string }; + +// Build a profile with one JS source per descriptor, each carrying a +// sourceMapURL so it becomes a candidate for file-based symbolication. +function buildProfileWithSources(descs: SourceDescriptor[]): Profile { + const textSamples = descs.map( + (d, i) => `${String.fromCharCode(65 + i)}js[file:${d.filename}]` + ); + const { profile } = getProfileFromTextSamples(...textSamples); + const { sources, stringArray } = profile.shared; + for (const d of descs) { + const filenameStrIdx = stringArray.indexOf(d.filename); + const sourceIndex = sources.filename.findIndex((f) => f === filenameStrIdx); + const urlIdx = stringArray.length; + stringArray.push(d.sourceMapURL); + sources.sourceMapURL[sourceIndex] = urlIdx; + } + return profile; +} + +function sourceIndexOf(profile: Profile, filename: string): number { + const { sources, stringArray } = profile.shared; + const strIdx = stringArray.indexOf(filename); + return sources.filename.findIndex((f) => f === strIdx); +} + +// A fileReader stub matching the shape returned by receive-profile's +// _fileReader, resolving asText() to the given contents. +function makeFileReader(text: string) { + return (_file: File) => ({ + asText: () => Promise.resolve(text), + asArrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + }); +} + +function selectFile(fileName: string) { + const input = document.querySelector( + '.metaInfoSourceMapFileInput' + ) as HTMLInputElement; + const file = new File(['ignored'], fileName, { type: 'application/json' }); + fireEvent.change(input, { target: { files: [file] } }); +} + +describe('ApplySourceMapButton', function () { + const mockedApply = applySourceMapFile as jest.Mock; + + beforeEach(() => { + mockedApply.mockReset(); + // The action returns a thunk; default it to a successful apply. + mockedApply.mockReturnValue(() => + Promise.resolve({ type: 'applied', filename: 'app.min.js' }) + ); + }); + + function setup(descs: SourceDescriptor[], fileText: string = '{}') { + const profile = buildProfileWithSources(descs); + const store = storeWithProfile(profile); + render( + + + + ); + return { profile }; + } + + it('renders nothing when no source has a source map', function () { + const { profile } = getProfileFromTextSamples('Ajs[file:bundle.js]'); + const store = storeWithProfile(profile); + render( + + + + ); + expect(screen.queryByText(/Apply source map/)).not.toBeInTheDocument(); + }); + + it('renders the button when a source has a source map', function () { + setup([{ filename: 'app.min.js', sourceMapURL: 'app.min.js.map' }]); + expect(screen.getByText(/Apply source map/)).toBeInTheDocument(); + }); + + it('shows a success message when symbolication applies results', async function () { + const { profile } = setup([ + { filename: 'app.min.js', sourceMapURL: 'app.min.js.map' }, + ]); + + selectFile('app.min.js.map'); + + // Fluent wraps the interpolated filename in bidi isolation marks, so match + // the phrase with a regex and check the filename via textContent. + const status = await screen.findByText(/Original sources resolved for/); + expect(status).toHaveTextContent('app.min.js'); + expect(mockedApply).toHaveBeenCalledTimes(1); + // (fileName, fileContents, sourceIndex?) — auto-match leaves sourceIndex + // undefined; the thunk resolves it. + expect(mockedApply.mock.calls[0][0]).toBe('app.min.js.map'); + expect(mockedApply.mock.calls[0][2]).toBeUndefined(); + // The connected component still holds a real profile with the source. + expect(sourceIndexOf(profile, 'app.min.js')).toBeGreaterThanOrEqual(0); + }); + + it('reports when the map matched nothing', async function () { + mockedApply.mockReturnValue(() => + Promise.resolve({ type: 'no-match', filename: 'app.min.js' }) + ); + setup([{ filename: 'app.min.js', sourceMapURL: 'app.min.js.map' }]); + + selectFile('app.min.js.map'); + + const status = await screen.findByText(/matched this source map/); + expect(status).toHaveTextContent('app.min.js'); + }); + + it('shows a select picker on ambiguity, then symbolicates the chosen source', async function () { + const { profile } = setup([ + { filename: 'app.min.js', sourceMapURL: 'app.min.js.map' }, + { filename: 'vendor.min.js', sourceMapURL: 'vendor.min.js.map' }, + ]); + const appIndex = sourceIndexOf(profile, 'app.min.js'); + const vendorIndex = sourceIndexOf(profile, 'vendor.min.js'); + const candidates: EligibleSource[] = [ + { + sourceIndex: appIndex, + filename: 'app.min.js', + sourceMapURL: 'app.min.js.map', + }, + { + sourceIndex: vendorIndex, + filename: 'vendor.min.js', + sourceMapURL: 'vendor.min.js.map', + }, + ]; + mockedApply.mockReturnValueOnce(() => + Promise.resolve({ type: 'ambiguous', candidates }) + ); + mockedApply.mockReturnValueOnce(() => + Promise.resolve({ type: 'applied', filename: 'vendor.min.js' }) + ); + + selectFile('unrelated-name.map'); + + const select = (await screen.findByRole('combobox')) as HTMLSelectElement; + expect(mockedApply).toHaveBeenCalledTimes(1); + + fireEvent.change(select, { target: { value: String(vendorIndex) } }); + fireEvent.click(screen.getByText('Apply')); + + const status = await screen.findByText(/Original sources resolved for/); + expect(status).toHaveTextContent('vendor.min.js'); + expect(mockedApply).toHaveBeenCalledTimes(2); + expect(mockedApply.mock.calls[1][2]).toBe(vendorIndex); + }); + + it('lets the user cancel the source picker', async function () { + const { profile } = setup([ + { filename: 'app.min.js', sourceMapURL: 'app.min.js.map' }, + { filename: 'vendor.min.js', sourceMapURL: 'vendor.min.js.map' }, + ]); + mockedApply.mockReturnValueOnce(() => + Promise.resolve({ + type: 'ambiguous', + candidates: [ + { + sourceIndex: sourceIndexOf(profile, 'app.min.js'), + filename: 'app.min.js', + sourceMapURL: 'app.min.js.map', + }, + { + sourceIndex: sourceIndexOf(profile, 'vendor.min.js'), + filename: 'vendor.min.js', + sourceMapURL: 'vendor.min.js.map', + }, + ], + }) + ); + + selectFile('unrelated-name.map'); + + fireEvent.click(await screen.findByText('Cancel')); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + }); + + it('reports a parse error for an invalid source map file', async function () { + mockedApply.mockReturnValue(() => + Promise.resolve({ type: 'error', error: 'invalid-source-map' }) + ); + setup([{ filename: 'app.min.js', sourceMapURL: 'app.min.js.map' }]); + + selectFile('app.min.js.map'); + + expect( + await screen.findByText(/not a valid source map/) + ).toBeInTheDocument(); + }); +}); diff --git a/src/test/store/source-map-symbolication.test.ts b/src/test/store/source-map-symbolication.test.ts index 05f0fd8048..3166fcc324 100644 --- a/src/test/store/source-map-symbolication.test.ts +++ b/src/test/store/source-map-symbolication.test.ts @@ -22,6 +22,7 @@ import { SourceMapGenerator } from 'source-map'; import { runSourceMapSymbolicationCore } from '../../profile-logic/source-map-symbolication'; import { loadProfile } from '../../actions/receive-profile'; +import { applySourceMapFile } from '../../actions/source-map-symbolication'; import { getSourceMapSymbolicationStatus, getRawProfileSharedData, @@ -470,4 +471,144 @@ describe('receive-profile -> JS source map symbolication', function () { }); }); }); + + describe('applySourceMapFile (file upload path)', function () { + // Load a profile into a store without any auto source-map symbolication + // (browser connection can't fetch maps), so applySourceMapFile drives the + // whole flow. + async function loadWithoutAutoSymbolication(profile: Profile) { + const browserConnection = makeMockBrowserConnection({ + supportsSourceMapFetching: false, + }); + const { dispatch, getState } = blankStore(); + await dispatch(loadProfile(profile, { browserConnection })); + return { dispatch, getState }; + } + + it('applies a matching map to the single eligible source', async function () { + const profile = makeProfileWithJsSources([ + { + filename: 'bundle.js', + id: null, + sourceMapURL: 'https://example.com/bundle.js.map', + }, + ]); + const { dispatch, getState } = + await loadWithoutAutoSymbolication(profile); + + const map = buildSourceMap('bundle.js'); + const result = await dispatch( + applySourceMapFile('bundle.js.map', JSON.stringify(map)) + ); + + expect(result).toEqual({ type: 'applied', filename: 'bundle.js' }); + + const { funcTable, frameTable, stringArray } = + getRawProfileSharedData(getState()); + expect(stringArray[funcTable.name[0]]).toBe('greet'); + expect(frameTable.originalLocation[0]).not.toBeNull(); + }); + + it('returns ambiguous for two eligible sources with a non-matching name, then applies after a pick', async function () { + const profile = makeProfileWithJsSources([ + { + filename: 'a.js', + id: null, + sourceMapURL: 'https://example.com/a.js.map', + }, + { + filename: 'b.js', + id: null, + sourceMapURL: 'https://example.com/b.js.map', + }, + ]); + const { dispatch, getState } = + await loadWithoutAutoSymbolication(profile); + + // A bundle filename that matches neither source filename nor either + // sourceMapURL basename, so auto-matching genuinely can't decide. + const map = buildSourceMap('nomatch.js'); + const fileContents = JSON.stringify(map); + const result = await dispatch( + applySourceMapFile('unrelated.map', fileContents) + ); + + expect(result.type).toBe('ambiguous'); + + // Profile unchanged so far. + let { funcTable, stringArray } = getRawProfileSharedData(getState()); + expect(stringArray[funcTable.name[0]]).toBe('Ajs'); + + if (result.type !== 'ambiguous') { + throw new Error('expected ambiguous'); + } + const candidateForA = result.candidates.find( + (c) => c.filename === 'a.js' + ); + expect(candidateForA).toBeDefined(); + + const applied = await dispatch( + applySourceMapFile( + 'unrelated.map', + fileContents, + candidateForA!.sourceIndex + ) + ); + expect(applied).toEqual({ type: 'applied', filename: 'a.js' }); + + ({ funcTable, stringArray } = getRawProfileSharedData(getState())); + expect(stringArray[funcTable.name[0]]).toBe('greet'); + }); + + it('returns invalid-source-map for garbage text', async function () { + const profile = makeProfileWithJsSources([ + { + filename: 'bundle.js', + id: null, + sourceMapURL: 'https://example.com/bundle.js.map', + }, + ]); + const { dispatch } = await loadWithoutAutoSymbolication(profile); + + const result = await dispatch( + applySourceMapFile('bundle.js.map', 'not a source map') + ); + expect(result).toEqual({ type: 'error', error: 'invalid-source-map' }); + }); + + it('is a no-op when the same map is applied twice', async function () { + const profile = makeProfileWithJsSources([ + { + filename: 'bundle.js', + id: null, + sourceMapURL: 'https://example.com/bundle.js.map', + }, + ]); + const { dispatch, getState } = + await loadWithoutAutoSymbolication(profile); + + const fileContents = JSON.stringify(buildSourceMap('bundle.js')); + await dispatch(applySourceMapFile('bundle.js.map', fileContents)); + + const firstName = + getRawProfileSharedData(getState()).stringArray[ + getRawProfileSharedData(getState()).funcTable.name[0] + ]; + expect(firstName).toBe('greet'); + + const secondResult = await dispatch( + applySourceMapFile('bundle.js.map', fileContents) + ); + // Re-applying is idempotent: applySourceMapSymbolicationResponse skips + // already-populated rows, so nothing new is applied and the action + // reports 'no-match' while leaving the profile untouched. + expect(secondResult.type).toBe('no-match'); + + const secondName = + getRawProfileSharedData(getState()).stringArray[ + getRawProfileSharedData(getState()).funcTable.name[0] + ]; + expect(secondName).toBe('greet'); + }); + }); }); diff --git a/src/test/unit/source-map-matching.test.ts b/src/test/unit/source-map-matching.test.ts new file mode 100644 index 0000000000..00dcefd319 --- /dev/null +++ b/src/test/unit/source-map-matching.test.ts @@ -0,0 +1,312 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + getSourcesWithSourceMapURL, + parseSourceMapFileContents, + matchSourceMapToSource, +} from '../../profile-logic/source-map-matching'; + +import type { SourceTable } from 'firefox-profiler/types'; +import type { RawSourceMap } from 'source-map'; + +// A minimal valid RawSourceMap, with an overridable `file` field. +function makeMap(file: string = ''): RawSourceMap { + return { + version: 3, + file, + sources: ['original.js'], + names: [], + mappings: '', + }; +} + +describe('getSourcesWithSourceMapURL', function () { + it('returns only sources with a non-null sourceMapURL, resolving strings', function () { + const stringArray = [ + 'a.js', // 0 + 'https://example.com/a.js.map', // 1 + 'b.js', // 2 + 'c.js', // 3 + 'https://example.com/c.js.map', // 4 + ]; + const sources: SourceTable = { + length: 3, + id: [null, null, null], + filename: [0, 2, 3], + startLine: [1, 1, 1], + startColumn: [0, 0, 0], + sourceMapURL: [1, null, 4], + content: [null, null, null], + }; + + expect(getSourcesWithSourceMapURL(sources, stringArray)).toEqual([ + { sourceIndex: 0, filename: 'a.js', sourceMapURL: stringArray[1] }, + { sourceIndex: 2, filename: 'c.js', sourceMapURL: stringArray[4] }, + ]); + }); + + it('does not require a UUID id', function () { + const stringArray = ['a.js', 'a.js.map']; + const sources: SourceTable = { + length: 1, + id: [null], + filename: [0], + startLine: [1], + startColumn: [0], + sourceMapURL: [1], + content: [null], + }; + expect(getSourcesWithSourceMapURL(sources, stringArray)).toHaveLength(1); + }); +}); + +describe('parseSourceMapFileContents', function () { + it('parses a valid source map', function () { + const map = makeMap('bundle.js'); + const parsed = parseSourceMapFileContents(JSON.stringify(map)); + expect(parsed).not.toBeNull(); + expect(parsed?.version).toBe(3); + }); + + it('returns null for invalid JSON', function () { + expect(parseSourceMapFileContents('{not json')).toBeNull(); + }); + + it('returns null for JSON that is not a source map', function () { + expect(parseSourceMapFileContents('{"foo": "bar"}')).toBeNull(); + expect(parseSourceMapFileContents('[]')).toBeNull(); + expect(parseSourceMapFileContents('"a string"')).toBeNull(); + expect(parseSourceMapFileContents('null')).toBeNull(); + }); + + it('rejects index maps (with a sections field)', function () { + const indexMap = JSON.stringify({ version: 3, sections: [] }); + expect(parseSourceMapFileContents(indexMap)).toBeNull(); + }); +}); + +describe('matchSourceMapToSource', function () { + it('returns no-eligible-sources when there are none', function () { + expect(matchSourceMapToSource(makeMap(), 'x.map', [])).toEqual({ + type: 'no-eligible-sources', + }); + }); + + it('matches the single eligible source', function () { + const eligible = [ + { sourceIndex: 3, filename: 'bundle.js', sourceMapURL: 'whatever.map' }, + ]; + expect( + matchSourceMapToSource(makeMap(), 'unrelated.map', eligible) + ).toEqual({ type: 'match', sourceIndex: 3 }); + }); + + it('matches by uploaded file name vs basename(sourceMapURL)', function () { + const eligible = [ + { + sourceIndex: 0, + filename: 'a.js', + sourceMapURL: 'https://example.com/dist/a.js.map', + }, + { + sourceIndex: 1, + filename: 'b.js', + sourceMapURL: 'https://example.com/dist/b.js.map', + }, + ]; + expect(matchSourceMapToSource(makeMap(), 'a.js.map', eligible)).toEqual({ + type: 'match', + sourceIndex: 0, + }); + }); + + it('strips ?query when comparing basenames', function () { + const eligible = [ + { + sourceIndex: 0, + filename: 'a.js', + sourceMapURL: 'https://example.com/a.js.map?v=123', + }, + { + sourceIndex: 1, + filename: 'b.js', + sourceMapURL: 'https://example.com/b.js.map?v=456', + }, + ]; + expect(matchSourceMapToSource(makeMap(), 'a.js.map', eligible)).toEqual({ + type: 'match', + sourceIndex: 0, + }); + }); + + it('matches case-insensitively when there is no exact hit', function () { + const eligible = [ + { + sourceIndex: 0, + filename: 'a.js', + sourceMapURL: 'https://example.com/A.JS.MAP', + }, + { + sourceIndex: 1, + filename: 'b.js', + sourceMapURL: 'https://example.com/b.js.map', + }, + ]; + expect(matchSourceMapToSource(makeMap(), 'a.js.map', eligible)).toEqual({ + type: 'match', + sourceIndex: 0, + }); + }); + + it('is ambiguous when the uploaded name matches multiple sourceMapURLs', function () { + const eligible = [ + { + sourceIndex: 0, + filename: 'a.js', + sourceMapURL: 'https://cdn1.example.com/bundle.js.map', + }, + { + sourceIndex: 1, + filename: 'b.js', + sourceMapURL: 'https://cdn2.example.com/bundle.js.map', + }, + { + sourceIndex: 2, + filename: 'c.js', + sourceMapURL: 'https://example.com/other.js.map', + }, + ]; + const result = matchSourceMapToSource(makeMap(), 'bundle.js.map', eligible); + expect(result).toEqual({ + type: 'ambiguous', + candidates: [eligible[0], eligible[1]], + }); + }); + + it('strips a trailing .map from the uploaded name to match the source filename', function () { + const eligible = [ + { + sourceIndex: 0, + filename: 'bundle.js', + sourceMapURL: 'https://example.com/xyz.map', + }, + { + sourceIndex: 1, + filename: 'other.js', + sourceMapURL: 'https://example.com/abc.map', + }, + ]; + // Uploaded "bundle.js.map" -> "bundle.js" matches source filename bundle.js. + expect( + matchSourceMapToSource(makeMap(), 'bundle.js.map', eligible) + ).toEqual({ type: 'match', sourceIndex: 0 }); + }); + + it('strips a browser-appended .json suffix to match the sourceMapURL', function () { + const eligible = [ + { + sourceIndex: 0, + filename: 'https://example.com/assets/index-XVVABR7J.js', + sourceMapURL: 'https://example.com/assets/index-XVVABR7J.js.map', + }, + { + sourceIndex: 1, + filename: 'https://example.com/assets/vendor-ABCDEFGH.js', + sourceMapURL: 'https://example.com/assets/vendor-ABCDEFGH.js.map', + }, + ]; + // The browser saved "index-XVVABR7J.js.map" (served as application/json) as + // "index-XVVABR7J.js.map.json". + expect( + matchSourceMapToSource(makeMap(), 'index-XVVABR7J.js.map.json', eligible) + ).toEqual({ type: 'match', sourceIndex: 0 }); + }); + + it('handles Windows-style backslash paths in basenames', function () { + const eligible = [ + { + sourceIndex: 0, + filename: 'a.js', + sourceMapURL: 'https://example.com/dist/a.js.map', + }, + { + sourceIndex: 1, + filename: 'b.js', + sourceMapURL: 'C:\\builds\\dist\\b.js.map', + }, + ]; + expect(matchSourceMapToSource(makeMap(), 'b.js.map', eligible)).toEqual({ + type: 'match', + sourceIndex: 1, + }); + }); + + it('matches by map.file vs basename(source.filename) when name misses', function () { + const eligible = [ + { + sourceIndex: 0, + filename: 'app.js', + sourceMapURL: 'https://example.com/app.min.js.map', + }, + { + sourceIndex: 1, + filename: 'vendor.js', + sourceMapURL: 'https://example.com/vendor.min.js.map', + }, + ]; + // Uploaded name matches no sourceMapURL basename; map.file points at app.js. + const result = matchSourceMapToSource( + makeMap('app.js'), + 'downloaded.map', + eligible + ); + expect(result).toEqual({ type: 'match', sourceIndex: 0 }); + }); + + it('is ambiguous when map.file matches multiple filenames', function () { + const eligible = [ + { + sourceIndex: 0, + filename: 'index.js', + sourceMapURL: 'https://cdn1.example.com/a.map', + }, + { + sourceIndex: 1, + filename: 'index.js', + sourceMapURL: 'https://cdn2.example.com/b.map', + }, + ]; + const result = matchSourceMapToSource( + makeMap('index.js'), + 'nomatch.map', + eligible + ); + expect(result).toEqual({ + type: 'ambiguous', + candidates: [eligible[0], eligible[1]], + }); + }); + + it('falls through to ambiguous with all eligible sources on zero hits', function () { + const eligible = [ + { + sourceIndex: 0, + filename: 'a.js', + sourceMapURL: 'https://example.com/a.js.map', + }, + { + sourceIndex: 1, + filename: 'b.js', + sourceMapURL: 'https://example.com/b.js.map', + }, + ]; + const result = matchSourceMapToSource( + makeMap('nope.js'), + 'totally-unrelated.map', + eligible + ); + expect(result).toEqual({ type: 'ambiguous', candidates: eligible }); + }); +}); diff --git a/src/test/unit/source-map-worker-mock.test.ts b/src/test/unit/source-map-worker-mock.test.ts index 8349526cfd..2aa09fda27 100644 --- a/src/test/unit/source-map-worker-mock.test.ts +++ b/src/test/unit/source-map-worker-mock.test.ts @@ -27,7 +27,8 @@ describe('source map worker stub', function () { }; // Without the worker mock, this dispatch never resolves. The stub worker - // responds with { type: 'no-op' } so the dispatch finishes cleanly. + // responds with { type: 'no-op' } so the dispatch finishes cleanly, + // resolving to 'no-match'. await expect( dispatch( doSourceMapSymbolication( @@ -35,6 +36,6 @@ describe('source map worker stub', function () { new Map([[0, 'function a(){}\n']]) ) ) - ).resolves.toBeUndefined(); + ).resolves.toBe('no-match'); }); });