diff --git a/packages/cli/src/setup-demo-tour.test.ts b/packages/cli/src/setup-demo-tour.test.ts index a57aace8..f45b42f6 100644 --- a/packages/cli/src/setup-demo-tour.test.ts +++ b/packages/cli/src/setup-demo-tour.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import type { KtxSetupAgentsResult } from './setup-agents.js'; import { buildDemoReplayTimeline, DEMO_REPLAY_TARGETS, @@ -6,6 +7,7 @@ import { renderDemoBanner, renderDemoCardContent, renderDemoCompletionSummary, + runDemoTour, } from './setup-demo-tour.js'; /** Strip ANSI escape sequences for plain-text assertions. */ @@ -149,3 +151,116 @@ describe('DEMO_REPLAY_TARGETS', () => { } }); }); + +describe('runDemoTour', () => { + function createMockIo() { + const chunks: string[] = []; + return { + io: { + stdout: { isTTY: true, columns: 80, write: (chunk: string) => { chunks.push(chunk); } }, + stderr: { write: () => {} }, + }, + chunks, + }; + } + + it('returns 0 on successful tour with agent installed', async () => { + const { io, chunks } = createMockIo(); + const mockAgents = vi.fn().mockResolvedValue({ + status: 'ready', + projectDir: '/tmp/test', + installs: [{ target: 'claude-code', scope: 'project', mode: 'both' }], + } satisfies KtxSetupAgentsResult); + + const navigation = vi.fn().mockResolvedValue('forward'); + + const result = await runDemoTour( + { inputMode: 'auto' }, + io, + { + agents: mockAgents, + waitForNavigation: navigation, + skipReplayAnimation: true, + ensureProject: vi.fn().mockResolvedValue({ projectDir: '/tmp/test' }), + }, + ); + expect(result).toBe(0); + expect(mockAgents).toHaveBeenCalled(); + // Should have rendered completion summary + const allOutput = chunks.join(''); + expect(allOutput).toContain('agent is connected'); + }); + + it('handles back navigation from first step by exiting', async () => { + const { io } = createMockIo(); + const navigation = vi.fn().mockResolvedValue('back'); + + const result = await runDemoTour( + { inputMode: 'auto' }, + io, + { + waitForNavigation: navigation, + skipReplayAnimation: true, + ensureProject: vi.fn().mockResolvedValue({ projectDir: '/tmp/test' }), + }, + ); + expect(result).toBe(0); + // Navigation called once for databases step, then exits + expect(navigation).toHaveBeenCalledTimes(1); + }); + + it('goes back from sources to databases', async () => { + const { io } = createMockIo(); + let callCount = 0; + const navigation = vi.fn().mockImplementation(() => { + callCount++; + // First call (databases): forward + // Second call (sources): back + // Third call (databases again): back (exit) + if (callCount === 1) return Promise.resolve('forward'); + return Promise.resolve('back'); + }); + + const result = await runDemoTour( + { inputMode: 'auto' }, + io, + { + waitForNavigation: navigation, + skipReplayAnimation: true, + ensureProject: vi.fn().mockResolvedValue({ projectDir: '/tmp/test' }), + }, + ); + expect(result).toBe(0); + expect(navigation).toHaveBeenCalledTimes(3); + }); + + it('handles agent step returning back', async () => { + const { io } = createMockIo(); + let navCount = 0; + const navigation = vi.fn().mockImplementation(() => { + navCount++; + // Forward through databases, sources, context + // Then back from context (after agents returns back) + // Then back from sources, then back from databases (exit) + if (navCount <= 3) return Promise.resolve('forward'); + return Promise.resolve('back'); + }); + + const mockAgents = vi.fn().mockResolvedValue({ + status: 'back', + projectDir: '/tmp/test', + } satisfies KtxSetupAgentsResult); + + const result = await runDemoTour( + { inputMode: 'auto' }, + io, + { + agents: mockAgents, + waitForNavigation: navigation, + skipReplayAnimation: true, + ensureProject: vi.fn().mockResolvedValue({ projectDir: '/tmp/test' }), + }, + ); + expect(result).toBe(0); + }); +}); diff --git a/packages/cli/src/setup-demo-tour.ts b/packages/cli/src/setup-demo-tour.ts index d17d138d..75bdf8b9 100644 --- a/packages/cli/src/setup-demo-tour.ts +++ b/packages/cli/src/setup-demo-tour.ts @@ -4,7 +4,10 @@ import type { ContextBuildViewState, } from './context-build-view.js'; import { createRepainter, renderContextBuildView } from './context-build-view.js'; +import { defaultDemoProjectDir, ensureSeededDemoProject } from './demo-assets.js'; import type { KtxPublicIngestPlanTarget } from './public-ingest.js'; +import type { KtxSetupAgentsResult } from './setup-agents.js'; +import { runKtxSetupAgentsStep } from './setup-agents.js'; import { KtxSetupExitError } from './setup-interrupt.js'; // --------------------------------------------------------------------------- @@ -300,3 +303,82 @@ export async function runDemoContextReplay( io.stdout.write(renderDemoContextCompletionSummary() + '\n'); return waitForDemoNavigation(stdin); } + +// --------------------------------------------------------------------------- +// Demo tour orchestrator +// --------------------------------------------------------------------------- + +type DemoStep = 'databases' | 'sources' | 'context' | 'agents'; + +const DEMO_STEPS: DemoStep[] = ['databases', 'sources', 'context', 'agents']; + +export interface DemoTourDeps { + agents?: (args: Parameters[0], io: KtxCliIo) => Promise; + waitForNavigation?: (stdin?: NodeJS.ReadStream) => Promise<'forward' | 'back'>; + ensureProject?: typeof ensureSeededDemoProject; + skipReplayAnimation?: boolean; +} + +export async function runDemoTour( + args: { inputMode: 'auto' | 'disabled' }, + io: KtxCliIo, + deps: DemoTourDeps = {}, +): Promise { + const waitNav = deps.waitForNavigation ?? waitForDemoNavigation; + const ensureProject = deps.ensureProject ?? ensureSeededDemoProject; + + const projectDir = defaultDemoProjectDir(); + await ensureProject({ projectDir, force: false }); + + let stepIndex = 0; + + while (stepIndex < DEMO_STEPS.length) { + const step = DEMO_STEPS[stepIndex]!; + let direction: 'forward' | 'back'; + + if (step === 'databases') { + direction = await renderDemoCard('Database connection', ['PostgreSQL (demo warehouse)'], io, undefined, waitNav); + } else if (step === 'sources') { + direction = await renderDemoCard('Context sources', ['dbt', 'Metabase', 'Notion'], io, undefined, waitNav); + } else if (step === 'context') { + io.stdout.write(renderDemoBanner() + '\n\n'); + if (deps.skipReplayAnimation) { + direction = await waitNav(); + } else { + direction = await runDemoContextReplay(io); + } + } else { + // agents step — real interactive + io.stdout.write(renderDemoAgentTransition() + '\n'); + const agentsRunner = deps.agents ?? runKtxSetupAgentsStep; + const agentsResult = await agentsRunner( + { + projectDir, + inputMode: args.inputMode, + yes: false, + agents: true, + scope: 'project', + mode: 'both', + skipAgents: false, + }, + io, + ); + const agentInstalled = agentsResult.status === 'ready'; + if (agentsResult.status === 'back') { + direction = 'back'; + } else { + io.stdout.write(renderDemoCompletionSummary(projectDir, agentInstalled) + '\n'); + return 0; + } + } + + if (direction === 'back') { + if (stepIndex === 0) return 0; + stepIndex -= 1; + } else { + stepIndex += 1; + } + } + + return 0; +} diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index 5eac2e27..1373517a 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -4,7 +4,6 @@ import { cancel, isCancel, select } from '@clack/prompts'; import { loadKtxProject } from '@ktx/context/project'; import type { KtxCliIo } from './cli-runtime.js'; import type { KtxDemoArgs } from './demo.js'; -import { defaultDemoProjectDir } from './demo-assets.js'; import { formatSetupNextStepLines } from './next-steps.js'; import { isKtxSetupExitError, withSetupInterruptConfirmation } from './setup-interrupt.js'; import { @@ -220,15 +219,11 @@ async function runKtxSetupDemoFromEntryMenu( io: KtxCliIo, deps: KtxSetupDeps, ): Promise { - const runner = deps.demo ?? (await import('./demo.js')).runKtxDemo; - return await runner( - { - command: 'seeded', - projectDir: defaultDemoProjectDir(), - outputMode: 'viz', - inputMode: args.inputMode, - }, + const { runDemoTour } = await import('./setup-demo-tour.js'); + return await runDemoTour( + { inputMode: args.inputMode }, io, + { agents: deps.agents }, ); }