/** * Unified wizard integration test — exercises the `skipGateway: false` code * path so that wiring between `runWizard` and the two gateway stages is * covered. The gateway stages themselves are mocked (they require a real * daemon + network) but the dynamic imports and option plumbing are real. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, rmSync, cpSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js'; import { createConfigService } from '../../src/config/config-service.js'; import type { SelectOption } from '../../src/prompter/interface.js'; import type { MenuSection, WizardState } from '../../src/types.js'; const gatewayConfigMock = vi.fn(); const gatewayBootstrapMock = vi.fn(); const providerSetupMock = vi.fn(); const skillsSelectMock = vi.fn(); class SequencedMenuPrompter extends HeadlessPrompter { constructor( answers: Record, private readonly menuChoices: string[], ) { super(answers); } override async select(opts: { message: string; options: SelectOption[]; initialValue?: T; }): Promise { if (opts.message === 'What would you like to configure?') { const next = this.menuChoices.shift(); if (!next) throw new Error('No queued menu choice left'); const match = opts.options.find((o) => String(o.value) === next); if (!match) throw new Error(`Queued menu choice not available: ${next}`); return match.value; } return super.select(opts); } } vi.mock('../../src/stages/gateway-config.js', () => ({ gatewayConfigStage: (...args: unknown[]) => gatewayConfigMock(...args), })); vi.mock('../../src/stages/gateway-bootstrap.js', () => ({ gatewayBootstrapStage: (...args: unknown[]) => gatewayBootstrapMock(...args), })); vi.mock('../../src/stages/provider-setup.js', () => ({ providerSetupStage: (...args: unknown[]) => providerSetupMock(...args), })); vi.mock('../../src/stages/skills-select.js', () => ({ skillsSelectStage: (...args: unknown[]) => skillsSelectMock(...args), })); // Import AFTER the mocks so runWizard picks up the mocked stage modules. import { runWizard } from '../../src/wizard.js'; describe('Unified wizard (runWizard with default skipGateway)', () => { let tmpDir: string; const repoRoot = join(import.meta.dirname, '..', '..'); const originalIsTTY = process.stdin.isTTY; const originalAssumeYes = process.env['MOSAIC_ASSUME_YES']; beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-unified-wizard-')); const candidates = [join(repoRoot, 'framework', 'templates'), join(repoRoot, 'templates')]; for (const templatesDir of candidates) { if (existsSync(templatesDir)) { cpSync(templatesDir, join(tmpDir, 'templates'), { recursive: true }); break; } } gatewayConfigMock.mockReset(); gatewayBootstrapMock.mockReset(); providerSetupMock.mockReset(); skillsSelectMock.mockReset(); providerSetupMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => { state.providerType = 'none'; state.completedSections?.add('providers' satisfies MenuSection); }); skillsSelectMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => { state.selectedSkills = []; state.completedSections?.add('skills' satisfies MenuSection); }); // Pretend we're on an interactive TTY so the wizard's headless-abort // branch does not call `process.exit(1)` during these tests. Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); delete process.env['MOSAIC_ASSUME_YES']; }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true, }); if (originalAssumeYes === undefined) { delete process.env['MOSAIC_ASSUME_YES']; } else { process.env['MOSAIC_ASSUME_YES'] = originalAssumeYes; } }); it('invokes the gateway config + bootstrap stages by default', async () => { gatewayConfigMock.mockResolvedValue({ ready: true, host: 'localhost', port: 14242 }); gatewayBootstrapMock.mockResolvedValue({ completed: true }); const prompter = new HeadlessPrompter({ 'Installation mode': 'quick', 'What name should agents use?': 'TestBot', 'Communication style': 'direct', 'Your name': 'Tester', 'Your pronouns': 'They/Them', 'Your timezone': 'UTC', }); await runWizard({ mosaicHome: tmpDir, sourceDir: tmpDir, prompter, configService: createConfigService(tmpDir, tmpDir), gatewayHost: 'localhost', gatewayPort: 14242, skipGatewayNpmInstall: true, }); expect(gatewayConfigMock).toHaveBeenCalledTimes(1); expect(gatewayBootstrapMock).toHaveBeenCalledTimes(1); const configCall = gatewayConfigMock.mock.calls[0]; expect(configCall[2]).toMatchObject({ host: 'localhost', defaultPort: 14242, skipInstall: true, }); const bootstrapCall = gatewayBootstrapMock.mock.calls[0]; expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 }); }); it('prints the success summary only after gateway health succeeds', async () => { gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => { p.log('Gateway is healthy.'); return { ready: true, host: 'localhost', port: 14242 }; }); gatewayBootstrapMock.mockResolvedValue({ completed: true }); const prompter = new HeadlessPrompter({ 'Installation mode': 'quick', 'What name should agents use?': 'TestBot', 'Communication style': 'direct', 'Your name': 'Tester', 'Your pronouns': 'They/Them', 'Your timezone': 'UTC', }); await runWizard({ mosaicHome: tmpDir, sourceDir: tmpDir, prompter, configService: createConfigService(tmpDir, tmpDir), skipGatewayNpmInstall: true, }); const logs = prompter.getLogs(); const healthIndex = logs.findIndex((line) => line.includes('Gateway is healthy.')); const summaryIndex = logs.findIndex((line) => line.includes('Installation Summary')); const readyIndex = logs.findIndex((line) => line.includes('Mosaic is ready.')); expect(healthIndex).toBeGreaterThanOrEqual(0); expect(summaryIndex).toBeGreaterThan(healthIndex); expect(readyIndex).toBeGreaterThan(summaryIndex); }); it('does not claim success when gateway health reports not ready', async () => { gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => { p.warn('Gateway did not become healthy within 30 seconds.'); return { ready: false }; }); const prompter = new HeadlessPrompter({ 'Installation mode': 'quick', 'What name should agents use?': 'TestBot', 'Communication style': 'direct', 'Your name': 'Tester', 'Your pronouns': 'They/Them', 'Your timezone': 'UTC', }); await runWizard({ mosaicHome: tmpDir, sourceDir: tmpDir, prompter, configService: createConfigService(tmpDir, tmpDir), skipGatewayNpmInstall: true, }); const logs = prompter.getLogs(); expect(logs.some((line) => line.includes('Gateway did not become healthy'))).toBe(true); expect(logs.some((line) => line.includes('Installation Summary'))).toBe(false); expect(logs.some((line) => line.includes('Mosaic is ready.'))).toBe(false); expect(gatewayConfigMock).toHaveBeenCalledTimes(1); expect(gatewayBootstrapMock).not.toHaveBeenCalled(); }); it('respects skipGateway: true', async () => { const prompter = new HeadlessPrompter({ 'Installation mode': 'quick', 'What name should agents use?': 'TestBot', 'Communication style': 'direct', 'Your name': 'Tester', 'Your pronouns': 'They/Them', 'Your timezone': 'UTC', }); await runWizard({ mosaicHome: tmpDir, sourceDir: tmpDir, prompter, configService: createConfigService(tmpDir, tmpDir), skipGateway: true, }); expect(gatewayConfigMock).not.toHaveBeenCalled(); expect(gatewayBootstrapMock).not.toHaveBeenCalled(); }); it('does not re-run completed provider or skills menu steps', async () => { const prompter = new SequencedMenuPrompter( { 'What name should agents use?': 'TestBot', 'Communication style': 'direct', 'Your name': 'Tester', 'Your pronouns': 'They/Them', 'Your timezone': 'UTC', }, ['providers', 'providers', 'skills', 'skills', 'finish'], ); await runWizard({ mosaicHome: tmpDir, sourceDir: tmpDir, prompter, configService: createConfigService(tmpDir, tmpDir), skipGateway: true, }); expect(providerSetupMock).toHaveBeenCalledTimes(1); expect(skillsSelectMock).toHaveBeenCalledTimes(1); expect(prompter.getLogs()).toEqual( expect.arrayContaining([ expect.stringContaining('Providers [done] is already complete; skipping.'), expect.stringContaining('Skills [done] is already complete; skipping.'), ]), ); }); });