/** * CU-06-05 — Vitest tests for mosaic telemetry command */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { Command } from 'commander'; import { registerTelemetryCommand } from './telemetry.js'; import type { TelemetryConsent } from '../telemetry/consent-store.js'; // ─── module mocks ───────────────────────────────────────────────────────────── // Mock consent-store so tests don't touch the filesystem. const mockConsent: TelemetryConsent = { remoteEnabled: false, optedInAt: null, optedOutAt: null, lastUploadAt: null, }; vi.mock('../telemetry/consent-store.js', () => ({ readConsent: vi.fn(() => ({ ...mockConsent })), writeConsent: vi.fn(), optIn: vi.fn(() => ({ ...mockConsent, remoteEnabled: true, optedInAt: '2026-01-01T00:00:00.000Z', })), optOut: vi.fn(() => ({ ...mockConsent, remoteEnabled: false, optedOutAt: '2026-01-01T00:00:00.000Z', })), recordUpload: vi.fn(), })); // Mock the telemetry client shim. const mockClientInstance = { init: vi.fn(), captureEvent: vi.fn(), upload: vi.fn().mockResolvedValue(undefined), shutdown: vi.fn().mockResolvedValue(undefined), }; vi.mock('../telemetry/client-shim.js', () => ({ getTelemetryClient: vi.fn(() => mockClientInstance), setTelemetryClient: vi.fn(), resetTelemetryClient: vi.fn(), })); // Mock @clack/prompts so tests don't require stdin. vi.mock('@clack/prompts', () => ({ confirm: vi.fn().mockResolvedValue(true), intro: vi.fn(), outro: vi.fn(), isCancel: vi.fn().mockReturnValue(false), cancel: vi.fn(), })); // ─── helpers ────────────────────────────────────────────────────────────────── function buildProgram(): Command { const program = new Command(); program.exitOverride(); registerTelemetryCommand(program); return program; } function getTelemetryCmd(program: Command): Command { const found = program.commands.find((c) => c.name() === 'telemetry'); if (!found) throw new Error('telemetry command not found'); return found; } function getLocalCmd(telemetryCmd: Command): Command { const found = telemetryCmd.commands.find((c) => c.name() === 'local'); if (!found) throw new Error('local subcommand not found'); return found; } // ─── CU-06-05 a: command structure smoke test ───────────────────────────────── describe('registerTelemetryCommand — structure', () => { it('registers a "telemetry" command on the program', () => { const program = buildProgram(); const names = program.commands.map((c) => c.name()); expect(names).toContain('telemetry'); }); it('registers the expected top-level subcommands', () => { const program = buildProgram(); const tel = getTelemetryCmd(program); const subs = tel.commands.map((c) => c.name()).sort(); expect(subs).toEqual(['local', 'opt-in', 'opt-out', 'status', 'test', 'upload']); }); it('registers all three local subcommands', () => { const program = buildProgram(); const local = getLocalCmd(getTelemetryCmd(program)); const subs = local.commands.map((c) => c.name()).sort(); expect(subs).toEqual(['jaeger', 'status', 'tail']); }); }); // ─── CU-06-05 b: opt-in / opt-out ──────────────────────────────────────────── describe('telemetry opt-in', () => { let consoleSpy: ReturnType; beforeEach(async () => { vi.clearAllMocks(); consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); // Provide disabled consent so opt-in path is taken. const store = await import('../telemetry/consent-store.js'); vi.mocked(store.readConsent).mockReturnValue({ remoteEnabled: false, optedInAt: null, optedOutAt: null, lastUploadAt: null, }); vi.mocked(store.optIn).mockReturnValue({ remoteEnabled: true, optedInAt: '2026-01-01T00:00:00.000Z', optedOutAt: null, lastUploadAt: null, }); const clack = await import('@clack/prompts'); vi.mocked(clack.confirm).mockResolvedValue(true); vi.mocked(clack.isCancel).mockReturnValue(false); }); afterEach(() => { consoleSpy.mockRestore(); }); it('calls optIn() when user confirms', async () => { const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'telemetry', 'opt-in']); const store = await import('../telemetry/consent-store.js'); expect(vi.mocked(store.optIn)).toHaveBeenCalled(); }); it('does not call optIn() when user cancels', async () => { const clack = await import('@clack/prompts'); vi.mocked(clack.confirm).mockResolvedValue(false); const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'telemetry', 'opt-in']); const store = await import('../telemetry/consent-store.js'); expect(vi.mocked(store.optIn)).not.toHaveBeenCalled(); }); }); describe('telemetry opt-out', () => { let consoleSpy: ReturnType; beforeEach(async () => { vi.clearAllMocks(); consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); const store = await import('../telemetry/consent-store.js'); vi.mocked(store.readConsent).mockReturnValue({ remoteEnabled: true, optedInAt: '2026-01-01T00:00:00.000Z', optedOutAt: null, lastUploadAt: null, }); vi.mocked(store.optOut).mockReturnValue({ remoteEnabled: false, optedInAt: '2026-01-01T00:00:00.000Z', optedOutAt: '2026-02-01T00:00:00.000Z', lastUploadAt: null, }); const clack = await import('@clack/prompts'); vi.mocked(clack.confirm).mockResolvedValue(true); vi.mocked(clack.isCancel).mockReturnValue(false); }); afterEach(() => { consoleSpy.mockRestore(); }); it('calls optOut() when user confirms', async () => { const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'telemetry', 'opt-out']); const store = await import('../telemetry/consent-store.js'); expect(vi.mocked(store.optOut)).toHaveBeenCalled(); }); it('does not call optOut() when already disabled', async () => { const store = await import('../telemetry/consent-store.js'); vi.mocked(store.readConsent).mockReturnValue({ remoteEnabled: false, optedInAt: null, optedOutAt: null, lastUploadAt: null, }); const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'telemetry', 'opt-out']); expect(vi.mocked(store.optOut)).not.toHaveBeenCalled(); }); }); // ─── CU-06-05 c: status ────────────────────────────────────────────────────── describe('telemetry status', () => { let consoleSpy: ReturnType; beforeEach(() => { vi.clearAllMocks(); consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); }); afterEach(() => { consoleSpy.mockRestore(); }); it('shows disabled state when remote upload is off', async () => { const store = await import('../telemetry/consent-store.js'); vi.mocked(store.readConsent).mockReturnValue({ remoteEnabled: false, optedInAt: null, optedOutAt: null, lastUploadAt: null, }); const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'telemetry', 'status']); const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n'); expect(output).toContain('false'); expect(output).toContain('(never)'); }); it('shows enabled state and timestamps when opted in', async () => { const store = await import('../telemetry/consent-store.js'); vi.mocked(store.readConsent).mockReturnValue({ remoteEnabled: true, optedInAt: '2026-01-01T00:00:00.000Z', optedOutAt: null, lastUploadAt: '2026-03-01T00:00:00.000Z', }); const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'telemetry', 'status']); const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n'); expect(output).toContain('true'); expect(output).toContain('2026-01-01'); expect(output).toContain('2026-03-01'); }); it('shows dry-run banner when MOSAIC_TELEMETRY_DRY_RUN=1', async () => { process.env['MOSAIC_TELEMETRY_DRY_RUN'] = '1'; const store = await import('../telemetry/consent-store.js'); vi.mocked(store.readConsent).mockReturnValue({ remoteEnabled: false, optedInAt: null, optedOutAt: null, lastUploadAt: null, }); const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'telemetry', 'status']); const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n'); expect(output).toContain('[dry-run]'); delete process.env['MOSAIC_TELEMETRY_DRY_RUN']; }); }); // ─── CU-06-05 d: test / upload — dry-run assertions ────────────────────────── describe('telemetry test (dry-run)', () => { let consoleSpy: ReturnType; beforeEach(() => { vi.clearAllMocks(); consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); }); afterEach(() => { consoleSpy.mockRestore(); }); it('prints dry-run banner and does not call upload()', async () => { const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'telemetry', 'test']); const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n'); expect(output).toContain('[dry-run]'); expect(mockClientInstance.upload).not.toHaveBeenCalled(); }); it('calls captureEvent() with a mosaic.cli.test event', async () => { const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'telemetry', 'test']); expect(mockClientInstance.captureEvent).toHaveBeenCalledWith( expect.objectContaining({ name: 'mosaic.cli.test' }), ); }); it('does not make network calls in dry-run mode', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response()); const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'telemetry', 'test']); expect(fetchSpy).not.toHaveBeenCalled(); fetchSpy.mockRestore(); }); }); describe('telemetry upload (dry-run default)', () => { let consoleSpy: ReturnType; beforeEach(async () => { vi.clearAllMocks(); consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); // Remote disabled by default. const store = await import('../telemetry/consent-store.js'); vi.mocked(store.readConsent).mockReturnValue({ remoteEnabled: false, optedInAt: null, optedOutAt: null, lastUploadAt: null, }); }); afterEach(() => { consoleSpy.mockRestore(); delete process.env['MOSAIC_TELEMETRY_DRY_RUN']; delete process.env['MOSAIC_TELEMETRY_ENDPOINT']; }); it('prints dry-run banner when remote upload is disabled', async () => { const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'telemetry', 'upload']); const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n'); expect(output).toContain('[dry-run]'); expect(mockClientInstance.upload).not.toHaveBeenCalled(); }); it('prints dry-run banner when MOSAIC_TELEMETRY_DRY_RUN=1 even if opted in', async () => { process.env['MOSAIC_TELEMETRY_DRY_RUN'] = '1'; process.env['MOSAIC_TELEMETRY_ENDPOINT'] = 'http://example.com/telemetry'; const store = await import('../telemetry/consent-store.js'); vi.mocked(store.readConsent).mockReturnValue({ remoteEnabled: true, optedInAt: '2026-01-01T00:00:00.000Z', optedOutAt: null, lastUploadAt: null, }); const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'telemetry', 'upload']); const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n'); expect(output).toContain('[dry-run]'); expect(mockClientInstance.upload).not.toHaveBeenCalled(); }); }); // ─── local subcommand smoke tests ───────────────────────────────────────────── describe('telemetry local tail', () => { let consoleSpy: ReturnType; beforeEach(() => { vi.clearAllMocks(); consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); }); afterEach(() => { consoleSpy.mockRestore(); }); it('prints Jaeger UI URL and docker compose hint', async () => { const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'telemetry', 'local', 'tail']); const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n'); expect(output).toContain('Jaeger'); expect(output).toContain('docker compose'); }); }); describe('telemetry local jaeger', () => { let consoleSpy: ReturnType; beforeEach(() => { vi.clearAllMocks(); consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); delete process.env['JAEGER_UI_URL']; }); afterEach(() => { consoleSpy.mockRestore(); }); it('prints the default Jaeger URL', async () => { const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'telemetry', 'local', 'jaeger']); expect(consoleSpy).toHaveBeenCalledWith('http://localhost:16686'); }); it('respects JAEGER_UI_URL env var', async () => { process.env['JAEGER_UI_URL'] = 'http://jaeger.example.com:16686'; const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'telemetry', 'local', 'jaeger']); expect(consoleSpy).toHaveBeenCalledWith('http://jaeger.example.com:16686'); delete process.env['JAEGER_UI_URL']; }); });