Files
stack/packages/mosaic/src/commands/telemetry.spec.ts
jason.woltje a531029c5b
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
feat(mosaic): mosaic telemetry command (M6 CU-06-01..05) (#417)
2026-04-05 07:06:42 +00:00

427 lines
14 KiB
TypeScript

/**
* 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<typeof vi.spyOn>;
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<typeof vi.spyOn>;
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<typeof vi.spyOn>;
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<typeof vi.spyOn>;
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<typeof vi.spyOn>;
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<typeof vi.spyOn>;
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<typeof vi.spyOn>;
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'];
});
});