mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-19 08:28:06 +02:00
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.
This commit is contained in:
parent
66517fc320
commit
470802e58e
6 changed files with 493 additions and 14 deletions
|
|
@ -7,9 +7,16 @@ import { writeKtxSetupState } from '../src/context/project/setup-config.js';
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { localFakeBundleReport, persistLocalBundleReport } from './ingest.test-utils.js';
|
||||
import { beginCommandSpan, completeCommandSpan, resetCommandSpan } from '../src/telemetry/command-hook.js';
|
||||
import { contextBuildCommands, writeKtxSetupContextState } from '../src/setup-context.js';
|
||||
import { runDemoTour } from '../src/setup-demo-tour.js';
|
||||
import { formatKtxSetupCompletionSummary, formatKtxSetupStatus, readKtxSetupStatus, runKtxSetup } from '../src/setup.js';
|
||||
import {
|
||||
formatKtxSetupCompletionSummary,
|
||||
formatKtxSetupStatus,
|
||||
readKtxSetupStatus,
|
||||
runKtxSetup,
|
||||
setupTerminalOutcome,
|
||||
} from '../src/setup.js';
|
||||
|
||||
vi.mock('../src/setup-demo-tour.js', () => ({
|
||||
runDemoTour: vi.fn(async () => 0),
|
||||
|
|
@ -17,13 +24,13 @@ vi.mock('../src/setup-demo-tour.js', () => ({
|
|||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function makeIo() {
|
||||
function makeIo(stdoutIsTTY = false) {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdout: {
|
||||
isTTY: false,
|
||||
isTTY: stdoutIsTTY,
|
||||
write: (chunk: string) => {
|
||||
stdout += chunk;
|
||||
},
|
||||
|
|
@ -2667,3 +2674,216 @@ describe('setup status', () => {
|
|||
expect(embeddings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupTerminalOutcome', () => {
|
||||
it('exits non-zero and reports a self-diagnosing error for a genuine step failure', () => {
|
||||
expect(setupTerminalOutcome({ status: 'failed', step: 'runtime', interactive: true })).toEqual({
|
||||
exitCode: 1,
|
||||
annotation: {
|
||||
outcome: 'error',
|
||||
errorClass: 'KtxSetupStepFailed',
|
||||
errorDetail: 'runtime setup step failed',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('reuses a step-provided errorDetail for failures', () => {
|
||||
expect(
|
||||
setupTerminalOutcome({
|
||||
status: 'failed',
|
||||
step: 'sources',
|
||||
interactive: true,
|
||||
errorDetail: 'metabase source ingest failed',
|
||||
}),
|
||||
).toEqual({
|
||||
exitCode: 1,
|
||||
annotation: {
|
||||
outcome: 'error',
|
||||
errorClass: 'KtxSetupStepFailed',
|
||||
errorDetail: 'metabase source ingest failed',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('exits non-zero for missing input in a non-interactive run (an automation error)', () => {
|
||||
// Both `--no-input` and a piped/CI run without a TTY are non-interactive.
|
||||
expect(setupTerminalOutcome({ status: 'missing-input', step: 'models', interactive: false })).toEqual({
|
||||
exitCode: 1,
|
||||
annotation: {
|
||||
outcome: 'error',
|
||||
errorClass: 'KtxSetupMissingInput',
|
||||
errorDetail: 'models setup step requires input not provided in a non-interactive run',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('exits zero and aborts (not errors) when the user leaves an interactive prompt', () => {
|
||||
expect(setupTerminalOutcome({ status: 'missing-input', step: 'models', interactive: true })).toEqual({
|
||||
exitCode: 0,
|
||||
annotation: { outcome: 'aborted' },
|
||||
});
|
||||
});
|
||||
|
||||
it('exits zero and aborts on a project cancellation', () => {
|
||||
expect(setupTerminalOutcome({ status: 'cancelled', step: 'project', interactive: true })).toEqual({
|
||||
exitCode: 0,
|
||||
annotation: { outcome: 'aborted' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('runKtxSetup command-span annotation', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-span-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetCommandSpan();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('annotates the active command span with the step-failure reason on a non-throwing exit', async () => {
|
||||
resetCommandSpan();
|
||||
beginCommandSpan({
|
||||
commandPath: ['ktx', 'setup'],
|
||||
flagsPresent: {},
|
||||
hasProject: false,
|
||||
attachProjectGroup: true,
|
||||
startedAt: 0,
|
||||
});
|
||||
const testIo = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxSetup(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: true,
|
||||
skipEmbeddings: true,
|
||||
databaseSchemas: [],
|
||||
skipDatabases: true,
|
||||
skipSources: true,
|
||||
},
|
||||
testIo.io,
|
||||
{
|
||||
model: async () => ({ status: 'skipped', projectDir: tempDir }),
|
||||
embeddings: async () => ({ status: 'skipped', projectDir: tempDir }),
|
||||
databases: async () => ({ status: 'skipped', projectDir: tempDir }),
|
||||
sources: async () => ({ status: 'skipped', projectDir: tempDir }),
|
||||
runtime: async () => ({
|
||||
status: 'failed',
|
||||
projectDir: tempDir,
|
||||
requirements: { features: ['core'], requirements: [] },
|
||||
}),
|
||||
},
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
// The wrapper would derive a blank 'error' from the non-zero exit; the
|
||||
// annotation supplies the actionable reason instead.
|
||||
const completed = completeCommandSpan({ completedAt: 1, outcome: 'error' });
|
||||
expect(completed?.outcome).toBe('error');
|
||||
expect(completed?.errorClass).toBe('KtxSetupStepFailed');
|
||||
expect(completed?.errorDetail).toBe('runtime setup step failed');
|
||||
});
|
||||
|
||||
it('exits zero and marks the span aborted when the user abandons an interactive step', async () => {
|
||||
resetCommandSpan();
|
||||
beginCommandSpan({
|
||||
commandPath: ['ktx', 'setup'],
|
||||
flagsPresent: {},
|
||||
hasProject: true,
|
||||
attachProjectGroup: true,
|
||||
startedAt: 0,
|
||||
});
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
|
||||
// A real interactive session: input enabled AND a TTY attached.
|
||||
const testIo = makeIo(true);
|
||||
|
||||
await expect(
|
||||
runKtxSetup(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
agentScope: 'project',
|
||||
skipAgents: true,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: true,
|
||||
skipEmbeddings: false,
|
||||
databaseSchemas: [],
|
||||
skipDatabases: true,
|
||||
skipSources: true,
|
||||
},
|
||||
testIo.io,
|
||||
{
|
||||
// The user backs out of the embeddings prompt (interactive
|
||||
// missing-input). That is an abort, not a failure.
|
||||
embeddings: async () => ({ status: 'missing-input', projectDir: tempDir }),
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const completed = completeCommandSpan({ completedAt: 1, outcome: 'error' });
|
||||
expect(completed?.outcome).toBe('aborted');
|
||||
expect(completed?.errorClass).toBeUndefined();
|
||||
expect(completed?.errorDetail).toBeUndefined();
|
||||
});
|
||||
|
||||
it('exits non-zero with a reason when input is missing in a non-TTY run (no --no-input)', async () => {
|
||||
resetCommandSpan();
|
||||
beginCommandSpan({
|
||||
commandPath: ['ktx', 'setup'],
|
||||
flagsPresent: {},
|
||||
hasProject: true,
|
||||
attachProjectGroup: true,
|
||||
startedAt: 0,
|
||||
});
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
|
||||
// `inputMode: 'auto'` but NO TTY — a piped/CI run that never prompted. The
|
||||
// step's missing-input is an automation error, not a user abort, so it must
|
||||
// exit non-zero rather than report an aborted "success".
|
||||
const testIo = makeIo(false);
|
||||
|
||||
await expect(
|
||||
runKtxSetup(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
agentScope: 'project',
|
||||
skipAgents: true,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: true,
|
||||
skipEmbeddings: false,
|
||||
databaseSchemas: [],
|
||||
skipDatabases: true,
|
||||
skipSources: true,
|
||||
},
|
||||
testIo.io,
|
||||
{
|
||||
embeddings: async () => ({ status: 'missing-input', projectDir: tempDir }),
|
||||
},
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
const completed = completeCommandSpan({ completedAt: 1, outcome: 'error' });
|
||||
expect(completed?.outcome).toBe('error');
|
||||
expect(completed?.errorClass).toBe('KtxSetupMissingInput');
|
||||
expect(completed?.errorDetail).toBe('embeddings setup step requires input not provided in a non-interactive run');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue