ktx/packages/cli/test/connection-recovery.test.ts
Andrey Avtomonov ce1516b357
feat(cli): consistent connection setup recovery and build-time gate (#257)
* feat(cli): block context build when a required connection fails its live test

A context build can take several minutes, so a connection that is
unreachable or misconfigured should stop the build up front instead of
failing partway through. Before the build starts, run a live connection
test for every primary- and context-source connection the build depends
on.

Each test's output is captured in a discarded buffer so raw error text
(and database paths) never reach the user; failures are surfaced only by
connection id and connector type, with a pointer to `ktx connection test
<id>` for the underlying error.

- Interactive setup lets the user fix the connection and retry without
  restarting, re-resolving targets so an added/removed/reconfigured
  connection is honored.
- `--no-input` exits non-zero and writes a failed context state with a
  failureReason, so scripts stop early and setup never reads as ready.

Extract the buffered command IO helper out of setup-databases into
src/io/buffered-command-io.ts so both call sites share one implementation.

* feat(cli): use recovery primitive for database setup

* feat(cli): use recovery primitive for source setup

* docs: document setup connection recovery

* fix(cli): close database recovery gaps

* fix(cli): target failing project in gate hint and preserve missing-input

Address two review findings on the connection-recovery work:

- The connection-gate failure hint emitted `ktx connection test <id>` with no
  --project-dir, so a setup run started with `--project-dir ./analytics` pointed
  users at cwd/KTX_PROJECT_DIR instead of the project that just failed. Emit the
  resolved project dir, matching the contextBuildCommands convention.

- The non-interactive database configure path returned `cancelled`, which the
  recovery primitive collapses to `failed`. Sibling paths still report
  `missing-input` for absent flags, so incomplete-flag runs were
  indistinguishable from real connection failures. The database wrapper now
  tracks the configure missing-input signal and restores the `missing-input`
  step status; the shared primitive keeps its four outcomes.
2026-06-03 11:08:46 +00:00

171 lines
5.3 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import {
runConnectionSetupWithRecovery,
type ConfigureResult,
type RecoveryAction,
type ValidateResult,
} from '../src/connection-recovery.js';
function input(overrides: {
interactive?: boolean;
allowSkip?: boolean;
configure?: () => Promise<ConfigureResult>;
validate?: () => Promise<ValidateResult>;
selectValues?: string[];
extraActions?: RecoveryAction[];
}) {
const selectValues = [...(overrides.selectValues ?? [])];
const rollback = vi.fn(async () => {});
const select = vi.fn(async () => selectValues.shift() ?? 'back');
const validate = overrides.validate ?? vi.fn(async () => ({ status: 'ok' as const }));
return {
rollback,
select,
validate,
run: () =>
runConnectionSetupWithRecovery({
label: 'warehouse',
interactive: overrides.interactive ?? true,
allowSkip: overrides.allowSkip ?? true,
io: {
stdout: { write: vi.fn() },
stderr: { write: vi.fn() },
},
prompts: { select },
snapshot: vi.fn(async () => rollback),
configure: overrides.configure ?? vi.fn(async () => 'configured' as const),
validate,
}),
};
}
describe('runConnectionSetupWithRecovery', () => {
it('returns ready without opening the menu when first validation passes', async () => {
const setup = input({});
await expect(setup.run()).resolves.toBe('ready');
expect(setup.select).not.toHaveBeenCalled();
expect(setup.rollback).not.toHaveBeenCalled();
});
it('fails fast without prompting or rollback when noninteractive validation fails', async () => {
const setup = input({
interactive: false,
validate: vi.fn(async () => ({ status: 'failed' as const })),
});
await expect(setup.run()).resolves.toBe('failed');
expect(setup.select).not.toHaveBeenCalled();
expect(setup.rollback).not.toHaveBeenCalled();
});
it('retries the same config after Retry and returns ready', async () => {
let calls = 0;
const setup = input({
selectValues: ['retry'],
validate: vi.fn(async () => {
calls += 1;
return calls === 1 ? { status: 'failed' as const } : { status: 'ok' as const };
}),
});
await expect(setup.run()).resolves.toBe('ready');
expect(setup.validate).toHaveBeenCalledTimes(2);
expect(setup.rollback).not.toHaveBeenCalled();
});
it('re-enters config and validates the new attempt', async () => {
let calls = 0;
const configure = vi.fn(async () => 'configured' as const);
const setup = input({
configure,
selectValues: ['re-enter'],
validate: vi.fn(async () => {
calls += 1;
return calls === 1 ? { status: 'failed' as const } : { status: 'ok' as const };
}),
});
await expect(setup.run()).resolves.toBe('ready');
expect(configure).toHaveBeenCalledTimes(2);
expect(setup.validate).toHaveBeenCalledTimes(2);
expect(setup.rollback).not.toHaveBeenCalled();
});
it('rolls back once and returns skip when Skip is selected', async () => {
const setup = input({
selectValues: ['skip'],
validate: vi.fn(async () => ({ status: 'failed' as const })),
});
await expect(setup.run()).resolves.toBe('skip');
expect(setup.rollback).toHaveBeenCalledTimes(1);
});
it('omits Skip when allowSkip is false and rolls back on Back', async () => {
const setup = input({
allowSkip: false,
selectValues: ['back'],
validate: vi.fn(async () => ({ status: 'failed' as const })),
});
await expect(setup.run()).resolves.toBe('back');
expect(setup.select).toHaveBeenCalledWith({
message: 'Connection setup failed for warehouse',
options: [
{ value: 'retry', label: 'Retry connection test' },
{ value: 're-enter', label: 'Re-enter connection details' },
{ value: 'back', label: 'Back' },
],
});
expect(setup.rollback).toHaveBeenCalledTimes(1);
});
it('runs an extra action and then revalidates', async () => {
const action = vi.fn(async () => {});
let calls = 0;
const setup = input({
selectValues: ['disable-query-history'],
validate: vi.fn(async () => {
calls += 1;
return calls === 1
? {
status: 'failed' as const,
extraActions: [
{ value: 'disable-query-history', label: 'Disable query history and retry', run: action },
],
}
: { status: 'ok' as const };
}),
});
await expect(setup.run()).resolves.toBe('ready');
expect(action).toHaveBeenCalledTimes(1);
expect(setup.validate).toHaveBeenCalledTimes(2);
});
it('rolls back when re-enter returns back or cancelled', async () => {
const backSetup = input({
selectValues: ['re-enter'],
configure: vi.fn(async () => 'back' as const),
validate: vi.fn(async () => ({ status: 'failed' as const })),
});
await expect(backSetup.run()).resolves.toBe('back');
expect(backSetup.rollback).toHaveBeenCalledTimes(1);
const cancelledSetup = input({
selectValues: ['re-enter'],
configure: vi.fn(async () => 'cancelled' as const),
validate: vi.fn(async () => ({ status: 'failed' as const })),
});
await expect(cancelledSetup.run()).resolves.toBe('failed');
expect(cancelledSetup.rollback).toHaveBeenCalledTimes(1);
});
});