diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 8fe3ce518..540d5b667 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -14,9 +14,14 @@ export default defineConfig({ // Generate sourcemaps for Sentry. "hidden" produces .map files without // adding //# sourceMappingURL comments to the output (the debug IDs // injected post-build by `sentry sourcemap inject` are used instead). + // + // Astro 6 / Vite 7 reads `sourcemap` from + // `vite.environments.{client,ssr}.build.sourcemap` (Environments API), + // not the legacy top-level `vite.build.sourcemap`. vite: { - build: { - sourcemap: "hidden", + environments: { + client: { build: { sourcemap: "hidden" } }, + ssr: { build: { sourcemap: "hidden" } }, }, }, integrations: [ diff --git a/docs/src/fragments/commands/sourcemap.md b/docs/src/fragments/commands/sourcemap.md index ba5af15f0..1852767ac 100644 --- a/docs/src/fragments/commands/sourcemap.md +++ b/docs/src/fragments/commands/sourcemap.md @@ -27,3 +27,27 @@ sentry sourcemap upload ./dist --release 1.0.0 # Set a custom URL prefix sentry sourcemap upload ./dist --url-prefix '~/static/js/' ``` + +## Error handling + +Both `sentry sourcemap inject` and `sentry sourcemap upload` exit with an +error if zero JS + sourcemap pairs are discovered in the target +directory. This catches silent bundler misconfigurations — the most +common cause is a bundler that isn't emitting `.map` files: + +``` +# Vite / Astro: set `vite.build.sourcemap: "hidden"` (Astro 5) or +# `vite.environments.client.build.sourcemap: "hidden"` (Astro 6+). + +# webpack: set `devtool: "hidden-source-map"`. + +# esbuild: set `sourcemap: true` or `sourcemap: "linked"`. +``` + +For CI steps that may run against legitimately-empty directories (e.g., +library-only repos, conditional release skips), pass `--allow-empty` to +suppress the error: + +```bash +sentry sourcemap upload ./dist --allow-empty +``` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/sourcemap.md b/plugins/sentry-cli/skills/sentry-cli/references/sourcemap.md index 3907d68cb..f8e991268 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/sourcemap.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/sourcemap.md @@ -18,6 +18,7 @@ Inject debug IDs into JavaScript files and sourcemaps **Flags:** - `--ext - Comma-separated file extensions to process (default: .js,.cjs,.mjs)` - `--dry-run - Show what would be modified without writing` +- `--allow-empty - Exit successfully when no JS + sourcemap pairs are found (default: error out to catch silent build misconfigurations)` **Examples:** @@ -39,6 +40,7 @@ Upload sourcemaps to Sentry **Flags:** - `--release - Release version to associate with the upload` - `--url-prefix - URL prefix for uploaded files (default: ~/) - (default: "~/")` +- `--allow-empty - Exit successfully when no JS + sourcemap pairs are found (default: error out to catch silent build misconfigurations)` **Examples:** @@ -51,6 +53,8 @@ sentry sourcemap upload ./dist --release 1.0.0 # Set a custom URL prefix sentry sourcemap upload ./dist --url-prefix '~/static/js/' + +sentry sourcemap upload ./dist --allow-empty ``` All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/src/commands/sourcemap/inject.ts b/src/commands/sourcemap/inject.ts index 282de2c1b..a7f2ff623 100644 --- a/src/commands/sourcemap/inject.ts +++ b/src/commands/sourcemap/inject.ts @@ -14,6 +14,10 @@ import { } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { + assertDirectoryReadable, + buildEmptyDiscoveryError, + diagnoseEmptyDiscovery, + discoverFilePairs, type InjectResult, injectDirectory, } from "../../lib/sourcemap/inject.js"; @@ -55,10 +59,15 @@ export const injectCommand = buildCommand({ "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" + + "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 " + + "legitimately be empty.\n\n" + "Usage:\n" + " sentry sourcemap inject ./dist\n" + " sentry sourcemap inject ./build --ext .js,.mjs\n" + - " sentry sourcemap inject ./out --dry-run", + " sentry sourcemap inject ./out --dry-run\n" + + " sentry sourcemap inject ./maybe-empty --allow-empty", }, output: { human: formatInjectResult, @@ -88,14 +97,38 @@ export const injectCommand = buildCommand({ optional: true, default: false, }, + "allow-empty": { + kind: "boolean", + brief: + "Exit successfully when no JS + sourcemap pairs are found " + + "(default: error out to catch silent build misconfigurations)", + optional: true, + default: false, + }, }, }, async *func( this: SentryContext, - flags: { ext?: string; "dry-run"?: boolean }, + flags: { ext?: string; "dry-run"?: boolean; "allow-empty"?: boolean }, dir: string ) { + // Discover pairs read-only first so we don't error after partially + // mutating files. Zero *discovered* pairs (distinct from zero + // *injected* — the idempotent re-run case) almost always means a + // missing-.map bundler misconfiguration; --allow-empty opts out. + await assertDirectoryReadable(dir); + const extensions = flags.ext?.split(",").map((e) => e.trim()); + const extSet = extensions + ? new Set(extensions.map((e) => (e.startsWith(".") ? e : `.${e}`))) + : undefined; + + const pairs = await discoverFilePairs(dir, extSet); + if (pairs.length === 0 && !flags["allow-empty"]) { + const diag = await diagnoseEmptyDiscovery(dir, { extensions }); + throw buildEmptyDiscoveryError(dir, diag); + } + const results = await injectDirectory(dir, { extensions, dryRun: flags["dry-run"], diff --git a/src/commands/sourcemap/upload.ts b/src/commands/sourcemap/upload.ts index 84af8fbbc..853e31620 100644 --- a/src/commands/sourcemap/upload.ts +++ b/src/commands/sourcemap/upload.ts @@ -17,14 +17,21 @@ import { ContextError } from "../../lib/errors.js"; import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { resolveOrgAndProject } from "../../lib/resolve-target.js"; -import { injectDirectory } from "../../lib/sourcemap/inject.js"; +import { + assertDirectoryReadable, + buildEmptyDiscoveryError, + diagnoseEmptyDiscovery, + discoverFilePairs, + injectDirectory, +} from "../../lib/sourcemap/inject.js"; /** Result type for the upload command. */ type UploadCommandResult = { - /** Organization slug. */ - org: string; - /** Project slug. */ - project: string; + /** Organization slug. Omitted when --allow-empty short-circuits before + * org/project resolution. */ + org?: string; + /** Project slug. Omitted in the same short-circuit case. */ + project?: string; /** Release version, if provided. */ release?: string; /** Number of file pairs uploaded. */ @@ -33,11 +40,14 @@ type UploadCommandResult = { /** Format human-readable output for upload results. */ function formatUploadResult(data: UploadCommandResult): string { - const rows: [string, string][] = [ - ["Organization", data.org], - ["Project", data.project], - ["Files uploaded", String(data.filesUploaded)], - ]; + const rows: [string, string][] = []; + if (data.org) { + rows.push(["Organization", data.org]); + } + if (data.project) { + rows.push(["Project", data.project]); + } + rows.push(["Files uploaded", String(data.filesUploaded)]); if (data.release) { rows.push(["Release", data.release]); } @@ -54,10 +64,15 @@ export const uploadCommand = buildCommand({ "debug-ID-based matching.\n\n" + "Automatically injects debug IDs into any files that don't already have them.\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 " + + "--allow-empty to suppress this check for directories that may " + + "legitimately be empty.\n\n" + "Usage:\n" + " sentry sourcemap upload ./dist\n" + " sentry sourcemap upload ./dist --release 1.0.0\n" + - " sentry sourcemap upload ./dist --url-prefix '~/static/js/'", + " sentry sourcemap upload ./dist --url-prefix '~/static/js/'\n" + + " sentry sourcemap upload ./maybe-empty --allow-empty", }, output: { human: formatUploadResult, @@ -87,14 +102,50 @@ export const uploadCommand = buildCommand({ optional: true, default: "~/", }, + "allow-empty": { + kind: "boolean", + brief: + "Exit successfully when no JS + sourcemap pairs are found " + + "(default: error out to catch silent build misconfigurations)", + optional: true, + default: false, + }, }, }, async *func( this: SentryContext, - flags: { release?: string; "url-prefix"?: string }, + flags: { + release?: string; + "url-prefix"?: string; + "allow-empty"?: boolean; + }, dir: string ) { - // Resolve org/project via the standard cascade + // Validate the directory and discover pairs read-only first so we + // don't write debug IDs when the upload won't proceed (empty dir, + // typoed path, missing credentials). + await assertDirectoryReadable(dir); + const pairs = await discoverFilePairs(dir); + + if (pairs.length === 0) { + if (!flags["allow-empty"]) { + const diag = await diagnoseEmptyDiscovery(dir); + throw buildEmptyDiscoveryError(dir, diag); + } + // --allow-empty: nothing to upload, so don't require Sentry + // credentials. This makes the flag actually usable in the + // library-only / conditional-release-skip cases the docs name. + yield new CommandOutput({ + release: flags.release, + filesUploaded: 0, + }); + return { + hint: + "No JS + sourcemap pairs found in the target directory. " + + "If this is unexpected, check your bundler emits .map files.", + }; + } + const resolved = await resolveOrgAndProject({ cwd: this.cwd, usageHint: USAGE_HINT, @@ -104,21 +155,8 @@ export const uploadCommand = buildCommand({ } const { org, project } = resolved; - // Discover and inject debug IDs into JS + sourcemap pairs const results = await injectDirectory(dir); - if (results.length === 0) { - yield new CommandOutput({ - org, - project, - release: flags.release, - filesUploaded: 0, - }); - return { - hint: "No JS + sourcemap pairs found. Run `sentry sourcemap inject` first.", - }; - } - const urlPrefix = flags["url-prefix"] ?? "~/"; // Build artifact file list with paths relative to the upload directory diff --git a/src/lib/sourcemap/inject.ts b/src/lib/sourcemap/inject.ts index 3cf399771..592daa7d0 100644 --- a/src/lib/sourcemap/inject.ts +++ b/src/lib/sourcemap/inject.ts @@ -8,6 +8,7 @@ import { readFile, stat } from "node:fs/promises"; import { resolve as resolvePath } from "node:path"; import { NODE_MODULES_DIRNAME } from "../constants.js"; +import { ValidationError } from "../errors.js"; import { walkFiles } from "../scan/index.js"; import { EXISTING_DEBUGID_RE, injectDebugId } from "./debug-id.js"; @@ -72,7 +73,7 @@ export async function injectDirectory( } /** A discovered JS + sourcemap pair. */ -type FilePair = { jsPath: string; mapPath: string }; +export type FilePair = { jsPath: string; mapPath: string }; /** * Check if a path has a companion .map file. @@ -113,9 +114,15 @@ async function findCompanionMap(jsPath: string): Promise { */ const SOURCEMAP_SKIP_DIRS: readonly string[] = [NODE_MODULES_DIRNAME]; -async function discoverFilePairs( +/** + * Read-only discovery pass — returns the list of JS + sourcemap pairs + * without injecting debug IDs. Used as a pre-check by the upload + * command so the directory isn't mutated when the upload won't + * proceed (empty dir, missing credentials, etc.). + */ +export async function discoverFilePairs( dir: string, - extensions: Set + extensions: Set = DEFAULT_EXTENSIONS ): Promise { // `walkFiles` requires an absolute cwd. CLI callers pass // user-supplied positional args like `./dist` directly through to @@ -138,3 +145,123 @@ async function discoverFilePairs( } return pairs; } + +/** + * Throw {@link ValidationError} if `dir` doesn't exist or isn't a + * readable directory. Distinct messages per failure mode so the user + * gets a useful pointer instead of "no sourcemaps found". + */ +export async function assertDirectoryReadable(dir: string): Promise { + try { + const s = await stat(dir); + if (!s.isDirectory()) { + throw new ValidationError( + `Path '${dir}' is not a directory.`, + "directory" + ); + } + } catch (err) { + if (err instanceof ValidationError) { + throw err; + } + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + throw new ValidationError( + `Directory '${dir}' does not exist.`, + "directory" + ); + } + const msg = err instanceof Error ? err.message : String(err); + throw new ValidationError( + `Cannot read directory '${dir}': ${msg}`, + "directory" + ); + } +} + +/** + * Counts of JS and `.map` files in a directory, used by + * {@link buildEmptyDiscoveryError} to tailor the zero-pairs error. + */ +export type DiscoveryDiagnostic = { + jsFiles: number; + mapFiles: number; +}; + +/** + * Count JS and `.map` files in a single walker pass. Only called on + * the zero-pairs error path. + */ +export async function diagnoseEmptyDiscovery( + dir: string, + options: InjectDirectoryOptions = {} +): Promise { + // Build one set covering JS extensions + `.map` so the walker visits + // both in a single pass. + const extensions = options.extensions + ? new Set(options.extensions.map((e) => (e.startsWith(".") ? e : `.${e}`))) + : new Set(DEFAULT_EXTENSIONS); + extensions.add(".map"); + + const absDir = resolvePath(dir); + let jsFiles = 0; + let mapFiles = 0; + for await (const entry of walkFiles({ + cwd: absDir, + extensions, + alwaysSkipDirs: SOURCEMAP_SKIP_DIRS, + hidden: false, + respectGitignore: false, + maxFileSize: Number.POSITIVE_INFINITY, + })) { + if (entry.absolutePath.endsWith(".map")) { + mapFiles += 1; + } else { + jsFiles += 1; + } + } + return { jsFiles, mapFiles }; +} + +/** + * Build an actionable error for the zero-pairs case, tailored to + * which side of the JS/map pairing is missing. + */ +export function buildEmptyDiscoveryError( + dir: string, + diag: DiscoveryDiagnostic +): ValidationError { + const { jsFiles, mapFiles } = diag; + 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.", + "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.", + "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.", + "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.", + "directory" + ); +} diff --git a/test/commands/sourcemap/upload.test.ts b/test/commands/sourcemap/upload.test.ts new file mode 100644 index 000000000..b453ad646 --- /dev/null +++ b/test/commands/sourcemap/upload.test.ts @@ -0,0 +1,313 @@ +/** + * Tests for the strict-by-default zero-pairs behavior on `sentry + * sourcemap inject` / `upload`, including the per-shape diagnostic + * branches in `buildEmptyDiscoveryError`. + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +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"; + +type InjectFuncArgs = { + ext?: string; + "dry-run"?: boolean; + "allow-empty"?: boolean; +}; +type UploadFuncArgs = { + release?: string; + "url-prefix"?: string; + "allow-empty"?: boolean; +}; +type CmdFunc = (this: unknown, flags: A, dir: string) => Promise; + +function makeContext() { + return { + stdout: { write: mock(() => true) }, + stderr: { write: mock(() => true) }, + cwd: "/tmp", + }; +} + +describe("sourcemap inject command — --allow-empty behavior", () => { + let dir: string; + let func: CmdFunc; + + beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), "sentry-inject-cmd-")); + func = (await injectCommand.loader()) as unknown as CmdFunc; + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + test("empty directory: throws actionable ValidationError", async () => { + const ctx = makeContext(); + try { + await func.call(ctx, {}, dir); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ValidationError); + const msg = (err as Error).message; + expect(msg).toContain(dir); + expect(msg).toContain("--allow-empty"); + expect(msg).toMatch(/no JS or sourcemap files/i); + } + }); + + test("empty directory with --allow-empty: succeeds silently", async () => { + const ctx = makeContext(); + await expect( + func.call(ctx, { "allow-empty": true }, dir) + ).resolves.toBeUndefined(); + }); + + 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'); + 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"); + const ctx = makeContext(); + try { + await func.call(ctx, {}, dir); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ValidationError); + const msg = (err as Error).message; + expect(msg).toContain("2 JS file(s)"); + expect(msg).toMatch(/vite|webpack/i); + expect(msg).toContain("sourcemap"); + } + }); + + test(".map files without matching .js files: throws with mismatch hint", async () => { + writeFileSync(join(dir, "app.js.map"), '{"version":3}\n'); + const ctx = makeContext(); + try { + await func.call(ctx, {}, dir); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ValidationError); + const msg = (err as Error).message; + expect(msg).toContain(".map file(s)"); + expect(msg).toContain("no companion JS"); + } + }); + + 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'); + const ctx = makeContext(); + try { + await func.call(ctx, {}, dir); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ValidationError); + const msg = (err as Error).message; + expect(msg).toContain("1 JS"); + expect(msg).toContain("1 .map"); + expect(msg).toContain("matching basename"); + } + }); + + test("non-existent directory: throws with distinct 'does not exist' message", async () => { + const missing = join(dir, "does-not-exist"); + const ctx = makeContext(); + try { + await func.call(ctx, {}, missing); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ValidationError); + const msg = (err as Error).message; + expect(msg).toContain(missing); + expect(msg).toMatch(/does not exist/i); + expect(msg).not.toContain("--allow-empty"); + } + }); + + 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"); + const ctx = makeContext(); + try { + await func.call(ctx, {}, filePath); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ValidationError); + const msg = (err as Error).message; + expect(msg).toContain(filePath); + expect(msg).toMatch(/not a directory/i); + } + }); + + test("--dry-run + empty directory: still errors (dry-run is not an escape hatch)", async () => { + const ctx = makeContext(); + await expect( + func.call(ctx, { "dry-run": true }, dir) + ).rejects.toBeInstanceOf(ValidationError); + }); + + test("--dry-run + --allow-empty: succeeds silently", async () => { + const ctx = makeContext(); + await expect( + func.call(ctx, { "dry-run": true, "allow-empty": true }, dir) + ).resolves.toBeUndefined(); + }); +}); + +describe("sourcemap upload command — --allow-empty behavior", () => { + let dir: string; + let func: CmdFunc; + let savedEnv: Record; + + beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), "sentry-upload-cmd-")); + // Short-circuit resolveOrgAndProject so tests don't need DSN/config. + savedEnv = { + SENTRY_ORG: process.env.SENTRY_ORG, + SENTRY_PROJECT: process.env.SENTRY_PROJECT, + }; + process.env.SENTRY_ORG = "test-org"; + process.env.SENTRY_PROJECT = "test-project"; + func = (await uploadCommand.loader()) as unknown as CmdFunc; + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) { + delete process.env[k]; + } else { + process.env[k] = v; + } + } + }); + + test("empty directory: throws actionable ValidationError", async () => { + const ctx = makeContext(); + try { + await func.call(ctx, {}, dir); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ValidationError); + const msg = (err as Error).message; + expect(msg).toContain(dir); + expect(msg).toContain("--allow-empty"); + } + }); + + test("empty directory with --allow-empty: succeeds silently", async () => { + const ctx = makeContext(); + await expect( + func.call(ctx, { "allow-empty": true }, dir) + ).resolves.toBeUndefined(); + }); + + test("empty directory with --allow-empty: does not require credentials", async () => { + // The library-only / conditional-release-skip cases named in the + // docs may run without DSN/org/project context. With nothing to + // upload, the command must not insist on resolving them. + delete process.env.SENTRY_ORG; + delete process.env.SENTRY_PROJECT; + const ctx = makeContext(); + await expect( + func.call(ctx, { "allow-empty": true }, dir) + ).resolves.toBeUndefined(); + }); + + 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"); + const ctx = makeContext(); + await expect(func.call(ctx, {}, dir)).rejects.toBeInstanceOf( + ValidationError + ); + }); + + test("non-existent directory: throws before resolving org/project", async () => { + // Cleared so the dir-check has to fire first to produce a useful error. + delete process.env.SENTRY_ORG; + delete process.env.SENTRY_PROJECT; + const missing = join(dir, "does-not-exist"); + const ctx = makeContext(); + try { + await func.call(ctx, {}, missing); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ValidationError); + const msg = (err as Error).message; + expect(msg).toMatch(/does not exist/i); + } + }); + + 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")); + const jsPath = join(dir, "_astro", "app.js"); + const original = "console.log(1)\n"; + writeFileSync(jsPath, original); + const ctx = makeContext(); + await expect(func.call(ctx, {}, dir)).rejects.toBeInstanceOf( + ValidationError + ); + const after = await Bun.file(jsPath).text(); + expect(after).toBe(original); + expect(after).not.toContain("_sentryDebugIds"); + expect(after).not.toContain("sentry-dbid"); + }); + + test("happy path: directory with JS+map pair invokes uploadSourcemaps", async () => { + mkdirSync(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( + mapPath, + JSON.stringify({ + version: 3, + sources: ["app.ts"], + names: [], + mappings: "", + }) + ); + + const uploadSpy = spyOn( + sourcemapsApi, + "uploadSourcemaps" + ).mockResolvedValue(undefined); + try { + const ctx = makeContext(); + await func.call(ctx, {}, dir); + expect(uploadSpy).toHaveBeenCalledTimes(1); + const callArgs = uploadSpy.mock.calls[0]?.[0]; + expect(callArgs?.org).toBe("test-org"); + expect(callArgs?.project).toBe("test-project"); + expect(callArgs?.files).toHaveLength(2); + const types = callArgs?.files.map((f) => f.type); + expect(types).toContain("minified_source"); + expect(types).toContain("source_map"); + } finally { + uploadSpy.mockRestore(); + } + }); +});