diff --git a/src/commands/explore.ts b/src/commands/explore.ts index 6e401e8f3..b0610951c 100644 --- a/src/commands/explore.ts +++ b/src/commands/explore.ts @@ -42,6 +42,7 @@ import { getReplayRequestFields, isSupportedReplayField, listSupportedReplayFields, + normalizeVariadicFlag, parseReplayEnvironmentFilter, } from "../lib/replay-search.js"; import { resolveOrgOptionalProjectFromArg } from "../lib/resolve-target.js"; @@ -375,10 +376,8 @@ function appendFlagHints( if (flags.limit !== DEFAULT_LIMIT) { parts.push(`--limit ${flags.limit}`); } - if (flags.environment && flags.environment.length > 0) { - for (const environment of flags.environment) { - parts.push(`-e "${environment}"`); - } + for (const environment of normalizeVariadicFlag(flags.environment)) { + parts.push(`-e "${environment}"`); } appendPeriodHint(parts, flags.period, DEFAULT_PERIOD); return parts.length > 0 ? `${base} ${parts.join(" ")}` : base; @@ -710,11 +709,15 @@ export const exploreCommand = buildListCommand("explore", { ); let dataset = flags.dataset; - const userSuppliedFields = flags.field && flags.field.length > 0; - let fieldList = [...defaultFieldsForDataset(dataset)]; - if (userSuppliedFields) { - fieldList = flags.field; - } + // A single `-F` flag arrives from the parser as a bare string rather than a + // one-element array. Normalize once so every downstream consumer (fieldList, + // hintFlags, contextKey) sees an array — a leftover string makes the later + // `.filter`/`.join` calls throw a TypeError (CLI-28C). + const fields = normalizeVariadicFlag(flags.field); + const userSuppliedFields = fields.length > 0; + let fieldList = userSuppliedFields + ? [...fields] + : [...defaultFieldsForDataset(dataset)]; const timeRange = flags.period; const environment = parseReplayEnvironmentFilter(flags.environment); @@ -806,7 +809,7 @@ export const exploreCommand = buildListCommand("explore", { const hasMore = !!nextCursor; const baseTarget = project ? `${org}/${project}` : `${org}/`; - const hintFlags = { ...flags, dataset }; + const hintFlags = { ...flags, dataset, field: fields }; const nav = paginationHint({ hasPrev, hasMore, diff --git a/src/commands/replay/list.ts b/src/commands/replay/list.ts index 22ff2aba4..7b3a7cb13 100644 --- a/src/commands/replay/list.ts +++ b/src/commands/replay/list.ts @@ -42,6 +42,7 @@ import { import { withProgress } from "../../lib/polling.js"; import { getReplayUserLabel, + normalizeVariadicFlag, parseReplayEnvironmentFilter, } from "../../lib/replay-search.js"; import { resolveOrgOptionalProjectFromArg } from "../../lib/resolve-target.js"; @@ -182,10 +183,8 @@ function appendReplayFlags( const parts: string[] = []; appendQueryHint(parts, flags.query); appendSortHint(parts, flags.sort, DEFAULT_SORT); - if (flags.environment && flags.environment.length > 0) { - for (const environment of flags.environment) { - parts.push(`-e "${environment}"`); - } + for (const environment of normalizeVariadicFlag(flags.environment)) { + parts.push(`-e "${environment}"`); } appendPeriodHint(parts, flags.period, DEFAULT_PERIOD); return parts.length > 0 ? `${base} ${parts.join(" ")}` : base; diff --git a/src/lib/replay-search.ts b/src/lib/replay-search.ts index 53b01305c..100df2f81 100644 --- a/src/lib/replay-search.ts +++ b/src/lib/replay-search.ts @@ -43,16 +43,31 @@ export const DEFAULT_REPLAY_EXPLORE_FIELDS = [ "user.email", ] as const; +/** + * Coerce a variadic CLI flag value into an array. + * + * Stricli's variadic parser yields a bare `string` for a single occurrence of + * a flag and a `string[]` for multiple, even though the flag is declared + * variadic. Callers must normalize before spreading or iterating, otherwise a + * single value is treated as an array of characters (CLI-28C). + */ +export function normalizeVariadicFlag( + value: readonly string[] | string | undefined +): string[] { + if (value === undefined) { + return []; + } + return Array.isArray(value) ? [...value] : [value as string]; +} + /** Parse repeatable and comma-separated replay environment filters. */ export function parseReplayEnvironmentFilter( - values: readonly string[] | undefined + values: readonly string[] | string | undefined ): string[] | undefined { - const parsed = values - ? [...values] - .flatMap((value) => value.split(",")) - .map((value) => value.trim()) - .filter(Boolean) - : []; + const parsed = normalizeVariadicFlag(values) + .flatMap((value) => value.split(",")) + .map((value) => value.trim()) + .filter(Boolean); return parsed.length > 0 ? parsed : undefined; } diff --git a/test/commands/explore.test.ts b/test/commands/explore.test.ts index 5109f6f81..fdee8ab6d 100644 --- a/test/commands/explore.test.ts +++ b/test/commands/explore.test.ts @@ -321,6 +321,28 @@ describe("sentry explore", () => { ); }); + test("coerces a single -F flag (string) into an array without crashing", async () => { + // Stricli's variadic parser emits a bare string for a single `-F`, not a + // one-element array. The command must normalize it before the query and + // the pagination-hint path both use array methods on it (CLI-28C). + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + const { context } = createContext(); + + await func.call( + context, + { + ...DEFAULT_FLAGS, + field: "transaction" as unknown as string[], + }, + "test-org/" + ); + + expect(queryEventsSpy).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ fields: ["transaction"] }) + ); + }); + test("passes custom dataset", async () => { resolveTargetSpy.mockResolvedValue({ org: "test-org" }); const { context } = createContext(); diff --git a/test/lib/replay-search.test.ts b/test/lib/replay-search.test.ts index 743f39515..55b1a1eb0 100644 --- a/test/lib/replay-search.test.ts +++ b/test/lib/replay-search.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "vitest"; import { getReplayRequestFields, isSupportedReplayField, + normalizeVariadicFlag, + parseReplayEnvironmentFilter, } from "../../src/lib/replay-search.js"; describe("getReplayRequestFields", () => { @@ -38,3 +40,38 @@ describe("isSupportedReplayField", () => { expect(isSupportedReplayField("replay_type")).toBe(false); }); }); + +describe("normalizeVariadicFlag", () => { + test("wraps a single string value into an array", () => { + // A single `-e` flag arrives from the parser as a bare string, not a + // one-element array (CLI-28C). + expect(normalizeVariadicFlag("production")).toEqual(["production"]); + }); + + test("passes an array through and returns [] for undefined", () => { + expect(normalizeVariadicFlag(["a", "b"])).toEqual(["a", "b"]); + expect(normalizeVariadicFlag(undefined)).toEqual([]); + }); +}); + +describe("parseReplayEnvironmentFilter", () => { + test("treats a single -e value as one environment, not per-character", () => { + // Regression: a bare string must not be spread into characters. + expect( + parseReplayEnvironmentFilter("production" as unknown as string[]) + ).toEqual(["production"]); + }); + + test("splits comma-separated and repeated values", () => { + expect(parseReplayEnvironmentFilter(["prod,staging", "dev"])).toEqual([ + "prod", + "staging", + "dev", + ]); + }); + + test("returns undefined when empty", () => { + expect(parseReplayEnvironmentFilter(undefined)).toBeUndefined(); + expect(parseReplayEnvironmentFilter([])).toBeUndefined(); + }); +});