2026-05-22 18:18:47 +02:00
|
|
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
|
|
|
import { tmpdir } from 'node:os';
|
|
|
|
|
import { join } from 'node:path';
|
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
|
|
test: split cli tests from source tree (#216)
* 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
2026-05-26 08:49:05 +02:00
|
|
|
import { runCommanderKtxCli } from '../src/cli-program.js';
|
|
|
|
|
import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from '../src/cli-runtime.js';
|
2026-05-31 23:44:33 +02:00
|
|
|
import { TELEMETRY_NOTICE } from '../src/telemetry/identity.js';
|
2026-05-22 18:18:47 +02:00
|
|
|
|
2026-06-05 19:36:21 +02:00
|
|
|
const reportExceptionMock = vi.hoisted(() => vi.fn(async () => {}));
|
|
|
|
|
|
|
|
|
|
vi.mock('../src/telemetry/exception.js', () => ({
|
|
|
|
|
reportException: reportExceptionMock,
|
|
|
|
|
}));
|
|
|
|
|
|
2026-06-09 12:22:56 +02:00
|
|
|
function makeIo(
|
|
|
|
|
stdoutIsTTY = true,
|
|
|
|
|
stderrIsTTY = false,
|
|
|
|
|
): { io: KtxCliIo; stdout: () => string; stderr: () => string } {
|
2026-05-22 18:18:47 +02:00
|
|
|
let stdout = '';
|
|
|
|
|
let stderr = '';
|
2026-06-09 12:22:56 +02:00
|
|
|
const stderrStream = stderrIsTTY
|
|
|
|
|
? {
|
|
|
|
|
isTTY: true,
|
|
|
|
|
columns: 80,
|
|
|
|
|
on: () => undefined,
|
|
|
|
|
write: (chunk: string) => {
|
|
|
|
|
stderr += chunk;
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
: {
|
|
|
|
|
write: (chunk: string) => {
|
|
|
|
|
stderr += chunk;
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-22 18:18:47 +02:00
|
|
|
return {
|
|
|
|
|
io: {
|
|
|
|
|
stdout: {
|
|
|
|
|
isTTY: stdoutIsTTY,
|
|
|
|
|
write: (chunk) => {
|
|
|
|
|
stdout += chunk;
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-06-09 12:22:56 +02:00
|
|
|
stderr: stderrStream,
|
2026-05-22 18:18:47 +02:00
|
|
|
},
|
|
|
|
|
stdout: () => stdout,
|
|
|
|
|
stderr: () => stderr,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const info: KtxCliPackageInfo = { name: '@kaelio/ktx', version: '0.4.1' };
|
|
|
|
|
|
|
|
|
|
describe('runCommanderKtxCli telemetry', () => {
|
|
|
|
|
let tempDir: string;
|
|
|
|
|
const originalEnv = process.env;
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-telemetry-'));
|
|
|
|
|
await writeFile(join(tempDir, 'ktx.yaml'), '{}\n', 'utf-8');
|
|
|
|
|
vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
|
|
|
|
|
vi.stubEnv('HOME', tempDir);
|
|
|
|
|
vi.stubEnv('CI', '');
|
|
|
|
|
vi.stubEnv('KTX_TELEMETRY_DISABLED', '');
|
|
|
|
|
vi.stubEnv('DO_NOT_TRACK', '');
|
2026-06-05 19:36:21 +02:00
|
|
|
reportExceptionMock.mockClear();
|
2026-05-22 18:18:47 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
vi.unstubAllEnvs();
|
|
|
|
|
process.env = originalEnv;
|
|
|
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('emits debug command telemetry for registered actions', async () => {
|
|
|
|
|
const io = makeIo(true);
|
|
|
|
|
await expect(
|
|
|
|
|
runCommanderKtxCli(
|
|
|
|
|
['--project-dir', tempDir, 'status', '--help'],
|
|
|
|
|
io.io,
|
|
|
|
|
{},
|
|
|
|
|
info,
|
|
|
|
|
{ runInit: async () => 0 },
|
|
|
|
|
),
|
|
|
|
|
).resolves.toBe(0);
|
|
|
|
|
|
|
|
|
|
expect(io.stderr()).not.toContain('[telemetry]');
|
|
|
|
|
|
|
|
|
|
const statusIo = makeIo(true);
|
|
|
|
|
const deps: KtxCliDeps = { doctor: async () => 0 };
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
runCommanderKtxCli(
|
|
|
|
|
['--project-dir', tempDir, 'status', '--json'],
|
|
|
|
|
statusIo.io,
|
|
|
|
|
deps,
|
|
|
|
|
info,
|
|
|
|
|
{ runInit: async () => 0 },
|
|
|
|
|
),
|
|
|
|
|
).resolves.toBe(0);
|
|
|
|
|
|
|
|
|
|
expect(statusIo.stderr()).toContain('[telemetry]');
|
|
|
|
|
expect(statusIo.stderr()).toContain('"event":"install_first_run"');
|
|
|
|
|
expect(statusIo.stderr()).toContain('"event":"command"');
|
|
|
|
|
expect(statusIo.stderr()).toContain('"commandPath":["ktx","status"]');
|
|
|
|
|
expect(statusIo.stderr()).toContain('"event":"project_stack_snapshot"');
|
|
|
|
|
expect(statusIo.stderr()).toContain('"connectionCount"');
|
|
|
|
|
expect(statusIo.stderr()).not.toContain(tempDir);
|
|
|
|
|
|
2026-05-31 23:44:33 +02:00
|
|
|
const noticeIndex = statusIo.stderr().indexOf(TELEMETRY_NOTICE);
|
2026-05-22 18:18:47 +02:00
|
|
|
const firstTelemetryIndex = statusIo.stderr().indexOf('[telemetry]');
|
|
|
|
|
expect(noticeIndex).toBeGreaterThanOrEqual(0);
|
|
|
|
|
expect(firstTelemetryIndex).toBeGreaterThan(noticeIndex);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('emits aborted telemetry when project validation aborts after preAction starts', async () => {
|
|
|
|
|
const missingProjectDir = join(tempDir, 'missing');
|
|
|
|
|
await mkdir(missingProjectDir, { recursive: true });
|
|
|
|
|
const io = makeIo(true);
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
runCommanderKtxCli(
|
|
|
|
|
['--project-dir', missingProjectDir, 'connection'],
|
|
|
|
|
io.io,
|
|
|
|
|
{},
|
|
|
|
|
info,
|
|
|
|
|
{ runInit: async () => 0 },
|
|
|
|
|
),
|
|
|
|
|
).resolves.toBe(1);
|
|
|
|
|
|
|
|
|
|
expect(io.stderr()).toContain('[telemetry]');
|
|
|
|
|
expect(io.stderr()).toContain('"outcome":"aborted"');
|
|
|
|
|
expect(io.stderr()).toContain('"hasProject":false');
|
|
|
|
|
expect(io.stderr()).toContain('"projectGroupAttached":false');
|
|
|
|
|
expect(io.stderr()).not.toContain(missingProjectDir);
|
|
|
|
|
});
|
|
|
|
|
|
fix(cli): classify ktx setup abandonment as aborted, not a blank error (#278)
* fix(cli): classify ktx setup abandonment as aborted, not a blank error
ktx setup returned a non-zero exit code without throwing when a user
abandoned the interactive wizard, so the command telemetry recorded
outcome=error with no errorClass/errorDetail — an unactionable blank in
the errors dashboard, where most ktx setup "errors" were really people
backing out of the wizard.
Add annotateCommandOutcome() to the command span so the setup flow (the
decision-maker) records the true outcome: genuine step failures and
--no-input missing input become outcome=error with a self-diagnosing
reason, while interactive abandonment and project cancellation become
outcome=aborted and drop out of the error view.
Unify the exit code and telemetry through setupTerminalOutcome() so they
can never diverge: aborts now exit 0 (matching the entry-menu Exit,
project cancel, and a confirmed Ctrl+C), while failures and automation
errors still exit 1.
* fix(cli): treat non-TTY setup missing-input as an error, not an abort
setupTerminalOutcome classified `missing-input` by `args.inputMode`, but
`auto` only means "interactive if a TTY is attached". A piped/CI `ktx
setup` without `--no-input` and without `--yes` is still `auto`, yet the
project and agents steps return `missing-input` there without ever
prompting (e.g. "pass --yes to create a project outside an interactive
terminal"). Classifying that as `aborted` made a broken automation run
exit 0 — a silent failure.
Key the classification off actual interactivity instead: input enabled
AND `io.stdout.isTTY === true`. Non-interactive missing-input now exits
1 with a `KtxSetupMissingInput` reason; only a genuine interactive abort
exits 0. Adds a non-TTY regression test and fixes the abandonment test
to use a real TTY.
2026-06-09 12:53:15 +02:00
|
|
|
it('emits aborted (not error) when setup exits non-zero after the user abandons the wizard', async () => {
|
|
|
|
|
const io = makeIo(true);
|
|
|
|
|
const deps: KtxCliDeps = {
|
|
|
|
|
setup: async () => {
|
|
|
|
|
// What runKtxSetup does when an interactive step is abandoned: it
|
|
|
|
|
// annotates the span and returns a non-zero exit code without throwing.
|
|
|
|
|
const { annotateCommandOutcome } = await import('../src/telemetry/index.js');
|
|
|
|
|
annotateCommandOutcome({ outcome: 'aborted' });
|
|
|
|
|
return 1;
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
runCommanderKtxCli(['--project-dir', tempDir, 'setup'], io.io, deps, info, { runInit: async () => 0 }),
|
|
|
|
|
).resolves.toBe(1);
|
|
|
|
|
|
|
|
|
|
expect(io.stderr()).toContain('"event":"command"');
|
|
|
|
|
expect(io.stderr()).toContain('"commandPath":["ktx","setup"]');
|
|
|
|
|
// The non-zero exit alone would have produced a blank "error"; the
|
|
|
|
|
// annotation reclassifies it as a user abort that leaves the error view.
|
|
|
|
|
expect(io.stderr()).toContain('"outcome":"aborted"');
|
|
|
|
|
expect(io.stderr()).not.toContain('"outcome":"error"');
|
|
|
|
|
expect(reportExceptionMock).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('emits a self-diagnosing error reason when a setup step genuinely fails without throwing', async () => {
|
|
|
|
|
const io = makeIo(true);
|
|
|
|
|
const deps: KtxCliDeps = {
|
|
|
|
|
setup: async () => {
|
|
|
|
|
const { annotateCommandOutcome } = await import('../src/telemetry/index.js');
|
|
|
|
|
annotateCommandOutcome({
|
|
|
|
|
outcome: 'error',
|
|
|
|
|
errorClass: 'KtxSetupStepFailed',
|
|
|
|
|
errorDetail: 'runtime setup step failed',
|
|
|
|
|
});
|
|
|
|
|
return 1;
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
runCommanderKtxCli(['--project-dir', tempDir, 'setup'], io.io, deps, info, { runInit: async () => 0 }),
|
|
|
|
|
).resolves.toBe(1);
|
|
|
|
|
|
|
|
|
|
expect(io.stderr()).toContain('"outcome":"error"');
|
|
|
|
|
expect(io.stderr()).toContain('"errorClass":"KtxSetupStepFailed"');
|
|
|
|
|
expect(io.stderr()).toContain('"errorDetail":"runtime setup step failed"');
|
|
|
|
|
// Non-throwing failures have no exception twin; the command event carries
|
|
|
|
|
// the reason on its own.
|
|
|
|
|
expect(reportExceptionMock).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-22 18:18:47 +02:00
|
|
|
it('does not import or emit telemetry for help, version, bare non-TTY, or unknown top-level command', async () => {
|
|
|
|
|
const helpIo = makeIo(true);
|
|
|
|
|
await expect(runCommanderKtxCli(['--help'], helpIo.io, {}, info, { runInit: async () => 0 })).resolves.toBe(0);
|
|
|
|
|
expect(helpIo.stderr()).not.toContain('[telemetry]');
|
|
|
|
|
|
|
|
|
|
const versionIo = makeIo(true);
|
|
|
|
|
await expect(runCommanderKtxCli(['--version'], versionIo.io, {}, info, { runInit: async () => 0 })).resolves.toBe(0);
|
|
|
|
|
expect(versionIo.stderr()).not.toContain('[telemetry]');
|
|
|
|
|
|
|
|
|
|
const bareIo = makeIo(false);
|
|
|
|
|
await expect(runCommanderKtxCli([], bareIo.io, {}, info, { runInit: async () => 0 })).resolves.toBe(0);
|
|
|
|
|
expect(bareIo.stderr()).not.toContain('[telemetry]');
|
|
|
|
|
|
|
|
|
|
const unknownIo = makeIo(true);
|
|
|
|
|
await expect(runCommanderKtxCli(['unknown'], unknownIo.io, {}, info, { runInit: async () => 0 })).resolves.toBe(1);
|
|
|
|
|
expect(unknownIo.stderr()).not.toContain('[telemetry]');
|
|
|
|
|
});
|
2026-06-05 19:36:21 +02:00
|
|
|
|
|
|
|
|
it('reports genuine top-level command catches as handled exceptions', async () => {
|
|
|
|
|
const io = makeIo(true);
|
|
|
|
|
const deps: KtxCliDeps = {
|
|
|
|
|
doctor: async () => {
|
|
|
|
|
throw new Error('status failed');
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
runCommanderKtxCli(
|
|
|
|
|
['--project-dir', tempDir, 'status', '--json'],
|
|
|
|
|
io.io,
|
|
|
|
|
deps,
|
|
|
|
|
info,
|
|
|
|
|
{ runInit: async () => 0 },
|
|
|
|
|
),
|
|
|
|
|
).resolves.toBe(1);
|
|
|
|
|
|
|
|
|
|
expect(reportExceptionMock).toHaveBeenCalledWith(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
context: expect.objectContaining({ source: 'ktx status', handled: true, fatal: false }),
|
|
|
|
|
projectDir: tempDir,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-06-09 12:22:56 +02:00
|
|
|
|
|
|
|
|
it('prints the Slack hint for unexpected command errors on TTY stderr only', async () => {
|
|
|
|
|
const ttyIo = makeIo(true, true);
|
|
|
|
|
const deps: KtxCliDeps = {
|
|
|
|
|
doctor: async () => {
|
|
|
|
|
throw new Error('status failed');
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
runCommanderKtxCli(
|
|
|
|
|
['--project-dir', tempDir, 'status', '--json'],
|
|
|
|
|
ttyIo.io,
|
|
|
|
|
deps,
|
|
|
|
|
info,
|
|
|
|
|
{ runInit: async () => 0 },
|
|
|
|
|
),
|
|
|
|
|
).resolves.toBe(1);
|
|
|
|
|
|
|
|
|
|
expect(ttyIo.stderr()).toContain('status failed');
|
|
|
|
|
expect(ttyIo.stderr()).toContain('Stuck? The ktx community can help');
|
|
|
|
|
expect(ttyIo.stderr()).toContain('https://ktx.sh/slack');
|
|
|
|
|
|
|
|
|
|
const pipeIo = makeIo(true, false);
|
|
|
|
|
await expect(
|
|
|
|
|
runCommanderKtxCli(
|
|
|
|
|
['--project-dir', tempDir, 'status', '--json'],
|
|
|
|
|
pipeIo.io,
|
|
|
|
|
deps,
|
|
|
|
|
info,
|
|
|
|
|
{ runInit: async () => 0 },
|
|
|
|
|
),
|
|
|
|
|
).resolves.toBe(1);
|
|
|
|
|
|
|
|
|
|
expect(pipeIo.stderr()).toContain('status failed');
|
|
|
|
|
expect(pipeIo.stderr()).not.toContain('https://ktx.sh/slack');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('does not print the Slack hint for Commander usage errors', async () => {
|
|
|
|
|
const io = makeIo(true, true);
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
runCommanderKtxCli(['--not-a-real-option'], io.io, {}, info, { runInit: async () => 0 }),
|
|
|
|
|
).resolves.toBe(1);
|
|
|
|
|
|
|
|
|
|
expect(io.stderr()).toContain("unknown option '--not-a-real-option'");
|
|
|
|
|
expect(io.stderr()).not.toContain('Stuck? The ktx community can help');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('prints the Slack hint for bare interactive setup failures on TTY stderr', async () => {
|
|
|
|
|
const originalCwd = process.cwd();
|
|
|
|
|
const noProjectDir = await mkdtemp(join(tmpdir(), 'ktx-cli-bare-'));
|
|
|
|
|
const io = makeIo(true, true);
|
|
|
|
|
const deps: KtxCliDeps = {
|
|
|
|
|
setup: async () => {
|
|
|
|
|
throw new Error('setup failed');
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
process.chdir(noProjectDir);
|
|
|
|
|
await expect(runCommanderKtxCli([], io.io, deps, info, { runInit: async () => 0 })).resolves.toBe(1);
|
|
|
|
|
} finally {
|
|
|
|
|
process.chdir(originalCwd);
|
|
|
|
|
await rm(noProjectDir, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expect(io.stderr()).toContain('setup failed');
|
|
|
|
|
expect(io.stderr()).toContain('Stuck? The ktx community can help');
|
|
|
|
|
expect(io.stderr()).toContain('https://ktx.sh/slack');
|
|
|
|
|
});
|
2026-05-22 18:18:47 +02:00
|
|
|
});
|