ktx/packages/cli/test/completion/complete-engine.test.ts
Andrey Avtomonov d320d54ab2
feat(cli): shell completion for commands, flags, and entity names (#244)
* feat(completion): complete known argument values

* fix(completion): hide Commander-hidden subcommands from completions

Replace the `__`-prefix name heuristic with Commander's `_hidden` flag so
internal subcommands registered with { hidden: true } (e.g. `mcp serve-internal`)
are excluded from completions, mirroring `ktx --help`.

* test: cover wiki and sl read command routing

* test: cover raw wiki and sl reads

* feat: add wiki read command

* feat: add sl read command

* feat: complete read command entity names

* docs: document wiki and sl read commands

* test: include read commands in command tree

* feat(sl): read and validate unique sources by name

* feat(sl): make read and validate connection id optional

* fix(completion): dedupe semantic source names

* docs(sl): document connection-optional read and validate

* fix(sl): require connection id for query command

* docs(sl): clarify query connection requirement

* fix(completion): don't resolve option values as subcommands

resolveCommand skipped flag tokens but not the value consumed by a
value-taking option in the `--flag value` form, so a connection id like
`query` was matched as the `sl query` subcommand and yielded no `sl`
completions. Track value-taking options and skip their consumed value
before matching subcommands.

* test(telemetry): assert first-run notice via TELEMETRY_NOTICE constant

CI (which tests this branch merged with main) failed because #243 changed
the first-run notice wording in identity.ts (dropped "anonymous") but left
this test grepping for the old literal 'ktx collects anonymous usage data',
so indexOf returned -1. Assert against the exported TELEMETRY_NOTICE
constant instead so the test tracks the source of truth and cannot drift
when the notice text changes again.
2026-05-31 23:44:33 +02:00

137 lines
5.4 KiB
TypeScript

import type { Command } from '@commander-js/extra-typings';
import { describe, expect, it } from 'vitest';
import { buildKtxProgram } from '../../src/cli-program.js';
import type { KtxCliIo, KtxCliPackageInfo } from '../../src/cli-runtime.js';
import { type CompletionProviders, computeCompletions } from '../../src/completion/complete-engine.js';
function stubIo(): KtxCliIo {
return { stdout: { isTTY: false, columns: 80, write: () => {} }, stderr: { write: () => {} } };
}
function stubPackageInfo(): KtxCliPackageInfo {
return { name: '@kaelio/ktx', version: '0.0.0-test' };
}
function buildProgram(): Command {
return buildKtxProgram({ io: stubIo(), deps: {}, packageInfo: stubPackageInfo(), runInit: async () => 0 });
}
const SOURCES = ['orders', 'customers'];
const WIKI_KEYS = ['revenue', 'churn'];
const CONNECTIONS = ['warehouse'];
function fakeProviders(overrides: Partial<CompletionProviders> = {}): CompletionProviders {
return {
async positionalCandidates(commandPath) {
const key = commandPath.join(' ');
if (key === 'sl read' || key === 'sl validate') {
return SOURCES;
}
if (key === 'wiki read') {
return WIKI_KEYS;
}
return [];
},
async optionValueCandidates(_commandPath, optionFlag) {
return optionFlag === '--connection-id' ? CONNECTIONS : [];
},
...overrides,
};
}
function complete(words: string[], providers: CompletionProviders = fakeProviders()): Promise<string[]> {
return computeCompletions(buildProgram(), words, providers);
}
describe('computeCompletions', () => {
it('lists top-level commands and hides internal ones', async () => {
const result = await complete(['']);
expect(result).toContain('sl');
expect(result).toContain('wiki');
expect(result).toContain('completion');
expect(result).not.toContain('__complete');
});
it('filters top-level commands by prefix', async () => {
expect(await complete(['co'])).toEqual(['completion', 'connection']);
});
it('hides Commander-hidden subcommands such as `mcp serve-internal`', async () => {
const result = await complete(['mcp', '']);
expect(result).not.toContain('serve-internal');
expect(result).toEqual(['logs', 'start', 'status', 'stdio', 'stop']);
});
it('offers only sl subcommands at the bare sl positional', async () => {
expect(await complete(['sl', ''])).toEqual(['query', 'read', 'validate']);
});
it('offers source names for sl read and sl validate', async () => {
expect(await complete(['sl', 'read', ''])).toEqual(['customers', 'orders']);
expect(await complete(['sl', 'validate', ''])).toEqual(['customers', 'orders']);
});
it('offers only the wiki read subcommand at the bare wiki positional', async () => {
expect(await complete(['wiki', ''])).toEqual(['read']);
});
it('offers wiki page keys for wiki read', async () => {
expect(await complete(['wiki', 'read', ''])).toEqual(['churn', 'revenue']);
});
it('does not complete entity names for bare search positionals', async () => {
expect(await complete(['sl', 'o'])).toEqual([]);
expect(await complete(['wiki', 'r'])).toEqual(['read']);
});
it('completes flags (own + inherited globals) when the partial starts with a dash', async () => {
const result = await complete(['sl', '-']);
expect(result).toContain('--connection-id');
expect(result).toContain('--output');
expect(result).toContain('--json');
expect(result).toContain('--debug');
expect(result).toContain('--project-dir');
});
it('completes option choices for the `--opt value` form', async () => {
expect(await complete(['sl', '--output', ''])).toEqual(['json', 'plain', 'pretty']);
});
it('completes option choices for the `--opt=value` form', async () => {
expect(await complete(['sl', '--output=pr'])).toEqual(['--output=pretty']);
});
it('completes option values from a provider for options without static choices', async () => {
expect(await complete(['sl', '--connection-id', ''])).toEqual(['warehouse']);
});
it('falls through to positional completion after a boolean flag', async () => {
const result = await complete(['sl', '--json', '']);
expect(result).toEqual(['query', 'read', 'validate']);
});
it('does not treat a value-taking option value as a subcommand', async () => {
// A connection id that happens to match a subcommand name (`query`, `read`)
// is the `--connection-id` value, not a subcommand: the next positional must
// still offer the `sl` subcommands rather than resolving into `sl query`/`sl read`.
expect(await complete(['sl', '--connection-id', 'query', ''])).toEqual(['query', 'read', 'validate']);
expect(await complete(['sl', '--connection-id', 'read', ''])).toEqual(['query', 'read', 'validate']);
});
it('still returns subcommands/flags when dynamic providers yield nothing (no project)', async () => {
const empty = fakeProviders({
positionalCandidates: async () => [],
optionValueCandidates: async () => [],
});
expect(await complete(['sl', ''], empty)).toEqual(['query', 'read', 'validate']);
expect(await complete(['-'], empty)).toContain('--debug');
});
it('completes the completion command shell positional from its static choices', async () => {
expect(await complete(['completion', ''])).toEqual(['bash', 'zsh']);
});
it('filters positional argument choices by prefix', async () => {
expect(await complete(['completion', 'z'])).toEqual(['zsh']);
});
});