From e9b90eeaf40cc7e2899e1da5c45e43330a3d2996 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 1 May 2026 05:48:53 +0000 Subject: [PATCH 1/6] feat(event): support multi-event view with newline-separated IDs (CLI-1HT) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents (Codex) sometimes paste multiple event IDs as a single newline-separated argument, e.g. `org/project/id1\nid2\nid3`. Instead of rejecting the input with a ValidationError, expand newline-separated args and fetch all events in parallel. Changes: - Add `expandNewlineArgs` to split newline-separated positional args - Change `parsePositionalArgs` to collect extra event IDs beyond the first two args into `extraEventIds` - Add `collectEventIds` and `fetchMultipleEvents` helpers for parallel multi-event fetching with per-ID failure handling - Change output type to `{ events: SingleEventViewData[] }` — single event yields flat JSON (backward compat), multiple events yield array - Update human formatter to separate multiple events with `---` Fixes CLI-1HT --- AGENTS.md | 107 ++++---- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 2 +- .../skills/sentry-cli/references/event.md | 2 +- src/commands/event/view.ts | 253 +++++++++++++++--- test/commands/event/view.test.ts | 71 ++++- test/lib/arg-parsing.test.ts | 11 + 6 files changed, 343 insertions(+), 103 deletions(-) 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..af9811a2d 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"; @@ -67,26 +69,41 @@ 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[]; +}; + /** * 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 { 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 +112,65 @@ 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( 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; + }; + + if (data.events.length === 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 / "; +/** + * Split a single argument on newlines into individual IDs. + * + * Agents (Codex, Claude) sometimes paste multiple event IDs as a single + * newline-separated argument (CLI-1HT). This mirrors `log view`'s + * `splitLogIds` helper. + */ +function splitOnNewlines(arg: string): string[] { + return arg + .split("\n") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +/** + * 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. + */ +function expandNewlineArgs(args: string[]): string[] { + return args.flatMap(splitOnNewlines); +} + /** * Sentinel eventId for "fetch the latest event for this issue." * Uses the @-prefix convention from {@link IssueSelector} magic selectors. @@ -195,6 +250,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[]; }; /** @@ -282,8 +339,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 +527,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 +549,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 +694,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 +702,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 +772,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 +798,109 @@ 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) + */ +function collectEventIds( + primaryId: string, + extraIds: string[] | undefined +): string[] { + const allIds = [primaryId]; + if (!extraIds || extraIds.length === 0) { + return allIds; + } + const log = logger.withTag("event.view"); + for (const rawId of extraIds) { + try { + allIds.push(validateHexId(rawId, "Event ID")); + } 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. + */ +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 +912,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 +933,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 +969,7 @@ export const viewCommand = buildCommand({ ); return; } - yield new CommandOutput(issueShortcut.data); + yield new CommandOutput({ events: [issueShortcut.data] }); return { hint: issueShortcut.hint }; } @@ -840,18 +997,26 @@ export const viewCommand = buildCommand({ 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, + }); + + // 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(viewData); + yield new CommandOutput({ events: viewDataEntries }); return { hint: target.detectedFrom ? `Detected from ${target.detectedFrom}` diff --git a/test/commands/event/view.test.ts b/test/commands/event/view.test.ts index a89bc01cd..a4ef87826 100644 --- a/test/commands/event/view.test.ts +++ b/test/commands/event/view.test.ts @@ -241,7 +241,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 +249,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 +259,74 @@ 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"; + // expandNewlineArgs splits this into: + // ["perzimo/perzimo-server/189945b37884462cb9134bd5cabeaa3d", "60c277e6c73f41c58ca46231b46dc0f8", "722e1158dfa147ec90ed831c4d096ae7"] + // parsePositionalArgs sees 3 args: first is org/project/id, rest are extra IDs + // But since first has 2 slashes, parseSingleArg routes it through parseSlashSeparatedArg + // Actually with 3 args, first is target, second is event ID, rest are extras + // Let's test the expanded form directly: + const expanded = multiLineArg + .split("\n") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + expect(expanded).toEqual([ + "perzimo/perzimo-server/189945b37884462cb9134bd5cabeaa3d", + "60c277e6c73f41c58ca46231b46dc0f8", + "722e1158dfa147ec90ed831c4d096ae7", + ]); + + // First arg is org/project/id → single-arg path → parseSlashSeparatedArg + // → eventId = "189945b37884462cb9134bd5cabeaa3d", targetArg = "perzimo/perzimo-server" + // But we have 3 args, so first is target, second is eventId, third is extra + // After expansion: ["perzimo/perzimo-server/189945b37884462cb9134bd5cabeaa3d", ...] + // args.length > 1 → first = target, second = event ID + // BUT first has 2 slashes, so it's "org/project/id" not a plain target + // Actually, the two-arg branch treats first as target, second as event ID + // So first = "perzimo/perzimo-server/189945b37884462cb9134bd5cabeaa3d" (target) + // second = "60c277e6c73f41c58ca46231b46dc0f8" (eventId) + // third = "722e1158dfa147ec90ed831c4d096ae7" (extra) + const result = parsePositionalArgs(expanded); + expect(result.targetArg).toBe( + "perzimo/perzimo-server/189945b37884462cb9134bd5cabeaa3d" + ); + expect(result.eventId).toBe("60c277e6c73f41c58ca46231b46dc0f8"); + expect(result.extraEventIds).toEqual([ + "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("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. diff --git a/test/lib/arg-parsing.test.ts b/test/lib/arg-parsing.test.ts index 44512375a..00f92d0c5 100644 --- a/test/lib/arg-parsing.test.ts +++ b/test/lib/arg-parsing.test.ts @@ -1296,4 +1296,15 @@ 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(); + }); }); From 28329cdd4846abac5bc02d42f115fc7ee8d92a72 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 1 May 2026 21:47:17 +0000 Subject: [PATCH 2/6] test(event): add coverage for multi-event helpers and fix swap-args extraEventIds - Export and test splitOnNewlines, expandNewlineArgs, collectEventIds, formatEventView, jsonTransformEventView, fetchMultipleEvents - Test formatEventView with span tree lines and multi-event separator - Test jsonTransformEventView single vs multi-event and field filtering - Test fetchMultipleEvents success, prefetch, partial failure, all-fail - Test parsePositionalArgs extraEventIds in swap-detected path - Patch coverage at 91%+ (remaining uncovered: func() integration paths) --- src/commands/event/view.ts | 21 +- test/commands/event/view.test.ts | 360 +++++++++++++++++++++++++++++++ 2 files changed, 374 insertions(+), 7 deletions(-) diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index af9811a2d..ebc46ae76 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -91,7 +91,7 @@ type EventViewData = { * 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[] = []; for (const entry of data.events) { @@ -120,7 +120,7 @@ function formatEventView(data: EventViewData): string { * 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 { @@ -153,7 +153,7 @@ const USAGE_HINT = "sentry event view / "; * newline-separated argument (CLI-1HT). This mirrors `log view`'s * `splitLogIds` helper. */ -function splitOnNewlines(arg: string): string[] { +export function splitOnNewlines(arg: string): string[] { return arg .split("\n") .map((s) => s.trim()) @@ -167,7 +167,7 @@ function splitOnNewlines(arg: string): string[] { * this produces `["org/project/id1", "id2", "id3"]` — the first retains * the org/project prefix so `parsePositionalArgs` can extract the target. */ -function expandNewlineArgs(args: string[]): string[] { +export function expandNewlineArgs(args: string[]): string[] { return args.flatMap(splitOnNewlines); } @@ -274,6 +274,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, []); @@ -323,7 +324,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"). @@ -808,7 +815,7 @@ async function resolveIssueShortcut( * @param primaryId - Already-validated primary event ID * @returns All valid event IDs (primary + validated extras) */ -function collectEventIds( +export function collectEventIds( primaryId: string, extraIds: string[] | undefined ): string[] { @@ -850,7 +857,7 @@ type FetchMultipleOptions = { * * When all fetches fail, re-throws the error from the primary (first) event. */ -async function fetchMultipleEvents( +export async function fetchMultipleEvents( options: FetchMultipleOptions ): Promise { const { eventIds, org, project, prefetchedEvent, primaryId } = options; diff --git a/test/commands/event/view.test.ts b/test/commands/event/view.test.ts index a4ef87826..9f8b5cbbf 100644 --- a/test/commands/event/view.test.ts +++ b/test/commands/event/view.test.ts @@ -15,11 +15,17 @@ import { test, } from "bun:test"; import { + collectEventIds, + expandNewlineArgs, fetchEventWithContext, + fetchMultipleEvents, + formatEventView, + jsonTransformEventView, parsePositionalArgs, resolveAutoDetectTarget, resolveEventTarget, resolveOrgAllTarget, + splitOnNewlines, viewCommand, } from "../../../src/commands/event/view.js"; import type { ProjectWithOrg } from "../../../src/lib/api-client.js"; @@ -1244,3 +1250,357 @@ describe("fetchEventWithContext", () => { expect(resolveEventSpy).not.toHaveBeenCalled(); }); }); + +// --------------------------------------------------------------------------- +// splitOnNewlines +// --------------------------------------------------------------------------- + +describe("splitOnNewlines", () => { + test("splits on newlines and trims each part", () => { + expect(splitOnNewlines("abc\n def \nghi")).toEqual(["abc", "def", "ghi"]); + }); + + test("filters out empty lines", () => { + expect(splitOnNewlines("abc\n\n\ndef")).toEqual(["abc", "def"]); + }); + + test("handles CRLF", () => { + expect(splitOnNewlines("abc\r\ndef")).toEqual(["abc", "def"]); + }); + + test("returns single element for no newlines", () => { + expect(splitOnNewlines("abc123")).toEqual(["abc123"]); + }); + + test("returns empty array for whitespace-only input", () => { + expect(splitOnNewlines(" \n \n ")).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// 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"]); + }); +}); + +// --------------------------------------------------------------------------- +// 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 }], + }); + 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 }, + ], + }); + 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)"], + }, + ], + }); + 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 }], + }); + 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 }, + ], + }); + 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("applies field filtering for single event", () => { + const result = jsonTransformEventView( + { events: [{ event: mockEvent("abc123"), trace: null }] }, + ["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 }, + ], + }, + ["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"]); + }); +}); From ada2f043acbdccedc7d6b1ba7be8372192ed9386 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 1 May 2026 21:57:28 +0000 Subject: [PATCH 3/6] fix(event): route org/project/id multi-arg through single-arg path + extract shared splitNewlineArg Fix bug where expanded 'org/project/id1\nid2' was incorrectly parsed with first arg as target (losing id1). When the first arg has 2+ slashes, route through parseSingleArg to correctly split org/project from id. Extract splitNewlineArg to src/lib/arg-parsing.ts as a shared utility, replacing the duplicate splitLogIds in log/view.ts and splitOnNewlines in event/view.ts. --- src/commands/event/view.ts | 28 +++++++------- src/commands/log/view.ts | 9 ++--- src/lib/arg-parsing.ts | 16 ++++++++ test/commands/event/view.test.ts | 63 ++++++++++++++++---------------- 4 files changed, 63 insertions(+), 53 deletions(-) diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index ebc46ae76..5c90aab85 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -22,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"; @@ -146,20 +147,6 @@ export function jsonTransformEventView( /** Usage hint for ContextError messages */ const USAGE_HINT = "sentry event view / "; -/** - * Split a single argument on newlines into individual IDs. - * - * Agents (Codex, Claude) sometimes paste multiple event IDs as a single - * newline-separated argument (CLI-1HT). This mirrors `log view`'s - * `splitLogIds` helper. - */ -export function splitOnNewlines(arg: string): string[] { - return arg - .split("\n") - .map((s) => s.trim()) - .filter((s) => s.length > 0); -} - /** * Expand positional args by splitting each on newlines. * @@ -168,7 +155,7 @@ export function splitOnNewlines(arg: string): string[] { * the org/project prefix so `parsePositionalArgs` can extract the target. */ export function expandNewlineArgs(args: string[]): string[] { - return args.flatMap(splitOnNewlines); + return args.flatMap(splitNewlineArg); } /** @@ -315,6 +302,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 diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index 373472d33..fdfd8ec24 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"; @@ -65,12 +66,8 @@ const USAGE_HINT = "sentry log view / [...]"; * @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); -} +/** @deprecated Use {@link splitNewlineArg} from arg-parsing.ts */ +const splitLogIds = splitNewlineArg; /** * Parse positional arguments for log view. 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 9f8b5cbbf..0780f9855 100644 --- a/test/commands/event/view.test.ts +++ b/test/commands/event/view.test.ts @@ -25,13 +25,15 @@ import { resolveAutoDetectTarget, resolveEventTarget, resolveOrgAllTarget, - splitOnNewlines, viewCommand, } from "../../../src/commands/event/view.js"; import type { ProjectWithOrg } from "../../../src/lib/api-client.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as apiClient from "../../../src/lib/api-client.js"; -import { ProjectSpecificationType } from "../../../src/lib/arg-parsing.js"; +import { + ProjectSpecificationType, + splitNewlineArg, +} from "../../../src/lib/arg-parsing.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as browser from "../../../src/lib/browser.js"; import { DEFAULT_SENTRY_URL } from "../../../src/lib/constants.js"; @@ -269,38 +271,20 @@ describe("parsePositionalArgs", () => { test("expands newline-separated IDs from single structured arg", () => { const multiLineArg = "perzimo/perzimo-server/189945b37884462cb9134bd5cabeaa3d\n60c277e6c73f41c58ca46231b46dc0f8\n722e1158dfa147ec90ed831c4d096ae7"; - // expandNewlineArgs splits this into: - // ["perzimo/perzimo-server/189945b37884462cb9134bd5cabeaa3d", "60c277e6c73f41c58ca46231b46dc0f8", "722e1158dfa147ec90ed831c4d096ae7"] - // parsePositionalArgs sees 3 args: first is org/project/id, rest are extra IDs - // But since first has 2 slashes, parseSingleArg routes it through parseSlashSeparatedArg - // Actually with 3 args, first is target, second is event ID, rest are extras - // Let's test the expanded form directly: - const expanded = multiLineArg - .split("\n") - .map((s) => s.trim()) - .filter((s) => s.length > 0); + const expanded = expandNewlineArgs([multiLineArg]); expect(expanded).toEqual([ "perzimo/perzimo-server/189945b37884462cb9134bd5cabeaa3d", "60c277e6c73f41c58ca46231b46dc0f8", "722e1158dfa147ec90ed831c4d096ae7", ]); - // First arg is org/project/id → single-arg path → parseSlashSeparatedArg - // → eventId = "189945b37884462cb9134bd5cabeaa3d", targetArg = "perzimo/perzimo-server" - // But we have 3 args, so first is target, second is eventId, third is extra - // After expansion: ["perzimo/perzimo-server/189945b37884462cb9134bd5cabeaa3d", ...] - // args.length > 1 → first = target, second = event ID - // BUT first has 2 slashes, so it's "org/project/id" not a plain target - // Actually, the two-arg branch treats first as target, second as event ID - // So first = "perzimo/perzimo-server/189945b37884462cb9134bd5cabeaa3d" (target) - // second = "60c277e6c73f41c58ca46231b46dc0f8" (eventId) - // third = "722e1158dfa147ec90ed831c4d096ae7" (extra) + // 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/189945b37884462cb9134bd5cabeaa3d" - ); - expect(result.eventId).toBe("60c277e6c73f41c58ca46231b46dc0f8"); + expect(result.targetArg).toBe("perzimo/perzimo-server"); + expect(result.eventId).toBe("189945b37884462cb9134bd5cabeaa3d"); expect(result.extraEventIds).toEqual([ + "60c277e6c73f41c58ca46231b46dc0f8", "722e1158dfa147ec90ed831c4d096ae7", ]); }); @@ -315,6 +299,21 @@ describe("parsePositionalArgs", () => { 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", @@ -1255,25 +1254,25 @@ describe("fetchEventWithContext", () => { // splitOnNewlines // --------------------------------------------------------------------------- -describe("splitOnNewlines", () => { +describe("splitNewlineArg", () => { test("splits on newlines and trims each part", () => { - expect(splitOnNewlines("abc\n def \nghi")).toEqual(["abc", "def", "ghi"]); + expect(splitNewlineArg("abc\n def \nghi")).toEqual(["abc", "def", "ghi"]); }); test("filters out empty lines", () => { - expect(splitOnNewlines("abc\n\n\ndef")).toEqual(["abc", "def"]); + expect(splitNewlineArg("abc\n\n\ndef")).toEqual(["abc", "def"]); }); test("handles CRLF", () => { - expect(splitOnNewlines("abc\r\ndef")).toEqual(["abc", "def"]); + expect(splitNewlineArg("abc\r\ndef")).toEqual(["abc", "def"]); }); test("returns single element for no newlines", () => { - expect(splitOnNewlines("abc123")).toEqual(["abc123"]); + expect(splitNewlineArg("abc123")).toEqual(["abc123"]); }); test("returns empty array for whitespace-only input", () => { - expect(splitOnNewlines(" \n \n ")).toEqual([]); + expect(splitNewlineArg(" \n \n ")).toEqual([]); }); }); From 08099f6539d454d1c1e9ff0cad79cd01785835bb Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 1 May 2026 22:07:43 +0000 Subject: [PATCH 4/6] fix(event): use requestedCount for deterministic JSON shape on partial failure When multiple events are requested but some fail, the JSON output shape should be deterministic based on what was requested, not what succeeded. Use requestedCount (from allEventIds.length) instead of events.length to decide flat object vs array format. Also extract splitNewlineArg to src/lib/arg-parsing.ts as shared utility, replacing duplicates in event/view.ts and log/view.ts. --- src/commands/event/view.ts | 16 +++++++++++++--- test/commands/event/view.test.ts | 22 +++++++++++++++++++++- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index 5c90aab85..17f378b27 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -84,6 +84,8 @@ type SingleEventViewData = { */ type EventViewData = { events: SingleEventViewData[]; + /** Number of events originally requested (before partial failures) */ + requestedCount: number; }; /** @@ -136,7 +138,9 @@ export function jsonTransformEventView( return result; }; - if (data.events.length === 1) { + // 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); @@ -974,7 +978,10 @@ export const viewCommand = buildCommand({ ); return; } - yield new CommandOutput({ events: [issueShortcut.data] }); + yield new CommandOutput({ + events: [issueShortcut.data], + requestedCount: 1, + }); return { hint: issueShortcut.hint }; } @@ -1021,7 +1028,10 @@ export const viewCommand = buildCommand({ ) ); - yield new CommandOutput({ events: viewDataEntries }); + yield new CommandOutput({ + events: viewDataEntries, + requestedCount: allEventIds.length, + }); return { hint: target.detectedFrom ? `Detected from ${target.detectedFrom}` diff --git a/test/commands/event/view.test.ts b/test/commands/event/view.test.ts index 0780f9855..3a5aebac9 100644 --- a/test/commands/event/view.test.ts +++ b/test/commands/event/view.test.ts @@ -1388,6 +1388,7 @@ describe("formatEventView", () => { test("renders single event", () => { const result = formatEventView({ events: [{ event: mockEvent("abc123"), trace: null }], + requestedCount: 1, }); expect(result).toContain("abc123"); }); @@ -1398,6 +1399,7 @@ describe("formatEventView", () => { { event: mockEvent("event1"), trace: null }, { event: mockEvent("event2"), trace: null }, ], + requestedCount: 2, }); expect(result).toContain("event1"); expect(result).toContain("---"); @@ -1413,6 +1415,7 @@ describe("formatEventView", () => { spanTreeLines: [" span-1 (50ms)", " span-2 (20ms)"], }, ], + requestedCount: 1, }); expect(result).toContain("span-1 (50ms)"); expect(result).toContain("span-2 (20ms)"); @@ -1433,6 +1436,7 @@ describe("jsonTransformEventView", () => { 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 }) @@ -1447,6 +1451,7 @@ describe("jsonTransformEventView", () => { { event: mockEvent("event1"), trace: null }, { event: mockEvent("event2"), trace: null }, ], + requestedCount: 2, }); expect(Array.isArray(result)).toBe(true); const arr = result as Record[]; @@ -1455,9 +1460,23 @@ describe("jsonTransformEventView", () => { 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 }] }, + { + events: [{ event: mockEvent("abc123"), trace: null }], + requestedCount: 1, + }, ["eventID"] ); expect(result).toEqual({ eventID: "abc123" }); @@ -1470,6 +1489,7 @@ describe("jsonTransformEventView", () => { { event: mockEvent("event1"), trace: null }, { event: mockEvent("event2"), trace: null }, ], + requestedCount: 2, }, ["eventID"] ); From 83559ab3d35b28fecd9a42f5e17dd32b2d9c46fd Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 1 May 2026 22:17:05 +0000 Subject: [PATCH 5/6] fix(event): deduplicate event IDs in collectEventIds Agent-pasted lists often contain duplicate IDs. Deduplicate using a Set to avoid redundant API fetches and duplicate output entries. --- src/commands/event/view.ts | 7 ++++++- test/commands/event/view.test.ts | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index 17f378b27..bd2671216 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -821,6 +821,7 @@ export function collectEventIds( primaryId: string, extraIds: string[] | undefined ): string[] { + const seen = new Set([primaryId]); const allIds = [primaryId]; if (!extraIds || extraIds.length === 0) { return allIds; @@ -828,7 +829,11 @@ export function collectEventIds( const log = logger.withTag("event.view"); for (const rawId of extraIds) { try { - allIds.push(validateHexId(rawId, "Event ID")); + const validated = validateHexId(rawId, "Event ID"); + if (!seen.has(validated)) { + seen.add(validated); + allIds.push(validated); + } } catch { log.info(`Skipping invalid event ID: ${rawId}`); } diff --git a/test/commands/event/view.test.ts b/test/commands/event/view.test.ts index 3a5aebac9..0a0dbedbf 100644 --- a/test/commands/event/view.test.ts +++ b/test/commands/event/view.test.ts @@ -1364,6 +1364,18 @@ describe("collectEventIds", () => { 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", + ]); + }); }); // --------------------------------------------------------------------------- From e71760cfb862d7fdad903d2c3b7193bfd4c0de3f Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 1 May 2026 23:19:53 +0000 Subject: [PATCH 6/6] review: warn on --web with extra IDs, complete splitLogIds refactoring, relocate tests Address self-review findings: - Warn when --web flag is used with extra event IDs (only first opens) - Replace splitLogIds alias with direct splitNewlineArg calls in log/view.ts - Move splitNewlineArg tests to test/lib/arg-parsing.test.ts (correct location) - Remove orphaned JSDoc block from log/view.ts --- src/commands/event/view.ts | 5 +++++ src/commands/log/view.ts | 19 ++++--------------- test/commands/event/view.test.ts | 31 ++----------------------------- test/lib/arg-parsing.test.ts | 23 +++++++++++++++++++++++ 4 files changed, 34 insertions(+), 44 deletions(-) diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index bd2671216..21c46488a 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -1010,6 +1010,11 @@ 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; } diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index fdfd8ec24..8d2e9d4d2 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -58,17 +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 - */ -/** @deprecated Use {@link splitNewlineArg} from arg-parsing.ts */ -const splitLogIds = splitNewlineArg; - /** * Parse positional arguments for log view. * Handles: @@ -112,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, []); } @@ -127,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, []); } @@ -148,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/test/commands/event/view.test.ts b/test/commands/event/view.test.ts index 0a0dbedbf..3b4bf9148 100644 --- a/test/commands/event/view.test.ts +++ b/test/commands/event/view.test.ts @@ -30,10 +30,7 @@ import { import type { ProjectWithOrg } from "../../../src/lib/api-client.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as apiClient from "../../../src/lib/api-client.js"; -import { - ProjectSpecificationType, - splitNewlineArg, -} from "../../../src/lib/arg-parsing.js"; +import { ProjectSpecificationType } from "../../../src/lib/arg-parsing.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as browser from "../../../src/lib/browser.js"; import { DEFAULT_SENTRY_URL } from "../../../src/lib/constants.js"; @@ -1250,31 +1247,7 @@ describe("fetchEventWithContext", () => { }); }); -// --------------------------------------------------------------------------- -// splitOnNewlines -// --------------------------------------------------------------------------- - -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([]); - }); -}); +// Note: splitNewlineArg is tested in test/lib/arg-parsing.test.ts // --------------------------------------------------------------------------- // expandNewlineArgs diff --git a/test/lib/arg-parsing.test.ts b/test/lib/arg-parsing.test.ts index 00f92d0c5..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"; @@ -1308,3 +1309,25 @@ describe("parseSlashSeparatedArg: whitespace trimming", () => { 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([]); + }); +});