ktx/packages/cli/src/tree-picker-tui.test.tsx
Andrey Avtomonov 6c4623f2ff
feat(cli): enforce required database selection and improve tree-picker UX (#86)
* feat(cli): enforce required database selection and improve tree-picker UX

- Require at least one database driver via prompt `required: true` instead of
  looping on empty selection; remove the now-dead retry/back-on-empty branch.
- Surface the recommended option with a "(recommended)" hint in the depth and
  query-history prompts.
- Tree picker: add `◧` partial glyph for parents whose descendants are checked,
  and make `a` toggle select-all-visible / select-none.

* fix(cli): drop unused export from tree-picker toggleSelectAllVisible

Knip flagged the export as unused; the function is only consumed by the
internal reducer via the 'toggle-select-all-visible' command, so demote
it to a module-local helper to keep CI's dead-code check green.

* test(cli): drop empty-selection warning assertion from setup test

The empty-selection retry/warning loop in `chooseDrivers` was removed in
favor of `multiselect`'s `required: true`, so the legacy warning string
is unreachable. Update the test to assert the simpler back-from-selection
return-to-embeddings flow.
2026-05-14 14:35:58 +02:00

386 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* @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 './tree-picker-state.js';
import {
TreePickerApp,
renderTreePickerTui,
resolveTreePickerWidth,
sanitizeTreePickerTuiError,
treePickerCommandForInkInput,
windowItems,
windowOffset,
type TreePickerChrome,
type TreePickerInkInstance,
type TreePickerInkRenderOptions,
} from './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> = {}): 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', () => {
it('maps browse, search, and confirm input to reducer commands', () => {
expect(treePickerCommandForInkInput('', { downArrow: true }, state().search, null)).toBe('cursor-down');
expect(treePickerCommandForInkInput('', { upArrow: true }, state().search, null)).toBe('cursor-up');
expect(treePickerCommandForInkInput('', { rightArrow: true }, state().search, null)).toBe('cursor-right');
expect(treePickerCommandForInkInput('', { leftArrow: true }, state().search, null)).toBe('cursor-left');
expect(treePickerCommandForInkInput(' ', {}, state().search, null)).toBe('toggle-check');
expect(treePickerCommandForInkInput('/', {}, state().search, null)).toBe('search-start');
expect(treePickerCommandForInkInput('a', {}, state().search, null)).toBe('toggle-select-all-visible');
expect(treePickerCommandForInkInput('n', {}, state().search, null)).toBe('select-none');
expect(treePickerCommandForInkInput('', { return: true }, state().search, null)).toBe('save-request');
expect(treePickerCommandForInkInput('', { escape: true }, state().search, null)).toBe('quit');
expect(treePickerCommandForInkInput('c', { ctrl: true }, state().search, null)).toBe('quit');
expect(treePickerCommandForInkInput('s', {}, state().search, null)).toBeNull();
expect(treePickerCommandForInkInput('q', {}, state().search, null)).toBeNull();
expect(treePickerCommandForInkInput('x', {}, { editing: true, query: '' }, null)).toEqual({
type: 'search-input',
value: 'x',
});
expect(treePickerCommandForInkInput('', { backspace: true }, { editing: true, query: 'x' }, null)).toBe(
'search-backspace',
);
expect(treePickerCommandForInkInput('', { return: true }, { editing: true, query: 'x' }, null)).toBe(
'search-submit',
);
expect(treePickerCommandForInkInput('', { escape: true }, { editing: true, query: 'x' }, null)).toBe(
'search-cancel',
);
expect(treePickerCommandForInkInput('y', {}, state().search, 'save-confirm')).toBe('save-confirm');
expect(treePickerCommandForInkInput('', { return: true }, state().search, 'save-confirm')).toBe('save-confirm');
expect(treePickerCommandForInkInput('n', {}, state().search, 'save-confirm')).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(
<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(
'Right Arrow to expand, Up/Down to move, Space to select or unselect, Slash to filter, Enter to confirm, Escape to go back, or Ctrl+C to exit.',
);
});
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');
});
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');
});
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}
/>,
);
stdin.write(' ');
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('');
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(
<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');
});
});