From 816f706db41049f08948ce48913853d2f03d64ae Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Thu, 27 Aug 2026 14:32:00 +0200 Subject: [PATCH 1/3] fix(sourcemap): adopt a debug ID already present on the sourcemap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `inject` and `upload` only looked for an existing debug ID in the JS file (`//# debugId=`). When the JS had no such comment but the sourcemap already carried `debug_id`/`debugId`, the CLI minted a new content-derived ID, rewrote the JS, and overwrote the map's field. That breaks Sentry's bundler plugins running with `sourcemaps.disable: 'disable-upload'`. Those builds inject a runtime `_sentryDebugIds` snippet and stamp the same ID onto the emitted `.js.map`, but deliberately leave the bundle without a `//# debugId=` comment — rewriting it after emit invalidates subresource-integrity hashes computed during the build (getsentry/sentry-javascript-bundler-plugins#949). A later `sentry sourcemap upload ./dist` minted a different ID, so what the SDK reported at runtime never matched what Sentry indexed. Debug ID precedence is now: 1. `//# debugId=` in the JS (unchanged; the on-disk spec marker) 2. a valid `debug_id`/`debugId` on the sourcemap — adopted as-is 3. otherwise, mint from content (unchanged) In case 2 neither file is written: no IIFE snippet (the bundle already has the plugin's own `_sentryDebugIds` writer, and a second one under a different stack key makes the runtime mapping ambiguous) and no `mappings` offset (the plugin's map already lines up with the un-offset bundle). `debug_id` wins over `debugId`; a value that isn't a well-formed UUID is treated as absent and falls through to minting. Applies to external and inline maps, to `--dry-run`, and to the discovery read behind `sourcemap resolve`. Default behavior, matching v3 — not gated behind a flag, and `--no-rewrite` is unchanged. Note this is slightly stricter than v3's `sourcemaps inject`, which adopted the map's ID but still added its snippet to the bundle and re-serialized the map (its "Ignored: … already have debug ids" report meant "no new ID minted", not "file untouched"). Leaving both files byte-identical is what makes the integrity-hash workflow above work. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/content/docs/migrating-from-v3.md | 8 + packages/cli/src/lib/sourcemap/debug-id.ts | 60 ++++++- packages/cli/src/lib/sourcemap/inject.ts | 43 ++++- .../test/commands/sourcemap/upload.test.ts | 83 +++++++++- .../cli/test/lib/sourcemap/inject.test.ts | 156 ++++++++++++++++++ 5 files changed, 340 insertions(+), 10 deletions(-) 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..e82c7fcbd2 100644 --- a/packages/cli/src/lib/sourcemap/debug-id.ts +++ b/packages/cli/src/lib/sourcemap/debug-id.ts @@ -35,6 +35,33 @@ 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})/; +/** UUID format: 8-4-4-4-12 hex with hyphens. */ +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * 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_RE.test(candidate)) { + return candidate; + } + } + return; +} + /** * Generate a deterministic debug ID (UUID v4 format) from content. * @@ -118,7 +145,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 +171,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 +210,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 +285,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 +324,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..b50581bec8 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,29 @@ 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.debug(`failed to read debug ID from sourcemap: ${map.mapPath}`, err); + return; + } +} + /** A discovered JS + sourcemap pair. */ export type FilePair = { jsPath: string; map: MapSource }; @@ -781,6 +806,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..c01b6f6c24 100644 --- a/packages/cli/test/commands/sourcemap/upload.test.ts +++ b/packages/cli/test/commands/sourcemap/upload.test.ts @@ -4,7 +4,13 @@ * branches in `buildEmptyDiscoveryError`. */ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -721,4 +727,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, + }); + writeFileSync(jsPath, js); + writeFileSync(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(readFileSync(jsPath, "utf-8")).toBe(js); + expect(readFileSync(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`; + writeFileSync(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(readFileSync(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); + }); +}); From c11a364475e4de38a35ed92096fa61340a95d142 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Fri, 28 Aug 2026 09:43:52 +0200 Subject: [PATCH 2/3] apply suggestions --- packages/cli/src/lib/sourcemap/debug-id.ts | 9 +- packages/cli/src/lib/sourcemap/inject.ts | 24 +-- .../test/commands/sourcemap/upload.test.ts | 160 +++++++++--------- 3 files changed, 97 insertions(+), 96 deletions(-) diff --git a/packages/cli/src/lib/sourcemap/debug-id.ts b/packages/cli/src/lib/sourcemap/debug-id.ts index e82c7fcbd2..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,12 +36,8 @@ 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})/; -/** UUID format: 8-4-4-4-12 hex with hyphens. */ -const UUID_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - /** - * Read a pre-existing debug ID off a parsed sourcemap.å + * 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. @@ -55,7 +52,7 @@ export function readSourcemapDebugId(map: unknown): string | undefined { } const { debug_id: snake, debugId: camel } = map as SourcemapJson; for (const candidate of [snake, camel]) { - if (typeof candidate === "string" && UUID_RE.test(candidate)) { + if (typeof candidate === "string" && UUID_DASH_RE.test(candidate)) { return candidate; } } diff --git a/packages/cli/src/lib/sourcemap/inject.ts b/packages/cli/src/lib/sourcemap/inject.ts index b50581bec8..f5c3435ede 100644 --- a/packages/cli/src/lib/sourcemap/inject.ts +++ b/packages/cli/src/lib/sourcemap/inject.ts @@ -159,7 +159,9 @@ async function readMapDebugId(map: MapSource): Promise { JSON.parse(await readFile(map.mapPath, "utf-8")) ); } catch (err) { - log.debug(`failed to read debug ID from sourcemap: ${map.mapPath}`, err); + log.warn( + `could not read a debug ID from ${map.mapPath}: ${err instanceof Error ? err.message : String(err)}` + ); return; } } @@ -842,33 +844,33 @@ export function buildEmptyDiscoveryError( if (jsFiles === 0 && mapFiles === 0) { return new ValidationError( `Directory '${dir}' contains no JS or sourcemap files. ` + - "Check the path points at your build output, or pass " + - "--allow-empty to suppress this error.", + "Check the path points at your build output, or pass " + + "--allow-empty to suppress this error.", "directory" ); } if (jsFiles > 0 && mapFiles === 0) { return new ValidationError( `Found ${jsFiles} JS file(s) in '${dir}' but no companion .map ` + - "files. Your bundler is not emitting sourcemaps. For Vite/Astro: " + - "`vite.environments.client.build.sourcemap: 'hidden'`. For webpack: " + - "`devtool: 'hidden-source-map'`. Pass --allow-empty to suppress.", + "files. Your bundler is not emitting sourcemaps. For Vite/Astro: " + + "`vite.environments.client.build.sourcemap: 'hidden'`. For webpack: " + + "`devtool: 'hidden-source-map'`. Pass --allow-empty to suppress.", "directory" ); } if (mapFiles > 0 && jsFiles === 0) { return new ValidationError( `Found ${mapFiles} .map file(s) in '${dir}' but no companion JS ` + - "files. Ensure your build emits both JS and maps to the same " + - "directory. Pass --allow-empty to suppress.", + "files. Ensure your build emits both JS and maps to the same " + + "directory. Pass --allow-empty to suppress.", "directory" ); } return new ValidationError( `Found ${jsFiles} JS and ${mapFiles} .map file(s) in '${dir}' but ` + - "no JS file has a matching `.map` companion. Check that your " + - "bundler emits JS and sourcemaps with matching basenames. Pass " + - "--allow-empty to suppress.", + "no JS file has a matching `.map` companion. Check that your " + + "bundler emits JS and sourcemaps with matching basenames. Pass " + + "--allow-empty to suppress.", "directory" ); } diff --git a/packages/cli/test/commands/sourcemap/upload.test.ts b/packages/cli/test/commands/sourcemap/upload.test.ts index c01b6f6c24..6d7c8a3aa7 100644 --- a/packages/cli/test/commands/sourcemap/upload.test.ts +++ b/packages/cli/test/commands/sourcemap/upload.test.ts @@ -4,14 +4,7 @@ * branches in `buildEmptyDiscoveryError`. */ -import { - mkdirSync, - mkdtempSync, - readFileSync, - 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"; @@ -53,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 () => { @@ -83,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); @@ -106,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); @@ -120,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); @@ -152,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); @@ -181,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: "" }) ); @@ -196,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: "" }) ); @@ -217,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" ); @@ -227,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" ); @@ -245,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, @@ -256,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]; @@ -300,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 @@ -327,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 @@ -342,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, @@ -379,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") @@ -410,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") @@ -438,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, @@ -466,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, @@ -503,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, @@ -514,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") @@ -534,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: "" }) ); @@ -567,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") @@ -600,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: "" }) ); @@ -616,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: "" }) ); @@ -641,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: "" }) ); @@ -675,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: "" }) ); @@ -694,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: "" }) ); @@ -743,8 +745,8 @@ describe("sourcemap upload command — --allow-empty behavior", () => { mappings: "AAAA", debug_id: pluginId, }); - writeFileSync(jsPath, js); - writeFileSync(mapPath, map); + await writeFile(jsPath, js); + await writeFile(mapPath, map); const uploadSpy = vi .spyOn(sourcemapsApi, "uploadSourcemaps") @@ -760,8 +762,8 @@ describe("sourcemap upload command — --allow-empty behavior", () => { expect(files.find((f) => f.type === "source_map")?.debugId).toBe( pluginId ); - expect(readFileSync(jsPath, "utf-8")).toBe(js); - expect(readFileSync(mapPath, "utf-8")).toBe(map); + expect(await readFile(jsPath, "utf-8")).toBe(js); + expect(await readFile(mapPath, "utf-8")).toBe(map); } finally { uploadSpy.mockRestore(); } @@ -779,7 +781,7 @@ describe("sourcemap upload command — --allow-empty behavior", () => { }; const dataUrl = `data:application/json;base64,${Buffer.from(JSON.stringify(map)).toString("base64")}`; const js = `console.log(1)\n//# sourceMappingURL=${dataUrl}\n`; - writeFileSync(jsPath, js); + await writeFile(jsPath, js); const uploadSpy = vi .spyOn(sourcemapsApi, "uploadSourcemaps") @@ -797,7 +799,7 @@ describe("sourcemap upload command — --allow-empty behavior", () => { expect( JSON.parse((mapFile?.content as Buffer).toString("utf-8")).debug_id ).toBe(pluginId); - expect(readFileSync(jsPath, "utf-8")).toBe(js); + expect(await readFile(jsPath, "utf-8")).toBe(js); } finally { uploadSpy.mockRestore(); } From 1f671e307223f7ab81459385e9d7462350275281 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Fri, 28 Aug 2026 10:01:16 +0200 Subject: [PATCH 3/3] lint --- packages/cli/src/lib/sourcemap/inject.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/lib/sourcemap/inject.ts b/packages/cli/src/lib/sourcemap/inject.ts index f5c3435ede..0c51b38593 100644 --- a/packages/cli/src/lib/sourcemap/inject.ts +++ b/packages/cli/src/lib/sourcemap/inject.ts @@ -844,33 +844,33 @@ export function buildEmptyDiscoveryError( if (jsFiles === 0 && mapFiles === 0) { return new ValidationError( `Directory '${dir}' contains no JS or sourcemap files. ` + - "Check the path points at your build output, or pass " + - "--allow-empty to suppress this error.", + "Check the path points at your build output, or pass " + + "--allow-empty to suppress this error.", "directory" ); } if (jsFiles > 0 && mapFiles === 0) { return new ValidationError( `Found ${jsFiles} JS file(s) in '${dir}' but no companion .map ` + - "files. Your bundler is not emitting sourcemaps. For Vite/Astro: " + - "`vite.environments.client.build.sourcemap: 'hidden'`. For webpack: " + - "`devtool: 'hidden-source-map'`. Pass --allow-empty to suppress.", + "files. Your bundler is not emitting sourcemaps. For Vite/Astro: " + + "`vite.environments.client.build.sourcemap: 'hidden'`. For webpack: " + + "`devtool: 'hidden-source-map'`. Pass --allow-empty to suppress.", "directory" ); } if (mapFiles > 0 && jsFiles === 0) { return new ValidationError( `Found ${mapFiles} .map file(s) in '${dir}' but no companion JS ` + - "files. Ensure your build emits both JS and maps to the same " + - "directory. Pass --allow-empty to suppress.", + "files. Ensure your build emits both JS and maps to the same " + + "directory. Pass --allow-empty to suppress.", "directory" ); } return new ValidationError( `Found ${jsFiles} JS and ${mapFiles} .map file(s) in '${dir}' but ` + - "no JS file has a matching `.map` companion. Check that your " + - "bundler emits JS and sourcemaps with matching basenames. Pass " + - "--allow-empty to suppress.", + "no JS file has a matching `.map` companion. Check that your " + + "bundler emits JS and sourcemaps with matching basenames. Pass " + + "--allow-empty to suppress.", "directory" ); }