diff --git a/src/ui/tui/__tests__/flows.test.ts b/src/ui/tui/__tests__/flows.test.ts new file mode 100644 index 00000000..009a2402 --- /dev/null +++ b/src/ui/tui/__tests__/flows.test.ts @@ -0,0 +1,147 @@ +import { buildSession, RunPhase } from '../../../lib/wizard-session.js'; +import { WizardReadiness } from '../../../lib/health-checks/readiness.js'; +import { FLOWS, Flow, Screen } from '../flows.js'; + +function getEntry(flow: Flow, screen: Screen) { + const entry = FLOWS[flow].find((candidate) => candidate.screen === screen); + if (!entry) { + throw new Error(`Missing flow entry for ${flow}:${screen}`); + } + return entry; +} + +describe('FLOWS', () => { + describe('Wizard setup predicate', () => { + it('hides setup when there are no setup questions', () => { + const session = buildSession({}); + const entry = getEntry(Flow.Wizard, Screen.Setup); + + expect(entry.show?.(session)).toBe(false); + expect(entry.isComplete?.(session)).toBe(true); + }); + + it('shows setup when framework questions are missing answers', () => { + const session = buildSession({}); + const entry = getEntry(Flow.Wizard, Screen.Setup); + + session.frameworkConfig = { + metadata: { + setup: { + questions: [{ key: 'packageManager' }, { key: 'srcDir' }], + }, + }, + } as never; + session.frameworkContext = { packageManager: 'pnpm' }; + + expect(entry.show?.(session)).toBe(true); + expect(entry.isComplete?.(session)).toBe(false); + }); + + it('marks setup complete once all required answers are present', () => { + const session = buildSession({}); + const entry = getEntry(Flow.Wizard, Screen.Setup); + + session.frameworkConfig = { + metadata: { + setup: { + questions: [{ key: 'packageManager' }, { key: 'srcDir' }], + }, + }, + } as never; + session.frameworkContext = { + packageManager: 'pnpm', + srcDir: 'src', + }; + + expect(entry.show?.(session)).toBe(false); + expect(entry.isComplete?.(session)).toBe(true); + }); + }); + + describe('Wizard health-check predicate', () => { + it('stays incomplete before readiness exists', () => { + const session = buildSession({}); + const entry = getEntry(Flow.Wizard, Screen.HealthCheck); + + expect(entry.isComplete?.(session)).toBe(false); + }); + + it('stays incomplete for blocking readiness until outage is dismissed', () => { + const session = buildSession({}); + const entry = getEntry(Flow.Wizard, Screen.HealthCheck); + + session.readinessResult = { + decision: WizardReadiness.No, + health: {} as never, + reasons: ['Anthropic: down'], + }; + + expect(entry.isComplete?.(session)).toBe(false); + + session.outageDismissed = true; + + expect(entry.isComplete?.(session)).toBe(true); + }); + + it('completes immediately for non-blocking readiness', () => { + const session = buildSession({}); + const entry = getEntry(Flow.Wizard, Screen.HealthCheck); + + session.readinessResult = { + decision: WizardReadiness.YesWithWarnings, + health: {} as never, + reasons: [], + }; + + expect(entry.isComplete?.(session)).toBe(true); + }); + }); + + describe('Wizard run predicate', () => { + it('stays incomplete while run is idle or running', () => { + const session = buildSession({}); + const entry = getEntry(Flow.Wizard, Screen.Run); + + session.runPhase = RunPhase.Idle; + expect(entry.isComplete?.(session)).toBe(false); + + session.runPhase = RunPhase.Running; + expect(entry.isComplete?.(session)).toBe(false); + }); + + it('completes when run finishes or errors', () => { + const session = buildSession({}); + const entry = getEntry(Flow.Wizard, Screen.Run); + + session.runPhase = RunPhase.Completed; + expect(entry.isComplete?.(session)).toBe(true); + + session.runPhase = RunPhase.Error; + expect(entry.isComplete?.(session)).toBe(true); + }); + }); + + describe('MCP flow predicates', () => { + it('uses mcpComplete for McpAdd', () => { + const session = buildSession({}); + const entry = getEntry(Flow.McpAdd, Screen.McpAdd); + + expect(entry.isComplete?.(session)).toBe(false); + + session.mcpComplete = true; + + expect(entry.isComplete?.(session)).toBe(true); + }); + + it('uses mcpComplete for McpRemove', () => { + const session = buildSession({}); + const entry = getEntry(Flow.McpRemove, Screen.McpRemove); + + expect(entry.isComplete?.(session)).toBe(false); + + session.mcpComplete = true; + + expect(entry.isComplete?.(session)).toBe(true); + }); + }); +}); diff --git a/src/ui/tui/__tests__/router.test.ts b/src/ui/tui/__tests__/router.test.ts new file mode 100644 index 00000000..0c3b0b58 --- /dev/null +++ b/src/ui/tui/__tests__/router.test.ts @@ -0,0 +1,106 @@ +import { buildSession, RunPhase } from '../../../lib/wizard-session.js'; +import { WizardReadiness } from '../../../lib/health-checks/readiness.js'; +import { WizardRouter, Flow, Screen, Overlay } from '../router.js'; + +function baseWizardSession() { + return buildSession({}); +} + +describe('WizardRouter', () => { + describe('resolve', () => { + it('returns the first incomplete visible screen for the wizard flow', () => { + const router = new WizardRouter(Flow.Wizard); + const session = baseWizardSession(); + + expect(router.resolve(session)).toBe(Screen.Intro); + + session.setupConfirmed = true; + session.readinessResult = { + decision: WizardReadiness.Yes, + health: {} as never, + reasons: [], + }; + session.credentials = { + accessToken: 'tok', + projectApiKey: 'pk', + host: 'https://app.posthog.com', + projectId: 1, + }; + + expect(router.resolve(session)).toBe(Screen.Run); + }); + + it('skips the setup screen when there are no unanswered framework questions', () => { + const router = new WizardRouter(Flow.Wizard); + const session = baseWizardSession(); + + session.setupConfirmed = true; + session.readinessResult = { + decision: WizardReadiness.Yes, + health: {} as never, + reasons: [], + }; + session.frameworkConfig = { + metadata: { + setup: { + questions: [{ key: 'packageManager' }], + }, + }, + } as never; + session.frameworkContext = { packageManager: 'pnpm' }; + + expect(router.resolve(session)).toBe(Screen.Auth); + }); + + it('returns the last flow screen when every entry is complete', () => { + const router = new WizardRouter(Flow.Wizard); + const session = baseWizardSession(); + + session.setupConfirmed = true; + session.readinessResult = { + decision: WizardReadiness.Yes, + health: {} as never, + reasons: [], + }; + session.credentials = { + accessToken: 'tok', + projectApiKey: 'pk', + host: 'https://app.posthog.com', + projectId: 1, + }; + session.runPhase = RunPhase.Completed; + session.mcpComplete = true; + + expect(router.resolve(session)).toBe(Screen.Outro); + }); + + it('gives the topmost overlay precedence over the flow screen', () => { + const router = new WizardRouter(Flow.Wizard); + const session = baseWizardSession(); + + router.pushOverlay(Overlay.SettingsOverride); + router.pushOverlay(Overlay.AuthError); + + expect(router.resolve(session)).toBe(Overlay.AuthError); + + router.popOverlay(); + expect(router.resolve(session)).toBe(Overlay.SettingsOverride); + }); + }); + + describe('activeScreen', () => { + it('defaults to the first screen in the active flow', () => { + const router = new WizardRouter(Flow.McpRemove); + + expect(router.activeScreen).toBe(Screen.McpRemove); + }); + + it('returns the top overlay when overlays are active', () => { + const router = new WizardRouter(Flow.Wizard); + + router.pushOverlay(Overlay.ManagedSettings); + + expect(router.activeScreen).toBe(Overlay.ManagedSettings); + }); + }); +}); diff --git a/src/ui/tui/__tests__/store.test.ts b/src/ui/tui/__tests__/store.test.ts index d40e8344..2688ca0e 100644 --- a/src/ui/tui/__tests__/store.test.ts +++ b/src/ui/tui/__tests__/store.test.ts @@ -8,7 +8,10 @@ import { McpOutcome, } from '../store.js'; import { OutroKind, AdditionalFeature } from '../../../lib/wizard-session.js'; -import { WizardReadiness } from '../../../lib/health-checks/readiness.js'; +import { + WizardReadiness, + evaluateWizardReadiness, +} from '../../../lib/health-checks/readiness.js'; import { buildSession } from '../../../lib/wizard-session.js'; import { Integration } from '../../../lib/constants.js'; import { analytics } from '../../../utils/analytics.js'; @@ -42,10 +45,24 @@ function createStore(flow?: Flow): WizardStore { } const wizardCaptureMock = analytics.wizardCapture as jest.Mock; +const evaluateWizardReadinessMock = + evaluateWizardReadiness as jest.MockedFunction< + typeof evaluateWizardReadiness + >; + +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} describe('WizardStore', () => { beforeEach(() => { jest.clearAllMocks(); + evaluateWizardReadinessMock.mockResolvedValue({ + decision: WizardReadiness.Yes, + health: {} as never, + reasons: [], + }); }); // ── Construction ───────────────────────────────────────────────── @@ -924,6 +941,116 @@ describe('WizardStore', () => { }); }); + // ── healthGateComplete promise ──────────────────────────────────── + + describe('healthGateComplete', () => { + it('resolves immediately for non-Wizard flows', async () => { + const store = createStore(Flow.McpAdd); + + await expect(store.healthGateComplete).resolves.toBeUndefined(); + }); + + it('resolves automatically when readiness is non-blocking', async () => { + evaluateWizardReadinessMock.mockResolvedValueOnce({ + decision: WizardReadiness.Yes, + health: {} as never, + reasons: [], + }); + + const store = createStore(); + let resolved = false; + + void store.healthGateComplete.then(() => { + resolved = true; + }); + + await flushMicrotasks(); + + expect(resolved).toBe(true); + expect(store.session.readinessResult).toEqual({ + decision: WizardReadiness.Yes, + health: {} as never, + reasons: [], + }); + }); + + it('stays pending for blocking readiness until outage is dismissed', async () => { + evaluateWizardReadinessMock.mockResolvedValueOnce({ + decision: WizardReadiness.No, + health: {} as never, + reasons: ['Anthropic: down'], + }); + + const store = createStore(); + let resolved = false; + + void store.healthGateComplete.then(() => { + resolved = true; + }); + + await flushMicrotasks(); + + expect(resolved).toBe(false); + expect(store.currentScreen).toBe(Screen.Intro); + + store.dismissOutage(); + await store.healthGateComplete; + + expect(resolved).toBe(true); + expect(store.session.outageDismissed).toBe(true); + }); + }); + + // ── Screen transition analytics ─────────────────────────────────── + + describe('screen transition analytics', () => { + it('fires when a real screen transition occurs after the initial screen', () => { + const store = createStore(); + + store.completeSetup(); + wizardCaptureMock.mockClear(); + + store.setReadinessResult({ + decision: WizardReadiness.Yes, + health: {} as never, + reasons: [], + }); + + expect(wizardCaptureMock).toHaveBeenCalledWith( + 'screen auth', + expect.objectContaining({ + from_screen: Screen.HealthCheck, + }), + ); + }); + + it('does not fire a screen event when the visible screen stays the same', () => { + const store = createStore(); + store.completeSetup(); + store.setReadinessResult({ + decision: WizardReadiness.Yes, + health: {} as never, + reasons: [], + }); + store.setCredentials({ + accessToken: 'tok', + projectApiKey: 'pk', + host: 'h', + projectId: 1, + }); + wizardCaptureMock.mockClear(); + + store.setRunPhase(RunPhase.Running); + + expect(store.currentScreen).toBe(Screen.Run); + expect( + wizardCaptureMock.mock.calls.some( + ([event]) => typeof event === 'string' && event.startsWith('screen '), + ), + ).toBe(false); + }); + }); + // ── setupComplete promise ──────────────────────────────────────── describe('setupComplete', () => {