sotashimozono_obsidian-remo.../plugin/tests/WindowSpawner.test.ts
Souta 1cea91270f feat(plugin): Phase 2 — ShadowVaultBootstrap + Manager + WindowSpawner + ObsidianRegistry (0.4.8)
Lands the Phase 2 surface from
docs/architecture-shadow-vault.md §3: the local-disk + URL-handler
plumbing that lets us open a shadow vault for a profile in a new
Obsidian window. No connect happens here; the auto-connect inside
the new window is Phase 4.

What's in:

  src/shadow/ObsidianRegistry.ts
    Reads / atomically rewrites obsidian.json (Obsidian's per-user
    list of known vaults) so Obsidian's `obsidian://open?path=…`
    URL handler accepts the shadow vault path directly instead of
    falling back to its picker UI. Per-OS path resolution
    (Windows %APPDATA%, macOS ~/Library/Application Support, Linux
    $XDG_CONFIG_HOME) — never hardcodes a specific user. register()
    is idempotent and case-insensitive on Windows.

  src/shadow/ShadowVaultBootstrap.ts
    Materialises ~/.obsidian-remote/vaults/<profile-id>/ with
    .obsidian/community-plugins.json (["remote-ssh"]),
    .obsidian/plugins/remote-ssh/ as a symlink (junction on
    Windows) or recursive copy fallback, and data.json containing
    the profile config + an `autoConnectProfileId` marker the
    shadow window will read in Phase 4. Re-running for the same
    profile refreshes the plugin install (so dev iterations land
    immediately) and rewrites data.json but never touches files
    Obsidian itself created.

  src/shadow/WindowSpawner.ts
    Builds the `obsidian://open?path=…` URL and fires it via
    window.open(). Pluggable opener so tests can record without
    actually launching browsers.

  src/shadow/ShadowVaultManager.ts
    Top-level orchestrator: bootstrap → spawn.

  src/main.ts
    Hidden command "Remote SSH: Debug: open shadow vault for
    active profile (Phase 2 POC)". Visible only when an active
    profile is selected (no need to be connected; this is a
    local-disk operation).

What's NOT in (deferred):

  - Settings UI rewires of the Connect button — Phase 3.
  - Auto-connect-on-startup inside the shadow window — Phase 4.
    (data.json is written with the marker; nothing reads it yet.)
  - Removing the legacy reconcileVaultRoot / autoPatchAdapter
    paths — Phase 5.
  - The shadow data.json is intentionally minimal for Phase 2:
    profiles + activeProfileId + autoConnectProfileId. Phase 4
    will add hostKeyStore / secrets / clientId / ... so the
    auto-connect actually has everything it needs.

Tests: 274 → 296, all green. New unit tests cover:
  - ObsidianRegistry: per-OS default path; reads preserve unknown
    top-level keys (adblock, cli, …); register adds a hex16 id
    with current ts; idempotent on the same path; preserves
    untouched vault entries; canonicalises trailing slashes;
    case-insensitive on Windows; atomic write leaves no .tmp file.
  - ShadowVaultBootstrap: layoutFor returns the expected paths;
    sanitises ids that contain `..` / `/` so the vault dir stays
    inside baseDir; bootstrap creates dir + plugin install +
    community-plugins.json + data.json + registry entry; idempotent;
    writes ALL profiles into data.json; refreshes plugin install
    on re-bootstrap.
  - WindowSpawner: builds `obsidian://open?path=…` with proper
    URL encoding; returns the URL it fired.
  - ShadowVaultManager: bootstrap before spawn; does not spawn
    when bootstrap throws.

Manual smoke (after merge):

  1. Reload plugin in dev vault.
  2. Make sure a profile is active (Settings → Remote SSH).
  3. Command palette → "Remote SSH: Debug: open shadow vault…".
  4. Notice reports "opened shadow vault for <name>
     (symlink|copy, newly registered)".
  5. New Obsidian window opens at
     ~/.obsidian-remote/vaults/<profile-id>/, empty, with
     Remote SSH plugin pre-installed (still disabled on first
     load — Obsidian's default for community plugins; user
     enables once).
  6. ~/.obsidian-remote/vaults/<profile-id>/ on disk has the
     expected layout; obsidian.json has a new vault entry with
     a fresh hex16 id.

Bump 0.4.7 → 0.4.8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 00:57:14 +09:00

36 lines
1.5 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { WindowSpawner, type UrlOpener } from '../src/shadow/WindowSpawner';
function recordingOpener() {
const calls: string[] = [];
const opener: UrlOpener = { openUrl(url) { calls.push(url); } };
return { opener, calls };
}
describe('WindowSpawner', () => {
it('builds an obsidian://open?path=… URL with the encoded vault path', () => {
const { opener, calls } = recordingOpener();
const spawner = new WindowSpawner(opener);
const url = spawner.spawn('C:\\Users\\alice\\.obsidian-remote\\vaults\\p1');
expect(calls).toHaveLength(1);
expect(calls[0]).toBe(url);
expect(url.startsWith('obsidian://open?path=')).toBe(true);
expect(url).toContain(encodeURIComponent('C:\\Users\\alice\\.obsidian-remote\\vaults\\p1'));
});
it('encodes characters that would otherwise break the URL', () => {
const { opener, calls } = recordingOpener();
const spawner = new WindowSpawner(opener);
spawner.spawn('/path/with spaces/and?question&amp');
expect(calls[0]).toContain(encodeURIComponent('/path/with spaces/and?question&amp'));
// Spaces / `?` / `&` must NOT appear unencoded after `path=`.
const after = calls[0].slice('obsidian://open?path='.length);
expect(after).not.toMatch(/[ ?&]/);
});
it('returns the URL it fired (so callers can log or surface it)', () => {
const { opener } = recordingOpener();
const url = new WindowSpawner(opener).spawn('/a/b');
expect(url).toBe('obsidian://open?path=' + encodeURIComponent('/a/b'));
});
});