sotashimozono_obsidian-remo.../plugin/tests/openShadowVaultFor.test.ts
Souta 94d7321813 fix(connect): address #352 review — failed-spawn lockout + double Notice
- CRITICAL: openShadowVaultFor armed the 15s guard-clear timer in a
  `finally`, so a FAILED spawn locked retry for 15s behind a stale
  "still opening" toast (the exact "can't reconnect" symptom). Now
  asymmetric: success arms the 15s debounce; failure clears the guard
  synchronously → instant retry.
- IMPORTANT: a failed shadow-window auto-connect showed connectProfile's
  classified, cause-specific Notice AND runAutoConnect's generic one in
  the SAME window — the second stacked on and obscured the first. Drop
  the generic Notice; keep the log line the e2e oracle asserts on.
- Correct the shadowSpawnInFlight JSDoc to describe the (now) asymmetric
  semantics instead of "held after each spawn".
- Add tests/openShadowVaultFor.test.ts: locks the failed-spawn
  instant-clear, the success 15s hold, and the in-flight re-entrancy
  rejection (the C2 guard the suite never exercised).
- Consume the shared e2e/helpers/sshd.ts (drops the triplicated copy).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 01:47:03 +09:00

117 lines
4.2 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
// Control ShadowVaultManager.openShadowFor (success vs failure) and
// stub the other three collaborators openShadowVaultFor constructs so
// the re-entrancy STATE MACHINE is exercised in isolation — no real
// disk bootstrap, registry, or window spawn.
const openShadowForMock = vi.fn();
vi.mock('../src/shadow/ShadowVaultManager', () => ({
ShadowVaultManager: class {
openShadowFor = openShadowForMock;
},
}));
vi.mock('../src/shadow/ShadowVaultBootstrap', () => ({
ShadowVaultBootstrap: class {},
}));
vi.mock('../src/shadow/WindowSpawner', () => ({
WindowSpawner: class {},
}));
vi.mock('../src/shadow/ObsidianRegistry', () => ({
ObsidianRegistry: class {
static defaultConfigPath() { return '/synthetic/obsidian.json'; }
},
}));
// main.ts's import graph pulls in RemoteTerminalView (extends the
// obsidian `ItemView`, which the unit-test obsidian mock does not
// model) — irrelevant to the spawn guard, so stub the leaf module.
vi.mock('../src/ui/RemoteTerminalView', () => ({
RemoteTerminalView: class {},
VIEW_TYPE_REMOTE_TERMINAL: 'remote-terminal',
}));
import { App, recordedNotices, clearNotices } from 'obsidian';
import RemoteSshPlugin from '../src/main';
import type { SshProfile } from '../src/types';
const profile = { id: 'p1', name: 'Prod', remotePath: '~/v' } as unknown as SshProfile;
function makePlugin(): RemoteSshPlugin {
// Construct WITHOUT onload — openShadowVaultFor only needs
// app.vault.{adapter,configDir}, manifest.id, settings.profiles.
const plugin = new RemoteSshPlugin(new App() as never) as RemoteSshPlugin;
(plugin as unknown as { settings: unknown }).settings = { profiles: [profile] };
return plugin;
}
function okResult() {
return {
layout: { vaultDir: '/synthetic/shadow/p1' },
registryId: 'reg-1',
registryCreated: true,
pluginInstallMethod: 'symlink' as const,
};
}
describe('openShadowVaultFor — shadowSpawnInFlight guard (#352)', () => {
beforeEach(() => {
openShadowForMock.mockReset();
clearNotices();
vi.useFakeTimers();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
const inFlight = (p: RemoteSshPlugin) =>
(p as unknown as { shadowSpawnInFlight: boolean }).shadowSpawnInFlight;
it('a FAILED spawn clears the guard synchronously (instant retry, no 15s lockout)', async () => {
const plugin = makePlugin();
openShadowForMock.mockRejectedValue(new Error('bootstrap blew up'));
await plugin.openShadowVaultFor(profile);
// The bug: the old `finally` armed a 15s timer on the failure
// path too, stranding the user. The flag must already be false
// WITHOUT advancing any timer.
expect(inFlight(plugin)).toBe(false);
expect(recordedNotices().some((n) => /shadow vault failed/.test(n))).toBe(true);
// And a retry must go through immediately (not be swallowed by a
// lingering guard / "still opening" toast).
openShadowForMock.mockResolvedValue(okResult());
clearNotices();
await plugin.openShadowVaultFor(profile);
expect(openShadowForMock).toHaveBeenCalledTimes(2);
expect(recordedNotices().some((n) => /still opening/.test(n))).toBe(false);
});
it('a SUCCESSFUL spawn holds the guard ~15s, then clears it', async () => {
const plugin = makePlugin();
openShadowForMock.mockResolvedValue(okResult());
await plugin.openShadowVaultFor(profile);
// Held immediately after a successful spawn (debounce window).
expect(inFlight(plugin)).toBe(true);
vi.advanceTimersByTime(14_999);
expect(inFlight(plugin)).toBe(true);
vi.advanceTimersByTime(1);
expect(inFlight(plugin)).toBe(false);
});
it('a second spawn WHILE one is in flight is rejected (no double obsidian://open)', async () => {
const plugin = makePlugin();
openShadowForMock.mockResolvedValue(okResult());
await plugin.openShadowVaultFor(profile); // arms the 15s hold
expect(inFlight(plugin)).toBe(true);
clearNotices();
await plugin.openShadowVaultFor(profile); // re-click during hold
expect(openShadowForMock).toHaveBeenCalledTimes(1); // NOT 2
expect(recordedNotices().some((n) => /still opening/.test(n))).toBe(true);
});
});