From 813f0b499747a394c4e6358a22f73507e6b02d3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Thu, 16 Jul 2026 22:50:29 +0200 Subject: [PATCH 1/5] Make the source map symbolication runner injectable Added a SourceMapRunner type and an optional `run` argument to doSourceMapSymbolication through applySourceMapFile, to make the source map symbolication runner injectable. The default behavior stays as the Web Worker runner, but let's the non-browser callers inject a runner that calls the core logic directly, since the profiler-cli Node daemon has no Workers. --- src/actions/source-map-symbolication.ts | 26 +++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/actions/source-map-symbolication.ts b/src/actions/source-map-symbolication.ts index 009569d9e5..a90c2124dd 100644 --- a/src/actions/source-map-symbolication.ts +++ b/src/actions/source-map-symbolication.ts @@ -21,8 +21,9 @@ import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; /** * Run source map symbolication using previously-fetched source maps from Redux - * state. Offloads source parsing and source-map lookups to a dedicated Web - * Worker so the main thread stays responsive. + * state. By default, offloads source parsing and source-map lookups to a + * dedicated Web Worker so the main thread stays responsive. Pass `run` to + * substitute another runner (see `SourceMapRunner`). * * Reads the current profile from Redux state at dispatch time. Callers must * ensure native symbolication has already committed its changes before @@ -34,9 +35,18 @@ import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; */ export type SourceMapSymbolicationResult = 'applied' | 'no-match' | 'error'; +/** + * Runs the source map symbolication core and returns its output. The browser + * default (`_runSourceMapWorker`) offloads to a Web Worker; other environments + * (for example, profiler-cli node daemon) inject a runner that calls + * `runSourceMapSymbolicationCore` directly. + */ +export type SourceMapRunner = (input: WorkerInput) => Promise; + export function doSourceMapSymbolication( resolvedSourceMaps: Map, - compiledSources: Map + compiledSources: Map, + run: SourceMapRunner = _runSourceMapWorker ): ThunkAction> { return async (dispatch, getState) => { if (resolvedSourceMaps.size === 0) { @@ -56,7 +66,7 @@ export function doSourceMapSymbolication( }; dispatch({ type: 'START_SOURCE_MAP_SYMBOLICATION' }); - const result = await _runSourceMapWorker(input); + const result = await run(input); switch (result.type) { case 'success': { // Apply against the current shared state (not the snapshot the worker @@ -123,7 +133,10 @@ export function applySourceMapFile( fileName: string, fileContents: string, // Set when the user picked a bundle in the picker; skips auto-matching. - sourceIndex?: IndexIntoSourceTable + sourceIndex?: IndexIntoSourceTable, + // Injected by non-browser callers (e.g. the Node daemon) that can't spawn a + // Web Worker. Browser callers omit it and get the default worker runner. + run?: SourceMapRunner ): ThunkAction> { return async (dispatch, getState) => { const map = parseSourceMapFileContents(fileContents); @@ -169,7 +182,8 @@ export function applySourceMapFile( const outcome = await dispatch( doSourceMapSymbolication( new Map([[targetSourceIndex, map]]), - compiledSources + compiledSources, + run ) ); switch (outcome) { From 295eee8ca8555a010720cb0d4c2e8540ead16cfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Thu, 30 Jul 2026 18:27:23 +0200 Subject: [PATCH 2/5] Report the matched source's index from applySourceMapFile `ApplySourceMapFileResult` only carried the resolved bundle source's filename, which is all the web UI needs. Add the source table index alongside it, so callers that identify sources by index rather than by name can report which source the map landed on. A follow-up uses this to add "sourcemap" commands to profiler-cli, which refers to sources by "src-N" handles. --- src/actions/source-map-symbolication.ts | 14 ++++++++------ .../components/ApplySourceMapButton.test.tsx | 18 +++++++++++++++--- .../store/source-map-symbolication.test.ts | 12 ++++++++++-- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/actions/source-map-symbolication.ts b/src/actions/source-map-symbolication.ts index a90c2124dd..601fafcfc7 100644 --- a/src/actions/source-map-symbolication.ts +++ b/src/actions/source-map-symbolication.ts @@ -116,10 +116,12 @@ export type ApplySourceMapError = * 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 } + // `sourceIndex` / `filename` identify the resolved bundle source the map was + // applied to, so callers can show which source was matched (auto-matched or + // user-picked). The web UI shows the filename; profiler-cli also needs the + // index to report its `src-N` handle. + | { type: 'applied'; sourceIndex: IndexIntoSourceTable; filename: string } + | { type: 'no-match'; sourceIndex: IndexIntoSourceTable; filename: string } | { type: 'ambiguous'; candidates: EligibleSource[] } | { type: 'error'; error: ApplySourceMapError }; @@ -188,9 +190,9 @@ export function applySourceMapFile( ); switch (outcome) { case 'applied': - return { type: 'applied', filename }; + return { type: 'applied', sourceIndex: targetSourceIndex, filename }; case 'no-match': - return { type: 'no-match', filename }; + return { type: 'no-match', sourceIndex: targetSourceIndex, filename }; case 'error': return { type: 'error', error: 'symbolication-failed' }; default: diff --git a/src/test/components/ApplySourceMapButton.test.tsx b/src/test/components/ApplySourceMapButton.test.tsx index 1a390c86cf..b891cb7784 100644 --- a/src/test/components/ApplySourceMapButton.test.tsx +++ b/src/test/components/ApplySourceMapButton.test.tsx @@ -72,7 +72,11 @@ describe('ApplySourceMapButton', function () { mockedApply.mockReset(); // The action returns a thunk; default it to a successful apply. mockedApply.mockReturnValue(() => - Promise.resolve({ type: 'applied', filename: 'app.min.js' }) + Promise.resolve({ + type: 'applied', + sourceIndex: 0, + filename: 'app.min.js', + }) ); }); @@ -125,7 +129,11 @@ describe('ApplySourceMapButton', function () { it('reports when the map matched nothing', async function () { mockedApply.mockReturnValue(() => - Promise.resolve({ type: 'no-match', filename: 'app.min.js' }) + Promise.resolve({ + type: 'no-match', + sourceIndex: 0, + filename: 'app.min.js', + }) ); setup([{ filename: 'app.min.js', sourceMapURL: 'app.min.js.map' }]); @@ -158,7 +166,11 @@ describe('ApplySourceMapButton', function () { Promise.resolve({ type: 'ambiguous', candidates }) ); mockedApply.mockReturnValueOnce(() => - Promise.resolve({ type: 'applied', filename: 'vendor.min.js' }) + Promise.resolve({ + type: 'applied', + sourceIndex: vendorIndex, + filename: 'vendor.min.js', + }) ); selectFile('unrelated-name.map'); diff --git a/src/test/store/source-map-symbolication.test.ts b/src/test/store/source-map-symbolication.test.ts index 3166fcc324..3ff1ce9e7d 100644 --- a/src/test/store/source-map-symbolication.test.ts +++ b/src/test/store/source-map-symbolication.test.ts @@ -501,7 +501,11 @@ describe('receive-profile -> JS source map symbolication', function () { applySourceMapFile('bundle.js.map', JSON.stringify(map)) ); - expect(result).toEqual({ type: 'applied', filename: 'bundle.js' }); + expect(result).toEqual({ + type: 'applied', + filename: 'bundle.js', + sourceIndex: expect.any(Number), + }); const { funcTable, frameTable, stringArray } = getRawProfileSharedData(getState()); @@ -554,7 +558,11 @@ describe('receive-profile -> JS source map symbolication', function () { candidateForA!.sourceIndex ) ); - expect(applied).toEqual({ type: 'applied', filename: 'a.js' }); + expect(applied).toEqual({ + type: 'applied', + filename: 'a.js', + sourceIndex: candidateForA!.sourceIndex, + }); ({ funcTable, stringArray } = getRawProfileSharedData(getState())); expect(stringArray[funcTable.name[0]]).toBe('greet'); From b3759c72e9a4a91b633f4fa431c025fd79ea4fd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Thu, 30 Jul 2026 18:27:57 +0200 Subject: [PATCH 3/5] Add "sourcemap sources" to profiler-cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lists the bundle sources that carry a `sourceMapURL` and are therefore eligible to have a `.map` applied to them -- the same set the web app's "Apply source map…" picker offers. Each gets a `src-N` handle, which is a direct index into `profile.shared.sources` and so is stable across sessions for the same profile, like `f-N`. Firefox stores an inline map's entire `data:` URL in the source table, so a "URL" can be megabytes of base64. Those are reported by media type and size instead, which keeps the payload out of both the text and `--json` output. The integration fixtures are pre-generated and committed because the profile builders are DOM-coupled and can't run in the node-env test process, so sourcemap-generator.ts regenerates them via a browser-env test. A follow-up adds "sourcemap apply", which consumes these handles. --- profiler-cli/README.md | 1 + profiler-cli/schemas.txt | 9 + profiler-cli/src/commands/sourcemap.ts | 33 ++ profiler-cli/src/daemon.ts | 7 + profiler-cli/src/formatters.ts | 33 ++ profiler-cli/src/index.ts | 3 + profiler-cli/src/output.ts | 3 + profiler-cli/src/protocol.ts | 11 +- .../src/test/fixtures/sourcemap-generator.ts | 175 +++++++++ .../src/test/fixtures/sourcemap/bundle.js.map | 1 + .../src/test/fixtures/sourcemap/garbage.map | 1 + .../src/test/fixtures/sourcemap/mystery.map | 1 + .../src/test/fixtures/sourcemap/no-map.json | 304 ++++++++++++++++ .../src/test/fixtures/sourcemap/single.json | 304 ++++++++++++++++ .../src/test/fixtures/sourcemap/two.json | 339 ++++++++++++++++++ .../src/test/integration/sourcemap.test.ts | 58 +++ .../src/test/unit/sourcemap-fixtures.test.ts | 41 +++ .../test/unit/sourcemap-formatting.test.ts | 102 ++++++ src/profile-query/README.md | 1 + src/profile-query/index.ts | 29 ++ src/profile-query/source-handle.ts | 35 ++ src/profile-query/source-map.ts | 40 +++ src/profile-query/types.ts | 31 ++ .../profile-query/source-map-location.test.ts | 57 +++ 24 files changed, 1618 insertions(+), 1 deletion(-) create mode 100644 profiler-cli/src/commands/sourcemap.ts create mode 100644 profiler-cli/src/test/fixtures/sourcemap-generator.ts create mode 100644 profiler-cli/src/test/fixtures/sourcemap/bundle.js.map create mode 100644 profiler-cli/src/test/fixtures/sourcemap/garbage.map create mode 100644 profiler-cli/src/test/fixtures/sourcemap/mystery.map create mode 100644 profiler-cli/src/test/fixtures/sourcemap/no-map.json create mode 100644 profiler-cli/src/test/fixtures/sourcemap/single.json create mode 100644 profiler-cli/src/test/fixtures/sourcemap/two.json create mode 100644 profiler-cli/src/test/integration/sourcemap.test.ts create mode 100644 profiler-cli/src/test/unit/sourcemap-fixtures.test.ts create mode 100644 profiler-cli/src/test/unit/sourcemap-formatting.test.ts create mode 100644 src/profile-query/source-handle.ts create mode 100644 src/profile-query/source-map.ts create mode 100644 src/test/unit/profile-query/source-map-location.test.ts diff --git a/profiler-cli/README.md b/profiler-cli/README.md index 2593de72ce..ef271b754c 100644 --- a/profiler-cli/README.md +++ b/profiler-cli/README.md @@ -54,6 +54,7 @@ profiler-cli filter push # Push a sticky sample filter (see fi profiler-cli filter pop [N] # Pop the last N filters (default: 1) profiler-cli filter list # List active filters for current thread profiler-cli filter clear # Remove all filters for current thread +profiler-cli sourcemap sources # List bundle sources eligible for a source map (src-N handles) profiler-cli status # Show session status (selected thread, zoom ranges, filters) profiler-cli stop # Stop current daemon profiler-cli stop # Stop a specific session diff --git a/profiler-cli/schemas.txt b/profiler-cli/schemas.txt index 9aff7cae56..f17b47deda 100644 --- a/profiler-cli/schemas.txt +++ b/profiler-cli/schemas.txt @@ -235,3 +235,12 @@ profiler-cli status --json rootRange: { start, end }, filterStacks: [{ threadHandle, filters: FilterEntry[] }] } + +profiler-cli sourcemap sources --json + { + type: "sourcemap-sources", + sources: [{ sourceHandle, sourceIndex, filename, sourceMap: SourceMapLocation }], + context: SessionContext + } + + SourceMapLocation = { kind: "url", url } | { kind: "inline", mediaType: string | null, byteLength } diff --git a/profiler-cli/src/commands/sourcemap.ts b/profiler-cli/src/commands/sourcemap.ts new file mode 100644 index 0000000000..c0b6f68721 --- /dev/null +++ b/profiler-cli/src/commands/sourcemap.ts @@ -0,0 +1,33 @@ +/* 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/. */ + +/** + * `profiler-cli sourcemap` command. + */ + +import type { Command } from 'commander'; +import { addGlobalOptions, runCommand } from './shared'; + +export function registerSourcemapCommand( + program: Command, + sessionDir: string +): void { + const sourcemap = program + .command('sourcemap') + .description('Apply source maps to de-minify JavaScript stacks'); + + addGlobalOptions( + sourcemap + .command('sources') + .description( + 'List bundle sources eligible for a source map (src-N handles)' + ) + ).action(async (opts) => { + await runCommand( + sessionDir, + { command: 'sourcemap', subcommand: 'sources' }, + opts + ); + }); +} diff --git a/profiler-cli/src/daemon.ts b/profiler-cli/src/daemon.ts index 109fa51f8d..d15c92d176 100644 --- a/profiler-cli/src/daemon.ts +++ b/profiler-cli/src/daemon.ts @@ -557,6 +557,13 @@ export class Daemon { default: throw assertExhaustiveCheck(command); } + case 'sourcemap': + switch (command.subcommand) { + case 'sources': + return this.querier.listSourceMapSources(); + default: + throw assertExhaustiveCheck(command); + } case 'status': return this.querier.getStatus(); default: diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index a3f0c08fc3..0c299eec6c 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -42,7 +42,11 @@ import type { CounterSummary, CounterListResult, CounterInfoResult, + SourceEntry, + SourceMapLocation, + SourceMapSourcesResult, } from './protocol'; +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; import { truncateFunctionName } from '../../src/profile-query/function-list'; import { describeSpec } from '../../src/profile-query/filter-stack'; import { @@ -2092,3 +2096,32 @@ export function formatThreadSelectResult( } return `Selected ${count} threads: ${result.threadHandle} (${names})`; } + +function describeSourceMapLocation(sourceMap: SourceMapLocation): string { + switch (sourceMap.kind) { + case 'url': + return sourceMap.url; + case 'inline': { + const mediaType = sourceMap.mediaType ?? 'unknown media type'; + return `inline data: URL, ${mediaType}, ${formatBytes(sourceMap.byteLength)}`; + } + default: + throw assertExhaustiveCheck(sourceMap); + } +} + +/** One `src-N filename (sourceMapURL: ...)` line. */ +function formatSourceEntry(entry: SourceEntry): string { + return ` ${entry.sourceHandle} ${entry.filename} (sourceMapURL: ${describeSourceMapLocation(entry.sourceMap)})`; +} + +export function formatSourceMapSourcesResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + if (result.sources.length === 0) { + return `${contextHeader}\n\nNo sources with a source map URL in this profile.`; + } + const lines = result.sources.map(formatSourceEntry); + return `${contextHeader}\n\nSources with source maps (${result.sources.length}):\n${lines.join('\n')}`; +} diff --git a/profiler-cli/src/index.ts b/profiler-cli/src/index.ts index 7d5a2a8986..eefbbf1a86 100644 --- a/profiler-cli/src/index.ts +++ b/profiler-cli/src/index.ts @@ -40,6 +40,7 @@ import { registerFunctionCommand } from './commands/function'; import { registerCounterCommand } from './commands/counter'; import { registerZoomCommand } from './commands/zoom'; import { registerFilterCommand } from './commands/filter'; +import { registerSourcemapCommand } from './commands/sourcemap'; import { registerSessionCommand } from './commands/session'; // Read session directory from environment (only place this is read) @@ -90,6 +91,7 @@ Examples: profiler-cli counter info c-0 profiler-cli zoom push 2.7,3.1 profiler-cli filter push --excludes-function f-184 + profiler-cli sourcemap sources profiler-cli status profiler-cli stop --all` ); @@ -193,6 +195,7 @@ Examples: registerCounterCommand(program, SESSION_DIR); registerZoomCommand(program, SESSION_DIR); registerFilterCommand(program, SESSION_DIR); + registerSourcemapCommand(program, SESSION_DIR); registerSessionCommand(program, SESSION_DIR); try { diff --git a/profiler-cli/src/output.ts b/profiler-cli/src/output.ts index 601ba0a426..5e96ca83c0 100644 --- a/profiler-cli/src/output.ts +++ b/profiler-cli/src/output.ts @@ -31,6 +31,7 @@ import { formatThreadSelectResult, formatCounterListResult, formatCounterInfoResult, + formatSourceMapSourcesResult, } from './formatters'; /** @@ -97,6 +98,8 @@ export function formatOutput( return formatCounterListResult(result); case 'counter-info': return formatCounterInfoResult(result); + case 'sourcemap-sources': + return formatSourceMapSourcesResult(result); default: throw assertExhaustiveCheck(result); } diff --git a/profiler-cli/src/protocol.ts b/profiler-cli/src/protocol.ts index 3cc416d002..43c685783e 100644 --- a/profiler-cli/src/protocol.ts +++ b/profiler-cli/src/protocol.ts @@ -59,6 +59,9 @@ export type { CounterSummary, CounterListResult, CounterInfoResult, + SourceEntry, + SourceMapLocation, + SourceMapSourcesResult, } from '../../src/profile-query/types'; export type { CallTreeCollectionOptions } from '../../src/profile-query/formatters/call-tree'; @@ -92,6 +95,7 @@ import type { ThreadSelectResult, CounterListResult, CounterInfoResult, + SourceMapSourcesResult, } from '../../src/profile-query/types'; import type { CallTreeCollectionOptions } from '../../src/profile-query/formatters/call-tree'; @@ -186,6 +190,10 @@ export type ClientCommand = spec?: SampleFilterSpec; count?: number; } + | { + command: 'sourcemap'; + subcommand: 'sources'; + } | { command: 'status' }; export type ServerResponse = @@ -221,7 +229,8 @@ export type CommandResult = | WithContext | WithContext | WithContext - | WithContext; + | WithContext + | WithContext; export interface SessionMetadata { id: string; diff --git a/profiler-cli/src/test/fixtures/sourcemap-generator.ts b/profiler-cli/src/test/fixtures/sourcemap-generator.ts new file mode 100644 index 0000000000..7beec20c5b --- /dev/null +++ b/profiler-cli/src/test/fixtures/sourcemap-generator.ts @@ -0,0 +1,175 @@ +/* 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/. */ + +/** + * Generator for the static `sourcemap` integration fixtures. + * + * The integration tests spawn the real CLI binary in a Node environment that + * can't import the DOM-coupled profile builders, so the fixtures are + * pre-generated into `sourcemap/` and committed. Regenerate them by running + * the browser-env test `profiler-cli/src/test/unit/sourcemap-fixtures.test.ts`, + * which calls `generateSourcemapFixtures` below. + * + * The setup mirrors src/test/store/source-map-symbolication.test.ts: a minified + * bundle plus a source map that de-minifies `a` -> `greet`, and a profile whose + * JS funcs / frames sit at the bundle positions the map covers. + */ + +import { writeFileSync } from 'fs'; +import { join } from 'path'; +import { SourceMapGenerator } from 'source-map'; + +import { getProfileFromTextSamples } from 'firefox-profiler/test/fixtures/profiles/processed-profile'; +import { serializeProfileToJsonString } from 'firefox-profiler/profile-logic/process-profile'; + +import type { Profile } from 'firefox-profiler/types'; +import type { RawSourceMap } from 'source-map'; + +const ORIGINAL_SOURCE = `function greet(name) { + return "Hello, " + name; +} +`; + +// The mappings below address positions in the minified single-line bundle +// `function a(b){return"Hello, "+b}` (`greet` -> `a`, `name` -> `b`). + +const ORIGINAL_FILENAME = 'hello.js'; + +/** Build a source map for BUNDLE_SOURCE that resolves `a` back to `greet`. */ +function buildSourceMap(bundleFilename: string): RawSourceMap { + const gen = new SourceMapGenerator({ file: bundleFilename }); + gen.setSourceContent(ORIGINAL_FILENAME, ORIGINAL_SOURCE); + gen.addMapping({ + source: ORIGINAL_FILENAME, + original: { line: 1, column: 0 }, + generated: { line: 1, column: 0 }, + name: 'greet', + }); + gen.addMapping({ + source: ORIGINAL_FILENAME, + original: { line: 1, column: 9 }, + generated: { line: 1, column: 9 }, + name: 'greet', + }); + gen.addMapping({ + source: ORIGINAL_FILENAME, + original: { line: 2, column: 2 }, + generated: { line: 1, column: 14 }, + }); + return JSON.parse(gen.toString()) as RawSourceMap; +} + +type SourceDescriptor = { + filename: string; + sourceMapURL: string | null; +}; + +/** + * Build a profile with one JS func per source descriptor, each positioned in + * BUNDLE_SOURCE so applying buildSourceMap renames its func to `greet`. + */ +function makeProfileWithJsSources(sources: SourceDescriptor[]): Profile { + const textSamples = sources.map((s) => `Ajs[file:${s.filename}]`); + const { profile } = getProfileFromTextSamples(...textSamples); + // Skip native symbolication — we only exercise JS source map symbolication. + profile.meta.symbolicated = true; + + const { + funcTable, + frameTable, + sources: sourceTable, + stringArray, + } = profile.shared; + + for (const desc of sources) { + const filenameStrIdx = stringArray.indexOf(desc.filename); + const sourceIndex = sourceTable.filename.findIndex( + (f) => f === filenameStrIdx + ); + if (sourceIndex === -1) { + throw new Error(`No source row for ${desc.filename}`); + } + if (desc.sourceMapURL !== null) { + const urlIdx = stringArray.length; + stringArray.push(desc.sourceMapURL); + sourceTable.sourceMapURL[sourceIndex] = urlIdx; + } else { + sourceTable.sourceMapURL[sourceIndex] = null; + } + } + + for (let i = 0; i < sources.length; i++) { + funcTable.lineNumber[i] = 1; + funcTable.columnNumber[i] = 10; + frameTable.line[i] = 1; + frameTable.column[i] = 15; + } + + return profile; +} + +/** + * Write every static fixture into `outDir` (the committed `sourcemap/` + * directory). See file header for how to run this. + */ +export function generateSourcemapFixtures(outDir: string): void { + writeFileSync( + join(outDir, 'single.json'), + serializeProfileToJsonString( + makeProfileWithJsSources([ + { + filename: 'bundle.js', + sourceMapURL: 'https://example.com/bundle.js.map', + }, + ]) + ), + 'utf8' + ); + + writeFileSync( + join(outDir, 'two.json'), + serializeProfileToJsonString( + makeProfileWithJsSources([ + { + filename: 'bundle-a.js', + sourceMapURL: 'https://example.com/bundle-a.js.map', + }, + { + filename: 'bundle-b.js', + sourceMapURL: 'https://example.com/bundle-b.js.map', + }, + ]) + ), + 'utf8' + ); + + writeFileSync( + join(outDir, 'no-map.json'), + serializeProfileToJsonString( + makeProfileWithJsSources([{ filename: 'plain.js', sourceMapURL: null }]) + ), + 'utf8' + ); + + // Matches the single-source bundle by basename. + writeFileSync( + join(outDir, 'bundle.js.map'), + JSON.stringify(buildSourceMap('bundle.js')), + 'utf8' + ); + + // Matches neither eligible source in two.json, forcing the ambiguous path. + writeFileSync( + join(outDir, 'mystery.map'), + JSON.stringify(buildSourceMap('mystery.js')), + 'utf8' + ); + + // Valid JSON that isn't a source map. + writeFileSync( + join(outDir, 'garbage.map'), + JSON.stringify({ thisIsNot: 'a source map' }), + 'utf8' + ); +} diff --git a/profiler-cli/src/test/fixtures/sourcemap/bundle.js.map b/profiler-cli/src/test/fixtures/sourcemap/bundle.js.map new file mode 100644 index 0000000000..a845f2350b --- /dev/null +++ b/profiler-cli/src/test/fixtures/sourcemap/bundle.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["hello.js"],"names":["greet"],"mappings":"AAAAA,SAASA,KACP","file":"bundle.js","sourcesContent":["function greet(name) {\n return \"Hello, \" + name;\n}\n"]} \ No newline at end of file diff --git a/profiler-cli/src/test/fixtures/sourcemap/garbage.map b/profiler-cli/src/test/fixtures/sourcemap/garbage.map new file mode 100644 index 0000000000..ff1313d382 --- /dev/null +++ b/profiler-cli/src/test/fixtures/sourcemap/garbage.map @@ -0,0 +1 @@ +{"thisIsNot":"a source map"} \ No newline at end of file diff --git a/profiler-cli/src/test/fixtures/sourcemap/mystery.map b/profiler-cli/src/test/fixtures/sourcemap/mystery.map new file mode 100644 index 0000000000..301e21ccee --- /dev/null +++ b/profiler-cli/src/test/fixtures/sourcemap/mystery.map @@ -0,0 +1 @@ +{"version":3,"sources":["hello.js"],"names":["greet"],"mappings":"AAAAA,SAASA,KACP","file":"mystery.js","sourcesContent":["function greet(name) {\n return \"Hello, \" + name;\n}\n"]} \ No newline at end of file diff --git a/profiler-cli/src/test/fixtures/sourcemap/no-map.json b/profiler-cli/src/test/fixtures/sourcemap/no-map.json new file mode 100644 index 0000000000..9733a3ffca --- /dev/null +++ b/profiler-cli/src/test/fixtures/sourcemap/no-map.json @@ -0,0 +1,304 @@ +{ + "meta": { + "interval": 1, + "startTime": 0, + "abi": "", + "misc": "", + "oscpu": "", + "platform": "", + "processType": 0, + "extensions": { "id": [], "name": [], "baseURL": [], "length": 0 }, + "categories": [ + { "name": "Other", "color": "grey", "subcategories": ["Other"] }, + { "name": "Idle", "color": "transparent", "subcategories": ["Other"] }, + { "name": "Layout", "color": "purple", "subcategories": ["Other"] }, + { "name": "JavaScript", "color": "yellow", "subcategories": ["Other"] }, + { "name": "GC / CC", "color": "orange", "subcategories": ["Other"] }, + { "name": "Network", "color": "lightblue", "subcategories": ["Other"] }, + { "name": "Graphics", "color": "green", "subcategories": ["Other"] }, + { "name": "DOM", "color": "blue", "subcategories": ["Other"] } + ], + "product": "Firefox", + "stackwalk": 0, + "toolkit": "", + "version": 34, + "preprocessedProfileVersion": 68, + "appBuildID": "", + "sourceURL": "", + "physicalCPUs": 0, + "logicalCPUs": 0, + "CPUName": "", + "symbolicated": true, + "markerSchema": [ + { + "name": "GCMajor", + "display": ["marker-chart", "marker-table", "timeline-memory"], + "fields": [] + }, + { + "name": "GCMinor", + "display": ["marker-chart", "marker-table", "timeline-memory"], + "fields": [] + }, + { + "name": "GCSlice", + "display": ["marker-chart", "marker-table", "timeline-memory"], + "fields": [] + }, + { + "name": "CC", + "tooltipLabel": "Cycle Collect", + "display": ["marker-chart", "marker-table", "timeline-memory"], + "fields": [] + }, + { + "name": "FileIO", + "display": ["marker-chart", "marker-table"], + "fields": [ + { "key": "operation", "label": "Operation", "format": "string" }, + { "key": "source", "label": "Source", "format": "string" }, + { "key": "filename", "label": "Filename", "format": "file-path" } + ] + }, + { + "name": "MediaSample", + "display": ["marker-chart", "marker-table"], + "fields": [ + { + "key": "sampleStartTimeUs", + "label": "Sample start time", + "format": "microseconds" + }, + { + "key": "sampleEndTimeUs", + "label": "Sample end time", + "format": "microseconds" + } + ] + }, + { + "name": "Styles", + "display": ["marker-chart", "marker-table", "timeline-overview"], + "fields": [ + { + "key": "elementsTraversed", + "label": "Elements traversed", + "format": "integer" + }, + { + "key": "elementsStyled", + "label": "Elements styled", + "format": "integer" + }, + { + "key": "elementsMatched", + "label": "Elements matched", + "format": "integer" + }, + { + "key": "stylesShared", + "label": "Styles shared", + "format": "integer" + }, + { + "key": "stylesReused", + "label": "Styles reused", + "format": "integer" + } + ] + }, + { + "name": "PreferenceRead", + "display": ["marker-chart", "marker-table"], + "fields": [ + { "key": "prefName", "label": "Name", "format": "string" }, + { "key": "prefKind", "label": "Kind", "format": "string" }, + { "key": "prefType", "label": "Type", "format": "string" }, + { "key": "prefValue", "label": "Value", "format": "string" } + ] + }, + { + "name": "UserTiming", + "tooltipLabel": "{marker.data.name}", + "chartLabel": "{marker.data.name}", + "tableLabel": "{marker.data.name}", + "display": ["marker-chart", "marker-table"], + "fields": [ + { "key": "name", "label": "Name", "format": "string" }, + { "key": "entryType", "label": "Entry Type", "format": "string" } + ], + "description": "UserTiming is created using the DOM APIs performance.mark() and performance.measure()." + }, + { + "name": "Text", + "tableLabel": "{marker.name} — {marker.data.name}", + "chartLabel": "{marker.name} — {marker.data.name}", + "display": ["marker-chart", "marker-table"], + "fields": [{ "key": "name", "label": "Details", "format": "string" }] + }, + { + "name": "Log", + "display": ["marker-table"], + "tableLabel": "({marker.data.module}) {marker.data.name}", + "fields": [ + { "key": "module", "label": "Module", "format": "string" }, + { "key": "name", "label": "Name", "format": "string" }, + { "key": "level", "label": "Level", "format": "unique-string" } + ] + }, + { + "name": "DOMEvent", + "tooltipLabel": "{marker.data.eventType} — DOMEvent", + "tableLabel": "{marker.data.eventType}", + "chartLabel": "{marker.data.eventType}", + "display": ["marker-chart", "marker-table", "timeline-overview"], + "fields": [ + { "key": "latency", "label": "Latency", "format": "duration" }, + { "key": "eventType", "label": "Event Type", "format": "string" } + ] + }, + { + "name": "tracing", + "display": ["marker-chart", "marker-table", "timeline-overview"], + "fields": [{ "key": "category", "label": "Type", "format": "string" }] + }, + { + "name": "Layout", + "display": ["marker-chart", "marker-table", "timeline-overview"], + "fields": [{ "key": "category", "label": "Type", "format": "string" }] + }, + { + "name": "IPC", + "tooltipLabel": "IPC — {marker.data.niceDirection}", + "tableLabel": "{marker.name} — {marker.data.messageType} — {marker.data.niceDirection}", + "chartLabel": "{marker.data.messageType}", + "display": ["marker-chart", "marker-table", "timeline-ipc"], + "fields": [ + { "key": "messageType", "label": "Type", "format": "string" }, + { "key": "sync", "label": "Sync", "format": "string" }, + { "key": "sendThreadName", "label": "From", "format": "string" }, + { "key": "recvThreadName", "label": "To", "format": "string" } + ] + }, + { + "name": "VisibleInTimelineOverview", + "display": ["marker-chart", "marker-table", "timeline-overview"], + "fields": [] + }, + { + "name": "StringTesting", + "display": ["marker-chart", "marker-table", "timeline-overview"], + "fields": [ + { "key": "string", "label": "string field", "format": "string" }, + { + "key": "uniqueString", + "label": "unique string field", + "format": "unique-string" + } + ] + }, + { + "name": "MarkerWithHiddenField", + "display": ["marker-chart", "marker-table", "timeline-overview"], + "fields": [ + { + "key": "hiddenString", + "label": "Hidden string", + "format": "string", + "hidden": true + } + ] + } + ] + }, + "libs": [], + "pages": [], + "shared": { + "stackTable": { "frame": [0], "prefixOffset": [0], "length": 1 }, + "frameTable": { + "address": [-1], + "inlineDepth": [0], + "category": [3], + "subcategory": [0], + "func": [0], + "nativeSymbol": [null], + "innerWindowID": [0], + "line": [1], + "column": [15], + "originalLocation": [null], + "length": 1 + }, + "funcTable": { + "isJS": [true], + "relevantForJS": [false], + "name": [1], + "resource": [-1], + "source": [0], + "lineNumber": [1], + "columnNumber": [10], + "originalLocation": [null], + "length": 1 + }, + "resourceTable": { + "lib": [], + "name": [], + "host": [], + "type": [], + "length": 0 + }, + "nativeSymbols": { + "libIndex": [], + "address": [], + "name": [], + "functionSize": [], + "length": 0 + }, + "stringArray": ["plain.js", "Ajs"], + "sources": { + "id": [null], + "filename": [0], + "startLine": [1], + "startColumn": [1], + "sourceMapURL": [null], + "content": [null], + "length": 1 + }, + "sourceLocationTable": { + "source": [], + "line": [], + "column": [], + "length": 0 + } + }, + "threads": [ + { + "processType": "default", + "processStartupTime": 0, + "processShutdownTime": null, + "registerTime": 0, + "unregisterTime": null, + "pausedRanges": [], + "name": "Empty", + "isMainThread": false, + "pid": "0", + "tid": 0, + "samples": { + "weightType": "samples", + "weight": null, + "eventDelay": [0], + "stack": [0], + "time": [0], + "length": 1 + }, + "markers": { + "data": [], + "name": [], + "startTime": [], + "endTime": [], + "phase": [], + "category": [], + "length": 0 + } + } + ] +} diff --git a/profiler-cli/src/test/fixtures/sourcemap/single.json b/profiler-cli/src/test/fixtures/sourcemap/single.json new file mode 100644 index 0000000000..56743ad001 --- /dev/null +++ b/profiler-cli/src/test/fixtures/sourcemap/single.json @@ -0,0 +1,304 @@ +{ + "meta": { + "interval": 1, + "startTime": 0, + "abi": "", + "misc": "", + "oscpu": "", + "platform": "", + "processType": 0, + "extensions": { "id": [], "name": [], "baseURL": [], "length": 0 }, + "categories": [ + { "name": "Other", "color": "grey", "subcategories": ["Other"] }, + { "name": "Idle", "color": "transparent", "subcategories": ["Other"] }, + { "name": "Layout", "color": "purple", "subcategories": ["Other"] }, + { "name": "JavaScript", "color": "yellow", "subcategories": ["Other"] }, + { "name": "GC / CC", "color": "orange", "subcategories": ["Other"] }, + { "name": "Network", "color": "lightblue", "subcategories": ["Other"] }, + { "name": "Graphics", "color": "green", "subcategories": ["Other"] }, + { "name": "DOM", "color": "blue", "subcategories": ["Other"] } + ], + "product": "Firefox", + "stackwalk": 0, + "toolkit": "", + "version": 34, + "preprocessedProfileVersion": 68, + "appBuildID": "", + "sourceURL": "", + "physicalCPUs": 0, + "logicalCPUs": 0, + "CPUName": "", + "symbolicated": true, + "markerSchema": [ + { + "name": "GCMajor", + "display": ["marker-chart", "marker-table", "timeline-memory"], + "fields": [] + }, + { + "name": "GCMinor", + "display": ["marker-chart", "marker-table", "timeline-memory"], + "fields": [] + }, + { + "name": "GCSlice", + "display": ["marker-chart", "marker-table", "timeline-memory"], + "fields": [] + }, + { + "name": "CC", + "tooltipLabel": "Cycle Collect", + "display": ["marker-chart", "marker-table", "timeline-memory"], + "fields": [] + }, + { + "name": "FileIO", + "display": ["marker-chart", "marker-table"], + "fields": [ + { "key": "operation", "label": "Operation", "format": "string" }, + { "key": "source", "label": "Source", "format": "string" }, + { "key": "filename", "label": "Filename", "format": "file-path" } + ] + }, + { + "name": "MediaSample", + "display": ["marker-chart", "marker-table"], + "fields": [ + { + "key": "sampleStartTimeUs", + "label": "Sample start time", + "format": "microseconds" + }, + { + "key": "sampleEndTimeUs", + "label": "Sample end time", + "format": "microseconds" + } + ] + }, + { + "name": "Styles", + "display": ["marker-chart", "marker-table", "timeline-overview"], + "fields": [ + { + "key": "elementsTraversed", + "label": "Elements traversed", + "format": "integer" + }, + { + "key": "elementsStyled", + "label": "Elements styled", + "format": "integer" + }, + { + "key": "elementsMatched", + "label": "Elements matched", + "format": "integer" + }, + { + "key": "stylesShared", + "label": "Styles shared", + "format": "integer" + }, + { + "key": "stylesReused", + "label": "Styles reused", + "format": "integer" + } + ] + }, + { + "name": "PreferenceRead", + "display": ["marker-chart", "marker-table"], + "fields": [ + { "key": "prefName", "label": "Name", "format": "string" }, + { "key": "prefKind", "label": "Kind", "format": "string" }, + { "key": "prefType", "label": "Type", "format": "string" }, + { "key": "prefValue", "label": "Value", "format": "string" } + ] + }, + { + "name": "UserTiming", + "tooltipLabel": "{marker.data.name}", + "chartLabel": "{marker.data.name}", + "tableLabel": "{marker.data.name}", + "display": ["marker-chart", "marker-table"], + "fields": [ + { "key": "name", "label": "Name", "format": "string" }, + { "key": "entryType", "label": "Entry Type", "format": "string" } + ], + "description": "UserTiming is created using the DOM APIs performance.mark() and performance.measure()." + }, + { + "name": "Text", + "tableLabel": "{marker.name} — {marker.data.name}", + "chartLabel": "{marker.name} — {marker.data.name}", + "display": ["marker-chart", "marker-table"], + "fields": [{ "key": "name", "label": "Details", "format": "string" }] + }, + { + "name": "Log", + "display": ["marker-table"], + "tableLabel": "({marker.data.module}) {marker.data.name}", + "fields": [ + { "key": "module", "label": "Module", "format": "string" }, + { "key": "name", "label": "Name", "format": "string" }, + { "key": "level", "label": "Level", "format": "unique-string" } + ] + }, + { + "name": "DOMEvent", + "tooltipLabel": "{marker.data.eventType} — DOMEvent", + "tableLabel": "{marker.data.eventType}", + "chartLabel": "{marker.data.eventType}", + "display": ["marker-chart", "marker-table", "timeline-overview"], + "fields": [ + { "key": "latency", "label": "Latency", "format": "duration" }, + { "key": "eventType", "label": "Event Type", "format": "string" } + ] + }, + { + "name": "tracing", + "display": ["marker-chart", "marker-table", "timeline-overview"], + "fields": [{ "key": "category", "label": "Type", "format": "string" }] + }, + { + "name": "Layout", + "display": ["marker-chart", "marker-table", "timeline-overview"], + "fields": [{ "key": "category", "label": "Type", "format": "string" }] + }, + { + "name": "IPC", + "tooltipLabel": "IPC — {marker.data.niceDirection}", + "tableLabel": "{marker.name} — {marker.data.messageType} — {marker.data.niceDirection}", + "chartLabel": "{marker.data.messageType}", + "display": ["marker-chart", "marker-table", "timeline-ipc"], + "fields": [ + { "key": "messageType", "label": "Type", "format": "string" }, + { "key": "sync", "label": "Sync", "format": "string" }, + { "key": "sendThreadName", "label": "From", "format": "string" }, + { "key": "recvThreadName", "label": "To", "format": "string" } + ] + }, + { + "name": "VisibleInTimelineOverview", + "display": ["marker-chart", "marker-table", "timeline-overview"], + "fields": [] + }, + { + "name": "StringTesting", + "display": ["marker-chart", "marker-table", "timeline-overview"], + "fields": [ + { "key": "string", "label": "string field", "format": "string" }, + { + "key": "uniqueString", + "label": "unique string field", + "format": "unique-string" + } + ] + }, + { + "name": "MarkerWithHiddenField", + "display": ["marker-chart", "marker-table", "timeline-overview"], + "fields": [ + { + "key": "hiddenString", + "label": "Hidden string", + "format": "string", + "hidden": true + } + ] + } + ] + }, + "libs": [], + "pages": [], + "shared": { + "stackTable": { "frame": [0], "prefixOffset": [0], "length": 1 }, + "frameTable": { + "address": [-1], + "inlineDepth": [0], + "category": [3], + "subcategory": [0], + "func": [0], + "nativeSymbol": [null], + "innerWindowID": [0], + "line": [1], + "column": [15], + "originalLocation": [null], + "length": 1 + }, + "funcTable": { + "isJS": [true], + "relevantForJS": [false], + "name": [1], + "resource": [-1], + "source": [0], + "lineNumber": [1], + "columnNumber": [10], + "originalLocation": [null], + "length": 1 + }, + "resourceTable": { + "lib": [], + "name": [], + "host": [], + "type": [], + "length": 0 + }, + "nativeSymbols": { + "libIndex": [], + "address": [], + "name": [], + "functionSize": [], + "length": 0 + }, + "stringArray": ["bundle.js", "Ajs", "https://example.com/bundle.js.map"], + "sources": { + "id": [null], + "filename": [0], + "startLine": [1], + "startColumn": [1], + "sourceMapURL": [2], + "content": [null], + "length": 1 + }, + "sourceLocationTable": { + "source": [], + "line": [], + "column": [], + "length": 0 + } + }, + "threads": [ + { + "processType": "default", + "processStartupTime": 0, + "processShutdownTime": null, + "registerTime": 0, + "unregisterTime": null, + "pausedRanges": [], + "name": "Empty", + "isMainThread": false, + "pid": "0", + "tid": 0, + "samples": { + "weightType": "samples", + "weight": null, + "eventDelay": [0], + "stack": [0], + "time": [0], + "length": 1 + }, + "markers": { + "data": [], + "name": [], + "startTime": [], + "endTime": [], + "phase": [], + "category": [], + "length": 0 + } + } + ] +} diff --git a/profiler-cli/src/test/fixtures/sourcemap/two.json b/profiler-cli/src/test/fixtures/sourcemap/two.json new file mode 100644 index 0000000000..8d7591e693 --- /dev/null +++ b/profiler-cli/src/test/fixtures/sourcemap/two.json @@ -0,0 +1,339 @@ +{ + "meta": { + "interval": 1, + "startTime": 0, + "abi": "", + "misc": "", + "oscpu": "", + "platform": "", + "processType": 0, + "extensions": { "id": [], "name": [], "baseURL": [], "length": 0 }, + "categories": [ + { "name": "Other", "color": "grey", "subcategories": ["Other"] }, + { "name": "Idle", "color": "transparent", "subcategories": ["Other"] }, + { "name": "Layout", "color": "purple", "subcategories": ["Other"] }, + { "name": "JavaScript", "color": "yellow", "subcategories": ["Other"] }, + { "name": "GC / CC", "color": "orange", "subcategories": ["Other"] }, + { "name": "Network", "color": "lightblue", "subcategories": ["Other"] }, + { "name": "Graphics", "color": "green", "subcategories": ["Other"] }, + { "name": "DOM", "color": "blue", "subcategories": ["Other"] } + ], + "product": "Firefox", + "stackwalk": 0, + "toolkit": "", + "version": 34, + "preprocessedProfileVersion": 68, + "appBuildID": "", + "sourceURL": "", + "physicalCPUs": 0, + "logicalCPUs": 0, + "CPUName": "", + "symbolicated": true, + "markerSchema": [ + { + "name": "GCMajor", + "display": ["marker-chart", "marker-table", "timeline-memory"], + "fields": [] + }, + { + "name": "GCMinor", + "display": ["marker-chart", "marker-table", "timeline-memory"], + "fields": [] + }, + { + "name": "GCSlice", + "display": ["marker-chart", "marker-table", "timeline-memory"], + "fields": [] + }, + { + "name": "CC", + "tooltipLabel": "Cycle Collect", + "display": ["marker-chart", "marker-table", "timeline-memory"], + "fields": [] + }, + { + "name": "FileIO", + "display": ["marker-chart", "marker-table"], + "fields": [ + { "key": "operation", "label": "Operation", "format": "string" }, + { "key": "source", "label": "Source", "format": "string" }, + { "key": "filename", "label": "Filename", "format": "file-path" } + ] + }, + { + "name": "MediaSample", + "display": ["marker-chart", "marker-table"], + "fields": [ + { + "key": "sampleStartTimeUs", + "label": "Sample start time", + "format": "microseconds" + }, + { + "key": "sampleEndTimeUs", + "label": "Sample end time", + "format": "microseconds" + } + ] + }, + { + "name": "Styles", + "display": ["marker-chart", "marker-table", "timeline-overview"], + "fields": [ + { + "key": "elementsTraversed", + "label": "Elements traversed", + "format": "integer" + }, + { + "key": "elementsStyled", + "label": "Elements styled", + "format": "integer" + }, + { + "key": "elementsMatched", + "label": "Elements matched", + "format": "integer" + }, + { + "key": "stylesShared", + "label": "Styles shared", + "format": "integer" + }, + { + "key": "stylesReused", + "label": "Styles reused", + "format": "integer" + } + ] + }, + { + "name": "PreferenceRead", + "display": ["marker-chart", "marker-table"], + "fields": [ + { "key": "prefName", "label": "Name", "format": "string" }, + { "key": "prefKind", "label": "Kind", "format": "string" }, + { "key": "prefType", "label": "Type", "format": "string" }, + { "key": "prefValue", "label": "Value", "format": "string" } + ] + }, + { + "name": "UserTiming", + "tooltipLabel": "{marker.data.name}", + "chartLabel": "{marker.data.name}", + "tableLabel": "{marker.data.name}", + "display": ["marker-chart", "marker-table"], + "fields": [ + { "key": "name", "label": "Name", "format": "string" }, + { "key": "entryType", "label": "Entry Type", "format": "string" } + ], + "description": "UserTiming is created using the DOM APIs performance.mark() and performance.measure()." + }, + { + "name": "Text", + "tableLabel": "{marker.name} — {marker.data.name}", + "chartLabel": "{marker.name} — {marker.data.name}", + "display": ["marker-chart", "marker-table"], + "fields": [{ "key": "name", "label": "Details", "format": "string" }] + }, + { + "name": "Log", + "display": ["marker-table"], + "tableLabel": "({marker.data.module}) {marker.data.name}", + "fields": [ + { "key": "module", "label": "Module", "format": "string" }, + { "key": "name", "label": "Name", "format": "string" }, + { "key": "level", "label": "Level", "format": "unique-string" } + ] + }, + { + "name": "DOMEvent", + "tooltipLabel": "{marker.data.eventType} — DOMEvent", + "tableLabel": "{marker.data.eventType}", + "chartLabel": "{marker.data.eventType}", + "display": ["marker-chart", "marker-table", "timeline-overview"], + "fields": [ + { "key": "latency", "label": "Latency", "format": "duration" }, + { "key": "eventType", "label": "Event Type", "format": "string" } + ] + }, + { + "name": "tracing", + "display": ["marker-chart", "marker-table", "timeline-overview"], + "fields": [{ "key": "category", "label": "Type", "format": "string" }] + }, + { + "name": "Layout", + "display": ["marker-chart", "marker-table", "timeline-overview"], + "fields": [{ "key": "category", "label": "Type", "format": "string" }] + }, + { + "name": "IPC", + "tooltipLabel": "IPC — {marker.data.niceDirection}", + "tableLabel": "{marker.name} — {marker.data.messageType} — {marker.data.niceDirection}", + "chartLabel": "{marker.data.messageType}", + "display": ["marker-chart", "marker-table", "timeline-ipc"], + "fields": [ + { "key": "messageType", "label": "Type", "format": "string" }, + { "key": "sync", "label": "Sync", "format": "string" }, + { "key": "sendThreadName", "label": "From", "format": "string" }, + { "key": "recvThreadName", "label": "To", "format": "string" } + ] + }, + { + "name": "VisibleInTimelineOverview", + "display": ["marker-chart", "marker-table", "timeline-overview"], + "fields": [] + }, + { + "name": "StringTesting", + "display": ["marker-chart", "marker-table", "timeline-overview"], + "fields": [ + { "key": "string", "label": "string field", "format": "string" }, + { + "key": "uniqueString", + "label": "unique string field", + "format": "unique-string" + } + ] + }, + { + "name": "MarkerWithHiddenField", + "display": ["marker-chart", "marker-table", "timeline-overview"], + "fields": [ + { + "key": "hiddenString", + "label": "Hidden string", + "format": "string", + "hidden": true + } + ] + } + ] + }, + "libs": [], + "pages": [], + "shared": { + "stackTable": { "frame": [0, 1], "prefixOffset": [0, 0], "length": 2 }, + "frameTable": { + "address": [-1, -1], + "inlineDepth": [0, 0], + "category": [3, 3], + "subcategory": [0, 0], + "func": [0, 1], + "nativeSymbol": [null, null], + "innerWindowID": [0, 0], + "line": [1, 1], + "column": [15, 15], + "originalLocation": [null, null], + "length": 2 + }, + "funcTable": { + "isJS": [true, true], + "relevantForJS": [false, false], + "name": [1, 1], + "resource": [-1, -1], + "source": [0, 1], + "lineNumber": [1, 1], + "columnNumber": [10, 10], + "originalLocation": [null, null], + "length": 2 + }, + "resourceTable": { + "lib": [], + "name": [], + "host": [], + "type": [], + "length": 0 + }, + "nativeSymbols": { + "libIndex": [], + "address": [], + "name": [], + "functionSize": [], + "length": 0 + }, + "stringArray": [ + "bundle-a.js", + "Ajs", + "bundle-b.js", + "https://example.com/bundle-a.js.map", + "https://example.com/bundle-b.js.map" + ], + "sources": { + "id": [null, null], + "filename": [0, 2], + "startLine": [1, 1], + "startColumn": [1, 1], + "sourceMapURL": [3, 4], + "content": [null, null], + "length": 2 + }, + "sourceLocationTable": { + "source": [], + "line": [], + "column": [], + "length": 0 + } + }, + "threads": [ + { + "processType": "default", + "processStartupTime": 0, + "processShutdownTime": null, + "registerTime": 0, + "unregisterTime": null, + "pausedRanges": [], + "name": "Empty", + "isMainThread": false, + "pid": "0", + "tid": 0, + "samples": { + "weightType": "samples", + "weight": null, + "eventDelay": [0], + "stack": [0], + "time": [0], + "length": 1 + }, + "markers": { + "data": [], + "name": [], + "startTime": [], + "endTime": [], + "phase": [], + "category": [], + "length": 0 + } + }, + { + "processType": "default", + "processStartupTime": 0, + "processShutdownTime": null, + "registerTime": 0, + "unregisterTime": null, + "pausedRanges": [], + "name": "Empty", + "isMainThread": false, + "pid": "0", + "tid": 1, + "samples": { + "weightType": "samples", + "weight": null, + "eventDelay": [0], + "stack": [1], + "time": [0], + "length": 1 + }, + "markers": { + "data": [], + "name": [], + "startTime": [], + "endTime": [], + "phase": [], + "category": [], + "length": 0 + } + } + ] +} diff --git a/profiler-cli/src/test/integration/sourcemap.test.ts b/profiler-cli/src/test/integration/sourcemap.test.ts new file mode 100644 index 0000000000..858895a5a6 --- /dev/null +++ b/profiler-cli/src/test/integration/sourcemap.test.ts @@ -0,0 +1,58 @@ +/* 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/. */ + +/** + * Integration tests for `profiler-cli sourcemap sources`. + * + * Fixtures under fixtures/sourcemap/ are pre-generated and committed (see + * fixtures/sourcemap-generator.ts) because they can't be built in this + * node-env test process. + */ + +import { join } from 'path'; +import { + createTestContext, + cleanupTestContext, + cli, + type CliTestContext, +} from './utils'; + +import type { SourceMapSourcesResult, WithContext } from '../../protocol'; + +const FIXTURES = join(__dirname, '..', 'fixtures', 'sourcemap'); +const SINGLE_PROFILE = join(FIXTURES, 'single.json'); + +describe('profiler-cli sourcemap', () => { + let ctx: CliTestContext; + + beforeEach(async () => { + ctx = await createTestContext(); + }); + + afterEach(async () => { + await cleanupTestContext(ctx); + }); + + it('lists eligible sources (text and --json)', async () => { + await cli(ctx, ['load', SINGLE_PROFILE]); + + const text = await cli(ctx, ['sourcemap', 'sources']); + expect(text.exitCode).toBe(0); + expect(text.stdout).toContain('bundle.js'); + expect(text.stdout).toContain('src-'); + + const jsonResult = await cli(ctx, ['sourcemap', 'sources', '--json']); + const list = JSON.parse( + jsonResult.stdout + ) as WithContext; + expect(list.type).toBe('sourcemap-sources'); + expect(list.sources).toHaveLength(1); + expect(list.sources[0].filename).toBe('bundle.js'); + expect(list.sources[0].sourceHandle).toMatch(/^src-\d+$/); + expect(list.sources[0].sourceMap).toEqual({ + kind: 'url', + url: 'https://example.com/bundle.js.map', + }); + }); +}); diff --git a/profiler-cli/src/test/unit/sourcemap-fixtures.test.ts b/profiler-cli/src/test/unit/sourcemap-fixtures.test.ts new file mode 100644 index 0000000000..1f334635d8 --- /dev/null +++ b/profiler-cli/src/test/unit/sourcemap-fixtures.test.ts @@ -0,0 +1,41 @@ +/* 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/. */ + +/** + * Validates the `sourcemap` integration fixture generator, and regenerates the + * committed fixtures when `REGEN_SOURCEMAP_FIXTURES=1`. + * + * This runs in the browser (jsdom) project because the profile builders it + * imports are DOM-coupled; the node-env integration tests can only consume the + * committed output, not build it. To regenerate: + * + * REGEN_SOURCEMAP_FIXTURES=1 JEST_PROJECTS=cli yarn jest sourcemap-fixtures + */ + +import { mkdtempSync, readFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { generateSourcemapFixtures } from '../fixtures/sourcemap-generator'; + +const COMMITTED_DIR = join(__dirname, '..', 'fixtures', 'sourcemap'); + +describe('sourcemap fixture generator', () => { + it('produces loadable profiles and source maps', () => { + const outDir = process.env.REGEN_SOURCEMAP_FIXTURES + ? COMMITTED_DIR + : mkdtempSync(join(tmpdir(), 'sourcemap-fixtures-')); + + generateSourcemapFixtures(outDir); + + const single = JSON.parse( + readFileSync(join(outDir, 'single.json'), 'utf8') + ); + expect(single.meta).toBeDefined(); + expect(single.shared.sources).toBeDefined(); + + const map = JSON.parse(readFileSync(join(outDir, 'bundle.js.map'), 'utf8')); + expect(map.version).toBe(3); + expect(map.mappings).toEqual(expect.any(String)); + }); +}); diff --git a/profiler-cli/src/test/unit/sourcemap-formatting.test.ts b/profiler-cli/src/test/unit/sourcemap-formatting.test.ts new file mode 100644 index 0000000000..def914dba6 --- /dev/null +++ b/profiler-cli/src/test/unit/sourcemap-formatting.test.ts @@ -0,0 +1,102 @@ +/* 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 { formatSourceMapSourcesResult } from '../../formatters'; +import type { + SessionContext, + SourceEntry, + SourceMapSourcesResult, + WithContext, +} from 'firefox-profiler/profile-query/types'; + +function createContext(): SessionContext { + return { + selectedThreadHandle: 't-0', + selectedThreads: [{ threadIndex: 0, name: 'GeckoMain' }], + currentViewRange: null, + rootRange: { start: 0, end: 3000 }, + }; +} + +function makeEntry(overrides: Partial = {}): SourceEntry { + return { + sourceHandle: 'src-0', + sourceIndex: 0, + filename: 'bundle.js', + sourceMap: { kind: 'url', url: 'https://example.com/bundle.js.map' }, + ...overrides, + }; +} + +describe('formatSourceMapSourcesResult', () => { + it('lists eligible sources with their handles', () => { + const result: WithContext = { + type: 'sourcemap-sources', + sources: [ + makeEntry(), + makeEntry({ + sourceHandle: 'src-3', + sourceIndex: 3, + filename: 'vendor.js', + sourceMap: { kind: 'url', url: 'https://example.com/vendor.js.map' }, + }), + ], + context: createContext(), + }; + const text = formatSourceMapSourcesResult(result); + expect(text).toContain('Sources with source maps (2):'); + expect(text).toContain('src-0 bundle.js'); + expect(text).toContain('src-3 vendor.js'); + expect(text).toContain('https://example.com/vendor.js.map'); + }); + + it('handles an empty list', () => { + const result: WithContext = { + type: 'sourcemap-sources', + sources: [], + context: createContext(), + }; + expect(formatSourceMapSourcesResult(result)).toContain( + 'No sources with a source map URL' + ); + }); + + it('renders an inline map by media type and size', () => { + const result: WithContext = { + type: 'sourcemap-sources', + sources: [ + makeEntry({ + sourceMap: { + kind: 'inline', + mediaType: 'application/json;base64', + byteLength: 534605, + }, + }), + ], + context: createContext(), + }; + const text = formatSourceMapSourcesResult(result); + expect(text).toContain('src-0 bundle.js'); + expect(text).toContain('inline data: URL, application/json;base64, 535KB'); + // The line stays a readable width rather than scaling with the map. + expect(Math.max(...text.split('\n').map((l) => l.length))).toBeLessThan( + 120 + ); + }); + + it('renders a malformed inline map without a media type', () => { + const result: WithContext = { + type: 'sourcemap-sources', + sources: [ + makeEntry({ + sourceMap: { kind: 'inline', mediaType: null, byteLength: 534605 }, + }), + ], + context: createContext(), + }; + expect(formatSourceMapSourcesResult(result)).toContain( + 'inline data: URL, unknown media type, 535KB' + ); + }); +}); diff --git a/src/profile-query/README.md b/src/profile-query/README.md index 46463dd553..f03315762b 100644 --- a/src/profile-query/README.md +++ b/src/profile-query/README.md @@ -45,6 +45,7 @@ The library is built on top of the Firefox Profiler's Redux store and selectors: - **MarkerMap**: Maps marker handles (e.g., `m-0`, `m-1`) to marker indexes within threads - **FilterStack**: Manages per-thread stacks of sample filters (backed by Redux transforms) - **Function handles**: Canonical handles like `f-123` refer to shared `profile.shared.funcTable` indices and are stable across sessions for the same processed profile data +- **Source handles**: Handles like `src-3` refer to `profile.shared.sources` indices and, like function handles, are stable across sessions for the same profile - **Formatters**: Format query results into structured result objects All query results are returned as typed result objects containing structured data. The CLI layer in `profiler-cli` is responsible for formatting these into human-readable text. diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index 164b524403..baf81cae69 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -21,6 +21,7 @@ import { getProfile, getProfileRootRange, + getSourcesWithSourceMaps, } from 'firefox-profiler/selectors/profile'; import { getAllCommittedRanges, @@ -43,6 +44,9 @@ import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; import { TimestampManager } from './timestamps'; import { ThreadMap } from './thread-map'; import { parseFunctionHandle } from './function-map'; +import { getSourceHandle } from './source-handle'; +import { toSourceMapLocation } from './source-map'; +import type { EligibleSource } from 'firefox-profiler/profile-logic/source-map-matching'; import { getLibForFunc } from './function-list'; import { MarkerMap } from './marker-map'; import { loadProfileFromFileOrUrl, type LoadOptions } from './loader'; @@ -101,6 +105,8 @@ import type { ProfileLogsResult, CounterListResult, CounterInfoResult, + SourceMapSourcesResult, + SourceEntry, MarkerFilterOptions, FunctionFilterOptions, SampleFilterSpec, @@ -112,6 +118,15 @@ import type { CallTreeCollectionOptions } from './formatters/call-tree'; import { getThreadsKey } from 'firefox-profiler/profile-logic/profile-data'; import type { Store } from '../types/store'; +function toSourceEntry(source: EligibleSource): SourceEntry { + return { + sourceHandle: getSourceHandle(source.sourceIndex), + sourceIndex: source.sourceIndex, + filename: source.filename, + sourceMap: toSourceMapLocation(source.sourceMapURL), + }; +} + export class ProfileQuerier { _store: Store; _processIndexMap: Map; @@ -570,6 +585,20 @@ export class ProfileQuerier { }; } + /** + * List every bundle source that carries a `sourceMapURL` and is therefore + * eligible for `sourcemap apply`. Read-only. + */ + listSourceMapSources(): WithContext { + const eligible = getSourcesWithSourceMaps(this._store.getState()); + const sources: SourceEntry[] = eligible.map(toSourceEntry); + return { + type: 'sourcemap-sources', + sources, + context: this._getContext(), + }; + } + /** * Map the current Redux transform stack for `threadsKey` to FilterEntry[], * grouping consecutive transforms that came from the same `filter push` diff --git a/src/profile-query/source-handle.ts b/src/profile-query/source-handle.ts new file mode 100644 index 0000000000..879f9cf4de --- /dev/null +++ b/src/profile-query/source-handle.ts @@ -0,0 +1,35 @@ +/* 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 } from 'firefox-profiler/types'; + +/** + * A handle like "src-3" always refers to sourceTable index 3 for this profile, + * mirroring the `t-N` / `f-N` handle schemes. + */ +export function getSourceHandle( + sourceIndex: IndexIntoSourceTable +): `src-${number}` { + return `src-${sourceIndex}`; +} + +/** + * Parse a source handle and validate it against the shared sourceTable length. + */ +export function parseSourceHandle( + sourceHandle: string, + sourceCount: number +): IndexIntoSourceTable { + const match = /^src-(\d+)$/.exec(sourceHandle); + if (match === null) { + throw new Error(`Unknown source ${sourceHandle}`); + } + + const sourceIndex = Number(match[1]); + if (sourceIndex >= sourceCount) { + throw new Error(`Unknown source ${sourceHandle}`); + } + + return sourceIndex; +} diff --git a/src/profile-query/source-map.ts b/src/profile-query/source-map.ts new file mode 100644 index 0000000000..513ec47a86 --- /dev/null +++ b/src/profile-query/source-map.ts @@ -0,0 +1,40 @@ +/* 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 { SourceMapLocation } from './types'; + +const DATA_URL_PREFIX = 'data:'; + +/** + * Upper bound on a plausible data: URL media type. Anything longer means the + * URL is malformed and we are looking at payload, not a media type. + */ +const MAX_MEDIA_TYPE_LENGTH = 100; + +/** + * Firefox stores an inline map's entire `data:` URL in the source table, so the + * "URL" can be megabytes of base64. Describe those by media type and size + * instead, which keeps the payload out of both the text and `--json` output. + * + * The URL is page-controlled and never validated on the way here (SpiderMonkey + * stores the `//# sourceMappingURL=` comment verbatim), so a `data:` URL with a + * missing or absurdly distant comma is possible. Report those without a media + * type rather than echoing the payload into it. + */ +export function toSourceMapLocation(sourceMapURL: string): SourceMapLocation { + if (!sourceMapURL.startsWith(DATA_URL_PREFIX)) { + return { kind: 'url', url: sourceMapURL }; + } + const commaIndex = sourceMapURL.indexOf(',', DATA_URL_PREFIX.length); + const mediaTypeLength = commaIndex - DATA_URL_PREFIX.length; + const hasMediaType = + commaIndex !== -1 && mediaTypeLength <= MAX_MEDIA_TYPE_LENGTH; + return { + kind: 'inline', + mediaType: hasMediaType + ? sourceMapURL.slice(DATA_URL_PREFIX.length, commaIndex) + : null, + byteLength: sourceMapURL.length, + }; +} diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 3fba1cdf44..d0e05da3cf 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -975,3 +975,34 @@ export type ProfileMetaResult = { entries: Array<{ label: string; value: any; formatted: string }>; }>; }; + +// ===== Sourcemap Commands ===== + +/** + * Where a source's map lives. Firefox stores an inline map's entire `data:` URL + * in the source table, so for those we report facts about the map rather than the + * payload, which can run to megabytes of base64. + * + * `mediaType` is null when the `data:` URL is malformed. `byteLength` is the + * length of the whole `data:` URL, header included. + */ +export type SourceMapLocation = + | { kind: 'url'; url: string } + | { kind: 'inline'; mediaType: string | null; byteLength: number }; + +/** + * One bundle source that carries a `sourceMapURL` and is therefore eligible to + * have a user-supplied `.map` applied. `sourceHandle` is the `src-N` handle for + * `sourcemap apply --to`. + */ +export type SourceEntry = { + sourceHandle: string; // "src-N" + sourceIndex: number; + filename: string; + sourceMap: SourceMapLocation; +}; + +export type SourceMapSourcesResult = { + type: 'sourcemap-sources'; + sources: SourceEntry[]; +}; diff --git a/src/test/unit/profile-query/source-map-location.test.ts b/src/test/unit/profile-query/source-map-location.test.ts new file mode 100644 index 0000000000..edf89ef5b5 --- /dev/null +++ b/src/test/unit/profile-query/source-map-location.test.ts @@ -0,0 +1,57 @@ +/* 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 { toSourceMapLocation } from '../../../profile-query/source-map'; + +describe('toSourceMapLocation', function () { + it('passes through a regular URL', function () { + expect(toSourceMapLocation('https://example.com/a.js.map')).toEqual({ + kind: 'url', + url: 'https://example.com/a.js.map', + }); + }); + + it('describes a well-formed inline map by media type and size', function () { + const url = 'data:application/json;base64,' + 'A'.repeat(1000); + expect(toSourceMapLocation(url)).toEqual({ + kind: 'inline', + mediaType: 'application/json;base64', + byteLength: url.length, + }); + }); + + it('handles an inline map with an empty media type', function () { + expect(toSourceMapLocation('data:,{}')).toEqual({ + kind: 'inline', + mediaType: '', + byteLength: 8, + }); + }); + + it('does not echo the payload when the data: URL has no comma', function () { + const url = 'data:application/json;base64' + 'A'.repeat(100000); + expect(toSourceMapLocation(url)).toEqual({ + kind: 'inline', + mediaType: null, + byteLength: url.length, + }); + }); + + it('does not echo the payload when the first comma is implausibly far in', function () { + const url = 'data:' + 'A'.repeat(100000) + ',rest'; + expect(toSourceMapLocation(url)).toEqual({ + kind: 'inline', + mediaType: null, + byteLength: url.length, + }); + }); + + it('treats a bare "data:" as an inline map with no media type', function () { + expect(toSourceMapLocation('data:')).toEqual({ + kind: 'inline', + mediaType: null, + byteLength: 5, + }); + }); +}); From 6a0c70e8943981b4c789ea2c457c07162df35827 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Thu, 30 Jul 2026 18:32:30 +0200 Subject: [PATCH 4/5] Add "sourcemap apply" to profiler-cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the web app's "Apply source map…" feature to profiler-cli, reusing the shared `applySourceMapFile` thunk. Since the CLI is one-shot, the web picker becomes a two-step flow: "sourcemap sources" lists the candidates, then "sourcemap apply [--to src-N]" reads the .map on the daemon, auto-matches it to a source (or applies to the source given by --to) and re-symbolicates in place. Ambiguous matches and errors exit non-zero so scripts can branch on them. The daemon has no Web Worker, so it injects a runner that calls the symbolication core directly on the current thread. That core's `source-map` dependency reads its WASM parser from `path.join(__dirname, 'mappings.wasm')` at runtime, and esbuild does not bundle that file, so the build copies it next to the bundle and the publish check now fails if it is missing -- without it, "sourcemap apply" silently applies nothing. `--to` only accepts sources that carry a `sourceMapURL`, the same set the web picker offers. Applying a map to any other source would skip the auto-match step's `no-eligible-sources` guard and then de-minify nothing, which reads as a successful apply. --- profiler-cli/README.md | 2 + profiler-cli/package.json | 3 +- profiler-cli/schemas.txt | 11 + profiler-cli/src/commands/sourcemap.ts | 45 ++++ profiler-cli/src/daemon.ts | 5 + profiler-cli/src/formatters.ts | 45 +++- profiler-cli/src/index.ts | 1 + profiler-cli/src/output.ts | 6 + profiler-cli/src/protocol.ts | 11 +- .../src/test/integration/sourcemap.test.ts | 200 +++++++++++++++++- .../test/unit/sourcemap-formatting.test.ts | 89 +++++++- scripts/build-profiler-cli.mjs | 15 +- scripts/verify-profiler-cli-build.mjs | 13 ++ src/actions/source-map-symbolication.ts | 20 +- src/profile-logic/source-map-matching.ts | 29 ++- src/profile-query/index.ts | 135 +++++++++++- src/profile-query/source-map.ts | 27 +++ src/profile-query/types.ts | 26 +++ .../store/source-map-symbolication.test.ts | 1 + src/test/unit/source-map-matching.test.ts | 8 +- 20 files changed, 671 insertions(+), 21 deletions(-) diff --git a/profiler-cli/README.md b/profiler-cli/README.md index ef271b754c..8d367ffd61 100644 --- a/profiler-cli/README.md +++ b/profiler-cli/README.md @@ -55,6 +55,7 @@ profiler-cli filter pop [N] # Pop the last N filters (default: 1) profiler-cli filter list # List active filters for current thread profiler-cli filter clear # Remove all filters for current thread profiler-cli sourcemap sources # List bundle sources eligible for a source map (src-N handles) +profiler-cli sourcemap apply # Apply a .map file to de-minify JS stacks [--to ] profiler-cli status # Show session status (selected thread, zoom ranges, filters) profiler-cli stop # Stop current daemon profiler-cli stop # Stop a specific session @@ -94,6 +95,7 @@ profiler-cli thread info --thread t-0 # View info for specific thread witho | `--jank-limit ` | Max jank periods to show in `thread page-load` (default: 10, 0 = show all) | | `--list` | Show a flat chronological list of individual markers (for `thread markers`) | | `--all` | Show all threads in `profile info` (overrides default top-5 limit) | +| `--to ` | Target source for `sourcemap apply`, skipping auto-matching (from `sourcemap sources`) | | `--session ` | Use a specific session instead of the current one | ## Sample Filter Flags diff --git a/profiler-cli/package.json b/profiler-cli/package.json index e195ff5493..31f1f4aab3 100644 --- a/profiler-cli/package.json +++ b/profiler-cli/package.json @@ -11,7 +11,8 @@ "pq": "dist/profiler-cli.js" }, "files": [ - "dist/profiler-cli.js" + "dist/profiler-cli.js", + "dist/mappings.wasm" ], "engines": { "node": ">= 24" diff --git a/profiler-cli/schemas.txt b/profiler-cli/schemas.txt index f17b47deda..b5d830ea88 100644 --- a/profiler-cli/schemas.txt +++ b/profiler-cli/schemas.txt @@ -244,3 +244,14 @@ profiler-cli sourcemap sources --json } SourceMapLocation = { kind: "url", url } | { kind: "inline", mediaType: string | null, byteLength } + +profiler-cli sourcemap apply --json + { + type: "sourcemap-applied" | "sourcemap-unchanged" | "sourcemap-ambiguous" | "sourcemap-error", + sourceHandle?, + filename?, + reason?: "multiple-matches" | "no-matches", + candidates?: [{ sourceHandle, sourceIndex, filename, sourceMap: SourceMapLocation }], + error?: "invalid-source-map" | "no-eligible-sources" | "symbolication-failed", + context: SessionContext + } diff --git a/profiler-cli/src/commands/sourcemap.ts b/profiler-cli/src/commands/sourcemap.ts index c0b6f68721..a55f1a1d8a 100644 --- a/profiler-cli/src/commands/sourcemap.ts +++ b/profiler-cli/src/commands/sourcemap.ts @@ -6,8 +6,12 @@ * `profiler-cli sourcemap` command. */ +import * as fs from 'fs'; +import * as path from 'path'; import type { Command } from 'commander'; import { addGlobalOptions, runCommand } from './shared'; +import { sendCommand } from '../client'; +import { formatOutput } from '../output'; export function registerSourcemapCommand( program: Command, @@ -30,4 +34,45 @@ export function registerSourcemapCommand( opts ); }); + + addGlobalOptions( + sourcemap + .command('apply ') + .description('Apply a .map file, auto-matching it to a bundle source') + .option( + '--to ', + 'Apply to this source instead of auto-matching (from "sourcemap sources")' + ) + ).action(async (mapPath: string, opts) => { + // Resolve to an absolute path here: the daemon runs with a different cwd, + // so it can only read the file by absolute path (mirrors the load flow). + const absolutePath = path.resolve(mapPath); + if (!fs.existsSync(absolutePath)) { + console.error(`Error: Source map file not found: ${absolutePath}`); + process.exitCode = 1; + return; + } + + const result = await sendCommand( + sessionDir, + { + command: 'sourcemap', + subcommand: 'apply', + path: absolutePath, + to: opts.to, + }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + + // `ambiguous` (needs disambiguation) and `error` are failures, so exit + // non-zero and let scripts branch on them. `applied` / `unchanged` exit 0. + if ( + typeof result !== 'string' && + (result.type === 'sourcemap-ambiguous' || + result.type === 'sourcemap-error') + ) { + process.exitCode = 1; + } + }); } diff --git a/profiler-cli/src/daemon.ts b/profiler-cli/src/daemon.ts index d15c92d176..af22fa2c4c 100644 --- a/profiler-cli/src/daemon.ts +++ b/profiler-cli/src/daemon.ts @@ -561,6 +561,11 @@ export class Daemon { switch (command.subcommand) { case 'sources': return this.querier.listSourceMapSources(); + case 'apply': + if (!command.path) { + throw new Error('path is required for sourcemap apply'); + } + return this.querier.applySourceMap(command.path, command.to); default: throw assertExhaustiveCheck(command); } diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index 0c299eec6c..02c74104c1 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -45,6 +45,7 @@ import type { SourceEntry, SourceMapLocation, SourceMapSourcesResult, + ApplySourceMapResult, } from './protocol'; import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; import { truncateFunctionName } from '../../src/profile-query/function-list'; @@ -2110,7 +2111,7 @@ function describeSourceMapLocation(sourceMap: SourceMapLocation): string { } } -/** One `src-N filename (sourceMapURL: ...)` line. */ +/** One `src-N filename (sourceMapURL: ...)` line, shared by sources and ambiguous. */ function formatSourceEntry(entry: SourceEntry): string { return ` ${entry.sourceHandle} ${entry.filename} (sourceMapURL: ${describeSourceMapLocation(entry.sourceMap)})`; } @@ -2125,3 +2126,45 @@ export function formatSourceMapSourcesResult( const lines = result.sources.map(formatSourceEntry); return `${contextHeader}\n\nSources with source maps (${result.sources.length}):\n${lines.join('\n')}`; } + +const APPLY_SOURCE_MAP_AMBIGUOUS_HEADERS: Record< + Extract['reason'], + string +> = { + 'multiple-matches': + 'The source map matches more than one source. Re-run with --to to pick one:', + 'no-matches': + 'The source map does not match any source in this profile. Re-run with --to to apply it to one of these anyway:', +}; + +const APPLY_SOURCE_MAP_ERROR_MESSAGES: Record< + Extract['error'], + string +> = { + 'invalid-source-map': + 'The file is not a valid source map (invalid JSON, or not a source map).', + 'no-eligible-sources': + 'No sources in this profile carry a source map URL, so nothing to apply to.', + 'symbolication-failed': 'Source map symbolication failed.', +}; + +export function formatApplySourceMapResult( + result: WithContext +): string { + switch (result.type) { + case 'sourcemap-applied': + return `Applied source map to ${result.filename} (${result.sourceHandle}). Re-run thread commands to see de-minified names.`; + case 'sourcemap-unchanged': + return `Source map applied to ${result.filename} (${result.sourceHandle}) but nothing changed (no stack positions mapped).`; + case 'sourcemap-ambiguous': { + const lines = result.candidates.map(formatSourceEntry); + return [APPLY_SOURCE_MAP_AMBIGUOUS_HEADERS[result.reason], ...lines].join( + '\n' + ); + } + case 'sourcemap-error': + return `Error: ${APPLY_SOURCE_MAP_ERROR_MESSAGES[result.error]}`; + default: + throw assertExhaustiveCheck(result); + } +} diff --git a/profiler-cli/src/index.ts b/profiler-cli/src/index.ts index eefbbf1a86..aa5590f993 100644 --- a/profiler-cli/src/index.ts +++ b/profiler-cli/src/index.ts @@ -92,6 +92,7 @@ Examples: profiler-cli zoom push 2.7,3.1 profiler-cli filter push --excludes-function f-184 profiler-cli sourcemap sources + profiler-cli sourcemap apply bundle.js.map profiler-cli status profiler-cli stop --all` ); diff --git a/profiler-cli/src/output.ts b/profiler-cli/src/output.ts index 5e96ca83c0..da258a7a29 100644 --- a/profiler-cli/src/output.ts +++ b/profiler-cli/src/output.ts @@ -32,6 +32,7 @@ import { formatCounterListResult, formatCounterInfoResult, formatSourceMapSourcesResult, + formatApplySourceMapResult, } from './formatters'; /** @@ -100,6 +101,11 @@ export function formatOutput( return formatCounterInfoResult(result); case 'sourcemap-sources': return formatSourceMapSourcesResult(result); + case 'sourcemap-applied': + case 'sourcemap-unchanged': + case 'sourcemap-ambiguous': + case 'sourcemap-error': + return formatApplySourceMapResult(result); default: throw assertExhaustiveCheck(result); } diff --git a/profiler-cli/src/protocol.ts b/profiler-cli/src/protocol.ts index 43c685783e..98aa1b4986 100644 --- a/profiler-cli/src/protocol.ts +++ b/profiler-cli/src/protocol.ts @@ -62,6 +62,7 @@ export type { SourceEntry, SourceMapLocation, SourceMapSourcesResult, + ApplySourceMapResult, } from '../../src/profile-query/types'; export type { CallTreeCollectionOptions } from '../../src/profile-query/formatters/call-tree'; @@ -96,6 +97,7 @@ import type { CounterListResult, CounterInfoResult, SourceMapSourcesResult, + ApplySourceMapResult, } from '../../src/profile-query/types'; import type { CallTreeCollectionOptions } from '../../src/profile-query/formatters/call-tree'; @@ -192,7 +194,11 @@ export type ClientCommand = } | { command: 'sourcemap'; - subcommand: 'sources'; + subcommand: 'sources' | 'apply'; + /** Absolute path to the `.map` file (resolved client-side). */ + path?: string; + /** `src-N` handle of the target source; skips auto-matching when set. */ + to?: string; } | { command: 'status' }; @@ -230,7 +236,8 @@ export type CommandResult = | WithContext | WithContext | WithContext - | WithContext; + | WithContext + | WithContext; export interface SessionMetadata { id: string; diff --git a/profiler-cli/src/test/integration/sourcemap.test.ts b/profiler-cli/src/test/integration/sourcemap.test.ts index 858895a5a6..738e0446fd 100644 --- a/profiler-cli/src/test/integration/sourcemap.test.ts +++ b/profiler-cli/src/test/integration/sourcemap.test.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * Integration tests for `profiler-cli sourcemap sources`. + * Integration tests for `profiler-cli sourcemap {sources,apply}`. * * Fixtures under fixtures/sourcemap/ are pre-generated and committed (see * fixtures/sourcemap-generator.ts) because they can't be built in this @@ -15,13 +15,29 @@ import { createTestContext, cleanupTestContext, cli, + cliFail, type CliTestContext, } from './utils'; -import type { SourceMapSourcesResult, WithContext } from '../../protocol'; +import type { + SourceMapSourcesResult, + ApplySourceMapResult, + WithContext, +} from '../../protocol'; + +type AmbiguousResult = Extract< + ApplySourceMapResult, + { type: 'sourcemap-ambiguous' } +>; +type ErrorResult = Extract; const FIXTURES = join(__dirname, '..', 'fixtures', 'sourcemap'); const SINGLE_PROFILE = join(FIXTURES, 'single.json'); +const TWO_PROFILE = join(FIXTURES, 'two.json'); +const NO_MAP_PROFILE = join(FIXTURES, 'no-map.json'); +const GOOD_MAP = join(FIXTURES, 'bundle.js.map'); +const MYSTERY_MAP = join(FIXTURES, 'mystery.map'); +const GARBAGE_MAP = join(FIXTURES, 'garbage.map'); describe('profiler-cli sourcemap', () => { let ctx: CliTestContext; @@ -55,4 +71,184 @@ describe('profiler-cli sourcemap', () => { url: 'https://example.com/bundle.js.map', }); }); + + it('applies a matching map and de-minifies the function name', async () => { + await cli(ctx, ['load', SINGLE_PROFILE]); + + const applyResult = await cli(ctx, [ + 'sourcemap', + 'apply', + GOOD_MAP, + '--json', + ]); + const applied = JSON.parse( + applyResult.stdout + ) as WithContext; + expect(applied.type).toBe('sourcemap-applied'); + + await cli(ctx, ['thread', 'select', 't-0']); + const samples = await cli(ctx, ['thread', 'samples']); + expect(samples.stdout).toContain('greet'); + expect(samples.stdout).not.toContain('Ajs'); + }); + + it('reports a map that matches nothing, then applies to the chosen --to', async () => { + await cli(ctx, ['load', TWO_PROFILE]); + + const listResult = await cli(ctx, ['sourcemap', 'sources', '--json']); + const list = JSON.parse( + listResult.stdout + ) as WithContext; + const handleForA = list.sources.find( + (s) => s.filename === 'bundle-a.js' + )!.sourceHandle; + + const ambiguous = await cliFail(ctx, [ + 'sourcemap', + 'apply', + MYSTERY_MAP, + '--json', + ]); + expect(ambiguous.exitCode).not.toBe(0); + const ambiguousResult = JSON.parse( + ambiguous.stdout + ) as WithContext; + expect(ambiguousResult.type).toBe('sourcemap-ambiguous'); + // mystery.map matches neither bundle by name, so all eligible sources are + // offered rather than being reported as several matches. + expect((ambiguousResult as AmbiguousResult).reason).toBe('no-matches'); + expect((ambiguousResult as AmbiguousResult).candidates).toHaveLength(2); + + const ambiguousText = await cliFail(ctx, [ + 'sourcemap', + 'apply', + MYSTERY_MAP, + ]); + expect(ambiguousText.stdout).toContain('does not match any source'); + + const applyResult = await cli(ctx, [ + 'sourcemap', + 'apply', + MYSTERY_MAP, + '--to', + handleForA, + '--json', + ]); + const applied = JSON.parse( + applyResult.stdout + ) as WithContext; + expect(applied.type).toBe('sourcemap-applied'); + }); + + it('rejects --to for a source that carries no source map URL', async () => { + await cli(ctx, ['load', NO_MAP_PROFILE]); + + // Without --to this is a `no-eligible-sources` error, so --to must not be a + // way around that guard. + const result = await cliFail(ctx, [ + 'sourcemap', + 'apply', + GOOD_MAP, + '--to', + 'src-0', + ]); + expect(result.exitCode).not.toBe(0); + const output = String(result.stdout || '') + String(result.stderr || ''); + expect(output).toContain('has no source map URL'); + }); + + it('rejects --to for an original source added by a previous apply', async () => { + await cli(ctx, ['load', SINGLE_PROFILE]); + await cli(ctx, ['sourcemap', 'apply', GOOD_MAP]); + + // Applying appends the map's original sources to the source table. They are + // valid src-N handles but are not bundles, so they can't be --to targets. + const listResult = await cli(ctx, ['sourcemap', 'sources', '--json']); + const list = JSON.parse( + listResult.stdout + ) as WithContext; + expect(list.sources).toHaveLength(1); + expect(list.sources[0].sourceHandle).toBe('src-0'); + + const result = await cliFail(ctx, [ + 'sourcemap', + 'apply', + GOOD_MAP, + '--to', + 'src-1', + ]); + expect(result.exitCode).not.toBe(0); + const output = String(result.stdout || '') + String(result.stderr || ''); + expect(output).toContain('has no source map URL'); + // The error points at the handles that would work. + expect(output).toContain('src-0 (bundle.js)'); + }); + + it('rejects an out-of-range --to handle', async () => { + await cli(ctx, ['load', SINGLE_PROFILE]); + + const result = await cliFail(ctx, [ + 'sourcemap', + 'apply', + GOOD_MAP, + '--to', + 'src-99', + ]); + expect(result.exitCode).not.toBe(0); + const output = String(result.stdout || '') + String(result.stderr || ''); + expect(output).toContain('Unknown source src-99'); + }); + + it('fails with invalid-source-map for a garbage file', async () => { + await cli(ctx, ['load', SINGLE_PROFILE]); + + const result = await cliFail(ctx, [ + 'sourcemap', + 'apply', + GARBAGE_MAP, + '--json', + ]); + expect(result.exitCode).not.toBe(0); + const parsed = JSON.parse( + result.stdout + ) as WithContext; + expect(parsed.type).toBe('sourcemap-error'); + expect((parsed as ErrorResult).error).toBe('invalid-source-map'); + }); + + it('fails with no-eligible-sources when nothing carries a source map URL', async () => { + await cli(ctx, ['load', NO_MAP_PROFILE]); + + const listResult = await cli(ctx, ['sourcemap', 'sources', '--json']); + const list = JSON.parse( + listResult.stdout + ) as WithContext; + expect(list.sources).toHaveLength(0); + + const result = await cliFail(ctx, [ + 'sourcemap', + 'apply', + GOOD_MAP, + '--json', + ]); + expect(result.exitCode).not.toBe(0); + const parsed = JSON.parse( + result.stdout + ) as WithContext; + expect(parsed.type).toBe('sourcemap-error'); + expect((parsed as ErrorResult).error).toBe('no-eligible-sources'); + }); + + it('errors for a missing map file before contacting the daemon', async () => { + await cli(ctx, ['load', SINGLE_PROFILE]); + + const result = await cliFail(ctx, [ + 'sourcemap', + 'apply', + join(FIXTURES, 'does-not-exist.map'), + ]); + expect(result.exitCode).not.toBe(0); + const output = String(result.stdout || '') + String(result.stderr || ''); + expect(output).toContain('not found'); + }); }); diff --git a/profiler-cli/src/test/unit/sourcemap-formatting.test.ts b/profiler-cli/src/test/unit/sourcemap-formatting.test.ts index def914dba6..2a7ac53bee 100644 --- a/profiler-cli/src/test/unit/sourcemap-formatting.test.ts +++ b/profiler-cli/src/test/unit/sourcemap-formatting.test.ts @@ -2,11 +2,15 @@ * 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 { formatSourceMapSourcesResult } from '../../formatters'; +import { + formatSourceMapSourcesResult, + formatApplySourceMapResult, +} from '../../formatters'; import type { SessionContext, SourceEntry, SourceMapSourcesResult, + ApplySourceMapResult, WithContext, } from 'firefox-profiler/profile-query/types'; @@ -100,3 +104,86 @@ describe('formatSourceMapSourcesResult', () => { ); }); }); + +describe('formatApplySourceMapResult', () => { + function withContext( + result: ApplySourceMapResult + ): WithContext { + return { ...result, context: createContext() }; + } + + it('formats applied', () => { + expect( + formatApplySourceMapResult( + withContext({ + type: 'sourcemap-applied', + sourceHandle: 'src-0', + filename: 'bundle.js', + }) + ) + ).toContain('Applied source map to bundle.js (src-0)'); + }); + + it('formats unchanged', () => { + expect( + formatApplySourceMapResult( + withContext({ + type: 'sourcemap-unchanged', + sourceHandle: 'src-0', + filename: 'bundle.js', + }) + ) + ).toContain('nothing changed'); + }); + + it('formats ambiguous with candidate handles', () => { + const text = formatApplySourceMapResult( + withContext({ + type: 'sourcemap-ambiguous', + reason: 'multiple-matches', + candidates: [ + makeEntry({ sourceHandle: 'src-0', filename: 'a.js' }), + makeEntry({ sourceHandle: 'src-1', filename: 'b.js' }), + ], + }) + ); + expect(text).toContain('matches more than one source'); + expect(text).toContain('--to '); + expect(text).toContain('src-0 a.js'); + expect(text).toContain('src-1 b.js'); + }); + + it('says nothing matched when the map matched no source', () => { + const text = formatApplySourceMapResult( + withContext({ + type: 'sourcemap-ambiguous', + reason: 'no-matches', + candidates: [ + makeEntry({ sourceHandle: 'src-0', filename: 'a.js' }), + makeEntry({ sourceHandle: 'src-1', filename: 'b.js' }), + ], + }) + ); + expect(text).toContain('does not match any source'); + expect(text).not.toContain('matches more than one source'); + expect(text).toContain('src-0 a.js'); + }); + + it('formats each error variant', () => { + expect( + formatApplySourceMapResult( + withContext({ type: 'sourcemap-error', error: 'invalid-source-map' }) + ) + ).toContain('not a valid source map'); + expect( + formatApplySourceMapResult( + withContext({ type: 'sourcemap-error', error: 'no-eligible-sources' }) + ) + ).toContain('No sources in this profile'); + expect( + formatApplySourceMapResult( + withContext({ type: 'sourcemap-error', error: 'symbolication-failed' }) + ) + ).toContain('symbolication failed'); + }); +}); diff --git a/scripts/build-profiler-cli.mjs b/scripts/build-profiler-cli.mjs index a8f8775c44..f5fd7d688c 100644 --- a/scripts/build-profiler-cli.mjs +++ b/scripts/build-profiler-cli.mjs @@ -2,9 +2,12 @@ * 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 esbuild from 'esbuild'; -import { chmodSync, readFileSync } from 'fs'; +import { chmodSync, copyFileSync, readFileSync } from 'fs'; +import { createRequire } from 'module'; import { nodeBaseConfig } from './lib/esbuild-configs.mjs'; +const require = createRequire(import.meta.url); + const { name, version } = JSON.parse( readFileSync(new URL('../profiler-cli/package.json', import.meta.url), 'utf8') ); @@ -34,6 +37,16 @@ const profilerCliConfig = { async function build() { await esbuild.build(profilerCliConfig); chmodSync('profiler-cli/dist/profiler-cli.js', 0o755); + + // The `source-map` package's Node build reads its WASM parser from + // `path.join(__dirname, 'mappings.wasm')` at runtime and its `initialize` is + // a no-op, so the .wasm must sit next to the bundle. esbuild bundles the JS + // but not this runtime file, so copy it in explicitly. + copyFileSync( + require.resolve('source-map/lib/mappings.wasm'), + 'profiler-cli/dist/mappings.wasm' + ); + console.log('✅ profiler-cli build completed'); } diff --git a/scripts/verify-profiler-cli-build.mjs b/scripts/verify-profiler-cli-build.mjs index 9c9db90f1e..004311016a 100644 --- a/scripts/verify-profiler-cli-build.mjs +++ b/scripts/verify-profiler-cli-build.mjs @@ -11,6 +11,10 @@ const distUrl = new URL( ); const distPath = fileURLToPath(distUrl); +// The `source-map` package reads this next to the bundle at runtime, so it is a +// required build artifact, not an optional extra. See scripts/build-profiler-cli.mjs. +const wasmUrl = new URL('../profiler-cli/dist/mappings.wasm', import.meta.url); + if (!existsSync(distUrl)) { console.error( `profiler-cli bundle not found at ${distPath}.\n` + @@ -19,6 +23,15 @@ if (!existsSync(distUrl)) { process.exit(1); } +if (!existsSync(wasmUrl)) { + console.error( + `profiler-cli source map parser not found at ${fileURLToPath(wasmUrl)}.\n` + + `Without it, 'sourcemap apply' silently applies nothing.\n` + + `Run 'yarn build-cli' from the repo root before publishing.` + ); + process.exit(1); +} + const { version } = JSON.parse(readFileSync(pkgUrl, 'utf8')); const bundle = readFileSync(distUrl, 'utf8'); const needle = JSON.stringify(version); diff --git a/src/actions/source-map-symbolication.ts b/src/actions/source-map-symbolication.ts index 601fafcfc7..781564d9a9 100644 --- a/src/actions/source-map-symbolication.ts +++ b/src/actions/source-map-symbolication.ts @@ -15,7 +15,10 @@ import type { 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 { + EligibleSource, + SourceMapAmbiguityReason, +} from 'firefox-profiler/profile-logic/source-map-matching'; import type { RawSourceMap } from 'source-map'; import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; @@ -122,7 +125,14 @@ export type ApplySourceMapFileResult = // index to report its `src-N` handle. | { type: 'applied'; sourceIndex: IndexIntoSourceTable; filename: string } | { type: 'no-match'; sourceIndex: IndexIntoSourceTable; filename: string } - | { type: 'ambiguous'; candidates: EligibleSource[] } + // Auto-matching couldn't pick a source, so the caller has to let the user + // choose among `candidates`. `reason` says whether that's because several + // sources matched or because none did. + | { + type: 'ambiguous'; + reason: SourceMapAmbiguityReason; + candidates: EligibleSource[]; + } | { type: 'error'; error: ApplySourceMapError }; /** @@ -161,7 +171,11 @@ export function applySourceMapFile( case 'no-eligible-sources': return { type: 'error', error: 'no-eligible-sources' }; case 'ambiguous': - return { type: 'ambiguous', candidates: result.candidates }; + return { + type: 'ambiguous', + reason: result.reason, + candidates: result.candidates, + }; case 'match': targetSourceIndex = result.sourceIndex; break; diff --git a/src/profile-logic/source-map-matching.ts b/src/profile-logic/source-map-matching.ts index a5ce512682..b79684f716 100644 --- a/src/profile-logic/source-map-matching.ts +++ b/src/profile-logic/source-map-matching.ts @@ -15,13 +15,25 @@ export type EligibleSource = { sourceMapURL: string; }; +/** + * Why auto-matching couldn't settle on a single source. `multiple-matches`: + * several sources matched the uploaded map. `no-matches`: none did, and every + * eligible source is offered as a candidate instead. Either way the caller has + * to ask the user to pick, but the two mean very different things to the user. + */ +export type SourceMapAmbiguityReason = 'multiple-matches' | 'no-matches'; + /** * 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: 'ambiguous'; + reason: SourceMapAmbiguityReason; + candidates: EligibleSource[]; + } | { type: 'no-eligible-sources' }; /** @@ -137,9 +149,10 @@ function matchByBasename( * 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. + * single source. More than one hit is `ambiguous` with reason + * `multiple-matches`; no hit under any criterion falls through to `ambiguous` + * with reason `no-matches` over all eligible sources. In both cases the caller + * asks the user to pick. * * The two trivial cases short-circuit first: zero eligible sources, or exactly * one, which we match unconditionally regardless of name. @@ -184,9 +197,13 @@ export function matchSourceMapToSource( return { type: 'match', sourceIndex: hits[0].sourceIndex }; } if (hits.length > 1) { - return { type: 'ambiguous', candidates: hits }; + return { + type: 'ambiguous', + reason: 'multiple-matches', + candidates: hits, + }; } } - return { type: 'ambiguous', candidates: eligible }; + return { type: 'ambiguous', reason: 'no-matches', candidates: eligible }; } diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index baf81cae69..11d58a0cf8 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -18,10 +18,13 @@ * > const p3 = await ProfileQuerier.load("https://share.firefox.dev/4oLEjCw"); */ +import * as fs from 'fs'; + import { getProfile, getProfileRootRange, getSourcesWithSourceMaps, + getSourceTable, } from 'firefox-profiler/selectors/profile'; import { getAllCommittedRanges, @@ -44,9 +47,20 @@ import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; import { TimestampManager } from './timestamps'; import { ThreadMap } from './thread-map'; import { parseFunctionHandle } from './function-map'; -import { getSourceHandle } from './source-handle'; -import { toSourceMapLocation } from './source-map'; -import type { EligibleSource } from 'firefox-profiler/profile-logic/source-map-matching'; +import { getSourceHandle, parseSourceHandle } from './source-handle'; +import { + runSourceMapSymbolicationNode, + toSourceMapLocation, +} from './source-map'; +import { + applySourceMapFile, + type ApplySourceMapFileResult, +} from 'firefox-profiler/actions/source-map-symbolication'; +import { + basename, + type EligibleSource, +} from 'firefox-profiler/profile-logic/source-map-matching'; +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; import { getLibForFunc } from './function-list'; import { MarkerMap } from './marker-map'; import { loadProfileFromFileOrUrl, type LoadOptions } from './loader'; @@ -75,6 +89,7 @@ import { parseTimeValue } from './time-range-parser'; import { describeTransformGroup, pushSpecTransforms } from './filter-stack'; import { functionAnnotate as computeFunctionAnnotate } from './function-annotate'; import type { + IndexIntoSourceTable, StartEndRange, ThreadIndex, ThreadsKey, @@ -107,6 +122,7 @@ import type { CounterInfoResult, SourceMapSourcesResult, SourceEntry, + ApplySourceMapResult, MarkerFilterOptions, FunctionFilterOptions, SampleFilterSpec, @@ -599,6 +615,119 @@ export class ProfileQuerier { }; } + /** + * Apply a `.map` file (read from `absPath`) to the profile. Mirrors the web + * app's "Apply source map…" flow via the shared `applySourceMapFile` thunk, + * injecting the Node runner since the daemon has no Web Worker. When + * `sourceHandle` is set the auto-match step is skipped and the map is applied + * to that source directly. + */ + async applySourceMap( + absPath: string, + sourceHandle?: string + ): Promise> { + // Resolved before reading the file so a bad `--to` fails without touching + // the filesystem. + const sourceIndex = + sourceHandle !== undefined + ? this._resolveTargetSource(sourceHandle) + : undefined; + + let contents: string; + try { + contents = fs.readFileSync(absPath, 'utf8'); + } catch (e) { + throw new Error( + `Could not read source map file ${absPath}: ${ + e instanceof Error ? e.message : String(e) + }` + ); + } + + const result = await this._store.dispatch( + applySourceMapFile( + basename(absPath), + contents, + sourceIndex, + runSourceMapSymbolicationNode + ) + ); + + return { + ...this._mapApplyResult(result), + context: this._getContext(), + }; + } + + /** + * Resolve a `--to src-N` handle to the source it targets, rejecting sources + * that carry no `sourceMapURL`. That is the same set the web app's picker + * offers, and it matters because applying a map to any other source skips the + * auto-match step's `no-eligible-sources` guard and then de-minifies nothing, + * which reads as a successful apply. + */ + private _resolveTargetSource(sourceHandle: string): IndexIntoSourceTable { + const state = this._store.getState(); + const sourceIndex = parseSourceHandle( + sourceHandle, + getSourceTable(state).length + ); + + const eligible = getSourcesWithSourceMaps(state); + if (eligible.some((source) => source.sourceIndex === sourceIndex)) { + return sourceIndex; + } + + if (eligible.length === 0) { + throw new Error( + `Source ${sourceHandle} has no source map URL, and neither does any other source in this profile.` + ); + } + + const handles = eligible + .map( + (source) => + `${getSourceHandle(source.sourceIndex)} (${source.filename})` + ) + .join(', '); + throw new Error( + `Source ${sourceHandle} has no source map URL. Sources you can apply a map to: ${handles}.` + ); + } + + /** + * Translate the shared thunk's `ApplySourceMapFileResult` into the CLI's + * `ApplySourceMapResult`, converting source indexes into `src-N` handles. + */ + private _mapApplyResult( + result: ApplySourceMapFileResult + ): ApplySourceMapResult { + switch (result.type) { + case 'applied': + return { + type: 'sourcemap-applied', + sourceHandle: getSourceHandle(result.sourceIndex), + filename: result.filename, + }; + case 'no-match': + return { + type: 'sourcemap-unchanged', + sourceHandle: getSourceHandle(result.sourceIndex), + filename: result.filename, + }; + case 'ambiguous': + return { + type: 'sourcemap-ambiguous', + reason: result.reason, + candidates: result.candidates.map(toSourceEntry), + }; + case 'error': + return { type: 'sourcemap-error', error: result.error }; + default: + throw assertExhaustiveCheck(result); + } + } + /** * Map the current Redux transform stack for `threadsKey` to FilterEntry[], * grouping consecutive transforms that came from the same `filter push` diff --git a/src/profile-query/source-map.ts b/src/profile-query/source-map.ts index 513ec47a86..4ddfee18e7 100644 --- a/src/profile-query/source-map.ts +++ b/src/profile-query/source-map.ts @@ -2,6 +2,14 @@ * 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 path from 'path'; +import { pathToFileURL } from 'url'; +import { runSourceMapSymbolicationCore } from 'firefox-profiler/profile-logic/source-map-symbolication'; + +import type { + WorkerInput, + WorkerOutput, +} from 'firefox-profiler/profile-logic/source-map-worker-types'; import type { SourceMapLocation } from './types'; const DATA_URL_PREFIX = 'data:'; @@ -38,3 +46,22 @@ export function toSourceMapLocation(sourceMapURL: string): SourceMapLocation { byteLength: sourceMapURL.length, }; } + +/** + * Node replacement for the browser's `_runSourceMapWorker`. The daemon has no + * `Worker`, so it runs the symbolication core directly on the current thread + * (blocking is fine in a background process). + * + * The `source-map` package's Node build reads `lib/mappings.wasm` from + * `path.join(__dirname, 'mappings.wasm')` and its `initialize` is a no-op, so + * the `wasmUrl` we pass here is effectively ignored at runtime. We still pass a + * `__dirname`-relative path: when bundled by esbuild both this module and + * `source-map`'s reader share the same `__dirname`, and the build step copies + * `mappings.wasm` next to the bundle (see scripts/build-profiler-cli.mjs). + */ +export async function runSourceMapSymbolicationNode( + input: WorkerInput +): Promise { + const wasmUrl = pathToFileURL(path.join(__dirname, 'mappings.wasm')).href; + return runSourceMapSymbolicationCore(input, wasmUrl); +} diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index d0e05da3cf..1263ec84b5 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -1006,3 +1006,29 @@ export type SourceMapSourcesResult = { type: 'sourcemap-sources'; sources: SourceEntry[]; }; + +/** + * Outcome of `sourcemap apply`, mirroring `ApplySourceMapFileResult` from the + * web thunk. `unchanged` means the map was matched to a source but symbolication + * changed nothing. `ambiguous` and `error` map to a non-zero CLI exit. + * + * `ambiguous` covers both "the map matched several sources" and "the map + * matched none of them", which `reason` tells apart. `candidates` is what to + * pass to `--to` in either case: the sources that matched, or every eligible + * source when nothing matched. + */ +export type ApplySourceMapResult = + | { type: 'sourcemap-applied'; sourceHandle: string; filename: string } + | { type: 'sourcemap-unchanged'; sourceHandle: string; filename: string } + | { + type: 'sourcemap-ambiguous'; + reason: 'multiple-matches' | 'no-matches'; + candidates: SourceEntry[]; + } + | { + type: 'sourcemap-error'; + error: + | 'invalid-source-map' + | 'no-eligible-sources' + | 'symbolication-failed'; + }; diff --git a/src/test/store/source-map-symbolication.test.ts b/src/test/store/source-map-symbolication.test.ts index 3ff1ce9e7d..cc8393c5ad 100644 --- a/src/test/store/source-map-symbolication.test.ts +++ b/src/test/store/source-map-symbolication.test.ts @@ -546,6 +546,7 @@ describe('receive-profile -> JS source map symbolication', function () { if (result.type !== 'ambiguous') { throw new Error('expected ambiguous'); } + expect(result.reason).toBe('no-matches'); const candidateForA = result.candidates.find( (c) => c.filename === 'a.js' ); diff --git a/src/test/unit/source-map-matching.test.ts b/src/test/unit/source-map-matching.test.ts index 00dcefd319..332eefc4a1 100644 --- a/src/test/unit/source-map-matching.test.ts +++ b/src/test/unit/source-map-matching.test.ts @@ -181,6 +181,7 @@ describe('matchSourceMapToSource', function () { const result = matchSourceMapToSource(makeMap(), 'bundle.js.map', eligible); expect(result).toEqual({ type: 'ambiguous', + reason: 'multiple-matches', candidates: [eligible[0], eligible[1]], }); }); @@ -285,6 +286,7 @@ describe('matchSourceMapToSource', function () { ); expect(result).toEqual({ type: 'ambiguous', + reason: 'multiple-matches', candidates: [eligible[0], eligible[1]], }); }); @@ -307,6 +309,10 @@ describe('matchSourceMapToSource', function () { 'totally-unrelated.map', eligible ); - expect(result).toEqual({ type: 'ambiguous', candidates: eligible }); + expect(result).toEqual({ + type: 'ambiguous', + reason: 'no-matches', + candidates: eligible, + }); }); }); From 1d84c548abd43c789c1127326665d6f0044bfdbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Thu, 30 Jul 2026 18:33:14 +0200 Subject: [PATCH 5/5] Document source maps in the profiler-cli guide Adds a SOURCE MAPS section covering the sources -> apply flow and the --to disambiguation step, and lists src-N in the handle reference tables alongside t-N / f-N / c-N / ts-N. --- profiler-cli/guide.txt | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/profiler-cli/guide.txt b/profiler-cli/guide.txt index 6c7a6c8c76..b472c6cabd 100644 --- a/profiler-cli/guide.txt +++ b/profiler-cli/guide.txt @@ -125,6 +125,7 @@ HANDLE SYSTEM f-12 Function handles (from "thread samples", "thread functions") c-0, c-1 Counter handles (from "counter list" or "profile info") ts-6 Timestamp handles (named points in time, usable with "zoom push") + src-3 Source handles (from "sourcemap sources") Handle lifetime and stability: @@ -137,6 +138,8 @@ HANDLE SYSTEM c-N counter list Yes -- direct index into the profile's counter array; same profile always yields the same c-N ts-N thread markers No -- position-based, session-scoped + src-N sourcemap Yes -- direct index into the profile's source + sources table; same profile always yields the same src-N ────────────────────────────────────────────────────────────────────────── Function handles (f-N) can be saved and reused across sessions for the same profile. @@ -249,6 +252,24 @@ COUNTERS profiler-cli counter info c-0 +SOURCE MAPS + + If a JavaScript stack shows minified names (e.g. "a", "t.exports"), you can + de-minify it by applying a source map. + + profiler-cli sourcemap sources List sources that carry a source map URL + profiler-cli sourcemap apply bundle.js.map Apply a .map, auto-matching a source + + "apply" re-symbolicates the affected stacks in place. When the map could match + more than one source it exits non-zero and prints the candidates, so re-run with + one of their src-N handles: + + profiler-cli sourcemap apply bundle.js.map --to src-3 + + Maps that carry "sourcesContent" also give you the original source, so + "function annotate f-N" can show it with per-line sample counts. + + JSON OUTPUT Add --json to any command to get structured JSON output, suitable for piping to jq