sotashimozono_obsidian-remo.../plugin/tests/Telemetry.test.ts
Souta e76c4c38c1 fix: address PR-223 review comments
Medium fixes:
- M1: Telemetry.flush() no longer clears the in-memory counters
  before the appendFile resolves. A write failure (disk full,
  permissions) used to silently lose the buffered deltas; now it
  leaves them intact so the next flush retries. New unit test
  forces EISDIR via a directory-as-path and asserts counts survive.
- M2: Extract telemetryLogPath(basePath, configDir, manifestId)
  helper. main.ts (onload) and SettingsTab.ts (toggle handler)
  both call it instead of duplicating the path.join. Single source
  of truth + new unit test.

Low fixes:
- L1: setEnabled JSDoc clarifies the idempotency: a second
  setEnabled(true, newPath) ignores newPath. To re-point the log,
  disable then re-enable.
- L2: snapshot() JSDoc clarifies the at field — it's snapshot
  time, not original event time, since deltas don't track per-
  event timestamps.

L3 (fire-and-forget on unload) and L4 (timer: number type) accepted
as documented design.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:58:14 +09:00

196 lines
6.7 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { Telemetry, telemetryLogPath } from '../src/util/Telemetry';
describe('Telemetry', () => {
let tmpDir: string;
let logPath: string;
let t: Telemetry;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'remote-ssh-telemetry-'));
logPath = path.join(tmpDir, 'telemetry.jsonl');
t = new Telemetry();
});
afterEach(async () => {
await t.setEnabled(false);
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
});
describe('disabled by default', () => {
it('isEnabled() returns false on a fresh instance', () => {
expect(t.isEnabled()).toBe(false);
});
it('record* are no-ops while disabled — snapshot stays empty', () => {
t.recordError('timeout', -32000);
t.recordReconnect('failed');
expect(t.snapshot()).toHaveLength(0);
});
it('flush() while disabled is a no-op (does not create the file)', async () => {
await t.flush();
expect(fs.existsSync(logPath)).toBe(false);
});
});
describe('setEnabled', () => {
it('throws when enabled=true without a logPath', async () => {
await expect(t.setEnabled(true)).rejects.toThrow(/requires a logPath/);
});
it('flips isEnabled() and accepts subsequent records', async () => {
await t.setEnabled(true, logPath);
expect(t.isEnabled()).toBe(true);
t.recordError('timeout', -32000);
expect(t.snapshot()).toHaveLength(1);
});
it('disabling flushes any buffered counters', async () => {
await t.setEnabled(true, logPath);
t.recordError('auth');
t.recordError('auth');
await t.setEnabled(false);
const written = fs.readFileSync(logPath, 'utf8').trim().split('\n');
expect(written).toHaveLength(1);
const rec = JSON.parse(written[0]);
expect(rec).toMatchObject({ kind: 'error', category: 'auth', count: 2 });
expect(typeof rec.at).toBe('number');
});
it('repeated setEnabled(true) is idempotent (no double timer)', async () => {
await t.setEnabled(true, logPath);
await t.setEnabled(true, logPath);
expect(t.isEnabled()).toBe(true);
});
});
describe('counters and snapshot', () => {
beforeEach(async () => { await t.setEnabled(true, logPath); });
it('groups same category+code into a single counter with bumped count', () => {
t.recordError('timeout', -32000);
t.recordError('timeout', -32000);
t.recordError('timeout', -32000);
const snap = t.snapshot();
expect(snap).toHaveLength(1);
expect(snap[0]).toMatchObject({ kind: 'error', category: 'timeout', code: -32000, count: 3 });
});
it('keeps distinct (category, code) tuples separate', () => {
t.recordError('timeout', -32000);
t.recordError('timeout', -32001);
t.recordError('auth');
const keys = t.snapshot().map(r => `${r.kind}:${r.category}:${r.code ?? ''}`).sort();
expect(keys).toEqual([
'error:auth:',
'error:timeout:-32000',
'error:timeout:-32001',
]);
});
it('tracks reconnect kinds independently from errors', () => {
t.recordError('timeout', -32000);
t.recordReconnect('attempting');
t.recordReconnect('attempting');
t.recordReconnect('recovered');
const snap = t.snapshot();
expect(snap.find(r => r.kind === 'reconnect' && r.state === 'attempting')?.count).toBe(2);
expect(snap.find(r => r.kind === 'reconnect' && r.state === 'recovered')?.count).toBe(1);
});
it('handles record without code (undefined → omitted)', () => {
t.recordError('auth');
const snap = t.snapshot();
expect(snap[0].kind).toBe('error');
expect(snap[0].category).toBe('auth');
expect(snap[0].code).toBeUndefined();
});
it('preserves non-numeric codes as strings', () => {
t.recordError('protocol', 'EBADRESP');
expect(t.snapshot()[0].code).toBe('EBADRESP');
});
});
describe('flush', () => {
beforeEach(async () => { await t.setEnabled(true, logPath); });
it('appends one JSONL line per counter and clears in-memory state', async () => {
t.recordError('timeout', -32000);
t.recordError('auth');
t.recordReconnect('failed');
await t.flush();
expect(t.snapshot()).toHaveLength(0);
const lines = fs.readFileSync(logPath, 'utf8').trim().split('\n');
expect(lines).toHaveLength(3);
for (const line of lines) {
const rec = JSON.parse(line);
expect(rec).toHaveProperty('at');
expect(rec).toHaveProperty('kind');
expect(rec).toHaveProperty('count');
expect(typeof rec.at).toBe('number');
}
});
it('successive flushes append rather than overwrite', async () => {
t.recordError('timeout', -32000);
await t.flush();
t.recordError('auth');
await t.flush();
const lines = fs.readFileSync(logPath, 'utf8').trim().split('\n');
expect(lines).toHaveLength(2);
expect(JSON.parse(lines[0]).category).toBe('timeout');
expect(JSON.parse(lines[1]).category).toBe('auth');
});
it('flush with no counters is a no-op (file not created)', async () => {
await t.flush();
expect(fs.existsSync(logPath)).toBe(false);
});
it('preserves counters when appendFile fails (M1: no data loss)', async () => {
// Force a write failure by pointing logPath at a directory.
// Node will reject appendFile with EISDIR — the catch in
// flush() should leave counters intact for the next attempt.
const dirAsPath = path.join(tmpDir, 'dir-not-file');
fs.mkdirSync(dirAsPath);
const t2 = new Telemetry();
await t2.setEnabled(true, dirAsPath);
t2.recordError('timeout', -32000);
t2.recordError('timeout', -32000);
await t2.flush();
// Counters should survive the failed write.
const snap = t2.snapshot();
expect(snap).toHaveLength(1);
expect(snap[0].count).toBe(2);
await t2.setEnabled(false);
});
});
describe('telemetryLogPath helper', () => {
it('joins basePath / configDir / plugins / manifestId / telemetry.jsonl', () => {
const p = telemetryLogPath('/vault', '.obsidian', 'remote-ssh');
// Use path.sep so the test is platform-independent.
expect(p).toBe(path.join('/vault', '.obsidian', 'plugins', 'remote-ssh', 'telemetry.jsonl'));
});
});
describe('reset', () => {
it('drops in-memory counters without touching the file', async () => {
await t.setEnabled(true, logPath);
t.recordError('timeout', -32000);
t.reset();
expect(t.snapshot()).toHaveLength(0);
expect(fs.existsSync(logPath)).toBe(false);
});
});
});