sotashimozono_obsidian-remo.../plugin/tests/withTimeout.test.ts
Souta 802f10522c feat(shadow): pre-spawn pull so the shadow window boots on canonical config (#429b)
Phase B/3. Until now the shadow window booted on its STALE local
`.obsidian/` and only pulled the remote config (#342), plugin list
(#434) and binaries (B/2) AFTER Obsidian had already read settings at
startup — so a setting changed on another machine was a session late.

openShadowVaultFor now runs a best-effort, time-boxed pre-spawn pull
between bootstrap and spawn (via a new ShadowVaultManager onBootstrapped
hook). preSpawnPull builds a STANDALONE SftpClient that does NOT patch
the source window's vault adapter (so the user's real vault is never
hijacked), pulls shared config + plugin list + binaries into the shadow
dir, then disconnects. The window then boots on canonical config — no
mid-session reload.

Safety:
- non-interactive by design: the kbd-interactive + host-key callbacks
  REJECT instead of prompting, so a 2FA / unknown-host profile falls
  through to the shadow window (which prompts exactly once) — no double
  prompt, no surprise modal in the source window
- time-boxed (connectTimeoutMs + 8s) via new withTimeout util; a slow
  link falls back to spawn-and-catch-up
- the manager swallows any hook throw, so the spawn NEVER blocks
- disconnects in finally; first connect to an unknown host falls back
  and the next (host now trusted) gets the canonical boot

Tests: withTimeout (4) + ShadowVaultManager hook order/fallback (2).
The actual SSH connect + canonical-boot effect needs a manual Obsidian
smoke (can't run headless) — the fallback path IS unit-tested.

Refs #429 #342

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:15:08 +09:00

31 lines
1.3 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from 'vitest';
import { withTimeout } from '../src/util/withTimeout';
describe('withTimeout', () => {
afterEach(() => { vi.useRealTimers(); });
it('resolves with the value when the promise settles before the timeout', async () => {
await expect(withTimeout(Promise.resolve('ok'), 1000, 'x')).resolves.toBe('ok');
});
it('propagates the promise rejection when it rejects before the timeout', async () => {
await expect(withTimeout(Promise.reject(new Error('boom')), 1000, 'x')).rejects.toThrow('boom');
});
it('rejects with a labelled timeout error when the promise is too slow', async () => {
vi.useFakeTimers();
const slow = new Promise<string>(() => { /* never settles */ });
const p = withTimeout(slow, 5000, 'pre-spawn pull');
const assertion = expect(p).rejects.toThrow('pre-spawn pull timed out after 5000ms');
await vi.advanceTimersByTimeAsync(5000);
await assertion;
});
it('clears the timer on a fast resolve so a later tick raises no timeout', async () => {
vi.useFakeTimers();
const p = withTimeout(Promise.resolve('fast'), 5000, 'x');
await expect(p).resolves.toBe('fast');
// Past the budget the cleared timer must not fire a stray rejection.
await vi.advanceTimersByTimeAsync(10_000);
});
});