diff --git a/packages/cli/src/ingest-viz.test.ts b/packages/cli/src/ingest-viz.test.ts index 1347b3a8..6963d277 100644 --- a/packages/cli/src/ingest-viz.test.ts +++ b/packages/cli/src/ingest-viz.test.ts @@ -331,8 +331,9 @@ describe('runKtxIngest viz and replay', () => { ).resolves.toBe(0); expect(runLocal).toHaveBeenCalledWith(expect.objectContaining({ memoryFlow: expect.anything() })); - expect(io.stdout()).toContain('[5%] Fetching source files for warehouse/fake'); + expect(io.stderr()).toContain('[5%] Fetching source files for warehouse/fake'); expect(io.stdout()).toContain('Job: plain-run'); + expect(io.stdout()).not.toContain('[5%]'); expect(io.stdout()).not.toContain('KTX memory flow'); }); @@ -407,8 +408,9 @@ describe('runKtxIngest viz and replay', () => { expect(startLiveMemoryFlow).not.toHaveBeenCalled(); expect(runLocal).toHaveBeenCalledWith(expect.objectContaining({ memoryFlow: expect.anything() })); - expect(io.stdout()).toContain('[5%] Fetching source files for warehouse/fake'); + expect(io.stderr()).toContain('[5%] Fetching source files for warehouse/fake'); expect(io.stdout()).toContain('Job: raw-missing-viz-run'); + expect(io.stdout()).not.toContain('[5%]'); expect(io.stdout()).not.toContain('KTX memory flow'); expect(io.stderr()).toContain( 'Visualization requested but stdin raw mode is unavailable; printing plain output.', diff --git a/packages/cli/src/ingest.test-utils.ts b/packages/cli/src/ingest.test-utils.ts index a83b38be..71d85c6c 100644 --- a/packages/cli/src/ingest.test-utils.ts +++ b/packages/cli/src/ingest.test-utils.ts @@ -546,7 +546,7 @@ export async function runPublicMetabaseSyncModeCase(tempDir: string, input: Sync ), ).resolves.toBe(0); - expect(io.stderr()).toBe(''); + expect(io.stderr()).toContain('Metabase ingest: prod-metabase'); expect(io.stdout()).toContain('Metabase fan-out: all_succeeded'); expect(io.stdout()).toContain(`target=warehouse_a database=1 status=done job=${jobId}`); diff --git a/packages/cli/src/ingest.test.ts b/packages/cli/src/ingest.test.ts index 8f7bf5b8..2573262a 100644 --- a/packages/cli/src/ingest.test.ts +++ b/packages/cli/src/ingest.test.ts @@ -230,7 +230,7 @@ describe('runKtxIngest', () => { expect(io.stdout()).toContain('Metabase fan-out: all_succeeded'); expect(io.stdout()).toContain('warehouse_a'); expect(io.stdout()).toContain('metabase-child-1'); - expect(io.stderr()).toBe(''); + expect(io.stderr()).toContain('Metabase ingest: prod-metabase'); }); it('returns a non-zero code when Metabase fan-out has failed children', async () => { @@ -300,7 +300,7 @@ describe('runKtxIngest', () => { expect(io.stdout()).toContain('Metabase fan-out: partial_failure'); expect(io.stdout()).toContain('Failed work units: 1'); expect(io.stdout()).toContain('status=error'); - expect(io.stderr()).toBe(''); + expect(io.stderr()).toContain('Metabase ingest: prod-metabase'); }); it('prints Metabase fan-out progress before the final summary', async () => { @@ -374,12 +374,56 @@ describe('runKtxIngest', () => { ), ).resolves.toBe(0); - expect(io.stdout()).toContain('Metabase ingest: prod-metabase'); - expect(io.stdout()).toContain('Targets: 1 mapped database'); - expect(io.stdout()).toContain('- database=1 target=warehouse_a status=running job=metabase-child-1'); - expect(io.stdout()).toContain('- database=1 target=warehouse_a status=done job=metabase-child-1'); + expect(io.stderr()).toContain('Metabase ingest: prod-metabase'); + expect(io.stderr()).toContain('Targets: 1 mapped database'); + expect(io.stderr()).toContain('- database=1 target=warehouse_a status=running job=metabase-child-1'); + expect(io.stderr()).toContain('- database=1 target=warehouse_a status=done job=metabase-child-1'); expect(io.stdout()).toContain('Metabase fan-out: all_succeeded'); - expect(io.stderr()).toBe(''); + expect(io.stdout()).not.toContain('status=running job=metabase-child-1'); + }); + + it('writes metabase fan-out progress to stderr and final result to stdout', async () => { + const projectDir = join(tempDir, 'project'); + await writeMetabaseConfig(projectDir); + const io = makeIo({ isTTY: true }); + + await expect( + runKtxIngest( + { + command: 'run', + projectDir, + connectionId: 'prod-metabase', + adapter: 'metabase', + outputMode: 'plain', + }, + io.io, + { + runLocalMetabaseIngest: async (input) => { + input.progress?.onMetabaseFanoutPlanned({ + metabaseConnectionId: 'prod-metabase', + children: [{ metabaseDatabaseId: 1, targetConnectionId: 'warehouse_a' }], + }); + input.progress?.onMetabaseChildStarted({ + metabaseConnectionId: 'prod-metabase', + metabaseDatabaseId: 1, + targetConnectionId: 'warehouse_a', + jobId: 'metabase-child-1', + }); + return { + metabaseConnectionId: 'prod-metabase', + status: 'all_succeeded', + totals: { workUnits: 0, failedWorkUnits: 0 }, + children: [], + }; + }, + }, + ), + ).resolves.toBe(0); + + expect(io.stderr()).toContain('Metabase ingest: prod-metabase'); + expect(io.stderr()).toContain('status=running job=metabase-child-1'); + expect(io.stdout()).toContain('Metabase fan-out: all_succeeded'); + expect(io.stdout()).not.toContain('status=running job=metabase-child-1'); }); it('runs Metabase scheduled ingest through the public CLI command path with real fan-out', async () => { @@ -464,7 +508,8 @@ describe('runKtxIngest', () => { ), ).resolves.toBe(0); - expect(io.stderr()).toBe(''); + expect(io.stderr()).toContain('Metabase ingest: prod-metabase'); + expect(io.stderr()).toContain('Targets: 2 mapped databases'); expect(io.stdout()).toContain('Metabase fan-out: all_succeeded'); expect(io.stdout()).toContain('Source: prod-metabase'); expect(io.stdout()).toContain('Children: 2'); @@ -1031,16 +1076,46 @@ describe('runKtxIngest', () => { ), ).resolves.toBe(0); - const stdout = io.stdout(); - expect(stdout).toContain('[5%] Fetching source files for warehouse/historic-sql'); - expect(stdout).toContain('[15%] Fetched 3 source files from historic-sql'); - expect(stdout).toContain('[45%] Planned 1 work unit'); - expect(stdout).toContain('[80%] Processed 1/1 work units'); - expect(stdout).toContain('[100%] Ingest completed'); - expect(stdout.indexOf('[5%] Fetching source files for warehouse/historic-sql')).toBeLessThan( - stdout.indexOf('Report: report-live-1'), - ); - expect(io.stderr()).toBe(''); + const stderr = io.stderr(); + expect(stderr).toContain('[5%] Fetching source files for warehouse/historic-sql'); + expect(stderr).toContain('[15%] Fetched 3 source files from historic-sql'); + expect(stderr).toContain('[45%] Planned 1 work unit'); + expect(stderr).toContain('[80%] Processed 1/1 work units'); + expect(stderr).toContain('[100%] Ingest completed'); + expect(io.stdout()).toContain('Report: report-live-1'); + expect(io.stdout()).not.toContain('[5%]'); + }); + + it('writes plain TTY ingest progress to stderr and final report to stdout', async () => { + const projectDir = join(tempDir, 'project'); + await writeWarehouseConfig(projectDir); + const sourceDir = join(tempDir, 'source'); + await mkdir(join(sourceDir, 'orders'), { recursive: true }); + await writeFile(join(sourceDir, 'orders', 'orders.json'), '{"name":"orders"}\n', 'utf-8'); + const runLocal = vi.fn(async (input: RunLocalIngestOptions) => completedLocalBundleRun(input, 'local-job-1')); + const io = makeIo({ isTTY: true }); + + await expect( + runKtxIngest( + { + command: 'run', + projectDir, + connectionId: 'warehouse', + adapter: 'fake', + sourceDir, + outputMode: 'plain', + }, + io.io, + { + env: interactiveEnv(), + runLocalIngest: runLocal, + }, + ), + ).resolves.toBe(0); + + expect(io.stderr()).toContain('[5%] Fetching source files for warehouse/fake'); + expect(io.stdout()).toContain('Report: report-live-1'); + expect(io.stdout()).not.toContain('[5%]'); }); it('prints plain WorkUnit step progress during long-running local ingest', async () => { @@ -1127,11 +1202,12 @@ describe('runKtxIngest', () => { ), ).resolves.toBe(0); - const stdout = io.stdout(); - expect(stdout).toContain('[45%] Planned 2 work units'); - expect(stdout).toContain('[55%] Processing 1/2 work units: historic-sql-table-public-orders'); - expect(stdout).toContain('[58%] Processing 1/2 work units: historic-sql-table-public-orders step 7/40'); - expect(stdout).toContain('[68%] Processed 1/2 work units'); + const stderr = io.stderr(); + expect(stderr).toContain('[45%] Planned 2 work units'); + expect(stderr).toContain('[55%] Processing 1/2 work units: historic-sql-table-public-orders'); + expect(stderr).toContain('[58%] Processing 1/2 work units: historic-sql-table-public-orders step 7/40'); + expect(stderr).toContain('[68%] Processed 1/2 work units'); + expect(io.stdout()).not.toContain('[45%]'); }); it('passes local Looker pull-config options and agent runner into scheduled ingest for Looker scheduled ingest', async () => { diff --git a/packages/cli/src/ingest.ts b/packages/cli/src/ingest.ts index 516d4dd6..36b45a6a 100644 --- a/packages/cli/src/ingest.ts +++ b/packages/cli/src/ingest.ts @@ -143,22 +143,22 @@ function createMetabaseFanoutProgress( connectionId: string, io: KtxIngestIo, ): LocalMetabaseFanoutProgress { - io.stdout.write(`Metabase ingest: ${connectionId}\n`); - io.stdout.write('Checking mappings and scheduled-pull targets...\n'); + io.stderr.write(`Metabase ingest: ${connectionId}\n`); + io.stderr.write('Checking mappings and scheduled-pull targets...\n'); return { onMetabaseFanoutPlanned(event) { - io.stdout.write(`Targets: ${pluralize(event.children.length, 'mapped database')}\n`); + io.stderr.write(`Targets: ${pluralize(event.children.length, 'mapped database')}\n`); for (const child of event.children) { - io.stdout.write(`- database=${child.metabaseDatabaseId} target=${child.targetConnectionId} status=queued\n`); + io.stderr.write(`- database=${child.metabaseDatabaseId} target=${child.targetConnectionId} status=queued\n`); } }, onMetabaseChildStarted(event) { - io.stdout.write( + io.stderr.write( `- database=${event.metabaseDatabaseId} target=${event.targetConnectionId} status=running job=${event.jobId}\n`, ); }, onMetabaseChildCompleted(event) { - io.stdout.write( + io.stderr.write( `- database=${event.metabaseDatabaseId} target=${event.targetConnectionId} status=${event.status} job=${event.jobId}\n`, ); }, @@ -290,7 +290,7 @@ function createPlainIngestProgressRenderer( const write = (percent: number, message: string) => { const nextPercent = Math.max(lastPercent, Math.max(0, Math.min(100, percent))); lastPercent = nextPercent; - io.stdout.write(`[${nextPercent}%] ${message}\n`); + io.stderr.write(`[${nextPercent}%] ${message}\n`); }; return {