diff --git a/apps/cli-docs/src/content/docs/features.md b/apps/cli-docs/src/content/docs/features.md index 1e9b0f8aa..61adf9b7d 100644 --- a/apps/cli-docs/src/content/docs/features.md +++ b/apps/cli-docs/src/content/docs/features.md @@ -5,6 +5,10 @@ description: Advanced features of the Sentry CLI The Sentry CLI includes several features designed to streamline your workflow, especially in complex project setups. +## Interactive Selection + +When the CLI asks you to choose an organization or project, type to filter the loaded options by name or slug. The filter appears as `/text` below the list. Matching ignores case and tolerates small typos. Use arrow keys to navigate, Enter to select, and Backspace to edit. Esc clears the filter before cancelling; Ctrl+C cancels immediately. The same controls work in the organization, team, and monorepo app selectors in `sentry init`. + ## DSN Auto-Detection The CLI automatically detects your Sentry project from your codebase, eliminating the need to specify the target for every command. DSN detection is one part of the [resolution priority chain](./configuration/#resolution-priority) — it runs after checking for explicit arguments, environment variables, and `.sentryclirc` config files. diff --git a/apps/cli-docs/src/fragments/commands/init.md b/apps/cli-docs/src/fragments/commands/init.md index 12cf0eff5..138e845de 100644 --- a/apps/cli-docs/src/fragments/commands/init.md +++ b/apps/cli-docs/src/fragments/commands/init.md @@ -4,6 +4,8 @@ **Authentication:** Interactive runs start the OAuth login flow automatically when credentials are missing or the stored session has expired. Non-interactive and CI runs must authenticate before running `sentry init`. +In organization, team, and monorepo app selectors, type to filter the loaded choices by name or slug. The filter appears as `/text` below the list. Matching ignores case and tolerates small typos. Use arrow keys to navigate, Enter to select, and Backspace to edit. Esc clears an active filter; with no filter, Esc cancels. Ctrl+C always cancels. + ## Examples ```bash diff --git a/packages/cli/script/preview-selectors.ts b/packages/cli/script/preview-selectors.ts new file mode 100644 index 000000000..1bc632b51 --- /dev/null +++ b/packages/cli/script/preview-selectors.ts @@ -0,0 +1,74 @@ +/** + * Preview the real entity pickers with local fixtures, without authentication + * or project changes. Run `pnpm tsx script/preview-selectors.ts`; add `--compact` + * for the Org/Project picker used outside the init wizard. + */ +import { createInkUI, selectWithFilter } from "../src/lib/init/ui/ink-ui.js"; +import { isCancelled, type SelectOptions } from "../src/lib/init/ui/types.js"; + +const organization: SelectOptions = { + message: "Which organization should the project be created in?", + filterable: true, + options: [ + { value: "acme", label: "Acme", hint: "acme" }, + { value: "frontend-labs", label: "Frontend Labs", hint: "frontend-labs" }, + { value: "sandbox", label: "Sandbox", hint: "sandbox" }, + ], +}; +const team: SelectOptions = { + message: "Which team should own this project?", + filterable: true, + options: [ + { value: "backend", label: "backend", hint: "Backend Engineering" }, + { value: "frontend", label: "frontend", hint: "Web Engineering" }, + { value: "mobile", label: "mobile", hint: "Mobile Engineering" }, + { value: "platform", label: "platform", hint: "Developer Platform" }, + ], +}; +const project: SelectOptions = { + message: "Select a project:", + filterable: true, + options: [ + "api-gateway", + "backend-workers", + "frontend-admin", + "frontend-web", + "mobile-ios", + "payments-api", + "storefront-api", + ].map((value) => ({ value, label: value })), +}; + +async function preview(): Promise { + if (!(process.stdin.isTTY && process.stdout.isTTY)) { + throw new Error("Run this preview in an interactive terminal."); + } + const chosen: string[] = []; + if (process.argv.includes("--compact")) { + for (const prompt of [organization, project]) { + const value = await selectWithFilter(prompt); + if (value === null) { + return; + } + chosen.push(value); + } + } else { + await using ui = await createInkUI(); + ui.setIntroMode(true); + for (const prompt of [ + organization, + team, + { ...project, message: "Which app would you like to configure?" }, + ]) { + const value = await ui.select(prompt); + if (isCancelled(value)) { + return; + } + chosen.push(value); + } + ui.outro("Selector preview complete"); + } + process.stdout.write(`Selected: ${chosen.join(" / ")}\n`); +} + +await preview(); diff --git a/packages/cli/src/lib/init/interactive.ts b/packages/cli/src/lib/init/interactive.ts index a4e40a69a..1d3cad659 100644 --- a/packages/cli/src/lib/init/interactive.ts +++ b/packages/cli/src/lib/init/interactive.ts @@ -197,6 +197,7 @@ async function handleSelect( const selected = await ui.select({ message: payload.prompt, + filterable: apps.length > 0, options: items.map((item) => { const app = apps.find((a) => a.name === item); return { diff --git a/packages/cli/src/lib/init/preflight.ts b/packages/cli/src/lib/init/preflight.ts index 8e8fdb5a7..04c6589c7 100644 --- a/packages/cli/src/lib/init/preflight.ts +++ b/packages/cli/src/lib/init/preflight.ts @@ -480,6 +480,7 @@ async function resolveImplicitTeam( const selected = await ui.select({ message: "Which team should own this project?", + filterable: true, options: candidateTeams.map((team) => ({ value: team.slug, label: team.slug, @@ -573,6 +574,7 @@ async function resolveOrgSlug( const selected = await ui.select({ message: "Which organization should the project be created in?", + filterable: true, options: orgs.map((org) => ({ value: org.slug, label: org.name, diff --git a/packages/cli/src/lib/init/ui/ink-app.tsx b/packages/cli/src/lib/init/ui/ink-app.tsx index b64e7fa6a..8d6157871 100644 --- a/packages/cli/src/lib/init/ui/ink-app.tsx +++ b/packages/cli/src/lib/init/ui/ink-app.tsx @@ -63,6 +63,11 @@ import { useInkShortcuts, } from "./ink-shortcuts.js"; import { BLOCK_LINE_COUNT, LEARN_SEQUENCE } from "./learn-content.js"; +import { + filterSelectOptions, + removeLastFilterCharacter, + sanitizeSelectFilterInput, +} from "./select-filter.js"; import { SENTRY_TIPS, type SentryTip } from "./sentry-tips.js"; import type { CompletionActions, @@ -2215,6 +2220,7 @@ function SelectPromptHeader({ footer, isOptionWindowed, highlighted, + minTitleRows, promptTitle, totalCount, visiblePromptDetails, @@ -2225,6 +2231,7 @@ function SelectPromptHeader({ footer?: PromptDetail; isOptionWindowed: boolean; highlighted: number; + minTitleRows?: number; promptTitle: string; totalCount: number; visiblePromptDetails: PromptDetail[]; @@ -2248,7 +2255,12 @@ function SelectPromptHeader({ if (alignment === "center") { return ( - + {promptTitle} {position} @@ -2259,7 +2271,7 @@ function SelectPromptHeader({ return ( - + {ICONS.diamondOpen} @@ -2283,6 +2295,7 @@ function SelectPromptArea({ prompt: Extract; }): React.ReactNode { const { columns, rows } = useInkFrameSize(); + const reservedRows = occupiedRows + (prompt.filterable ? 1 : 0); const hasStructuredCopy = (prompt.details?.length ?? 0) > 0 || prompt.footer !== undefined; if (alignment === "start" && hasStructuredCopy) { @@ -2290,7 +2303,7 @@ function SelectPromptArea({ alignment, details: prompt.details ?? [], footer: prompt.footer, - occupiedRows, + occupiedRows: reservedRows, optionCount: prompt.options.length, promptTitle: prompt.message, terminalColumns: columns, @@ -2324,7 +2337,7 @@ function SelectPromptArea({ terminalRows: rows, alignment, kind: prompt.kind, - occupiedRows, + occupiedRows: reservedRows, messageRows, }); const optionLimit = Math.max(1, optionCapacity); @@ -2462,17 +2475,52 @@ function SelectPrompt({ const centeredOptionsWidth = centeredLayout ? Math.min(centeredLayout.width, getPromptContentWidth(columns, alignment)) : undefined; - const totalCount = prompt.options.length; - const [highlighted, setHighlighted] = useState(() => - Math.min(Math.max(prompt.initialIndex, 0), Math.max(0, totalCount - 1)), + const initialHighlighted = Math.min( + Math.max(prompt.initialIndex, 0), + Math.max(0, prompt.options.length - 1), + ); + const [selection, setSelection] = useState({ + prompt, + query: "", + highlighted: initialHighlighted, + }); + const selectionRef = useRef(selection); + // Ink can deliver paste and Enter before React commits the next render. + const updateSelection = useCallback( + (update: (state: typeof selection) => typeof selection) => { + const next = update(selectionRef.current); + selectionRef.current = next; + setSelection(next); + }, + [], ); + // Successive wizard prompts can replace each other without unmounting. + if (selection.prompt !== prompt) { + const next = { prompt, query: "", highlighted: initialHighlighted }; + selectionRef.current = next; + setSelection(next); + } + const { query, highlighted } = selection; + const filteredOptions = filterSelectOptions(prompt.options, query); + const totalCount = filteredOptions.length; const [windowStart, windowEnd] = getOptionWindow( totalCount, highlighted, maxVisibleOptions, ); - const visibleOptions = prompt.options.slice(windowStart, windowEnd); + const visibleOptions = filteredOptions.slice(windowStart, windowEnd); const isWindowed = visibleOptions.length < totalCount; + // Keep the title's height when filtering removes its window position counter. + const minTitleRows = + prompt.filterable && prompt.options.length > maxVisibleOptions + ? getPromptMessageRows({ + message: promptTitle, + terminalColumns: columns, + alignment, + kind: "select", + totalCount: prompt.options.length, + }) + : undefined; const shortcuts = useMemo( () => [ @@ -2482,11 +2530,20 @@ function SelectPrompt({ priority: 40, match: (_input, key) => key.upArrow || key.downArrow, run: (_input, key) => { - if (key.upArrow) { - setHighlighted((idx) => (idx === 0 ? totalCount - 1 : idx - 1)); - return; - } - setHighlighted((idx) => (idx + 1) % totalCount); + updateSelection((state) => { + const count = filterSelectOptions( + prompt.options, + state.query, + ).length; + if (count === 0) { + return state; + } + return { + ...state, + highlighted: + (state.highlighted + (key.upArrow ? count - 1 : 1)) % count, + }; + }); }, }, ...(detailShortcut ? [detailShortcut] : []), @@ -2496,7 +2553,10 @@ function SelectPrompt({ priority: 42, match: (_input, key) => key.return, run: () => { - const current = prompt.options[highlighted]; + const state = selectionRef.current; + const current = filterSelectOptions(prompt.options, state.query)[ + state.highlighted + ]; if (current) { prompt.resolve(current.value); } @@ -2504,13 +2564,63 @@ function SelectPrompt({ }, { key: "esc", - action: "cancel", + action: query ? "clear filter" : "cancel", priority: 43, match: (input, key) => key.escape || (key.ctrl && input === "c"), - run: () => prompt.resolve(null), + run: (input, key) => { + if (key.ctrl && input === "c") { + prompt.resolve(null); + } else if (selectionRef.current.query) { + updateSelection((state) => ({ + ...state, + query: "", + highlighted: 0, + })); + } else { + prompt.resolve(null); + } + }, }, + ...(prompt.filterable + ? ([ + { + key: "backspace", + action: "delete", + showInFooter: false, + match: (_input, key) => key.backspace || key.delete, + run: () => { + updateSelection((state) => { + if (!state.query) { + return state; + } + return { + ...state, + query: removeLastFilterCharacter(state.query), + highlighted: 0, + }; + }); + }, + }, + { + key: "type", + action: "filter", + showInFooter: false, + match: (input, key) => + !(key.ctrl || key.meta || key.tab) && + sanitizeSelectFilterInput(input).length > 0, + run: (input) => { + const text = sanitizeSelectFilterInput(input); + updateSelection((state) => ({ + ...state, + query: state.query + text, + highlighted: 0, + })); + }, + }, + ] satisfies ShortcutBinding[]) + : []), ], - [detailShortcut, highlighted, prompt, totalCount], + [detailShortcut, prompt, query, updateSelection], ); useInkShortcuts("select-prompt", shortcuts); @@ -2528,44 +2638,118 @@ function SelectPrompt({ footer={prompt.footer} highlighted={highlighted} isOptionWindowed={isWindowed} + minTitleRows={minTitleRows} promptTitle={promptTitle} totalCount={totalCount} visiblePromptDetails={visiblePromptDetails} /> - - {visibleOptions.map((option, visibleIndex) => { - const idx = windowStart + visibleIndex; - const isCursor = idx === highlighted; - return ( - - ); - })} - + {totalCount === 0 ? ( + + No matches + + ) : ( + + {visibleOptions.map((option, visibleIndex) => { + const idx = windowStart + visibleIndex; + const isCursor = idx === highlighted; + return ( + + ); + })} + + )} + + {prompt.filterable ? ( + + ) : null} + + ); +} + +function SelectFilterStatus({ + centered, + matches, + query, + total, +}: { + centered: boolean; + matches: number; + query: string; + total: number; +}): React.ReactNode { + const color = query ? ACCENT : MUTED; + return ( + + + + {query ? `/${query}` : "type to filter"} + + {query ? ( + + {`${matches} of ${total}`} + + ) : null} ); } +function SelectOptionText({ + text, + query, +}: { + text: string; + query: string; +}): React.ReactNode { + const start = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1; + if (start === -1) { + return text; + } + return ( + <> + {text.slice(0, start)} + {text.slice(start, start + query.length)} + {text.slice(start + query.length)} + + ); +} + function SelectPromptOptionRow({ centered, centeredLabelWidth, isCursor, option, + query, }: { centered: boolean; centeredLabelWidth?: number; isCursor: boolean; option: SelectPromptOptionData; + query: string; }): React.ReactNode { if (centered) { return ( @@ -2574,10 +2758,15 @@ function SelectPromptOptionRow({ {isCursor ? ICONS.triangleRight : " "} - {option.label} + + + {option.hint !== undefined && option.hint !== "" ? ( - {option.hint} + + {" "} + + ) : null} ); @@ -2587,9 +2776,14 @@ function SelectPromptOptionRow({ {isCursor ? ICONS.triangleRight : " "} - {option.label} + + + {option.hint !== undefined && option.hint !== "" ? ( - {option.hint} + + {" "} + + ) : null} ); @@ -3010,6 +3204,30 @@ function MultiSelectPromptOptionRow({ ); } +/** Compact entity picker for commands outside the full-screen init wizard. */ +export function SelectPicker({ + prompt, +}: { + prompt: Extract; +}): React.ReactNode { + return ( + + + + + + + ); +} + +/** Mount the compact picker with the sidecar's own Ink and React instances. */ +export function mountSelectPrompt( + prompt: Extract, + options: Parameters[1], +): ReturnType { + return inkRender(createElement(SelectPicker, { prompt }), options); +} + /** * Mount the wizard App component via Ink and return the Ink instance. * diff --git a/packages/cli/src/lib/init/ui/ink-ui.ts b/packages/cli/src/lib/init/ui/ink-ui.ts index 7109657db..0dbc854aa 100644 --- a/packages/cli/src/lib/init/ui/ink-ui.ts +++ b/packages/cli/src/lib/init/ui/ink-ui.ts @@ -249,15 +249,8 @@ function openFreshTtyForInk(): ReadStream | null { } } -/** - * Async factory for `InkUI`. Imports `ink`, `react`, and the local - * `App` component lazily, mounts the React tree, and returns the - * bridge instance. Throws if Ink can't be loaded (e.g. missing peer - * deps). - */ -export async function createInkUI( - opts: CreateInkUIOptions = {}, -): Promise { +/** Load the self-contained Ink sidecar without importing React into the CLI bundle. */ +async function loadInkApp(): Promise { // Import the Ink App sidecar. Three runtime contexts: // // 1. Node SEA binary: the sidecar is embedded as a SEA asset. @@ -321,6 +314,115 @@ export async function createInkUI( } } + return app; +} + +/** Build the same select prompt for the wizard and inline entity pickers. */ +function createSelectPrompt( + opts: SelectOptions, + resolve: (value: string | null) => void, +): Extract { + return { + kind: "select", + message: stripAnsi(opts.message), + ...(opts.details + ? { + details: opts.details.map((detail) => ({ + ...detail, + text: stripAnsi(detail.text), + })), + } + : {}), + ...(opts.footer + ? { footer: { ...opts.footer, text: stripAnsi(opts.footer.text) } } + : {}), + options: opts.options.map((option) => ({ + value: option.value, + label: option.label, + ...(option.hint ? { hint: option.hint } : {}), + })), + initialIndex: + opts.initialValue !== undefined + ? Math.max( + 0, + opts.options.findIndex( + (option) => option.value === opts.initialValue, + ), + ) + : 0, + filterable: opts.filterable, + resolve, + }; +} + +/** + * Show an inline entity picker using the wizard's filterable select control. + * Loads the sidecar lazily and releases terminal resources after selection. + * + * @param opts - Choices, prompt copy, and initial selection. Filtering is always enabled. + * @returns The selected value, or `null` on cancellation or exit without a choice. + * @throws Propagates loading, rendering, and terminal cleanup failures. + */ +export async function selectWithFilter( + opts: SelectOptions, +): Promise { + const app = await loadInkApp(); + const freshStdin = openFreshTtyForInk(); + const stdin = freshStdin ?? process.stdin; + const wasRaw = stdin.isRaw ?? false; + let instance: InkInstance | undefined; + let resolveSelection!: (value: string | null) => void; + const selection = new Promise((resolve) => { + resolveSelection = resolve; + }); + const cancel = () => resolveSelection(null); + process.once("SIGINT", cancel); + try { + instance = app.mountSelectPrompt( + createSelectPrompt({ ...opts, filterable: true }, resolveSelection), + { stdin, exitOnCtrlC: false, patchConsole: false }, + ); + // Surface renderer failures instead of waiting forever for a selection. + return await Promise.race([ + selection, + instance.waitUntilExit().then(() => null), + ]); + } finally { + process.removeListener("SIGINT", cancel); + // Each resource must be released even if an earlier cleanup step fails. + try { + instance?.clear(); + } finally { + try { + instance?.unmount(); + await instance?.waitUntilExit(); + } finally { + try { + stdin.setRawMode?.(wasRaw); + } finally { + if (freshStdin) { + try { + freshStdin.pause(); + } finally { + freshStdin.destroy(); + } + } + } + } + } + } +} + +/** + * Async factory for `InkUI`. Imports `ink`, `react`, and the local + * `App` component lazily, mounts the React tree, and returns the + * bridge instance. Throws if Ink can't be loaded (e.g. missing peer + * deps). + */ +export async function createInkUI( + opts: CreateInkUIOptions = {}, +): Promise { + const app = await loadInkApp(); const store = new WizardStore({ cliVersion: CLI_VERSION, // Seed with the full banner; IntroScreen re-fits it to the live terminal @@ -706,15 +808,6 @@ export class InkUI implements WizardUI { select(opts: SelectOptions): Promise { return this.waitForPrompt("select", (resolve) => { - const initialIndex = - opts.initialValue !== undefined - ? Math.max( - 0, - opts.options.findIndex( - (option) => option.value === opts.initialValue, - ), - ) - : 0; let settled = false; this.activePromptCancel = () => { if (settled) { @@ -725,43 +818,17 @@ export class InkUI implements WizardUI { this.activePromptCancel = undefined; resolve(CANCELLED); }; - const prompt: Extract = { - kind: "select", - message: stripAnsi(opts.message), - ...(opts.details - ? { - details: opts.details.map((detail) => ({ - ...detail, - text: stripAnsi(detail.text), - })), - } - : {}), - ...(opts.footer - ? { - footer: { - ...opts.footer, - text: stripAnsi(opts.footer.text), - }, - } - : {}), - options: opts.options.map((option) => ({ - value: option.value, - label: option.label, - ...(option.hint ? { hint: option.hint } : {}), - })), - initialIndex, - resolve: (value) => { - if (settled) { - return; - } - settled = true; - this.completePrompt( - prompt, - value === null ? CANCELLED : (value as T), - resolve, - ); - }, - }; + const prompt = createSelectPrompt(opts, (value) => { + if (settled) { + return; + } + settled = true; + this.completePrompt( + prompt, + value === null ? CANCELLED : (value as T), + resolve, + ); + }); this.store.setPrompt(prompt); }); } diff --git a/packages/cli/src/lib/init/ui/select-filter.ts b/packages/cli/src/lib/init/ui/select-filter.ts new file mode 100644 index 000000000..1d9ccb7ab --- /dev/null +++ b/packages/cli/src/lib/init/ui/select-filter.ts @@ -0,0 +1,41 @@ +/** Text filtering and editing for entity selection prompts. */ + +import { stripAnsi } from "../../formatters/plain-detect.js"; +import { fuzzyMatch } from "../../fuzzy.js"; +import type { SelectOption } from "./types.js"; + +const graphemeSegmenter = new Intl.Segmenter(undefined, { + granularity: "grapheme", +}); +const NON_PRINTABLE_INPUT = /[\p{Cc}\p{Zl}\p{Zp}]/gu; + +/** Fuzzy-match names, slugs, and visible hints without changing the option order. */ +export function filterSelectOptions>( + options: readonly T[], + query: string, +): readonly T[] { + if (query === "") { + return options; + } + + return options.filter((option) => { + const candidates = [option.label, option.value]; + if (option.hint) { + candidates.push(option.hint); + } + return fuzzyMatch(query, candidates).length > 0; + }); +} + +/** Keep pasted input on one line and prevent terminal sequences in the filter. */ +export function sanitizeSelectFilterInput(input: string): string { + return stripAnsi(input).replace(NON_PRINTABLE_INPUT, ""); +} + +/** Backspace removes one displayed character, including combined emoji. */ +export function removeLastFilterCharacter(query: string): string { + const lastCharacter = graphemeSegmenter + .segment(query) + .containing(query.length - 1); + return query.slice(0, lastCharacter?.index ?? 0); +} diff --git a/packages/cli/src/lib/init/ui/types.ts b/packages/cli/src/lib/init/ui/types.ts index d42eda662..cb5d04690 100644 --- a/packages/cli/src/lib/init/ui/types.ts +++ b/packages/cli/src/lib/init/ui/types.ts @@ -124,6 +124,8 @@ export type SelectOptions = { footer?: PromptDetail; /** Choices presented to the user in display order. */ options: SelectOption[]; + /** Allow typing to filter entity choices by label, value, or hint. Defaults to false. */ + filterable?: boolean; /** Initially highlighted value. Defaults to the first option. */ initialValue?: T; }; diff --git a/packages/cli/src/lib/init/ui/wizard-store.ts b/packages/cli/src/lib/init/ui/wizard-store.ts index 2f0584dd7..756206772 100644 --- a/packages/cli/src/lib/init/ui/wizard-store.ts +++ b/packages/cli/src/lib/init/ui/wizard-store.ts @@ -124,6 +124,8 @@ export type ActivePrompt = footer?: PromptDetail; /** Choices presented to the user in display order. */ options: PromptOption[]; + /** Allow typing to filter this list of entities. Defaults to false. */ + filterable?: boolean; /** Zero-based option index highlighted when the prompt mounts. */ initialIndex: number; /** Completes the prompt with a value, or `null` on cancellation. */ diff --git a/packages/cli/src/lib/resolve-target.ts b/packages/cli/src/lib/resolve-target.ts index d88350114..2111d6434 100644 --- a/packages/cli/src/lib/resolve-target.ts +++ b/packages/cli/src/lib/resolve-target.ts @@ -1808,7 +1808,7 @@ function canPromptForTarget(override?: boolean): boolean { } /** - * Prompt the user (consola) to pick a value from a list, returning the selected + * Prompt the user to filter and pick a value from a list, returning the selected * value or `null` if cancelled. A single-element list is auto-selected without * prompting. */ @@ -1820,9 +1820,8 @@ async function promptSelect( if (options.length === 1 && sole) { return sole.value; } - const response = await log.prompt(message, { type: "select", options }); - // consola returns Symbol(clack:cancel) on Ctrl+C — a truthy non-string. - return typeof response === "string" ? response : null; + const { selectWithFilter } = await import("./init/ui/ink-ui.js"); + return selectWithFilter({ message, options }); } /** diff --git a/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx b/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx index 547895f2e..39ffe8f90 100644 --- a/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx +++ b/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx @@ -22,6 +22,7 @@ import { import { App, formatFeedbackBanner, + SelectPicker, } from "../../../../src/lib/init/ui/ink-app.js"; import { WizardStore } from "../../../../src/lib/init/ui/wizard-store.js"; @@ -110,11 +111,16 @@ function makeStdin(): Readable { async function renderApp( store: WizardStore, columns: number, - options: { rows?: number; input?: string[] } = {}, + options: { rows?: number; input?: string[]; standalone?: boolean } = {}, ): Promise { const out = new CaptureStream(columns, options.rows ?? 40); const stdin = makeStdin(); - const instance = render(createElement(App, { store }), { + const prompt = store.getSnapshot().prompt; + const component = + options.standalone && prompt?.kind === "select" + ? createElement(SelectPicker, { prompt }) + : createElement(App, { store }); + const instance = render(component, { stdout: out as unknown as NodeJS.WriteStream, stderr: out as unknown as NodeJS.WriteStream, stdin: stdin as unknown as NodeJS.ReadStream, @@ -123,7 +129,8 @@ async function renderApp( }); for (const input of options.input ?? []) { stdin.push(input); - await sleep(20); + // Ink buffers bare Esc for 20ms to distinguish it from escape sequences. + await sleep(input === "\u001b" ? FRAME_SETTLE_MS : 20); } await sleep(FRAME_SETTLE_MS); out.settledOutput = out.allOutput(); @@ -1083,6 +1090,398 @@ describe("Ink App snapshot", () => { expect(frame).not.toContain("switch tab"); }); + test("typing filters entity names and slugs and confirms the visible choice", async () => { + const store = new WizardStore({ bannerRows: [], layout: "intro" }); + const resolve = vi.fn(); + store.setPrompt({ + kind: "select", + message: "Choose an organization", + filterable: true, + options: [ + { value: "backend", label: "Backend" }, + { value: "front-admin", label: "Administration", hint: "front-admin" }, + { value: "web", label: "Frontend" }, + ], + initialIndex: 0, + resolve, + }); + + const output = await renderApp(store, 120, { + input: ["FRONT", DOWN_ARROW, "\r"], + }); + const frame = stripAnsi(output.latestFrame()); + expect(frame).toContain("/FRONT"); + expect(frame).toContain("2 of 3"); + expect(frame).toContain("Administration"); + expect(frame).not.toContain("Backend"); + expect(resolve).toHaveBeenCalledWith("web"); + }); + + test.each([false, true])( + "typing a typo filters and confirms the matching entity (standalone=%s)", + async (standalone) => { + const store = new WizardStore({ bannerRows: [] }); + const resolve = vi.fn(); + store.setPrompt({ + kind: "select", + message: "Choose a project", + filterable: true, + options: [ + { value: "backend", label: "Backend" }, + { value: "frontend", label: "Frontend" }, + ], + initialIndex: 0, + resolve, + }); + + const output = await renderApp(store, 80, { + standalone, + input: ["\u001b[200~fronend\u001b[201~\r"], + }); + const frame = stripAnsi(output.latestFrame()); + expect(frame).toContain("/fronend 1 of 2"); + expect(frame).toContain("Frontend"); + expect(frame).not.toContain("Backend"); + expect(resolve).toHaveBeenCalledExactlyOnceWith("frontend"); + }, + ); + + test("zero matches cannot be confirmed and Backspace restores matches", async () => { + const store = new WizardStore({ bannerRows: [] }); + const resolve = vi.fn(); + store.setPrompt({ + kind: "select", + message: "Choose a team", + filterable: true, + options: [{ value: "frontend", label: "Web team", hint: "Frontend" }], + initialIndex: 0, + resolve, + }); + + const output = new CaptureStream(120); + const stdin = makeStdin(); + const instance = render(createElement(App, { store }), { + // CI defaults to non-interactive output, which skips intermediate frames. + interactive: true, + stdout: output as unknown as NodeJS.WriteStream, + stderr: output as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + exitOnCtrlC: false, + }); + try { + stdin.push("frontz"); + // Observe the empty state before later input can coalesce its render. + await vi.waitFor(() => { + expect(stripAnsi(output.latestFrame())).toContain("No matches"); + }); + + stdin.push(`${DOWN_ARROW}\r`); + await instance.waitUntilRenderFlush(); + expect(resolve).not.toHaveBeenCalled(); + + stdin.push("\u007f\r"); + await vi.waitFor(() => { + expect(resolve).toHaveBeenCalledExactlyOnceWith("frontend"); + expect(stripAnsi(output.latestFrame())).toContain("/front 1 of 1"); + }); + } finally { + instance.unmount(); + await instance.waitUntilExit(); + } + }); + + test.each([ + { + action: "confirms the first filtered option", + navigation: "", + standalone: false, + expected: "frontend-a", + }, + { + action: "wraps up to the last filtered option", + navigation: "\u001b[A", + standalone: true, + expected: "frontend-b", + }, + { + action: "wraps down within the filtered options", + navigation: DOWN_ARROW.repeat(2), + standalone: true, + expected: "frontend-a", + }, + ])( + "same-chunk paste and Enter $action", + async ({ navigation, standalone, expected }) => { + const store = new WizardStore({ bannerRows: [] }); + const resolve = vi.fn(); + store.setPrompt({ + kind: "select", + message: "Choose a project", + filterable: true, + options: [ + { value: "backend", label: "Backend" }, + { value: "frontend-a", label: "Frontend A" }, + { value: "frontend-b", label: "Frontend B" }, + ], + initialIndex: 0, + resolve, + }); + + const output = await renderApp(store, 80, { + standalone, + input: [`\u001b[200~front\u001b[201~${navigation}\r`], + }); + expect(stripAnsi(output.latestFrame())).toContain("/front 2 of 3"); + expect(resolve).toHaveBeenCalledExactlyOnceWith(expected); + }, + ); + + test("same-chunk paste and Enter cannot confirm a filter with no matches", async () => { + const store = new WizardStore({ bannerRows: [] }); + const resolve = vi.fn(); + store.setPrompt({ + kind: "select", + message: "Choose an organization", + filterable: true, + options: [{ value: "backend", label: "Backend" }], + initialIndex: 0, + resolve, + }); + + const output = await renderApp(store, 80, { + input: ["\u001b[200~missing\u001b[201~\r"], + }); + expect(stripAnsi(output.latestFrame())).toContain("No matches"); + expect(resolve).not.toHaveBeenCalled(); + }); + + test("same-chunk Backspace and Enter confirm a restored filter match", async () => { + const store = new WizardStore({ bannerRows: [] }); + const resolve = vi.fn(); + store.setPrompt({ + kind: "select", + message: "Choose a team", + filterable: true, + options: [ + { value: "backend", label: "Backend" }, + { value: "frontend", label: "Frontend" }, + ], + initialIndex: 0, + resolve, + }); + + const output = await renderApp(store, 80, { + input: ["\u001b[200~frontz\u001b[201~\u007f\r"], + }); + expect(stripAnsi(output.latestFrame())).toContain("/front 1 of 2"); + expect(resolve).toHaveBeenCalledExactlyOnceWith("frontend"); + }); + + test.each([ + { key: "Backspace", input: "\u007f", standalone: false }, + { key: "Delete", input: "\u001b[3~", standalone: true }, + ])( + "$key preserves the selection when the filter is empty", + async ({ input, standalone }) => { + const store = new WizardStore({ bannerRows: [] }); + const resolve = vi.fn(); + store.setPrompt({ + kind: "select", + message: "Choose a project", + filterable: true, + options: [ + { value: "backend", label: "Backend" }, + { value: "frontend", label: "Frontend" }, + { value: "worker", label: "Worker" }, + ], + initialIndex: 1, + resolve, + }); + + const output = await renderApp(store, 80, { + standalone, + input: [`${DOWN_ARROW}${input}\r`], + }); + expect(stripAnsi(output.latestFrame())).toContain("type to filter"); + expect(resolve).toHaveBeenCalledExactlyOnceWith("worker"); + }, + ); + + test("Esc clears the filter before cancelling, while Ctrl+C cancels directly", async () => { + const store = new WizardStore({ bannerRows: [] }); + const resolve = vi.fn(); + store.setPrompt({ + kind: "select", + message: "Choose a team", + filterable: true, + options: [{ value: "frontend", label: "Frontend" }], + initialIndex: 0, + resolve, + }); + const cleared = await renderApp(store, 120, { input: ["front", "\u001b"] }); + expect(stripAnsi(cleared.latestFrame())).toContain("type to filter"); + expect(resolve).not.toHaveBeenCalled(); + + await renderApp(store, 120, { input: ["front", "\u001b", "\u001b"] }); + expect(resolve).toHaveBeenCalledExactlyOnceWith(null); + resolve.mockClear(); + await renderApp(store, 120, { input: ["front", "\u0003"] }); + expect(resolve).toHaveBeenCalledExactlyOnceWith(null); + }); + + test("successive entity prompts reset their filter and initial selection", async () => { + const store = new WizardStore({ bannerRows: [] }); + const resolveTeam = vi.fn(); + store.setPrompt({ + kind: "select", + message: "Choose an organization", + filterable: true, + options: [{ value: "frontend", label: "Frontend" }], + initialIndex: 0, + resolve: () => + store.setPrompt({ + kind: "select", + message: "Choose a team", + filterable: true, + options: [ + { value: "backend", label: "Backend" }, + { value: "mobile", label: "Mobile" }, + ], + initialIndex: 1, + resolve: resolveTeam, + }), + }); + const output = await renderApp(store, 120, { + input: ["front", "\r", "\r"], + }); + expect(stripAnsi(output.latestFrame())).toContain("type to filter"); + expect(stripAnsi(output.latestFrame())).not.toContain("/front"); + expect(resolveTeam).toHaveBeenCalledExactlyOnceWith("mobile"); + }); + + test("action menus ignore typed text", async () => { + const store = new WizardStore({ bannerRows: [] }); + const resolve = vi.fn(); + store.setPrompt({ + kind: "select", + message: "Choose an action", + options: [{ value: "continue", label: "Continue" }], + initialIndex: 0, + resolve, + }); + const output = await renderApp(store, 120, { input: ["xyz", "\r"] }); + expect(stripAnsi(output.latestFrame())).not.toContain("type to filter"); + expect(stripAnsi(output.latestFrame())).not.toContain("/xyz"); + expect(resolve).toHaveBeenCalledExactlyOnceWith("continue"); + }); + + test("the compact project picker filters with the same keyboard controls", async () => { + const store = new WizardStore({ bannerRows: [] }); + const resolve = vi.fn(); + store.setPrompt({ + kind: "select", + message: "Select a project:", + filterable: true, + options: [ + { value: "backend", label: "Backend" }, + { value: "frontend-web", label: "Web", hint: "frontend-web" }, + ], + initialIndex: 0, + resolve, + }); + const output = await renderApp(store, 80, { + standalone: true, + input: ["front", "\r"], + }); + expect(stripAnsi(output.latestFrame())).toContain("/front"); + expect(stripAnsi(output.latestFrame())).toContain("1 of 2"); + expect(resolve).toHaveBeenCalledExactlyOnceWith("frontend-web"); + }); + + test("filterable lists keep the query inside a narrow terminal", async () => { + const store = new WizardStore({ + bannerRows: FULL_BANNER_LINES, + layout: "intro", + }); + store.setPrompt({ + kind: "select", + message: "Choose a team", + filterable: true, + options: Array.from({ length: 20 }, (_, index) => ({ + value: `team-${index}`, + label: `${index < 11 ? "Frontend" : "Backend"} ${index}`, + })), + initialIndex: 0, + resolve: ignorePromptResolution, + }); + const output = await renderApp(store, 40, { + rows: 24, + input: ["frontend", DOWN_ARROW], + }); + const frame = stripFinalLineBreak(stripAnsi(output.latestFrame())); + expect(frame).toContain("/frontend"); + expect(frame).toContain("11 of 20"); + expect(frame.split(LINE_SPLIT_RE).length).toBeLessThanOrEqual(24); + expect(frame.split(LINE_SPLIT_RE).every((line) => line.length <= 40)).toBe( + true, + ); + }); + + test.each([ + { layout: "intro" as const, standalone: false, optionCount: 1 }, + { layout: "intro" as const, standalone: false, optionCount: 3 }, + { layout: "intro" as const, standalone: false, optionCount: 20 }, + { layout: "standard" as const, standalone: false, optionCount: 1 }, + { layout: "standard" as const, standalone: false, optionCount: 3 }, + { layout: "standard" as const, standalone: false, optionCount: 20 }, + { layout: "standard" as const, standalone: true, optionCount: 1 }, + { layout: "standard" as const, standalone: true, optionCount: 3 }, + { layout: "standard" as const, standalone: true, optionCount: 20 }, + ])( + "keeps the filter row fixed with $optionCount options (layout=$layout, standalone=$standalone)", + async ({ layout, standalone, optionCount }) => { + const store = new WizardStore({ + bannerRows: FULL_BANNER_LINES, + ...(layout === "intro" ? { layout } : {}), + }); + store.setPrompt({ + kind: "select", + message: "Which team should own this project?", + filterable: true, + options: Array.from({ length: optionCount }, (_, index) => ({ + value: index === 0 ? "frontend" : `team-${index}`, + label: index === 0 ? "Frontend" : `Team ${index}`, + })), + initialIndex: 0, + resolve: ignorePromptResolution, + }); + + let initialFilterRow = -1; + for (const { input, status } of [ + { input: [], status: "type to filter" }, + { input: ["fronend"], status: `/fronend 1 of ${optionCount}` }, + { input: ["missing"], status: `/missing 0 of ${optionCount}` }, + { input: ["missing", "\u001b"], status: "type to filter" }, + ]) { + const output = await renderApp(store, 40, { + rows: 24, + standalone, + input, + }); + const frame = stripFinalLineBreak(stripAnsi(output.latestFrame())); + const lines = frame.split(LINE_SPLIT_RE); + const filterRow = lines.findIndex((line) => line.includes(status)); + expect(filterRow).toBeGreaterThanOrEqual(0); + if (initialFilterRow === -1) { + initialFilterRow = filterRow; + } + expect(filterRow).toBe(initialFilterRow); + expect(lines.length).toBeLessThanOrEqual(24); + } + }, + ); + test("long select prompts only render options that fit the terminal", async () => { const store = new WizardStore({ bannerRows: FULL_BANNER_LINES, diff --git a/packages/cli/test/lib/init/ui/ink-select.test.ts b/packages/cli/test/lib/init/ui/ink-select.test.ts new file mode 100644 index 000000000..4fc732682 --- /dev/null +++ b/packages/cli/test/lib/init/ui/ink-select.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { ActivePrompt } from "../../../../src/lib/init/ui/wizard-store.js"; + +type SelectPrompt = Extract; + +const { mountSelectPrompt, terminal } = vi.hoisted(() => ({ + mountSelectPrompt: vi.fn(), + terminal: { + isRaw: false, + setRawMode: vi.fn(), + pause: vi.fn(), + destroy: vi.fn(), + }, +})); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + openSync: vi.fn((...args: Parameters) => + args[0] === "/dev/tty" ? 99 : actual.openSync(...args), + ), + }; +}); +vi.mock("node:tty", async (importOriginal) => ({ + ...(await importOriginal()), + ReadStream: class { + isRaw = terminal.isRaw; + setRawMode = terminal.setRawMode; + pause = terminal.pause; + destroy = terminal.destroy; + }, +})); +vi.mock("../../../../src/lib/init/ui/ink-app.tsx", () => ({ + default: new URL("../../../../src/lib/init/ui/ink-app.tsx", import.meta.url) + .pathname, + mountSelectPrompt, +})); + +import { selectWithFilter } from "../../../../src/lib/init/ui/ink-ui.js"; + +const OPTIONS = { + message: "Select a project:", + options: [ + { value: "frontend", label: "Web Application" }, + { value: "backend", label: "API Service" }, + ], + initialValue: "backend", +}; + +let exit: () => void; +let instance: { + clear: ReturnType; + unmount: ReturnType; + waitUntilExit: ReturnType; +}; + +beforeEach(() => { + vi.clearAllMocks(); + const exited = new Promise((resolve) => { + exit = resolve; + }); + instance = { + clear: vi.fn(), + unmount: vi.fn(() => exit()), + waitUntilExit: vi.fn(() => exited), + }; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("inline filterable select", () => { + test("returns the choice and releases the prompt and terminal", async () => { + mountSelectPrompt.mockImplementation((prompt: SelectPrompt) => { + queueMicrotask(() => prompt.resolve("backend")); + return instance; + }); + + await expect(selectWithFilter(OPTIONS)).resolves.toBe("backend"); + + expect(mountSelectPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + message: OPTIONS.message, + options: OPTIONS.options, + filterable: true, + initialIndex: 1, + }), + { stdin: terminal, exitOnCtrlC: false, patchConsole: false }, + ); + expect(instance.clear).toHaveBeenCalledOnce(); + expect(instance.unmount).toHaveBeenCalledOnce(); + expect(terminal.setRawMode).toHaveBeenCalledWith(false); + expect(terminal.destroy).toHaveBeenCalledOnce(); + }); + + test("cancels on SIGINT and removes the handler after restoring the terminal", async () => { + const initialHandlers = process.listeners("SIGINT"); + mountSelectPrompt.mockImplementation(() => { + queueMicrotask(() => { + // The test preload also handles SIGINT by exiting the worker. + // Invoke only the picker listener so the harness remains alive. + const cancel = process + .listeners("SIGINT") + .find((handler) => !initialHandlers.includes(handler)); + cancel?.(); + }); + return instance; + }); + + await expect(selectWithFilter(OPTIONS)).resolves.toBeNull(); + + expect(process.listeners("SIGINT")).toEqual(initialHandlers); + expect(terminal.setRawMode).toHaveBeenCalledWith(false); + expect(terminal.destroy).toHaveBeenCalledOnce(); + }); + + test("restores and closes the terminal when mounting fails", async () => { + const error = new Error("Unable to mount picker"); + mountSelectPrompt.mockImplementation(() => { + throw error; + }); + + await expect(selectWithFilter(OPTIONS)).rejects.toBe(error); + + expect(terminal.setRawMode).toHaveBeenCalledWith(false); + expect(terminal.destroy).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/cli/test/lib/init/ui/select-filter.test.ts b/packages/cli/test/lib/init/ui/select-filter.test.ts new file mode 100644 index 000000000..a5dbf6e43 --- /dev/null +++ b/packages/cli/test/lib/init/ui/select-filter.test.ts @@ -0,0 +1,113 @@ +import { + array, + constantFrom, + assert as fcAssert, + property, + record, + string, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + filterSelectOptions, + removeLastFilterCharacter, + sanitizeSelectFilterInput, +} from "../../../../src/lib/init/ui/select-filter.js"; +import { DEFAULT_NUM_RUNS } from "../../../model-based/helpers.js"; + +const options = [ + { value: "frontend-web", label: "Web", hint: "JavaScript" }, + { value: "api", label: "Backend", hint: "Python" }, + { value: "mobile", label: "Frontend Mobile" }, +] as const; + +describe("filterSelectOptions", () => { + test("matches names, slugs, and hints without case sensitivity or reordering", () => { + expect(filterSelectOptions(options, "FRONT")).toEqual([ + options[0], + options[2], + ]); + expect(filterSelectOptions(options, "python")).toEqual([options[1]]); + expect(filterSelectOptions(options, "missing")).toEqual([]); + }); + + test.each([ + { field: "label", query: "BAKEND", expected: options[1] }, + { field: "value", query: "fronted-web", expected: options[0] }, + { field: "hint", query: "Pythn", expected: options[1] }, + ])("tolerates typos in the $field", ({ query, expected }) => { + expect(filterSelectOptions(options, query)).toEqual([expected]); + }); + + test("missing or empty hints do not create unrelated fuzzy matches", () => { + expect( + filterSelectOptions( + [ + { value: "frontend", label: "Web client" }, + { value: "backend", label: "API server", hint: "" }, + ], + "zz", + ), + ).toEqual([]); + }); + + test("an empty filter restores every original option", () => { + fcAssert( + property( + array(record({ value: string(), label: string(), hint: string() })), + (generatedOptions) => { + expect(filterSelectOptions(generatedOptions, "")).toBe( + generatedOptions, + ); + }, + ), + { numRuns: DEFAULT_NUM_RUNS }, + ); + }); + + test("matches are original options in their original order without duplicates", () => { + fcAssert( + property( + array(record({ value: string(), label: string(), hint: string() })), + string(), + (generatedOptions, query) => { + const matches = filterSelectOptions(generatedOptions, query); + let previousIndex = -1; + for (const match of matches) { + const index = generatedOptions.indexOf(match); + expect(index).toBeGreaterThan(previousIndex); + previousIndex = index; + } + }, + ), + { numRuns: DEFAULT_NUM_RUNS }, + ); + }); +}); + +describe("sanitizeSelectFilterInput", () => { + test("keeps ordinary pasted text while removing terminal escapes and controls", () => { + expect( + sanitizeSelectFilterInput( + "\u001b[31mFront\u001b[0m\t End\r\n\u0000\u007f", + ), + ).toBe("Front End"); + expect(sanitizeSelectFilterInput("Equipo 👩🏽‍💻")).toBe("Equipo 👩🏽‍💻"); + expect(sanitizeSelectFilterInput("\u001b[A")).toBe(""); + }); +}); + +describe("removeLastFilterCharacter", () => { + test("removes the final displayed character without breaking Unicode", () => { + fcAssert( + property( + array(constantFrom("a", " ", "é", "e\u0301", "👩🏽‍💻", "🇪🇸")), + (characters) => { + expect(removeLastFilterCharacter(characters.join(""))).toBe( + characters.slice(0, -1).join(""), + ); + }, + ), + { numRuns: DEFAULT_NUM_RUNS }, + ); + }); +}); diff --git a/packages/cli/test/lib/resolve-target-picker.test.ts b/packages/cli/test/lib/resolve-target-picker.test.ts new file mode 100644 index 000000000..756b6ea09 --- /dev/null +++ b/packages/cli/test/lib/resolve-target-picker.test.ts @@ -0,0 +1,168 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; +import { setAuthToken } from "../../src/lib/db/auth.js"; +import { + getDefaultOrganization, + getDefaultProject, + setDefaultOrganization, + setDefaultProject, +} from "../../src/lib/db/defaults.js"; +import { ContextError } from "../../src/lib/errors.js"; +import { guideOrgProjectFailure } from "../../src/lib/resolve-target.js"; +import organizationsFixture from "../fixtures/organizations.json"; +import projectsFixture from "../fixtures/projects.json"; +import { useEnvSandbox, useTestConfigDir } from "../helpers.js"; +import { createMockServer, type MockServer } from "../mocks/server.js"; + +const { selectWithFilter } = vi.hoisted(() => ({ selectWithFilter: vi.fn() })); +vi.mock("../../src/lib/init/ui/ink-ui.js", () => ({ selectWithFilter })); + +useTestConfigDir("test-target-picker-"); +useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_URL", + "SENTRY_HOST", + "SENTRY_ORG", + "SENTRY_PROJECT", +]); + +let originalFetch: typeof globalThis.fetch; +let server: MockServer; +let organizations: typeof organizationsFixture; +let projects: typeof projectsFixture; +let requestedPaths: string[]; + +// Keep one origin for the process-level region cache; each case gets a fresh DB. +beforeAll(async () => { + server = createMockServer( + [ + { + method: "GET", + path: "/api/0/organizations/", + response: (request: Request) => { + requestedPaths.push(new URL(request.url).pathname); + return { body: organizations }; + }, + }, + { + method: "GET", + path: "/api/0/organizations/:orgSlug/projects/", + response: (request: Request) => { + requestedPaths.push(new URL(request.url).pathname); + return { body: projects }; + }, + }, + ], + { validTokens: ["test-picker-token"] }, + ); + await server.start(); +}); + +afterAll(() => { + server?.stop(); +}); + +beforeEach(() => { + selectWithFilter.mockReset(); + originalFetch = globalThis.fetch; + organizations = organizationsFixture; + projects = projectsFixture; + requestedPaths = []; + const realFetch = (globalThis as { __originalFetch?: typeof fetch }) + .__originalFetch; + if (!realFetch) { + throw new Error( + "Test preload did not preserve the real fetch implementation", + ); + } + process.env.SENTRY_URL = server.url; + setAuthToken("test-picker-token"); + globalThis.fetch = realFetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("interactive org/project resolution", () => { + test("passes names and slugs to filterable pickers and persists the selected target", async () => { + selectWithFilter + .mockResolvedValueOnce("test-org-2") + .mockResolvedValueOnce("test-project-2"); + + const target = await guideOrgProjectFailure({ interactive: true }); + + expect(target).toMatchObject({ + org: "test-org-2", + project: "test-project-2", + }); + expect(selectWithFilter).toHaveBeenNthCalledWith(1, { + message: "Select an organization:", + options: organizationsFixture.map((org) => ({ + label: org.name, + value: org.slug, + })), + }); + expect(selectWithFilter).toHaveBeenNthCalledWith(2, { + message: "Select a project:", + options: projectsFixture.map((project) => ({ + label: project.name, + value: project.slug, + })), + }); + expect(requestedPaths).toEqual([ + "/api/0/organizations/", + "/api/0/organizations/test-org-2/projects/", + ]); + expect(getDefaultOrganization()).toBe("test-org-2"); + expect(getDefaultProject()).toBe("test-project-2"); + }); + + test("preserves saved defaults when the project picker is cancelled", async () => { + setDefaultOrganization("saved-org"); + setDefaultProject("saved-project"); + selectWithFilter + .mockResolvedValueOnce("test-org") + .mockResolvedValueOnce(null); + + await expect( + guideOrgProjectFailure({ interactive: true }), + ).rejects.toBeInstanceOf(ContextError); + + expect(getDefaultOrganization()).toBe("saved-org"); + expect(getDefaultProject()).toBe("saved-project"); + }); + + test("auto-selects and persists sole options without loading a picker", async () => { + organizations = organizationsFixture.slice(0, 1); + projects = projectsFixture.slice(0, 1); + + const target = await guideOrgProjectFailure({ interactive: true }); + + expect(target).toMatchObject({ org: "test-org", project: "test-project" }); + expect(selectWithFilter).not.toHaveBeenCalled(); + expect(getDefaultOrganization()).toBe("test-org"); + expect(getDefaultProject()).toBe("test-project"); + }); + + test("surfaces renderer failures without persisting a target or retrying the prompt", async () => { + const error = new Error("Unable to render picker"); + selectWithFilter.mockRejectedValue(error); + + await expect(guideOrgProjectFailure({ interactive: true })).rejects.toBe( + error, + ); + expect(selectWithFilter).toHaveBeenCalledTimes(1); + expect(getDefaultOrganization()).toBeNull(); + expect(getDefaultProject()).toBeNull(); + }); +});