From aa0e2ad15bebfa0c6f50316244de7e267a358cf5 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Thu, 14 May 2026 15:58:04 +0200 Subject: [PATCH] feat(cli): add `ktx connection test --all` summary list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests every configured connection in parallel and renders a single Clack-style list (◇/│/◆/└, green ✓ / red ✗) consistent with sl list, with per-row detail and a passed/failed footer. Exits non-zero if any connection fails. Single-id `ktx connection test` output is preserved. --- .../cli/src/commands/connection-commands.ts | 18 +- packages/cli/src/connection.test.ts | 70 +++++ packages/cli/src/connection.ts | 244 ++++++++++++------ packages/cli/src/index.test.ts | 2 +- packages/cli/src/io/symbols.ts | 8 + packages/cli/src/print-command-tree.test.ts | 2 +- 6 files changed, 263 insertions(+), 81 deletions(-) diff --git a/packages/cli/src/commands/connection-commands.ts b/packages/cli/src/commands/connection-commands.ts index d814ffe9..181e8905 100644 --- a/packages/cli/src/commands/connection-commands.ts +++ b/packages/cli/src/commands/connection-commands.ts @@ -33,12 +33,24 @@ export function registerConnectionCommands(program: Command, context: KtxCliComm connection .command('test') .description('Test a configured connection') - .argument('', 'KTX connection id') - .action(async (connectionId: string, _options: unknown, command) => { + .argument('[connectionId]', 'KTX connection id (omit when --all is set)') + .option('--all', 'Test every configured connection and print a summary list') + .action(async (connectionId: string | undefined, options: { all?: boolean }, command) => { + const all = options.all === true; + if (all && connectionId !== undefined) { + command.error('error: --all cannot be combined with a connection id argument'); + } + if (!all && connectionId === undefined) { + command.error('error: missing required argument (or pass --all)'); + } + if (all) { + await runConnectionArgs(context, { command: 'test-all', projectDir: resolveCommandProjectDir(command) }); + return; + } await runConnectionArgs(context, { command: 'test', projectDir: resolveCommandProjectDir(command), - connectionId, + connectionId: connectionId as string, }); }); } diff --git a/packages/cli/src/connection.test.ts b/packages/cli/src/connection.test.ts index 821be03e..920512f5 100644 --- a/packages/cli/src/connection.test.ts +++ b/packages/cli/src/connection.test.ts @@ -7,6 +7,10 @@ import type { KtxConnectionDriver, KtxScanConnector } from '@ktx/context/scan'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { runKtxConnection } from './connection.js'; +function stripAnsi(s: string): string { + return s.replace(/\[[0-9;]*m/g, ''); +} + function makeIo() { let stdout = ''; let stderr = ''; @@ -416,6 +420,72 @@ describe('runKtxConnection', () => { expect(io.stdout()).toContain('Driver: metricflow'); }); + it('--all: prints a single coherent list with one row per connection', async () => { + const projectDir = join(tempDir, 'project'); + await initKtxProject({ projectDir, projectName: 'warehouse' }); + await writeConnections(projectDir, { + warehouse: { driver: 'sqlite' }, + docs: { driver: 'notion', auth_token: 'secret_token', crawl_mode: 'all_accessible' }, // pragma: allowlist secret + }); + const { connector } = nativeConnector('sqlite'); + const createScanConnector = vi.fn(async () => connector); + const createNotionClient = vi.fn(async (): Promise> => ({ + retrieveBotUser: vi.fn(async () => ({ id: 'bot-1', name: 'Docs Bot' })), + })); + const io = makeIo(); + + await expect( + runKtxConnection({ command: 'test-all', projectDir }, io.io, { createScanConnector, createNotionClient }), + ).resolves.toBe(0); + + const out = stripAnsi(io.stdout()); + expect(out).toContain('connection test --all'); + expect(out).toMatch(/docs\s+notion\s+✓ ok\s+Bot: Docs Bot/); + expect(out).toMatch(/warehouse\s+sqlite\s+✓ ok\s+Status: ok/); + expect(out).toContain('2 tested'); + expect(out).toContain('2 passed'); + expect(out).not.toContain('failed'); + expect(io.stderr()).toBe(''); + }); + + it('--all: marks failing connections, keeps passing ones, and returns non-zero', async () => { + const projectDir = join(tempDir, 'project'); + await initKtxProject({ projectDir, projectName: 'warehouse' }); + await writeConnections(projectDir, { + warehouse: { driver: 'sqlite' }, + broken: { driver: 'sqlite' }, + }); + const okConnector = nativeConnector('sqlite').connector; + const failConnector = nativeConnector('sqlite', { success: false, error: 'database file is unreadable' }).connector; + const createScanConnector = vi.fn(async (_p, connectionId: string) => + connectionId === 'broken' ? failConnector : okConnector, + ); + const io = makeIo(); + + await expect( + runKtxConnection({ command: 'test-all', projectDir }, io.io, { createScanConnector }), + ).resolves.toBe(1); + + const out = stripAnsi(io.stdout()); + expect(out).toMatch(/broken\s+sqlite\s+✗ failed\s+database file is unreadable/); + expect(out).toMatch(/warehouse\s+sqlite\s+✓ ok\s+Status: ok/); + expect(out).toContain('1 passed'); + expect(out).toContain('1 failed'); + expect(io.stderr()).toBe(''); + }); + + it('--all: shows an empty-state message when no connections are configured', async () => { + const projectDir = join(tempDir, 'project'); + await initKtxProject({ projectDir, projectName: 'warehouse' }); + const io = makeIo(); + + await expect(runKtxConnection({ command: 'test-all', projectDir }, io.io)).resolves.toBe(0); + + const out = stripAnsi(io.stdout()); + expect(out).toContain('connection test --all'); + expect(out).toContain('No connections configured. Run `ktx setup` to add one.'); + }); + it('rejects unknown drivers with a helpful error', async () => { const projectDir = join(tempDir, 'project'); await initKtxProject({ projectDir, projectName: 'warehouse' }); diff --git a/packages/cli/src/connection.ts b/packages/cli/src/connection.ts index 6ec564f6..c65fc3c3 100644 --- a/packages/cli/src/connection.ts +++ b/packages/cli/src/connection.ts @@ -15,6 +15,7 @@ import { resolveKtxConfigReference } from '@ktx/context/core'; import { type KtxLocalProject, loadKtxProject } from '@ktx/context/project'; import type { KtxScanConnector } from '@ktx/context/scan'; import type { KtxCliIo } from './index.js'; +import { bold, dim, green, red, SYMBOLS } from './io/symbols.js'; import { createKtxCliScanConnector } from './local-scan-connectors.js'; import { profileMark } from './startup-profile.js'; @@ -22,7 +23,8 @@ profileMark('module:connection'); export type KtxConnectionArgs = | { command: 'list'; projectDir: string } - | { command: 'test'; projectDir: string; connectionId: string }; + | { command: 'test'; projectDir: string; connectionId: string } + | { command: 'test-all'; projectDir: string }; type MetabaseTestPort = Pick; type LookerTestPort = Pick; @@ -218,6 +220,164 @@ async function testGitRepoConnection( return { repoUrl }; } +interface DriverTestOutcome { + driver: string; + detailKey: string; + detailValue: string; +} + +async function testConnectionByDriver( + project: KtxLocalProject, + connectionId: string, + deps: KtxConnectionDeps, +): Promise { + const driver = normalizedConnectionDriver(project, connectionId); + if (!driver) { + throw new Error(`Connection "${connectionId}" has no \`driver\` field in ktx.yaml`); + } + + if (driver === 'metabase') { + const result = await testMetabaseConnection( + project, + connectionId, + deps.createMetabaseClient ?? createDefaultMetabaseClient, + ); + return { driver, detailKey: 'Databases', detailValue: String(result.databaseCount) }; + } + + if (driver === 'looker') { + const result = await testLookerConnection( + project, + connectionId, + deps.createLookerClient ?? createDefaultLookerClient, + ); + return { driver, detailKey: 'User', detailValue: result.user }; + } + + if (driver === 'notion') { + const result = await testNotionConnection( + project, + connectionId, + deps.createNotionClient ?? createDefaultNotionClient, + ); + return { driver, detailKey: 'Bot', detailValue: result.bot }; + } + + if (driver === 'dbt' || driver === 'metricflow' || driver === 'lookml') { + const result = await testGitRepoConnection( + project, + connectionId, + driver, + deps.testRepoConnection ?? testRepoConnection, + ); + return { driver, detailKey: 'Repo', detailValue: result.repoUrl }; + } + + if ( + driver === 'sqlite' || + driver === 'sqlite3' || + driver === 'postgres' || + driver === 'postgresql' || + driver === 'mysql' || + driver === 'clickhouse' || + driver === 'sqlserver' || + driver === 'bigquery' || + driver === 'snowflake' + ) { + const result = await testNativeConnection( + project, + connectionId, + deps.createScanConnector ?? createKtxCliScanConnector, + ); + return { driver: result.driver, detailKey: 'Status', detailValue: 'ok' }; + } + + throw new Error( + `Connection "${connectionId}" uses driver "${driver}", which has no test implementation in ktx. Supported: ${SUPPORTED_TEST_DRIVERS.join(', ')}.`, + ); +} + +interface ConnectionTestRow { + connectionId: string; + driver: string; + ok: boolean; + detail: string; +} + +function visualWidth(text: string): number { + // styleText wraps content in ANSI escape sequences; strip them before measuring. + return text.replace(/\[[0-9;]*m/g, '').length; +} + +function padVisual(text: string, width: number): string { + const pad = width - visualWidth(text); + return pad > 0 ? `${text}${' '.repeat(pad)}` : text; +} + +function renderTestAll(io: KtxCliIo, rows: ReadonlyArray): void { + io.stdout.write(`${SYMBOLS.barStart} connection test --all\n`); + io.stdout.write(`${SYMBOLS.bar}\n`); + + if (rows.length === 0) { + io.stdout.write(`${SYMBOLS.barEnd} No connections configured. Run \`ktx setup\` to add one.\n`); + return; + } + + const okLabel = green('✓ ok'); + const failLabel = red('✗ failed'); + const idWidth = Math.max(...rows.map((r) => r.connectionId.length)); + const driverWidth = Math.max(...rows.map((r) => r.driver.length)); + const statusWidth = Math.max(visualWidth(okLabel), visualWidth(failLabel)); + + for (const row of rows) { + const id = bold(padVisual(row.connectionId, idWidth)); + const driver = dim(padVisual(row.driver, driverWidth)); + const status = padVisual(row.ok ? okLabel : failLabel, statusWidth); + const detail = dim(row.detail); + io.stdout.write(`${SYMBOLS.bar} ${SYMBOLS.item} ${id} ${driver} ${status} ${detail}\n`); + } + + const failed = rows.filter((r) => !r.ok).length; + const passed = rows.length - failed; + io.stdout.write(`${SYMBOLS.bar}\n`); + const summary = + failed === 0 + ? `${rows.length} tested ${dim(SYMBOLS.middot)} ${green(`${passed} passed`)}` + : `${rows.length} tested ${dim(SYMBOLS.middot)} ${green(`${passed} passed`)} ${dim(SYMBOLS.middot)} ${red(`${failed} failed`)}`; + io.stdout.write(`${SYMBOLS.barEnd} ${summary}\n`); +} + +async function runTestAll( + project: KtxLocalProject, + io: KtxCliIo, + deps: KtxConnectionDeps, +): Promise { + const entries = Object.entries(project.config.connections).sort(([a], [b]) => a.localeCompare(b)); + const rows = await Promise.all( + entries.map(async ([connectionId, connection]): Promise => { + const declaredDriver = String(connection.driver ?? '').trim().toLowerCase() || 'unknown'; + try { + const outcome = await testConnectionByDriver(project, connectionId, deps); + return { + connectionId, + driver: outcome.driver || declaredDriver, + ok: true, + detail: `${outcome.detailKey}: ${outcome.detailValue}`, + }; + } catch (error) { + return { + connectionId, + driver: declaredDriver, + ok: false, + detail: error instanceof Error ? error.message : String(error), + }; + } + }), + ); + renderTestAll(io, rows); + return rows.some((row) => !row.ok) ? 1 : 0; +} + export async function runKtxConnection( args: KtxConnectionArgs, io: KtxCliIo = process, @@ -243,83 +403,15 @@ export async function runKtxConnection( return 0; } - const driver = normalizedConnectionDriver(project, args.connectionId); - if (!driver) { - throw new Error(`Connection "${args.connectionId}" has no \`driver\` field in ktx.yaml`); + if (args.command === 'test-all') { + return await runTestAll(project, io, deps); } - const writePassed = (detailKey: string, detailValue: string): void => { - io.stdout.write(`Connection test passed: ${args.connectionId}\n`); - io.stdout.write(`Driver: ${driver}\n`); - io.stdout.write(`${detailKey}: ${detailValue}\n`); - }; - - if (driver === 'metabase') { - const result = await testMetabaseConnection( - project, - args.connectionId, - deps.createMetabaseClient ?? createDefaultMetabaseClient, - ); - writePassed('Databases', String(result.databaseCount)); - return 0; - } - - if (driver === 'looker') { - const result = await testLookerConnection( - project, - args.connectionId, - deps.createLookerClient ?? createDefaultLookerClient, - ); - writePassed('User', result.user); - return 0; - } - - if (driver === 'notion') { - const result = await testNotionConnection( - project, - args.connectionId, - deps.createNotionClient ?? createDefaultNotionClient, - ); - writePassed('Bot', result.bot); - return 0; - } - - if (driver === 'dbt' || driver === 'metricflow' || driver === 'lookml') { - const result = await testGitRepoConnection( - project, - args.connectionId, - driver, - deps.testRepoConnection ?? testRepoConnection, - ); - writePassed('Repo', result.repoUrl); - return 0; - } - - if ( - driver === 'sqlite' || - driver === 'sqlite3' || - driver === 'postgres' || - driver === 'postgresql' || - driver === 'mysql' || - driver === 'clickhouse' || - driver === 'sqlserver' || - driver === 'bigquery' || - driver === 'snowflake' - ) { - const result = await testNativeConnection( - project, - args.connectionId, - deps.createScanConnector ?? createKtxCliScanConnector, - ); - io.stdout.write(`Connection test passed: ${args.connectionId}\n`); - io.stdout.write(`Driver: ${result.driver}\n`); - io.stdout.write('Status: ok\n'); - return 0; - } - - throw new Error( - `Connection "${args.connectionId}" uses driver "${driver}", which has no test implementation in ktx. Supported: ${SUPPORTED_TEST_DRIVERS.join(', ')}.`, - ); + const { driver, detailKey, detailValue } = await testConnectionByDriver(project, args.connectionId, deps); + io.stdout.write(`Connection test passed: ${args.connectionId}\n`); + io.stdout.write(`Driver: ${driver}\n`); + io.stdout.write(`${detailKey}: ${detailValue}\n`); + return 0; } catch (error) { io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); return 1; diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index fff5fb09..1e2da422 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -1517,7 +1517,7 @@ describe('runKtxCli', () => { expect(helpIo.stdout()).toContain('Usage: ktx connection'); expect(helpIo.stdout()).toContain('list'); - expect(helpIo.stdout()).toContain('test '); + expect(helpIo.stdout()).toContain('test [options] [connectionId]'); for (const removed of ['add', 'remove', 'map', 'mapping', 'metabase', 'notion']) { expect(helpIo.stdout()).not.toMatch(new RegExp(`\\b${removed}\\b`)); } diff --git a/packages/cli/src/io/symbols.ts b/packages/cli/src/io/symbols.ts index 8fa88aa4..f80c2b79 100644 --- a/packages/cli/src/io/symbols.ts +++ b/packages/cli/src/io/symbols.ts @@ -35,3 +35,11 @@ export function bold(text: string): string { export function gray(text: string): string { return styleText('gray', text); } + +export function green(text: string): string { + return styleText('green', text); +} + +export function red(text: string): string { + return styleText('red', text); +} diff --git a/packages/cli/src/print-command-tree.test.ts b/packages/cli/src/print-command-tree.test.ts index 86ef451e..9bbfa0a8 100644 --- a/packages/cli/src/print-command-tree.test.ts +++ b/packages/cli/src/print-command-tree.test.ts @@ -16,7 +16,7 @@ describe('renderKtxCommandTree', () => { expect(topLevel).toContain(expected); } - expect(output).toContain('│ └── test '); + expect(output).toContain('│ └── test [connectionId]'); expect(output).not.toContain('│ ├── add'); expect(output).not.toContain('│ ├── remove'); expect(output).not.toContain('│ ├── map');