sotashimozono_obsidian-remo.../plugin/tests/ShadowVaultManager.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

108 lines
4.3 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { ShadowVaultManager } from '../src/shadow/ShadowVaultManager';
import type { ShadowVaultBootstrap, BootstrapResult } from '../src/shadow/ShadowVaultBootstrap';
import type { WindowSpawner } from '../src/shadow/WindowSpawner';
import type { SshProfile } from '../src/types';
function makeProfile(id: string, name = id): SshProfile {
return {
id, name,
host: 'h', port: 22, username: 'u', authMethod: 'privateKey',
remotePath: '~/v/', privateKeyPath: '/dev/null',
connectTimeoutMs: 5000, keepaliveIntervalMs: 10000, keepaliveCountMax: 3,
} as SshProfile;
}
function makeResult(): BootstrapResult {
return {
layout: {
vaultDir: '/tmp/v', configDir: '/tmp/v/.obsidian',
pluginDir: '/tmp/v/.obsidian/plugins/remote-ssh',
pluginDataFile: '/tmp/v/.obsidian/plugins/remote-ssh/data.json',
},
registryId: 'abc', registryCreated: false, pluginInstallMethod: 'symlink',
};
}
describe('ShadowVaultManager.openShadowFor', () => {
it('runs bootstrap then spawn, in that order, with the right args', async () => {
const order: string[] = [];
const fakeResult: BootstrapResult = {
layout: {
vaultDir: '/tmp/v', configDir: '/tmp/v/.obsidian',
pluginDir: '/tmp/v/.obsidian/plugins/remote-ssh',
pluginDataFile: '/tmp/v/.obsidian/plugins/remote-ssh/data.json',
},
registryId: 'abc', registryCreated: true, pluginInstallMethod: 'symlink',
};
const bootstrap = {
bootstrap: vi.fn(async (..._args: unknown[]) => { order.push('bootstrap'); return fakeResult; }),
} as unknown as ShadowVaultBootstrap;
const spawner = {
spawn: vi.fn((..._args: unknown[]) => { order.push('spawn'); return ''; }),
} as unknown as WindowSpawner;
const profile = makeProfile('p1');
const all = [profile, makeProfile('p2')];
const result = await new ShadowVaultManager(bootstrap, spawner).openShadowFor(profile, all);
expect(order).toEqual(['bootstrap', 'spawn']);
expect(bootstrap.bootstrap).toHaveBeenCalledWith(profile, all);
expect(spawner.spawn).toHaveBeenCalledWith('/tmp/v');
expect(result).toBe(fakeResult);
});
it('does NOT spawn if bootstrap throws', async () => {
const bootstrap = {
bootstrap: vi.fn(async () => { throw new Error('disk full'); }),
} as unknown as ShadowVaultBootstrap;
const spawner = {
spawn: vi.fn(() => ''),
} as unknown as WindowSpawner;
const profile = makeProfile('p1');
await expect(
new ShadowVaultManager(bootstrap, spawner).openShadowFor(profile, [profile]),
).rejects.toThrow(/disk full/);
expect(spawner.spawn).not.toHaveBeenCalled();
});
it('runs onBootstrapped AFTER bootstrap and BEFORE spawn, with the bootstrap result', async () => {
const order: string[] = [];
const fakeResult = makeResult();
const bootstrap = {
bootstrap: vi.fn(async () => { order.push('bootstrap'); return fakeResult; }),
} as unknown as ShadowVaultBootstrap;
const spawner = {
spawn: vi.fn(() => { order.push('spawn'); return ''; }),
} as unknown as WindowSpawner;
let seen: BootstrapResult | null = null;
const hook = vi.fn(async (r: BootstrapResult) => { order.push('hook'); seen = r; });
const profile = makeProfile('p1');
await new ShadowVaultManager(bootstrap, spawner).openShadowFor(profile, [profile], hook);
expect(order, 'hook runs between bootstrap and spawn').toEqual(['bootstrap', 'hook', 'spawn']);
expect(seen).toBe(fakeResult);
});
it('still spawns when onBootstrapped throws (graceful fallback — never blocks the window)', async () => {
const fakeResult = makeResult();
const bootstrap = {
bootstrap: vi.fn(async () => fakeResult),
} as unknown as ShadowVaultBootstrap;
const spawner = {
spawn: vi.fn(() => ''),
} as unknown as WindowSpawner;
const hook = vi.fn(async () => { throw new Error('pre-spawn pull failed'); });
const profile = makeProfile('p1');
const result = await new ShadowVaultManager(bootstrap, spawner)
.openShadowFor(profile, [profile], hook);
expect(hook).toHaveBeenCalledOnce();
expect(spawner.spawn, 'a pre-spawn hook failure must NOT block the spawn')
.toHaveBeenCalledWith('/tmp/v');
expect(result).toBe(fakeResult);
});
});