- EmitInput union 13 → 15 - narrowing guards (noteId 없는 kind 분기) 에 tag_vocab_hit/miss 추가 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
217 lines
11 KiB
TypeScript
217 lines
11 KiB
TypeScript
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);
|
|
// discriminant narrowing — noteId 없는 kind(empty_trash/expired_banner_shown/expired_batch_trash) 가 섞이면 명시적으로 실패
|
|
expect(events.map((e) =>
|
|
(e.kind === 'empty_trash' || e.kind === 'expired_banner_shown' || e.kind === 'expired_batch_trash' || e.kind === 'ollama_unreachable' || e.kind === 'ollama_recovered' || e.kind === 'ollama_recheck_manual' || e.kind === 'ai_retry_manual' || e.kind === 'tag_vocab_hit' || e.kind === 'tag_vocab_miss')
|
|
? null
|
|
: e.payload.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);
|
|
const ev = events[0]!;
|
|
expect(ev.kind).toBe('capture');
|
|
if (ev.kind !== 'empty_trash' && ev.kind !== 'expired_banner_shown' && ev.kind !== 'expired_batch_trash' && ev.kind !== 'ollama_unreachable' && ev.kind !== 'ollama_recovered' && ev.kind !== 'ollama_recheck_manual' && ev.kind !== 'ai_retry_manual' && ev.kind !== 'tag_vocab_hit' && ev.kind !== 'tag_vocab_miss') expect(ev.payload.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');
|
|
});
|
|
});
|