diff --git a/AGENTS.md b/AGENTS.md index 1bfd4b629..7a1ab4b53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1001,86 +1001,81 @@ mock.module("./some-module", () => ({ ### Architecture - -* **@sentry/api SDK integration: type wrapping pattern and pagination helpers**: @sentry/api SDK integration: wrap SDK types at \`src/lib/api/\*.ts\` boundaries with \`as unknown as SentryX\` casts; never leak SDK types to commands. Wrappers in \`src/types/sentry.ts\` use \`Partial\ & RequiredCore\`. \`src/lib/region.ts\` imports \`retrieveAnOrganization\` directly to avoid circular dep with api-client. \`unwrapResult\`/\`unwrapPaginatedResult\` MUST stay CLI-owned — SDK versions throw plain \`Error\`, breaking the 'all errors are CliError subclasses' invariant (see also 365e4299). Body-shape casts use \`Parameters\\[0]\["body"]\`. + +* **Auth token env var override pattern: SENTRY\_AUTH\_TOKEN > SENTRY\_TOKEN > SQLite**: Auth in \`src/lib/db/auth.ts\` follows layered precedence: \`SENTRY\_AUTH\_TOKEN\` > \`SENTRY\_TOKEN\` > SQLite OAuth token. \`getEnvToken()\` trims env vars (empty/whitespace = unset). \`AuthSource\` tracks provenance. \`ENV\_SOURCE\_PREFIX = "env:"\` — use \`.length\` not hardcoded 4. Env tokens bypass refresh/expiry. \`isEnvTokenActive()\` guards auth commands. Logout must NOT clear stored auth when env token active. These functions stay in \`db/auth.ts\` despite not touching DB because they're tightly coupled with token retrieval. - -* **apiRequestToRegion/rawApiRequest options shape — no timeout/signal/headers on the typed API**: \`ApiRequestOptions\\` in \`src/lib/api/infrastructure.ts\` has only \`{ method, body, params, schema }\`. \`rawApiRequest\` adds \`headers?\`; neither exposes \`timeout\`/\`signal\`. Call sites pass \`(url, init: RequestInit)\` to authenticated fetch — never a \`Request\` (only @sentry/api SDK does). \`apiRequestToRegion\` auto-sets JSON Content-Type and \`JSON.stringify\`s body; \`rawApiRequest\` preserves string bodies, only sets JSON Content-Type when body is object and caller didn't provide one. 204/205 throw \`ApiError\` rather than crashing on \`response.json()\` — bulk-mutate callers must catch. + +* **Consola chosen as CLI logger with Sentry createConsolaReporter integration**: Consola is the CLI logger with Sentry \`createConsolaReporter\` integration. Two reporters: FancyReporter (stderr) + Sentry structured logs. Level via \`SENTRY\_LOG\_LEVEL\`. \`buildCommand\` injects hidden \`--log-level\`/\`--verbose\` flags. \`withTag()\` creates independent instances; \`setLogLevel()\` propagates via registry. All user-facing output must use consola, not raw stderr. \`HandlerContext\` intentionally omits stderr. - -* **Completion fast-path skips Sentry SDK via SENTRY\_CLI\_NO\_TELEMETRY and SQLite telemetry queue**: Shell completions (\`\_\_complete\`) set \`SENTRY\_CLI\_NO\_TELEMETRY=1\` in \`bin.ts\` before any imports, skipping \`createTracedDatabase\` and avoiding \`@sentry/node-core/light\` load (~85ms). Completion timing queued to \`completion\_telemetry\_queue\` SQLite table (~1ms); normal runs drain via \`DELETE FROM ... RETURNING\` and emit as \`Sentry.metrics.distribution\`. Achieves ~60ms dev / ~140ms CI within 200ms e2e budget. + +* **DSN cache invalidation uses two-level mtime tracking (sourceMtimes + dirMtimes)**: DSN cache invalidation — two-level mtime tracking: \`sourceMtimes\` (DSN-bearing files, catches in-place edits) + \`dirMtimes\` (every walked dir, catches new files) + root mtime fast-path + 24h TTL. Dropping either map is a correctness regression. Walker emits mtimes via \`onDirectoryVisit\` hook + \`recordMtimes\` option; DSN scanner uses \`grepFiles({pattern: DSN\_PATTERN, recordMtimes: true, onDirectoryVisit})\` (~20% faster than walkFiles). \`scanCodeForFirstDsn\` stays on direct walker loop (worker init ~20ms dominates single-DSN). Invariants: \`processMatch\` must record mtime for EVERY file with host-validated DSN via \`fileHadValidDsn\` flag independent of \`seen.has(raw)\`. \`scanDirectory\` catch MUST return empty \`dirMtimes: {}\`, NOT partial map (would silently bless unvisited dirs); \`ConfigError\` re-throws. - -* **Fuzzy recovery auto-resolves dash/underscore slug mismatches without original-slug fallback**: Display-name project input (contains spaces) skips slug lookup, goes to name-based fuzzy search across four resolution sites: \`resolveProjectBySlug\`, \`resolveOrgProjectTarget\` (project-search), \`org-list.ts#handleProjectSearch\`, \`project/list.ts#handleProjectNotFound\`. \`parseOrgProjectArg\` detects spaces via \`looksLikeDisplayName()\` and sets \`originalSlug\` on \`project-search\`; sites check \`isDisplayName = originalSlug !== undefined\` and skip \`findProjectsBySlug\` (404s on URL-encoded spaces), going directly to \`triageProjectNotFound\` → \`findSimilarProjectsAcrossOrgs\`. \*\*Critical\*\*: recursive fuzzy recovery calls must NOT pass \`originalSlug\` — otherwise the recovered slug also skips lookup, causing infinite skip→empty→not-found loop. + +* **Grep worker pool: binary-transferable matches + streaming dispatch in src/lib/scan/**: Grep worker pool (\`src/lib/scan/worker-pool.ts\` + \`grep-worker.js\`): lazy singleton, size \`min(8, max(2, availableParallelism()))\`. Matches encoded as \`Uint32Array\` quads \`\[pathIdx, lineNum, lineOffset, lineLength]\` + \`linePool\` string, transferred via \`postMessage(msg, \[ints.buffer])\` (~40% faster than structuredClone). Worker imported via \`with { type: 'text' }\` → \`Blob\` + \`URL.createObjectURL\`; \`new Worker(new URL(...))\` HANGS in \`bun build --compile\` binaries. FIFO \`pending\` queue per worker — per-dispatch \`addEventListener\` causes wrong-request resolution. \`ref()\`/\`unref()\` idempotent booleans, NOT refcounted — only unref when \`inflight\` drops to 0; spawn unref'd. Disable via \`SENTRY\_SCAN\_DISABLE\_WORKERS=1\`. Track dispatched/failed batches with \`Promise.allSettled\`; throw if all failed so DSN cache doesn't persist false-negatives. - -* **Project cache is org-scoped with three key formats and three population paths**: \`project\_cache\` SQLite table uses three key shapes: \`{orgId}:{projectId}\` (DSN resolution), \`dsn:{publicKey}\` (DSN without orgId), \`list:{orgSlug}/{projectSlug}\` (batch from API). Helpers: \`getCachedProject\`, \`getCachedProjectByDsnKey\`, \`getCachedProjectsForOrg\` (completions), \`getCachedProjectBySlug\` (queries all three shapes for hot-path slug lookups; used by \`fetchProjectId\` to skip \`GET /projects/{org}/{project}/\`). Population paths: DSN resolution in resolve-target.ts, \`listProjects()\` batch via \`cacheProjectsForOrg\`, \`fetchProjectId\` seeds on API success. Resolution errors use live API via \`findSimilarProjectsAcrossOrgs\` — no cross-org cache search. + +* **Host-scoped token model: auth.host column + three-layer enforcement**: Host-scoped token model (PR #844): every token bound to issuing host via \`auth.host\` column (schema v16), lazy-migrated from boot-env. Trust established ONLY via \`sentry auth login --url\` or shell-exported \`SENTRY\_HOST\`/\`SENTRY\_URL\` at boot — \`.sentryclirc\` URL never a trust source (mtime-based freshness doesn't work: git clone resets, \`touch -t\` backdates). Three enforcement layers: (1) \`applySentryUrlContext\` throws on URL-arg mismatch; (2) \`applySentryCliRcEnvShim\` throws on rc-url mismatch (auth login/logout bypass via \`skipUrlTrustCheck\`); (3) fetch-layer \`isRequestOriginTrusted\`. Region trust: in-process Set in \`db/regions.ts\`, auto-synced by \`setOrgRegion(s)\`. \`clearTrustedHostState\` must NOT clear login anchor (breaks IAP re-auth). Login refusal scoped to \`--token\`. \`HostScopeError\` (\`src/lib/errors.ts\`) is canonical formatter with overloads \`(message)\` and \`(source, destinationUrl, tokenHost)\`; used by rc-shim, URL-arg, fetch bearer, sntrys\_ claim, OAuth refresh. E2E: pass \`--url ${ctx.serverUrl}\` to \`auth login --token\`; child \`SENTRY\_URL\` alone doesn't anchor. - -* **Sentry API: events require org+project, issues have legacy global endpoint**: Sentry API scoping/auth quirks: (1) Events require org+project (\`/projects/{org}/{project}/events/{id}/\`); issues use legacy global \`/api/0/issues/{id}/\`; traces need only org. Cross-project search via Discover \`/organizations/{org}/events/?query=id:{eventId}\`. (2) \`/users/me/\` returns 403 for OAuth tokens — use \`/auth/\` instead (all token types, control silo). \`getControlSiloUrl()\` routes; \`SentryUserSchema\` uses \`.passthrough()\` since \`/auth/\` only requires \`id\`. (3) Chunk upload endpoint returns camelCase (\`chunkSize\`, \`chunksPerRequest\`, \`maxRequestSize\`, \`hashAlgorithm\`); \`AssembleResponse\` also camelCase — exception to snake\_case convention. + +* **isSentrySaasUrl vs isSaaSTrustOrigin: two intentional SaaS checks**: \`src/lib/sentry-urls.ts\` exports two SaaS-detection helpers with intentional split: (1) \`isSentrySaasUrl(url)\` — hostname-only check (\`sentry.io\` or \`\*.sentry.io\`), accepts any protocol/port. Used for routing/UX: custom-headers warning, \`getSentryBaseUrl\`/\`isSelfHosted\`, region resolution skip, telemetry \`is\_self\_hosted\` tag. (2) \`isSaaSTrustOrigin(url)\` — stricter: additionally requires \`https:\` and default port. Used for security decisions: token-host trust comparison, sentryclirc URL trust check, URL-arg trust, login refusal. Rule: hostname-only for routing/UX (don't break users behind TLS-terminating proxies with \`http://sentry.io\`); strict for credential scoping. JSDoc on \`isSentrySaasUrl\` points callers to \`isSaaSTrustOrigin\` for security contexts. Keep both implementations in sync re: hostname matching. - -* **Sentry CLI authenticated fetch architecture with response caching**: Authenticated fetch (\`createAuthenticatedFetch\` in \`src/lib/sentry-client.ts\`): auth headers, 30s \`REQUEST\_TIMEOUT\_MS\`, retry max 2, 401 refresh, span tracing. Dual input: SDK \`Request\` vs \`(url, init)\`. \`buildAttemptFactory\` yields fresh \`(input, init)\` per attempt; \`Request\` clones; \`FormData\`/\`Blob\`/\`URLSearchParams\` pass through. Only bare \`ReadableStream\` needs materialization. Do NOT materialize FormData — strips multipart boundary. Internal aborts tagged \`INTERNAL\_TIMEOUT\_MARKER\` Symbol; last attempt throws \`TimeoutError\`. Per-endpoint \`ENDPOINT\_TIMEOUT\_OVERRIDES\` (e.g. \`/autofix/\` 120s). Response cache: \`http-cache-semantics\` RFC 7234 at \`~/.sentry/cache/responses/\`; GET 2xx only. On 4xx/5xx, \`apiRequestToRegion\` attaches allow-listed response headers to Sentry scope as \`api\_response\_headers\` context. Cache hit invisibility solved via module-level \`lastCacheHitAgeMs\` (set on hit, cleared per-call); \`src/lib/cache-hint.ts\` provides \`formatCacheHint()\`/\`appendCacheHint()\`, wired in \`buildCommand\` only when generator returns \`CommandReturn\` (bare \`return;\` paths skip). + +* **Magic @ selectors resolve issues dynamically via sort-based list API queries**: Magic @ selectors resolve issues dynamically: \`@latest\`, \`@most\_frequent\` in \`parseIssueArg\` detected before \`validateResourceId\` (@ not in forbidden charset). \`SELECTOR\_MAP\` provides case-insensitive matching. \`resolveSelector\` maps to \`IssueSort\` values, calls \`listIssuesPaginated\` with \`perPage: 1\`, \`query: 'is:unresolved'\`. Supports org-prefixed: \`sentry/@latest\`. Unrecognized \`@\`-prefixed strings fall through. \`ParsedIssueArg\` union includes \`{ type: 'selector' }\`. - -* **Sentry CLI resolve-target cascade has 5 priority levels with env var support**: resolve-target.ts cascade has 5 priority levels: (1) Explicit CLI flags, (2) SENTRY\_ORG/SENTRY\_PROJECT env vars, (3) SQLite config defaults, (4) DSN auto-detection, (5) Directory name inference. SENTRY\_PROJECT supports combo notation \`org/project\` — when used, SENTRY\_ORG is ignored. If combo parse fails (e.g. \`org/\`), the entire value is discarded. \`resolveFromEnvVars()\` helper is injected into all four resolution functions. + +* **safe-read.ts wraps isRegularFile + Bun.file().text() for FIFO-safe user-path reads**: \`src/lib/safe-read.ts\` \`safeReadFile(path, operation): Promise\\` combines \`isRegularFile()\` + \`Bun.file().text()\` + broad error swallow (FIFO/ENOENT/EACCES/EPERM/EISDIR/ENOTDIR). Sole caller: \`apply-patchset.ts\`. \*\*Do NOT use for committed config loads\*\* — swallows EPERM/EISDIR, making \`chmod 000 .sentryclirc\` manifest as confusing 'no auth token'. For loud permission surfacing (\`tryReadSentryCliRc\`), call \`fs.promises.stat\` directly, gate on \`isFile()\`, catch only ENOENT/EACCES. \`read-files.ts\`/\`workflow-inputs.ts\` use direct stat to reuse one stat for size-gating. Test with real \`mkfifo\` + short timeout as hang detector. -### Decision - - -* **Issue list global limit with fair per-project distribution and representation guarantees**: \`issue list --limit\` is a global total across all detected projects. \`fetchWithBudget\` Phase 1 divides evenly, Phase 2 redistributes surplus via cursor resume. \`trimWithProjectGuarantee\` ensures ≥1 issue per project before filling remaining slots. JSON output wraps in \`{ data, hasMore }\` with optional \`errors\` array. Compound cursor (pipe-separated) enables \`-c last\` for multi-target pagination, keyed by sorted target fingerprint. + +* **Sentry SDK uses @sentry/node-core/light instead of @sentry/bun to avoid OTel overhead**: Sentry SDK uses \`@sentry/node-core/light\` instead of \`@sentry/bun\` to avoid OpenTelemetry overhead (~150ms, 24MB). \`@sentry/core\` barrel patched via \`bun patch\` to remove ~32 unused exports. Gotcha: \`LightNodeClient\` hardcodes \`runtime: { name: 'node' }\` AFTER spreading options — fix by patching \`client.getOptions().runtime\` post-init (mutable ref). Transport uses Node \`http\` instead of native \`fetch\`. Upstream: getsentry/sentry-javascript#19885, #19886. - -* **Prefer dedicated SQLite tables + migrations over metadata KV for non-trivial caches**: Prefer dedicated SQLite tables + migrations over \`metadata\` KV for non-trivial caches. Schema migrations are cheap — don't shoehorn structured caches into \`metadata\` with dotted-prefix keys. Dedicated tables give clearer schema, proper indexes, simpler bulk-clear, no prefix collisions. \`metadata\` KV is fine for small scalars (defaults.\*, install.\*). Example: \`issue\_org\_cache\` (schema v15) replaced \`metadata\` keys \`issue\_org.{numericId}\`. Migration pattern: bump \`CURRENT\_SCHEMA\_VERSION\`, add \`EXPECTED\_TABLES.foo\`, add \`if (currentVersion < N) db.exec(EXPECTED\_TABLES.foo)\`. HTTP response cache (URL+headers, short TTLs) can't answer structural questions like 'which org owns issue 123?' — use dedicated tables for structural/mapping questions, HTTP cache for content. + +* **Sentry token formats: only sntrys\_ embeds host claim, and it's unsigned**: Sentry token formats (verified in getsentry/sentry \`orgauthtoken\_token.py\`): \`sntryu\_\\` (user auth) — no claims; \`sntrys\_\\_\\` (org auth) — \*\*unsigned\*\*, plaintext base64, anyone can forge; \`sntrya\_\`/\`sntryi\_\` — random hex; OAuth — random, no prefix. \`sntrys\_\` payload is a UX hint, NOT verifiable; \`auth.host\` column \[\[019dc168-adb2-7bed-900e-cab5d3716099]] is strictly stronger. \`parseSntrysClaim\` in \`src/lib/token-claims.ts\` requires exactly 2 underscores, base64-decodes, requires \`iat\`, 2 KB cap, fail-open. Two consumers: (1) \`captureEnvTokenHost\` claim-first for \`sntrys\_\`: claim url > \`SENTRY\_HOST\`/\`SENTRY\_URL\` > \`DEFAULT\_SENTRY\_URL\` (defends against layered-CI \`$GITHUB\_ENV\` poisoning); for \`sntryu\_\`/OAuth, env wins (no \`SENTRY\_BOUND\_TOKEN\` protocol — narrow protection, broad UX cost). (2) \`prepareHeaders\` defense-in-depth — refuses bearer attach if request origin doesn't match claim url. - -* **Top-level --help stays terse Stricli output, not branded help**: \`sentry --help\` and \`sentry -h\` MUST render Stricli's terse default template, NOT the branded help (Flags + Environment Variables sections). Agents parse \`--help\` output and branding wastes tokens. Branded help is reserved for human discovery paths: \`sentry\` (no-args, via \`defaultCommand: "help"\`) and \`sentry help\`. Do NOT add interception logic in \`src/cli.ts\` to rewrite \`--help\` → \`help\`. TTY/agent detection is not worth the complexity — agents have skills documentation; humans get the footer hint pointing to \`sentry help\`. Subcommand help (e.g. \`sentry issue --help\`) is also left to Stricli for command-specific flag rendering. - -### Gotcha + +* **Telemetry opt-out is env-var-only — no persistent preference or DO\_NOT\_TRACK**: Telemetry opt-out priority: (1) \`SENTRY\_CLI\_NO\_TELEMETRY=1\`, (2) \`DO\_NOT\_TRACK=1\`, (3) \`metadata.defaults.telemetry\`, (4) default on. DB read try/catch wrapped (runs before DB init). Schema v13 merged \`defaults\` table into \`metadata\` KV with keys \`defaults.{org,project,telemetry,url}\`; getters/setters in \`src/lib/db/defaults.ts\`. \`sentry cli defaults\` uses variadic \`\[key, value?]\`: no args → show all; 1 arg → show key; 2 args → set; \`--clear\` without args → clear all (guarded); \`--clear key\` → clear specific. \`computeTelemetryEffective()\` returns resolved source for display. - -* **@sentry/api SDK can return non-array data for empty/edge responses**: \`@sentry/api\` SDK (in \`node\_modules/@sentry/api/dist/index.js\`) returns \`data = {}\` (not \`\[]\`) when response body is empty, has \`Content-Length: 0\`, or status 204; and returns a \`ReadableStream\` when \`Content-Type\` is missing. \`unwrapResult\` from \`src/lib/api/infrastructure.ts\` returns \`data\` as-is, and \`as unknown as SentryX\[]\` casts silently lie. Always guard array-typed SDK results with \`Array.isArray(data)\` before \`.map()\` — applied in \`listOrganizationsInRegion\` (CLI-1CQ). Self-hosted instances behind reverse proxies (nginx, Cloudflare, WAFs) commonly trigger this by stripping bodies or wrapping responses. Throw a descriptive \`ApiError\` on mismatch rather than letting \`TypeError: x.map is not a function\` bubble up minified. + +* **Zod schema on OutputConfig enables self-documenting JSON fields in help and SKILL.md**: Zod schema on OutputConfig enables self-documenting JSON fields: List commands register \`schema?: ZodType\` on \`OutputConfig\\`. \`extractSchemaFields()\` produces \`SchemaFieldInfo\[]\` from Zod shapes. \`buildFieldsFlag()\` enriches \`--fields\` brief; \`enrichDocsWithSchema()\` appends fields to \`fullDescription\`. Schema exposed as \`\_\_jsonSchema\` on built commands — \`introspect.ts\` reads it into \`CommandInfo.jsonFields\`, \`help.ts\` and \`generate-skill.ts\` render it. For \`buildOrgListCommand\`/\`dispatchOrgScopedList\`, pass \`schema\` via \`OrgListConfig\`. - -* **Bun bytecode: true crashes esbuild→compile ESM bundles (Bun 1.3.11)**: Bun build flags for compiled CLI (\`script/build.ts\`): (1) Do NOT enable \`bytecode: true\` with esbuild→\`Bun.build({ compile })\` pipeline. Still broken on Bun 1.3.13 — crashes \`TypeError: Expected CommonJS module to have a function wrapper\` at entry.instantiate (esbuild emits ESM; bytecode loader mis-caches as CJS). Exit 0, no output. Upstream: oven-sh/bun#21097, #23490. (2) Pass \`autoloadDotenv: false\` and \`autoloadBunfig: false\` — otherwise user's \`.env\`/\`bunfig.toml\` silently injects into \`process.env\` (e.g. Next.js \`.env.local\` could override stored OAuth token). Shell env vars still work; suggest direnv for dir-scoped vars. +### Decision - -* **dist/bin.cjs runtime Node version check must match engines.node**: \`engines.node >=22.12\` matches Astro 6 floor. CI builds matrix \`\["22","24"]\`; docs jobs pin \`actions/setup-node@v6\` with \`node-version: "24"\` after \`setup-bun\`. The npm package's \`dist/bin.cjs\` (from \`script/bundle.ts\`) contains an inline Node guard that MUST match \`engines.node\`. Simple \`parseInt(process.versions.node) < 22\` misses 22.0.0–22.11.x — use explicit major+minor: \`let v=process.versions.node.split('.').map(Number);if(v\[0]<22||(v\[0]===22&\&v\[1]<12)){...}\`. When bumping, update BIN\_WRAPPER string AND error message in lockstep. Without \`engine-strict=true\`, npm only warns — the runtime guard is real enforcement. + +* **All view subcommands should use \ \ positional pattern**: All \`\* view\` subcommands use \`\ \\` positional pattern (Intent-First Correction UX): target is optional \`org/project\`. Use opportunistic arg swapping with \`log.warn()\` when args are wrong order — when intent is unambiguous, do what they meant. Normalize at command level, keep parsers pure. Model after \`gh\` CLI. Exception: \`auth\` uses \`defaultCommand: "status"\` (no viewable entity). Routes without defaults: \`cli\`, \`sourcemap\`, \`repo\`, \`team\`, \`trial\`, \`release\`, \`dashboard/widget\`. - -* **Making clearAuth() async breaks model-based tests — use non-async Promise\ return instead**: Making \`clearAuth()\` \`async\` breaks fast-check model-based tests — real async yields during \`asyncModelRun\` cause \`createIsolatedDbContext\` cleanup to interleave. Keep non-async; return \`clearResponseCache().catch(...)\` directly. Model-based tests also need explicit timeouts (e.g., \`30\_000\`) — Bun's default 5s causes false failures during shrinking. + +* **Sentry-derived terminal color palette tuned for dual-background contrast**: Terminal color palette tuned for dual-background contrast: 10-color chart palette derived from Sentry's categorical hues (\`static/app/utils/theme/scraps/tokens/color.tsx\`), adjusted to mid-luminance for ≥3:1 contrast on both dark and light backgrounds. Adjustments: orange #FF9838→#C06F20, green #67C800→#3D8F09, yellow #FFD00E→#9E8B18, purple #5D3EB2→#8B6AC8, indigo #50219C→#7B50D0; blurple/pink/magenta unchanged; teal #228A83 added. Hex preferred over ANSI 16-color for guaranteed contrast. - -* **script/generate-api-schema.ts regex is brittle against SDK bundler output changes**: \`script/generate-api-schema.ts\` parses \`node\_modules/@sentry/api/dist/index.js\` with a regex (\`/var (\w+) = \\(options\S\*\\) => \\(options\S\*client \\?\\? client\\)\\.(\w+)\\(/g\`) to map SDK function names to URL+method pairs, producing \`src/generated/api-schema.json\`. If the SDK changes its generator's bundle format (e.g., switches to \`const\`, arrow vs function, different client-selection pattern), schema generation silently produces empty \`fn\` fields. When bumping \`@sentry/api\`, verify \`sentry schema\` output still populates function names. \`src/generated/api-schema.json\` is gitignored — regenerates on every dev/build/typecheck via \`bun run generate:schema\`. +### Gotcha - -* **Source Map v3 spec allows null entries in sources array**: The Source Map v3 spec allows \`null\` entries in the \`sources\` array, and bundlers like esbuild actually produce them. Any code iterating over \`sources\` and calling string methods (e.g., \`.replaceAll()\`) must guard against null: \`map.sources.map((s) => typeof s === "string" ? s.replaceAll("\\\\", "/") : s)\`. Without the guard, \`null.replaceAll()\` throws \`TypeError\`. This applies to \`src/lib/sourcemap/debug-id.ts\` and any future sourcemap manipulation code. + +* **AuthError constructor takes reason first, message second**: \`AuthError(reason: AuthErrorReason, message?: string)\` where \`AuthErrorReason\` is \`"not\_authenticated" | "expired" | "invalid"\`. Easy to accidentally swap args as \`new AuthError("Token expired", "expired")\` — the string \`"Token expired"\` gets assigned as \`reason\` (invalid enum value). Tests aren't type-checked (tsconfig excludes them), so TypeScript won't catch this. Correct: \`new AuthError("expired", "Token expired")\`. Default messages exist for each reason, so the second arg is often unnecessary. - -* **Starlight 0.33+ route data moved from Astro.props to Astro.locals.starlightRoute**: Starlight 0.33+ / Astro 6 docs migration: (1) Route data moved from \`Astro.props\` to \`Astro.locals.starlightRoute\` — old \`Astro.props.sidebar\` is \`undefined\`. Field rename: \`slug\` → \`id\`. Import types via \`@astrojs/starlight/route-data\`. Built-in children (SiteTitle, Search, SocialIcons) take no props. \`starlight.social\` is array-form. (2) Content collections must migrate to Content Layer API: rename \`src/content/config.ts\` → \`src/content.config.ts\`, use \`docsLoader()\` + \`docsSchema()\`. Landing-page detection: \`id === ""\` (\`normalizeIndexSlug\` maps \`"index"\` → \`""\`). + +* **Biome noMisplacedAssertion fires on test-helper functions; use inline biome-ignore**: Biome's \`lint/suspicious/noMisplacedAssertion\` rule flags \`expect()\` calls outside \`test()\`/\`it()\` bodies, including in named helper functions used by multiple tests (e.g. \`expectTokenStored(spy, token)\`). File-level \`biome-ignore-all\` doesn't suppress this rule — must use individual \`// biome-ignore lint/suspicious/noMisplacedAssertion: \\` directly above each \`expect()\` line in the helper. Tests aren't type-checked but they ARE lint-checked, so this catches code that passes \`bun test\` but fails \`bun run lint\`. -### Pattern + +* **GET response cache bypasses fetch wrapper across tests**: \`sentry-client.ts::createAuthenticatedFetch\` checks the response cache BEFORE calling fetch for GET requests. Tests that mock \`globalThis.fetch\` and assert call counts will see 0 calls if a prior test cached the same URL — the cached response is served without invoking the wrapper. Fix in test \`beforeEach\`: \`import('./response-cache.js')\` then call \`resetCacheState()\` + \`disableResponseCache()\`. Pair with \`resetAuthenticatedFetch()\` if cached fetch instance is also stale. Symptom: \`expect(fetchCalls).toHaveLength(1)\` fails with \`Received length: 0\` only when run after another test hitting the same URL; passes in isolation. - -* **Bun global installs use .bun path segment for detection**: Bun global installs place scripts under \`~/.bun/install/global/node\_modules/\`. In \`detectPackageManagerFromPath()\`, check \`segments.includes('.bun')\` before npm fallback. Order: \`.pnpm\` → pnpm, \`.bun\` → bun, other \`node\_modules\` → npm. Yarn classic shares npm layout so falls through — acceptable because path detection is \*\*fallback\*\* after subprocess calls (which identify yarn correctly). Path detection must NOT override stored DB info, only serve as fallback when subprocess fails (e.g., Windows \`.cmd\` ENOENT). + +* **Node polyfill in script/node-polyfills.ts lacks Bun.file().stat() — use node:fs/promises stat instead**: \`script/node-polyfills.ts\` shims Bun APIs for npm (Node) distribution but is INCOMPLETE — \`Bun.file(path)\` only has \`size\`, \`lastModified\`, \`exists()\`, \`text()\`, \`json()\`, \`stat()\`; NOT \`.arrayBuffer()\`, \`.stream()\`, etc. Also no \`Bun.$\` shim. Tests run under Bun natively and never exercise the polyfill, so missing shims ship undetected (CLI-1EA/1EB: \`Bun.file().stat()\` regression, 400+ events). Prefer \`node:fs/promises\` directly for file ops; \`execSync\` from \`node:child\_process\` for shell. When extending polyfill, alias Node functions via \`bind\` not wrapper closures. Mirror polyfill tests to \`test/lib/\` — \`test:unit\` globs are narrow (\`test/lib test/commands test/types\`); tests under \`test/fixtures/\`, \`test/scripts/\`, \`test/script/\` are NOT picked up by CI. - -* **Evict-then-read pattern: return cacheEvicted flag from helpers that clear cache on 404**: When a helper function transparently evicts a stale cache entry on 404 and falls back to an unscoped call, callers holding the now-stale cached value will let it win \`??\` chains. Fix: helper must return \`{ result, cacheEvicted }\` so callers compute \`effectiveCachedValue = cacheEvicted ? null : cachedValue\` before the \`??\` fallback, and re-cache the freshly-derived value. Applied in \`fetchIssueByNumericId\` in \`src/commands/issue/utils.ts\` — both \`resolveNumericIssue\` and \`resolveShareIssue\` consume the flag. A local cached variable outliving its DB entry is the common shape of this bug; always audit post-eviction read paths. + +* **process.stdin.isTTY unreliable in Bun — use isatty(0) and backfill for clack**: \`process.stdin.isTTY\` unreliable in Bun — use \`isatty(0)\` from \`node:tty\`. Bun's single-file binary can leave \`process.stdin.isTTY === undefined\` on TTY fds inherited via redirects like \`exec … \ -* **Non-essential DB cache writes should be guarded with try-catch**: Non-essential DB cache writes (e.g., \`setUserInfo()\`, \`setInstallInfo()\`) must be wrapped in try-catch so a broken/read-only DB doesn't crash a command whose primary operation succeeded. Pattern: \`try { setInstallInfo(...) } catch { log.debug(...) }\`. In login.ts, \`getCurrentUser()\` failure after token save must not block auth — log warning, continue. In upgrade.ts, \`setInstallInfo\` after legacy detection is guarded same way. Exception: \`getUserRegions()\` failure should \`clearAuth()\` and fail hard (indicates invalid token). This is enforced by BugBot reviews — any \`setInstallInfo\`/\`setUserInfo\` call outside setup.ts's \`bestEffort()\` wrapper needs its own try-catch. + +* **runInteractiveLogin swallows errors and sets process.exitCode = 1**: \`runInteractiveLogin\` in \`src/lib/interactive-login.ts\` catches OAuth flow errors internally (device-code fetch failures, timeout, etc.) and returns falsy on failure. The login command then sets \`process.exitCode = 1\` and returns normally — the wrapped command function resolves, NOT rejects. Tests that mock fetch to throw and expect \`rejects.toThrow()\` will fail with \`resolved: Promise { \ }\`. Assert behavior via fetch-call inspection (\`fetchCalls.length > 0\`, header content) instead. \`requestDeviceCode\` requires \`SENTRY\_CLIENT\_ID\` env var — unset in tests means it throws \`ConfigError\` before any fetch fires. - -* **Sentry CLI command docs are auto-generated from Stricli route tree with CI freshness check**: Sentry CLI command docs are auto-generated from Stricli route tree: Docs in \`docs/src/content/docs/commands/\*.md\` and skill files in \`plugins/sentry-cli/skills/sentry-cli/references/\*.md\` are generated via \`bun run generate:docs\`. Content between \`\\` markers is regenerated; hand-written examples go in \`docs/src/fragments/commands/\`. CI checks \`check:command-docs\` and \`check:skill\` fail if stale. Run generators after changing command parameters/flags/docs. + +* **Stricli rejects unknown flags — pre-parsed global flags must be consumed from argv**: Stricli flag parsing traps: (1) Unknown \`--flag\`s rejected — global flags parsed in \`bin.ts\` MUST be spliced from argv (check both \`--flag value\` and \`--flag=value\`). (2) \`FLAG\_NAME\_PATTERN\` requires 2+ chars after \`--\`; single-char flags like \`--x\` silently become positionals — use aliases (\`-x\` → longer name). Bit \`dashboard widget --x\`/\`--y\`. (3) \`FlagDef.hidden\` is propagated by \`extractFlags\` so \`generateCommandDoc\` filters hidden flags alongside \`help\`/\`helpAll\`; hidden \`--log-level\`/\`--verbose\` appear only in global options docs. - -* **Stricli buildCommand output config injects json flag into func params**: Stricli command gotchas: (1) In \`func()\` handlers use \`this.stdout\`/\`this.stderr\` directly — NOT \`this.process.stdout\`. \`SentryContext\` has \`process\` and \`stdout\`/\`stderr\` as separate top-level properties; test mocks omit full \`process\` so \`this.process.stdout\` throws \`TypeError\` at runtime (TS doesn't catch). (2) \`output: { json: true, human: formatFn }\` auto-injects \`--json\`/\`--fields\` flags — type flags explicitly (\`flags: { json?: boolean }\`). Commands with interactive side effects (prompts, QR codes) should check \`flags.json\` and skip. (3) Route maps with \`defaultCommand\` blend the default command's flags into subcommand completions — completion tests must track \`hasDefaultCommand\` and skip strict subcommand-matching. + +* **Whole-buffer matchAll slower than split+test when aggregated over many files**: Grep/scan traps in \`src/lib/scan/\`: (1) Whole-buffer \`regex.exec\` 12× faster per-file but ~1.6× SLOWER over 10k files — early-exit at \`maxResults\` via \`mapFilesConcurrent.onResult\` wins. (2) Literal prefilter is FILE-LEVEL gate (\`indexOf\`→skip); per-line verify breaks cross-newline patterns and Unicode length-changing \`toLowerCase\` (Turkish \`İ\`→\`i̇\`). (3) Extractor \`hasTopLevelAlternation\`+\`skipGroup\` must call \`skipCharacterClass\` (PCRE \`\[]abc]\` ≠ JS empty class). (4) Wake-latch race: naive \`let notify=null; await new Promise(r=>notify=r)\` loses signals — use latched \`pendingWake\` flag. (5) \`mapFilesConcurrent\` filters \`null\` but NOT \`\[]\` — return \`null\` for no-op files. (6) \`collectGlob\`/\`collectGrep\` must NOT forward \`maxResults\` to iterator; drain uncapped, set \`truncated=true\`. - -* **Token-type classification via literal prefix match (classifySentryToken)**: Token-type classification via literal prefix match: \`src/lib/token-type.ts\` \`classifySentryToken(token)\` returns \`'org-auth-token'\` (\`sntrys\_\` prefix), \`'user-auth-token'\` (\`sntryu\_\` prefix), or \`'oauth-or-legacy'\`. Case-sensitive \`startsWith\` matches Sentry backend's \`SENTRY\_ORG\_AUTH\_TOKEN\_PREFIX\`. Use to short-circuit commands where a token type is semantically invalid (e.g. \`whoami\` with org token — \`/auth/\` rejects \`sntrys\_\`) before a confusing API failure. \`getAuthToken()\` from \`db/auth\` returns the effective token (env + DB fallback). +### Pattern -### Preference + +* **Test helpers for host-scoping security tests**: Test helpers for host-scoping security tests: \`test/helpers.ts\` provides shared utilities. \`useEnvSandbox(keys)\` registers beforeEach/afterEach to save+clear+restore env keys (do NOT use in tests that depend on preload's \`SENTRY\_AUTH\_TOKEN\`, e.g. \`sentryclirc-url-poison.test.ts\` calls \`getActiveTokenHost()\` which needs a token). \`resetHostScopingState()\` bundles \`resetEnvTokenHostForTesting\` + \`resetLoginTrustAnchorForTesting\` + \`resetTrustedRegionUrlsForTesting\` (always reset together). \`mintSntrysToken(payload)\` produces \`sntrys\_\\_\\` test tokens matching server format (rstrip \`=\`). \`extractFetchUrl(input)\` for fetch-mock assertions. \`useTestConfigDir\` \[\[019dc573-d853-735a-aeb5-68ff49afe037]] handles config-dir isolation separately. - -* **PR workflow: address review comments, resolve threads, wait for CI**: PR workflow: (1) wait for CI; (2) check unresolved comments via \`gh api repos/.../pulls/N/comments\`; (3) fix in follow-up commits (NEVER amend a pushed commit without explicit user request + force push); (4) reply explaining fix; (5) resolve thread via \`gh api graphql resolveReviewThread\`; (6) push + re-check CI. BugBot/Seer/Warden/Cursor post new comments per-commit and often catch bugs in fix commits — re-check after each push. Dispatch a subagent review before declaring merge-ready. Branches: \`fix/\*\` or \`feat/\*\`. Style: \`Array.from(set)\` over spreads; 'allowlist' not 'whitelist'; \`arr.at(-1)\` over index math. Reviewer questions may be inquiries — confirm intent before changing. After reverts/changes affecting PR scope, update the PR description to match. + +* **Tests calling setAuthToken must pass {host} matching the mock URL**: Host-scoping test gotchas \[\[019dc168-adb2-7bed-900e-cab5d3716099]]: (1) Tests mocking \`fetch\` with non-SaaS URLs and calling \`setAuthToken(token, ttl)\` without \`{host}\` fail with \`HostScopeError\` — token defaults to SaaS via \`captureEnvTokenHost\`. Fix: \`setAuthToken("fake", 3600, { host: "https://sentry.example.com" })\`. SaaS URLs work via equivalence. (2) For \`assertRcUrlTrusted\` tests, env-token-host snapshot must lock BEFORE rc shim mutates env: sequence is \`resetEnvTokenHostForTesting()\` → delete \`SENTRY\_HOST\`/\`SENTRY\_URL\` → \`captureEnvTokenHost()\` → \`applySentryCliRcEnvShim(testDir)\` → \`assertRcUrlTrusted(testDir)\`. Without explicit capture, lazy auto-capture reads poisoned \`SENTRY\_URL\`. (3) E2E fixture \`createE2EContext\` parent must \`setAuthToken(token, ttl, {host: serverUrl})\` matching child's \`SENTRY\_URL\`; multi-region tests need \`registerTrustedRegionUrls\` during \`listOrganizationsUncached\` before fan-out (regional mocks on different localhost ports, no SaaS equivalence). Symptom: \`HostScopeError: Refusing to send credentials\`. diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 13c240043..69aa8683f 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -325,7 +325,7 @@ Manage Sentry issues View and list Sentry events -- `sentry event view ` — View details of a specific event +- `sentry event view ` — View details of one or more events - `sentry event list ` — List events for an issue → Full flags and examples: `references/event.md` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/event.md b/plugins/sentry-cli/skills/sentry-cli/references/event.md index 9cf1fa902..705dbf667 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/event.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/event.md @@ -13,7 +13,7 @@ View and list Sentry events ### `sentry event view ` -View details of a specific event +View details of one or more events **Flags:** - `-w, --web - Open in browser` diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index 852d1d504..21c46488a 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -4,12 +4,14 @@ * View detailed information about a Sentry event. */ +import pLimit from "p-limit"; import type { SentryContext } from "../../context.js"; import { findEventAcrossOrgs, getEvent, getIssueByShortId, getLatestEvent, + ORG_FANOUT_CONCURRENCY, type ResolvedEvent, resolveEventInOrg, } from "../../lib/api-client.js"; @@ -20,6 +22,7 @@ import { parseOrgProjectArg, parseSlashSeparatedArg, spansFlag, + splitNewlineArg, } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; @@ -67,26 +70,43 @@ type ViewFlags = { readonly fields?: string[]; }; -/** Return type for event view — includes all data both renderers need */ -type EventViewData = { +/** Return type for a single event — includes all data both renderers need */ +type SingleEventViewData = { event: SentryEvent; trace: { traceId: string; spans: unknown[] } | null; /** Pre-formatted span tree lines for human output (not serialized) */ spanTreeLines?: string[]; }; +/** + * Output type for event view — supports both single and multi-event. + * Multi-event output occurs when agents paste newline-separated IDs. + */ +type EventViewData = { + events: SingleEventViewData[]; + /** Number of events originally requested (before partial failures) */ + requestedCount: number; +}; + /** * Format event view data for human-readable terminal output. * - * Renders event details and optional span tree. + * Renders event details and optional span tree. Multiple events + * are separated by horizontal rules. */ -function formatEventView(data: EventViewData): string { +export function formatEventView(data: EventViewData): string { const parts: string[] = []; - parts.push(formatEventDetails(data.event, `Event ${data.event.eventID}`)); + for (const entry of data.events) { + if (parts.length > 0) { + parts.push("\n---\n"); + } + + parts.push(formatEventDetails(entry.event, `Event ${entry.event.eventID}`)); - if (data.spanTreeLines && data.spanTreeLines.length > 0) { - parts.push(data.spanTreeLines.join("\n")); + if (entry.spanTreeLines && entry.spanTreeLines.length > 0) { + parts.push(entry.spanTreeLines.join("\n")); + } } return parts.join("\n"); @@ -95,27 +115,53 @@ function formatEventView(data: EventViewData): string { /** * Transform event view data for JSON output. * - * Flattens the event as the primary object so that `--fields eventID,title` - * works directly on event properties. The `trace` enrichment data is - * attached as a nested key, accessible via `--fields trace.traceId`. + * For single-event output, flattens the event as the primary object so that + * `--fields eventID,title` works directly on event properties. The `trace` + * enrichment data is attached as a nested key. * - * Without this transform, `--fields eventID` would return `{}` because - * the raw yield shape is `{ event, trace }` and `eventID` lives inside `event`. + * For multi-event output, returns an array of flattened event objects. + * This preserves backward compatibility: single-event callers still get + * a flat object, while multi-event callers get an array. */ -function jsonTransformEventView( +export function jsonTransformEventView( data: EventViewData, fields?: string[] ): unknown { - const { event, trace } = data; - const result: Record = { ...event, trace }; - if (fields && fields.length > 0) { - return filterFields(result, fields); + const transform = (entry: SingleEventViewData): Record => { + const result: Record = { + ...entry.event, + trace: entry.trace, + }; + if (fields && fields.length > 0) { + return filterFields(result, fields) as Record; + } + return result; + }; + + // Use requestedCount (not events.length) to decide the shape so that + // partial failures don't non-deterministically switch from array to object. + if (data.requestedCount <= 1) { + const [first] = data.events; + if (first) { + return transform(first); + } } - return result; + return data.events.map(transform); } /** Usage hint for ContextError messages */ const USAGE_HINT = "sentry event view / "; +/** + * Expand positional args by splitting each on newlines. + * + * When an agent pastes `"org/project/id1\nid2\nid3"` as a single arg, + * this produces `["org/project/id1", "id2", "id3"]` — the first retains + * the org/project prefix so `parsePositionalArgs` can extract the target. + */ +export function expandNewlineArgs(args: string[]): string[] { + return args.flatMap(splitNewlineArg); +} + /** * Sentinel eventId for "fetch the latest event for this issue." * Uses the @-prefix convention from {@link IssueSelector} magic selectors. @@ -195,6 +241,8 @@ type ParsedPositionalArgs = { issueShortId?: string; /** Warning message if arguments appear to be in the wrong order */ warning?: string; + /** Additional event IDs from newline-separated input or extra positional args */ + extraEventIds?: string[]; }; /** @@ -217,6 +265,7 @@ type ParsedPositionalArgs = { * * @returns Parsed event ID and optional target arg */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: positional arg parsing has many format branches by design export function parsePositionalArgs(args: string[]): ParsedPositionalArgs { if (args.length === 0) { throw new ContextError("Event ID", USAGE_HINT, []); @@ -257,6 +306,17 @@ export function parsePositionalArgs(args: string[]): ParsedPositionalArgs { return parseSingleArg(first); } + // When newline expansion splits "org/project/id1\nid2" into multiple args, + // the first arg is "org/project/id1" (2+ slashes). Route it through the + // single-arg path to correctly extract org/project vs id, then collect the + // remaining args as extra event IDs (CLI-1HT). + const slashCount = (first.match(/\//g) ?? []).length; + if (slashCount >= 2) { + const parsed = parseSingleArg(first); + const extraEventIds = args.slice(1); + return { ...parsed, extraEventIds }; + } + const second = args[1]; if (second === undefined) { // Should not happen given length check, but TypeScript needs this @@ -266,7 +326,13 @@ export function parsePositionalArgs(args: string[]): ParsedPositionalArgs { // Detect swapped args: user put ID first and target second const swapWarning = detectSwappedViewArgs(first, second); if (swapWarning) { - return { eventId: first, targetArg: second, warning: swapWarning }; + const extraEventIds = args.length > 2 ? args.slice(2) : undefined; + return { + eventId: first, + targetArg: second, + warning: swapWarning, + extraEventIds, + }; } // Detect issue short ID passed as first arg (e.g., "CAM-82X 95fd7f5a"). @@ -282,8 +348,10 @@ export function parsePositionalArgs(args: string[]): ParsedPositionalArgs { }; } - // Two or more args - first is target, second is event ID - return { eventId: second, targetArg: first }; + // Two or more args - first is target, second is event ID. + // Any additional args are extra event IDs (from newline-separated input). + const extraEventIds = args.length > 2 ? args.slice(2) : undefined; + return { eventId: second, targetArg: first, extraEventIds }; } /** @@ -468,11 +536,11 @@ export async function resolveAutoDetectTarget( * @param event - Already-fetched event * @param spans - Span tree depth (0 = skip) */ -async function buildEventViewData( +async function buildSingleEventViewData( org: string, event: SentryEvent, spans: number -): Promise { +): Promise { const spanTreeResult = spans > 0 ? await getSpanTreeLines(org, event, spans) : undefined; const trace = @@ -490,9 +558,9 @@ async function fetchLatestEventData( org: string, issueId: string, spans: number -): Promise { +): Promise { const event = await getLatestEvent(org, issueId); - return buildEventViewData(org, event, spans); + return buildSingleEventViewData(org, event, spans); } /** @@ -635,7 +703,7 @@ async function resolveIssueShortIdEvent( issueShortId: string, org: string, spans: number -): Promise { +): Promise { const issue = await getIssueByShortId(org, issueShortId); return fetchLatestEventData(org, issue.id, spans); } @@ -643,7 +711,7 @@ async function resolveIssueShortIdEvent( /** Result from an issue-based shortcut (URL or short ID) */ type IssueShortcutResult = { org: string; - data: EventViewData; + data: SingleEventViewData; hint: string; }; @@ -713,7 +781,7 @@ async function resolveIssueShortcut( ); } const event = await getEvent(resolved.org, issueProject, eventId); - const data = await buildEventViewData(resolved.org, event, spans); + const data = await buildSingleEventViewData(resolved.org, event, spans); return { org: resolved.org, data, @@ -739,15 +807,114 @@ async function resolveIssueShortcut( return null; } +/** + * Validate extra event IDs from newline-expanded agent input. + * + * Skips invalid IDs with an info log — agent-pasted lists may contain + * garbage (partial lines, headers, etc). + * + * @param extraIds - Raw extra event IDs to validate + * @param primaryId - Already-validated primary event ID + * @returns All valid event IDs (primary + validated extras) + */ +export function collectEventIds( + primaryId: string, + extraIds: string[] | undefined +): string[] { + const seen = new Set([primaryId]); + const allIds = [primaryId]; + if (!extraIds || extraIds.length === 0) { + return allIds; + } + const log = logger.withTag("event.view"); + for (const rawId of extraIds) { + try { + const validated = validateHexId(rawId, "Event ID"); + if (!seen.has(validated)) { + seen.add(validated); + allIds.push(validated); + } + } catch { + log.info(`Skipping invalid event ID: ${rawId}`); + } + } + return allIds; +} + +/** Options for fetching multiple events in parallel */ +type FetchMultipleOptions = { + /** Event IDs to fetch */ + eventIds: string[]; + /** Organization slug */ + org: string; + /** Project slug */ + project: string; + /** Pre-fetched event for the primary ID (from cross-project resolution) */ + prefetchedEvent: SentryEvent | null; + /** The primary event ID (may have a prefetched event) */ + primaryId: string; +}; + +/** + * Fetch multiple events with bounded concurrency, collecting successes + * and warning on failures. + * + * Uses {@link ORG_FANOUT_CONCURRENCY} (5) to avoid overwhelming the API + * when agents paste dozens of IDs. + * + * When all fetches fail, re-throws the error from the primary (first) event. + */ +export async function fetchMultipleEvents( + options: FetchMultipleOptions +): Promise { + const { eventIds, org, project, prefetchedEvent, primaryId } = options; + const log = logger.withTag("event.view"); + const limit = pLimit(ORG_FANOUT_CONCURRENCY); + + const results = await Promise.allSettled( + eventIds.map((id) => + limit(() => + fetchEventWithContext( + id === primaryId ? prefetchedEvent : null, + org, + project, + id + ) + ) + ) + ); + + const events: SentryEvent[] = []; + for (let i = 0; i < results.length; i++) { + const result = results[i]; + if (result?.status === "fulfilled") { + events.push(result.value); + } else if (result?.status === "rejected") { + log.warn(`Failed to fetch event ${eventIds[i]}: ${result.reason}`); + } + } + + if (events.length === 0) { + const firstResult = results[0]; + if (firstResult?.status === "rejected") { + throw firstResult.reason; + } + } + + return events; +} + export const viewCommand = buildCommand({ docs: { - brief: "View details of a specific event", + brief: "View details of one or more events", fullDescription: - "View detailed information about a Sentry event by its ID.\n\n" + + "View detailed information about Sentry events by their IDs.\n\n" + "Target specification:\n" + - " sentry event view # auto-detect from DSN or config\n" + - " sentry event view / # explicit org and project\n" + - " sentry event view # find project across all orgs", + " sentry event view # auto-detect from DSN or config\n" + + " sentry event view / [...] # explicit org and project\n" + + " sentry event view [...] # find project across all orgs\n\n" + + "Multiple event IDs can be passed as separate arguments or newline-separated\n" + + "within a single argument (handy when piping from other commands).", }, output: { human: formatEventView, @@ -759,7 +926,7 @@ export const viewCommand = buildCommand({ parameter: { placeholder: "org/project/event-id", brief: - "[/] - Target (optional) and event ID (required)", + "[/] [...] - Target (optional) and one or more event IDs", parse: String, }, }, @@ -780,12 +947,16 @@ export const viewCommand = buildCommand({ const log = logger.withTag("event.view"); + // Expand newline-separated args — agents paste multiple event IDs + // as a single newline-separated argument (CLI-1HT). + const expandedArgs = expandNewlineArgs(args); + // Parse positional args - const parsedArgs = parsePositionalArgs(args); + const parsedArgs = parsePositionalArgs(expandedArgs); if (parsedArgs.warning) { log.warn(parsedArgs.warning); } - const { targetArg, issueId, issueShortId } = parsedArgs; + const { targetArg, issueId, issueShortId, extraEventIds } = parsedArgs; let { eventId } = parsedArgs; const parsed = parseOrgProjectArg(targetArg); @@ -812,7 +983,10 @@ export const viewCommand = buildCommand({ ); return; } - yield new CommandOutput(issueShortcut.data); + yield new CommandOutput({ + events: [issueShortcut.data], + requestedCount: 1, + }); return { hint: issueShortcut.hint }; } @@ -836,22 +1010,38 @@ export const viewCommand = buildCommand({ } if (flags.web) { + if (extraEventIds && extraEventIds.length > 0) { + log.warn( + "--web only opens the first event; extra event IDs are ignored." + ); + } await openInBrowser(buildEventSearchUrl(target.org, eventId), "event"); return; } - // Use the pre-fetched event when cross-project resolution already fetched it, - // avoiding a redundant API call. - const event = await fetchEventWithContext( - target.prefetchedEvent ?? null, - target.org, - target.project, - eventId - ); + // Collect all event IDs (primary + validated extras from newline expansion) + const allEventIds = collectEventIds(eventId, extraEventIds); - const viewData = await buildEventViewData(target.org, event, flags.spans); + // Fetch all events in parallel, warning on individual failures + const fetchedEvents = await fetchMultipleEvents({ + eventIds: allEventIds, + org: target.org, + project: target.project, + prefetchedEvent: target.prefetchedEvent ?? null, + primaryId: eventId, + }); - yield new CommandOutput(viewData); + // Build view data for each event in parallel + const viewDataEntries = await Promise.all( + fetchedEvents.map((event) => + buildSingleEventViewData(target.org, event, flags.spans) + ) + ); + + yield new CommandOutput({ + events: viewDataEntries, + requestedCount: allEventIds.length, + }); return { hint: target.detectedFrom ? `Detected from ${target.detectedFrom}` diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index 373472d33..8d2e9d4d2 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -13,6 +13,7 @@ import { looksLikeIssueShortId, parseOrgProjectArg, parseSlashSeparatedArg, + splitNewlineArg, } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; @@ -57,21 +58,6 @@ type ViewFlags = { /** Usage hint for ContextError messages */ const USAGE_HINT = "sentry log view / [...]"; -/** - * Split a raw argument into individual log IDs. - * Handles newline-separated IDs within a single argument (common when - * piping or pasting from other tools). - * - * @param arg - Raw positional argument - * @returns Array of non-empty trimmed strings - */ -function splitLogIds(arg: string): string[] { - return arg - .split("\n") - .map((s) => s.trim()) - .filter((s) => s.length > 0); -} - /** * Parse positional arguments for log view. * Handles: @@ -115,7 +101,7 @@ export function parsePositionalArgs(args: string[]): { "Log ID", USAGE_HINT ); - const rawLogIds = splitLogIds(id); + const rawLogIds = splitNewlineArg(id); if (rawLogIds.length === 0) { throw new ContextError("Log ID", USAGE_HINT, []); } @@ -130,7 +116,7 @@ export function parsePositionalArgs(args: string[]): { // (first has no "/", second has "/"), but the user's intent is clearly // to view an issue, not to swap log-view arguments. if (looksLikeIssueShortId(first)) { - const rawLogIds = args.slice(1).flatMap(splitLogIds); + const rawLogIds = args.slice(1).flatMap(splitNewlineArg); if (rawLogIds.length === 0) { throw new ContextError("Log ID", USAGE_HINT, []); } @@ -151,14 +137,14 @@ export function parsePositionalArgs(args: string[]): { const swapWarning = detectSwappedViewArgs(first, second); if (swapWarning) { return { - rawLogIds: splitLogIds(first), + rawLogIds: splitNewlineArg(first), targetArg: second, suggestion: swapWarning, }; } } - const rawLogIds = args.slice(1).flatMap(splitLogIds); + const rawLogIds = args.slice(1).flatMap(splitNewlineArg); if (rawLogIds.length === 0) { throw new ContextError("Log ID", USAGE_HINT, []); } diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index df8d076f7..ab27fe4fd 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -1088,3 +1088,19 @@ export function buildProjectQuery( const pf = `project:${projectFilter}`; return query ? `${pf} ${query}` : pf; } + +/** + * Split a single argument on newlines into individual entries. + * + * Agents sometimes paste multiple IDs as a single newline-separated + * argument. This utility trims each line and discards empty ones. + * + * @param arg - Raw argument string, possibly containing newlines + * @returns Non-empty trimmed lines + */ +export function splitNewlineArg(arg: string): string[] { + return arg + .split("\n") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} diff --git a/test/commands/event/view.test.ts b/test/commands/event/view.test.ts index a89bc01cd..3b4bf9148 100644 --- a/test/commands/event/view.test.ts +++ b/test/commands/event/view.test.ts @@ -15,7 +15,12 @@ import { test, } from "bun:test"; import { + collectEventIds, + expandNewlineArgs, fetchEventWithContext, + fetchMultipleEvents, + formatEventView, + jsonTransformEventView, parsePositionalArgs, resolveAutoDetectTarget, resolveEventTarget, @@ -241,7 +246,7 @@ describe("parsePositionalArgs", () => { }); describe("edge cases", () => { - test("handles more than two args (ignores extras)", () => { + test("collects extra args as additional event IDs", () => { const result = parsePositionalArgs([ "my-org/frontend", "abc123", @@ -249,6 +254,7 @@ describe("parsePositionalArgs", () => { ]); expect(result.targetArg).toBe("my-org/frontend"); expect(result.eventId).toBe("abc123"); + expect(result.extraEventIds).toEqual(["extra-arg"]); }); test("handles empty string event ID in two-arg case", () => { @@ -258,6 +264,71 @@ describe("parsePositionalArgs", () => { }); }); + describe("newline-separated IDs (CLI-1HT)", () => { + test("expands newline-separated IDs from single structured arg", () => { + const multiLineArg = + "perzimo/perzimo-server/189945b37884462cb9134bd5cabeaa3d\n60c277e6c73f41c58ca46231b46dc0f8\n722e1158dfa147ec90ed831c4d096ae7"; + const expanded = expandNewlineArgs([multiLineArg]); + expect(expanded).toEqual([ + "perzimo/perzimo-server/189945b37884462cb9134bd5cabeaa3d", + "60c277e6c73f41c58ca46231b46dc0f8", + "722e1158dfa147ec90ed831c4d096ae7", + ]); + + // First arg has 2+ slashes → routed through single-arg path to correctly + // extract org/project and first event ID, remaining become extraEventIds. + const result = parsePositionalArgs(expanded); + expect(result.targetArg).toBe("perzimo/perzimo-server"); + expect(result.eventId).toBe("189945b37884462cb9134bd5cabeaa3d"); + expect(result.extraEventIds).toEqual([ + "60c277e6c73f41c58ca46231b46dc0f8", + "722e1158dfa147ec90ed831c4d096ae7", + ]); + }); + + test("single arg with newlines goes through single-arg path after expansion", () => { + // When there's only one line (no newlines), single-arg path works normally + const result = parsePositionalArgs([ + "perzimo/perzimo-server/189945b37884462cb9134bd5cabeaa3d", + ]); + expect(result.eventId).toBe("189945b37884462cb9134bd5cabeaa3d"); + expect(result.targetArg).toBe("perzimo/perzimo-server"); + expect(result.extraEventIds).toBeUndefined(); + }); + + test("first arg with 2+ slashes routes through single-arg path and collects extras", () => { + // Simulates expanded "org/project/id1\nid2\nid3" → 3 args + const result = parsePositionalArgs([ + "perzimo/perzimo-server/189945b37884462cb9134bd5cabeaa3d", + "60c277e6c73f41c58ca46231b46dc0f8", + "722e1158dfa147ec90ed831c4d096ae7", + ]); + expect(result.eventId).toBe("189945b37884462cb9134bd5cabeaa3d"); + expect(result.targetArg).toBe("perzimo/perzimo-server"); + expect(result.extraEventIds).toEqual([ + "60c277e6c73f41c58ca46231b46dc0f8", + "722e1158dfa147ec90ed831c4d096ae7", + ]); + }); + + test("collects multiple extra event IDs", () => { + const result = parsePositionalArgs([ + "my-org/frontend", + "abc123", + "def456", + "789abc", + ]); + expect(result.targetArg).toBe("my-org/frontend"); + expect(result.eventId).toBe("abc123"); + expect(result.extraEventIds).toEqual(["def456", "789abc"]); + }); + + test("no extra IDs when only two args", () => { + const result = parsePositionalArgs(["my-org/frontend", "abc123"]); + expect(result.extraEventIds).toBeUndefined(); + }); + }); + // URL integration tests — applySentryUrlContext may set SENTRY_HOST/SENTRY_URL as a side effect. // Host-scoping: self-hosted URLs now require the token to be scoped to the // same host. Tests seed SENTRY_HOST before parsing so env-token-host matches. @@ -1175,3 +1246,365 @@ describe("fetchEventWithContext", () => { expect(resolveEventSpy).not.toHaveBeenCalled(); }); }); + +// Note: splitNewlineArg is tested in test/lib/arg-parsing.test.ts + +// --------------------------------------------------------------------------- +// expandNewlineArgs +// --------------------------------------------------------------------------- + +describe("expandNewlineArgs", () => { + test("expands newline-separated args into flat array", () => { + expect(expandNewlineArgs(["org/proj/id1\nid2\nid3"])).toEqual([ + "org/proj/id1", + "id2", + "id3", + ]); + }); + + test("passes through args without newlines", () => { + expect(expandNewlineArgs(["org/proj", "eventid"])).toEqual([ + "org/proj", + "eventid", + ]); + }); + + test("handles mixed args with and without newlines", () => { + expect(expandNewlineArgs(["org/proj", "id1\nid2"])).toEqual([ + "org/proj", + "id1", + "id2", + ]); + }); + + test("handles empty array", () => { + expect(expandNewlineArgs([])).toEqual([]); + }); + + test("real Codex pattern: org/project/id with many newline-separated IDs", () => { + const codexArg = [ + "perzimo/perzimo-server/189945b37884462cb9134bd5cabeaa3d", + "60c277e6c73f41c58ca46231b46dc0f8", + "722e1158dfa147ec90ed831c4d096ae7", + ].join("\n"); + expect(expandNewlineArgs([codexArg])).toEqual([ + "perzimo/perzimo-server/189945b37884462cb9134bd5cabeaa3d", + "60c277e6c73f41c58ca46231b46dc0f8", + "722e1158dfa147ec90ed831c4d096ae7", + ]); + }); +}); + +// --------------------------------------------------------------------------- +// collectEventIds +// --------------------------------------------------------------------------- + +describe("collectEventIds", () => { + test("returns only primary ID when no extras", () => { + expect(collectEventIds("abc123", undefined)).toEqual(["abc123"]); + }); + + test("returns only primary ID when extras is empty", () => { + expect(collectEventIds("abc123", [])).toEqual(["abc123"]); + }); + + test("validates and collects valid extra hex IDs", () => { + const ids = collectEventIds("abc123", [ + "60c277e6c73f41c58ca46231b46dc0f8", + "722e1158dfa147ec90ed831c4d096ae7", + ]); + expect(ids).toEqual([ + "abc123", + "60c277e6c73f41c58ca46231b46dc0f8", + "722e1158dfa147ec90ed831c4d096ae7", + ]); + }); + + test("skips invalid extra IDs silently", () => { + const ids = collectEventIds("abc123", [ + "60c277e6c73f41c58ca46231b46dc0f8", + "not-a-hex-id", + "722e1158dfa147ec90ed831c4d096ae7", + ]); + expect(ids).toEqual([ + "abc123", + "60c277e6c73f41c58ca46231b46dc0f8", + "722e1158dfa147ec90ed831c4d096ae7", + ]); + }); + + test("skips all invalid extras", () => { + const ids = collectEventIds("abc123", ["bad1", "bad2"]); + expect(ids).toEqual(["abc123"]); + }); + + test("deduplicates event IDs", () => { + const ids = collectEventIds("60c277e6c73f41c58ca46231b46dc0f8", [ + "60c277e6c73f41c58ca46231b46dc0f8", // same as primary + "722e1158dfa147ec90ed831c4d096ae7", + "722e1158dfa147ec90ed831c4d096ae7", // duplicate extra + ]); + expect(ids).toEqual([ + "60c277e6c73f41c58ca46231b46dc0f8", + "722e1158dfa147ec90ed831c4d096ae7", + ]); + }); +}); + +// --------------------------------------------------------------------------- +// parsePositionalArgs: extraEventIds collection +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// formatEventView +// --------------------------------------------------------------------------- + +describe("formatEventView", () => { + const mockEvent = (id: string) => + ({ + eventID: id, + title: `Error ${id}`, + context: {}, + contexts: {}, + entries: [], + tags: [], + }) as unknown as import("../../../src/types/sentry.js").SentryEvent; + + test("renders single event", () => { + const result = formatEventView({ + events: [{ event: mockEvent("abc123"), trace: null }], + requestedCount: 1, + }); + expect(result).toContain("abc123"); + }); + + test("renders multiple events separated by horizontal rule", () => { + const result = formatEventView({ + events: [ + { event: mockEvent("event1"), trace: null }, + { event: mockEvent("event2"), trace: null }, + ], + requestedCount: 2, + }); + expect(result).toContain("event1"); + expect(result).toContain("---"); + expect(result).toContain("event2"); + }); + + test("includes span tree lines when present", () => { + const result = formatEventView({ + events: [ + { + event: mockEvent("abc123"), + trace: null, + spanTreeLines: [" span-1 (50ms)", " span-2 (20ms)"], + }, + ], + requestedCount: 1, + }); + expect(result).toContain("span-1 (50ms)"); + expect(result).toContain("span-2 (20ms)"); + }); +}); + +// --------------------------------------------------------------------------- +// jsonTransformEventView +// --------------------------------------------------------------------------- + +describe("jsonTransformEventView", () => { + const mockEvent = (id: string) => + ({ + eventID: id, + title: `Error ${id}`, + }) as unknown as import("../../../src/types/sentry.js").SentryEvent; + + test("returns flat object for single event", () => { + const result = jsonTransformEventView({ + events: [{ event: mockEvent("abc123"), trace: null }], + requestedCount: 1, + }); + expect(result).toEqual( + expect.objectContaining({ eventID: "abc123", trace: null }) + ); + // Should NOT be an array + expect(Array.isArray(result)).toBe(false); + }); + + test("returns array for multiple events", () => { + const result = jsonTransformEventView({ + events: [ + { event: mockEvent("event1"), trace: null }, + { event: mockEvent("event2"), trace: null }, + ], + requestedCount: 2, + }); + expect(Array.isArray(result)).toBe(true); + const arr = result as Record[]; + expect(arr).toHaveLength(2); + expect(arr[0]).toEqual(expect.objectContaining({ eventID: "event1" })); + expect(arr[1]).toEqual(expect.objectContaining({ eventID: "event2" })); + }); + + test("returns array when multiple requested but some failed", () => { + // Requested 3, only 1 succeeded — still array (CLI-1HT deterministic shape) + const result = jsonTransformEventView({ + events: [{ event: mockEvent("event1"), trace: null }], + requestedCount: 3, + }); + expect(Array.isArray(result)).toBe(true); + const arr = result as Record[]; + expect(arr).toHaveLength(1); + }); + + test("applies field filtering for single event", () => { + const result = jsonTransformEventView( + { + events: [{ event: mockEvent("abc123"), trace: null }], + requestedCount: 1, + }, + ["eventID"] + ); + expect(result).toEqual({ eventID: "abc123" }); + }); + + test("applies field filtering for multiple events", () => { + const result = jsonTransformEventView( + { + events: [ + { event: mockEvent("event1"), trace: null }, + { event: mockEvent("event2"), trace: null }, + ], + requestedCount: 2, + }, + ["eventID"] + ); + expect(result).toEqual([{ eventID: "event1" }, { eventID: "event2" }]); + }); +}); + +// --------------------------------------------------------------------------- +// fetchMultipleEvents +// --------------------------------------------------------------------------- + +describe("fetchMultipleEvents", () => { + const mockEvent = (id: string) => + ({ + eventID: id, + title: `Error ${id}`, + }) as unknown as import("../../../src/types/sentry.js").SentryEvent; + + test("fetches single event successfully", async () => { + const event = mockEvent("abc123"); + spyOn(apiClient, "getEvent").mockResolvedValue(event); + + const result = await fetchMultipleEvents({ + eventIds: ["abc123"], + org: "my-org", + project: "my-project", + prefetchedEvent: null, + primaryId: "abc123", + }); + expect(result).toEqual([event]); + }); + + test("uses prefetched event for primary ID", async () => { + const prefetched = mockEvent("abc123"); + + const result = await fetchMultipleEvents({ + eventIds: ["abc123"], + org: "my-org", + project: "my-project", + prefetchedEvent: prefetched, + primaryId: "abc123", + }); + expect(result).toEqual([prefetched]); + }); + + test("fetches multiple events in parallel", async () => { + const event1 = mockEvent("event1"); + const event2 = mockEvent("event2"); + spyOn(apiClient, "getEvent").mockImplementation( + (_org: string, _proj: string, id: string) => + Promise.resolve(id === "event1" ? event1 : event2) + ); + + const result = await fetchMultipleEvents({ + eventIds: ["event1", "event2"], + org: "my-org", + project: "my-project", + prefetchedEvent: null, + primaryId: "event1", + }); + expect(result).toHaveLength(2); + expect(result[0]?.eventID).toBe("event1"); + expect(result[1]?.eventID).toBe("event2"); + }); + + test("warns on individual fetch failures and continues", async () => { + const event1 = mockEvent("event1"); + spyOn(apiClient, "getEvent").mockImplementation( + (_org: string, _proj: string, id: string) => + id === "event1" + ? Promise.resolve(event1) + : Promise.reject(new Error("not found")) + ); + + const result = await fetchMultipleEvents({ + eventIds: ["event1", "event2"], + org: "my-org", + project: "my-project", + prefetchedEvent: null, + primaryId: "event1", + }); + // Only the successful event is returned + expect(result).toEqual([event1]); + }); + + test("re-throws primary event error when all fetches fail", async () => { + const error = new ApiError("Server error", 500); + spyOn(apiClient, "getEvent").mockRejectedValue(error); + + await expect( + fetchMultipleEvents({ + eventIds: ["event1", "event2"], + org: "my-org", + project: "my-project", + prefetchedEvent: null, + primaryId: "event1", + }) + ).rejects.toThrow("Server error"); + }); +}); + +describe("parsePositionalArgs: extraEventIds", () => { + test("no extras for single arg", () => { + const result = parsePositionalArgs(["abc123"]); + expect(result.extraEventIds).toBeUndefined(); + }); + + test("no extras for two args", () => { + const result = parsePositionalArgs(["my-org/proj", "abc123"]); + expect(result.extraEventIds).toBeUndefined(); + }); + + test("collects extras for three+ args", () => { + const result = parsePositionalArgs([ + "my-org/proj", + "abc123", + "def456", + "ghi789", + ]); + expect(result.extraEventIds).toEqual(["def456", "ghi789"]); + }); + + test("collects extras when args are swapped", () => { + // When swap is detected: first looks like hex ID, second looks like target + const result = parsePositionalArgs([ + "abc123def456abc123def456abc123de", + "test-org/test-proj", + "extra1", + ]); + expect(result.warning).toBeDefined(); + expect(result.extraEventIds).toEqual(["extra1"]); + }); +}); diff --git a/test/lib/arg-parsing.test.ts b/test/lib/arg-parsing.test.ts index 44512375a..5488c9074 100644 --- a/test/lib/arg-parsing.test.ts +++ b/test/lib/arg-parsing.test.ts @@ -15,6 +15,7 @@ import { parseIssueArg, parseOrgProjectArg, parseSlashSeparatedArg, + splitNewlineArg, } from "../../src/lib/arg-parsing.js"; import { stripDsnOrgPrefix } from "../../src/lib/dsn/index.js"; import { ValidationError } from "../../src/lib/errors.js"; @@ -1296,4 +1297,37 @@ describe("parseSlashSeparatedArg: whitespace trimming", () => { ); expect(result).toEqual({ id: "a9b4ad2c", targetArg: undefined }); }); + + test("preserves newlines in no-slash path (log view splits downstream)", () => { + const result = parseSlashSeparatedArg( + "abc123\ndef456", + "Log ID", + "sentry log view " + ); + // No-slash path must NOT strip newlines — log view splits them downstream + expect(result.id).toBe("abc123\ndef456"); + expect(result.targetArg).toBeUndefined(); + }); +}); + +describe("splitNewlineArg", () => { + test("splits on newlines and trims each part", () => { + expect(splitNewlineArg("abc\n def \nghi")).toEqual(["abc", "def", "ghi"]); + }); + + test("filters out empty lines", () => { + expect(splitNewlineArg("abc\n\n\ndef")).toEqual(["abc", "def"]); + }); + + test("handles CRLF", () => { + expect(splitNewlineArg("abc\r\ndef")).toEqual(["abc", "def"]); + }); + + test("returns single element for no newlines", () => { + expect(splitNewlineArg("abc123")).toEqual(["abc123"]); + }); + + test("returns empty array for whitespace-only input", () => { + expect(splitNewlineArg(" \n \n ")).toEqual([]); + }); });