mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
1977 lines
60 KiB
TypeScript
1977 lines
60 KiB
TypeScript
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { createRequire } from 'node:module';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
getKtxCliPackageInfo,
|
|
rendererUnavailableVizFallback,
|
|
renderMemoryFlowTui,
|
|
resolveVizFallback,
|
|
runKtxCli,
|
|
sanitizeMemoryFlowTuiError,
|
|
startLiveMemoryFlowTui,
|
|
warnVizFallbackOnce,
|
|
} from './index.js';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
|
|
function makeIo(options: { stdoutIsTty?: boolean } = {}) {
|
|
let stdout = '';
|
|
let stderr = '';
|
|
return {
|
|
io: {
|
|
stdout: {
|
|
isTTY: options.stdoutIsTty,
|
|
write: (chunk: string) => {
|
|
stdout += chunk;
|
|
},
|
|
},
|
|
stderr: {
|
|
write: (chunk: string) => {
|
|
stderr += chunk;
|
|
},
|
|
},
|
|
},
|
|
stdout: () => stdout,
|
|
stderr: () => stderr,
|
|
};
|
|
}
|
|
|
|
describe('getKtxCliPackageInfo', () => {
|
|
it('identifies the CLI package and its context dependency', () => {
|
|
expect(getKtxCliPackageInfo()).toEqual({
|
|
name: '@ktx/cli',
|
|
version: '0.0.0-private',
|
|
contextPackageName: '@ktx/context',
|
|
});
|
|
});
|
|
|
|
it('exports package metadata for package managers and runtime diagnostics', () => {
|
|
const packageJson = require('@ktx/cli/package.json') as { name: string; version: string };
|
|
|
|
expect(packageJson).toMatchObject({
|
|
name: '@ktx/cli',
|
|
version: '0.0.0-private',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('memory-flow renderer exports', () => {
|
|
it('exports runtime-agnostic renderer entry points for hosted terminal clients', () => {
|
|
expect(renderMemoryFlowTui).toBeTypeOf('function');
|
|
expect(startLiveMemoryFlowTui).toBeTypeOf('function');
|
|
expect(sanitizeMemoryFlowTuiError('token=abc123')).toBe('[redacted]');
|
|
});
|
|
|
|
it('exports shared visualization fallback helpers for hosted terminal clients', () => {
|
|
const fallback = resolveVizFallback({ stdout: { isTTY: true }, stderr: { write: vi.fn() } }, { TERM: 'dumb' });
|
|
|
|
expect(fallback).toEqual({
|
|
shouldDegrade: true,
|
|
reason: 'term-dumb',
|
|
message: 'TERM=dumb does not support the visual renderer',
|
|
});
|
|
expect(rendererUnavailableVizFallback()).toEqual({
|
|
shouldDegrade: true,
|
|
reason: 'renderer-unavailable',
|
|
message: 'the terminal renderer is unavailable',
|
|
});
|
|
expect(warnVizFallbackOnce).toBeTypeOf('function');
|
|
});
|
|
});
|
|
|
|
describe('runKtxCli', () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('prints version information', async () => {
|
|
const testIo = makeIo();
|
|
|
|
await expect(runKtxCli(['--version'], testIo.io)).resolves.toBe(0);
|
|
|
|
expect(testIo.stdout()).toBe('@ktx/cli 0.0.0-private\n');
|
|
expect(testIo.stderr()).toBe('');
|
|
});
|
|
|
|
it('prints the May 6 public command surface in root help', async () => {
|
|
const testIo = makeIo();
|
|
|
|
await expect(runKtxCli(['--help'], testIo.io)).resolves.toBe(0);
|
|
|
|
expect(testIo.stdout()).toContain('Usage: ktx [options] [command]');
|
|
for (const command of ['setup', 'connection', 'ingest', 'wiki', 'sl', 'serve', 'status']) {
|
|
expect(testIo.stdout()).toContain(`${command}`);
|
|
}
|
|
for (const removed of ['demo', 'init', 'connect', 'scan', 'ask', 'knowledge', 'agent', 'completion']) {
|
|
expect(testIo.stdout()).not.toContain(`${removed} [`);
|
|
expect(testIo.stdout()).not.toContain(`${removed} `);
|
|
}
|
|
expect(testIo.stdout()).toContain('--project-dir <path>');
|
|
expect(testIo.stdout()).toContain('KTX_PROJECT_DIR');
|
|
expect(testIo.stdout()).toContain('--debug');
|
|
expect(testIo.stdout()).not.toContain('--' + 'verbose');
|
|
expect(testIo.stdout()).toContain('Advanced:');
|
|
expect(testIo.stdout()).toContain('ktx dev');
|
|
expect(testIo.stderr()).toBe('');
|
|
});
|
|
|
|
it('exposes demo under setup help instead of root help', async () => {
|
|
const testIo = makeIo();
|
|
|
|
await expect(runKtxCli(['setup', '--help'], testIo.io)).resolves.toBe(0);
|
|
|
|
expect(testIo.stdout()).toContain('Usage: ktx setup [options] [command]');
|
|
expect(testIo.stdout()).toContain('demo');
|
|
expect(testIo.stdout()).toContain('Run the packaged KTX demo from setup');
|
|
expect(testIo.stdout()).not.toContain('--skip-llm');
|
|
expect(testIo.stdout()).not.toContain('--skip-embeddings');
|
|
expect(testIo.stdout()).not.toContain('--embedding-model');
|
|
expect(testIo.stdout()).not.toContain('--embedding-dimensions');
|
|
expect(testIo.stdout()).not.toContain('--embedding-base-url');
|
|
expect(testIo.stderr()).toBe('');
|
|
});
|
|
|
|
it('prints help for bare ktx outside a TTY', async () => {
|
|
const setup = vi.fn(async () => 0);
|
|
const testIo = makeIo({ stdoutIsTty: false });
|
|
|
|
await expect(runKtxCli([], testIo.io, { setup })).resolves.toBe(0);
|
|
|
|
expect(testIo.stdout()).toContain('Usage: ktx [options] [command]');
|
|
expect(setup).not.toHaveBeenCalled();
|
|
expect(testIo.stderr()).toBe('');
|
|
});
|
|
|
|
it('starts setup for bare ktx in a TTY when no project is discoverable', async () => {
|
|
const { mkdtemp, realpath, rm } = await import('node:fs/promises');
|
|
const { tmpdir } = await import('node:os');
|
|
const { join } = await import('node:path');
|
|
const originalCwd = process.cwd();
|
|
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-bare-setup-'));
|
|
const setup = vi.fn(async () => 0);
|
|
const testIo = makeIo({ stdoutIsTty: true });
|
|
const previousProjectDir = process.env.KTX_PROJECT_DIR;
|
|
const expectedProjectDir = await realpath(tempDir);
|
|
|
|
try {
|
|
delete process.env.KTX_PROJECT_DIR;
|
|
process.chdir(tempDir);
|
|
|
|
await expect(runKtxCli([], testIo.io, { setup })).resolves.toBe(0);
|
|
|
|
expect(setup).toHaveBeenCalledWith(
|
|
{
|
|
command: 'run',
|
|
projectDir: expectedProjectDir,
|
|
mode: 'auto',
|
|
agents: false,
|
|
agentScope: 'project',
|
|
agentInstallMode: 'cli',
|
|
skipAgents: false,
|
|
inputMode: 'auto',
|
|
yes: false,
|
|
skipLlm: false,
|
|
skipEmbeddings: false,
|
|
databaseSchemas: [],
|
|
skipDatabases: false,
|
|
skipSources: false,
|
|
},
|
|
testIo.io,
|
|
);
|
|
expect(testIo.stdout()).not.toContain('Usage: ktx [options] [command]');
|
|
expect(testIo.stderr()).toBe('');
|
|
} finally {
|
|
process.chdir(originalCwd);
|
|
if (previousProjectDir === undefined) {
|
|
delete process.env.KTX_PROJECT_DIR;
|
|
} else {
|
|
process.env.KTX_PROJECT_DIR = previousProjectDir;
|
|
}
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('prints help without project status for bare ktx in a TTY when a project is discoverable', async () => {
|
|
const { mkdtemp, realpath, rm, writeFile } = await import('node:fs/promises');
|
|
const { tmpdir } = await import('node:os');
|
|
const { join } = await import('node:path');
|
|
const originalCwd = process.cwd();
|
|
const previousProjectDir = process.env.KTX_PROJECT_DIR;
|
|
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-bare-existing-'));
|
|
const setup = vi.fn(async () => 0);
|
|
const testIo = makeIo({ stdoutIsTty: true });
|
|
const expectedProjectDir = await realpath(tempDir);
|
|
|
|
try {
|
|
delete process.env.KTX_PROJECT_DIR;
|
|
await writeFile(join(tempDir, 'ktx.yaml'), 'project: revenue\nconnections: {}\n', 'utf-8');
|
|
process.chdir(tempDir);
|
|
|
|
await expect(runKtxCli([], testIo.io, { setup })).resolves.toBe(0);
|
|
|
|
expect(testIo.stdout()).toContain('Usage: ktx [options] [command]');
|
|
expect(testIo.stdout()).not.toContain(`Project: ${expectedProjectDir}`);
|
|
expect(setup).not.toHaveBeenCalled();
|
|
} finally {
|
|
process.chdir(originalCwd);
|
|
if (previousProjectDir === undefined) {
|
|
delete process.env.KTX_PROJECT_DIR;
|
|
} else {
|
|
process.env.KTX_PROJECT_DIR = previousProjectDir;
|
|
}
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('does not invoke status for bare ktx in a TTY when status would fail', async () => {
|
|
const setup = vi.fn(async () => {
|
|
throw new Error('Unsupported ingest.llm: use top-level llm.provider, llm.models, and ingest.workUnits');
|
|
});
|
|
const testIo = makeIo({ stdoutIsTty: true });
|
|
const previousProjectDir = process.env.KTX_PROJECT_DIR;
|
|
|
|
try {
|
|
process.env.KTX_PROJECT_DIR = tempDir;
|
|
|
|
await expect(runKtxCli([], testIo.io, { setup })).resolves.toBe(0);
|
|
|
|
expect(testIo.stdout()).toContain('Usage: ktx [options] [command]');
|
|
expect(setup).not.toHaveBeenCalled();
|
|
expect(testIo.stderr()).toBe('');
|
|
} finally {
|
|
if (previousProjectDir === undefined) {
|
|
delete process.env.KTX_PROJECT_DIR;
|
|
} else {
|
|
process.env.KTX_PROJECT_DIR = previousProjectDir;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('rejects removed verbose global option through Commander', async () => {
|
|
const testIo = makeIo();
|
|
const removedVerboseOption = '--' + 'verbose';
|
|
|
|
await expect(runKtxCli([removedVerboseOption, 'connection', 'list'], testIo.io)).resolves.toBe(1);
|
|
|
|
expect(testIo.stderr()).toContain(`unknown option '${removedVerboseOption}'`);
|
|
expect(testIo.stdout()).toBe('');
|
|
});
|
|
|
|
it('prints a zsh completion function', async () => {
|
|
const testIo = makeIo();
|
|
const zshWords = '$' + '{words[@]}';
|
|
|
|
await expect(runKtxCli(['dev', 'completion', 'zsh'], testIo.io)).resolves.toBe(0);
|
|
|
|
expect(testIo.stdout()).toContain('#compdef ktx');
|
|
expect(testIo.stdout()).toContain('KTX_COMPLETION_COMMAND:-ktx');
|
|
expect(testIo.stdout()).toContain(`dev __complete --shell zsh --position "$CURRENT" -- "${zshWords}"`);
|
|
expect(testIo.stdout()).toContain('compdef _ktx ktx');
|
|
expect(testIo.stderr()).toBe('');
|
|
});
|
|
|
|
it('installs zsh completions into the user zsh config directory', async () => {
|
|
const testIo = makeIo();
|
|
const previousHome = process.env.HOME;
|
|
const previousZdotdir = process.env.ZDOTDIR;
|
|
const tempHome = await mkdtemp(join(tmpdir(), 'ktx-completion-home-'));
|
|
|
|
try {
|
|
process.env.HOME = tempHome;
|
|
delete process.env.ZDOTDIR;
|
|
|
|
await expect(runKtxCli(['dev', 'completion', 'zsh', '--install'], testIo.io)).resolves.toBe(0);
|
|
|
|
const completionFile = await readFile(join(tempHome, '.zfunc', '_ktx'), 'utf-8');
|
|
const zshrc = await readFile(join(tempHome, '.zshrc'), 'utf-8');
|
|
expect(completionFile).toContain('#compdef ktx');
|
|
expect(zshrc).toContain('# >>> ktx completion >>>');
|
|
expect(zshrc).toContain('_ktx_completion_command()');
|
|
expect(zshrc).toContain('"name": "ktx-workspace"');
|
|
expect(zshrc).toContain('scripts/run-ktx.mjs');
|
|
expect(zshrc).toContain("export KTX_COMPLETION_COMMAND='$(_ktx_completion_command)'");
|
|
expect(zshrc).toContain('setopt complete_aliases');
|
|
expect(zshrc).toContain('fpath=("$HOME/.zfunc" $fpath)');
|
|
expect(zshrc).toContain('autoload -Uz compinit');
|
|
expect(zshrc).toContain('compinit');
|
|
expect(testIo.stdout()).toContain('Installed zsh completion:');
|
|
expect(testIo.stdout()).toContain('Restart your shell or run: source ~/.zshrc');
|
|
expect(testIo.stderr()).toBe('');
|
|
} finally {
|
|
if (previousHome === undefined) {
|
|
delete process.env.HOME;
|
|
} else {
|
|
process.env.HOME = previousHome;
|
|
}
|
|
if (previousZdotdir === undefined) {
|
|
delete process.env.ZDOTDIR;
|
|
} else {
|
|
process.env.ZDOTDIR = previousZdotdir;
|
|
}
|
|
await rm(tempHome, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('updates zsh completion install block idempotently before existing compinit', async () => {
|
|
const firstIo = makeIo();
|
|
const secondIo = makeIo();
|
|
const previousHome = process.env.HOME;
|
|
const previousZdotdir = process.env.ZDOTDIR;
|
|
const tempHome = await mkdtemp(join(tmpdir(), 'ktx-completion-home-'));
|
|
|
|
try {
|
|
process.env.HOME = tempHome;
|
|
delete process.env.ZDOTDIR;
|
|
await writeFile(join(tempHome, '.zshrc'), 'export EDITOR=vim\nautoload -Uz compinit\ncompinit\n', 'utf-8');
|
|
|
|
await expect(runKtxCli(['dev', 'completion', 'zsh', '--install'], firstIo.io)).resolves.toBe(0);
|
|
await expect(runKtxCli(['dev', 'completion', 'zsh', '--install'], secondIo.io)).resolves.toBe(0);
|
|
|
|
const zshrc = await readFile(join(tempHome, '.zshrc'), 'utf-8');
|
|
expect(zshrc.match(/# >>> ktx completion >>>/g)).toHaveLength(1);
|
|
expect(zshrc.indexOf('fpath=("$HOME/.zfunc" $fpath)')).toBeLessThan(zshrc.indexOf('autoload -Uz compinit'));
|
|
expect(zshrc.match(/_ktx_completion_command\(\)/g)).toHaveLength(1);
|
|
expect(zshrc.match(/^compinit$/gm)).toHaveLength(1);
|
|
expect(secondIo.stdout()).toContain('Updated zsh config:');
|
|
expect(firstIo.stderr()).toBe('');
|
|
expect(secondIo.stderr()).toBe('');
|
|
} finally {
|
|
if (previousHome === undefined) {
|
|
delete process.env.HOME;
|
|
} else {
|
|
process.env.HOME = previousHome;
|
|
}
|
|
if (previousZdotdir === undefined) {
|
|
delete process.env.ZDOTDIR;
|
|
} else {
|
|
process.env.ZDOTDIR = previousZdotdir;
|
|
}
|
|
await rm(tempHome, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('completes root and nested Commander command names', async () => {
|
|
const rootIo = makeIo();
|
|
const connectionIo = makeIo();
|
|
|
|
await expect(
|
|
runKtxCli(['dev', '__complete', '--shell', 'zsh', '--position', '2', '--', 'ktx', 'co'], rootIo.io),
|
|
).resolves.toBe(0);
|
|
await expect(
|
|
runKtxCli(
|
|
['dev', '__complete', '--shell', 'zsh', '--position', '3', '--', 'ktx', 'connection', 'm'],
|
|
connectionIo.io,
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(rootIo.stdout()).toContain('connection:Add, list, test, and map data sources');
|
|
expect(rootIo.stdout()).not.toContain('__complete');
|
|
expect(connectionIo.stdout()).toContain('map:Refresh and validate BI-to-warehouse mappings');
|
|
expect(connectionIo.stdout()).toContain('mapping:Manage Metabase warehouse mappings');
|
|
expect(rootIo.stderr()).toBe('');
|
|
expect(connectionIo.stderr()).toBe('');
|
|
});
|
|
|
|
it('completes options and Commander choices', async () => {
|
|
const optionIo = makeIo();
|
|
const choiceIo = makeIo();
|
|
|
|
await expect(
|
|
runKtxCli(
|
|
['dev', '__complete', '--shell', 'zsh', '--position', '4', '--', 'ktx', 'connection', 'add', '--cr'],
|
|
optionIo.io,
|
|
),
|
|
).resolves.toBe(0);
|
|
await expect(
|
|
runKtxCli(
|
|
[
|
|
'dev',
|
|
'__complete',
|
|
'--shell',
|
|
'zsh',
|
|
'--position',
|
|
'7',
|
|
'--',
|
|
'ktx',
|
|
'connection',
|
|
'add',
|
|
'notion',
|
|
'docs',
|
|
'--crawl-mode',
|
|
'',
|
|
],
|
|
choiceIo.io,
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(optionIo.stdout()).toContain('--crawl-mode:Notion crawl mode');
|
|
expect(choiceIo.stdout()).toContain('all_accessible');
|
|
expect(choiceIo.stdout()).toContain('selected_roots');
|
|
expect(optionIo.stderr()).toBe('');
|
|
expect(choiceIo.stderr()).toBe('');
|
|
});
|
|
|
|
it('dispatches serve stdio commands', async () => {
|
|
const testIo = makeIo();
|
|
const serveStdio = vi.fn().mockResolvedValue(0);
|
|
|
|
await expect(
|
|
runKtxCli(['--project-dir', tempDir, 'serve', '--mcp', 'stdio', '--user-id', 'agent'], testIo.io, {
|
|
serveStdio,
|
|
}),
|
|
).resolves.toBe(0);
|
|
|
|
expect(serveStdio).toHaveBeenCalledWith({
|
|
mcp: 'stdio',
|
|
projectDir: tempDir,
|
|
userId: 'agent',
|
|
semanticCompute: false,
|
|
semanticComputeUrl: undefined,
|
|
executeQueries: false,
|
|
memoryCapture: false,
|
|
memoryModel: undefined,
|
|
});
|
|
});
|
|
|
|
it('routes public ingest through the public ingest parser', async () => {
|
|
const testIo = makeIo();
|
|
const ingest = vi.fn().mockResolvedValue(0);
|
|
|
|
await expect(
|
|
runKtxCli(['--project-dir', '/tmp/project', 'ingest', 'warehouse'], testIo.io, { publicIngest: ingest }),
|
|
).resolves.toBe(0);
|
|
|
|
expect(ingest).toHaveBeenCalledWith(
|
|
{
|
|
command: 'run',
|
|
projectDir: '/tmp/project',
|
|
targetConnectionId: 'warehouse',
|
|
all: false,
|
|
json: false,
|
|
inputMode: 'auto',
|
|
},
|
|
testIo.io,
|
|
);
|
|
});
|
|
|
|
it('prints public ingest watch help from Commander', async () => {
|
|
const testIo = makeIo();
|
|
const publicIngest = vi.fn(async () => 0);
|
|
const lowLevelIngest = vi.fn(async () => 0);
|
|
|
|
await expect(
|
|
runKtxCli(['ingest', 'watch', '--help'], testIo.io, { publicIngest, ingest: lowLevelIngest }),
|
|
).resolves.toBe(0);
|
|
|
|
expect(testIo.stdout()).toContain('Usage: ktx ingest watch [options] [runId]');
|
|
expect(testIo.stdout()).toContain('[runId]');
|
|
expect(testIo.stdout()).toContain('--project-dir <path>');
|
|
expect(testIo.stdout()).toContain('--json');
|
|
expect(testIo.stdout()).toContain('--no-input');
|
|
expect(testIo.stderr()).toBe('');
|
|
expect(publicIngest).not.toHaveBeenCalled();
|
|
expect(lowLevelIngest).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('dispatches public ingest status and watch through Commander', async () => {
|
|
const statusIo = makeIo();
|
|
const watchIo = makeIo();
|
|
const publicIngest = vi.fn(async () => 0);
|
|
|
|
await expect(
|
|
runKtxCli(['--project-dir', tempDir, 'ingest', 'status', 'run-1', '--json', '--no-input'], statusIo.io, {
|
|
publicIngest,
|
|
}),
|
|
).resolves.toBe(0);
|
|
await expect(
|
|
runKtxCli(['--project-dir', tempDir, 'ingest', 'watch', '--no-input'], watchIo.io, {
|
|
publicIngest,
|
|
}),
|
|
).resolves.toBe(0);
|
|
|
|
expect(publicIngest).toHaveBeenNthCalledWith(
|
|
1,
|
|
{
|
|
command: 'status',
|
|
projectDir: tempDir,
|
|
runId: 'run-1',
|
|
json: true,
|
|
inputMode: 'disabled',
|
|
},
|
|
statusIo.io,
|
|
);
|
|
expect(publicIngest).toHaveBeenNthCalledWith(
|
|
2,
|
|
{
|
|
command: 'watch',
|
|
projectDir: tempDir,
|
|
json: false,
|
|
inputMode: 'disabled',
|
|
},
|
|
watchIo.io,
|
|
);
|
|
expect(statusIo.stderr()).toBe('');
|
|
expect(watchIo.stderr()).toBe('');
|
|
});
|
|
|
|
it('rejects standalone demo commands', async () => {
|
|
const testIo = makeIo();
|
|
const demo = vi.fn().mockResolvedValue(0);
|
|
|
|
await expect(runKtxCli(['demo', '--mode', 'replay', '--no-input'], testIo.io, { demo })).resolves.toBe(1);
|
|
|
|
expect(testIo.stderr()).toMatch(/unknown command|error:/i);
|
|
expect(demo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('dispatches setup demo commands', async () => {
|
|
const testIo = makeIo();
|
|
const demo = vi.fn().mockResolvedValue(0);
|
|
|
|
await expect(
|
|
runKtxCli(['--project-dir', tempDir, 'setup', 'demo', '--mode', 'replay', '--no-input'], testIo.io, { demo }),
|
|
).resolves.toBe(0);
|
|
|
|
expect(demo).toHaveBeenCalledWith(
|
|
{
|
|
command: 'replay',
|
|
projectDir: tempDir,
|
|
outputMode: 'viz',
|
|
inputMode: 'disabled',
|
|
},
|
|
testIo.io,
|
|
);
|
|
|
|
demo.mockClear();
|
|
await expect(
|
|
runKtxCli(['--project-dir', tempDir, 'setup', 'demo', '--mode', 'seeded', '--no-input'], testIo.io, {
|
|
demo,
|
|
}),
|
|
).resolves.toBe(0);
|
|
expect(demo).toHaveBeenCalledWith(
|
|
{
|
|
command: 'seeded',
|
|
projectDir: tempDir,
|
|
outputMode: 'viz',
|
|
inputMode: 'disabled',
|
|
},
|
|
testIo.io,
|
|
);
|
|
|
|
demo.mockClear();
|
|
await expect(
|
|
runKtxCli(['--project-dir', tempDir, 'setup', '--no-input', 'demo', '--mode', 'seeded'], testIo.io, {
|
|
demo,
|
|
}),
|
|
).resolves.toBe(0);
|
|
expect(demo).toHaveBeenCalledWith(
|
|
{
|
|
command: 'seeded',
|
|
projectDir: tempDir,
|
|
outputMode: 'viz',
|
|
inputMode: 'disabled',
|
|
},
|
|
testIo.io,
|
|
);
|
|
|
|
demo.mockClear();
|
|
await expect(
|
|
runKtxCli(['--project-dir', tempDir, 'setup', 'demo', 'inspect', '--no-input'], testIo.io, { demo }),
|
|
).resolves.toBe(0);
|
|
expect(demo).toHaveBeenCalledWith(
|
|
{
|
|
command: 'inspect',
|
|
projectDir: tempDir,
|
|
outputMode: 'plain',
|
|
inputMode: 'disabled',
|
|
},
|
|
testIo.io,
|
|
);
|
|
});
|
|
|
|
it('dispatches demo ingest argv', async () => {
|
|
const testIo = makeIo();
|
|
const demo = vi.fn().mockResolvedValue(0);
|
|
|
|
await expect(
|
|
runKtxCli(['--project-dir', tempDir, 'setup', 'demo', 'ingest', '--mode', 'full', '--no-input'], testIo.io, {
|
|
demo,
|
|
}),
|
|
).resolves.toBe(0);
|
|
|
|
expect(demo).toHaveBeenCalledWith(
|
|
{
|
|
command: 'ingest',
|
|
mode: 'full',
|
|
projectDir: tempDir,
|
|
outputMode: 'viz',
|
|
inputMode: 'disabled',
|
|
},
|
|
testIo.io,
|
|
);
|
|
|
|
demo.mockClear();
|
|
await expect(
|
|
runKtxCli(['--project-dir', tempDir, 'setup', '--no-input', 'demo', 'ingest', '--mode', 'seeded'], testIo.io, {
|
|
demo,
|
|
}),
|
|
).resolves.toBe(0);
|
|
|
|
expect(demo).toHaveBeenCalledWith(
|
|
{
|
|
command: 'ingest',
|
|
mode: 'seeded',
|
|
projectDir: tempDir,
|
|
outputMode: 'viz',
|
|
inputMode: 'disabled',
|
|
},
|
|
testIo.io,
|
|
);
|
|
|
|
demo.mockClear();
|
|
await expect(
|
|
runKtxCli(
|
|
['--project-dir', tempDir, 'setup', 'demo', 'ingest', '--mode', 'full', '--no-input', '--plain'],
|
|
testIo.io,
|
|
{
|
|
demo,
|
|
},
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(demo).toHaveBeenCalledWith(
|
|
{
|
|
command: 'ingest',
|
|
mode: 'full',
|
|
projectDir: tempDir,
|
|
outputMode: 'plain',
|
|
inputMode: 'disabled',
|
|
},
|
|
testIo.io,
|
|
);
|
|
});
|
|
|
|
it('prints public ingest help without invoking ingest execution', async () => {
|
|
const testIo = makeIo();
|
|
const publicIngest = vi.fn();
|
|
const lowLevelIngest = vi.fn();
|
|
|
|
await expect(runKtxCli(['ingest', '--help'], testIo.io, { publicIngest, ingest: lowLevelIngest })).resolves.toBe(0);
|
|
|
|
expect(testIo.stdout()).toContain('Usage: ktx ingest [options] [connectionId]');
|
|
expect(testIo.stdout()).toContain('Build and refresh KTX context from configured sources');
|
|
expect(testIo.stdout()).toContain('status');
|
|
expect(testIo.stdout()).toContain('watch');
|
|
expect(testIo.stdout()).toContain('ktx ingest --all [options]');
|
|
expect(testIo.stdout()).toContain('ktx ingest status [runId] [options]');
|
|
expect(testIo.stdout()).toContain('ktx ingest watch [runId] [options]');
|
|
expect(testIo.stdout()).not.toContain('ktx ingest replay <runId> [options]');
|
|
expect(testIo.stdout()).toContain('--no-input');
|
|
expect(testIo.stdout()).not.toContain('--adapter');
|
|
expect(testIo.stderr()).toBe('');
|
|
expect(publicIngest).not.toHaveBeenCalled();
|
|
expect(lowLevelIngest).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reserves public ingest run while keeping dev ingest run available', async () => {
|
|
const publicRunIo = makeIo();
|
|
const publicHelpIo = makeIo();
|
|
const devRunIo = makeIo();
|
|
const publicIngest = vi.fn(async () => 0);
|
|
const lowLevelIngest = vi.fn(async () => 0);
|
|
|
|
await expect(runKtxCli(['ingest', 'run'], publicRunIo.io, { publicIngest, ingest: lowLevelIngest })).resolves.toBe(
|
|
1,
|
|
);
|
|
expect(publicRunIo.stderr()).toMatch(/invalid argument|reserved|run/i);
|
|
expect(publicIngest).not.toHaveBeenCalled();
|
|
|
|
await expect(
|
|
runKtxCli(['ingest', 'run', '--help'], publicHelpIo.io, { publicIngest, ingest: lowLevelIngest }),
|
|
).resolves.toBe(0);
|
|
expect(publicHelpIo.stdout()).toContain('Usage: ktx ingest [options] [connectionId]');
|
|
expect(publicHelpIo.stdout()).not.toContain('Usage: ktx ingest ' + 'run');
|
|
|
|
await expect(
|
|
runKtxCli(['dev', 'ingest', 'run', '--connection-id', 'warehouse', '--adapter', 'metabase'], devRunIo.io, {
|
|
publicIngest,
|
|
ingest: lowLevelIngest,
|
|
}),
|
|
).resolves.toBe(0);
|
|
expect(lowLevelIngest).toHaveBeenCalledWith(
|
|
expect.objectContaining({ command: 'run', connectionId: 'warehouse', adapter: 'metabase' }),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it('dispatches dev doctor and ingest parser cases through Commander', async () => {
|
|
const doctor = vi.fn(async () => 0);
|
|
const ingest = vi.fn(async () => 0);
|
|
const doctorIo = makeIo();
|
|
const ingestRunIo = makeIo();
|
|
const ingestReplayHelpIo = makeIo();
|
|
|
|
await expect(runKtxCli(['dev', 'doctor', 'setup', '--json', '--no-input'], doctorIo.io, { doctor })).resolves.toBe(
|
|
0,
|
|
);
|
|
await expect(
|
|
runKtxCli(
|
|
[
|
|
'dev',
|
|
'ingest',
|
|
'run',
|
|
'--project-dir',
|
|
tempDir,
|
|
'--connection-id',
|
|
'warehouse',
|
|
'--adapter',
|
|
'fake',
|
|
'--source-dir',
|
|
tempDir,
|
|
'--debug-llm-request-file',
|
|
`${tempDir}/debug.jsonl`,
|
|
'--json',
|
|
'--no-input',
|
|
],
|
|
ingestRunIo.io,
|
|
{ ingest },
|
|
),
|
|
).resolves.toBe(0);
|
|
await expect(runKtxCli(['dev', 'ingest', 'replay', '--help'], ingestReplayHelpIo.io, { ingest })).resolves.toBe(0);
|
|
|
|
expect(doctor).toHaveBeenCalledWith({ command: 'setup', outputMode: 'json', inputMode: 'disabled' }, doctorIo.io);
|
|
expect(ingest).toHaveBeenCalledWith(
|
|
{
|
|
command: 'run',
|
|
projectDir: tempDir,
|
|
connectionId: 'warehouse',
|
|
adapter: 'fake',
|
|
sourceDir: tempDir,
|
|
databaseIntrospectionUrl: undefined,
|
|
debugLlmRequestFile: `${tempDir}/debug.jsonl`,
|
|
outputMode: 'json',
|
|
inputMode: 'disabled',
|
|
},
|
|
ingestRunIo.io,
|
|
);
|
|
expect(ingestReplayHelpIo.stdout()).toContain('Usage: ktx dev ingest replay [options] <runId>');
|
|
expect(ingestReplayHelpIo.stdout()).toContain('<runId>');
|
|
expect(doctorIo.stderr()).toBe('');
|
|
expect(ingestRunIo.stderr()).toBe('');
|
|
expect(ingestReplayHelpIo.stderr()).toBe('');
|
|
});
|
|
|
|
it('dispatches public connection through the existing connection implementation', async () => {
|
|
const testIo = makeIo();
|
|
const connection = vi.fn(async () => 0);
|
|
|
|
await expect(runKtxCli(['--project-dir', tempDir, 'connection', 'list'], testIo.io, { connection })).resolves.toBe(
|
|
0,
|
|
);
|
|
|
|
expect(connection).toHaveBeenCalledWith({ command: 'list', projectDir: tempDir }, testIo.io);
|
|
expect(testIo.stderr()).toBe('');
|
|
});
|
|
|
|
it('dispatches setup status and top-level status through the setup runner', async () => {
|
|
const setup = vi.fn(async () => 0);
|
|
const setupIo = makeIo();
|
|
const statusIo = makeIo();
|
|
|
|
await expect(
|
|
runKtxCli(['--project-dir', tempDir, 'setup', 'status', '--json'], setupIo.io, { setup }),
|
|
).resolves.toBe(0);
|
|
await expect(runKtxCli(['--project-dir', tempDir, 'status', '--json'], statusIo.io, { setup })).resolves.toBe(0);
|
|
|
|
expect(setup).toHaveBeenNthCalledWith(1, { command: 'status', projectDir: tempDir, json: true }, setupIo.io);
|
|
expect(setup).toHaveBeenNthCalledWith(2, { command: 'status', projectDir: tempDir, json: true }, statusIo.io);
|
|
});
|
|
|
|
it('dispatches setup context recovery commands through the setup runner', async () => {
|
|
const setup = vi.fn(async () => 0);
|
|
const buildIo = makeIo();
|
|
const watchIo = makeIo();
|
|
const statusIo = makeIo();
|
|
const stopIo = makeIo();
|
|
|
|
await expect(runKtxCli(['--project-dir', tempDir, 'setup', 'context', 'build'], buildIo.io, { setup })).resolves.toBe(
|
|
0,
|
|
);
|
|
await expect(
|
|
runKtxCli(['--project-dir', tempDir, 'setup', 'context', 'watch', 'setup-context-local-1'], watchIo.io, {
|
|
setup,
|
|
}),
|
|
).resolves.toBe(0);
|
|
await expect(
|
|
runKtxCli(['--project-dir', tempDir, 'setup', 'context', 'status', 'setup-context-local-1', '--json'], statusIo.io, {
|
|
setup,
|
|
}),
|
|
).resolves.toBe(0);
|
|
await expect(
|
|
runKtxCli(['--project-dir', tempDir, 'setup', 'context', 'stop', 'setup-context-local-1'], stopIo.io, {
|
|
setup,
|
|
}),
|
|
).resolves.toBe(0);
|
|
|
|
expect(setup).toHaveBeenNthCalledWith(
|
|
1,
|
|
{ command: 'context-build', projectDir: tempDir, inputMode: 'auto' },
|
|
buildIo.io,
|
|
);
|
|
expect(setup).toHaveBeenNthCalledWith(
|
|
2,
|
|
{ command: 'context-watch', projectDir: tempDir, runId: 'setup-context-local-1', inputMode: 'auto' },
|
|
watchIo.io,
|
|
);
|
|
expect(setup).toHaveBeenNthCalledWith(
|
|
3,
|
|
{ command: 'context-status', projectDir: tempDir, runId: 'setup-context-local-1', json: true },
|
|
statusIo.io,
|
|
);
|
|
expect(setup).toHaveBeenNthCalledWith(
|
|
4,
|
|
{ command: 'context-stop', projectDir: tempDir, runId: 'setup-context-local-1' },
|
|
stopIo.io,
|
|
);
|
|
});
|
|
|
|
it('dispatches Anthropic setup flags to the setup runner', async () => {
|
|
const setup = vi.fn(async () => 0);
|
|
const setupIo = makeIo();
|
|
|
|
await expect(
|
|
runKtxCli(
|
|
[
|
|
'--project-dir',
|
|
tempDir,
|
|
'setup',
|
|
'--no-input',
|
|
'--anthropic-api-key-env',
|
|
'ANTHROPIC_API_KEY',
|
|
'--anthropic-model',
|
|
'claude-sonnet-4-6',
|
|
],
|
|
setupIo.io,
|
|
{ setup },
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(setup).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
command: 'run',
|
|
projectDir: tempDir,
|
|
inputMode: 'disabled',
|
|
anthropicApiKeyEnv: 'ANTHROPIC_API_KEY',
|
|
anthropicModel: 'claude-sonnet-4-6',
|
|
skipLlm: false,
|
|
}),
|
|
setupIo.io,
|
|
);
|
|
});
|
|
|
|
it('rejects conflicting Anthropic credential setup flags', async () => {
|
|
const setup = vi.fn(async () => 0);
|
|
const setupIo = makeIo();
|
|
|
|
await expect(
|
|
runKtxCli(
|
|
[
|
|
'--project-dir',
|
|
tempDir,
|
|
'setup',
|
|
'--anthropic-api-key-env',
|
|
'ANTHROPIC_API_KEY',
|
|
'--anthropic-api-key-file',
|
|
'/tmp/anthropic-key',
|
|
],
|
|
setupIo.io,
|
|
{ setup },
|
|
),
|
|
).resolves.toBe(1);
|
|
|
|
expect(setup).not.toHaveBeenCalled();
|
|
expect(setupIo.stderr()).toContain('Choose only one Anthropic credential source');
|
|
});
|
|
|
|
it('dispatches embedding setup flags to the setup runner', async () => {
|
|
const setup = vi.fn(async () => 0);
|
|
const setupIo = makeIo();
|
|
|
|
await expect(
|
|
runKtxCli(
|
|
[
|
|
'--project-dir',
|
|
tempDir,
|
|
'setup',
|
|
'--no-input',
|
|
'--skip-llm',
|
|
'--embedding-backend',
|
|
'openai',
|
|
'--embedding-api-key-env',
|
|
'OPENAI_API_KEY',
|
|
],
|
|
setupIo.io,
|
|
{ setup },
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(setup).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
command: 'run',
|
|
projectDir: tempDir,
|
|
inputMode: 'disabled',
|
|
skipLlm: true,
|
|
embeddingBackend: 'openai',
|
|
embeddingApiKeyEnv: 'OPENAI_API_KEY',
|
|
skipEmbeddings: false,
|
|
}),
|
|
setupIo.io,
|
|
);
|
|
});
|
|
|
|
it('dispatches database setup flags to the setup runner', async () => {
|
|
const setup = vi.fn(async () => 0);
|
|
const setupIo = makeIo();
|
|
|
|
await expect(
|
|
runKtxCli(
|
|
[
|
|
'setup',
|
|
'--project-dir',
|
|
'/tmp/project',
|
|
'--no-input',
|
|
'--yes',
|
|
'--skip-llm',
|
|
'--skip-embeddings',
|
|
'--database',
|
|
'postgres',
|
|
'--new-database-connection-id',
|
|
'warehouse',
|
|
'--database-url',
|
|
'env:DATABASE_URL',
|
|
'--database-schema',
|
|
'public',
|
|
'--enable-historic-sql',
|
|
'--historic-sql-window-days',
|
|
'30',
|
|
'--historic-sql-min-calls',
|
|
'12',
|
|
],
|
|
setupIo.io,
|
|
{ setup },
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(setup).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
command: 'run',
|
|
projectDir: '/tmp/project',
|
|
inputMode: 'disabled',
|
|
yes: true,
|
|
skipLlm: true,
|
|
skipEmbeddings: true,
|
|
databaseDrivers: ['postgres'],
|
|
databaseConnectionId: 'warehouse',
|
|
databaseUrl: 'env:DATABASE_URL',
|
|
databaseSchemas: ['public'],
|
|
enableHistoricSql: true,
|
|
historicSqlWindowDays: 30,
|
|
historicSqlMinCalls: 12,
|
|
skipDatabases: false,
|
|
}),
|
|
setupIo.io,
|
|
);
|
|
});
|
|
|
|
it('dispatches setup source flags', async () => {
|
|
const setup = vi.fn(async () => 0);
|
|
const testIo = makeIo();
|
|
|
|
await expect(
|
|
runKtxCli(
|
|
[
|
|
'--project-dir',
|
|
tempDir,
|
|
'setup',
|
|
'--no-input',
|
|
'--source',
|
|
'metabase',
|
|
'--source-connection-id',
|
|
'prod_metabase',
|
|
'--source-url',
|
|
'https://metabase.example.com',
|
|
'--source-api-key-ref',
|
|
'env:METABASE_API_KEY',
|
|
'--source-warehouse-connection-id',
|
|
'warehouse',
|
|
'--metabase-database-id',
|
|
'1',
|
|
'--skip-llm',
|
|
'--skip-embeddings',
|
|
'--skip-databases',
|
|
],
|
|
testIo.io,
|
|
{ setup },
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(setup).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
command: 'run',
|
|
projectDir: tempDir,
|
|
source: 'metabase',
|
|
sourceConnectionId: 'prod_metabase',
|
|
sourceUrl: 'https://metabase.example.com',
|
|
sourceApiKeyRef: 'env:METABASE_API_KEY',
|
|
sourceWarehouseConnectionId: 'warehouse',
|
|
metabaseDatabaseId: 1,
|
|
}),
|
|
testIo.io,
|
|
);
|
|
});
|
|
|
|
it('dispatches setup agent flags and removal', async () => {
|
|
const setup = vi.fn(async () => 0);
|
|
const setupIo = makeIo();
|
|
const removeIo = makeIo();
|
|
|
|
await expect(
|
|
runKtxCli(
|
|
[
|
|
'--project-dir',
|
|
tempDir,
|
|
'setup',
|
|
'--agents',
|
|
'--target',
|
|
'codex',
|
|
'--project',
|
|
'--agent-install-mode',
|
|
'both',
|
|
'--no-input',
|
|
'--yes',
|
|
],
|
|
setupIo.io,
|
|
{ setup },
|
|
),
|
|
).resolves.toBe(0);
|
|
await expect(
|
|
runKtxCli(['--project-dir', tempDir, 'setup', 'remove', '--agents'], removeIo.io, { setup }),
|
|
).resolves.toBe(0);
|
|
|
|
expect(setup).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({
|
|
command: 'run',
|
|
agents: true,
|
|
target: 'codex',
|
|
agentScope: 'project',
|
|
agentInstallMode: 'both',
|
|
inputMode: 'disabled',
|
|
yes: true,
|
|
}),
|
|
setupIo.io,
|
|
);
|
|
expect(setup).toHaveBeenNthCalledWith(2, { command: 'remove-agents', projectDir: tempDir }, removeIo.io);
|
|
});
|
|
|
|
it('rejects source-path with source-git-url', async () => {
|
|
const setup = vi.fn(async () => 0);
|
|
const testIo = makeIo();
|
|
|
|
await expect(
|
|
runKtxCli(
|
|
[
|
|
'--project-dir',
|
|
tempDir,
|
|
'setup',
|
|
'--no-input',
|
|
'--source',
|
|
'dbt',
|
|
'--source-path',
|
|
'/repo/dbt',
|
|
'--source-git-url',
|
|
'https://github.com/acme/dbt.git',
|
|
],
|
|
testIo.io,
|
|
{ setup },
|
|
),
|
|
).resolves.toBe(1);
|
|
|
|
expect(setup).not.toHaveBeenCalled();
|
|
expect(testIo.stderr()).toContain('Choose only one source location');
|
|
});
|
|
|
|
it('rejects deterministic as a setup embedding backend', async () => {
|
|
const setup = vi.fn(async () => 0);
|
|
const setupIo = makeIo();
|
|
|
|
await expect(
|
|
runKtxCli(['--project-dir', tempDir, 'setup', '--embedding-backend', 'deterministic'], setupIo.io, { setup }),
|
|
).resolves.toBe(1);
|
|
|
|
expect(setup).not.toHaveBeenCalled();
|
|
expect(setupIo.stderr()).toContain("invalid choice 'deterministic'");
|
|
});
|
|
|
|
it('rejects gateway as a setup embedding backend', async () => {
|
|
const setup = vi.fn(async () => 0);
|
|
const setupIo = makeIo();
|
|
|
|
await expect(
|
|
runKtxCli(['--project-dir', tempDir, 'setup', '--embedding-backend', 'gateway'], setupIo.io, { setup }),
|
|
).resolves.toBe(1);
|
|
|
|
expect(setup).not.toHaveBeenCalled();
|
|
expect(setupIo.stderr()).toContain("invalid choice 'gateway'");
|
|
});
|
|
|
|
it('rejects conflicting embedding credential setup flags', async () => {
|
|
const setup = vi.fn(async () => 0);
|
|
const setupIo = makeIo();
|
|
|
|
await expect(
|
|
runKtxCli(
|
|
[
|
|
'--project-dir',
|
|
tempDir,
|
|
'setup',
|
|
'--embedding-backend',
|
|
'openai',
|
|
'--embedding-api-key-env',
|
|
'OPENAI_API_KEY',
|
|
'--embedding-api-key-file',
|
|
'/tmp/openai-key',
|
|
],
|
|
setupIo.io,
|
|
{ setup },
|
|
),
|
|
).resolves.toBe(1);
|
|
|
|
expect(setup).not.toHaveBeenCalled();
|
|
expect(setupIo.stderr()).toContain('Choose only one embedding credential source');
|
|
});
|
|
|
|
it('rejects conflicting Historic SQL setup flags', async () => {
|
|
const setup = vi.fn(async () => 0);
|
|
const setupIo = makeIo();
|
|
|
|
await expect(
|
|
runKtxCli(['--project-dir', tempDir, 'setup', '--enable-historic-sql', '--disable-historic-sql'], setupIo.io, {
|
|
setup,
|
|
}),
|
|
).resolves.toBe(1);
|
|
|
|
expect(setup).not.toHaveBeenCalled();
|
|
expect(setupIo.stderr()).toContain('Choose only one Historic SQL action');
|
|
});
|
|
|
|
it('registers hidden agent help and tools discovery without showing agent in root help', async () => {
|
|
const helpIo = makeIo();
|
|
const toolsIo = makeIo();
|
|
const agent = vi.fn(async () => 0);
|
|
|
|
await expect(runKtxCli(['agent', '--help'], helpIo.io, { agent })).resolves.toBe(0);
|
|
await expect(
|
|
runKtxCli(['--project-dir', tempDir, 'agent', 'tools', '--json'], toolsIo.io, { agent }),
|
|
).resolves.toBe(0);
|
|
|
|
expect(helpIo.stdout()).toContain('Usage: ktx agent');
|
|
expect(toolsIo.stderr()).toBe('');
|
|
expect(agent).toHaveBeenCalledWith({ command: 'tools', projectDir: tempDir, json: true }, toolsIo.io);
|
|
});
|
|
|
|
it('dispatches full hidden agent commands without exposing agent in root help', async () => {
|
|
const agent = vi.fn(async () => 0);
|
|
const cases = [
|
|
{
|
|
argv: ['--project-dir', tempDir, 'agent', 'context', '--json'],
|
|
args: { command: 'context', projectDir: tempDir, json: true },
|
|
},
|
|
{
|
|
argv: [
|
|
'--project-dir',
|
|
tempDir,
|
|
'agent',
|
|
'sl',
|
|
'list',
|
|
'--json',
|
|
'--connection-id',
|
|
'warehouse',
|
|
'--query',
|
|
'orders',
|
|
],
|
|
args: { command: 'sl-list', projectDir: tempDir, json: true, connectionId: 'warehouse', query: 'orders' },
|
|
},
|
|
{
|
|
argv: ['--project-dir', tempDir, 'agent', 'sl', 'read', 'orders', '--json', '--connection-id', 'warehouse'],
|
|
args: { command: 'sl-read', projectDir: tempDir, json: true, sourceName: 'orders', connectionId: 'warehouse' },
|
|
},
|
|
{
|
|
argv: [
|
|
'--project-dir',
|
|
tempDir,
|
|
'agent',
|
|
'sl',
|
|
'query',
|
|
'--json',
|
|
'--connection-id',
|
|
'warehouse',
|
|
'--query-file',
|
|
'/tmp/query.json',
|
|
'--execute',
|
|
'--max-rows',
|
|
'100',
|
|
],
|
|
args: {
|
|
command: 'sl-query',
|
|
projectDir: tempDir,
|
|
json: true,
|
|
connectionId: 'warehouse',
|
|
queryFile: '/tmp/query.json',
|
|
execute: true,
|
|
maxRows: 100,
|
|
},
|
|
},
|
|
{
|
|
argv: ['--project-dir', tempDir, 'agent', 'wiki', 'search', 'revenue', '--json', '--limit', '5'],
|
|
args: { command: 'wiki-search', projectDir: tempDir, json: true, query: 'revenue', limit: 5 },
|
|
},
|
|
{
|
|
argv: ['--project-dir', tempDir, 'agent', 'wiki', 'read', 'page-1', '--json'],
|
|
args: { command: 'wiki-read', projectDir: tempDir, json: true, pageId: 'page-1' },
|
|
},
|
|
{
|
|
argv: [
|
|
'--project-dir',
|
|
tempDir,
|
|
'agent',
|
|
'sql',
|
|
'execute',
|
|
'--json',
|
|
'--connection-id',
|
|
'warehouse',
|
|
'--sql-file',
|
|
'/tmp/query.sql',
|
|
'--max-rows',
|
|
'100',
|
|
],
|
|
args: {
|
|
command: 'sql-execute',
|
|
projectDir: tempDir,
|
|
json: true,
|
|
connectionId: 'warehouse',
|
|
sqlFile: '/tmp/query.sql',
|
|
maxRows: 100,
|
|
},
|
|
},
|
|
];
|
|
|
|
for (const entry of cases) {
|
|
const io = makeIo();
|
|
await expect(runKtxCli(entry.argv, io.io, { agent })).resolves.toBe(0);
|
|
expect(agent).toHaveBeenLastCalledWith(entry.args, io.io);
|
|
expect(io.stderr()).toBe('');
|
|
}
|
|
|
|
const helpIo = makeIo();
|
|
await expect(runKtxCli(['--help'], helpIo.io, { agent })).resolves.toBe(0);
|
|
expect(helpIo.stdout()).not.toContain('agent ');
|
|
});
|
|
|
|
it('prints semantic-layer hybrid search metadata from the hidden agent sl list command', async () => {
|
|
const agent = vi.fn(async (args, io) => {
|
|
expect(args).toEqual({
|
|
command: 'sl-list',
|
|
projectDir: tempDir,
|
|
json: true,
|
|
connectionId: 'warehouse',
|
|
query: 'paid',
|
|
});
|
|
io.stdout.write(
|
|
`${JSON.stringify(
|
|
{
|
|
sources: [
|
|
{
|
|
connectionId: 'warehouse',
|
|
connectionName: 'warehouse',
|
|
name: 'orders',
|
|
columnCount: 2,
|
|
measureCount: 1,
|
|
joinCount: 0,
|
|
score: 0.03278688524590164,
|
|
matchReasons: ['dictionary'],
|
|
dictionaryMatches: [{ column: 'status', values: ['paid'] }],
|
|
},
|
|
],
|
|
totalSources: 1,
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
return 0;
|
|
});
|
|
const io = makeIo();
|
|
|
|
await expect(
|
|
runKtxCli(
|
|
['--project-dir', tempDir, 'agent', 'sl', 'list', '--json', '--connection-id', 'warehouse', '--query', 'paid'],
|
|
io.io,
|
|
{ agent },
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(JSON.parse(io.stdout())).toEqual({
|
|
sources: [
|
|
expect.objectContaining({
|
|
connectionId: 'warehouse',
|
|
name: 'orders',
|
|
matchReasons: ['dictionary'],
|
|
dictionaryMatches: [{ column: 'status', values: ['paid'] }],
|
|
}),
|
|
],
|
|
totalSources: 1,
|
|
});
|
|
});
|
|
|
|
it('prints wiki hybrid search metadata from the hidden agent wiki search command', async () => {
|
|
const agent = vi.fn(async (args, io) => {
|
|
expect(args).toEqual({
|
|
command: 'wiki-search',
|
|
projectDir: tempDir,
|
|
json: true,
|
|
query: 'paid order',
|
|
limit: 5,
|
|
});
|
|
io.stdout.write(
|
|
`${JSON.stringify(
|
|
{
|
|
results: [
|
|
{
|
|
key: 'metrics/revenue',
|
|
path: 'knowledge/global/metrics/revenue.md',
|
|
scope: 'GLOBAL',
|
|
summary: 'Revenue metric definition',
|
|
score: 0.02459016393442623,
|
|
matchReasons: ['lexical', 'token'],
|
|
},
|
|
],
|
|
totalFound: 1,
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
return 0;
|
|
});
|
|
const io = makeIo();
|
|
|
|
await expect(
|
|
runKtxCli(['--project-dir', tempDir, 'agent', 'wiki', 'search', 'paid order', '--json', '--limit', '5'], io.io, {
|
|
agent,
|
|
}),
|
|
).resolves.toBe(0);
|
|
|
|
expect(JSON.parse(io.stdout())).toEqual({
|
|
results: [
|
|
expect.objectContaining({
|
|
key: 'metrics/revenue',
|
|
path: 'knowledge/global/metrics/revenue.md',
|
|
matchReasons: ['lexical', 'token'],
|
|
}),
|
|
],
|
|
totalFound: 1,
|
|
});
|
|
});
|
|
|
|
it('dispatches public connection subcommands through the existing connection implementation', async () => {
|
|
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-connection-dispatch-'));
|
|
const connection = vi.fn(async () => 0);
|
|
|
|
await expect(
|
|
runKtxCli(['--project-dir', tempDir, 'connection', 'list'], makeIo().io, { connection }),
|
|
).resolves.toBe(0);
|
|
|
|
const removeIo = makeIo();
|
|
await expect(
|
|
runKtxCli(['--project-dir', tempDir, 'connection', 'remove', 'warehouse', '--force', '--no-input'], removeIo.io, {
|
|
connection,
|
|
}),
|
|
).resolves.toBe(0);
|
|
|
|
const mapIo = makeIo();
|
|
await expect(
|
|
runKtxCli(['--project-dir', tempDir, 'connection', 'map', 'prod-metabase', '--json'], mapIo.io, {
|
|
connection,
|
|
}),
|
|
).resolves.toBe(0);
|
|
|
|
expect(connection).toHaveBeenNthCalledWith(1, { command: 'list', projectDir: tempDir }, expect.anything());
|
|
expect(connection).toHaveBeenNthCalledWith(
|
|
2,
|
|
{
|
|
command: 'remove',
|
|
projectDir: tempDir,
|
|
connectionId: 'warehouse',
|
|
force: true,
|
|
inputMode: 'disabled',
|
|
},
|
|
expect.anything(),
|
|
);
|
|
expect(connection).toHaveBeenNthCalledWith(
|
|
3,
|
|
{
|
|
command: 'map',
|
|
projectDir: tempDir,
|
|
sourceConnectionId: 'prod-metabase',
|
|
json: true,
|
|
},
|
|
expect.anything(),
|
|
);
|
|
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('prints help for connection metabase setup', async () => {
|
|
const helpIo = makeIo();
|
|
|
|
await expect(runKtxCli(['connection', 'metabase', 'setup', '--help'], helpIo.io)).resolves.toBe(0);
|
|
|
|
expect(helpIo.stdout()).toContain('Usage: ktx connection metabase setup');
|
|
for (const option of [
|
|
'--id <connectionId>',
|
|
'--url <url>',
|
|
'--api-key <key>',
|
|
'--username <email>',
|
|
'--password <password>',
|
|
'--mint-api-key',
|
|
'--map <metabaseDatabaseId=targetConnectionId>',
|
|
'--sync <metabaseDatabaseId>',
|
|
'--sync-mode <mode>',
|
|
'--run-ingest',
|
|
'--yes',
|
|
'--no-input',
|
|
]) {
|
|
expect(helpIo.stdout()).toContain(option);
|
|
}
|
|
expect(helpIo.stdout()).toContain('Guided equivalent of:');
|
|
for (const line of [
|
|
'ktx connection mapping refresh <connectionId> --auto-accept',
|
|
'ktx connection mapping set <connectionId> databaseMappings <id>=<target>',
|
|
'ktx connection mapping set-sync-enabled <connectionId> <id> --enabled true',
|
|
'ktx ingest <connectionId>',
|
|
]) {
|
|
expect(helpIo.stdout()).toContain(line);
|
|
}
|
|
expect(helpIo.stderr()).toBe('');
|
|
});
|
|
|
|
it('dispatches connection metabase setup through Commander', async () => {
|
|
const connectionMetabaseSetup = vi.fn(async () => 0);
|
|
const fakeMetabaseCredential = 'mb_example';
|
|
const setupIo = makeIo();
|
|
|
|
await expect(
|
|
runKtxCli(
|
|
[
|
|
'connection',
|
|
'metabase',
|
|
'setup',
|
|
'--project-dir',
|
|
tempDir,
|
|
'--id',
|
|
'metabase',
|
|
'--url',
|
|
'http://metabase.example.test:3000',
|
|
'--api-key',
|
|
'mb_example',
|
|
'--map',
|
|
'2=orbit',
|
|
'--sync',
|
|
'2',
|
|
'--yes',
|
|
'--no-input',
|
|
],
|
|
setupIo.io,
|
|
{ connectionMetabaseSetup },
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(connectionMetabaseSetup).toHaveBeenCalledWith(
|
|
{
|
|
command: 'setup',
|
|
projectDir: tempDir,
|
|
connectionId: 'metabase',
|
|
url: 'http://metabase.example.test:3000',
|
|
apiKey: fakeMetabaseCredential,
|
|
mintApiKey: false,
|
|
mappings: [{ metabaseDatabaseId: 2, targetConnectionId: 'orbit' }],
|
|
syncEnabledDatabaseIds: [2],
|
|
syncMode: 'ALL',
|
|
runIngest: false,
|
|
yes: true,
|
|
inputMode: 'disabled',
|
|
},
|
|
expect.anything(),
|
|
);
|
|
expect(setupIo.stderr()).toBe('');
|
|
});
|
|
|
|
it('validates connection metabase setup option values before runner dispatch', async () => {
|
|
const connectionMetabaseSetup = vi.fn(async () => 0);
|
|
|
|
for (const argv of [
|
|
[
|
|
'connection',
|
|
'metabase',
|
|
'setup',
|
|
'--project-dir',
|
|
tempDir,
|
|
'--url',
|
|
'http://metabase.example.test:3000',
|
|
'--api-key',
|
|
'mb_example',
|
|
'--map',
|
|
'nope=orbit',
|
|
],
|
|
[
|
|
'connection',
|
|
'metabase',
|
|
'setup',
|
|
'--project-dir',
|
|
tempDir,
|
|
'--url',
|
|
'http://metabase.example.test:3000',
|
|
'--api-key',
|
|
'mb_example',
|
|
'--map',
|
|
'2=../orbit',
|
|
],
|
|
[
|
|
'connection',
|
|
'metabase',
|
|
'setup',
|
|
'--project-dir',
|
|
tempDir,
|
|
'--url',
|
|
'http://metabase.example.test:3000',
|
|
'--api-key',
|
|
'mb_example',
|
|
'--sync',
|
|
'nope',
|
|
],
|
|
[
|
|
'connection',
|
|
'metabase',
|
|
'setup',
|
|
'--project-dir',
|
|
tempDir,
|
|
'--url',
|
|
'http://metabase.example.test:3000',
|
|
'--api-key',
|
|
'mb_example',
|
|
'--sync-mode',
|
|
'BAD',
|
|
],
|
|
[
|
|
'connection',
|
|
'metabase',
|
|
'setup',
|
|
'--project-dir',
|
|
tempDir,
|
|
'--url',
|
|
'http://metabase.example.test:3000',
|
|
'--api-key',
|
|
'mb_example',
|
|
'--mint-api-key',
|
|
'--api-key',
|
|
'also_bad',
|
|
],
|
|
]) {
|
|
const testIo = makeIo();
|
|
await expect(runKtxCli(argv, testIo.io, { connectionMetabaseSetup })).resolves.toBe(1);
|
|
expect(testIo.stderr()).toMatch(/map|sync|sync-mode|conflict|cannot be used|invalid|integer|choices/i);
|
|
}
|
|
|
|
expect(connectionMetabaseSetup).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects commands removed from the May 6 root surface', async () => {
|
|
for (const argv of [
|
|
['init'],
|
|
['connect', 'list'],
|
|
['scan', 'warehouse'],
|
|
['knowledge', 'list'],
|
|
['ask', 'What sources are connected?'],
|
|
]) {
|
|
const testIo = makeIo();
|
|
|
|
await expect(runKtxCli(argv, testIo.io)).resolves.toBe(1);
|
|
|
|
expect(testIo.stderr()).toMatch(/unknown command|error:/);
|
|
}
|
|
});
|
|
|
|
it('dispatches connection add options through Commander', async () => {
|
|
const testIo = makeIo();
|
|
const connection = vi.fn(async () => 0);
|
|
|
|
await expect(
|
|
runKtxCli(
|
|
[
|
|
'connection',
|
|
'add',
|
|
'notion',
|
|
'notion-main',
|
|
'--project-dir',
|
|
tempDir,
|
|
'--token-env',
|
|
'NOTION_AUTH_TOKEN',
|
|
'--crawl-mode',
|
|
'selected_roots',
|
|
'--root-page-id',
|
|
'page-1',
|
|
'--root-database-id',
|
|
'database-1',
|
|
'--max-pages',
|
|
'80',
|
|
],
|
|
testIo.io,
|
|
{ connection },
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(connection).toHaveBeenCalledWith(
|
|
{
|
|
command: 'add',
|
|
projectDir: tempDir,
|
|
driver: 'notion',
|
|
connectionId: 'notion-main',
|
|
url: undefined,
|
|
schemas: [],
|
|
readonly: false,
|
|
force: false,
|
|
allowLiteralCredentials: false,
|
|
notion: {
|
|
authTokenRef: 'env:NOTION_AUTH_TOKEN',
|
|
crawlMode: 'selected_roots',
|
|
rootPageIds: ['page-1'],
|
|
rootDatabaseIds: ['database-1'],
|
|
rootDataSourceIds: [],
|
|
maxPagesPerRun: 80,
|
|
maxKnowledgeCreatesPerRun: undefined,
|
|
maxKnowledgeUpdatesPerRun: undefined,
|
|
},
|
|
},
|
|
testIo.io,
|
|
);
|
|
expect(testIo.stderr()).toBe('');
|
|
});
|
|
|
|
it('prints generated connection notion pick help without invoking execution', async () => {
|
|
const helpCases = [
|
|
['connection', 'notion', '--help'],
|
|
['connection', 'notion', 'pick', '--help'],
|
|
['connection', 'notion', 'pick', 'notion-main', '--help'],
|
|
];
|
|
|
|
for (const argv of helpCases) {
|
|
const testIo = makeIo();
|
|
const connectionNotion = vi.fn(async () => 0);
|
|
|
|
await expect(runKtxCli(argv, testIo.io, { connectionNotion })).resolves.toBe(0);
|
|
|
|
expect(testIo.stdout()).toContain('Usage: ktx connection notion');
|
|
expect(testIo.stdout()).toContain('pick');
|
|
expect(testIo.stderr()).toBe('');
|
|
expect(connectionNotion).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it('dispatches connection notion pick through Commander', async () => {
|
|
const testIo = makeIo();
|
|
const connectionNotion = vi.fn(async () => 0);
|
|
|
|
await expect(
|
|
runKtxCli(
|
|
[
|
|
'--project-dir',
|
|
tempDir,
|
|
'connection',
|
|
'notion',
|
|
'pick',
|
|
'notion-main',
|
|
'--no-input',
|
|
'--root-page-id',
|
|
'11111111222233334444555555555555',
|
|
'--root-page-id',
|
|
'11111111-2222-3333-4444-555555555555',
|
|
],
|
|
testIo.io,
|
|
{ connectionNotion },
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(connectionNotion).toHaveBeenCalledWith(
|
|
{
|
|
command: 'pick',
|
|
projectDir: tempDir,
|
|
connectionId: 'notion-main',
|
|
mode: 'non-interactive',
|
|
rootPageIds: ['11111111-2222-3333-4444-555555555555'],
|
|
},
|
|
testIo.io,
|
|
);
|
|
expect(testIo.stderr()).toBe('');
|
|
});
|
|
|
|
it('ignores connection notion pick root page flags in interactive mode', async () => {
|
|
const testIo = makeIo();
|
|
const connectionNotion = vi.fn(async () => 0);
|
|
|
|
await expect(
|
|
runKtxCli(['connection', 'notion', 'pick', 'notion-main', '--root-page-id', 'not-a-uuid'], testIo.io, {
|
|
connectionNotion,
|
|
}),
|
|
).resolves.toBe(0);
|
|
|
|
expect(connectionNotion).toHaveBeenCalledWith(
|
|
{
|
|
command: 'pick',
|
|
projectDir: expect.any(String),
|
|
connectionId: 'notion-main',
|
|
mode: 'interactive',
|
|
},
|
|
testIo.io,
|
|
);
|
|
expect(testIo.stderr()).toBe('');
|
|
});
|
|
|
|
it('rejects connection notion pick no-input mode without root page ids', async () => {
|
|
const testIo = makeIo();
|
|
const connectionNotion = vi.fn(async () => 0);
|
|
|
|
await expect(
|
|
runKtxCli(['connection', 'notion', 'pick', 'notion-main', '--no-input'], testIo.io, { connectionNotion }),
|
|
).resolves.toBe(1);
|
|
|
|
expect(connectionNotion).not.toHaveBeenCalled();
|
|
expect(testIo.stderr()).toContain('connection notion pick --no-input requires at least one --root-page-id');
|
|
});
|
|
|
|
it('writes basic debug dispatch information when --debug is set', async () => {
|
|
const testIo = makeIo();
|
|
const connection = vi.fn().mockResolvedValue(0);
|
|
|
|
await expect(
|
|
runKtxCli(['--project-dir', tempDir, '--debug', 'connection', 'list'], testIo.io, { connection }),
|
|
).resolves.toBe(0);
|
|
|
|
expect(testIo.stderr()).toContain(`[debug] projectDir=${tempDir}`);
|
|
expect(testIo.stderr()).toContain('[debug] dispatch=connection');
|
|
});
|
|
|
|
it('routes low-level scan through ktx dev with top-level project-dir', async () => {
|
|
const testIo = makeIo();
|
|
const scan = vi.fn().mockResolvedValue(0);
|
|
|
|
await expect(runKtxCli(['--project-dir', tempDir, 'dev', 'scan', 'warehouse'], testIo.io, { scan })).resolves.toBe(
|
|
0,
|
|
);
|
|
|
|
expect(scan).toHaveBeenCalledWith(
|
|
{
|
|
command: 'run',
|
|
projectDir: tempDir,
|
|
connectionId: 'warehouse',
|
|
mode: 'structural',
|
|
detectRelationships: false,
|
|
dryRun: false,
|
|
databaseIntrospectionUrl: undefined,
|
|
},
|
|
testIo.io,
|
|
);
|
|
});
|
|
|
|
it('dispatches serve public command options through Commander', async () => {
|
|
const serveIo = makeIo();
|
|
const serveStdio = vi.fn(async () => 0);
|
|
|
|
await expect(
|
|
runKtxCli(
|
|
[
|
|
'serve',
|
|
'--mcp',
|
|
'stdio',
|
|
'--project-dir',
|
|
tempDir,
|
|
'--semantic-compute-url',
|
|
'http://127.0.0.1:18080',
|
|
'--execute-queries',
|
|
'--memory-capture',
|
|
'--memory-model',
|
|
'openai/gpt-5.2',
|
|
],
|
|
serveIo.io,
|
|
{ serveStdio },
|
|
),
|
|
).resolves.toBe(0);
|
|
|
|
expect(serveStdio).toHaveBeenCalledWith({
|
|
mcp: 'stdio',
|
|
projectDir: tempDir,
|
|
userId: 'local',
|
|
semanticCompute: true,
|
|
semanticComputeUrl: 'http://127.0.0.1:18080',
|
|
databaseIntrospectionUrl: undefined,
|
|
executeQueries: true,
|
|
memoryCapture: true,
|
|
memoryModel: 'openai/gpt-5.2',
|
|
});
|
|
expect(serveIo.stderr()).toBe('');
|
|
});
|
|
|
|
it('prints dev help for bare dev commands', async () => {
|
|
const testIo = makeIo();
|
|
|
|
await expect(runKtxCli(['dev'], testIo.io)).resolves.toBe(0);
|
|
|
|
expect(testIo.stdout()).toContain('Usage: ktx dev [options] [command]');
|
|
expect(testIo.stdout()).toContain('Low-level diagnostics');
|
|
expect(testIo.stdout()).toContain('scan');
|
|
expect(testIo.stdout()).toContain('ingest');
|
|
expect(testIo.stdout()).toContain('mapping');
|
|
expect(testIo.stdout()).not.toContain('model');
|
|
expect(testIo.stdout()).not.toContain('knowledge');
|
|
expect(testIo.stderr()).toBe('');
|
|
});
|
|
|
|
it('prints dev command help without invoking low-level execution', async () => {
|
|
for (const [command, expected] of [
|
|
['scan', ['Usage: ktx dev scan', '--dry-run', 'status', 'report']],
|
|
['ingest', ['Usage: ktx dev ingest', 'run', 'replay']],
|
|
['mapping', ['Usage: ktx dev mapping', 'sync-state', 'validate']],
|
|
] as const) {
|
|
const testIo = makeIo();
|
|
const scan = vi.fn().mockResolvedValue(0);
|
|
const sl = vi.fn().mockResolvedValue(0);
|
|
|
|
await expect(runKtxCli(['dev', command, '--help'], testIo.io, { scan, sl })).resolves.toBe(0);
|
|
|
|
for (const text of expected) {
|
|
expect(testIo.stdout()).toContain(text);
|
|
}
|
|
expect(testIo.stderr()).toBe('');
|
|
expect(scan).not.toHaveBeenCalled();
|
|
expect(sl).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it('prints dev scan subcommand help without invoking scan execution', async () => {
|
|
const testIo = makeIo();
|
|
const scan = vi.fn().mockResolvedValue(0);
|
|
|
|
await expect(runKtxCli(['dev', 'scan', 'report', '--help'], testIo.io, { scan })).resolves.toBe(0);
|
|
|
|
expect(testIo.stdout()).toContain('Usage: ktx dev scan report [options] <runId>');
|
|
expect(testIo.stderr()).toBe('');
|
|
expect(scan).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects removed reserved dev subcommands', async () => {
|
|
const testIo = makeIo();
|
|
|
|
await expect(runKtxCli(['dev', 'artifacts'], testIo.io)).resolves.toBe(1);
|
|
|
|
expect(testIo.stderr()).toMatch(/unknown command|error:/);
|
|
});
|
|
|
|
it('rejects mutually exclusive output modes before invoking runners', async () => {
|
|
const ingest = vi.fn(async () => 0);
|
|
const demo = vi.fn(async () => 0);
|
|
|
|
for (const argv of [
|
|
['dev', 'ingest', 'run', '--connection-id', 'warehouse', '--adapter', 'fake', '--json', '--plain'],
|
|
['dev', 'ingest', 'status', 'run-1', '--json', '--viz'],
|
|
['setup', 'demo', '--json', '--plain'],
|
|
['setup', 'demo', 'replay', '--json', '--plain'],
|
|
]) {
|
|
const testIo = makeIo();
|
|
await expect(runKtxCli(argv, testIo.io, { ingest, demo })).resolves.toBe(1);
|
|
expect(testIo.stderr()).toMatch(/conflict|cannot be used/i);
|
|
}
|
|
|
|
expect(ingest).not.toHaveBeenCalled();
|
|
expect(demo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects mutually exclusive credential and scan mode options before invoking runners', async () => {
|
|
const connection = vi.fn(async () => 0);
|
|
const scan = vi.fn(async () => 0);
|
|
|
|
const tokenIo = makeIo();
|
|
await expect(
|
|
runKtxCli(
|
|
[
|
|
'connection',
|
|
'add',
|
|
'notion',
|
|
'notion-main',
|
|
'--token-env',
|
|
'NOTION_TOKEN',
|
|
'--token-file',
|
|
'/tmp/notion-token',
|
|
'--root-page-id',
|
|
'11111111111111111111111111111111',
|
|
],
|
|
tokenIo.io,
|
|
{ connection },
|
|
),
|
|
).resolves.toBe(1);
|
|
expect(tokenIo.stderr()).toMatch(/conflict|cannot be used/i);
|
|
|
|
expect(connection).not.toHaveBeenCalled();
|
|
expect(scan).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('validates connection mapping set syntax before runner domain validation', async () => {
|
|
const badFieldIo = makeIo();
|
|
await expect(
|
|
runKtxCli(['connection', 'mapping', 'set', 'prod-metabase', 'invalidMappings', '1=warehouse'], badFieldIo.io),
|
|
).resolves.toBe(1);
|
|
expect(badFieldIo.stderr()).toContain('databaseMappings or connectionMappings');
|
|
|
|
for (const assignment of ['missing-equals', '=warehouse', '1=']) {
|
|
const testIo = makeIo();
|
|
await expect(
|
|
runKtxCli(['connection', 'mapping', 'set', 'prod-metabase', 'databaseMappings', assignment], testIo.io),
|
|
).resolves.toBe(1);
|
|
expect(testIo.stderr()).toContain('non-empty <key>=<value>');
|
|
}
|
|
});
|
|
|
|
it('does not expose root init after setup owns project creation', async () => {
|
|
const testIo = makeIo();
|
|
|
|
await expect(runKtxCli(['init'], testIo.io)).resolves.toBe(1);
|
|
|
|
expect(testIo.stderr()).toContain("error: unknown command 'init'");
|
|
});
|
|
|
|
it('returns an error code for unknown commands', async () => {
|
|
const testIo = makeIo();
|
|
|
|
await expect(runKtxCli(['unknown'], testIo.io)).resolves.toBe(1);
|
|
|
|
expect(testIo.stderr()).toContain("error: unknown command 'unknown'");
|
|
});
|
|
});
|