mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 17:10:32 +00:00
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>
196 lines
6.7 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|