2026-05-13 18:41:44 -04:00
/* @jsxImportSource react */
import { render as renderInkTest } from 'ink-testing-library' ;
import { type ReactNode } from 'react' ;
import { describe , expect , it , vi } from 'vitest' ;
2026-05-25 13:17:46 +02:00
import { buildInitialState , buildPickerTree , type TreePickerNodeInput } from '../src/tree-picker-state.js' ;
2026-05-13 18:41:44 -04:00
import {
TreePickerApp ,
renderTreePickerTui ,
resolveTreePickerWidth ,
sanitizeTreePickerTuiError ,
treePickerCommandForInkInput ,
windowItems ,
windowOffset ,
type TreePickerChrome ,
type TreePickerInkInstance ,
type TreePickerInkRenderOptions ,
2026-05-25 13:17:46 +02:00
} from '../src/tree-picker-tui.js' ;
2026-05-13 18:41:44 -04:00
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 > = { } ) : TreePickerChrome {
return {
title : 'Select items' ,
subtitleLines : [ 'Source: Test' ] ,
. . . overrides ,
} ;
}
async function waitForInkInput ( ) : Promise < void > {
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' , ( ) = > {
2026-05-24 19:29:37 +02:00
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 ( {
2026-05-13 18:41:44 -04:00
type : 'search-input' ,
value : 'x' ,
} ) ;
2026-05-24 19:29:37 +02:00
} ) ;
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 (
2026-05-13 18:41:44 -04:00
'search-backspace' ,
) ;
2026-05-24 19:29:37 +02:00
expect ( treePickerCommandForInkInput ( '' , { delete : true } , browse ( { search : { query : 'x' } } ) ) ) . toBe (
'search-backspace' ,
2026-05-13 18:41:44 -04:00
) ;
2026-05-24 19:29:37 +02:00
expect ( treePickerCommandForInkInput ( '' , { escape : true } , browse ( { search : { query : 'x' } } ) ) ) . toBe ( 'search-clear' ) ;
expect ( treePickerCommandForInkInput ( '' , { escape : true } , browse ( ) ) ) . toBe ( 'quit' ) ;
} ) ;
2026-05-13 18:41:44 -04:00
2026-05-24 19:29:37 +02:00
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' ) ;
2026-05-13 18:41:44 -04:00
} ) ;
} ) ;
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 (
< TreePickerApp
initialState = { initialState }
chrome = { chrome ( {
title : 'Select fancy widgets' ,
subtitleLines : [ 'Workspace: Design Workspace' ] ,
warningLines : [ '5000-page cap reached - some pages not shown' ] ,
} ) }
terminalRows = { 24 }
terminalWidth = { 100 }
onExit = { vi . fn ( ) }
/ > ,
) ;
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 (
2026-05-24 19:29:37 +02:00
'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.' ,
2026-05-13 18:41:44 -04:00
) ;
2026-05-24 19:29:37 +02:00
expect ( frame ) . toContain ( 'Search:' ) ;
2026-05-13 18:41:44 -04:00
} ) ;
it ( 'renders custom help text when supplied' , ( ) = > {
const { lastFrame } = renderInkTest (
< TreePickerApp
initialState = { state ( ) }
chrome = { chrome ( { helpText : 'Bespoke instructions here.' } ) }
terminalRows = { 24 }
terminalWidth = { 100 }
onExit = { vi . fn ( ) }
/ > ,
) ;
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 (
< TreePickerApp
initialState = { initialState }
chrome = { chrome ( ) }
terminalRows = { 24 }
terminalWidth = { 100 }
onExit = { vi . fn ( ) }
/ > ,
) ;
const frame = lastFrame ( ) ? ? '' ;
expect ( frame ) . toContain ( '◼ Engineering Docs ▾' ) ;
expect ( frame ) . toContain ( ' ◼ Architecture' ) ;
} ) ;
2026-05-14 14:35:58 +02:00
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 (
< TreePickerApp
initialState = { initialState }
chrome = { chrome ( ) }
terminalRows = { 24 }
terminalWidth = { 100 }
onExit = { vi . fn ( ) }
/ > ,
) ;
const frame = lastFrame ( ) ? ? '' ;
expect ( frame ) . toContain ( '◧ Engineering Docs ▾' ) ;
expect ( frame ) . toContain ( ' ◼ Architecture' ) ;
expect ( frame ) . not . toContain ( '◻ Engineering Docs' ) ;
} ) ;
2026-05-13 18:41:44 -04:00
it ( 'supports keyboard selection, confirm-on-save, and save callback' , async ( ) = > {
const onExit = vi . fn ( ) ;
const { stdin , lastFrame } = renderInkTest (
< TreePickerApp
initialState = { state ( { requireConfirmOnSave : true } ) }
chrome = { chrome ( {
confirmSaveMessage : ( current ) = >
` Confirm: ${ current . checked . size } item ${ current . checked . size === 1 ? '' : 's' } ? Press Enter or Escape. ` ,
} ) }
terminalRows = { 24 }
terminalWidth = { 100 }
onExit = { onExit }
/ > ,
) ;
2026-05-24 19:29:37 +02:00
stdin . write ( '\t' ) ;
2026-05-13 18:41:44 -04:00
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 (
< TreePickerApp
initialState = { state ( ) }
chrome = { chrome ( { skipEmptyMessage : 'No selections. Skip or back?' } ) }
terminalRows = { 24 }
terminalWidth = { 100 }
onExit = { onExit }
/ > ,
) ;
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 (
< TreePickerApp
initialState = { initialState }
chrome = { chrome ( ) }
terminalRows = { 13 }
terminalWidth = { 100 }
onExit = { onExit }
/ > ,
) ;
expect ( lastFrame ( ) ) . toContain ( '↓ 4 more' ) ;
stdin . write ( ' [B' ) ;
stdin . write ( ' [B' ) ;
stdin . write ( ' [B' ) ;
stdin . write ( ' [B' ) ;
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 (
< TreePickerApp
initialState = { state ( ) }
chrome = { chrome ( ) }
terminalRows = { 24 }
terminalWidth = { 100 }
onExit = { onExit }
/ > ,
) ;
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' ) ;
} ) ;
} ) ;