Fix setup output formatting

This commit is contained in:
Andrey Avtomonov 2026-05-13 14:56:43 +02:00
parent 347332540c
commit 496cc3a767
4 changed files with 146 additions and 7 deletions

View file

@ -1236,6 +1236,48 @@ describe('setup databases step', () => {
expect(config.connections.warehouse).toMatchObject({ driver: 'postgres', url: 'env:DATABASE_URL' });
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
expect(io.stderr()).toContain('Structural scan failed for warehouse.');
expect(io.stderr()).toContain('│ Structural scan failed for warehouse.');
expect(io.stderr()).not.toMatch(/^Structural scan failed for warehouse\./m);
});
it('prints the native SQLite rebuild command when scanning hits a Node ABI mismatch', async () => {
const io = makeIo();
const result = await runKtxSetupDatabasesStep(
{
projectDir: tempDir,
inputMode: 'disabled',
databaseDrivers: ['postgres'],
databaseConnectionId: 'warehouse',
databaseUrl: 'env:DATABASE_URL',
databaseSchemas: [],
skipDatabases: false,
},
io.io,
{
testConnection: vi.fn(async () => 0),
scanConnection: vi.fn(async (_projectDir: string, _connectionId: string, commandIo: KtxCliIo) => {
commandIo.stderr.write(
[
"The module '/workspace/node_modules/better-sqlite3/build/Release/better_sqlite3.node'",
'was compiled against a different Node.js version using',
'NODE_MODULE_VERSION 147. This version of Node.js requires',
'NODE_MODULE_VERSION 137. Please try re-compiling or re-installing',
'the module (for instance, using `npm rebuild` or `npm install`).',
'',
].join('\n'),
);
return 1;
}),
},
);
expect(result.status).toBe('failed');
expect(io.stderr()).toContain('Native SQLite is built for a different Node.js ABI.');
expect(io.stderr()).toContain('│ Native SQLite is built for a different Node.js ABI.');
expect(io.stderr()).toContain('Fix: pnpm run native:rebuild');
expect(io.stderr()).toContain(`Retry: ktx scan --project-dir ${tempDir} warehouse`);
expect(io.stderr()).not.toContain('npm rebuild');
expect(io.stderr()).not.toMatch(/^Native SQLite is built for a different Node.js ABI\./m);
});
it('writes Historic SQL config for supported Snowflake databases after validation succeeds', async () => {

View file

@ -951,6 +951,36 @@ function flushBufferedCommandOutput(io: KtxCliIo, bufferedIo: BufferedCommandIo)
}
}
function writePrefixedLines(write: (chunk: string) => void, output: string): void {
for (const line of output.split(/\r?\n/)) {
if (line.length > 0) {
write(`${line}\n`);
}
}
}
function flushPrefixedBufferedCommandOutput(io: KtxCliIo, bufferedIo: BufferedCommandIo): void {
writePrefixedLines((chunk) => io.stdout.write(chunk), bufferedIo.stdoutText());
writePrefixedLines((chunk) => io.stderr.write(chunk), bufferedIo.stderrText());
}
function nativeSqliteAbiMismatchDetail(output: string): string | null {
const mentionsBetterSqlite = /\bbetter-sqlite3\b|better_sqlite3/i.test(output);
const mentionsAbiMismatch = /compiled against a different Node\.js version|NODE_MODULE_VERSION/i.test(output);
if (!mentionsBetterSqlite || !mentionsAbiMismatch) {
return null;
}
const versionMatch = output.match(
/compiled against[\s\S]*?NODE_MODULE_VERSION\s+(\d+)[\s\S]*?requires[\s\S]*?NODE_MODULE_VERSION\s+(\d+)/i,
);
if (!versionMatch) {
return 'better-sqlite3 native module could not load for the current Node.js runtime.';
}
return `better-sqlite3 was compiled for NODE_MODULE_VERSION ${versionMatch[1]}, but this Node.js requires ${versionMatch[2]}.`;
}
function readOutputValue(output: string, label: string): string | undefined {
const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = new RegExp(`^\\s*${escapedLabel}:\\s*(.+?)\\s*$`, 'im').exec(output);
@ -1445,9 +1475,28 @@ async function validateAndScanConnection(input: {
const scanIo = createBufferedCommandIo();
const scanCode = await scanConnection(input.projectDir, input.connectionId, scanIo);
if (scanCode !== 0) {
flushBufferedCommandOutput(input.io, scanIo);
input.io.stderr.write(`Structural scan failed for ${input.connectionId}.\n`);
input.io.stderr.write(`Debug command: ktx scan --project-dir ${input.projectDir} ${input.connectionId}\n`);
const nativeSqliteDetail = nativeSqliteAbiMismatchDetail(`${scanIo.stderrText()}\n${scanIo.stdoutText()}`);
if (nativeSqliteDetail) {
writePrefixedLines(
(chunk) => input.io.stderr.write(chunk),
[
`Structural scan failed for ${input.connectionId}.`,
'Native SQLite is built for a different Node.js ABI.',
`Detail: ${nativeSqliteDetail}`,
'Fix: pnpm run native:rebuild',
`Retry: ktx scan --project-dir ${input.projectDir} ${input.connectionId}`,
].join('\n'),
);
} else {
flushPrefixedBufferedCommandOutput(input.io, scanIo);
writePrefixedLines(
(chunk) => input.io.stderr.write(chunk),
[
`Structural scan failed for ${input.connectionId}.`,
`Debug command: ktx scan --project-dir ${input.projectDir} ${input.connectionId}`,
].join('\n'),
);
}
return false;
}
const scanOutput = scanIo.stdoutText();

View file

@ -176,7 +176,10 @@ describe('setup sources step', () => {
it('writes Metabase config and validates mapping through existing mapping path', async () => {
await addPrimarySource();
const validateMetabase = vi.fn(async () => ({ ok: true as const, detail: 'user=admin@example.com' }));
const runMapping = vi.fn(async () => 0);
const runMapping = vi.fn(async (_projectDir: string, _connectionId: string, commandIo: KtxCliIo) => {
commandIo.stdout.write('Mapping validated — 1 mapping configured\n');
return 0;
});
const io = makeIo();
await expect(
@ -208,7 +211,16 @@ describe('setup sources step', () => {
syncMode: 'ALL',
},
});
expect(runMapping).toHaveBeenCalledWith(projectDir, 'prod_metabase', io.io);
expect(runMapping).toHaveBeenCalledWith(
projectDir,
'prod_metabase',
expect.objectContaining({
stdout: expect.objectContaining({ write: expect.any(Function) }),
stderr: expect.objectContaining({ write: expect.any(Function) }),
}),
);
expect(io.stdout()).toContain('│ Mapping validated — 1 mapping configured');
expect(io.stdout()).not.toMatch(/^Mapping validated — 1 mapping configured$/m);
});
it('writes Notion config with the full default knowledge create budget', async () => {
@ -544,7 +556,14 @@ describe('setup sources step', () => {
),
).resolves.toEqual({ status: 'failed', projectDir });
expect(runMapping).toHaveBeenCalledWith(projectDir, 'metabase-main', io.io);
expect(runMapping).toHaveBeenCalledWith(
projectDir,
'metabase-main',
expect.objectContaining({
stdout: expect.objectContaining({ write: expect.any(Function) }),
stderr: expect.objectContaining({ write: expect.any(Function) }),
}),
);
expect(io.stderr()).toContain('1: Metabase database does not match KTX connection database');
expect(io.stderr()).not.toContain('Metabase mapping validation failed');
});

View file

@ -664,6 +664,31 @@ function splitOutputLines(output: string): string[] {
.filter(Boolean);
}
function writeSetupPrefixedLines(write: (chunk: string) => void, output: string): void {
for (const line of output.split(/\r?\n/)) {
if (line.length > 0) {
write(`${line}\n`);
}
}
}
function createSetupPrefixedIo(io: KtxCliIo): KtxCliIo {
return {
stdout: {
isTTY: io.stdout.isTTY,
columns: io.stdout.columns,
write(chunk: string) {
writeSetupPrefixedLines((line) => io.stdout.write(line), chunk);
},
},
stderr: {
write(chunk: string) {
writeSetupPrefixedLines((line) => io.stderr.write(line), chunk);
},
},
};
}
function parseMappingListJson(output: string): unknown[] {
const trimmed = output.trim();
if (!trimmed) {
@ -1524,7 +1549,11 @@ export async function runKtxSetupSourcesStep(
}
if (source === 'metabase' || source === 'looker') {
prompts.log?.(`Validating ${sourceLabel(source)} mapping…`);
const mappingCode = await (deps.runMapping ?? defaultRunMapping)(args.projectDir, connectionId, io);
const mappingCode = await (deps.runMapping ?? defaultRunMapping)(
args.projectDir,
connectionId,
createSetupPrefixedIo(io),
);
if (mappingCode !== 0) {
await rollback?.();
return { status: 'failed', projectDir: args.projectDir };