diff --git a/apps/cli-docs/src/content/docs/migrating-from-v3.md b/apps/cli-docs/src/content/docs/migrating-from-v3.md index 4f1b22fd04..2555dd361c 100644 --- a/apps/cli-docs/src/content/docs/migrating-from-v3.md +++ b/apps/cli-docs/src/content/docs/migrating-from-v3.md @@ -484,6 +484,14 @@ Two behavioral differences to be aware of: `--wait`, `--wait-for`, `--no-sourcemap-reference`, `--debug-id-reference`, `--bundle`, `--bundle-sourcemap`, `--strict`. `sourcemap inject` also drops `--release`. Run `sentry sourcemap upload --help` for the current set. +- **`--debug-id-reference` is now automatic:** in v3 that flag let `sourcemaps + upload` take the debug ID from the linked sourcemap when it couldn't verify + one in the bundle itself, for example in binary bundles. + v4 does this by default. If a sourcemap already carries `debug_id` + — from a bundler plugin configured with `sourcemaps.disable: + 'disable-upload'`, or copied across by a tool like React Native's + `copy-debugid.js` — `inject` and `upload` adopt that ID rather than generating a + new one. See [`sourcemap`](/commands/sourcemap/) for details. diff --git a/packages/cli/src/lib/sourcemap/debug-id.ts b/packages/cli/src/lib/sourcemap/debug-id.ts index b8b2a990fb..8358830bb1 100644 --- a/packages/cli/src/lib/sourcemap/debug-id.ts +++ b/packages/cli/src/lib/sourcemap/debug-id.ts @@ -21,6 +21,7 @@ import { createHash } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; +import { UUID_DASH_RE } from "../hex-id.js"; import { logger } from "../logger.js"; import { type DecodedInlineMap, @@ -35,6 +36,29 @@ const DEBUGID_COMMENT_PREFIX = "//# debugId="; /** Regex to extract an existing debug ID from a JS file. @internal */ export const EXISTING_DEBUGID_RE = /\/\/# debugId=([0-9a-fA-F-]{36})/; +/** + * Read a pre-existing debug ID off a parsed sourcemap. + * + * `debug_id` wins over `debugId` when both are present. A value that is not a + * well-formed UUID is treated as absent so the caller falls through to minting. + * + * @param map - A parsed sourcemap object (any shape; fields are probed) + * @returns The debug ID, or `undefined` when the map carries none + * @internal + */ +export function readSourcemapDebugId(map: unknown): string | undefined { + if (typeof map !== "object" || map === null) { + return; + } + const { debug_id: snake, debugId: camel } = map as SourcemapJson; + for (const candidate of [snake, camel]) { + if (typeof candidate === "string" && UUID_DASH_RE.test(candidate)) { + return candidate; + } + } + return; +} + /** * Generate a deterministic debug ID (UUID v4 format) from content. * @@ -118,7 +142,9 @@ export function prependDebugIdSnippet( * registered in source code (`constants.ts`) instead of via the IIFE. * * The operation is **idempotent** — files that already contain a - * `//# debugId=` comment are returned unchanged. + * `//# debugId=` comment are returned unchanged. A debug ID already present + * on the sourcemap is likewise adopted as-is, leaving both files untouched + * (see {@link readSourcemapDebugId}). * * @param jsPath - Path to the JavaScript file * @param mapPath - Path to the companion `.map` file @@ -142,6 +168,19 @@ export async function injectDebugId( return { debugId: existingMatch[1], wasInjected: false }; } + const map = JSON.parse(mapContent) as SourcemapJson; + + // The JS carries no comment, but the map may already have been stamped by a + // bundler plugin that intentionally left the bundle alone. Adopt that ID and + // touch neither file: the bundle already registers it via the plugin's own + // `_sentryDebugIds` writer (a second snippet under a different stack key + // would make the runtime mapping ambiguous), and the map's `mappings` line + // up with the un-offset bundle. + const mapDebugId = readSourcemapDebugId(map); + if (mapDebugId) { + return { debugId: mapDebugId, wasInjected: false }; + } + // Derive the debug ID from the minified JS content combined with the // sourcemap content. Hashing both guarantees that distinct minified // artifacts receive distinct debug IDs even when their sourcemaps are @@ -168,7 +207,6 @@ export async function injectDebugId( newJs += `\n${DEBUGID_COMMENT_PREFIX}${debugId}\n`; // --- Mutate sourcemap --- - const map = JSON.parse(mapContent) as SourcemapJson; mutateSourcemap(map, debugId, { offsetMappings: !skipSnippet }); // Write both files concurrently @@ -244,7 +282,8 @@ const INLINE_DIRECTIVE_RE = * place**, so the file stays self-contained. Only the **last** inline * directive is rewritten. * - * Idempotent — files already carrying a `//# debugId=` comment are unchanged. + * Idempotent — files already carrying a `//# debugId=` comment are unchanged, + * as are files whose decoded inline map already carries a debug ID. * * @param jsPath - Path to the JavaScript file * @param decoded - The decoded inline sourcemap and its re-encode metadata @@ -282,6 +321,18 @@ export async function injectInlineDebugId( }; } + // Same rule as the external path: a debug ID already stamped on the map by a + // bundler plugin is adopted verbatim, leaving the JS (and its embedded map) + // untouched. Upload the map exactly as decoded. + const mapDebugId = readSourcemapDebugId(decoded.map); + if (mapDebugId) { + return { + debugId: mapDebugId, + wasInjected: false, + injectedMapContent: Buffer.from(decoded.json), + }; + } + // Locate the LAST inline directive to rewrite. If it can't be found (the // discovery parser and this regex disagree on an edge case), abort WITHOUT // modifying the file. Return an EMPTY debug ID and no map content so the diff --git a/packages/cli/src/lib/sourcemap/inject.ts b/packages/cli/src/lib/sourcemap/inject.ts index d0f64fcdfe..0c51b38593 100644 --- a/packages/cli/src/lib/sourcemap/inject.ts +++ b/packages/cli/src/lib/sourcemap/inject.ts @@ -16,6 +16,7 @@ import { EXISTING_DEBUGID_RE, injectDebugId, injectInlineDebugId, + readSourcemapDebugId, } from "./debug-id.js"; import { type DecodedInlineMap, @@ -101,17 +102,18 @@ export async function injectDirectory( for (const { jsPath, map } of filePairs) { const mapPath = map.kind === "external" ? map.mapPath : undefined; if (options.dryRun) { - // Check if file already has a debug ID without modifying it + // Resolve the debug ID the real run would use, without modifying + // anything. Mirrors injectDebugId's precedence: the JS comment first, + // then an ID the sourcemap already carries — either means no injection. const js = await readFile(jsPath, "utf-8"); - const existing = js.match(EXISTING_DEBUGID_RE); - const wouldInject = !existing; - const id = existing?.[1] ?? "(pending)"; + const existing = + js.match(EXISTING_DEBUGID_RE)?.[1] ?? (await readMapDebugId(map)); results.push({ jsPath, map, mapPath, - injected: wouldInject, - debugId: id, + injected: !existing, + debugId: existing ?? "(pending)", }); continue; } @@ -139,6 +141,31 @@ export async function injectDirectory( return results; } +/** + * Read a debug ID already present on a discovered sourcemap, if any. + * + * Non-fatal: an unreadable or malformed map yields `undefined`, so callers + * fall through to minting a content-derived ID exactly as before. + * + * @param map - The discovered sourcemap location + * @returns The map's debug ID, or `undefined` when it carries none + */ +async function readMapDebugId(map: MapSource): Promise { + if (map.kind === "inline") { + return readSourcemapDebugId(map.decoded.map); + } + try { + return readSourcemapDebugId( + JSON.parse(await readFile(map.mapPath, "utf-8")) + ); + } catch (err) { + log.warn( + `could not read a debug ID from ${map.mapPath}: ${err instanceof Error ? err.message : String(err)}` + ); + return; + } +} + /** A discovered JS + sourcemap pair. */ export type FilePair = { jsPath: string; map: MapSource }; @@ -781,6 +808,12 @@ export async function resolveDirectorySourcemaps( } catch (err) { log.debug(`failed to read JS file for debug ID: ${jsPath}`, err); } + // No comment in the JS: the linked map may still carry the ID a bundler + // plugin stamped at build time, which injection would adopt rather than + // replace. + if (!debugId && map) { + debugId = await readMapDebugId(map); + } results.push({ jsPath, diff --git a/packages/cli/test/commands/sourcemap/upload.test.ts b/packages/cli/test/commands/sourcemap/upload.test.ts index 17e6d41146..6d7c8a3aa7 100644 --- a/packages/cli/test/commands/sourcemap/upload.test.ts +++ b/packages/cli/test/commands/sourcemap/upload.test.ts @@ -4,8 +4,7 @@ * branches in `buildEmptyDiscoveryError`. */ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { readFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -47,12 +46,12 @@ describe("sourcemap inject command — --allow-empty behavior", () => { let func: CmdFunc; beforeEach(async () => { - dir = mkdtempSync(join(tmpdir(), "sentry-inject-cmd-")); + dir = await mkdtemp(join(tmpdir(), "sentry-inject-cmd-")); func = (await injectCommand.loader()) as unknown as CmdFunc; }); - afterEach(() => { - rmSync(dir, { recursive: true, force: true }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); }); test("empty directory: throws actionable ValidationError", async () => { @@ -77,15 +76,15 @@ describe("sourcemap inject command — --allow-empty behavior", () => { }); test("directory with a .js + .map pair: succeeds (0 pairs guard not triggered)", async () => { - writeFileSync(join(dir, "app.js"), "console.log(1)\n"); - writeFileSync(join(dir, "app.js.map"), '{"version":3}\n'); + await writeFile(join(dir, "app.js"), "console.log(1)\n"); + await writeFile(join(dir, "app.js.map"), '{"version":3}\n'); const ctx = makeContext(); await expect(func.call(ctx, {}, dir)).resolves.toBeUndefined(); }); test(".js files without matching .map files: throws with bundler hint", async () => { - writeFileSync(join(dir, "app.js"), "console.log(1)\n"); - writeFileSync(join(dir, "other.js"), "console.log(2)\n"); + await writeFile(join(dir, "app.js"), "console.log(1)\n"); + await writeFile(join(dir, "other.js"), "console.log(2)\n"); const ctx = makeContext(); try { await func.call(ctx, {}, dir); @@ -100,7 +99,7 @@ describe("sourcemap inject command — --allow-empty behavior", () => { }); test(".map files without matching .js files: throws with mismatch hint", async () => { - writeFileSync(join(dir, "app.js.map"), '{"version":3}\n'); + await writeFile(join(dir, "app.js.map"), '{"version":3}\n'); const ctx = makeContext(); try { await func.call(ctx, {}, dir); @@ -114,8 +113,8 @@ describe("sourcemap inject command — --allow-empty behavior", () => { }); test("js and map present but no basename match: reports both counts", async () => { - writeFileSync(join(dir, "app.abc123.js"), "console.log(1)\n"); - writeFileSync(join(dir, "app.js.map"), '{"version":3}\n'); + await writeFile(join(dir, "app.abc123.js"), "console.log(1)\n"); + await writeFile(join(dir, "app.js.map"), '{"version":3}\n'); const ctx = makeContext(); try { await func.call(ctx, {}, dir); @@ -146,7 +145,7 @@ describe("sourcemap inject command — --allow-empty behavior", () => { test("path is a file, not a directory: throws with distinct message", async () => { const filePath = join(dir, "not-a-dir.txt"); - writeFileSync(filePath, "hello\n"); + await writeFile(filePath, "hello\n"); const ctx = makeContext(); try { await func.call(ctx, {}, filePath); @@ -175,12 +174,12 @@ describe("sourcemap inject command — --allow-empty behavior", () => { test("sourceMappingURL: follows external map reference when convention fails", async () => { // JS file with sourceMappingURL pointing to a differently-named map - writeFileSync( + await writeFile( join(dir, "bundle.js"), "console.log(1)\n//# sourceMappingURL=bundle.abc123.js.map\n" ); // Map file with non-convention name (no bundle.js.map exists) - writeFileSync( + await writeFile( join(dir, "bundle.abc123.js.map"), JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) ); @@ -190,17 +189,17 @@ describe("sourcemap inject command — --allow-empty behavior", () => { test("sourceMappingURL: prefers convention naming over directive", async () => { // JS file with sourceMappingURL pointing to a different file - writeFileSync( + await writeFile( join(dir, "app.js"), "console.log(1)\n//# sourceMappingURL=other.js.map\n" ); // Convention map exists — should be used - writeFileSync( + await writeFile( join(dir, "app.js.map"), JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) ); // The directive target also exists - writeFileSync( + await writeFile( join(dir, "other.js.map"), JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) ); @@ -211,7 +210,7 @@ describe("sourcemap inject command — --allow-empty behavior", () => { test("sourceMappingURL: valid inline data: URL is injected (1 pair)", async () => { // eyJ2ZXJzaW9uIjozfQ== === {"version":3} - writeFileSync( + await writeFile( join(dir, "inline.js"), "console.log(1)\n//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozfQ==\n" ); @@ -221,7 +220,7 @@ describe("sourcemap inject command — --allow-empty behavior", () => { }); test("sourceMappingURL: invalid inline base64 is skipped (zero pairs)", async () => { - writeFileSync( + await writeFile( join(dir, "bad-inline.js"), "console.log(1)\n//# sourceMappingURL=data:application/json;base64,@@@not-base64@@@\n" ); @@ -239,7 +238,7 @@ describe("sourcemap upload command — --allow-empty behavior", () => { let savedEnv: Record; beforeEach(async () => { - dir = mkdtempSync(join(tmpdir(), "sentry-upload-cmd-")); + dir = await mkdtemp(join(tmpdir(), "sentry-upload-cmd-")); // Short-circuit resolveOrgAndProject so tests don't need DSN/config. savedEnv = { SENTRY_ORG: process.env.SENTRY_ORG, @@ -250,8 +249,8 @@ describe("sourcemap upload command — --allow-empty behavior", () => { func = (await uploadCommand.loader()) as unknown as CmdFunc; }); - afterEach(() => { - rmSync(dir, { recursive: true, force: true }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); for (const [k, v] of Object.entries(savedEnv)) { if (v === undefined) { delete process.env[k]; @@ -294,8 +293,8 @@ describe("sourcemap upload command — --allow-empty behavior", () => { }); test("directory with .js files but no .map files: throws", async () => { - mkdirSync(join(dir, "_astro")); - writeFileSync(join(dir, "_astro", "app.js"), "console.log(1)\n"); + await mkdir(join(dir, "_astro")); + await writeFile(join(dir, "_astro", "app.js"), "console.log(1)\n"); const ctx = makeContext(); await expect(func.call(ctx, {}, dir)).rejects.toBeInstanceOf( ValidationError @@ -321,10 +320,10 @@ describe("sourcemap upload command — --allow-empty behavior", () => { test("error path does not mutate files (js-only dir)", async () => { // Discovery must be read-only — injection only runs once we've // decided the upload will proceed. - mkdirSync(join(dir, "_astro")); + await mkdir(join(dir, "_astro")); const jsPath = join(dir, "_astro", "app.js"); const original = "console.log(1)\n"; - writeFileSync(jsPath, original); + await writeFile(jsPath, original); const ctx = makeContext(); await expect(func.call(ctx, {}, dir)).rejects.toBeInstanceOf( ValidationError @@ -336,11 +335,11 @@ describe("sourcemap upload command — --allow-empty behavior", () => { }); test("happy path: directory with JS+map pair invokes uploadSourcemaps", async () => { - mkdirSync(join(dir, "_astro")); + await mkdir(join(dir, "_astro")); const jsPath = join(dir, "_astro", "app.js"); const mapPath = join(dir, "_astro", "app.js.map"); - writeFileSync(jsPath, "console.log(1)\n"); - writeFileSync( + await writeFile(jsPath, "console.log(1)\n"); + await writeFile( mapPath, JSON.stringify({ version: 3, @@ -373,7 +372,10 @@ describe("sourcemap upload command — --allow-empty behavior", () => { const jsPath = join(dir, "inline.js"); const map = { version: 3, sources: ["a.ts"], names: [], mappings: "AAAA" }; const dataUrl = `data:application/json;base64,${Buffer.from(JSON.stringify(map)).toString("base64")}`; - writeFileSync(jsPath, `console.log(1)\n//# sourceMappingURL=${dataUrl}\n`); + await writeFile( + jsPath, + `console.log(1)\n//# sourceMappingURL=${dataUrl}\n` + ); const uploadSpy = vi .spyOn(sourcemapsApi, "uploadSourcemaps") @@ -404,7 +406,10 @@ describe("sourcemap upload command — --allow-empty behavior", () => { const jsPath = join(dir, "inline-norw.js"); const map = { version: 3, sources: ["b.ts"], names: [], mappings: "BBBB" }; const dataUrl = `data:application/json;base64,${Buffer.from(JSON.stringify(map)).toString("base64")}`; - writeFileSync(jsPath, `console.log(1)\n//# sourceMappingURL=${dataUrl}\n`); + await writeFile( + jsPath, + `console.log(1)\n//# sourceMappingURL=${dataUrl}\n` + ); const uploadSpy = vi .spyOn(sourcemapsApi, "uploadSourcemaps") @@ -432,9 +437,9 @@ describe("sourcemap upload command — --allow-empty behavior", () => { }); test("--dist flag: passes dist to uploadSourcemaps", async () => { - mkdirSync(join(dir, "_astro")); - writeFileSync(join(dir, "_astro", "app.js"), "console.log(1)\n"); - writeFileSync( + await mkdir(join(dir, "_astro")); + await writeFile(join(dir, "_astro", "app.js"), "console.log(1)\n"); + await writeFile( join(dir, "_astro", "app.js.map"), JSON.stringify({ version: 3, @@ -460,12 +465,12 @@ describe("sourcemap upload command — --allow-empty behavior", () => { }); test("--no-rewrite: uploads without injecting debug IDs", async () => { - mkdirSync(join(dir, "_astro")); + await mkdir(join(dir, "_astro")); const jsPath = join(dir, "_astro", "app.js"); const mapPath = join(dir, "_astro", "app.js.map"); const originalJs = "console.log(1)\n"; - writeFileSync(jsPath, originalJs); - writeFileSync( + await writeFile(jsPath, originalJs); + await writeFile( mapPath, JSON.stringify({ version: 3, @@ -497,8 +502,8 @@ describe("sourcemap upload command — --allow-empty behavior", () => { }); test("--ext: discovers files with custom extensions", async () => { - writeFileSync(join(dir, "app.ts"), "console.log(1)\n"); - writeFileSync( + await writeFile(join(dir, "app.ts"), "console.log(1)\n"); + await writeFile( join(dir, "app.ts.map"), JSON.stringify({ version: 3, @@ -508,7 +513,7 @@ describe("sourcemap upload command — --allow-empty behavior", () => { }) ); // A .js file that should NOT be discovered when --ext is .ts - writeFileSync(join(dir, "other.js"), "console.log(2)\n"); + await writeFile(join(dir, "other.js"), "console.log(2)\n"); const uploadSpy = vi .spyOn(sourcemapsApi, "uploadSourcemaps") @@ -528,16 +533,16 @@ describe("sourcemap upload command — --allow-empty behavior", () => { }); test("--ignore: excludes matching files from upload", async () => { - mkdirSync(join(dir, "vendor")); + await mkdir(join(dir, "vendor")); // File that should be excluded - writeFileSync(join(dir, "vendor", "lib.js"), "console.log(1)\n"); - writeFileSync( + await writeFile(join(dir, "vendor", "lib.js"), "console.log(1)\n"); + await writeFile( join(dir, "vendor", "lib.js.map"), JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) ); // File that should be included - writeFileSync(join(dir, "app.js"), "console.log(2)\n"); - writeFileSync( + await writeFile(join(dir, "app.js"), "console.log(2)\n"); + await writeFile( join(dir, "app.js.map"), JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) ); @@ -561,20 +566,20 @@ describe("sourcemap upload command — --allow-empty behavior", () => { }); test("--ignore-file: reads patterns from a file", async () => { - mkdirSync(join(dir, "vendor")); - writeFileSync(join(dir, "vendor", "lib.js"), "console.log(1)\n"); - writeFileSync( + await mkdir(join(dir, "vendor")); + await writeFile(join(dir, "vendor", "lib.js"), "console.log(1)\n"); + await writeFile( join(dir, "vendor", "lib.js.map"), JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) ); - writeFileSync(join(dir, "app.js"), "console.log(2)\n"); - writeFileSync( + await writeFile(join(dir, "app.js"), "console.log(2)\n"); + await writeFile( join(dir, "app.js.map"), JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) ); // Write an ignore file const ignoreFilePath = join(dir, ".sourcemapignore"); - writeFileSync(ignoreFilePath, "vendor/\n"); + await writeFile(ignoreFilePath, "vendor/\n"); const uploadSpy = vi .spyOn(sourcemapsApi, "uploadSourcemaps") @@ -594,8 +599,8 @@ describe("sourcemap upload command — --allow-empty behavior", () => { }); test("--ignore-file with non-existent file: throws ValidationError", async () => { - writeFileSync(join(dir, "app.js"), "console.log(1)\n"); - writeFileSync( + await writeFile(join(dir, "app.js"), "console.log(1)\n"); + await writeFile( join(dir, "app.js.map"), JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) ); @@ -610,9 +615,9 @@ describe("sourcemap upload command — --allow-empty behavior", () => { }); test("--strip-prefix: removes explicit prefix from uploaded URLs", async () => { - mkdirSync(join(dir, "static", "js"), { recursive: true }); - writeFileSync(join(dir, "static", "js", "app.js"), "console.log(1)\n"); - writeFileSync( + await mkdir(join(dir, "static", "js"), { recursive: true }); + await writeFile(join(dir, "static", "js", "app.js"), "console.log(1)\n"); + await writeFile( join(dir, "static", "js", "app.js.map"), JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) ); @@ -635,17 +640,20 @@ describe("sourcemap upload command — --allow-empty behavior", () => { }); test("--strip-common-prefix: auto-strips shared directory prefix", async () => { - mkdirSync(join(dir, "build", "output"), { recursive: true }); - writeFileSync(join(dir, "build", "output", "main.js"), "console.log(1)\n"); - writeFileSync( + await mkdir(join(dir, "build", "output"), { recursive: true }); + await writeFile( + join(dir, "build", "output", "main.js"), + "console.log(1)\n" + ); + await writeFile( join(dir, "build", "output", "main.js.map"), JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) ); - writeFileSync( + await writeFile( join(dir, "build", "output", "vendor.js"), "console.log(2)\n" ); - writeFileSync( + await writeFile( join(dir, "build", "output", "vendor.js.map"), JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) ); @@ -669,8 +677,8 @@ describe("sourcemap upload command — --allow-empty behavior", () => { }); test("--strip-prefix + --strip-common-prefix: mutually exclusive", async () => { - writeFileSync(join(dir, "app.js"), "console.log(1)\n"); - writeFileSync( + await writeFile(join(dir, "app.js"), "console.log(1)\n"); + await writeFile( join(dir, "app.js.map"), JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) ); @@ -688,19 +696,19 @@ describe("sourcemap upload command — --allow-empty behavior", () => { // Simulates a concatenated bundle with two directives in the tail. // No convention map (concat.js.map) exists, so discovery falls back // to sourceMappingURL. The last directive should win. - writeFileSync( + await writeFile( join(dir, "concat.js"), "console.log(1)\n" + "//# sourceMappingURL=wrong.js.map\n" + "console.log(2)\n" + "//# sourceMappingURL=correct.js.map\n" ); - writeFileSync( + await writeFile( join(dir, "correct.js.map"), JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) ); // wrong.js.map also exists — but the last directive should win - writeFileSync( + await writeFile( join(dir, "wrong.js.map"), JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) ); @@ -721,4 +729,79 @@ describe("sourcemap upload command — --allow-empty behavior", () => { uploadSpy.mockRestore(); } }); + + test("pre-existing map debug ID: uploaded on both entries, files untouched", async () => { + // What a bundler plugin running with `sourcemaps.disable: 'disable-upload'` + // emits: the ID lives on the map, and the bundle is left alone so its + // subresource-integrity hash stays valid. + const pluginId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + const jsPath = join(dir, "app.js"); + const mapPath = join(dir, "app.js.map"); + const js = `;!function(){e._sentryDebugIdIdentifier="sentry-dbid-${pluginId}"}();\nconsole.log(1)\n//# sourceMappingURL=app.js.map\n`; + const map = JSON.stringify({ + version: 3, + sources: ["app.ts"], + names: [], + mappings: "AAAA", + debug_id: pluginId, + }); + await writeFile(jsPath, js); + await writeFile(mapPath, map); + + const uploadSpy = vi + .spyOn(sourcemapsApi, "uploadSourcemaps") + .mockResolvedValue(undefined); + try { + const ctx = makeContext(); + await func.call(ctx, {}, dir); + const files = uploadSpy.mock.calls[0]?.[0]?.files ?? []; + expect(files).toHaveLength(2); + expect(files.find((f) => f.type === "minified_source")?.debugId).toBe( + pluginId + ); + expect(files.find((f) => f.type === "source_map")?.debugId).toBe( + pluginId + ); + expect(await readFile(jsPath, "utf-8")).toBe(js); + expect(await readFile(mapPath, "utf-8")).toBe(map); + } finally { + uploadSpy.mockRestore(); + } + }); + + test("pre-existing inline map debug ID: uploaded on both entries", async () => { + const pluginId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + const jsPath = join(dir, "inline-plugin.js"); + const map = { + version: 3, + sources: ["a.ts"], + names: [], + mappings: "AAAA", + debug_id: pluginId, + }; + const dataUrl = `data:application/json;base64,${Buffer.from(JSON.stringify(map)).toString("base64")}`; + const js = `console.log(1)\n//# sourceMappingURL=${dataUrl}\n`; + await writeFile(jsPath, js); + + const uploadSpy = vi + .spyOn(sourcemapsApi, "uploadSourcemaps") + .mockResolvedValue(undefined); + try { + const ctx = makeContext(); + await func.call(ctx, {}, dir); + const files = uploadSpy.mock.calls[0]?.[0]?.files ?? []; + expect(files).toHaveLength(2); + const mapFile = files.find((f) => f.type === "source_map"); + expect(files.find((f) => f.type === "minified_source")?.debugId).toBe( + pluginId + ); + expect(mapFile?.debugId).toBe(pluginId); + expect( + JSON.parse((mapFile?.content as Buffer).toString("utf-8")).debug_id + ).toBe(pluginId); + expect(await readFile(jsPath, "utf-8")).toBe(js); + } finally { + uploadSpy.mockRestore(); + } + }); }); diff --git a/packages/cli/test/lib/sourcemap/inject.test.ts b/packages/cli/test/lib/sourcemap/inject.test.ts index d66736acf2..4d1c94975e 100644 --- a/packages/cli/test/lib/sourcemap/inject.test.ts +++ b/packages/cli/test/lib/sourcemap/inject.test.ts @@ -426,3 +426,159 @@ describe("injectDirectory — debug ID uniqueness (regression #3350)", () => { expect(ids["a.js"]).toBe(ids["b.js"]); }); }); + +/** + * A debug ID already stamped on the sourcemap by a bundler plugin + * (`sourcemaps.disable: 'disable-upload'`) must be adopted, not replaced. + * Those builds deliberately leave the bundle without a `//# debugId=` comment + * so post-emit rewriting can't invalidate subresource-integrity hashes. + */ +describe("injectDirectory — pre-existing sourcemap debug ID", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "sentry-inject-mapid-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + /** A debug ID a bundler plugin would have minted at build time. */ + const PLUGIN_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + + const BASE_MAP = { + version: 3, + sources: ["a.ts"], + names: [], + mappings: "AAAA", + }; + + /** Build a `data:` URL for a sourcemap object. */ + function toDataUrl(map: unknown): string { + const b64 = Buffer.from(JSON.stringify(map)).toString("base64"); + return `data:application/json;base64,${b64}`; + } + + /** + * Write what the plugin emits: a bundle carrying its own `_sentryDebugIds` + * writer but no `//# debugId=` comment, plus a map holding the ID. + */ + function writePluginPair( + name: string, + mapExtra: Record + ): { jsPath: string; mapPath: string; js: string; map: string } { + const jsPath = join(dir, name); + const mapPath = `${jsPath}.map`; + const js = `;!function(){e._sentryDebugIdIdentifier="sentry-dbid-${PLUGIN_ID}"}();\nconsole.log(1)\n//# sourceMappingURL=${name}.map\n`; + const map = JSON.stringify({ ...BASE_MAP, ...mapExtra }); + writeFileSync(jsPath, js); + writeFileSync(mapPath, map); + return { jsPath, mapPath, js, map }; + } + + test("adopts a `debug_id` from an external map without touching either file", async () => { + const pair = writePluginPair("bundle.js", { debug_id: PLUGIN_ID }); + + const results = await injectDirectory(dir); + + expect(results).toHaveLength(1); + expect(results[0]?.debugId).toBe(PLUGIN_ID); + expect(results[0]?.injected).toBe(false); + expect(readFileSync(pair.jsPath, "utf-8")).toBe(pair.js); + expect(readFileSync(pair.mapPath, "utf-8")).toBe(pair.map); + }); + + test("adopts the camelCase `debugId` spelling", async () => { + const pair = writePluginPair("bundle.js", { debugId: PLUGIN_ID }); + + const results = await injectDirectory(dir); + + expect(results[0]?.debugId).toBe(PLUGIN_ID); + expect(results[0]?.injected).toBe(false); + expect(readFileSync(pair.jsPath, "utf-8")).toBe(pair.js); + expect(readFileSync(pair.mapPath, "utf-8")).toBe(pair.map); + }); + + test("prefers `debug_id` when both spellings disagree", async () => { + const other = "11111111-2222-3333-4444-555555555555"; + writePluginPair("bundle.js", { debug_id: PLUGIN_ID, debugId: other }); + + const results = await injectDirectory(dir); + + expect(results[0]?.debugId).toBe(PLUGIN_ID); + }); + + test("adopts a debug ID carried by an inline map", async () => { + const jsPath = join(dir, "inline.js"); + const js = `console.log(1)\n//# sourceMappingURL=${toDataUrl({ ...BASE_MAP, debug_id: PLUGIN_ID })}\n`; + writeFileSync(jsPath, js); + + const results = await injectDirectory(dir); + + expect(results[0]?.debugId).toBe(PLUGIN_ID); + expect(results[0]?.injected).toBe(false); + expect(readFileSync(jsPath, "utf-8")).toBe(js); + // The map is uploaded exactly as decoded — no snippet, so no line offset. + const uploaded = JSON.parse( + (results[0]?.injectedMapContent ?? Buffer.alloc(0)).toString() + ); + expect(uploaded.debug_id).toBe(PLUGIN_ID); + expect(uploaded.mappings).toBe(BASE_MAP.mappings); + }); + + test("a `//# debugId=` comment in the JS wins over a conflicting map field", async () => { + const jsPath = join(dir, "bundle.js"); + const jsId = "99999999-8888-7777-6666-555555555555"; + writeFileSync( + jsPath, + `console.log(1)\n//# sourceMappingURL=bundle.js.map\n//# debugId=${jsId}\n` + ); + writeFileSync( + `${jsPath}.map`, + JSON.stringify({ ...BASE_MAP, debug_id: PLUGIN_ID }) + ); + + const results = await injectDirectory(dir); + + expect(results[0]?.debugId).toBe(jsId); + expect(results[0]?.injected).toBe(false); + }); + + test("falls through to minting when the map's debug ID is malformed", async () => { + const pair = writePluginPair("bundle.js", { debug_id: "not-a-uuid" }); + + const results = await injectDirectory(dir); + + expect(results[0]?.injected).toBe(true); + expect(results[0]?.debugId).toMatch(/^[0-9a-f-]{36}$/); + expect(results[0]?.debugId).not.toBe("not-a-uuid"); + const js = readFileSync(pair.jsPath, "utf-8"); + expect(js).toContain(`//# debugId=${results[0]?.debugId}`); + expect(JSON.parse(readFileSync(pair.mapPath, "utf-8")).debug_id).toBe( + results[0]?.debugId + ); + }); + + test("--dry-run reports the adopted ID rather than a pending injection", async () => { + const pair = writePluginPair("bundle.js", { debug_id: PLUGIN_ID }); + + const results = await injectDirectory(dir, { dryRun: true }); + + expect(results[0]?.debugId).toBe(PLUGIN_ID); + expect(results[0]?.injected).toBe(false); + expect(readFileSync(pair.jsPath, "utf-8")).toBe(pair.js); + expect(readFileSync(pair.mapPath, "utf-8")).toBe(pair.map); + }); + + test("repeated runs stay a no-op", async () => { + const pair = writePluginPair("bundle.js", { debug_id: PLUGIN_ID }); + + await injectDirectory(dir); + const results = await injectDirectory(dir); + + expect(results[0]?.debugId).toBe(PLUGIN_ID); + expect(readFileSync(pair.jsPath, "utf-8")).toBe(pair.js); + expect(readFileSync(pair.mapPath, "utf-8")).toBe(pair.map); + }); +});