427 lines
14 KiB
TypeScript
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'];
|
|
});
|
|
});
|