diff --git a/docs/src/fragments/commands/log.md b/docs/src/fragments/commands/log.md index 00ff24276..0ef18bc83 100644 --- a/docs/src/fragments/commands/log.md +++ b/docs/src/fragments/commands/log.md @@ -1,5 +1,4 @@ - ## Examples ### List logs diff --git a/script/build.ts b/script/build.ts index 0f1b7e562..ad43b46e6 100644 --- a/script/build.ts +++ b/script/build.ts @@ -30,7 +30,7 @@ * bin.js.map (sourcemap, uploaded to Sentry then deleted) */ -import { mkdirSync } from "node:fs"; +import { mkdirSync, renameSync } from "node:fs"; import { promisify } from "node:util"; import { gzip } from "node:zlib"; import { processBinary } from "binpunch"; @@ -38,7 +38,7 @@ import { $ } from "bun"; import { build as esbuild } from "esbuild"; import pkg from "../package.json"; import { uploadSourcemaps } from "../src/lib/api/sourcemaps.js"; -import { injectDebugId } from "./debug-id.js"; +import { injectDebugId, PLACEHOLDER_DEBUG_ID } from "./debug-id.js"; const gzipAsync = promisify(gzip); @@ -104,13 +104,15 @@ async function bundleJs(): Promise { target: "esnext", format: "esm", external: ["bun:*"], - sourcemap: "external", + sourcemap: "linked", + // Minify syntax and whitespace but NOT identifiers. Bun.build minify: true, metafile: true, define: { SENTRY_CLI_VERSION: JSON.stringify(VERSION), SENTRY_CLIENT_ID_BUILD: JSON.stringify(SENTRY_CLIENT_ID), "process.env.NODE_ENV": JSON.stringify("production"), + __SENTRY_DEBUG_ID__: JSON.stringify(PLACEHOLDER_DEBUG_ID), }, }); @@ -147,13 +149,23 @@ async function bundleJs(): Promise { * injection always runs (even without auth token) so local builds get * debug IDs for development/testing. */ -async function injectAndUploadSourcemap(): Promise { - // Always inject debug IDs (even without auth token) so local builds - // get debug IDs for development/testing purposes. +/** Module-level debug ID set by {@link injectDebugIds} for use in {@link uploadSourcemapToSentry}. */ +let currentDebugId: string | undefined; + +/** + * Inject debug IDs into the JS and sourcemap. Runs before compilation. + * The upload happens separately after compilation (see {@link uploadSourcemapToSentry}). + */ +async function injectDebugIds(): Promise { + // skipSnippet: true — the IIFE snippet breaks ESM (placed before import + // declarations). The debug ID is instead registered in constants.ts via + // a build-time __SENTRY_DEBUG_ID__ constant. console.log(" Injecting debug IDs..."); - let debugId: string; try { - ({ debugId } = await injectDebugId(BUNDLE_JS, SOURCEMAP_FILE)); + const { debugId } = await injectDebugId(BUNDLE_JS, SOURCEMAP_FILE, { + skipSnippet: true, + }); + currentDebugId = debugId; console.log(` -> Debug ID: ${debugId}`); } catch (error) { const msg = error instanceof Error ? error.message : String(error); @@ -161,6 +173,33 @@ async function injectAndUploadSourcemap(): Promise { return; } + // Replace the placeholder UUID with the real debug ID in the JS bundle. + // Both are 36-char UUIDs so sourcemap character positions stay valid. + try { + const jsContent = await Bun.file(BUNDLE_JS).text(); + await Bun.write( + BUNDLE_JS, + jsContent.split(PLACEHOLDER_DEBUG_ID).join(currentDebugId) + ); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.warn( + ` Warning: Debug ID placeholder replacement failed: ${msg}` + ); + } +} + +/** + * Upload the (composed) sourcemap to Sentry. Runs after compilation + * because {@link compileTarget} composes the Bun sourcemap with the + * esbuild sourcemap first. + */ +async function uploadSourcemapToSentry(): Promise { + const debugId = currentDebugId; + if (!debugId) { + return; + } + if (!process.env.SENTRY_AUTH_TOKEN) { console.log(" No SENTRY_AUTH_TOKEN, skipping sourcemap upload"); return; @@ -169,7 +208,13 @@ async function injectAndUploadSourcemap(): Promise { console.log(` Uploading sourcemap to Sentry (release: ${VERSION})...`); try { - const urlPrefix = "~/$bunfs/root/"; + // With sourcemap: "linked", Bun's runtime auto-resolves Error.stack + // paths via the embedded map, producing relative paths like + // "dist-bin/bin.js". The beforeSend hook normalizes these to absolute + // ("/dist-bin/bin.js") so the symbolicator's candidate URL generator + // produces "~/dist-bin/bin.js" — matching our upload URL. + const dir = BUNDLE_JS.slice(0, BUNDLE_JS.lastIndexOf("/") + 1); + const urlPrefix = `~/${dir}`; const jsBasename = BUNDLE_JS.split("/").pop() ?? "bin.js"; const mapBasename = SOURCEMAP_FILE.split("/").pop() ?? "bin.js.map"; @@ -204,8 +249,11 @@ async function injectAndUploadSourcemap(): Promise { /** * Step 2: Compile the pre-bundled JS into a native binary for a target. * - * Uses the JS file produced by {@link bundleJs} — no sourcemap is embedded - * in the binary (it's uploaded to Sentry separately). + * Uses the JS file produced by {@link bundleJs}. The esbuild sourcemap + * (JS → original TS) is uploaded to Sentry as-is — no composition needed + * because `sourcemap: "linked"` causes Bun to embed a sourcemap in the + * binary that its runtime uses to auto-resolve `Error.stack` positions + * back to the esbuild output's coordinate space. */ async function compileTarget(target: BuildTarget): Promise { const packageName = getPackageName(target); @@ -215,31 +263,48 @@ async function compileTarget(target: BuildTarget): Promise { console.log(` Step 2: Compiling ${packageName}...`); - const result = await Bun.build({ - entrypoints: [BUNDLE_JS], - compile: { - target: getBunTarget(target) as - | "bun-darwin-arm64" - | "bun-darwin-x64" - | "bun-linux-x64" - | "bun-linux-arm64" - | "bun-windows-x64", - outfile, - }, - // Already minified in Step 1 — skip re-minification to avoid - // double-minifying identifiers and producing different output. - minify: false, - }); - - if (!result.success) { - console.error(` Failed to compile ${packageName}:`); - for (const log of result.logs) { - console.error(` ${log}`); + // Rename the esbuild map out of the way before Bun.build overwrites it + // (sourcemap: "linked" writes Bun's own map to bin.js.map). + // Restored in the finally block so subsequent targets and the upload + // always find the esbuild map, even if compilation fails. + const esbuildMapBackup = `${SOURCEMAP_FILE}.esbuild`; + renameSync(SOURCEMAP_FILE, esbuildMapBackup); + + try { + const result = await Bun.build({ + entrypoints: [BUNDLE_JS], + compile: { + target: getBunTarget(target) as + | "bun-darwin-arm64" + | "bun-darwin-x64" + | "bun-linux-x64" + | "bun-linux-arm64" + | "bun-windows-x64", + outfile, + }, + // "linked" embeds a sourcemap in the binary. At runtime, Bun's engine + // auto-resolves Error.stack positions through this embedded map back to + // the esbuild output positions. The esbuild sourcemap (uploaded to + // Sentry) then maps those to original TypeScript sources. + sourcemap: "linked", + // Minify whitespace and syntax but NOT identifiers to avoid Bun's + // identifier renaming collision bug (oven-sh/bun#14585). + minify: { whitespace: true, syntax: true, identifiers: false }, + }); + + if (!result.success) { + console.error(` Failed to compile ${packageName}:`); + for (const log of result.logs) { + console.error(` ${log}`); + } + return false; } - return false; - } - console.log(` -> ${outfile}`); + console.log(` -> ${outfile}`); + } finally { + // Restore the esbuild sourcemap (Bun.build wrote its own map). + renameSync(esbuildMapBackup, SOURCEMAP_FILE); + } // Hole-punch: zero unused ICU data entries so they compress to nearly nothing. // Always runs so the smoke test exercises the same binary as the release. @@ -344,8 +409,10 @@ async function build(): Promise { process.exit(1); } - // Inject debug IDs and upload sourcemap to Sentry before compiling (non-fatal on failure) - await injectAndUploadSourcemap(); + // Inject debug IDs into the JS and sourcemap (non-fatal on failure). + // Upload happens AFTER compilation because Bun.build (with sourcemap: "linked") + // overwrites bin.js.map. We restore it from the saved copy before uploading. + await injectDebugIds(); console.log(""); @@ -362,6 +429,9 @@ async function build(): Promise { } } + // Step 3: Upload the composed sourcemap to Sentry (after compilation) + await uploadSourcemapToSentry(); + // Clean up intermediate bundle (only the binaries are artifacts) await $`rm -f ${BUNDLE_JS} ${SOURCEMAP_FILE}`; diff --git a/script/bundle.ts b/script/bundle.ts index 10fa77ea6..20c863e15 100644 --- a/script/bundle.ts +++ b/script/bundle.ts @@ -3,7 +3,7 @@ import { unlink } from "node:fs/promises"; import { build, type Plugin } from "esbuild"; import pkg from "../package.json"; import { uploadSourcemaps } from "../src/lib/api/sourcemaps.js"; -import { injectDebugId } from "./debug-id.js"; +import { injectDebugId, PLACEHOLDER_DEBUG_ID } from "./debug-id.js"; const VERSION = pkg.version; const SENTRY_CLIENT_ID = process.env.SENTRY_CLIENT_ID ?? ""; @@ -67,7 +67,9 @@ async function injectDebugIdsForOutputs( for (const jsPath of jsFiles) { const mapPath = `${jsPath}.map`; try { - const { debugId } = await injectDebugId(jsPath, mapPath); + const { debugId } = await injectDebugId(jsPath, mapPath, { + skipSnippet: true, + }); injected.push({ jsPath, mapPath, debugId }); console.log(` Debug ID injected: ${debugId}`); } catch (err) { @@ -151,6 +153,16 @@ const sentrySourcemapPlugin: Plugin = { return; } + // Replace the placeholder UUID with the real debug ID in each JS output. + // Both are 36-char UUIDs so sourcemap character positions stay valid. + for (const { jsPath, debugId } of injected) { + const content = await Bun.file(jsPath).text(); + await Bun.write( + jsPath, + content.split(PLACEHOLDER_DEBUG_ID).join(debugId) + ); + } + if (!process.env.SENTRY_AUTH_TOKEN) { return; } @@ -194,6 +206,7 @@ const result = await build({ SENTRY_CLI_VERSION: JSON.stringify(VERSION), SENTRY_CLIENT_ID_BUILD: JSON.stringify(SENTRY_CLIENT_ID), "process.env.NODE_ENV": JSON.stringify("production"), + __SENTRY_DEBUG_ID__: JSON.stringify(PLACEHOLDER_DEBUG_ID), // Replace import.meta.url with the injected shim variable for CJS "import.meta.url": "import_meta_url", }, diff --git a/script/debug-id.ts b/script/debug-id.ts index 612b4b439..d168e558a 100644 --- a/script/debug-id.ts +++ b/script/debug-id.ts @@ -10,3 +10,13 @@ export { getDebugIdSnippet, injectDebugId, } from "../src/lib/sourcemap/debug-id.js"; + +/** + * Placeholder UUID used by esbuild's `define` for `__SENTRY_DEBUG_ID__`. + * + * After esbuild finishes and the real debug ID is computed from the + * sourcemap content hash, this placeholder is replaced in the JS output + * via `String.replaceAll`. The placeholder is exactly 36 characters + * (standard UUID length) so character positions in the sourcemap stay valid. + */ +export const PLACEHOLDER_DEBUG_ID = "deb00000-de60-4d00-a000-000000000000"; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index b3b3aaf6d..4f0f300d9 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -7,6 +7,17 @@ import { getEnv } from "./env.js"; /** Build-time constant injected by esbuild/bun */ declare const SENTRY_CLI_VERSION: string | undefined; +/** + * Build-time debug ID for sourcemap resolution, injected by esbuild. + * + * During the build, esbuild's `define` replaces this identifier with a + * placeholder UUID string literal. After esbuild finishes, the build + * script replaces the placeholder with the real debug ID (derived from + * the sourcemap content hash). The same-length swap keeps sourcemap + * character positions valid. + */ +declare const __SENTRY_DEBUG_ID__: string | undefined; + /** Default Sentry SaaS hostname */ export const DEFAULT_SENTRY_HOST = "sentry.io"; @@ -106,3 +117,31 @@ export function getUserAgent(): string { */ export const SENTRY_CLI_DSN = "https://1188a86f3f8168f089450587b00bca66@o1.ingest.us.sentry.io/4510776311808000"; + +/** + * Register the build-time debug ID with the Sentry SDK's native discovery. + * + * The SDK reads `globalThis._sentryDebugIds` (a map of Error.stack → debugId) + * during event processing to populate `debug_meta.images`, which the server + * uses to match uploaded sourcemaps. + * + * Previously this was done by a runtime IIFE snippet prepended to the bundle + * output by `injectDebugId()`. That broke ESM because the snippet appeared + * before `import` declarations. Placing the same logic here — inside the + * module, after all imports — is valid ESM and feeds the SDK's existing + * mechanism directly. + */ +if (typeof __SENTRY_DEBUG_ID__ !== "undefined") { + try { + // biome-ignore lint/suspicious/useErrorMessage: stack trace capture only + const stack = new Error().stack; + if (stack) { + // biome-ignore lint/suspicious/noExplicitAny: SDK reads this untyped global + const g = globalThis as any; + g._sentryDebugIds = g._sentryDebugIds || {}; + g._sentryDebugIds[stack] = __SENTRY_DEBUG_ID__; + } + } catch (_) { + // Non-critical — sourcemap resolution degrades gracefully + } +} diff --git a/src/lib/sourcemap/debug-id.ts b/src/lib/sourcemap/debug-id.ts index 887127167..7b4e629ea 100644 --- a/src/lib/sourcemap/debug-id.ts +++ b/src/lib/sourcemap/debug-id.ts @@ -64,22 +64,30 @@ export function getDebugIdSnippet(debugId: string): string { * Inject a Sentry debug ID into a JavaScript file and its companion * sourcemap. * - * Performs four mutations: + * By default performs four mutations: * 1. Prepends the runtime snippet to the JS file (after any hashbang) * 2. Appends a `//# debugId=` comment to the JS file * 3. Prepends a `;` to the sourcemap `mappings` (offsets by one line) * 4. Adds `debug_id` and `debugId` fields to the sourcemap JSON * + * When `options.skipSnippet` is `true`, step 1 is skipped and step 3 + * is adjusted (no extra `;` prefix since no snippet line is added). + * 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. * * @param jsPath - Path to the JavaScript file * @param mapPath - Path to the companion `.map` file + * @param options - Optional settings + * @param options.skipSnippet - Skip the IIFE runtime snippet (steps 1 & 3) * @returns The debug ID and whether it was newly injected */ export async function injectDebugId( jsPath: string, - mapPath: string + mapPath: string, + options?: { skipSnippet?: boolean } ): Promise<{ debugId: string; wasInjected: boolean }> { const [jsContent, mapContent] = await Promise.all([ readFile(jsPath, "utf-8"), @@ -94,21 +102,29 @@ export async function injectDebugId( // Generate debug ID from the sourcemap content (deterministic) const debugId = contentToDebugId(mapContent); - const snippet = getDebugIdSnippet(debugId); + const skipSnippet = options?.skipSnippet ?? false; // --- Mutate JS file --- - // Preserve hashbang if present, insert snippet after it let newJs: string; - if (jsContent.startsWith("#!")) { - const newlineIdx = jsContent.indexOf("\n"); - // Handle hashbang without trailing newline (entire file is the #! line) - const splitAt = newlineIdx === -1 ? jsContent.length : newlineIdx + 1; - const hashbang = jsContent.slice(0, splitAt); - const rest = jsContent.slice(splitAt); - const sep = newlineIdx === -1 ? "\n" : ""; - newJs = `${hashbang}${sep}${snippet}\n${rest}`; + if (skipSnippet) { + // Metadata-only mode: just append the debugId comment, no IIFE snippet. + // Used by the CLI's own build where the debug ID is registered in source. + newJs = jsContent; } else { - newJs = `${snippet}\n${jsContent}`; + // Full mode: prepend the runtime IIFE snippet (for user-facing injection). + const snippet = getDebugIdSnippet(debugId); + // Preserve hashbang if present, insert snippet after it + if (jsContent.startsWith("#!")) { + const newlineIdx = jsContent.indexOf("\n"); + // Handle hashbang without trailing newline (entire file is the #! line) + const splitAt = newlineIdx === -1 ? jsContent.length : newlineIdx + 1; + const hashbang = jsContent.slice(0, splitAt); + const rest = jsContent.slice(splitAt); + const sep = newlineIdx === -1 ? "\n" : ""; + newJs = `${hashbang}${sep}${snippet}\n${rest}`; + } else { + newJs = `${snippet}\n${jsContent}`; + } } // Append debug ID comment at the end newJs += `\n${DEBUGID_COMMENT_PREFIX}${debugId}\n`; @@ -120,10 +136,12 @@ export async function injectDebugId( debug_id?: string; debugId?: string; }; - // Prepend one `;` to mappings — tells decoders "no mappings for the - // first line" (the injected snippet line). Each `;` in VLQ mappings - // represents a line boundary. - map.mappings = `;${map.mappings}`; + if (!skipSnippet) { + // Prepend one `;` to mappings — tells decoders "no mappings for the + // first line" (the injected snippet line). Each `;` in VLQ mappings + // represents a line boundary. + map.mappings = `;${map.mappings}`; + } map.debug_id = debugId; map.debugId = debugId; diff --git a/src/lib/sourcemap/zip.ts b/src/lib/sourcemap/zip.ts index 0b96ddbc4..458e55de9 100644 --- a/src/lib/sourcemap/zip.ts +++ b/src/lib/sourcemap/zip.ts @@ -89,16 +89,35 @@ export class ZipWriter { /** * Create a new {@link ZipWriter} that writes to the given path. * - * The file is created (or truncated) immediately. The caller must - * eventually call {@link finalize} to produce a valid archive and - * release the file handle. + * The file is created (or truncated) immediately. An 8-byte source + * bundle header (`SYSB` magic + version 2) is written first — this + * is required by Sentry's symbolicator which uses the `symbolic` + * crate's `SourceBundle::test()` to identify valid bundles. Without + * this header, the symbolicator silently skips the archive and + * reports `js_no_source` / `missing_source`. + * + * The caller must eventually call {@link finalize} to produce a + * valid archive and release the file handle. * * @param outputPath - Filesystem path for the output ZIP file. * @returns A ready-to-use writer instance. */ static async create(outputPath: string): Promise { const fh = await open(outputPath, "w"); - return new ZipWriter(fh); + const writer = new ZipWriter(fh); + + // Write the SourceBundle magic header before any ZIP data. + // Format: 4-byte magic "SYSB" + 4-byte LE version (2). + // Sentry's symbolicator uses the `symbolic` crate to parse artifact + // bundles. Without this header, `SourceBundle::test()` rejects the + // archive and the symbolicator cannot extract files from it. + const header = Buffer.alloc(8); + header.write("SYSB", 0, 4, "ascii"); + header.writeUInt32LE(2, 4); + await fh.write(header, 0, header.length); + writer.offset = 8; + + return writer; } /** diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index d093356b0..e90238f04 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -367,6 +367,54 @@ export function initSentry( const libraryMode = options?.libraryMode ?? false; const environment = getCliEnvironment(); + /** + * Ensure frame paths are absolute so Sentry's symbolicator can match them. + * + * Bun compiled binaries with `sourcemap: "linked"` produce relative + * paths like `"dist-bin/bin.js"` in `Error.stack`. The symbolicator's + * `get_release_file_candidate_urls` generates `"~dist-bin/bin.js"` for + * relative paths (missing the `/` after `~`), which never matches + * uploaded artifacts at `"~/dist-bin/bin.js"`. Prepending `/` makes + * the candidate `"~/dist-bin/bin.js"` — a match. + */ + /** True if the path is relative (no leading `/`, no scheme, not `native`). */ + function isRelativePath(p: string): boolean { + if (p.startsWith("/") || p === "native") { + return false; + } + return !p.includes("://"); + } + + function ensureAbsolute(p: string): string { + return isRelativePath(p) ? `/${p}` : p; + } + + function normalizeExceptionFrames(event: Sentry.ErrorEvent): void { + for (const exc of event.exception?.values ?? []) { + for (const frame of exc.stacktrace?.frames ?? []) { + if (frame.abs_path) { + frame.abs_path = ensureAbsolute(frame.abs_path); + } + if (frame.filename) { + frame.filename = ensureAbsolute(frame.filename); + } + } + } + } + + function normalizeDebugImages(event: Sentry.ErrorEvent): void { + for (const img of event.debug_meta?.images ?? []) { + if ("code_file" in img && typeof img.code_file === "string") { + img.code_file = ensureAbsolute(img.code_file); + } + } + } + + function normalizeFramePaths(event: Sentry.ErrorEvent): void { + normalizeExceptionFrames(event); + normalizeDebugImages(event); + } + // Close the previous client to clean up its internal timers and beforeExit // handlers (client report flusher interval, log flush listener). Without // this, re-initializing the SDK (e.g., in tests) leaks setInterval handles @@ -434,6 +482,13 @@ export function initSentry( return null; } + // Normalize relative frame paths to absolute. Bun's compiled binaries + // with sourcemap: "linked" produce relative paths like "dist-bin/bin.js" + // in Error.stack. Sentry's symbolicator only matches absolute paths + // when generating tilde-prefixed URL candidates (e.g., "~/dist-bin/bin.js"), + // silently skipping resolution for relative paths. + normalizeFramePaths(event); + return event; }, }); diff --git a/test/lib/sourcemap/zip.test.ts b/test/lib/sourcemap/zip.test.ts index b54e29d12..24f079a83 100644 --- a/test/lib/sourcemap/zip.test.ts +++ b/test/lib/sourcemap/zip.test.ts @@ -147,18 +147,27 @@ describe("property: ZipWriter round-trip", () => { }); describe("ZipWriter binary format", () => { - test("starts with local file header signature", async () => { + test("starts with SYSB header followed by local file header", async () => { const zipPath = join(tmpDir, "sig.zip"); const zip = await ZipWriter.create(zipPath); await zip.addEntry("a.txt", Buffer.from("a")); await zip.finalize(); const data = await readFile(zipPath); - // Local file header signature: PK\x03\x04 - expect(data[0]).toBe(0x50); // P - expect(data[1]).toBe(0x4b); // K - expect(data[2]).toBe(0x03); - expect(data[3]).toBe(0x04); + // SourceBundle magic header: SYSB + version 2 (LE) + expect(data[0]).toBe(0x53); // S + expect(data[1]).toBe(0x59); // Y + expect(data[2]).toBe(0x53); // S + expect(data[3]).toBe(0x42); // B + expect(data[4]).toBe(0x02); // version 2 (LE) + expect(data[5]).toBe(0x00); + expect(data[6]).toBe(0x00); + expect(data[7]).toBe(0x00); + // Local file header signature follows: PK\x03\x04 + expect(data[8]).toBe(0x50); // P + expect(data[9]).toBe(0x4b); // K + expect(data[10]).toBe(0x03); + expect(data[11]).toBe(0x04); }); test("ends with EOCD signature", async () => { diff --git a/test/script/debug-id.test.ts b/test/script/debug-id.test.ts index f9920e82e..2498ad845 100644 --- a/test/script/debug-id.test.ts +++ b/test/script/debug-id.test.ts @@ -222,6 +222,69 @@ describe("injectDebugId", () => { expect(jsResult).toContain('console.log("hello")'); }); + test("skipSnippet: true skips IIFE but still appends debugId comment and mutates sourcemap", async () => { + const jsPath = join(tmpDir, "bundle.js"); + const mapPath = join(tmpDir, "bundle.js.map"); + const originalMappings = "AAAA;BACA"; + + await writeFile(jsPath, 'console.log("hello");\n'); + await writeFile( + mapPath, + JSON.stringify({ + version: 3, + sources: ["input.ts"], + mappings: originalMappings, + }) + ); + + const { debugId } = await injectDebugId(jsPath, mapPath, { + skipSnippet: true, + }); + + // Debug ID should be a valid UUID v4 + expect(debugId).toMatch(UUID_V4_RE); + + // JS file should NOT have the IIFE snippet + const jsResult = await readFile(jsPath, "utf-8"); + expect(jsResult).not.toContain("sentry-dbid-"); + expect(jsResult).not.toContain("_sentryDebugIds"); + // But should have the debugId comment + expect(jsResult).toContain(`//# debugId=${debugId}`); + // Original content should still be present + expect(jsResult).toContain('console.log("hello")'); + + // Sourcemap should have debugId fields + const mapResult = JSON.parse(await readFile(mapPath, "utf-8")); + expect(mapResult.debugId).toBe(debugId); + expect(mapResult.debug_id).toBe(debugId); + // Mappings should NOT have the extra `;` prefix (no snippet line added) + expect(mapResult.mappings).toBe(originalMappings); + }); + + test("skipSnippet: true is idempotent", async () => { + const jsPath = join(tmpDir, "bundle.js"); + const mapPath = join(tmpDir, "bundle.js.map"); + + await writeFile(jsPath, 'console.log("hello");\n'); + await writeFile( + mapPath, + JSON.stringify({ + version: 3, + sources: ["input.ts"], + mappings: "AAAA", + }) + ); + + const first = await injectDebugId(jsPath, mapPath, { skipSnippet: true }); + const jsAfterFirst = await readFile(jsPath, "utf-8"); + + const second = await injectDebugId(jsPath, mapPath, { skipSnippet: true }); + const jsAfterSecond = await readFile(jsPath, "utf-8"); + + expect(second.debugId).toBe(first.debugId); + expect(jsAfterSecond).toBe(jsAfterFirst); + }); + test("debug ID is deterministic based on sourcemap content", async () => { // Create two different JS files with the same sourcemap content const jsPath1 = join(tmpDir, "a.js");