From 94c1d71bf1d058cfac191bec32d873d1c108af02 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 11 Apr 2026 00:12:16 +0000 Subject: [PATCH] fix(test): silence "unexpected fetch call to" warnings in unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminate all 182 "unexpected fetch call to" warnings from the unit and isolated test suites. The warnings were caused by test code paths reaching the global fetch trap in test/preload.ts without proper mocking — tests passed because errors were caught internally, but console.error fired before the catch. Root causes across 11 test files: - Missing org region cache: tests called resolveEffectiveOrg without pre-populating setOrgRegion, triggering API fetches to discover region URLs (resolve-target-listing, span/view, project/create) - Fire-and-forget warmOrgCache: after successful login, warmOrgCache() calls listOrganizationsUncached() as a detached promise that outlives the test and spills warnings into subsequent test files (auth/login, login-reauth) - Resolution cascade fall-through: env var tests, SDK integration tests, and help-positional tests triggered DSN detection and directory inference when env vars were empty, making API calls on the fall-through path (resolve-target, index, help-positional, issue/utils) - Background version check: maybeCheckForUpdateInBackground() fired real fetches to GitHub API (version-check) - Missing per-test fetch mock: one test inside a describe block with save/restore lacked its own fetch mock (resolve-effective-org) --- AGENTS.md | 121 ++++++++---------------- test/commands/auth/login.test.ts | 7 ++ test/commands/issue/utils.test.ts | 8 +- test/commands/project/create.test.ts | 12 +++ test/commands/span/view.test.ts | 12 +++ test/isolated/login-reauth.test.ts | 7 ++ test/lib/help-positional.test.ts | 23 ++++- test/lib/index.test.ts | 45 ++++++++- test/lib/resolve-effective-org.test.ts | 10 ++ test/lib/resolve-target-listing.test.ts | 2 + test/lib/resolve-target.test.ts | 13 +++ test/lib/version-check.test.ts | 13 ++- 12 files changed, 187 insertions(+), 86 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 20d5fc03a..8cd817431 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -984,108 +984,71 @@ mock.module("./some-module", () => ({ ### Architecture - -* **commandPrefix on SentryContext enables command identity in buildCommand wrapper**: commandPrefix and help-as-positional recovery: \`SentryContext.commandPrefix\` (optional \`readonly string\[]\`) is populated in \`forCommand()\` — Stricli calls this with the full prefix (e.g., \`\["sentry", "issue", "list"]\`) for help recovery and telemetry. Help recovery: (1) Leaf commands: \`maybeRecoverWithHelp\` catches \`CliError\` if any positional was \`"help"\`, shows help via \`introspectCommand()\`. (2) Route groups: post-run check in \`bin.ts\` detects \`ExitCode.UnknownCommand\` + last arg \`"help"\`, rewrites argv. Both dynamic-import \`help.js\` to avoid circular deps. + +* **AsyncIterable streaming for SDK blocked by four structural concerns**: AsyncIterable streaming for SDK implemented via AsyncChannel push/pull pattern. \`src/lib/async-channel.ts\` provides a dual-queue channel: producer calls \`push()\`/\`close()\`/\`error()\`, consumer iterates via \`for await...of\`. \`break\` triggers \`onReturn\` callback for cleanup. \`executeWithStream()\` in \`sdk-invoke.ts\` runs the command in background, pipes \`captureObject\` calls to the channel, and returns the channel immediately. Streaming detection: \`hasStreamingFlag()\` checks for \`--refresh\`/\`--follow\`/\`-f\`. \`buildInvoker\` accepts \`meta.streaming\` flag; \`buildRunner\` auto-detects from args. Abort wiring: \`AbortController\` created per stream, signal placed on fake \`process.abortSignal\`, \`channel.onReturn\` calls \`controller.abort()\`. Both \`log/list.ts\` and \`dashboard/view.ts\` check \`this.process?.abortSignal\` alongside SIGINT. Codegen generates callable interface overloads for streaming commands. - -* **Dashboard widget interval computed from terminal width and layout before API calls**: Dashboard widget interval computed from terminal width: \`computeOptimalInterval()\` in \`src/lib/api/dashboards.ts\` calculates optimal chart interval before API calls. Formula: \`colWidth = floor(layout.w / 6 \* termWidth)\`, \`chartWidth = colWidth - 4 - gutterW\`, \`idealSeconds = periodSeconds / chartWidth\`. Snaps to nearest Sentry interval bucket (\`1m\`–\`1d\`). \`periodToSeconds()\` parses \`"24h"\`, \`"7d"\` etc. \`queryAllWidgets\` computes per-widget intervals using \`getTermWidth()\` (min 80, fallback 100). + +* **Bundle uses esbuild with bun:sqlite polyfill plugin for Node.js compatibility**: \`script/bundle.ts\` uses esbuild to produce \`dist/index.cjs\` from \`src/index.ts\`. A \`bunSqlitePlugin\` replaces \`bun:sqlite\` imports with a polyfill. Build defines \`SENTRY\_CLI\_VERSION\` and \`SENTRY\_CLIENT\_ID\_BUILD\`, externalizes \`node:\*\` builtins. \`sentrySourcemapPlugin\` handles debug ID injection and sourcemap upload. After the main build, writes: (1) \`dist/bin.cjs\` — CLI wrapper with shebang/Node version check/warning suppression, (2) \`dist/index.d.cts\` — type declarations read from pre-built \`src/sdk.generated.d.cts\`. Both \`sdk.generated.\*\` files are gitignored and regenerated via \`generate:sdk\` script chained before \`bundle\` in \`package.json\`. Debug IDs solve sourcemap deduplication between npm bundle and bun compile builds. - -* **defaultCommand:help blocks Stricli fuzzy matching for top-level typos**: Fuzzy matching for CLI typos: Stricli has built-in Damerau-Levenshtein for subcommand/flag typos within known routes. \`defaultCommand: "help"\` bypasses this for top-level typos — fixed by \`resolveCommandPath()\` in \`introspect.ts\` returning \`UnresolvedPath\` with suggestions via \`fuzzyMatch()\` from \`fuzzy.ts\` (up to 3). Covers \`sentry \\` and \`sentry help \\`. Additional fuzzy: tab-completion in \`complete.ts\`, platform suggestions in \`platforms.ts\`, plural alias detection in \`app.ts\`. JSON includes \`suggestions\` array. + +* **CLI logic extracted from bin.ts into cli.ts for shared entry points**: \`src/cli.ts\` contains the full CLI runner extracted from \`bin.ts\`: \`runCompletion()\` (shell completion fast path), \`runCli()\` (full CLI with middleware — auto-auth, seer trial, unknown command telemetry), and \`startCli()\` (top-level dispatch). All functions are exported, no top-level execution. \`src/bin.ts\` is a thin ~30-line wrapper for bun compile that registers EPIPE/EIO stream error handlers and calls \`startCli()\`. The npm bin wrapper (\`dist/bin.cjs\`) is a ~300-byte generated script that \`require('./index.cjs').\_cli()\`. Both entry points share the same CLI logic via \`cli.ts\`. - -* **DSN org prefix normalization in arg-parsing.ts**: DSN org prefix normalization has four code paths: (1) \`extractOrgIdFromHost\` in \`dsn/parser.ts\` strips \`o\` prefix during parsing. (2) \`stripDsnOrgPrefix()\` handles user-typed \`o1081365/\` in \`parseOrgProjectArg()\` and \`resolveEffectiveOrg()\`. (3) \`normalizeNumericOrg()\` in \`resolve-target.ts\` handles bare numeric IDs — checks DB cache via \`getOrgByNumericId()\`, falls back to \`listOrganizationsUncached()\`. (4) Dashboard's \`resolveOrgFromTarget()\` pipes through \`resolveEffectiveOrg()\`. Critical: many API endpoints reject numeric org IDs with 404/403 — always normalize to slugs. + +* **Library API: variadic sentry() function with last-arg options detection**: \`createSentrySDK(options?)\` in \`src/index.ts\` is the sole public API. Returns a typed SDK object with methods for every CLI command plus \`run()\` escape hatch. \`SentryOptions\` in \`src/lib/sdk-types.ts\`: \`token?\`, \`text?\` (run-only), \`cwd?\`, \`url?\` (self-hosted base URL → \`SENTRY\_HOST\`), \`org?\` (default org → \`SENTRY\_ORG\`), \`project?\` (default project → \`SENTRY\_PROJECT\`). Env isolation via \`buildIsolatedEnv(options)\` helper in \`sdk-invoke.ts\` — shared by both \`buildInvoker\` and \`buildRunner\`, maps each option to its env var. Zero-copy \`captureObject\` return, \`OutputError\` → data recovery. Default JSON output via \`SENTRY\_OUTPUT\_FORMAT=json\`. Non-zero exit throws \`SentryError\` with \`.exitCode\` and \`.stderr\`. - -* **GHCR versioned nightly tags for delta upgrade support**: GHCR nightly distribution with delta upgrades: Three tag types: \`:nightly\` (rolling), \`:nightly-\\` (immutable), \`:patch-\\` (delta manifest). Delta patches use zig-bsdiff TRDIFF10 (zstd-compressed), ~50KB vs ~29MB full. Client bspatch via \`Bun.zstdDecompressSync()\`. N-1 patches only, full download fallback, SHA-256 verify, 60% size threshold. npm/Node excluded. + +* **Library mode telemetry strips all global-polluting Sentry integrations**: When \`initSentry(enabled, { libraryMode: true })\` is called, the Sentry SDK initializes without integrations that pollute the host process. \`LIBRARY\_EXCLUDED\_INTEGRATIONS\` extends the base set with: \`OnUncaughtException\`, \`OnUnhandledRejection\`, \`ProcessSession\` (process listeners), \`Http\`/\`NodeFetch\` (trace header injection), \`FunctionToString\` (wraps \`Function.prototype.toString\`), \`ChildProcess\`/\`NodeContext\`. Also disables \`enableLogs\` and \`sendClientReports\` (both use timers/\`beforeExit\`), and skips \`process.on('beforeExit')\` handler registration. Keeps pure integrations: \`eventFiltersIntegration\`, \`linkedErrorsIntegration\`. Library entry manually calls \`client.flush(3000)\` after command completion (both success and error paths via \`flushTelemetry()\` helper). Only unavoidable global: \`globalThis.\_\_SENTRY\_\_\[SDK\_VERSION]\`. - -* **Issue list auto-pagination beyond API's 100-item cap**: Sentry API silently caps \`limit\` at 100 per request. \`listIssuesAllPages()\` auto-paginates using Link headers, bounded by MAX\_PAGINATION\_PAGES (50). \`API\_MAX\_PER\_PAGE\` constant is shared across all paginated consumers. \`--limit\` means total results everywhere (max 1000, default 25). Org-all mode uses \`fetchOrgAllIssues()\`; explicit \`--cursor\` does single-page fetch to preserve cursor chain. - - -* **resolveProjectBySlug carries full projectData to avoid redundant getProject calls**: resolveProjectBySlug carries full projectData: Returns \`{ org, project, projectData: SentryProject }\` from \`findProjectsBySlug()\`. \`ResolvedOrgProject\` and \`ResolvedTarget\` have optional \`projectData?\` (populated only in project-search path). Downstream commands use \`resolved.projectData ?? await getProject(org, project)\` to skip redundant API calls (~500-800ms savings). When testing resolution results with optional fields, use \`toMatchObject()\` not \`toEqual()\`. - - -* **Self-hosted OAuth device flow requires Sentry 26.1.0+ and SENTRY\_CLIENT\_ID**: Self-hosted OAuth device flow requires Sentry 26.1.0+ and both \`SENTRY\_URL\` and \`SENTRY\_CLIENT\_ID\` env vars. Users must create a public OAuth app in Settings → Developer Settings. The client ID is NOT optional for self-hosted. Fallback for older instances: \`sentry auth login --token\`. \`getSentryUrl()\` and \`getClientId()\` in \`src/lib/oauth.ts\` read lazily (not at module load) so URL parsing from arguments can set \`SENTRY\_URL\` after import. - - -* **Sentry CLI markdown-first formatting pipeline replaces ad-hoc ANSI**: Formatters build CommonMark strings; \`renderMarkdown()\` renders to ANSI for TTY or raw markdown for non-TTY. Key helpers: \`colorTag()\`, \`mdKvTable()\`, \`mdRow()\`, \`mdTableHeader()\` (\`:\` suffix = right-aligned), \`renderTextTable()\`. \`isPlainOutput()\` checks \`SENTRY\_PLAIN\_OUTPUT\` > \`NO\_COLOR\` > \`!isTTY\`. Batch path: \`formatXxxTable()\`. Streaming path: \`StreamingTable\` (TTY) or raw markdown rows (plain). Both share \`buildXxxRowCells()\`. - - -* **Sentry dashboard API rejects discover/transaction-like widget types — use spans**: Sentry dashboard API rejects deprecated widget types — use spans: \`widgetType: 'discover'\` and \`'transaction-like'\` are rejected. Use \`'spans'\` for new widgets. Codebase splits \`WIDGET\_TYPES\` (active) vs \`ALL\_WIDGET\_TYPES\` (includes deprecated for parsing). \`DashboardWidgetInputSchema\` uses \`ALL\_WIDGET\_TYPES\` so editing existing deprecated widgets passes Zod. \`validateWidgetEnums()\` rejects deprecated for creation but accepts \`skipDeprecatedCheck: true\` for the edit path where \`effectiveDataset\` may be inherited. Tests must use \`error-events\` instead of \`discover\`. - - -* **Sentry issue stats field: time-series controlled by groupStatsPeriod**: Issue stats and list layout: \`stats\` key depends on \`groupStatsPeriod\` (\`""\`, \`"14d"\`, \`"24h"\`, \`"auto"\`); \`statsPeriod\` controls window. Critical: \`count\` is period-scoped — use \`lifetime.count\` for true total. Issue list uses \`groupStatsPeriod: 'auto'\` for sparklines. \`--compact\` tri-state: explicit overrides; \`undefined\` triggers \`shouldAutoCompact(rowCount)\` — compact if \`3N + 3 > termHeight\`. - - -* **Sentry trace-logs API is org-scoped, not project-scoped**: The Sentry trace-logs endpoint (\`/organizations/{org}/trace-logs/\`) is org-scoped, so \`trace logs\` uses \`resolveOrg()\` not \`resolveOrgAndProject()\`. The endpoint is PRIVATE in Sentry source, excluded from the public OpenAPI schema — \`@sentry/api\` has no generated types. The hand-written \`TraceLogSchema\` in \`src/types/sentry.ts\` is required until Sentry makes it public. - - -* **SKILL.md is fully generated — edit source files, not output**: SKILL.md is fully generated — edit source files, not output: Skill files under \`plugins/sentry-cli/skills/sentry-cli/\` are fully generated by \`bun run generate:skill\` (runs as part of \`dev\`, \`build\`, \`typecheck\`, \`test\`). To change content, edit sources: (1) \`docs/src/content/docs/agent-guidance.md\` → SKILL.md Agent Guidance section. (2) \`src/commands/\*/\` flag \`brief\` strings → reference file descriptions. (3) \`docs/src/fragments/commands/\*.md\` → hand-written examples. Command docs (\`docs/src/content/docs/commands/\*.md\`) are gitignored, rebuilt from fragments + CLI metadata. \`bun run check:fragments\` validates consistency. CI auto-commits stale skill files. - - -* **Stricli route errors are uninterceptable — only post-run detection works**: Stricli route errors are uninterceptable — only post-run detection works: (1) Route failures write to stderr and return \`ExitCode.UnknownCommand\` internally; only post-\`run()\` \`process.exitCode\` check works. (2) \`ExitCode.UnknownCommand\` is \`-5\` — Bun reads \`251\` (unsigned byte), Node reads \`-5\`, compare both. (3) \`OutputError\` in \`handleOutputError\` calls \`process.exit()\` immediately, bypassing telemetry. Top-level typos via \`defaultCommand:help\` → \`OutputError\` → \`process.exit(1)\` skip error reporting. - - -* **Three Sentry APIs for span custom attributes with different capabilities**: Three Sentry APIs for span data with different custom attribute support: (1) \`/trace/{traceId}/\` — hierarchical tree; \`additional\_attributes\` query param enumerates requested attributes. Returns \`measurements\` (web vitals, zero-filled on non-browser spans — \`filterSpanMeasurements()\` strips zeros in JSON). (2) \`/projects/{org}/{project}/trace-items/{itemId}/?trace\_id={id}\&item\_type=spans\` — single span full detail; returns ALL attributes as \`{name, type, value}\[]\` automatically. CLI's \`span view\` uses this via \`getSpanDetails()\`. (3) \`/events/?dataset=spans\&field=X\` — list/search; requires explicit \`field\` params. - - -* **withAuthGuard returns discriminated Result type, not fallback+onError**: \`withAuthGuard\(fn)\` in \`src/lib/errors.ts\` returns a discriminated Result: \`{ ok: true, value: T } | { ok: false, error: unknown }\`. AuthErrors always re-throw (triggers bin.ts auto-login). All other errors are captured. Callers inspect \`result.ok\` to degrade gracefully. Used across 12+ files. + +* **Typed SDK uses direct Command.loader() invocation bypassing Stricli dispatch**: \`createSentrySDK(options?)\` in \`src/index.ts\` builds a typed namespace API (\`sdk.org.list()\`, \`sdk.issue.view()\`) generated by \`script/generate-sdk.ts\`. At runtime, \`src/lib/sdk-invoke.ts\` resolves commands via Stricli route tree, caches \`Command\` objects, and calls \`command.loader()\` directly — bypassing string dispatch and flag parsing. The standalone variadic \`sentry()\` function has been removed. Typed SDK methods are the primary path, with \`sdk.run()\` as an escape hatch for arbitrary CLI strings (interactive commands like \`auth login\`, raw \`api\` passthrough). The codegen auto-discovers ALL commands from the route tree with zero config, using CLI route names as-is (\`org.list\`, \`dashboard.widget.add\`). Return types are derived from \`\_\_jsonSchema\` when present, otherwise \`unknown\`. Positional patterns are derived from introspection placeholder strings. Hidden routes (plural aliases) are skipped. ### Decision - -* **400 Bad Request from Sentry API indicates a CLI bug, not a user error**: The user considers 400 Bad Request responses from the Sentry API to be CLI bugs — the CLI should never construct a malformed request. Unlike 404 (wrong ID) or 403 (no access), a 400 means the request payload or parameters are invalid, which is a code defect. Therefore \`Sentry.captureException()\` should continue capturing 400s. The \`isClientApiError()\` function in \`telemetry.ts\` treats all 4xx uniformly, but 400 specifically warrants different handling from other client errors like 404/403/429. When fixing noisy telemetry, distinguish between 'user pointed at wrong thing' (404/403) and 'CLI sent bad data' (400). - -### Gotcha - - -* **Biome lint: Response.redirect() required, nested ternaries forbidden**: Biome lint rules and complexity: (1) \`useResponseRedirect\`: use \`Response.redirect(url, status)\` not \`new Response\`. (2) \`noNestedTernary\`: use \`if/else\`. (3) \`noComputedPropertyAccess\`: use \`obj.property\` not \`obj\["property"]\`. (4) Max cognitive complexity 15 — extract catch blocks to helper functions (pattern: \`function handleXxxError(error: unknown, ...ctx): never\`). Used in \`plan.ts\` with \`handleSeerCommandError()\`. Also extract validation helpers like \`rejectInvalidDataset()\`. - - -* **Bugbot flags defensive null-checks as dead code — keep them with JSDoc justification**: Cursor Bugbot and Sentry Seer repeatedly flag two false positives: (1) defensive null-checks as "dead code" — keep them with JSDoc explaining why the guard exists for future safety, especially when removing would require \`!\` assertions banned by \`noNonNullAssertion\`. (2) stderr spinner output during \`--json\` mode — always a false positive since progress goes to stderr, JSON to stdout. Reply explaining the rationale and resolve. + +* **Agent skill files embedded at build time, not fetched from network**: \`agent-skills.ts\` imports skill content from \`src/generated/skill-content.ts\` (a build-time generated module) and writes files directly to \`~/.claude/skills/sentry-cli/\`. No network fetching, no GitHub URLs, no fallback URLs, no \`REFERENCE\_FILES\` array. \`installAgentSkills(homeDir)\` takes only \`homeDir\` — no version parameter needed since content is baked into the binary. The generated module is ~47KB of inlined markdown strings, comparable to \`sdk.generated.ts\`. Works identically for both bun compile (native binary) and esbuild npm bundle since it's a standard TS module. Previously skill files were fetched from \`raw.githubusercontent.com\` with \`cli.sentry.dev\` fallback — a roundtrip for content already available at build time. - -* **Bun mock.module for node:tty requires default export and class stubs**: Bun testing gotchas: (1) \`mock.module()\` for CJS built-ins requires a \`default\` re-export plus ALL named exports — missing any causes \`SyntaxError: Export named 'X' not found\`. (2) \`Bun.mmap()\` always opens with PROT\_WRITE — macOS SIGKILL on signed Mach-O, Linux ETXTBSY. Fix: \`new Uint8Array(await Bun.file(path).arrayBuffer())\`. (3) Wrap \`Bun.which()\` with optional \`pathEnv\` param for deterministic testing. (4) Command func tests: \`const func = await cmd.loader()\`, then \`func.call(mockContext, flags, ...args)\`. File naming: \`\*.func.test.ts\`. + +* **OutputError propagates via throw instead of process.exit()**: The \`process.exit()\` call in \`command.ts\` (OutputError handler) is replaced with \`throw err\` to support library mode. \`OutputError\` is re-thrown through Stricli via \`exceptionWhileRunningCommand\` in \`app.ts\` (added before the \`AuthError\` check), so Stricli never writes an error message for it. In CLI mode (\`cli.ts\`), OutputError is caught and \`process.exitCode\` is set silently without writing to stderr (data was already rendered). In library mode (\`index.ts\`), the catch block checks if \`capturedResult\` has data (the OutputError's payload was rendered to stdout via \`captureObject\` before the throw) and returns it instead of throwing \`SentryError\`. This eliminates the only \`process.exit()\` outside of \`bin.ts\`. - -* **CLI telemetry command tags use sentry. prefix with dots not bare names**: The \`buildCommand\` wrapper sets the \`command\` telemetry tag using the full Stricli command prefix joined with dots: \`sentry.issue.explain\`, \`sentry.issue.list\`, \`sentry.api\`, etc. — NOT bare names like \`issue.explain\`. When querying Sentry Discover or building dashboard widgets, always use the \`sentry.\` prefix. Verify actual tag values with a Discover query (\`field:command, count()\`, grouped by \`command\`) before assuming the format. + +* **SDK codegen moving to auto-generate all commands from route tree**: \`script/generate-sdk.ts\` walks the Stricli route tree via \`discoverCommands()\`, skipping hidden routes. For each command: extracts flags, derives positional params from placeholder strings, checks \`\_\_jsonSchema\` for typed return types. Naming uses CLI route path as-is: \`\["org", "list"]\` → \`sdk.org.list()\`. Generates TWO gitignored files: (1) \`src/sdk.generated.ts\` — runtime, (2) \`src/sdk.generated.d.cts\` — npm type declarations. \`generate:sdk\` is chained before \`typecheck\`, \`dev\`, \`build\`, \`build:all\`, \`bundle\`. \`INTERNAL\_FLAGS\` set excludes \`json\`, \`fields\`, \`refresh\`, \`follow\` from generated parameter types — streaming flags are library-incompatible. CI check \`bun run check:skill\` validates SKILL.md stays in sync. - -* **Dashboard queryWidgetTable must guard sort param by dataset like queryWidgetTimeseries**: The Sentry events API \`sort\` parameter is only supported on the \`spans\` dataset. Passing \`sort\` to \`errors\` or \`discover\` datasets returns 400 Bad Request. In \`src/lib/api/dashboards.ts\`, \`queryWidgetTimeseries\` correctly guards this (line 387: \`if (dataset === 'spans')\`), but \`queryWidgetTable\` must also apply the same guard. Without it, any table/big\_number widget with \`orderby\` set on a non-spans dataset triggers a 400 that gets caught and silently displayed as a widget error. The fix: \`sort: dataset === 'spans' ? query?.orderby || undefined : undefined\`. +### Gotcha - + * **Dashboard tracemetrics dataset uses comma-separated aggregate format**: SDK v10+ custom metrics (, , ) emit envelope items. Dashboard widgets for these MUST use with aggregate format — e.g., . The parameter must match the SDK emission exactly: if no unit specified, for memory metrics, for uptime. only supports , , , , display types — no or . Widgets with always require . Sort expressions must reference aggregates present in . - -* **spansIndexed is not a valid Sentry dataset — use spans**: The Sentry Events/Explore API accepts 5 dataset values: \`spans\`, \`transactions\`, \`logs\`, \`errors\`, \`discover\`. The name \`spansIndexed\` is invalid and returns a generic HTTP 500 "Internal error" with no helpful validation message. This trips up AI agents and users. Valid datasets are documented in \`src/lib/api/datasets.ts\` (\`EVENTS\_API\_DATASETS\` constant) and in \`docs/commands/api.md\`. + +* **Test mocks lack process property — use optional chaining on this.process**: Command \`func()\` methods access \`this: SentryContext\` which has \`this.process\`. But test mocks created via \`createMockContext()\` only provide \`stdout\`/\`stderr\`/\`cwd\` — no \`process\` property. Accessing \`this.process.abortSignal\` crashes with \`undefined is not an object\`. Fix: always use optional chaining \`(this.process as T)?.abortSignal\` or check \`this.process\` exists first. This applies to any new property added to the process-like object in \`sdk-invoke.ts\` that commands read via \`this.process\`. ### Pattern - -* **--fields dual role: output filtering + API field selection for span list**: --fields dual role in span list: filters JSON output AND requests extra API fields. \`extractExtraApiFields()\` checks names against \`OUTPUT\_TO\_API\_FIELD\` mapping. Unknown names are treated as custom attributes added to the \`field\` API param. \`FIELD\_GROUP\_ALIASES\` supports shorthand expansion (e.g., \`gen\_ai\` → 4 fields). Extra fields survive Zod via \`SpanListItemSchema.passthrough()\` and are forwarded by \`spanListItemToFlatSpan()\`. \`formatSpanTable()\` dynamically adds columns. + +* **buildIsolatedEnv helper centralizes SDK env setup**: \`buildIsolatedEnv(options?)\` in \`src/lib/sdk-invoke.ts\` maps \`SentryOptions\` fields to env vars (\`token\` → \`SENTRY\_AUTH\_TOKEN\`, \`url\` → \`SENTRY\_HOST\`, etc.) plus \`SENTRY\_OUTPUT\_FORMAT=json\` (unless \`text: true\`). The core dedup is \`executeWithCapture\()\` which centralizes the env isolation → capture context → telemetry → error wrapping → output parsing pipeline. Both \`buildInvoker\` (typed methods) and \`buildRunner\` (\`run()\` escape hatch) are thin ~15-line wrappers providing only the executor callback. \`STREAMING\_FLAGS\` set (\`--refresh\`, \`--follow\`, \`-f\`) is checked in \`buildRunner\` before execution — throws \`SentryError\` immediately since streaming output is unsuitable for library mode. Same flags are in \`INTERNAL\_FLAGS\` in codegen so typed SDK methods can't trigger streaming. + + +* **Command docs use GENERATED:END marker for hybrid auto/manual content**: \`generate-command-docs.ts\` produces one \`.md\` per visible route in \`docs/src/content/docs/commands/\`. Each page splits at \`\\`: above is auto-generated (flags, args, descriptions from Stricli introspection), below is hand-written (examples, guides). Regeneration preserves custom content below the marker. \`check-command-docs.ts\` only compares the auto-generated portion. \`GLOBAL\_FLAG\_NAMES\` (\`json\`, \`fields\`) are excluded from per-command docs. The \`SKIP\_ROUTES\` set filters hidden plural aliases. Pages must contain bash code blocks for the skill generator to extract examples — \`check-skill.ts\` validates this via \`\*\*Examples:\*\*\` section check. - -* **--since is an alias for --period via shared PERIOD\_ALIASES**: \`PERIOD\_ALIASES\` in \`src/lib/list-command.ts\` maps both \`t\` and \`since\` to \`period\`. All commands using \`LIST\_PERIOD\_FLAG\` get \`--since\` as an alias for \`--period\` automatically via spread \`...PERIOD\_ALIASES\`. This was added because AI agents and humans naturally try \`--since 1h\` instead of \`--period 1h\`. + +* **SDK codegen callable interface pattern for streaming overloads**: In \`script/generate-sdk.ts\`, streaming-capable commands (those with flags in \`STREAMING\_FLAGS\` set) use a callable interface pattern instead of a simple method signature. This produces TypeScript overloaded signatures: \`(params?: T): Promise\\` for non-streaming and \`(params: T & { follow: string }): AsyncIterable\\` for streaming. At runtime, \`generateStreamingMethodBody()\` emits code that checks if any streaming flag is present in params, then passes \`{ streaming: true }\` meta to the invoker which branches to \`executeWithStream\` vs \`executeWithCapture\`. The \`STREAMING\_FLAGS\` set (\`refresh\`, \`follow\`) is separate from \`INTERNAL\_FLAGS\` — streaming flags ARE included in generated params but excluded from \`INTERNAL\_FLAGS\`. - -* **Branch naming and commit message conventions for Sentry CLI**: Branch naming: \`feat/\\` or \`fix/\-\\` (e.g., \`feat/ghcr-nightly-distribution\`, \`fix/268-limit-auto-pagination\`). Commit message format: \`type(scope): description (#issue)\` (e.g., \`fix(issue-list): auto-paginate --limit beyond 100 (#268)\`, \`feat(nightly): distribute via GHCR instead of GitHub Releases\`). Types seen: fix, refactor, meta, release, feat. PRs are created as drafts via \`gh pr create --draft\`. Implementation plans are attached to commits via \`git notes add\` rather than in PR body or commit message. + +* **SENTRY\_OUTPUT\_FORMAT env var enables JSON mode from env instead of --json flag**: In \`src/lib/command.ts\`, the \`wrappedFunc\` checks \`this.env?.SENTRY\_OUTPUT\_FORMAT === "json"\` to force JSON output mode without passing \`--json\` on the command line. This is how the library entry point (\`src/index.ts\`) gets JSON by default — it sets this env var in the isolated env. The check runs after \`cleanRawFlags\` and only when the command has an \`output\` config (supports JSON). Commands without JSON support (help, version) are unaffected. ~5-line addition to \`command.ts\`. - -* **Codecov patch coverage only counts test:unit and test:isolated, not E2E**: CI coverage merges \`test:unit\` (\`test/lib test/commands test/types --coverage\`) and \`test:isolated\` (\`test/isolated --coverage\`) into \`coverage/merged.lcov\`. E2E tests (\`test/e2e\`) are NOT included in coverage reports. So func tests that spy on exports (e.g., \`spyOn(apiClient, 'getLogs')\`) give zero coverage to the mocked function's body. To cover \`api-client.ts\` function bodies in unit tests, mock \`globalThis.fetch\` + \`setOrgRegion()\` + \`setAuthToken()\` and call the real function. + +* **set-commits default mode makes speculative --auto API call by design**: When \`release set-commits\` is called without \`--auto\` or \`--local\`, it tries auto-discovery first and falls back to local git on 400 error. This matches the reference sentry-cli behavior (parity-correct). A per-org negative cache in the \`metadata\` table (\`repos\_configured.\` = \`"false"\`, 1-hour TTL) skips the speculative auto call on subsequent runs when no repo integration is configured. The cache clears on successful auto-discovery. - -* **Pagination contextKey must include all query-varying parameters with escaping**: Pagination \`contextKey\` must encode every query-varying parameter (sort, query, period) with \`escapeContextKeyValue()\` (replaces \`|\` with \`%7C\`). Always provide a fallback before escaping since \`flags.period\` may be \`undefined\` in tests despite having a default: \`flags.period ? escapeContextKeyValue(flags.period) : "90d"\`. + +* **Skill files generation pipeline and staleness checks**: \`generate-skill.ts\` walks the Stricli route tree to produce \`SKILL.md\` + per-group \`references/\*.md\` + \`index.json\` under \`plugins/sentry-cli/skills/sentry-cli/\`, AND generates \`src/generated/skill-content.ts\` — a TypeScript module that inlines all skill file contents as a \`ReadonlyMap\\`. The generator must write a stub \`skill-content.ts\` before dynamically importing \`src/app.ts\` (chicken-and-egg: app.ts transitively imports agent-skills.ts which imports skill-content.ts). CRITICAL: \`generate:skill\` must run BEFORE \`generate:sdk\` in all package.json script chains, because \`generate:sdk\` also imports \`src/app.ts\` and will crash if the stub doesn't exist. \`check-skill.ts\` must NOT include \`skill-content.ts\` in its staleness comparison — it's gitignored, so fresh CI checkouts create a permanent diff that breaks fork PRs. - -* **PR review workflow: reply, resolve, amend, force-push**: PR review workflow: (1) Read unresolved threads via GraphQL, (2) make code changes, (3) run lint+typecheck+tests, (4) create a SEPARATE commit per review round (not amend) for incremental review, (5) push normally, (6) reply to comments via REST API, (7) resolve threads via GraphQL \`resolveReviewThread\`. Only amend+force-push when user explicitly asks or pre-commit hook modified files. + +* **Target argument 4-mode parsing convention (project-search-first)**: \`parseOrgProjectArg()\` in \`src/lib/arg-parsing.ts\` returns a 4-mode discriminated union: \`auto-detect\` (empty), \`explicit\` (\`org/project\`), \`org-all\` (\`org/\` trailing slash), \`project-search\` (bare slug). Bare slugs are ALWAYS \`project-search\` first. The "is this an org?" check is secondary: list commands with \`orgSlugMatchBehavior\` pre-check cached orgs (\`redirect\` or \`error\` mode), and \`handleProjectSearch()\` has a safety net checking orgs after project search fails. Non-list commands (init, view) treat bare slugs purely as project search with no org pre-check. For \`init\`, unmatched bare slugs become new project names. Key files: \`src/lib/arg-parsing.ts\` (parsing), \`src/lib/org-list.ts\` (dispatch + org pre-check), \`src/lib/resolve-target.ts\` (resolution cascade). - -* **Redact sensitive flags in raw argv before sending to telemetry**: Redact sensitive flags in raw argv before sending to telemetry: \`withTelemetry\` calls \`initTelemetryContext()\` BEFORE the callback — sets user ID, email, instance ID, runtime, is\_self\_hosted tags. For org context, reads \`getDefaultOrganization()\` from SQLite (no API call). Redact \`SENSITIVE\_FLAGS\` (currently \`token\`) — scan for \`--token\`/\`-token\`, replace value with \`\[REDACTED]\`. Handle both \`--flag value\` and \`--flag=value\` forms. + +* **Writer type is the minimal output interface for streams and mocks**: The \`Writer\` type in \`src/types/index.ts\` is \`{ write(data: string): void; captureObject?: (obj: unknown) => void }\`. The optional \`captureObject\` property replaces the previous duck-typing pattern (\`hasCaptureObject()\` with \`typeof\` check and \`Record\\` cast). In library mode, the writer sets \`captureObject\` to capture the fully-transformed JSON object directly without serialization. In CLI mode, \`process.stdout\` lacks this property so it's \`undefined\` → falsy, and \`emitJsonObject()\` falls through to \`JSON.stringify\`. The check is now a simple truthiness test: \`if (stdout.captureObject)\`. Since \`captureObject\` is part of the \`Writer\` type, \`sdk-invoke.ts\` no longer needs \`Writer & { captureObject?: ... }\` intersection types — plain \`Writer\` suffices. - -* **Set Sentry context for ApiError before captureException for structured diagnostics**: When \`Sentry.captureException(exc)\` is called for an \`ApiError\`, the SDK only captures \`name\`, \`message\`, and \`stacktrace\` — custom properties like \`status\`, \`endpoint\`, and \`detail\` are lost. Always call \`Sentry.setContext('api\_error', { status, endpoint, detail })\` before \`captureException\` so these fields appear as structured context in the Sentry event. This was added in \`exceptionWhileRunningCommand\` in \`app.ts\`. Without it, events show only the generic message 'API request failed: 400 Bad Request' with no way to identify which endpoint failed. +### Preference - -* **Stricli optional boolean flags produce tri-state (true/false/undefined)**: Stricli boolean flags with \`optional: true\` (no \`default\`) produce \`boolean | undefined\` in the flags type. \`--flag\` → \`true\`, \`--no-flag\` → \`false\`, omitted → \`undefined\`. This enables auto-detect patterns: explicit user choice overrides, \`undefined\` triggers heuristic. Used by \`--compact\` on issue list. The flag type must be \`readonly field?: boolean\` (not \`readonly field: boolean\`). This differs from \`default: false\` which always produces a defined boolean. + +* **Library features require README and docs site updates**: When adding new features like the library API, documentation must be updated in both places: the root \`README.md\` (library usage section between Configuration and Development, before the \`---\` divider) and the docs website at \`docs/src/content/docs/\`. The docs site uses Astro + Starlight with sidebar defined in \`docs/astro.config.mjs\`. New pages outside \`commands/\` must be manually added to the sidebar config. \`library-usage.md\` was added to the "Getting Started" sidebar section after "Configuration". Note: \`features.md\` and \`agent-guidance.md\` exist but are NOT in the sidebar. diff --git a/test/commands/auth/login.test.ts b/test/commands/auth/login.test.ts index 2d2ca7f8e..8541ec334 100644 --- a/test/commands/auth/login.test.ts +++ b/test/commands/auth/login.test.ts @@ -87,6 +87,7 @@ describe("loginCommand.func --token path", () => { let setUserInfoSpy: ReturnType; let runInteractiveLoginSpy: ReturnType; let hasStoredAuthCredentialsSpy: ReturnType; + let listOrgsUncachedSpy: ReturnType; let func: LoginFunc; beforeEach(async () => { @@ -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; @@ -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 () => { diff --git a/test/commands/issue/utils.test.ts b/test/commands/issue/utils.test.ts index ed3241538..633ab42ff 100644 --- a/test/commands/issue/utils.test.ts +++ b/test/commands/issue/utils.test.ts @@ -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 /ID for numeric IDs", () => { @@ -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); diff --git a/test/commands/project/create.test.ts b/test/commands/project/create.test.ts index e7030faa1..6ff1d7492 100644 --- a/test/commands/project/create.test.ts +++ b/test/commands/project/create.test.ts @@ -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, @@ -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", @@ -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 { @@ -73,6 +80,11 @@ describe("project create", () => { let resolveOrgSpy: ReturnType; 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"); diff --git a/test/commands/span/view.test.ts b/test/commands/span/view.test.ts index f147a082c..b125ddc30 100644 --- a/test/commands/span/view.test.ts +++ b/test/commands/span/view.test.ts @@ -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 @@ -280,6 +282,7 @@ function makeTraceSpan(spanId: string, children: unknown[] = []): unknown { describe("viewCommand.func", () => { let func: ViewFunc; let getDetailedTraceSpy: ReturnType; + let getSpanDetailsSpy: ReturnType; let resolveOrgAndProjectSpy: ReturnType; function createContext() { @@ -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(); }); diff --git a/test/isolated/login-reauth.test.ts b/test/isolated/login-reauth.test.ts index d1c98dfd1..d0ab026e3 100644 --- a/test/isolated/login-reauth.test.ts +++ b/test/isolated/login-reauth.test.ts @@ -114,6 +114,7 @@ describe("login re-authentication interactive prompt", () => { let clearAuthSpy: ReturnType; let runInteractiveLoginSpy: ReturnType; let getUserInfoSpy: ReturnType; + let listOrgsUncachedSpy: ReturnType; let func: LoginFunc; beforeEach(async () => { @@ -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); @@ -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 () => { diff --git a/test/lib/help-positional.test.ts b/test/lib/help-positional.test.ts index 8391ab3fe..666d35457 100644 --- a/test/lib/help-positional.test.ts +++ b/test/lib/help-positional.test.ts @@ -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; diff --git a/test/lib/index.test.ts b/test/lib/index.test.ts index 44a5f4f76..7f5b0918e 100644 --- a/test/lib/index.test.ts +++ b/test/lib/index.test.ts @@ -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"); @@ -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"); @@ -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"); diff --git a/test/lib/resolve-effective-org.test.ts b/test/lib/resolve-effective-org.test.ts index cea059f84..7795c5959 100644 --- a/test/lib/resolve-effective-org.test.ts +++ b/test/lib/resolve-effective-org.test.ts @@ -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"); }); diff --git a/test/lib/resolve-target-listing.test.ts b/test/lib/resolve-target-listing.test.ts index 8afad8fbf..b9b8ece90 100644 --- a/test/lib/resolve-target-listing.test.ts +++ b/test/lib/resolve-target-listing.test.ts @@ -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(() => { diff --git a/test/lib/resolve-target.test.ts b/test/lib/resolve-target.test.ts index 4facdcba0..0b72c9df2 100644 --- a/test/lib/resolve-target.test.ts +++ b/test/lib/resolve-target.test.ts @@ -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; }); diff --git a/test/lib/version-check.test.ts b/test/lib/version-check.test.ts index a2b745b3a..8e1bb84c3 100644 --- a/test/lib/version-check.test.ts +++ b/test/lib/version-check.test.ts @@ -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", () => { @@ -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;