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