Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 42 additions & 79 deletions AGENTS.md

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions test/commands/auth/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ describe("loginCommand.func --token path", () => {
let setUserInfoSpy: ReturnType<typeof spyOn>;
let runInteractiveLoginSpy: ReturnType<typeof spyOn>;
let hasStoredAuthCredentialsSpy: ReturnType<typeof spyOn>;
let listOrgsUncachedSpy: ReturnType<typeof spyOn>;
let func: LoginFunc;

beforeEach(async () => {
Expand All @@ -99,6 +100,11 @@ describe("loginCommand.func --token path", () => {
setUserInfoSpy = spyOn(dbUser, "setUserInfo");
runInteractiveLoginSpy = spyOn(interactiveLogin, "runInteractiveLogin");
hasStoredAuthCredentialsSpy = spyOn(dbAuth, "hasStoredAuthCredentials");
// Prevent warmOrgCache() fire-and-forget from hitting real fetch.
// After successful login, warmOrgCache() calls listOrganizationsUncached()
// which triggers API calls that leak as "unexpected fetch" warnings.
listOrgsUncachedSpy = spyOn(apiClient, "listOrganizationsUncached");
listOrgsUncachedSpy.mockResolvedValue([]);
isEnvTokenActiveSpy.mockReturnValue(false);
hasStoredAuthCredentialsSpy.mockReturnValue(false);
func = (await loginCommand.loader()) as unknown as LoginFunc;
Expand All @@ -114,6 +120,7 @@ describe("loginCommand.func --token path", () => {
setUserInfoSpy.mockRestore();
runInteractiveLoginSpy.mockRestore();
hasStoredAuthCredentialsSpy.mockRestore();
listOrgsUncachedSpy.mockRestore();
});

test("already authenticated (non-TTY, no --force): prints re-auth message with --force hint", async () => {
Expand Down
8 changes: 7 additions & 1 deletion test/commands/issue/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { setAuthToken } from "../../../src/lib/db/auth.js";
import { setCachedProject } from "../../../src/lib/db/project-cache.js";
import { setOrgRegion } from "../../../src/lib/db/regions.js";
import { ApiError, ResolutionError } from "../../../src/lib/errors.js";
import { useTestConfigDir } from "../../helpers.js";
import { mockFetch, useTestConfigDir } from "../../helpers.js";

describe("buildCommandHint", () => {
test("suggests <org>/ID for numeric IDs", () => {
Expand Down Expand Up @@ -81,6 +81,12 @@ let originalFetch: typeof globalThis.fetch;

beforeEach(async () => {
originalFetch = globalThis.fetch;
// Default to a silent 404 so tests that don't set a custom fetch mock
// won't produce "unexpected fetch" warnings from the preload trap.
globalThis.fetch = mockFetch(
async () =>
new Response(JSON.stringify({ detail: "Not found" }), { status: 404 })
);
await setAuthToken("test-token");
// Pre-populate region cache for orgs used in tests to avoid region resolution API calls
setOrgRegion("test-org", DEFAULT_SENTRY_URL);
Expand Down
12 changes: 12 additions & 0 deletions test/commands/project/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
import { createCommand } from "../../../src/commands/project/create.js";
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
import * as apiClient from "../../../src/lib/api-client.js";
import { DEFAULT_SENTRY_URL } from "../../../src/lib/constants.js";
import { setOrgRegion } from "../../../src/lib/db/regions.js";
import {
ApiError,
CliError,
Expand All @@ -27,6 +29,7 @@ import {
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
import * as resolveTarget from "../../../src/lib/resolve-target.js";
import type { SentryProject, SentryTeam } from "../../../src/types/index.js";
import { useTestConfigDir } from "../../helpers.js";

const sampleTeam: SentryTeam = {
id: "1",
Expand All @@ -52,6 +55,10 @@ const sampleProject: SentryProject = {
dateCreated: "2026-02-12T10:00:00Z",
};

// Isolated DB for region cache — prevents "unexpected fetch" warnings
// from resolveOrgRegion when buildOrgNotFoundError calls resolveEffectiveOrg
useTestConfigDir("test-project-create-");

function createMockContext() {
const stdoutWrite = mock(() => true);
return {
Expand All @@ -73,6 +80,11 @@ describe("project create", () => {
let resolveOrgSpy: ReturnType<typeof spyOn>;

beforeEach(() => {
// Pre-populate region cache for orgs used in tests to avoid
// "unexpected fetch" warnings from resolveOrgRegion
setOrgRegion("acme-corp", DEFAULT_SENTRY_URL);
setOrgRegion("123", DEFAULT_SENTRY_URL);

listTeamsSpy = spyOn(apiClient, "listTeams");
createProjectSpy = spyOn(apiClient, "createProject");
createTeamSpy = spyOn(apiClient, "createTeam");
Expand Down
12 changes: 12 additions & 0 deletions test/commands/span/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
} from "../../../src/commands/span/view.js";
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
import * as apiClient from "../../../src/lib/api-client.js";
import { DEFAULT_SENTRY_URL } from "../../../src/lib/constants.js";
import { setOrgRegion } from "../../../src/lib/db/regions.js";
import { ContextError, ValidationError } from "../../../src/lib/errors.js";
import { validateSpanId } from "../../../src/lib/hex-id.js";
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
Expand Down Expand Up @@ -280,6 +282,7 @@ function makeTraceSpan(spanId: string, children: unknown[] = []): unknown {
describe("viewCommand.func", () => {
let func: ViewFunc;
let getDetailedTraceSpy: ReturnType<typeof spyOn>;
let getSpanDetailsSpy: ReturnType<typeof spyOn>;
let resolveOrgAndProjectSpy: ReturnType<typeof spyOn>;

function createContext() {
Expand All @@ -305,15 +308,24 @@ describe("viewCommand.func", () => {
beforeEach(async () => {
func = (await viewCommand.loader()) as unknown as ViewFunc;
getDetailedTraceSpy = spyOn(apiClient, "getDetailedTrace");
getSpanDetailsSpy = spyOn(apiClient, "getSpanDetails").mockResolvedValue({
itemId: "mock-span",
itemType: "span",
attributes: [],
});
resolveOrgAndProjectSpy = spyOn(resolveTarget, "resolveOrgAndProject");
resolveOrgAndProjectSpy.mockResolvedValue({
org: "test-org",
project: "test-project",
});
// Pre-populate org region cache to prevent resolveOrgRegion from fetching
setOrgRegion("test-org", DEFAULT_SENTRY_URL);
setOrgRegion("my-org", DEFAULT_SENTRY_URL);
});

afterEach(() => {
getDetailedTraceSpy.mockRestore();
getSpanDetailsSpy.mockRestore();
resolveOrgAndProjectSpy.mockRestore();
});

Expand Down
7 changes: 7 additions & 0 deletions test/isolated/login-reauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ describe("login re-authentication interactive prompt", () => {
let clearAuthSpy: ReturnType<typeof spyOn>;
let runInteractiveLoginSpy: ReturnType<typeof spyOn>;
let getUserInfoSpy: ReturnType<typeof spyOn>;
let listOrgsUncachedSpy: ReturnType<typeof spyOn>;
let func: LoginFunc;

beforeEach(async () => {
Expand All @@ -122,6 +123,11 @@ describe("login re-authentication interactive prompt", () => {
clearAuthSpy = spyOn(dbAuth, "clearAuth");
runInteractiveLoginSpy = spyOn(interactiveLogin, "runInteractiveLogin");
getUserInfoSpy = spyOn(dbUser, "getUserInfo");
// Prevent warmOrgCache() fire-and-forget from hitting real fetch.
// After successful login, warmOrgCache() calls listOrganizationsUncached()
// which triggers API calls that leak as "unexpected fetch" warnings.
listOrgsUncachedSpy = spyOn(apiClient, "listOrganizationsUncached");
listOrgsUncachedSpy.mockResolvedValue([]);

// Defaults
isEnvTokenActiveSpy.mockReturnValue(false);
Expand All @@ -139,6 +145,7 @@ describe("login re-authentication interactive prompt", () => {
clearAuthSpy.mockRestore();
runInteractiveLoginSpy.mockRestore();
getUserInfoSpy.mockRestore();
listOrgsUncachedSpy.mockRestore();
});

test("shows prompt with user identity when authenticated on TTY", async () => {
Expand Down
23 changes: 21 additions & 2 deletions test/lib/help-positional.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,33 @@
* and verify help output is shown when resolution fails.
*/

import { describe, expect, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { run } from "@stricli/core";
import { app } from "../../src/app.js";
import type { SentryContext } from "../../src/context.js";
import { useTestConfigDir } from "../helpers.js";
import { mockFetch, useTestConfigDir } from "../helpers.js";

useTestConfigDir("help-positional-");

// Silence unmocked fetch calls from the resolution cascade.
// Commands run through run(app, args) with "help" as a positional arg
// trigger real resolution (e.g., findProjectsBySlug("help") → listOrganizations)
// before the help-recovery error handler fires. A silent 404 prevents
// preload warnings while preserving the error → recovery behavior.
let originalFetch: typeof globalThis.fetch;

beforeEach(() => {
originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch(
async () =>
new Response(JSON.stringify({ detail: "Not found" }), { status: 404 })
);
});

afterEach(() => {
globalThis.fetch = originalFetch;
});

/** Captured output from a command run */
type CapturedOutput = {
stdout: string;
Expand Down
45 changes: 42 additions & 3 deletions test/lib/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,44 @@
import { describe, expect, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import createSentrySDK, { SentryError } from "../../src/index.js";
import { mockFetch } from "../helpers.js";

describe("createSentrySDK() library API", () => {
// Silence unmocked fetch calls from resolution cascade.
// SDK tests that call commands like "issue list" or "org list" trigger
// the org/project resolution cascade which hits real API endpoints.
// A silent 404 prevents preload warnings while preserving error behavior.
let originalFetch: typeof globalThis.fetch;

beforeEach(() => {
originalFetch = globalThis.fetch;
// Return empty successes rather than 404s so the resolution cascade
// terminates cleanly without triggering follow-up requests that could
// outlive the test and spill into later test files.
globalThis.fetch = mockFetch(async (input) => {
let url: string;
if (typeof input === "string") {
url = input;
} else if (input instanceof URL) {
url = input.href;
} else {
url = new Request(input).url;
}
if (url.includes("/regions/")) {
return new Response(JSON.stringify({ regions: [] }), { status: 200 });
}
if (url.includes("/organizations/")) {
return new Response(JSON.stringify([]), { status: 200 });
}
// Return empty 200 for all other endpoints (projects, issues, etc.)
// to prevent follow-up requests from outliving the test.
return new Response(JSON.stringify({}), { status: 200 });
});
});

afterEach(() => {
globalThis.fetch = originalFetch;
});

test("sdk.run returns version string for --version", async () => {
const sdk = createSentrySDK();
const result = await sdk.run("--version");
Expand All @@ -19,7 +56,9 @@ describe("createSentrySDK() library API", () => {
});

test("sdk.run throws when auth is required but missing", async () => {
const sdk = createSentrySDK();
// Use cwd:/tmp to prevent DSN scanning of the repo root which finds
// real DSNs and triggers async project resolution that can outlive the test.
const sdk = createSentrySDK({ cwd: "/tmp" });
try {
// issue list requires auth — with no token and isolated config, it should fail
await sdk.run("issue", "list");
Expand All @@ -43,7 +82,7 @@ describe("createSentrySDK() library API", () => {
});

test("process.env is unchanged after failed call", async () => {
const sdk = createSentrySDK();
const sdk = createSentrySDK({ cwd: "/tmp" });
const envBefore = { ...process.env };
try {
await sdk.run("issue", "list");
Expand Down
10 changes: 10 additions & 0 deletions test/lib/resolve-effective-org.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,16 @@ describe("resolveEffectiveOrg with API refresh", () => {
const { clearAuth } = await import("../../src/lib/db/auth.js");
await clearAuth();

// Set a silent fetch mock to prevent preload warnings.
// Without auth, resolveEffectiveOrg tries an API refresh that fails,
// then falls back to the original slug.
globalThis.fetch = mockFetch(
async () =>
new Response(JSON.stringify({ detail: "Unauthorized" }), {
status: 401,
})
);

const result = await resolveEffectiveOrg("o1081365");
expect(result).toBe("o1081365");
});
Expand Down
2 changes: 2 additions & 0 deletions test/lib/resolve-target-listing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ describe("resolveOrgProjectTarget", () => {
resolveTargetModule,
"resolveOrgAndProject"
);
// Pre-populate org region cache so resolveEffectiveOrg doesn't fetch
setOrgRegion("my-org", DEFAULT_SENTRY_URL);
});

afterEach(() => {
Expand Down
13 changes: 13 additions & 0 deletions test/lib/resolve-target.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,12 +176,25 @@ describe("toNumericId", () => {
describe("Environment variable resolution (SENTRY_ORG / SENTRY_PROJECT)", () => {
useTestConfigDir("test-resolve-target-");

// Silence unmocked fetch calls from resolution cascade fall-through.
// Tests that set valid env vars short-circuit before fetch; tests that
// fall through (empty/whitespace env vars) trigger DSN detection and
// directory inference which call the API. A silent 404 prevents preload
// warnings while preserving the catch-and-continue behavior.
let originalFetch: typeof globalThis.fetch;

beforeEach(() => {
originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch(
async () =>
new Response(JSON.stringify({ detail: "Not found" }), { status: 404 })
);
delete process.env.SENTRY_ORG;
delete process.env.SENTRY_PROJECT;
});

afterEach(() => {
globalThis.fetch = originalFetch;
delete process.env.SENTRY_ORG;
delete process.env.SENTRY_PROJECT;
});
Expand Down
13 changes: 12 additions & 1 deletion test/lib/version-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
maybeCheckForUpdateInBackground,
shouldSuppressNotification,
} from "../../src/lib/version-check.js";
import { useTestConfigDir } from "../helpers.js";
import { mockFetch, useTestConfigDir } from "../helpers.js";

describe("shouldSuppressNotification", () => {
test("suppresses for upgrade command", () => {
Expand Down Expand Up @@ -162,16 +162,27 @@ describe("abortPendingVersionCheck", () => {
describe("maybeCheckForUpdateInBackground", () => {
useTestConfigDir("test-version-bg-");
let savedNoUpdateCheck: string | undefined;
let originalFetch: typeof globalThis.fetch;

beforeEach(() => {
// Save and clear the env var to test real implementation
savedNoUpdateCheck = process.env.SENTRY_CLI_NO_UPDATE_CHECK;
delete process.env.SENTRY_CLI_NO_UPDATE_CHECK;
// Silence background fetch calls to GitHub API that would otherwise
// hit the preload mock and produce "unexpected fetch" warnings.
originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch(
async () =>
new Response(JSON.stringify({ tag_name: "v0.0.0-dev" }), {
status: 200,
})
);
});

afterEach(() => {
// Abort any pending check to clean up
abortPendingVersionCheck();
globalThis.fetch = originalFetch;
// Restore the env var
if (savedNoUpdateCheck !== undefined) {
process.env.SENTRY_CLI_NO_UPDATE_CHECK = savedNoUpdateCheck;
Expand Down
Loading