diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 027f59e4d..0bed54cb1 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -275,7 +275,7 @@ const LIBRARY_EXCLUDED_INTEGRATIONS = new Set([ ...EXCLUDED_INTEGRATIONS, "OnUncaughtException", // process.on('uncaughtException') "OnUnhandledRejection", // process.on('unhandledRejection') - "ProcessSession", // process.on('beforeExit') + "ProcessSession", // process.on('beforeExit') — anonymous handler, no cleanup "Http", // diagnostics_channel + trace headers "NodeFetch", // diagnostics_channel + trace headers "FunctionToString", // wraps Function.prototype.toString @@ -355,6 +355,13 @@ export function initSentry( const libraryMode = options?.libraryMode ?? false; const environment = getEnv().NODE_ENV ?? "development"; + // 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 + // that keep the event loop alive and prevent the process from exiting. + // close(0) removes listeners synchronously; we don't need to await the flush. + Sentry.getClient()?.close(0); + const client = Sentry.init({ dsn: SENTRY_CLI_DSN, enabled, @@ -374,10 +381,12 @@ export function initSentry( }, environment, // Enable Sentry structured logs for non-exception telemetry (e.g., unexpected input warnings). - // Disabled in library mode — logs use timers/beforeExit that pollute the host process. - enableLogs: !libraryMode, - // Disable client reports in library mode — they use timers/beforeExit. - ...(libraryMode && { sendClientReports: false }), + // Disabled when telemetry is off or in library mode — the SDK registers + // beforeExit handlers for log flushing that keep the event loop alive. + enableLogs: enabled && !libraryMode, + // Disable client reports when telemetry is off or in library mode — the SDK + // registers a setInterval + beforeExit handler that keep the event loop alive. + ...((libraryMode || !enabled) && { sendClientReports: false }), // Sample all events for CLI telemetry (low volume) tracesSampleRate: 1, sampleRate: 1, @@ -405,6 +414,12 @@ export function initSentry( }, }); + // Always remove our own previous handler on re-init. + if (currentBeforeExitHandler) { + process.removeListener("beforeExit", currentBeforeExitHandler); + currentBeforeExitHandler = null; + } + if (client?.getOptions().enabled) { const isBun = typeof process.versions.bun !== "undefined"; const runtime = isBun ? "bun" : "node"; @@ -441,14 +456,7 @@ export function initSentry( // // Skipped in library mode — the host owns the process lifecycle. // The library entry point calls client.flush() manually after completion. - // - // Replace previous handler on re-init (e.g., auto-login retry calls - // withTelemetry → initSentry twice) to avoid duplicate handlers with - // independent re-entry guards and stale client references. if (!libraryMode) { - if (currentBeforeExitHandler) { - process.removeListener("beforeExit", currentBeforeExitHandler); - } currentBeforeExitHandler = createBeforeExitHandler(client); process.on("beforeExit", currentBeforeExitHandler); } diff --git a/test/commands/auth/logout.test.ts b/test/commands/auth/logout.test.ts index 8953cb448..49a9c4c66 100644 --- a/test/commands/auth/logout.test.ts +++ b/test/commands/auth/logout.test.ts @@ -125,6 +125,9 @@ describe("logoutCommand.func", () => { test("env token (SENTRY_TOKEN): shows correct env var name in error", async () => { isAuthenticatedSpy.mockReturnValue(true); isEnvTokenActiveSpy.mockReturnValue(true); + // Clear SENTRY_AUTH_TOKEN so SENTRY_TOKEN takes priority + const savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; // Set env var directly — getActiveEnvVarName() reads env vars via getEnvToken() process.env.SENTRY_TOKEN = "sntrys_token_456"; const { context } = createContext(); @@ -138,6 +141,9 @@ describe("logoutCommand.func", () => { expect(msg).toContain("SENTRY_TOKEN"); } finally { delete process.env.SENTRY_TOKEN; + if (savedAuthToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } } expect(clearAuthSpy).not.toHaveBeenCalled(); }); diff --git a/test/commands/auth/refresh.test.ts b/test/commands/auth/refresh.test.ts index 94c786a26..921b08cf5 100644 --- a/test/commands/auth/refresh.test.ts +++ b/test/commands/auth/refresh.test.ts @@ -84,6 +84,9 @@ describe("refreshCommand.func", () => { test("env token (SENTRY_TOKEN): throws AuthError with SENTRY_TOKEN in message", async () => { isEnvTokenActiveSpy.mockReturnValue(true); + // Clear SENTRY_AUTH_TOKEN so SENTRY_TOKEN takes priority + const savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; // Set env var directly — getActiveEnvVarName() reads env vars via getEnvToken() process.env.SENTRY_TOKEN = "sntrys_token_456"; @@ -99,6 +102,9 @@ describe("refreshCommand.func", () => { expect((err as AuthError).message).not.toContain("SENTRY_AUTH_TOKEN"); } finally { delete process.env.SENTRY_TOKEN; + if (savedAuthToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } } expect(refreshTokenSpy).not.toHaveBeenCalled(); diff --git a/test/commands/auth/whoami.test.ts b/test/commands/auth/whoami.test.ts index dd44e52dc..11a6d9c24 100644 --- a/test/commands/auth/whoami.test.ts +++ b/test/commands/auth/whoami.test.ts @@ -84,6 +84,24 @@ describe("whoamiCommand.func", () => { }); describe("unauthenticated", () => { + let getAuthConfigSpy: ReturnType; + let savedAuthToken: string | undefined; + + beforeEach(() => { + savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; + getAuthConfigSpy = spyOn(dbAuth, "getAuthConfig").mockReturnValue( + undefined + ); + }); + + afterEach(() => { + getAuthConfigSpy.mockRestore(); + if (savedAuthToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } + }); + test("throws AuthError(not_authenticated) when no token stored", async () => { isAuthenticatedSpy.mockReturnValue(false); diff --git a/test/commands/log/list.test.ts b/test/commands/log/list.test.ts index 43ecaab2a..450fba394 100644 --- a/test/commands/log/list.test.ts +++ b/test/commands/log/list.test.ts @@ -30,6 +30,8 @@ import { import { listCommand } from "../../../src/commands/log/list.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as apiClient from "../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as dbAuth from "../../../src/lib/db/auth.js"; import { AuthError, ContextError, @@ -237,6 +239,23 @@ const newerLogs: SentryLog[] = [ }, ]; +// ============================================================================ +// Auth setup — mock getAuthConfig for all tests (auth guard added in #611) +// ============================================================================ + +let getAuthConfigSpy: ReturnType; + +beforeEach(() => { + getAuthConfigSpy = spyOn(dbAuth, "getAuthConfig").mockReturnValue({ + token: "sntrys_test", + source: "oauth" as const, + }); +}); + +afterEach(() => { + getAuthConfigSpy.mockRestore(); +}); + // ============================================================================ // Standard mode (project-scoped, no trace-id positional) // ============================================================================ @@ -837,7 +856,14 @@ describe("listCommand.func — flag validation", () => { test("allows --sort newest with --follow", async () => { // Should not throw ValidationError — the error (if any) comes from - // downstream resolution, not flag validation. + // downstream resolution, not flag validation. Mock resolution to reject + // with a non-ValidationError so we can verify flag validation passed. + const resolveOrgProjectSpy = spyOn( + resolveTarget, + "resolveOrgProjectFromArg" + ).mockRejectedValueOnce( + new ContextError("Organization", "sentry log list") + ); const { context } = createMockContext(); const func = await listCommand.loader(); await expect( @@ -847,6 +873,7 @@ describe("listCommand.func — flag validation", () => { "my-org/my-project" ) ).rejects.not.toThrow(ValidationError); + resolveOrgProjectSpy.mockRestore(); }); }); diff --git a/test/commands/project/list.test.ts b/test/commands/project/list.test.ts index 870f45ae9..0f1fd4123 100644 --- a/test/commands/project/list.test.ts +++ b/test/commands/project/list.test.ts @@ -970,7 +970,16 @@ describe("fetchOrgProjectsSafe", () => { // Clear auth token so the API client throws AuthError before making any request await clearAuth(); - await expect(fetchOrgProjectsSafe("myorg")).rejects.toThrow(AuthError); + const savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; + + try { + await expect(fetchOrgProjectsSafe("myorg")).rejects.toThrow(AuthError); + } finally { + if (savedAuthToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } + } }); }); @@ -1234,13 +1243,22 @@ describe("handleAutoDetect", () => { // Clear auth so getAuthToken() throws AuthError before any fetch await clearAuth(); - await expect( - handleAutoDetect("/tmp/test-project", { - limit: 30, - json: true, - fresh: false, - }) - ).rejects.toThrow(AuthError); + const savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; + + try { + await expect( + handleAutoDetect("/tmp/test-project", { + limit: 30, + json: true, + fresh: false, + }) + ).rejects.toThrow(AuthError); + } finally { + if (savedAuthToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } + } }); test("slow path: uses full fetch when platform filter is active", async () => { diff --git a/test/fixture.ts b/test/fixture.ts index b177e552b..2d98346b4 100644 --- a/test/fixture.ts +++ b/test/fixture.ts @@ -114,6 +114,8 @@ export function createE2EContext( run: (args: string[]) => runCli(args, { env: { + SENTRY_AUTH_TOKEN: "", + SENTRY_TOKEN: "", [CONFIG_DIR_ENV_VAR]: configDir, SENTRY_URL: serverUrl, SENTRY_CLI_NO_TELEMETRY: "1", diff --git a/test/lib/api-client.test.ts b/test/lib/api-client.test.ts index 07f66ac51..f28f264af 100644 --- a/test/lib/api-client.test.ts +++ b/test/lib/api-client.test.ts @@ -108,6 +108,17 @@ describe("401 retry behavior", () => { // Note: These tests use rawApiRequest which goes to control silo (sentry.io) // and supports 401 retry with token refresh. + let savedAuthToken: string | undefined; + beforeEach(() => { + savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; + }); + afterEach(() => { + if (savedAuthToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } + }); + test("retries request with new token on 401 response", async () => { const requests: RequestLog[] = []; diff --git a/test/lib/command.test.ts b/test/lib/command.test.ts index e0883d380..7adddf218 100644 --- a/test/lib/command.test.ts +++ b/test/lib/command.test.ts @@ -75,6 +75,7 @@ function createTestContext() { describe("buildCommand", () => { test("builds a valid command object", () => { const command = buildCommand({ + auth: false, docs: { brief: "Test command" }, parameters: { flags: { @@ -90,6 +91,7 @@ describe("buildCommand", () => { test("handles commands with empty parameters", () => { const command = buildCommand({ + auth: false, docs: { brief: "Simple command" }, parameters: {}, async *func() { @@ -127,6 +129,7 @@ describe("buildCommand telemetry integration", () => { [], TestContext >({ + auth: false, docs: { brief: "Test" }, parameters: { flags: { @@ -167,6 +170,7 @@ describe("buildCommand telemetry integration", () => { test("skips false boolean flags in telemetry", async () => { const command = buildCommand<{ json: boolean }, [], TestContext>({ + auth: false, docs: { brief: "Test" }, parameters: { flags: { @@ -198,6 +202,7 @@ describe("buildCommand telemetry integration", () => { let calledArgs: unknown = null; const command = buildCommand, [string], TestContext>({ + auth: false, docs: { brief: "Test" }, parameters: { positional: { @@ -235,6 +240,7 @@ describe("buildCommand telemetry integration", () => { let capturedStdout = false; const command = buildCommand, [], TestContext>({ + auth: false, docs: { brief: "Test" }, parameters: {}, // biome-ignore lint/correctness/useYield: test command — no output to yield @@ -260,6 +266,7 @@ describe("buildCommand telemetry integration", () => { let executed = false; const command = buildCommand<{ delay: number }, [], TestContext>({ + auth: false, docs: { brief: "Test" }, parameters: { flags: { @@ -370,6 +377,7 @@ describe("applyLoggingFlags", () => { describe("buildCommand", () => { test("builds a valid command object", () => { const command = buildCommand({ + auth: false, docs: { brief: "Test command" }, parameters: { flags: { @@ -387,6 +395,7 @@ describe("buildCommand", () => { let calledFlags: Record | null = null; const command = buildCommand<{ json: boolean }, [], TestContext>({ + auth: false, docs: { brief: "Test" }, parameters: { flags: { @@ -420,6 +429,7 @@ describe("buildCommand", () => { const originalLevel = logger.level; try { const command = buildCommand, [], TestContext>({ + auth: false, docs: { brief: "Test" }, parameters: {}, async *func() { @@ -446,6 +456,7 @@ describe("buildCommand", () => { const originalLevel = logger.level; try { const command = buildCommand, [], TestContext>({ + auth: false, docs: { brief: "Test" }, parameters: {}, async *func() { @@ -472,6 +483,7 @@ describe("buildCommand", () => { const originalLevel = logger.level; try { const command = buildCommand, [], TestContext>({ + auth: false, docs: { brief: "Test" }, parameters: {}, async *func() { @@ -498,6 +510,7 @@ describe("buildCommand", () => { let receivedFlags: Record | null = null; const command = buildCommand<{ limit: number }, [], TestContext>({ + auth: false, docs: { brief: "Test" }, parameters: { flags: { @@ -546,6 +559,7 @@ describe("buildCommand", () => { [], TestContext >({ + auth: false, docs: { brief: "Test" }, parameters: { flags: { @@ -602,6 +616,7 @@ describe("buildCommand", () => { try { const command = buildCommand<{ verbose: boolean }, [], TestContext>({ + auth: false, docs: { brief: "Test" }, parameters: { flags: { @@ -673,6 +688,7 @@ describe("buildCommand output config", () => { [], TestContext >({ + auth: false, docs: { brief: "Test" }, output: { human: () => "unused" }, parameters: { @@ -716,6 +732,7 @@ describe("buildCommand output config", () => { [], TestContext >({ + auth: false, docs: { brief: "Test" }, output: { human: () => "unused" }, parameters: {}, @@ -755,6 +772,7 @@ describe("buildCommand output config", () => { [], TestContext >({ + auth: false, docs: { brief: "Test" }, output: { human: () => "unused" }, parameters: {}, @@ -793,6 +811,7 @@ describe("buildCommand output config", () => { [], TestContext >({ + auth: false, docs: { brief: "Test" }, output: { human: () => "unused" }, parameters: {}, @@ -824,6 +843,7 @@ describe("buildCommand output config", () => { // Command WITHOUT output config — --json should be rejected by Stricli const command = buildCommand, [], TestContext>({ + auth: false, docs: { brief: "Test" }, parameters: {}, // biome-ignore lint/correctness/useYield: test command — no output to yield @@ -857,6 +877,7 @@ describe("buildCommand output config", () => { [], TestContext >({ + auth: false, docs: { brief: "Test" }, output: { human: () => "unused" }, parameters: { @@ -900,6 +921,7 @@ describe("buildCommand output config", () => { [], TestContext >({ + auth: false, docs: { brief: "Test" }, output: { human: () => "unused" }, parameters: {}, @@ -941,6 +963,7 @@ describe("buildCommand output config", () => { [], TestContext >({ + auth: false, docs: { brief: "Test" }, output: { human: () => "unused" }, parameters: { @@ -995,6 +1018,7 @@ describe("buildCommand return-based output", () => { [], TestContext >({ + auth: false, docs: { brief: "Test" }, output: { human: (d: { name: string; role: string }) => `${d.name} (${d.role})`, @@ -1023,6 +1047,7 @@ describe("buildCommand return-based output", () => { [], TestContext >({ + auth: false, docs: { brief: "Test" }, output: { human: (d: { name: string; role: string }) => `${d.name} (${d.role})`, @@ -1052,6 +1077,7 @@ describe("buildCommand return-based output", () => { [], TestContext >({ + auth: false, docs: { brief: "Test" }, output: { human: (d: { id: number; name: string; role: string }) => `${d.name}`, @@ -1083,6 +1109,7 @@ describe("buildCommand return-based output", () => { test("shows hint in human mode, suppresses in JSON mode", async () => { const makeCommand = () => buildCommand<{ json: boolean; fields?: string[] }, [], TestContext>({ + auth: false, docs: { brief: "Test" }, output: { human: (d: { value: number }) => `Value: ${d.value}`, @@ -1130,6 +1157,7 @@ describe("buildCommand return-based output", () => { [], TestContext >({ + auth: false, docs: { brief: "Test" }, output: { human: () => "unused", @@ -1158,6 +1186,7 @@ describe("buildCommand return-based output", () => { test("data return is ignored without output config", async () => { const command = buildCommand, [], TestContext>({ + auth: false, docs: { brief: "Test" }, // Deliberately no output config parameters: {}, @@ -1187,6 +1216,7 @@ describe("buildCommand return-based output", () => { [], TestContext >({ + auth: false, docs: { brief: "Test" }, output: { human: (d: { name: string }) => `Hello, ${d.name}!`, @@ -1217,6 +1247,7 @@ describe("buildCommand return-based output", () => { [], TestContext >({ + auth: false, docs: { brief: "Test" }, output: { human: (d: Array<{ id: number }>) => d.map(((x) => x.id).join(", ")), @@ -1246,6 +1277,7 @@ describe("buildCommand return-based output", () => { test("hint shown in human mode only", async () => { const makeCommand = () => buildCommand<{ json: boolean; fields?: string[] }, [], TestContext>({ + auth: false, docs: { brief: "Test" }, output: { human: (d: { org: string }) => `Org: ${d.org}`, @@ -1284,6 +1316,7 @@ describe("buildCommand return-based output", () => { [], TestContext >({ + auth: false, docs: { brief: "Test" }, output: { human: (d: { error: string }) => `Error: ${d.error}`, @@ -1332,6 +1365,7 @@ describe("buildCommand return-based output", () => { [], TestContext >({ + auth: false, docs: { brief: "Test" }, output: { human: (d: { error: string }) => `Error: ${d.error}`, diff --git a/test/lib/config.test.ts b/test/lib/config.test.ts index ebd9af4ed..6c90cc262 100644 --- a/test/lib/config.test.ts +++ b/test/lib/config.test.ts @@ -4,7 +4,7 @@ * Integration tests for SQLite-based config storage. */ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { @@ -47,6 +47,17 @@ import { useTestConfigDir } from "../helpers.js"; */ const getConfigDir = useTestConfigDir("test-config-"); +let savedAuthToken: string | undefined; +beforeEach(() => { + savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; +}); +afterEach(() => { + if (savedAuthToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } +}); + describe("auth token management", () => { test("setAuthToken stores token", async () => { await setAuthToken("test-token-123"); diff --git a/test/lib/db/dsn-cache.test.ts b/test/lib/db/dsn-cache.test.ts index 35aa1d760..99912c724 100644 --- a/test/lib/db/dsn-cache.test.ts +++ b/test/lib/db/dsn-cache.test.ts @@ -5,7 +5,7 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdirSync, utimesSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { clearDsnCache, @@ -312,12 +312,15 @@ describe("getCachedDetection", () => { const before = await getCachedDetection(testProjectDir); expect(before).toBeDefined(); - // Wait a moment and modify the source file - await Bun.sleep(10); + // Modify source file and set mtime to cachedMtime + 5s to guarantee + // a detectable difference regardless of OS clock resolution. + const srcFile = join(testProjectDir, "src/app.ts"); writeFileSync( - join(testProjectDir, "src/app.ts"), + srcFile, 'const DSN = "https://changed@o123.ingest.sentry.io/789";' ); + const futureTime = new Date(sourceMtimes["src/app.ts"] + 5000); + utimesSync(srcFile, futureTime, futureTime); // Cache should be invalidated const after = await getCachedDetection(testProjectDir); @@ -373,9 +376,10 @@ describe("getCachedDetection", () => { const before = await getCachedDetection(testProjectDir); expect(before).toBeDefined(); - // Wait and add a new file to change directory mtime - await Bun.sleep(10); + // Add a new file and set root dir mtime to cachedMtime + 5s writeFileSync(join(testProjectDir, "new-file.txt"), "test"); + const futureTime = new Date(rootDirMtime + 5000); + utimesSync(testProjectDir, futureTime, futureTime); // Cache should be invalidated const after = await getCachedDetection(testProjectDir); @@ -407,12 +411,11 @@ describe("getCachedDetection", () => { const before = await getCachedDetection(testProjectDir); expect(before).toBeDefined(); - // Wait and add a new file to src/ to change its mtime - await Bun.sleep(10); - writeFileSync( - join(testProjectDir, "src/new-config.ts"), - "export default {}" - ); + // Add a new file to src/ and set mtime to cachedMtime + 5s + const srcDir = join(testProjectDir, "src"); + writeFileSync(join(srcDir, "new-config.ts"), "export default {}"); + const futureTime = new Date(srcDirMtime + 5000); + utimesSync(srcDir, futureTime, futureTime); // Cache should be invalidated because src/ mtime changed const after = await getCachedDetection(testProjectDir); diff --git a/test/lib/db/model-based.test.ts b/test/lib/db/model-based.test.ts index 6456b472d..ead199e01 100644 --- a/test/lib/db/model-based.test.ts +++ b/test/lib/db/model-based.test.ts @@ -898,6 +898,8 @@ describe("model-based: database layer", () => { fcAssert( property(tokenArb, (token) => { const cleanup = createIsolatedDbContext(); + const savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; try { // Set token that expires immediately (negative expiresIn) setAuthToken(token, -1); @@ -910,6 +912,9 @@ describe("model-based: database layer", () => { const config = getAuthConfig(); expect(config?.token).toBe(token); } finally { + if (savedAuthToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } cleanup(); } }), diff --git a/test/lib/db/project-root-cache.test.ts b/test/lib/db/project-root-cache.test.ts index 2100fd0e8..dd2b3a47c 100644 --- a/test/lib/db/project-root-cache.test.ts +++ b/test/lib/db/project-root-cache.test.ts @@ -5,7 +5,7 @@ */ import { beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdirSync, statSync, utimesSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { clearProjectRootCache, @@ -54,9 +54,11 @@ describe("getCachedProjectRoot", () => { const before = await getCachedProjectRoot(testProjectDir); expect(before).toBeDefined(); - // Wait a moment and add a new file to change directory mtime - await Bun.sleep(10); + // Add a new file and set dir mtime to cachedMtime + 5s writeFileSync(join(testProjectDir, "new-file.txt"), "test"); + const cachedMtime = statSync(testProjectDir).mtimeMs; + const futureTime = new Date(cachedMtime + 5000); + utimesSync(testProjectDir, futureTime, futureTime); // Cache should be invalidated const after = await getCachedProjectRoot(testProjectDir); diff --git a/test/lib/init/local-ops.create-sentry-project.test.ts b/test/lib/init/local-ops.create-sentry-project.test.ts index deb034578..3cb8bf1be 100644 --- a/test/lib/init/local-ops.create-sentry-project.test.ts +++ b/test/lib/init/local-ops.create-sentry-project.test.ts @@ -16,7 +16,15 @@ import * as projectCache from "../../../src/lib/db/project-cache.js"; import * as dbRegions from "../../../src/lib/db/regions.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as dsnIndex from "../../../src/lib/dsn/index.js"; -import { handleLocalOp } from "../../../src/lib/init/local-ops.js"; +import { ApiError } from "../../../src/lib/errors.js"; +import { WizardCancelledError } from "../../../src/lib/init/clack-utils.js"; +import { + detectExistingProject, + handleLocalOp, + resolveOrgSlug, +} from "../../../src/lib/init/local-ops.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as prefetch from "../../../src/lib/init/prefetch.js"; import type { CreateSentryProjectPayload, WizardOptions, @@ -34,6 +42,7 @@ function makeOptions(overrides?: Partial): WizardOptions { directory: "/tmp/test", yes: false, dryRun: false, + org: "acme-corp", ...overrides, }; } @@ -61,6 +70,17 @@ const sampleProject: SentryProject = { dateCreated: "2026-03-04T00:00:00Z", }; +let savedAuthToken: string | undefined; +beforeEach(() => { + savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; +}); +afterEach(() => { + if (savedAuthToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } +}); + describe("create-sentry-project", () => { let resolveOrgSpy: ReturnType; let listOrgsSpy: ReturnType; @@ -76,9 +96,13 @@ describe("create-sentry-project", () => { let setCachedProjectByDsnKeySpy: ReturnType; let findProjectByDsnKeySpy: ReturnType; let getProjectSpy: ReturnType; + let resolveOrgPrefetchedSpy: ReturnType; + let resolveDsnByPublicKeySpy: ReturnType; beforeEach(() => { resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); + resolveOrgPrefetchedSpy = spyOn(prefetch, "resolveOrgPrefetched"); + resolveDsnByPublicKeySpy = spyOn(resolveTarget, "resolveDsnByPublicKey"); listOrgsSpy = spyOn(apiClient, "listOrganizations"); resolveOrCreateTeamSpy = spyOn(resolveTeam, "resolveOrCreateTeam"); createProjectSpy = spyOn(apiClient, "createProject"); @@ -124,6 +148,8 @@ describe("create-sentry-project", () => { setCachedProjectByDsnKeySpy.mockRestore(); findProjectByDsnKeySpy.mockRestore(); getProjectSpy.mockRestore(); + resolveOrgPrefetchedSpy.mockRestore(); + resolveDsnByPublicKeySpy.mockRestore(); }); function mockDownstreamSuccess(orgSlug: string) { @@ -139,7 +165,6 @@ describe("create-sentry-project", () => { } test("success path returns project details", async () => { - resolveOrgSpy.mockResolvedValue({ org: "acme-corp" }); mockDownstreamSuccess("acme-corp"); const result = await handleLocalOp(makePayload(), makeOptions()); @@ -167,85 +192,77 @@ describe("create-sentry-project", () => { }); }); - test("single org fallback when resolveOrg returns null", async () => { - resolveOrgSpy.mockResolvedValue(null); - listOrgsSpy.mockResolvedValue([ - { id: "1", slug: "solo-org", name: "Solo Org" }, - ]); - mockDownstreamSuccess("solo-org"); - - const result = await handleLocalOp(makePayload(), makeOptions()); + describe("resolveOrgSlug (called directly)", () => { + test("single org fallback when resolveOrg returns null", async () => { + resolveOrgPrefetchedSpy.mockResolvedValue(null); + listOrgsSpy.mockResolvedValue([ + { id: "1", slug: "solo-org", name: "Solo Org" }, + ]); - expect(result.ok).toBe(true); - const data = result.data as { orgSlug: string }; - expect(data.orgSlug).toBe("solo-org"); - expect(selectSpy).not.toHaveBeenCalled(); - }); + const result = await resolveOrgSlug("/tmp/test", false); - test("no orgs (not authenticated) returns ok:false", async () => { - resolveOrgSpy.mockResolvedValue(null); - listOrgsSpy.mockResolvedValue([]); + expect(result).toBe("solo-org"); + expect(selectSpy).not.toHaveBeenCalled(); + }); - const result = await handleLocalOp(makePayload(), makeOptions()); + test("no orgs (not authenticated) returns error result", async () => { + resolveOrgPrefetchedSpy.mockResolvedValue(null); + listOrgsSpy.mockResolvedValue([]); - expect(result.ok).toBe(false); - expect(result.error).toContain("Not authenticated"); - expect(createProjectSpy).not.toHaveBeenCalled(); - }); + const result = await resolveOrgSlug("/tmp/test", false); - test("multiple orgs + --yes flag returns ok:false with slug list", async () => { - resolveOrgSpy.mockResolvedValue(null); - listOrgsSpy.mockResolvedValue([ - { id: "1", slug: "org-a", name: "Org A" }, - { id: "2", slug: "org-b", name: "Org B" }, - ]); + expect(typeof result).toBe("object"); + const err = result as { ok: boolean; error: string }; + expect(err.ok).toBe(false); + expect(err.error).toContain("Not authenticated"); + }); - const result = await handleLocalOp( - makePayload(), - makeOptions({ yes: true }) - ); + test("multiple orgs + yes flag returns error with slug list", async () => { + resolveOrgPrefetchedSpy.mockResolvedValue(null); + listOrgsSpy.mockResolvedValue([ + { id: "1", slug: "org-a", name: "Org A" }, + { id: "2", slug: "org-b", name: "Org B" }, + ]); - expect(result.ok).toBe(false); - expect(result.error).toContain("Multiple organizations found"); - expect(result.error).toContain("org-a"); - expect(result.error).toContain("org-b"); - expect(createProjectSpy).not.toHaveBeenCalled(); - }); + const result = await resolveOrgSlug("/tmp/test", true); - test("multiple orgs + interactive select picks chosen org", async () => { - resolveOrgSpy.mockResolvedValue(null); - listOrgsSpy.mockResolvedValue([ - { id: "1", slug: "org-a", name: "Org A" }, - { id: "2", slug: "org-b", name: "Org B" }, - ]); - selectSpy.mockResolvedValue("org-b"); - mockDownstreamSuccess("org-b"); + expect(typeof result).toBe("object"); + const err = result as { ok: boolean; error: string }; + expect(err.ok).toBe(false); + expect(err.error).toContain("Multiple organizations found"); + expect(err.error).toContain("org-a"); + expect(err.error).toContain("org-b"); + }); - const result = await handleLocalOp(makePayload(), makeOptions()); + test("multiple orgs + interactive select picks chosen org", async () => { + resolveOrgPrefetchedSpy.mockResolvedValue(null); + listOrgsSpy.mockResolvedValue([ + { id: "1", slug: "org-a", name: "Org A" }, + { id: "2", slug: "org-b", name: "Org B" }, + ]); + selectSpy.mockResolvedValue("org-b"); - expect(result.ok).toBe(true); - const data = result.data as { orgSlug: string }; - expect(data.orgSlug).toBe("org-b"); - expect(selectSpy).toHaveBeenCalledTimes(1); - }); + const result = await resolveOrgSlug("/tmp/test", false); - test("multiple orgs + user cancels select returns ok:false", async () => { - resolveOrgSpy.mockResolvedValue(null); - listOrgsSpy.mockResolvedValue([ - { id: "1", slug: "org-a", name: "Org A" }, - { id: "2", slug: "org-b", name: "Org B" }, - ]); - selectSpy.mockResolvedValue(Symbol.for("cancel")); + expect(result).toBe("org-b"); + expect(selectSpy).toHaveBeenCalledTimes(1); + }); - const result = await handleLocalOp(makePayload(), makeOptions()); + test("multiple orgs + user cancels select throws WizardCancelledError", async () => { + resolveOrgPrefetchedSpy.mockResolvedValue(null); + listOrgsSpy.mockResolvedValue([ + { id: "1", slug: "org-a", name: "Org A" }, + { id: "2", slug: "org-b", name: "Org B" }, + ]); + selectSpy.mockResolvedValue(Symbol.for("cancel")); - expect(result.ok).toBe(false); - expect(result.error).toContain("cancelled"); - expect(createProjectSpy).not.toHaveBeenCalled(); + await expect(resolveOrgSlug("/tmp/test", false)).rejects.toThrow( + WizardCancelledError + ); + }); }); test("API error (e.g. 409 conflict) returns ok:false", async () => { - resolveOrgSpy.mockResolvedValue({ org: "acme-corp" }); resolveOrCreateTeamSpy.mockResolvedValue({ slug: "engineering", source: "auto-selected", @@ -261,7 +278,6 @@ describe("create-sentry-project", () => { }); test("DSN unavailable still returns ok:true with empty dsn", async () => { - resolveOrgSpy.mockResolvedValue({ org: "acme-corp" }); resolveOrCreateTeamSpy.mockResolvedValue({ slug: "engineering", source: "auto-selected", @@ -280,58 +296,58 @@ describe("create-sentry-project", () => { }); describe("resolveOrgSlug — numeric org ID from DSN", () => { - test("numeric ID + cache hit → resolved to slug for project creation", async () => { - resolveOrgSpy.mockResolvedValue({ org: "4507492088676352" }); + test("numeric ID + cache hit → resolved to slug", async () => { + resolveOrgPrefetchedSpy.mockResolvedValue({ org: "4507492088676352" }); getOrgByNumericIdSpy.mockReturnValue({ slug: "acme-corp", regionUrl: "https://us.sentry.io", }); - mockDownstreamSuccess("acme-corp"); - const result = await handleLocalOp(makePayload(), makeOptions()); + const result = await resolveOrgSlug("/tmp/test", false); - expect(result.ok).toBe(true); - const data = result.data as { orgSlug: string }; - expect(data.orgSlug).toBe("acme-corp"); + expect(result).toBe("acme-corp"); expect(getOrgByNumericIdSpy).toHaveBeenCalledWith("4507492088676352"); }); test("numeric ID + cache miss → falls through to single org in listOrganizations", async () => { - resolveOrgSpy.mockResolvedValue({ org: "4507492088676352" }); + resolveOrgPrefetchedSpy.mockResolvedValue({ org: "4507492088676352" }); getOrgByNumericIdSpy.mockReturnValue(undefined); listOrgsSpy.mockResolvedValue([ { id: "1", slug: "solo-org", name: "Solo Org" }, ]); - mockDownstreamSuccess("solo-org"); - const result = await handleLocalOp(makePayload(), makeOptions()); + const result = await resolveOrgSlug("/tmp/test", false); - expect(result.ok).toBe(true); - const data = result.data as { orgSlug: string }; - expect(data.orgSlug).toBe("solo-org"); + expect(result).toBe("solo-org"); }); test("numeric ID + cache miss + multiple orgs + --yes → error with org list", async () => { - resolveOrgSpy.mockResolvedValue({ org: "4507492088676352" }); + resolveOrgPrefetchedSpy.mockResolvedValue({ org: "4507492088676352" }); getOrgByNumericIdSpy.mockReturnValue(undefined); listOrgsSpy.mockResolvedValue([ { id: "1", slug: "org-a", name: "Org A" }, { id: "2", slug: "org-b", name: "Org B" }, ]); - const result = await handleLocalOp( - makePayload(), - makeOptions({ yes: true }) - ); + const result = await resolveOrgSlug("/tmp/test", true); - expect(result.ok).toBe(false); - expect(result.error).toContain("Multiple organizations found"); - expect(createProjectSpy).not.toHaveBeenCalled(); + expect(typeof result).toBe("object"); + const err = result as { ok: boolean; error: string }; + expect(err.ok).toBe(false); + expect(err.error).toContain("Multiple organizations found"); }); }); - describe("detectExistingProject — existing DSN prompt", () => { - function mockExistingProject(orgSlug: string, projectSlug: string) { + describe("detectExistingProject (called directly)", () => { + test("no DSN found → returns null", async () => { + detectDsnSpy.mockResolvedValue(null); + + const result = await detectExistingProject("/tmp/test"); + + expect(result).toBeNull(); + }); + + test("DSN found + resolved via resolveDsnByPublicKey → returns org and project", async () => { detectDsnSpy.mockResolvedValue({ publicKey: "test-key-abc", protocol: "https", @@ -340,103 +356,69 @@ describe("create-sentry-project", () => { raw: "https://test-key-abc@o123.ingest.sentry.io/42", source: "env_file" as const, }); - getCachedProjectByDsnKeySpy.mockReturnValue({ - orgSlug, - orgName: orgSlug, - projectSlug, - projectName: projectSlug, - projectId: "42", - cachedAt: Date.now(), + resolveDsnByPublicKeySpy.mockResolvedValue({ + org: "acme-corp", + project: "my-app", }); - getProjectSpy.mockResolvedValue({ ...sampleProject, slug: projectSlug }); - tryGetPrimaryDsnSpy.mockResolvedValue( - "https://abc@o1.ingest.sentry.io/42" - ); - buildProjectUrlSpy.mockReturnValue( - `https://sentry.io/settings/${orgSlug}/projects/${projectSlug}/` - ); - } - - test("no DSN found → no prompt, proceeds with normal creation", async () => { - detectDsnSpy.mockResolvedValue(null); - resolveOrgSpy.mockResolvedValue({ org: "acme-corp" }); - mockDownstreamSuccess("acme-corp"); - - const result = await handleLocalOp(makePayload(), makeOptions()); - - expect(result.ok).toBe(true); - expect(selectSpy).not.toHaveBeenCalled(); - expect(createProjectSpy).toHaveBeenCalledTimes(1); - }); - - test("DSN found + --yes flag → auto-uses existing project without prompt", async () => { - mockExistingProject("acme-corp", "my-app"); - - const result = await handleLocalOp( - makePayload(), - makeOptions({ yes: true }) - ); - - expect(result.ok).toBe(true); - const data = result.data as { orgSlug: string; projectSlug: string }; - expect(data.orgSlug).toBe("acme-corp"); - expect(data.projectSlug).toBe("my-app"); - expect(selectSpy).not.toHaveBeenCalled(); - expect(createProjectSpy).not.toHaveBeenCalled(); - }); - - test("DSN found + pick 'existing' → returns existing project details", async () => { - mockExistingProject("acme-corp", "my-app"); - selectSpy.mockResolvedValue("existing"); - const result = await handleLocalOp(makePayload(), makeOptions()); + const result = await detectExistingProject("/tmp/test"); - expect(result.ok).toBe(true); - const data = result.data as { orgSlug: string; projectSlug: string }; - expect(data.orgSlug).toBe("acme-corp"); - expect(data.projectSlug).toBe("my-app"); - expect(createProjectSpy).not.toHaveBeenCalled(); + expect(result).toEqual({ + orgSlug: "acme-corp", + projectSlug: "my-app", + }); }); - test("DSN found + pick 'create' → proceeds with normal project creation", async () => { - mockExistingProject("acme-corp", "my-app"); - selectSpy.mockResolvedValue("create"); - resolveOrgSpy.mockResolvedValue({ org: "acme-corp" }); - mockDownstreamSuccess("acme-corp"); + test("DSN found + resolveDsnByPublicKey returns null → returns null", async () => { + detectDsnSpy.mockResolvedValue({ + publicKey: "test-key-abc", + protocol: "https", + host: "o123.ingest.sentry.io", + projectId: "42", + raw: "https://test-key-abc@o123.ingest.sentry.io/42", + source: "env_file" as const, + }); + resolveDsnByPublicKeySpy.mockResolvedValue(null); - const result = await handleLocalOp(makePayload(), makeOptions()); + const result = await detectExistingProject("/tmp/test"); - expect(result.ok).toBe(true); - expect(createProjectSpy).toHaveBeenCalledTimes(1); + expect(result).toBeNull(); }); - test("DSN found + cancel select → ok:false with cancelled error", async () => { - mockExistingProject("acme-corp", "my-app"); - selectSpy.mockResolvedValue(Symbol.for("cancel")); + test("DSN found + API throws (inaccessible org) → returns null", async () => { + detectDsnSpy.mockResolvedValue({ + publicKey: "test-key-abc", + protocol: "https", + host: "o999.ingest.sentry.io", + projectId: "99", + raw: "https://test-key-abc@o999.ingest.sentry.io/99", + source: "env_file" as const, + }); + resolveDsnByPublicKeySpy.mockRejectedValue(new Error("403 Forbidden")); - const result = await handleLocalOp(makePayload(), makeOptions()); + const result = await detectExistingProject("/tmp/test"); - expect(result.ok).toBe(false); - expect(result.error).toContain("Cancelled"); - expect(createProjectSpy).not.toHaveBeenCalled(); + expect(result).toBeNull(); }); - test("DSN found + API lookup (cache miss) → caches project and prompts user", async () => { + test("DSN without publicKey → returns null", async () => { detectDsnSpy.mockResolvedValue({ - publicKey: "test-key-abc", + publicKey: "", protocol: "https", host: "o123.ingest.sentry.io", projectId: "42", - raw: "https://test-key-abc@o123.ingest.sentry.io/42", + raw: "https://@o123.ingest.sentry.io/42", source: "env_file" as const, }); - getCachedProjectByDsnKeySpy.mockReturnValue(undefined); // cache miss - findProjectByDsnKeySpy.mockResolvedValue({ - ...sampleProject, - organization: { id: "1", slug: "acme-corp", name: "Acme Corp" }, - }); - setCachedProjectByDsnKeySpy.mockReturnValue(undefined); - selectSpy.mockResolvedValue("existing"); + + const result = await detectExistingProject("/tmp/test"); + + expect(result).toBeNull(); + }); + }); + + describe("createSentryProject with org+project set — existing project check", () => { + test("existing project found → returns it without creating", async () => { getProjectSpy.mockResolvedValue(sampleProject); tryGetPrimaryDsnSpy.mockResolvedValue( "https://abc@o1.ingest.sentry.io/42" @@ -445,31 +427,28 @@ describe("create-sentry-project", () => { "https://sentry.io/settings/acme-corp/projects/my-app/" ); - const result = await handleLocalOp(makePayload(), makeOptions()); + const result = await handleLocalOp( + makePayload(), + makeOptions({ org: "acme-corp", project: "my-app" }) + ); expect(result.ok).toBe(true); - expect(setCachedProjectByDsnKeySpy).toHaveBeenCalledTimes(1); + const data = result.data as { orgSlug: string; projectSlug: string }; + expect(data.orgSlug).toBe("acme-corp"); + expect(data.projectSlug).toBe("my-app"); expect(createProjectSpy).not.toHaveBeenCalled(); }); - test("DSN found + API throws (inaccessible org) → no prompt, normal creation", async () => { - detectDsnSpy.mockResolvedValue({ - publicKey: "test-key-abc", - protocol: "https", - host: "o999.ingest.sentry.io", - projectId: "99", - raw: "https://test-key-abc@o999.ingest.sentry.io/99", - source: "env_file" as const, - }); - getCachedProjectByDsnKeySpy.mockReturnValue(undefined); - findProjectByDsnKeySpy.mockRejectedValue(new Error("403 Forbidden")); - resolveOrgSpy.mockResolvedValue({ org: "acme-corp" }); + test("no existing project → creates new one", async () => { + getProjectSpy.mockRejectedValue(new ApiError("Not Found", 404)); mockDownstreamSuccess("acme-corp"); - const result = await handleLocalOp(makePayload(), makeOptions()); + const result = await handleLocalOp( + makePayload(), + makeOptions({ org: "acme-corp", project: "my-app" }) + ); expect(result.ok).toBe(true); - expect(selectSpy).not.toHaveBeenCalled(); expect(createProjectSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/test/lib/init/wizard-runner.test.ts b/test/lib/init/wizard-runner.test.ts index 24deee61c..5bcd198c9 100644 --- a/test/lib/init/wizard-runner.test.ts +++ b/test/lib/init/wizard-runner.test.ts @@ -48,6 +48,7 @@ function makeOptions(overrides?: Partial): WizardOptions { directory: "/tmp/test", yes: true, dryRun: false, + org: "test-org", ...overrides, }; } @@ -69,6 +70,8 @@ let checkGitStatusSpy: ReturnType; // deps let getAuthTokenSpy: ReturnType; +let getAuthConfigSpy: ReturnType; +let isAuthenticatedSpy: ReturnType; let formatBannerSpy: ReturnType; let formatResultSpy: ReturnType; let formatErrorSpy: ReturnType; @@ -123,6 +126,17 @@ function setupWorkflowSpy() { // ── Setup / Teardown ──────────────────────────────────────────────────────── +let savedAuthToken: string | undefined; +beforeEach(() => { + savedAuthToken = process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; +}); +afterEach(() => { + if (savedAuthToken !== undefined) { + process.env.SENTRY_AUTH_TOKEN = savedAuthToken; + } +}); + beforeEach(() => { mockStartResult = { status: "success" }; mockResumeResults = []; @@ -151,6 +165,11 @@ beforeEach(() => { // dep spies getAuthTokenSpy = spyOn(auth, "getAuthToken").mockReturnValue("fake-token"); + getAuthConfigSpy = spyOn(auth, "getAuthConfig").mockReturnValue({ + token: "fake-token", + source: "oauth" as const, + }); + isAuthenticatedSpy = spyOn(auth, "isAuthenticated").mockReturnValue(true); formatBannerSpy = spyOn(banner, "formatBanner").mockReturnValue("BANNER"); formatResultSpy = spyOn(fmt, "formatResult").mockImplementation(noop); formatErrorSpy = spyOn(fmt, "formatError").mockImplementation(noop); @@ -186,6 +205,8 @@ afterEach(() => { checkGitStatusSpy.mockRestore(); getAuthTokenSpy.mockRestore(); + getAuthConfigSpy.mockRestore(); + isAuthenticatedSpy.mockRestore(); formatBannerSpy.mockRestore(); formatResultSpy.mockRestore(); formatErrorSpy.mockRestore(); diff --git a/test/lib/resolve-target.test.ts b/test/lib/resolve-target.test.ts index 809853130..4facdcba0 100644 --- a/test/lib/resolve-target.test.ts +++ b/test/lib/resolve-target.test.ts @@ -438,11 +438,19 @@ describe("fetchProjectId", () => { test("rethrows AuthError when not authenticated", async () => { // No auth token set — refreshToken() will throw AuthError + const saved = process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_AUTH_TOKEN; setOrgRegion("test-org", DEFAULT_SENTRY_URL); - expect(fetchProjectId("test-org", "test-project")).rejects.toThrow( - AuthError - ); + try { + await expect(fetchProjectId("test-org", "test-project")).rejects.toThrow( + AuthError + ); + } finally { + if (saved !== undefined) { + process.env.SENTRY_AUTH_TOKEN = saved; + } + } }); test("returns undefined on transient server error", async () => { diff --git a/test/lib/telemetry.test.ts b/test/lib/telemetry.test.ts index da7442fa3..46f36c3c9 100644 --- a/test/lib/telemetry.test.ts +++ b/test/lib/telemetry.test.ts @@ -5,7 +5,15 @@ */ import { Database } from "bun:sqlite"; -import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + spyOn, + test, +} from "bun:test"; import { chmodSync, mkdirSync, rmSync } from "node:fs"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as Sentry from "@sentry/node-core/light"; @@ -30,6 +38,23 @@ import { withTracingSpan, } from "../../src/lib/telemetry.js"; +// Snapshot beforeExit listeners before any test calls initSentry(true). +// The ProcessSession integration registers an anonymous handler via setupOnce +// that has no cleanup mechanism. After all tests, we remove any listeners +// that weren't present before to prevent the Bun test runner from hanging. +const preTestListeners = new Set(process.rawListeners("beforeExit")); + +afterAll(() => { + for (const listener of process.rawListeners("beforeExit")) { + if (!preTestListeners.has(listener)) { + process.removeListener( + "beforeExit", + listener as (...args: unknown[]) => void + ); + } + } +}); + describe("initSentry", () => { test("returns client with enabled=false when disabled", () => { const client = initSentry(false); diff --git a/test/preload.ts b/test/preload.ts index e9aaa9dca..7d77e7d86 100644 --- a/test/preload.ts +++ b/test/preload.ts @@ -93,6 +93,12 @@ delete process.env.SENTRY_HOST; delete process.env.SENTRY_ORG; delete process.env.SENTRY_PROJECT; +// Set a fake auth token so buildCommand's auth guard passes in tests. +// Real API calls are blocked by the global fetch mock below. +// Tests that specifically verify unauthenticated behavior (e.g., auth status) +// mock getAuthConfig to return undefined. +process.env.SENTRY_AUTH_TOKEN = "sntrys_test-token-for-unit-tests_000000"; + // Disable telemetry and background update checks in tests // This prevents Sentry SDK from keeping the process alive and making external calls process.env.SENTRY_CLI_NO_TELEMETRY = "1";