import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, rmSync, readFileSync, existsSync, readdirSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { TelemetryService } from '@main/services/TelemetryService.js'; describe('TelemetryService.emit', () => { let dir: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'inkling-telem-')); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); it('appends a JSONL line to events-YYYY-MM-DD.jsonl (KST date)', async () => { // 2026-05-01 12:00 UTC → 2026-05-01 21:00 KST const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z')); await svc.emit({ kind: 'capture', payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false } }); const file = join(dir, 'events-2026-05-01.jsonl'); expect(existsSync(file)).toBe(true); const content = readFileSync(file, 'utf8').trim(); const parsed = JSON.parse(content); expect(parsed.kind).toBe('capture'); expect(parsed.payload.noteId).toBe('n1'); expect(typeof parsed.ts).toBe('string'); }); it('uses KST date even when UTC date differs (around midnight)', async () => { // 2026-05-01 23:30 UTC → 2026-05-02 08:30 KST const svc = new TelemetryService(dir, () => new Date('2026-05-01T23:30:00Z')); await svc.emit({ kind: 'capture', payload: { noteId: 'n2', rawTextLength: 1, hasMedia: false } }); expect(existsSync(join(dir, 'events-2026-05-02.jsonl'))).toBe(true); }); it('appends multiple events to same-day file', async () => { const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z')); await svc.emit({ kind: 'capture', payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false } }); await svc.emit({ kind: 'ai_succeeded', payload: { noteId: 'n1', durationMs: 100, attempts: 0 } }); const lines = readFileSync(join(dir, 'events-2026-05-01.jsonl'), 'utf8').trim().split('\n'); expect(lines).toHaveLength(2); expect(JSON.parse(lines[0]!).kind).toBe('capture'); expect(JSON.parse(lines[1]!).kind).toBe('ai_succeeded'); }); it('creates telemetry dir if absent', async () => { const fresh = join(dir, 'nested', 'telem'); const svc = new TelemetryService(fresh, () => new Date('2026-05-01T12:00:00Z')); await svc.emit({ kind: 'capture', payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false } }); expect(existsSync(fresh)).toBe(true); }); it('rejects malformed event (privacy invariant) — does NOT write file', async () => { const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z')); await expect(svc.emit({ kind: 'capture', payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false, rawText: 'leak' } as never })).rejects.toThrow(); // No file should have been created expect(readdirSync(dir).filter((f) => f.startsWith('events-'))).toEqual([]); }); it('emit is silent (does not throw) when fs write fails — invariant: telemetry never breaks app', async () => { // Make the "dir" actually a file so mkdir({recursive:true}) reliably fails on every platform. // (Earlier draft used /proc/0/... which on Windows resolves to C:\proc\0\... and // mkdir({recursive:true}) silently *creates* it, leaking filesystem side-effects + the // silent code path was never exercised.) const blockingFile = join(dir, 'this-is-a-file-not-a-dir'); writeFileSync(blockingFile, ''); const svc = new TelemetryService( blockingFile, () => new Date('2026-05-01T12:00:00Z'), 14, { silent: true } ); await expect(svc.emit({ kind: 'capture', payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false } })).resolves.toBeUndefined(); }); it('emit DOES throw when fs write fails AND silent is not set (default)', async () => { // Companion case — confirms silent is opt-in. Without silent, fs failure surfaces. const blockingFile = join(dir, 'block-default'); writeFileSync(blockingFile, ''); const svc = new TelemetryService(blockingFile, () => new Date('2026-05-01T12:00:00Z')); await expect(svc.emit({ kind: 'capture', payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false } })).rejects.toThrow(); }); }); describe('TelemetryService.cleanupOldFiles', () => { let dir: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'inkling-telem-')); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); it('removes events-*.jsonl older than retentionDays', async () => { // 시드: 오래된 파일 + 최근 파일 writeFileSync(join(dir, 'events-2026-04-01.jsonl'), '{}\n'); // 30일 전 writeFileSync(join(dir, 'events-2026-04-25.jsonl'), '{}\n'); // 6일 전 (retain) writeFileSync(join(dir, 'events-2026-05-01.jsonl'), '{}\n'); // 오늘 (retain) const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14); const r = await svc.cleanupOldFiles(); expect(r.removed).toEqual(['events-2026-04-01.jsonl']); expect(existsSync(join(dir, 'events-2026-04-25.jsonl'))).toBe(true); expect(existsSync(join(dir, 'events-2026-05-01.jsonl'))).toBe(true); }); it('returns empty when no files match prefix', async () => { writeFileSync(join(dir, 'unrelated.txt'), 'x'); const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14); const r = await svc.cleanupOldFiles(); expect(r.removed).toEqual([]); expect(existsSync(join(dir, 'unrelated.txt'))).toBe(true); }); it('handles missing dir gracefully (no throw)', async () => { const ghost = join(dir, 'ghost'); const svc = new TelemetryService(ghost, () => new Date('2026-05-01T12:00:00Z'), 14); const r = await svc.cleanupOldFiles(); expect(r.removed).toEqual([]); }); it('boundary: file exactly retentionDays old is retained', async () => { // 2026-04-17 = 14일 전 (boundary, retain) writeFileSync(join(dir, 'events-2026-04-17.jsonl'), '{}\n'); // 2026-04-16 = 15일 전 (delete) writeFileSync(join(dir, 'events-2026-04-16.jsonl'), '{}\n'); const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14); const r = await svc.cleanupOldFiles(); expect(r.removed).toEqual(['events-2026-04-16.jsonl']); expect(existsSync(join(dir, 'events-2026-04-17.jsonl'))).toBe(true); }); }); describe('TelemetryService.readAllRecent', () => { let dir: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'inkling-telem-')); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); it('reads events from all files within retentionDays', async () => { writeFileSync(join(dir, 'events-2026-04-25.jsonl'), JSON.stringify({ ts: '2026-04-25T00:00:00.000Z', kind: 'capture', payload: { noteId: 'a', rawTextLength: 1, hasMedia: false } }) + '\n'); writeFileSync(join(dir, 'events-2026-05-01.jsonl'), JSON.stringify({ ts: '2026-05-01T00:00:00.000Z', kind: 'capture', payload: { noteId: 'b', rawTextLength: 2, hasMedia: false } }) + '\n' + JSON.stringify({ ts: '2026-05-01T01:00:00.000Z', kind: 'ai_succeeded', payload: { noteId: 'b', durationMs: 100, attempts: 0 } }) + '\n'); const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14); const events = await svc.readAllRecent(); expect(events).toHaveLength(3); expect(events.map((e) => (e.payload as { noteId: string }).noteId)).toEqual(['a', 'b', 'b']); }); it('skips malformed lines (silent — invariant)', async () => { writeFileSync(join(dir, 'events-2026-05-01.jsonl'), 'not-json\n' + JSON.stringify({ ts: '2026-05-01T00:00:00.000Z', kind: 'capture', payload: { noteId: 'a', rawTextLength: 1, hasMedia: false } }) + '\n' + '{}\n'); // valid JSON but invalid event const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14); const events = await svc.readAllRecent(); expect(events).toHaveLength(1); expect((events[0]!.payload as { noteId: string }).noteId).toBe('a'); }); it('returns [] when dir missing', async () => { const ghost = join(dir, 'ghost'); const svc = new TelemetryService(ghost, () => new Date('2026-05-01T12:00:00Z'), 14); const events = await svc.readAllRecent(); expect(events).toEqual([]); }); it('returns [] when dir empty', async () => { const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14); expect(await svc.readAllRecent()).toEqual([]); }); }); describe('TelemetryService.exportTo', () => { let dir: string; let outDir: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'inkling-telem-')); outDir = mkdtempSync(join(tmpdir(), 'inkling-export-')); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); rmSync(outDir, { recursive: true, force: true }); }); it('writes events.jsonl (concat) + stats.md to folder', async () => { writeFileSync(join(dir, 'events-2026-05-01.jsonl'), JSON.stringify({ ts: '2026-05-01T00:00:00.000Z', kind: 'capture', payload: { noteId: 'a', rawTextLength: 1, hasMedia: false } }) + '\n'); const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14); const r = await svc.exportTo(outDir); expect(r.eventCount).toBe(1); expect(existsSync(join(outDir, 'events.jsonl'))).toBe(true); expect(existsSync(join(outDir, 'stats.md'))).toBe(true); const events = readFileSync(join(outDir, 'events.jsonl'), 'utf8').trim().split('\n'); expect(events).toHaveLength(1); const stats = readFileSync(join(outDir, 'stats.md'), 'utf8'); expect(stats).toContain('총 이벤트: 1'); }); it('handles empty input — writes 0-event stats', async () => { const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14); const r = await svc.exportTo(outDir); expect(r.eventCount).toBe(0); expect(readFileSync(join(outDir, 'events.jsonl'), 'utf8')).toBe(''); expect(readFileSync(join(outDir, 'stats.md'), 'utf8')).toContain('총 이벤트: 0'); }); });