diff --git a/profiler-cli/README.md b/profiler-cli/README.md index 2593de72ce..8d367ffd61 100644 --- a/profiler-cli/README.md +++ b/profiler-cli/README.md @@ -54,6 +54,8 @@ 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 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 @@ -93,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/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 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 9aff7cae56..b5d830ea88 100644 --- a/profiler-cli/schemas.txt +++ b/profiler-cli/schemas.txt @@ -235,3 +235,23 @@ 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 } + +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 new file mode 100644 index 0000000000..a55f1a1d8a --- /dev/null +++ b/profiler-cli/src/commands/sourcemap.ts @@ -0,0 +1,78 @@ +/* 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 * 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, + 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 + ); + }); + + 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 109fa51f8d..af22fa2c4c 100644 --- a/profiler-cli/src/daemon.ts +++ b/profiler-cli/src/daemon.ts @@ -557,6 +557,18 @@ export class Daemon { default: throw assertExhaustiveCheck(command); } + case 'sourcemap': + 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); + } case 'status': return this.querier.getStatus(); default: diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index a3f0c08fc3..02c74104c1 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -42,7 +42,12 @@ import type { CounterSummary, CounterListResult, CounterInfoResult, + SourceEntry, + SourceMapLocation, + SourceMapSourcesResult, + ApplySourceMapResult, } 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 +2097,74 @@ 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, shared by sources and ambiguous. */ +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')}`; +} + +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 7d5a2a8986..aa5590f993 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,8 @@ 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 sourcemap apply bundle.js.map profiler-cli status profiler-cli stop --all` ); @@ -193,6 +196,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..da258a7a29 100644 --- a/profiler-cli/src/output.ts +++ b/profiler-cli/src/output.ts @@ -31,6 +31,8 @@ import { formatThreadSelectResult, formatCounterListResult, formatCounterInfoResult, + formatSourceMapSourcesResult, + formatApplySourceMapResult, } from './formatters'; /** @@ -97,6 +99,13 @@ export function formatOutput( return formatCounterListResult(result); case 'counter-info': 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 3cc416d002..98aa1b4986 100644 --- a/profiler-cli/src/protocol.ts +++ b/profiler-cli/src/protocol.ts @@ -59,6 +59,10 @@ export type { CounterSummary, CounterListResult, CounterInfoResult, + SourceEntry, + SourceMapLocation, + SourceMapSourcesResult, + ApplySourceMapResult, } from '../../src/profile-query/types'; export type { CallTreeCollectionOptions } from '../../src/profile-query/formatters/call-tree'; @@ -92,6 +96,8 @@ import type { ThreadSelectResult, CounterListResult, CounterInfoResult, + SourceMapSourcesResult, + ApplySourceMapResult, } from '../../src/profile-query/types'; import type { CallTreeCollectionOptions } from '../../src/profile-query/formatters/call-tree'; @@ -186,6 +192,14 @@ export type ClientCommand = spec?: SampleFilterSpec; count?: number; } + | { + command: 'sourcemap'; + 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' }; export type ServerResponse = @@ -221,7 +235,9 @@ export type CommandResult = | WithContext | 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..738e0446fd --- /dev/null +++ b/profiler-cli/src/test/integration/sourcemap.test.ts @@ -0,0 +1,254 @@ +/* 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,apply}`. + * + * 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, + cliFail, + type CliTestContext, +} from './utils'; + +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; + + 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', + }); + }); + + 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-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..2a7ac53bee --- /dev/null +++ b/profiler-cli/src/test/unit/sourcemap-formatting.test.ts @@ -0,0 +1,189 @@ +/* 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, + formatApplySourceMapResult, +} from '../../formatters'; +import type { + SessionContext, + SourceEntry, + SourceMapSourcesResult, + ApplySourceMapResult, + 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' + ); + }); +}); + +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 009569d9e5..781564d9a9 100644 --- a/src/actions/source-map-symbolication.ts +++ b/src/actions/source-map-symbolication.ts @@ -15,14 +15,18 @@ 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'; /** * 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 +38,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 +69,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 @@ -106,11 +119,20 @@ 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 } - | { type: 'ambiguous'; candidates: EligibleSource[] } + // `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 } + // 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 }; /** @@ -123,7 +145,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); @@ -146,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; @@ -169,14 +198,15 @@ export function applySourceMapFile( const outcome = await dispatch( doSourceMapSymbolication( new Map([[targetSourceIndex, map]]), - compiledSources + compiledSources, + run ) ); 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/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/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..11d58a0cf8 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -18,9 +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, @@ -43,6 +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, 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'; @@ -71,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, @@ -101,6 +120,9 @@ import type { ProfileLogsResult, CounterListResult, CounterInfoResult, + SourceMapSourcesResult, + SourceEntry, + ApplySourceMapResult, MarkerFilterOptions, FunctionFilterOptions, SampleFilterSpec, @@ -112,6 +134,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 +601,133 @@ 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(), + }; + } + + /** + * 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-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..4ddfee18e7 --- /dev/null +++ b/src/profile-query/source-map.ts @@ -0,0 +1,67 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as 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:'; + +/** + * 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, + }; +} + +/** + * 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 3fba1cdf44..1263ec84b5 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -975,3 +975,60 @@ 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[]; +}; + +/** + * 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/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..cc8393c5ad 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()); @@ -542,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' ); @@ -554,7 +559,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'); 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, + }); + }); +}); 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, + }); }); });