/* @jsxImportSource react */ import { render as renderInkTest } from 'ink-testing-library'; import { type ReactNode } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { buildInitialState, buildPickerTree, type TreePickerNodeInput } from '../src/tree-picker-state.js'; import { TreePickerApp, renderTreePickerTui, resolveTreePickerWidth, sanitizeTreePickerTuiError, treePickerCommandForInkInput, windowItems, windowOffset, type TreePickerChrome, type TreePickerInkInstance, type TreePickerInkRenderOptions, } from '../src/tree-picker-tui.js'; const IDS = { engineering: '11111111-1111-1111-1111-111111111111', architecture: '22222222-2222-2222-2222-222222222222', marketing: '33333333-3333-3333-3333-333333333333', finance: '44444444-4444-4444-4444-444444444444', ops: '55555555-5555-5555-5555-555555555555', sales: '66666666-6666-6666-6666-666666666666', support: '77777777-7777-7777-7777-777777777777', product: '88888888-8888-8888-8888-888888888888', design: '99999999-9999-9999-9999-999999999999', }; function pages(): TreePickerNodeInput[] { return [ { id: IDS.engineering, title: 'Engineering Docs', archived: false, parentId: null }, { id: IDS.architecture, title: 'Architecture', archived: false, parentId: IDS.engineering }, { id: IDS.marketing, title: 'Marketing', archived: false, parentId: null }, ]; } function manyPages(): TreePickerNodeInput[] { return [ { id: IDS.engineering, title: 'Engineering Docs', archived: false, parentId: null }, { id: IDS.architecture, title: 'Architecture', archived: false, parentId: IDS.engineering }, { id: IDS.marketing, title: 'Marketing', archived: false, parentId: null }, { id: IDS.finance, title: 'Finance', archived: false, parentId: null }, { id: IDS.ops, title: 'Operations', archived: false, parentId: null }, { id: IDS.sales, title: 'Sales', archived: false, parentId: null }, { id: IDS.support, title: 'Support', archived: false, parentId: null }, { id: IDS.product, title: 'Product', archived: false, parentId: null }, { id: IDS.design, title: 'Design', archived: false, parentId: null }, ]; } function state(options: { requireConfirmOnSave?: boolean } = {}) { return buildInitialState({ tree: buildPickerTree(pages()), existingSelectedIds: [], requireConfirmOnSave: options.requireConfirmOnSave ?? false, }); } function chrome(overrides: Partial = {}): TreePickerChrome { return { title: 'Select items', subtitleLines: ['Source: Test'], ...overrides, }; } async function waitForInkInput(): Promise { await new Promise((resolve) => setTimeout(resolve, 10)); } function fakeInkInstance(): TreePickerInkInstance { return { rerender: vi.fn(), unmount: vi.fn(), waitUntilExit: vi.fn(async () => undefined), }; } function normalizeFrameWrap(frame: string | undefined): string { return frame?.replace(/\n/g, ' ').replace(/│ /g, '').replace(/ +/g, ' ') ?? ''; } describe('treePickerCommandForInkInput', () => { const browse = (overrides: Partial<{ search: { query: string }; isNavigating: boolean; pendingConfirm: null }> = {}) => ({ search: { query: '' }, isNavigating: false, pendingConfirm: null, ...overrides, }); const confirming = { ...browse(), pendingConfirm: 'save-confirm' as const }; it('routes cursor and confirm keys when no query is typed', () => { expect(treePickerCommandForInkInput('', { downArrow: true }, browse())).toBe('cursor-down'); expect(treePickerCommandForInkInput('', { upArrow: true }, browse())).toBe('cursor-up'); expect(treePickerCommandForInkInput('', { rightArrow: true }, browse())).toBe('cursor-right'); expect(treePickerCommandForInkInput('', { leftArrow: true }, browse())).toBe('cursor-left'); expect(treePickerCommandForInkInput('', { return: true }, browse())).toBe('save-request'); expect(treePickerCommandForInkInput('', { escape: true }, browse())).toBe('quit'); expect(treePickerCommandForInkInput('c', { ctrl: true }, browse())).toBe('quit'); }); it('Tab toggles selection regardless of search/navigation state', () => { expect(treePickerCommandForInkInput('', { tab: true }, browse())).toBe('toggle-check'); expect(treePickerCommandForInkInput('', { tab: true }, browse({ search: { query: 'foo' }, isNavigating: false }))).toBe( 'toggle-check', ); expect(treePickerCommandForInkInput('', { tab: true }, browse({ isNavigating: true }))).toBe('toggle-check'); }); it('Space toggles only when navigating; otherwise typed into the search query', () => { expect(treePickerCommandForInkInput(' ', {}, browse({ isNavigating: true }))).toBe('toggle-check'); expect(treePickerCommandForInkInput(' ', {}, browse({ isNavigating: false }))).toEqual({ type: 'search-input', value: ' ', }); }); it('typed printable chars feed the search query — including a, n, and slash', () => { expect(treePickerCommandForInkInput('a', {}, browse())).toEqual({ type: 'search-input', value: 'a' }); expect(treePickerCommandForInkInput('n', {}, browse())).toEqual({ type: 'search-input', value: 'n' }); expect(treePickerCommandForInkInput('/', {}, browse())).toEqual({ type: 'search-input', value: '/' }); expect(treePickerCommandForInkInput('x', {}, browse({ search: { query: 'foo' } }))).toEqual({ type: 'search-input', value: 'x', }); }); it('Ctrl+A and Ctrl+N drive the bulk toggle helpers', () => { expect(treePickerCommandForInkInput('a', { ctrl: true }, browse())).toBe('toggle-select-all-visible'); expect(treePickerCommandForInkInput('n', { ctrl: true }, browse())).toBe('select-none'); }); it('Backspace deletes from the query at any time; Esc clears query first then quits', () => { expect(treePickerCommandForInkInput('', { backspace: true }, browse({ search: { query: 'x' } }))).toBe( 'search-backspace', ); expect(treePickerCommandForInkInput('', { delete: true }, browse({ search: { query: 'x' } }))).toBe( 'search-backspace', ); expect(treePickerCommandForInkInput('', { escape: true }, browse({ search: { query: 'x' } }))).toBe('search-clear'); expect(treePickerCommandForInkInput('', { escape: true }, browse())).toBe('quit'); }); it('confirm prompts intercept y/n/Enter/Esc before search routing', () => { expect(treePickerCommandForInkInput('y', {}, confirming)).toBe('save-confirm'); expect(treePickerCommandForInkInput('', { return: true }, confirming)).toBe('save-confirm'); expect(treePickerCommandForInkInput('n', {}, confirming)).toBe('save-cancel'); expect(treePickerCommandForInkInput('', { escape: true }, confirming)).toBe('save-cancel'); }); }); describe('window helpers', () => { it('centers the selected row and returns the visible slice', () => { expect(windowOffset(20, 10, 5)).toBe(8); expect(windowItems(['a', 'b', 'c', 'd', 'e'], 3, 3)).toEqual({ items: ['c', 'd', 'e'], offset: 2 }); }); it('clamps picker width to the design rule', () => { expect(resolveTreePickerWidth(200)).toBe(120); expect(resolveTreePickerWidth(100)).toBe(96); expect(resolveTreePickerWidth(50)).toBe(60); expect(resolveTreePickerWidth(undefined)).toBe(96); }); }); describe('TreePickerApp', () => { it('renders chrome title, subtitle, warnings, help, and row glyphs', () => { const initialState = { ...state(), preLoadWarnings: ['1 stale stored selections - they will be removed if you save'], }; const { lastFrame } = renderInkTest( , ); const frame = lastFrame() ?? ''; expect(frame).toContain('Select fancy widgets'); expect(frame).toContain('Workspace: Design Workspace'); expect(frame).toContain('5000-page cap reached - some pages not shown'); expect(frame).toContain('1 stale stored selections - they will be removed if you save'); expect(frame).toContain('◻ Engineering Docs ▸ (1)'); expect(frame).toContain('◻ Marketing'); expect(normalizeFrameWrap(frame)).toContain( 'Up/Down to move, Right/Left to expand or collapse, Tab to select, Type to search, Enter to confirm, Escape to clear search or go back, Ctrl+C to exit.', ); expect(frame).toContain('Search:'); }); it('renders custom help text when supplied', () => { const { lastFrame } = renderInkTest( , ); expect(lastFrame() ?? '').toContain('Bespoke instructions here.'); }); it('renders checked parents and locked descendants with locked glyphs', () => { const initialState = { ...state(), checked: new Set([IDS.engineering]), expanded: new Set([IDS.engineering]), }; const { lastFrame } = renderInkTest( , ); const frame = lastFrame() ?? ''; expect(frame).toContain('◼ Engineering Docs ▾'); expect(frame).toContain(' ◼ Architecture'); }); it('renders the partial glyph on a parent whose descendant is checked', () => { const partialPages: TreePickerNodeInput[] = [ { id: IDS.engineering, title: 'Engineering Docs', archived: false, parentId: null }, { id: IDS.architecture, title: 'Architecture', archived: false, parentId: IDS.engineering }, ]; const initialState = buildInitialState({ tree: buildPickerTree(partialPages), existingSelectedIds: [IDS.architecture], }); const { lastFrame } = renderInkTest( , ); const frame = lastFrame() ?? ''; expect(frame).toContain('◧ Engineering Docs ▾'); expect(frame).toContain(' ◼ Architecture'); expect(frame).not.toContain('◻ Engineering Docs'); }); it('supports keyboard selection, confirm-on-save, and save callback', async () => { const onExit = vi.fn(); const { stdin, lastFrame } = renderInkTest( `Confirm: ${current.checked.size} item${current.checked.size === 1 ? '' : 's'}? Press Enter or Escape.`, })} terminalRows={24} terminalWidth={100} onExit={onExit} />, ); stdin.write('\t'); await waitForInkInput(); expect(lastFrame()).toContain('◼ Engineering Docs'); stdin.write('\r'); await waitForInkInput(); expect(normalizeFrameWrap(lastFrame())).toContain('Confirm: 1 item? Press Enter or Escape.'); stdin.write('y'); await waitForInkInput(); expect(onExit).toHaveBeenCalledWith({ kind: 'save', selectedIds: [IDS.engineering] }); }); it('uses the chrome-supplied skip-empty message and quits on confirm', async () => { const onExit = vi.fn(); const { stdin, lastFrame } = renderInkTest( , ); stdin.write('\r'); await waitForInkInput(); expect(normalizeFrameWrap(lastFrame())).toContain('No selections. Skip or back?'); expect(onExit).not.toHaveBeenCalled(); stdin.write('n'); await waitForInkInput(); expect(lastFrame()).not.toContain('No selections. Skip or back?'); expect(onExit).not.toHaveBeenCalled(); stdin.write('\r'); await waitForInkInput(); expect(lastFrame()).toContain('No selections. Skip or back?'); stdin.write('\r'); await waitForInkInput(); expect(onExit).toHaveBeenCalledWith({ kind: 'quit' }); }); it('renders row-window overflow indicators when the visible list is clipped', async () => { const onExit = vi.fn(); const initialState = buildInitialState({ tree: buildPickerTree(manyPages()), existingSelectedIds: [], }); initialState.expanded = new Set([IDS.engineering]); const { stdin, lastFrame } = renderInkTest( , ); expect(lastFrame()).toContain('↓ 4 more'); stdin.write(''); stdin.write(''); stdin.write(''); stdin.write(''); await waitForInkInput(); const frame = lastFrame() ?? ''; expect(frame).toContain('↑ '); expect(frame).toContain('↓ '); expect(onExit).not.toHaveBeenCalled(); }); it('quits without saving on Ctrl+C', async () => { const onExit = vi.fn(); const { stdin } = renderInkTest( , ); stdin.write(''); await waitForInkInput(); expect(onExit).toHaveBeenCalledWith({ kind: 'quit' }); }); }); describe('renderTreePickerTui', () => { it('returns the app result from the Ink runtime', async () => { const io = { stdin: { isTTY: true, setRawMode: vi.fn() }, stdout: { isTTY: true, columns: 100, rows: 24, write: vi.fn() }, stderr: { write: vi.fn() }, }; const renderInk = vi.fn((_tree: ReactNode, _options: TreePickerInkRenderOptions) => fakeInkInstance()); await expect( renderTreePickerTui( { initialState: state(), chrome: chrome() }, io, { renderInk }, ), ).resolves.toEqual({ kind: 'quit' }); expect(renderInk).toHaveBeenCalledOnce(); }); it('sanitizes render errors and uses the supplied scripted-mode hint', async () => { expect(sanitizeTreePickerTuiError(new Error('token=secret https://api.example.com/v1/search'))).toBe( '[redacted] [redacted-url]', ); }); it('falls back to quit with the scripted-mode hint when Ink cannot initialize', async () => { let stderr = ''; const io = { stdin: { isTTY: false, setRawMode: vi.fn() }, stdout: { isTTY: false, columns: 100, rows: 24, write: vi.fn() }, stderr: { write(chunk: string) { stderr += chunk; }, }, }; await expect( renderTreePickerTui( { initialState: state(), chrome: chrome() }, io, { renderInk: vi.fn(() => { throw new Error('token=secret'); }), scriptedModeHint: 'Use --no-input --foo bar for scripted mode.', }, ), ).resolves.toEqual({ kind: 'quit' }); expect(stderr).toContain('Use --no-input --foo bar for scripted mode.'); expect(stderr).not.toContain('secret'); }); });