sotashimozono_obsidian-remo.../plugin/tests/ReconnectManager.test.ts
sotashimozono bb5e0f5f0d
test: AuthResolver / ConflictResolver / ReconnectManager branch coverage (#261)
* test: AuthResolver / ConflictResolver / ReconnectManager branch coverage

Add tests for three modules not touched by PR #257:

tests/AuthResolver.test.ts (new):
  - storeSecret / getSecret / persistSecret / clearSecrets session-map API
  - buildAuthConfig for password, privateKey (+ passphrase / ENOENT / ~/ expand),
    agent (agentSocket / SSH_AUTH_SOCK fallback / missing socket), unknown method

tests/ConflictResolver.test.ts (new):
  - keep-theirs decision (lines 74-78 previously uncovered)
  - cancel decision (line 84)
  - re-read failure fallback (line ~50)
  - swapClient(), two-choice fallback, three-way merge paths

tests/ReconnectManager.test.ts (extended, 2 new tests):
  - onState throws → loop continues (transition() catch block)
  - cancel race: timer fires after cancel() sets cancelled flag (lines 133-134)

vitest.config.ts: raise thresholds to lines:76 / branches:70 / functions:71

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fixup! test(AuthResolver/ReconnectManager): cross-platform paths + microtask comment

AuthResolver.test.ts (review: HIGH — POSIX path convention):
* Replace /home/alice/.ssh/id_ed25519 with ~/.ssh/id_ed25519 — also
  exercises expandHome, and avoids hardcoding a user-home path
* Replace /nonexistent/key with nonexistent-key (readFileSync is mocked,
  path value is irrelevant but should not imply POSIX-only)
* Replace /run/user/1000/ssh-agent.socket with test-ssh-agent.socket
* Replace /tmp/ssh-auth.sock with test-ssh-auth.sock

ReconnectManager.test.ts (review: advisory — fragile assumption):
* Document the one-microtask assumption in the race test so future
  refactors that add an await before sleep() know to update the test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:21:22 +09:00

279 lines
9.3 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { ReconnectManager, type ReconnectState } from '../src/transport/ReconnectManager';
/**
* Small fake scheduler that runs callbacks synchronously, in the order
* they were registered, without any real waiting. The manager is
* driven entirely off this scheduler so tests don't sleep.
*/
function makeImmediateScheduler() {
type Job = { id: number; cb: () => void; ms: number };
const queue: Job[] = [];
let nextId = 1;
return {
setTimeoutFn: (cb: () => void, ms: number) => {
const id = nextId++;
queue.push({ id, cb, ms });
// Fire on next microtask so the manager has a chance to await
// the returned Promise from sleep().
queueMicrotask(() => {
const idx = queue.findIndex(j => j.id === id);
if (idx >= 0) {
const [{ cb: fired }] = queue.splice(idx, 1);
fired();
}
});
return id;
},
clearTimeoutFn: (handle: unknown) => {
const idx = queue.findIndex(j => j.id === handle);
if (idx >= 0) queue.splice(idx, 1);
},
};
}
const cfgFast = {
initialMs: 1,
multiplier: 2,
maxMs: 16,
jitterPct: 0,
maxRetries: 3,
};
describe('ReconnectManager', () => {
it('recovers on the first successful attempt', async () => {
const states: ReconnectState[] = [];
let calls = 0;
const m = new ReconnectManager({
attempt: async () => { calls++; },
onState: (s) => { states.push(s); },
backoff: cfgFast,
...makeImmediateScheduler(),
});
await m.run();
expect(calls).toBe(1);
expect(states.map(s => s.kind)).toEqual(['waiting', 'attempting', 'recovered']);
});
it('retries on failure until success', async () => {
let calls = 0;
const m = new ReconnectManager({
attempt: async () => {
calls++;
if (calls < 3) throw new Error('transient');
},
onState: () => { /* noop */ },
backoff: cfgFast,
...makeImmediateScheduler(),
});
const final = await m.run();
expect(calls).toBe(3);
expect(final.kind).toBe('recovered');
});
it('gives up after maxRetries', async () => {
let calls = 0;
const m = new ReconnectManager({
attempt: async () => { calls++; throw new Error('always'); },
onState: () => { /* noop */ },
backoff: cfgFast,
...makeImmediateScheduler(),
});
const final = await m.run();
expect(calls).toBe(cfgFast.maxRetries);
expect(final.kind).toBe('failed');
if (final.kind === 'failed') {
expect(final.reason).toMatch(/3 attempts/);
}
});
it('cancel during waiting aborts before the next attempt', async () => {
let calls = 0;
let resolveAttempt!: () => void;
const m = new ReconnectManager({
attempt: () => new Promise<void>(res => {
calls++;
resolveAttempt = res;
}),
onState: () => { /* noop */ },
backoff: { ...cfgFast, maxRetries: 5 },
// Manual scheduler so we can land cancel during a sleep.
setTimeoutFn: (cb, _ms) => {
// Fire on a longer microtask delay to leave a window for cancel.
queueMicrotask(() => queueMicrotask(cb));
return 0;
},
clearTimeoutFn: () => { /* noop */ },
});
const runPromise = m.run();
// Cancel before the first sleep callback fires.
m.cancel();
// Drain microtasks so the run loop observes the cancel.
await Promise.resolve();
await Promise.resolve();
// If the attempt did fire (race), close it out so the loop can
// observe cancelled.
if (resolveAttempt) resolveAttempt();
const final = await runPromise;
// Either we cancelled before the first attempt (calls === 0) or
// mid-flight before the second (calls === 1). Both terminate as
// 'cancelled'; we don't pin which.
expect(['cancelled', 'recovered']).toContain(final.kind);
expect(calls).toBeLessThanOrEqual(1);
});
it('isActive returns true between waiting and recovery', async () => {
let observedActive = false;
const sched = makeImmediateScheduler();
let m!: ReconnectManager;
m = new ReconnectManager({
attempt: async () => { observedActive = m.isActive(); },
onState: () => { /* noop */ },
backoff: cfgFast,
...sched,
});
await m.run();
expect(observedActive).toBe(true);
expect(m.isActive()).toBe(false);
});
it('starting a second run while one is active is a no-op', async () => {
let calls = 0;
let releaseFirst!: () => void;
const sched = makeImmediateScheduler();
const m = new ReconnectManager({
attempt: () => new Promise<void>(res => {
calls++;
releaseFirst = res;
}),
onState: () => { /* noop */ },
backoff: cfgFast,
...sched,
});
const first = m.run();
// Yield so the scheduler ticks into the attempt callback.
await Promise.resolve();
await Promise.resolve();
// While the first attempt is in flight, a second run() should
// observe isActive() === true and skip.
const second = await m.run();
expect(second.kind).toBe('attempting');
releaseFirst();
await first;
expect(calls).toBe(1);
});
it('forwards the configured maxRetries through state events', async () => {
const states: ReconnectState[] = [];
const m = new ReconnectManager({
attempt: async () => { throw new Error('always'); },
onState: (s) => { states.push(s); },
backoff: { ...cfgFast, maxRetries: 2 },
...makeImmediateScheduler(),
});
await m.run();
const totals = states
.map(s => 'totalAttempts' in s ? s.totalAttempts : null)
.filter((v): v is number => v !== null);
expect(totals.every(t => t === 2)).toBe(true);
});
it('emits delayMs values following exponential schedule', async () => {
const delays: number[] = [];
const m = new ReconnectManager({
attempt: async () => { throw new Error('always'); },
onState: (s) => {
if (s.kind === 'waiting') delays.push(s.delayMs);
},
backoff: { initialMs: 10, multiplier: 3, maxMs: 100, jitterPct: 0, maxRetries: 4 },
...makeImmediateScheduler(),
});
await m.run();
expect(delays[0]).toBe(10);
expect(delays[1]).toBe(30);
expect(delays[2]).toBe(90);
expect(delays[3]).toBe(100);
});
it('maxRetries:1 calls attempt exactly once then fails', async () => {
let calls = 0;
const m = new ReconnectManager({
attempt: async () => { calls++; throw new Error('boom'); },
onState: () => {},
backoff: { ...cfgFast, maxRetries: 1 },
...makeImmediateScheduler(),
});
const final = await m.run();
expect(calls).toBe(1);
expect(final.kind).toBe('failed');
});
it('cancel() immediately after construction leaves state idle', () => {
const m = new ReconnectManager({
attempt: async () => {},
onState: () => {},
backoff: cfgFast,
...makeImmediateScheduler(),
});
m.cancel();
expect(m.state().kind).toBe('idle');
expect(m.isActive()).toBe(false);
});
it('state() reflects the latest transition seen by onState', async () => {
let lastOnState: ReconnectState | undefined;
const m = new ReconnectManager({
attempt: async () => { throw new Error('fail'); },
onState: (s) => { lastOnState = s; },
backoff: { ...cfgFast, maxRetries: 1 },
...makeImmediateScheduler(),
});
await m.run();
expect(m.state().kind).toBe('failed');
expect(lastOnState?.kind).toBe('failed');
});
it('continues the loop when onState throws instead of crashing', async () => {
let calls = 0;
const m = new ReconnectManager({
attempt: async () => { calls++; throw new Error('fail'); },
onState: (s) => {
if (s.kind === 'waiting') throw new Error('onState exploded');
},
backoff: { ...cfgFast, maxRetries: 1 },
...makeImmediateScheduler(),
});
const final = await m.run();
expect(calls).toBe(1);
expect(final.kind).toBe('failed');
});
it('transitions to cancelled when the sleep timer fires after cancel() was called', async () => {
// Use a clearTimeoutFn that does NOT suppress the pending callback —
// simulating the race where the JS timer event fires between cancel()
// setting this.cancelled and clearTimeout actually removing the event.
let pendingCb: (() => void) | null = null;
const m = new ReconnectManager({
attempt: async () => { throw new Error('always'); },
onState: () => {},
backoff: { ...cfgFast, maxRetries: 3 },
setTimeoutFn: (cb) => { pendingCb = cb; return 1; },
clearTimeoutFn: () => { /* intentionally no-op — simulates late timer */ },
});
const runPromise = m.run();
// One microtask tick is sufficient: run() reaches sleep() without any
// intermediate awaits (all work before the first sleep() call is
// synchronous). If an await is ever added before sleep() in a future
// refactor, this one-tick assumption must be revisited.
await Promise.resolve();
m.cancel();
expect(pendingCb).not.toBeNull();
pendingCb!();
const final = await runPromise;
expect(final.kind).toBe('cancelled');
});
});