From 54118812766720740169f927f92c7a120641f903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Wed, 23 Sep 2026 20:29:24 +0200 Subject: [PATCH] fix(sourcemap): inject missing runtime snippets for existing debug IDs --- .../src/fragments/commands/sourcemap.md | 7 + packages/cli/src/commands/sourcemap/inject.ts | 3 +- packages/cli/src/commands/sourcemap/upload.ts | 2 +- packages/cli/src/lib/sourcemap/debug-id.ts | 119 +++++++----- packages/cli/src/lib/sourcemap/inject.ts | 16 +- .../test/commands/sourcemap/upload.test.ts | 90 ++++++++- .../cli/test/lib/sourcemap/inject.test.ts | 178 +++++++++++++++++- packages/cli/test/script/debug-id.test.ts | 19 ++ 8 files changed, 369 insertions(+), 65 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/sourcemap.md b/apps/cli-docs/src/fragments/commands/sourcemap.md index 7fa8969685..77c8aa307c 100644 --- a/apps/cli-docs/src/fragments/commands/sourcemap.md +++ b/apps/cli-docs/src/fragments/commands/sourcemap.md @@ -6,6 +6,13 @@ `sentry sourcemap inject` is a purely local file operation — it does not make any API calls and does not require authentication. You can run it in CI before authenticating. +Existing debug IDs from JavaScript comments or sourcemaps are preserved. If a +bundler emits an ID without Sentry's runtime registration snippet, `inject` +adds the snippet and adjusts the sourcemap mappings. Bundles that already +register their ID are left unchanged. `sourcemap upload` applies the same +injection by default, unless `--no-rewrite` is passed. +Binary bundles with an existing debug ID in their sourcemap are left unchanged. + ```bash # Inject debug IDs into all JS files in dist/ sentry sourcemap inject ./dist diff --git a/packages/cli/src/commands/sourcemap/inject.ts b/packages/cli/src/commands/sourcemap/inject.ts index aca157ed5e..c54cce1d9b 100644 --- a/packages/cli/src/commands/sourcemap/inject.ts +++ b/packages/cli/src/commands/sourcemap/inject.ts @@ -59,7 +59,8 @@ export const injectCommand = buildCommand({ fullDescription: "Scans a directory for .js/.mjs/.cjs files and their companion .map files, " + "then injects Sentry debug IDs for reliable sourcemap resolution.\n\n" + - "The injection is idempotent — files that already have debug IDs are skipped.\n\n" + + "Existing debug IDs are preserved; missing runtime registration snippets are added.\n" + + "Files that already register their debug ID at runtime are left unchanged.\n\n" + "Exits with an error if zero JS + sourcemap pairs are discovered " + "(typical cause: bundler not emitting .map files). Pass " + "--allow-empty to suppress this check for directories that may " + diff --git a/packages/cli/src/commands/sourcemap/upload.ts b/packages/cli/src/commands/sourcemap/upload.ts index e36d0f557b..f7709d58f4 100644 --- a/packages/cli/src/commands/sourcemap/upload.ts +++ b/packages/cli/src/commands/sourcemap/upload.ts @@ -208,7 +208,7 @@ export const uploadCommand = buildCommand({ fullDescription: "Upload JavaScript sourcemaps and source files to Sentry using " + "debug-ID-based matching.\n\n" + - "Automatically injects debug IDs into any files that don't already have them.\n" + + "Automatically injects missing debug IDs and runtime registration snippets, preserving existing IDs.\n" + "Org/project are auto-detected from DSN, env vars, or config defaults.\n\n" + "Exits with an error if zero JS + sourcemap pairs are discovered " + "(typical cause: bundler not emitting .map files). Pass " + diff --git a/packages/cli/src/lib/sourcemap/debug-id.ts b/packages/cli/src/lib/sourcemap/debug-id.ts index 8358830bb1..2db18e5c7d 100644 --- a/packages/cli/src/lib/sourcemap/debug-id.ts +++ b/packages/cli/src/lib/sourcemap/debug-id.ts @@ -23,6 +23,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 { isLikelyBinary } from "../scan/binary.js"; import { type DecodedInlineMap, encodeInlineSourcemap, @@ -36,6 +37,26 @@ 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})/; +/** Indexed registry writes emitted by Sentry's bundler plugins and legacy CLI. */ +const DEBUGID_REGISTRATION_RE = + /(?:\.\s*_sentryDebugIds|\[\s*["']_sentryDebugIds["']\s*\])\s*\[\s*[$\w]+\s*\]\s*=\s*(["'])([0-9a-fA-F-]{36})\1/g; + +/** + * Recognize a Sentry runtime snippet registering the selected debug ID. + * SDK registry reads and ECMA-426 comments alone do not register a bundle. + * Match the writer rather than the identifier marker, which the legacy CLI + * does not emit. This recognizes emitted snippets, not arbitrary JavaScript. + * @internal + */ +export function hasDebugIdRegistration(js: string, debugId: string): boolean { + for (const match of js.matchAll(DEBUGID_REGISTRATION_RE)) { + if (match[2]?.toLowerCase() === debugId.toLowerCase()) { + return true; + } + } + return false; +} + /** * Read a pre-existing debug ID off a parsed sourcemap. * @@ -141,10 +162,12 @@ export function prependDebugIdSnippet( * This is used by the CLI's own build pipeline where the debug ID is * 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. A debug ID already present - * on the sourcemap is likewise adopted as-is, leaving both files untouched - * (see {@link readSourcemapDebugId}). + * Existing IDs are preserved (JS comment first, then sourcemap). Files with + * a runtime registration for that ID are left untouched to preserve SRI. + * Otherwise the missing snippet is injected under the existing ID. In + * metadata-only mode, an existing ID is sufficient to leave files untouched. + * Binary bundles with an existing ID are also preserved: adding JavaScript + * would corrupt bytecode such as Hermes bundles. * * @param jsPath - Path to the JavaScript file * @param mapPath - Path to the companion `.map` file @@ -157,28 +180,29 @@ export async function injectDebugId( mapPath: string, options?: { skipSnippet?: boolean } ): Promise<{ debugId: string; wasInjected: boolean }> { - const [jsContent, mapContent] = await Promise.all([ - readFile(jsPath, "utf-8"), + const [jsBytes, mapContent] = await Promise.all([ + readFile(jsPath), readFile(mapPath, "utf-8"), ]); + const jsContent = jsBytes.toString("utf-8"); - // Idempotent: if the JS file already has a debug ID, extract and return it - const existingMatch = jsContent.match(EXISTING_DEBUGID_RE); - if (existingMatch?.[1]) { - return { debugId: existingMatch[1], wasInjected: false }; + const skipSnippet = options?.skipSnippet ?? false; + const skipRuntimeCheck = skipSnippet || isLikelyBinary(jsBytes); + const jsDebugId = jsContent.match(EXISTING_DEBUGID_RE)?.[1]; + if ( + jsDebugId && + (skipRuntimeCheck || hasDebugIdRegistration(jsContent, jsDebugId)) + ) { + return { debugId: jsDebugId, 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 }; + const existingId = jsDebugId ?? readSourcemapDebugId(map); + if ( + existingId && + (skipRuntimeCheck || hasDebugIdRegistration(jsContent, existingId)) + ) { + return { debugId: existingId, wasInjected: false }; } // Derive the debug ID from the minified JS content combined with the @@ -190,8 +214,7 @@ export async function injectDebugId( // separator can't occur in JS/JSON text, so the two inputs can't bleed // across the boundary. Collisions now require both files to be // byte-identical, in which case sharing an ID is correct. - const debugId = contentToDebugId(`${jsContent}\0${mapContent}`); - const skipSnippet = options?.skipSnippet ?? false; + const debugId = existingId ?? contentToDebugId(`${jsContent}\0${mapContent}`); // --- Mutate JS file --- let newJs: string; @@ -203,8 +226,9 @@ export async function injectDebugId( // Full mode: prepend the runtime IIFE snippet (for user-facing injection). newJs = prependDebugIdSnippet(jsContent, getDebugIdSnippet(debugId)); } - // Append debug ID comment at the end - newJs += `\n${DEBUGID_COMMENT_PREFIX}${debugId}\n`; + if (!jsDebugId) { + newJs += `\n${DEBUGID_COMMENT_PREFIX}${debugId}\n`; + } // --- Mutate sourcemap --- mutateSourcemap(map, debugId, { offsetMappings: !skipSnippet }); @@ -282,8 +306,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, - * as are files whose decoded inline map already carries a debug ID. + * Existing IDs from the JS comment or decoded map are reused. Files already + * registering that ID at runtime are left unchanged. * * @param jsPath - Path to the JavaScript file * @param decoded - The decoded inline sourcemap and its re-encode metadata @@ -302,36 +326,29 @@ export async function injectInlineDebugId( }> { // Full read required: the directive lives in the file body and must be // rewritten in place. - const jsContent = await readFile(jsPath, "utf-8"); + const jsBytes = await readFile(jsPath); + const jsContent = jsBytes.toString("utf-8"); - // Derive from the minified JS content combined with the (decoded) inline - // sourcemap, mirroring the external path so distinct chunks that share a - // byte-identical inline map still get distinct debug IDs - // (getsentry/sentry-cli#3350). `jsContent` already embeds the inline map, - // so this is belt-and-suspenders, but keeps the two paths symmetric. - const debugId = contentToDebugId(`${jsContent}\0${decoded.json}`); - - // Idempotent: if already injected, return the existing ID without writing. - const existingMatch = jsContent.match(EXISTING_DEBUGID_RE); - if (existingMatch?.[1]) { + const jsDebugId = jsContent.match(EXISTING_DEBUGID_RE)?.[1]; + const existingId = jsDebugId ?? readSourcemapDebugId(decoded.map); + if ( + existingId && + (isLikelyBinary(jsBytes) || hasDebugIdRegistration(jsContent, existingId)) + ) { return { - debugId: existingMatch[1], + debugId: existingId, wasInjected: false, injectedMapContent: Buffer.from(decoded.json), }; } - // 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), - }; - } + // Derive from the minified JS content combined with the (decoded) inline + // sourcemap, mirroring the external path so distinct chunks that share a + // byte-identical inline map still get distinct debug IDs + // (getsentry/sentry-cli#3350). `jsContent` already embeds the inline map, + // so this is belt-and-suspenders, but keeps the two paths symmetric. + const debugId = + existingId ?? contentToDebugId(`${jsContent}\0${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 @@ -366,7 +383,9 @@ export async function injectInlineDebugId( jsContent.slice(end); let newJs = prependDebugIdSnippet(rewritten, getDebugIdSnippet(debugId)); - newJs += `\n${DEBUGID_COMMENT_PREFIX}${debugId}\n`; + if (!jsDebugId) { + newJs += `\n${DEBUGID_COMMENT_PREFIX}${debugId}\n`; + } await writeFile(jsPath, newJs); diff --git a/packages/cli/src/lib/sourcemap/inject.ts b/packages/cli/src/lib/sourcemap/inject.ts index 0c51b38593..4b0152b179 100644 --- a/packages/cli/src/lib/sourcemap/inject.ts +++ b/packages/cli/src/lib/sourcemap/inject.ts @@ -11,9 +11,11 @@ import ignore from "ignore"; import { NODE_MODULES_DIRNAME } from "../constants.js"; import { ValidationError } from "../errors.js"; import { logger } from "../logger.js"; +import { isLikelyBinary } from "../scan/binary.js"; import { walkFiles } from "../scan/index.js"; import { EXISTING_DEBUGID_RE, + hasDebugIdRegistration, injectDebugId, injectInlineDebugId, readSourcemapDebugId, @@ -51,7 +53,7 @@ export type InjectResult = { * `undefined` for inline maps (which have no standalone file). */ mapPath?: string; - /** Whether debug IDs were injected (false if already present or skipped). */ + /** Whether injection changed the files (or would change them in dry-run mode). */ injected: boolean; /** The debug ID (injected or pre-existing). */ debugId: string; @@ -102,17 +104,19 @@ export async function injectDirectory( for (const { jsPath, map } of filePairs) { const mapPath = map.kind === "external" ? map.mapPath : undefined; if (options.dryRun) { - // 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"); + // Match the real run's ID precedence and runtime registration check. + const jsBytes = await readFile(jsPath); + const js = jsBytes.toString("utf-8"); const existing = js.match(EXISTING_DEBUGID_RE)?.[1] ?? (await readMapDebugId(map)); results.push({ jsPath, map, mapPath, - injected: !existing, + injected: !( + existing && + (isLikelyBinary(jsBytes) || hasDebugIdRegistration(js, existing)) + ), debugId: existing ?? "(pending)", }); continue; diff --git a/packages/cli/test/commands/sourcemap/upload.test.ts b/packages/cli/test/commands/sourcemap/upload.test.ts index 6d7c8a3aa7..097a776244 100644 --- a/packages/cli/test/commands/sourcemap/upload.test.ts +++ b/packages/cli/test/commands/sourcemap/upload.test.ts @@ -7,12 +7,14 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { runInNewContext } from "node:vm"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { injectCommand } from "../../../src/commands/sourcemap/inject.js"; import { uploadCommand } from "../../../src/commands/sourcemap/upload.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as sourcemapsApi from "../../../src/lib/api/sourcemaps.js"; import { ValidationError } from "../../../src/lib/errors.js"; +import { getDebugIdSnippet } from "../../../src/lib/sourcemap/debug-id.js"; type InjectFuncArgs = { ext?: string; @@ -730,6 +732,86 @@ describe("sourcemap upload command — --allow-empty behavior", () => { } }); + test.each([ + { mapKind: "external", idSource: "comment and map" }, + { mapKind: "external", idSource: "map only" }, + { mapKind: "inline", idSource: "comment and map" }, + { mapKind: "inline", idSource: "map only" }, + ])("$mapKind map with ID in $idSource: uploads runtime registration and shifted mappings once", async ({ + mapKind, + idSource, + }) => { + const debugId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + const jsPath = join(dir, "app.js"); + const mapPath = join(dir, "app.js.map"); + const map = JSON.stringify({ + version: 3, + sources: ["app.ts"], + sourcesContent: ["globalThis.completed = true;"], + names: [], + mappings: "AAAA", + debug_id: debugId, + }); + const mapUrl = + mapKind === "inline" + ? `data:application/json;base64,${Buffer.from(map).toString("base64")}` + : "app.js.map"; + const comment = + idSource === "comment and map" ? `//# debugId=${debugId}\n` : ""; + await writeFile( + jsPath, + `globalThis.completed = true;\n${comment}//# sourceMappingURL=${mapUrl}\n` + ); + if (mapKind === "external") { + 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); + const jsFile = files.find((file) => file.type === "minified_source"); + const mapFile = files.find((file) => file.type === "source_map"); + expect(jsFile?.path).toBe(jsPath); + expect(jsFile?.debugId).toBe(debugId); + expect(mapFile?.debugId).toBe(debugId); + + const uploadedJs = await readFile(jsPath, "utf-8"); + const runtime: { + _sentryDebugIds?: Record; + completed?: boolean; + } = {}; + runInNewContext(uploadedJs, runtime); + expect(runtime.completed).toBe(true); + expect(Object.values(runtime._sentryDebugIds ?? {})).toEqual([debugId]); + const uploadedMap = + mapFile?.content?.toString("utf-8") ?? + (await readFile(mapPath, "utf-8")); + expect(JSON.parse(uploadedMap)).toMatchObject({ + debug_id: debugId, + debugId, + mappings: ";AAAA", + }); + + await func.call(ctx, {}, dir); + expect(uploadSpy).toHaveBeenCalledTimes(2); + expect(await readFile(jsPath, "utf-8")).toBe(uploadedJs); + const secondMap = uploadSpy.mock.calls[1]?.[0]?.files.find( + (file) => file.type === "source_map" + ); + expect( + secondMap?.content?.toString("utf-8") ?? + (await readFile(mapPath, "utf-8")) + ).toBe(uploadedMap); + } finally { + 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 @@ -737,12 +819,12 @@ describe("sourcemap upload command — --allow-empty behavior", () => { 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 js = `${getDebugIdSnippet(pluginId)}\nconsole.log(1)\n//# sourceMappingURL=app.js.map\n`; const map = JSON.stringify({ version: 3, sources: ["app.ts"], names: [], - mappings: "AAAA", + mappings: ";AAAA", debug_id: pluginId, }); await writeFile(jsPath, js); @@ -776,11 +858,11 @@ describe("sourcemap upload command — --allow-empty behavior", () => { version: 3, sources: ["a.ts"], names: [], - mappings: "AAAA", + 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`; + const js = `${getDebugIdSnippet(pluginId)}\nconsole.log(1)\n//# sourceMappingURL=${dataUrl}\n`; await writeFile(jsPath, js); const uploadSpy = vi diff --git a/packages/cli/test/lib/sourcemap/inject.test.ts b/packages/cli/test/lib/sourcemap/inject.test.ts index 4d1c94975e..be05615a64 100644 --- a/packages/cli/test/lib/sourcemap/inject.test.ts +++ b/packages/cli/test/lib/sourcemap/inject.test.ts @@ -16,8 +16,11 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { runInNewContext } from "node:vm"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { getDebugIdSnippet } from "../../../src/lib/sourcemap/debug-id.js"; import { injectDirectory } from "../../../src/lib/sourcemap/inject.js"; +import { tryDecodeInlineSourcemap } from "../../../src/lib/sourcemap/inline-sourcemap.js"; describe("injectDirectory — discovery", () => { let dir: string; @@ -470,7 +473,7 @@ describe("injectDirectory — pre-existing sourcemap debug ID", () => { ): { 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 js = `${getDebugIdSnippet(PLUGIN_ID)}\nconsole.log(1)\n//# sourceMappingURL=${name}.map\n`; const map = JSON.stringify({ ...BASE_MAP, ...mapExtra }); writeFileSync(jsPath, js); writeFileSync(mapPath, map); @@ -511,7 +514,7 @@ describe("injectDirectory — pre-existing sourcemap debug 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`; + const js = `${getDebugIdSnippet(PLUGIN_ID)}\nconsole.log(1)\n//# sourceMappingURL=${toDataUrl({ ...BASE_MAP, debug_id: PLUGIN_ID })}\n`; writeFileSync(jsPath, js); const results = await injectDirectory(dir); @@ -542,7 +545,12 @@ describe("injectDirectory — pre-existing sourcemap debug ID", () => { const results = await injectDirectory(dir); expect(results[0]?.debugId).toBe(jsId); - expect(results[0]?.injected).toBe(false); + expect(results[0]?.injected).toBe(true); + expect(readFileSync(jsPath, "utf-8")).toContain(getDebugIdSnippet(jsId)); + const map = JSON.parse(readFileSync(`${jsPath}.map`, "utf-8")); + expect(map.debug_id).toBe(jsId); + expect(map.debugId).toBe(jsId); + expect(map.mappings).toBe(`;${BASE_MAP.mappings}`); }); test("falls through to minting when the map's debug ID is malformed", async () => { @@ -581,4 +589,168 @@ describe("injectDirectory — pre-existing sourcemap debug ID", () => { expect(readFileSync(pair.jsPath, "utf-8")).toBe(pair.js); expect(readFileSync(pair.mapPath, "utf-8")).toBe(pair.map); }); + + test("preserves binary bundles whose map already carries a debug ID", async () => { + const jsPath = join(dir, "main.bundle"); + // Hermes bytecode magic followed by the bytecode version, including NULs. + const bundle = Buffer.from("c61fbc03c103191f60000000", "hex"); + const map = JSON.stringify({ ...BASE_MAP, debugId: PLUGIN_ID }); + writeFileSync(jsPath, bundle); + writeFileSync(`${jsPath}.map`, map); + + for (const dryRun of [true, false]) { + const results = await injectDirectory(dir, { + extensions: [".bundle"], + dryRun, + }); + expect(results[0]).toMatchObject({ + debugId: PLUGIN_ID, + injected: false, + }); + expect(readFileSync(jsPath)).toEqual(bundle); + expect(readFileSync(`${jsPath}.map`, "utf-8")).toBe(map); + } + }); +}); + +describe.each([ + "external", + "inline", +] as const)("injectDirectory — %s map runtime registration", (kind) => { + let dir: string; + const debugId = "11111111-2222-5333-9444-555555555555"; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "sentry-inject-runtime-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + test.each([ + "legacy", + "formatted", + ])("preserves an existing %s registration without an identifier marker", async (format) => { + const jsPath = join(dir, "main.js"); + // Legacy CLI snippets have no _sentryDebugIdIdentifier marker. + const snippet = + format === "legacy" + ? `!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="${debugId}")}catch(e){}}();` + : `(() => { + var root = globalThis; + var stack = new root.Error().stack; + root['_sentryDebugIds'] = root['_sentryDebugIds'] || {}; + root['_sentryDebugIds'][stack] = '${debugId}'; +})();`; + const mapContent = JSON.stringify({ + version: 3, + sources: ["main.ts"], + names: [], + mappings: ";AAAA", + debugId, + }); + const mapUrl = + kind === "inline" + ? `data:application/json;base64,${Buffer.from(mapContent).toString("base64")}` + : "main.js.map"; + const js = `${snippet}\n//# debugId=${debugId}\n//# sourceMappingURL=${mapUrl}\n`; + writeFileSync(jsPath, js); + if (kind === "external") { + writeFileSync(`${jsPath}.map`, mapContent); + } + + for (const options of [{ dryRun: true }, {}]) { + expect((await injectDirectory(dir, options))[0]).toMatchObject({ + debugId, + injected: false, + }); + expect(readFileSync(jsPath, "utf-8")).toBe(js); + if (kind === "external") { + expect(readFileSync(`${jsPath}.map`, "utf-8")).toBe(mapContent); + } + } + const sandbox: { _sentryDebugIds?: Record } = {}; + runInNewContext(js, sandbox, { filename: "main.js" }); + expect(Object.values(sandbox._sentryDebugIds ?? {})).toEqual([debugId]); + }); + + test.each([ + "comment", + "debugId", + "debug_id", + "comment and map", + ])("registers an ID from %s exactly once", async (source) => { + const jsPath = join(dir, "main.js"); + const hasComment = source.startsWith("comment"); + const mapContent = JSON.stringify({ + version: 3, + sources: ["main.ts"], + sourcesContent: ["globalThis.answer = 42;"], + names: [], + mappings: "AAAA", + ...(source === "comment" + ? {} + : { [source === "debug_id" ? "debug_id" : "debugId"]: debugId }), + }); + const mapUrl = + kind === "inline" + ? `data:application/json;base64,${Buffer.from(mapContent).toString("base64")}` + : "main.js.map"; + // SDK readers mention the registry without registering a bundle ID. + const js = + "globalThis.answer = 42; globalThis.readIds = () => globalThis._sentryDebugIds;\n" + + (hasComment ? `//# debugId=${debugId}\n` : "") + + `//# sourceMappingURL=${mapUrl}\n`; + writeFileSync(jsPath, js); + if (kind === "external") { + writeFileSync(`${jsPath}.map`, mapContent); + } + + const preview = await injectDirectory(dir, { dryRun: true }); + expect(preview[0]).toMatchObject({ debugId, injected: true }); + expect(readFileSync(jsPath, "utf-8")).toBe(js); + if (kind === "external") { + expect(readFileSync(`${jsPath}.map`, "utf-8")).toBe(mapContent); + } + + const results = await injectDirectory(dir); + expect(results[0]).toMatchObject({ debugId, injected: true }); + const output = readFileSync(jsPath, "utf-8"); + const sandbox: { + answer?: number; + _sentryDebugIds?: Record; + } = {}; + runInNewContext(output, sandbox, { filename: "main.js" }); + expect(sandbox.answer).toBe(42); + expect(Object.values(sandbox._sentryDebugIds ?? {})).toEqual([debugId]); + expect(output.match(/\/\/# debugId=/g)).toHaveLength(1); + + const outputMap = + kind === "external" + ? readFileSync(`${jsPath}.map`, "utf-8") + : results[0]?.injectedMapContent?.toString("utf-8"); + expect(JSON.parse(outputMap ?? "{}")).toMatchObject({ + debugId, + debug_id: debugId, + mappings: ";AAAA", + }); + if (kind === "inline") { + const url = output.match(/\/\/# sourceMappingURL=(\S+)/)?.[1]; + expect(tryDecodeInlineSourcemap(url ?? "")?.json).toBe(outputMap); + } + + const second = await injectDirectory(dir); + expect(second[0]).toMatchObject({ debugId, injected: false }); + expect(readFileSync(jsPath, "utf-8")).toBe(output); + expect( + kind === "external" + ? readFileSync(`${jsPath}.map`, "utf-8") + : second[0]?.injectedMapContent?.toString("utf-8") + ).toBe(outputMap); + expect((await injectDirectory(dir, { dryRun: true }))[0]).toMatchObject({ + debugId, + injected: false, + }); + }); }); diff --git a/packages/cli/test/script/debug-id.test.ts b/packages/cli/test/script/debug-id.test.ts index ec704c6470..06a026d8f6 100644 --- a/packages/cli/test/script/debug-id.test.ts +++ b/packages/cli/test/script/debug-id.test.ts @@ -285,6 +285,25 @@ describe("injectDebugId", () => { expect(jsAfterSecond).toBe(jsAfterFirst); }); + test("skipSnippet: true preserves an existing map ID without runtime registration", async () => { + const jsPath = join(tmpDir, "bundle.js"); + const mapPath = `${jsPath}.map`; + const debugId = "11111111-2222-5333-9444-555555555555"; + const js = 'console.log("hello");\n'; + const map = JSON.stringify({ version: 3, mappings: "AAAA", debugId }); + await writeFile(jsPath, js); + await writeFile(mapPath, map); + + expect(await injectDebugId(jsPath, mapPath, { skipSnippet: true })).toEqual( + { + debugId, + wasInjected: false, + } + ); + expect(await readFile(jsPath, "utf-8")).toBe(js); + expect(await readFile(mapPath, "utf-8")).toBe(map); + }); + test("normalizes backslashes in sourcemap sources array", async () => { const jsPath = join(tmpDir, "bundle.js"); const mapPath = join(tmpDir, "bundle.js.map");