mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
* feat(cli): define full warehouse dialect contract
* test(cli): keep dialect edge tests focused
* fix(cli): stabilize dialect contract foundation
* refactor(connectors): own read-only query preparation
* refactor(connectors): resolve dialects through registry
* refactor(connectors): keep concrete dialect classes internal
* chore(workspace): enforce dialect import boundary
* refactor(cli): resolve relationship dialect at scan boundary
* refactor(cli): use dialect display parsing for entity details
* refactor(cli): use dialect display parsing for warehouse catalog
* refactor(cli): use dialect SQL in relationship workflows
* test(cli): verify solid dialect scan workflow closure
* test: split cli tests from source tree
* refactor(cli): standardize BigQuery scope listing
* feat(sqlite): implement connector scope listing
* test(connectors): cover required table listing
* feat(cli): add warehouse driver registry
* refactor(setup): route scope discovery through driver registry
* refactor(cli): route local query execution through driver registry
* refactor(historic-sql): route dialect support through driver registry
* refactor(cli): test warehouse connections through driver registry
* fix(cli): close driver registry type export gaps
* Improve setup daemon diagnostics
* refactor(setup): centralize rail-prefixed diagnostics + query-history fallback
Extract errorMessage, writePrefixedLines, and flushPrefixedBufferedCommandOutput
into clack.ts so the setup wizard, managed daemons, and embedding/agent steps
share one rail-formatted writer. setup-databases.ts also adds a
"disable query history and retry" option when the schema-context build fails
and query history is the likely culprit, surfaced via a new
failed-query-history-unavailable status.
* fix(cli): carry catalog through the picker so BigQuery/Snowflake/SQL Server scope filters match
The setup picker's KtxTableListEntry was a 2-level { schema, name }, so
qualifiedTableId always wrote db.name into enabled_tables. When BigQuery,
Snowflake, or SQL Server later ran fast ingest, their introspect step filtered
the scope set with scopedTableNames(scope, { catalog: projectId|database, db })
— catalog was non-null on the introspect side but null in the scope refs, so
every entry was rejected, the live-database adapter staged zero table files,
and detect() failed with 'Adapter "live-database" did not recognize fetched
source output'.
Align the picker boundary with the canonical 3-level KtxTableRef:
- Add catalog: string | null to KtxTableListEntry.
- BigQuery/Snowflake/SQL Server listTables populate catalog from the
resolved projectId / database; Postgres/MySQL/ClickHouse/SQLite set null.
- qualifiedTableId emits catalog.schema.name when catalog is non-null
(resolveEnabledTables already accepts the 3-part shape) and
schemasFromEnabledTables now goes through parseDottedTableEntry so it
recovers the schema correctly from both 2-part and 3-part entries.
- Export parseDottedTableEntry from enabled-tables.ts (@internal) for picker
reuse.
Update listTables expectations in all seven connector tests and the setup /
picker test fixtures. Add a picker regression test that covers the
catalog-bearing round-trip (save + refine).
* fix(cli): allow debug telemetry under opt-out env
420 lines
15 KiB
TypeScript
420 lines
15 KiB
TypeScript
/* @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> = {}): 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', () => {
|
||
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(
|
||
<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(
|
||
'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(
|
||
<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('\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(
|
||
<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');
|
||
});
|
||
});
|