From 1df3f44ca26d624a3c3c1aa7a84171333e3c1668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Thu, 16 Jul 2026 22:09:07 +0200 Subject: [PATCH 1/4] Make doSourceMapSymbolication report its result Change the return type from `Promise` to a `Promise<'applied' | 'no-match' | 'error'>` so callers can tell a valid-but-unmatched run apart from a worker failure. The existing load-time caller in receive-profile ignores the value, so this is behavior-neutral. The following file based symbolication path will use the result to show proper errors in its UI --- src/actions/receive-profile.ts | 4 +++- src/actions/source-map-symbolication.ts | 14 ++++++++------ src/test/unit/source-map-worker-mock.test.ts | 5 +++-- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/actions/receive-profile.ts b/src/actions/receive-profile.ts index e197bee440..43f2fd43ee 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, diff --git a/src/actions/source-map-symbolication.ts b/src/actions/source-map-symbolication.ts index 1ce7bdb9a6..fd8b8dd386 100644 --- a/src/actions/source-map-symbolication.ts +++ b/src/actions/source-map-symbolication.ts @@ -26,13 +26,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 +63,7 @@ export function doSourceMapSymbolication( ); if (applied === null) { dispatch({ type: 'SOURCE_MAP_SYMBOLICATION_FAILED' }); - break; + return 'no-match'; } dispatch({ type: 'BULK_SOURCE_MAP_SYMBOLICATION', @@ -71,15 +73,15 @@ 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); } 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'); }); }); From 1bce4ce4dff9f46f4a57964631e5b8dcb0c80b0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Thu, 16 Jul 2026 22:09:52 +0200 Subject: [PATCH 2/4] Add source-map file matching helpers Pure helpers for the upcoming "apply a .map file from disk" flow: - getSourcesWithSourceMapURL lists the profile sources that carry a sourceMapURL and are therefore eligible targets for a source map. - parseSourceMapFileContents parses and validates a .map file, rejecting index maps. - matchSourceMapToSource auto-matches a user-supplied map to a source by filename/sourceMapURL basename, returning an 'ambiguous' result (with candidates) when more than one source could match. Handles ?query, backslash paths, and the ".map"/".json" suffixes browsers append when saving a map served as JSON. --- src/profile-logic/source-map-matching.ts | 187 +++++++++++++ src/test/unit/source-map-matching.test.ts | 312 ++++++++++++++++++++++ 2 files changed, 499 insertions(+) create mode 100644 src/profile-logic/source-map-matching.ts create mode 100644 src/test/unit/source-map-matching.test.ts 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/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 }); + }); +}); From af8d6794099832464f6e5486757b2125eb4b9e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Thu, 16 Jul 2026 22:10:02 +0200 Subject: [PATCH 3/4] Add applySourceMapFile action for user-supplied maps Add a thunk that takes a .map file the user selected from disk, parses and auto-matches it to an eligible bundle source (or reports the match as ambiguous so the UI can prompt), then runs it through the existing doSourceMapSymbolication pipeline. Unlike the WebChannel path, eligibility only requires a sourceMapURL (no UUID id), since the map contents are supplied directly rather than fetched. --- src/actions/source-map-symbolication.ts | 97 ++++++++++++ .../store/source-map-symbolication.test.ts | 141 ++++++++++++++++++ 2 files changed, 238 insertions(+) diff --git a/src/actions/source-map-symbolication.ts b/src/actions/source-map-symbolication.ts index fd8b8dd386..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'; @@ -88,6 +94,97 @@ export function doSourceMapSymbolication( }; } +/** + * 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/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'); + }); + }); }); From 6de4d0f276f4d15077727a5ae60b86467a588089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Thu, 16 Jul 2026 22:10:18 +0200 Subject: [PATCH 4/4] =?UTF-8?q?Add=20an=20"Apply=20source=20map=E2=80=A6"?= =?UTF-8?q?=20button=20to=20the=20Profile=20Info=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The profiler already has a full JS source-map symbolication pipeline, but it only runs at load time when the browser provides source maps over the WebChannel. Profiles loaded from a file (or whose maps the browser couldn't fetch) had no way to symbolicate JS stacks. This adds a button in the Profile Info panel that accepts a .map file from disk and feeds it into the applySourceMapFile pipeline. The button is shown whenever the profile has at least one source with a sourceMapURL. It reads the file, runs the apply action, and reports which source was resolved, prompting with a picker when the match is ambiguous and showing an error otherwise. --- locales/en-US/app.ftl | 22 ++ src/actions/receive-profile.ts | 2 +- .../app/MenuButtons/ApplySourceMapButton.tsx | 327 ++++++++++++++++++ src/components/app/MenuButtons/MetaInfo.css | 49 +++ src/components/app/MenuButtons/MetaInfo.tsx | 2 + src/selectors/profile.ts | 7 + .../components/ApplySourceMapButton.test.tsx | 219 ++++++++++++ 7 files changed, 627 insertions(+), 1 deletion(-) create mode 100644 src/components/app/MenuButtons/ApplySourceMapButton.tsx create mode 100644 src/test/components/ApplySourceMapButton.test.tsx 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 43f2fd43ee..4bbf0226cf 100644 --- a/src/actions/receive-profile.ts +++ b/src/actions/receive-profile.ts @@ -1158,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/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/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(); + }); +});