import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; // Wrap `fs` in a vi.mock that re-exports the actual module. Vitest 4 // turns the resulting namespace into a configurable object so the // `vi.spyOn(fs, 'symlinkSync')` call in the symlink-fallback suite can // redefine that property — the raw ESM namespace is non-configurable // and rejects spies. All other fs calls run against the real // implementation, so the scratch-tree I/O elsewhere is unaffected. vi.mock('fs', async () => { const actual = await vi.importActual('fs'); return { ...actual, default: actual }; }); import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { ShadowVaultBootstrap } from '../src/shadow/ShadowVaultBootstrap'; import { ObsidianRegistry } from '../src/shadow/ObsidianRegistry'; import type { SshProfile, PendingPluginSuggestion } from '../src/types'; /** Minimal valid profile shape for the tests. */ function makeProfile(overrides: Partial = {}): SshProfile { return { id: 'profile-test-id', name: 'Test', host: 'example.invalid', port: 22, username: 'alice', authMethod: 'privateKey', remotePath: '~/test-vault/', privateKeyPath: '/dev/null', connectTimeoutMs: 5000, keepaliveIntervalMs: 10000, keepaliveCountMax: 3, ...overrides, } as SshProfile; } /** * Each test gets its own scratch tree so they can't cross-pollute and * the developer's real `~/.obsidian-remote/` is never touched. * * The source side mirrors a real vault layout: * * /source-vault/.obsidian/ * community-plugins.json ← optional, tests opt in * plugins/ * remote-ssh/main.js, manifest.json ← always staged * /... ← optional, tests opt in * * `sourceDir` is the path the production code calls `sourcePluginDir` * — the running plugin's own dir, two levels under `.obsidian/`. Tests * that need source community-plugins.json or third-party plugin dirs * can write them directly under `/source-vault/.obsidian/`. */ function makeScratch(): { baseDir: string; sourceDir: string; sourceVaultDir: string; sourceConfigDir: string; configPath: string; cleanup(): void; } { const root = path.join(os.tmpdir(), `shadow-vault-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`); const baseDir = path.join(root, 'vaults'); const sourceVaultDir = path.join(root, 'source-vault'); const sourceConfigDir = path.join(sourceVaultDir, '.obsidian'); const sourceDir = path.join(sourceConfigDir, 'plugins', 'remote-ssh'); const configPath = path.join(root, 'obsidian.json'); fs.mkdirSync(sourceDir, { recursive: true }); // Stage a couple of plugin files so install actually has something // to copy / link. fs.writeFileSync(path.join(sourceDir, 'main.js'), '// fake bundled plugin\n'); fs.writeFileSync(path.join(sourceDir, 'manifest.json'), JSON.stringify({ id: 'remote-ssh', version: '0.0.0' })); fs.writeFileSync(configPath, JSON.stringify({ vaults: {} })); return { baseDir, sourceDir, sourceVaultDir, sourceConfigDir, configPath, cleanup() { try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ } }, }; } describe('ShadowVaultBootstrap.layoutFor', () => { it('returns a layout under baseDir/profileId for a clean id', () => { const scratch = makeScratch(); try { const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const layout = r.layoutFor('abc-123'); expect(layout.vaultDir).toBe(path.join(scratch.baseDir, 'abc-123')); expect(layout.configDir).toBe(path.join(scratch.baseDir, 'abc-123', '.obsidian')); expect(layout.pluginDir).toBe(path.join(scratch.baseDir, 'abc-123', '.obsidian', 'plugins', 'remote-ssh')); expect(layout.pluginDataFile).toBe(path.join(layout.pluginDir, 'data.json')); } finally { scratch.cleanup(); } }); it('sanitises ids that contain path separators or traversal so vaultDir stays inside baseDir', () => { const scratch = makeScratch(); try { const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); // `../etc` would escape baseDir if not sanitised. const lo = r.layoutFor('../etc'); expect(path.dirname(lo.vaultDir)).toBe(scratch.baseDir); expect(path.resolve(lo.vaultDir).startsWith(path.resolve(scratch.baseDir) + path.sep)).toBe(true); // Slashes inside an id should not introduce nested directories. const slash = r.layoutFor('a/b/c'); expect(path.dirname(slash.vaultDir)).toBe(scratch.baseDir); } finally { scratch.cleanup(); } }); }); describe('ShadowVaultBootstrap.bootstrap', () => { let scratch: ReturnType; beforeEach(() => { scratch = makeScratch(); }); afterEach(() => { scratch.cleanup(); }); it('creates vaultDir, .obsidian/, plugin install, community-plugins.json, and data.json', async () => { const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1', name: 'P One' }); const result = await r.bootstrap(profile, [profile]); expect(fs.existsSync(result.layout.vaultDir)).toBe(true); expect(fs.existsSync(result.layout.configDir)).toBe(true); expect(fs.existsSync(result.layout.pluginDir)).toBe(true); expect(fs.existsSync(result.layout.pluginDataFile)).toBe(true); const cp = JSON.parse(fs.readFileSync(path.join(result.layout.configDir, 'community-plugins.json'), 'utf-8')); expect(cp).toEqual(['remote-ssh']); const data = JSON.parse(fs.readFileSync(result.layout.pluginDataFile, 'utf-8')); expect(data.profiles).toEqual([profile]); expect(data.activeProfileId).toBe('p1'); expect(data.autoConnectProfileId).toBe('p1'); // The plugin install method depends on platform / perms; just // assert the staged file is reachable through it. expect(fs.existsSync(path.join(result.layout.pluginDir, 'main.js'))).toBe(true); expect(['symlink', 'copy']).toContain(result.pluginInstallMethod); }); it('registers the vault path in obsidian.json with a fresh id', async () => { const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); const result = await r.bootstrap(profile, [profile]); const cfg = JSON.parse(fs.readFileSync(scratch.configPath, 'utf-8')); expect(result.registryCreated).toBe(true); expect(cfg.vaults[result.registryId].path).toBe(result.layout.vaultDir); expect(cfg.vaults[result.registryId].ts).toBeGreaterThan(0); }); it('is idempotent: a second bootstrap reuses the registry id and refreshes data.json', async () => { const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1', name: 'First' }); const first = await r.bootstrap(profile, [profile]); expect(first.registryCreated).toBe(true); const profileChanged = makeProfile({ id: 'p1', name: 'Renamed' }); const second = await r.bootstrap(profileChanged, [profileChanged]); expect(second.registryCreated).toBe(false); expect(second.registryId).toBe(first.registryId); const data = JSON.parse(fs.readFileSync(second.layout.pluginDataFile, 'utf-8')); expect(data.profiles[0].name).toBe('Renamed'); }); it('writes ALL profiles into the shadow vault data.json, with activeProfileId pinned to the target one', async () => { const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const p1 = makeProfile({ id: 'one', name: 'One' }); const p2 = makeProfile({ id: 'two', name: 'Two' }); const result = await r.bootstrap(p2, [p1, p2]); const data = JSON.parse(fs.readFileSync(result.layout.pluginDataFile, 'utf-8')); expect(data.profiles.map((p: SshProfile) => p.id)).toEqual(['one', 'two']); expect(data.activeProfileId).toBe('two'); expect(data.autoConnectProfileId).toBe('two'); }); it('inherits the source vault\'s data.json on first bootstrap so accumulated host keys / secrets carry over', async () => { // Stage a source data.json that simulates what a real running // plugin would write — host keys collected from past TOFU // prompts, secrets, etc. The first-ever bootstrap should seed // these into the shadow so the auto-connect doesn't re-prompt. fs.writeFileSync(path.join(scratch.sourceDir, 'data.json'), JSON.stringify({ hostKeyStore: { 'host:22': 'fingerprint-trusted-by-source' }, secrets: { somekey: 'somevalue' }, enableDebugLog: true, }), 'utf-8'); const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); const result = await r.bootstrap(profile, [profile]); const shadowData = JSON.parse(fs.readFileSync(result.layout.pluginDataFile, 'utf-8')); expect(shadowData.hostKeyStore).toEqual({ 'host:22': 'fingerprint-trusted-by-source' }); expect(shadowData.secrets).toEqual({ somekey: 'somevalue' }); expect(shadowData.enableDebugLog).toBe(true); // Bootstrap-managed fields override source values. expect(shadowData.activeProfileId).toBe('p1'); expect(shadowData.autoConnectProfileId).toBe('p1'); }); it('preserves shadow-side accumulated state on re-bootstrap (does not reset hostKeyStore back to source\'s)', async () => { const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); const first = await r.bootstrap(profile, [profile]); // Simulate what the shadow's running plugin would do after a // first connect: write new host keys / settings to its own data.json. const accumulated = JSON.parse(fs.readFileSync(first.layout.pluginDataFile, 'utf-8')); accumulated.hostKeyStore = { 'remote:22': 'fingerprint-collected-in-shadow' }; accumulated.secrets = { tofuKey: 'tofuValue' }; fs.writeFileSync(first.layout.pluginDataFile, JSON.stringify(accumulated, null, 2), 'utf-8'); // Re-bootstrap (e.g. user clicks Connect again from Settings). await r.bootstrap(profile, [profile]); const after = JSON.parse(fs.readFileSync(first.layout.pluginDataFile, 'utf-8')); expect(after.hostKeyStore).toEqual({ 'remote:22': 'fingerprint-collected-in-shadow' }); expect(after.secrets).toEqual({ tofuKey: 'tofuValue' }); // Bootstrap-managed fields still get refreshed. expect(after.profiles).toEqual([profile]); expect(after.autoConnectProfileId).toBe('p1'); }); it('regression: source plugin\'s data.json is NEVER touched by install, even on re-bootstrap', async () => { // Stage a sentinel data.json in the SOURCE plugin dir — this // mirrors the dev-vault setup where the developer's hostKeyStore / // secrets / etc live alongside main.js. The earlier whole-dir // symlink approach silently overwrote this file when the shadow // vault first wrote its own per-vault settings; per-file install // must keep it intact. const sourceDataJson = path.join(scratch.sourceDir, 'data.json'); const sentinel = JSON.stringify({ hostKeyStore: { 'host:22': 'fingerprint-must-survive' }, profiles: [{ id: 'source-profile' }], }); fs.writeFileSync(sourceDataJson, sentinel, 'utf-8'); const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); const result = await r.bootstrap(profile, [profile]); // Source untouched. expect(fs.readFileSync(sourceDataJson, 'utf-8')).toBe(sentinel); // Shadow has its own data.json. With the merge behaviour added in // Phase 4 it inherits the source's hostKeyStore on first // bootstrap, but bootstrap-managed fields override anything // source had. const shadowData = JSON.parse(fs.readFileSync(result.layout.pluginDataFile, 'utf-8')); expect(shadowData.activeProfileId).toBe('p1'); expect(shadowData.hostKeyStore).toEqual({ 'host:22': 'fingerprint-must-survive' }); // Re-bootstrap and re-check — once was a regression in PR #64, // both passes must keep the source data.json intact. await r.bootstrap(profile, [profile]); expect(fs.readFileSync(sourceDataJson, 'utf-8')).toBe(sentinel); }); it('regression: a stale whole-dir symlink at pluginDir is unlinked, not followed (does not delete source)', async () => { // Simulate the pre-fix on-disk state: pluginDir is a symlink to // sourceDir. installPlugin must replace this with a real dir // without deleting any of the source files. const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const layout = r.layoutFor('p1'); fs.mkdirSync(path.dirname(layout.pluginDir), { recursive: true }); try { const linkType = process.platform === 'win32' ? 'junction' : 'dir'; fs.symlinkSync(scratch.sourceDir, layout.pluginDir, linkType); } catch (e) { // If we can't even create a symlink in the test env, the test // can't exercise the relevant cleanup path — bail rather than // false-pass. (We always at least install per-file in that case, // so the regression isn't reachable.) console.warn(`skipping: cannot create symlink in test env: ${(e as Error).message}`); return; } // Stage a unique file in the source so we can detect deletion. const sourceCanary = path.join(scratch.sourceDir, 'CANARY.txt'); fs.writeFileSync(sourceCanary, 'do-not-delete', 'utf-8'); const profile = makeProfile({ id: 'p1' }); await r.bootstrap(profile, [profile]); // Source file still there. expect(fs.existsSync(sourceCanary)).toBe(true); expect(fs.readFileSync(sourceCanary, 'utf-8')).toBe('do-not-delete'); // Plugin dir is now a real dir, not a symlink. const stat = fs.lstatSync(layout.pluginDir); expect(stat.isSymbolicLink()).toBe(false); expect(stat.isDirectory()).toBe(true); }); it('writes [remote-ssh] only into shadow community-plugins.json on first bootstrap (does not auto-install source plugins)', async () => { fs.writeFileSync( path.join(scratch.sourceConfigDir, 'community-plugins.json'), JSON.stringify(['dataview', 'templater-obsidian', 'remote-ssh']), ); const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); const result = await r.bootstrap(profile, [profile]); const shadowList = JSON.parse(fs.readFileSync( path.join(result.layout.configDir, 'community-plugins.json'), 'utf-8', )); // Source's plugins are captured for the modal, not silently // enabled at startup. expect(shadowList).toEqual(['remote-ssh']); }); it('captures source-enabled plugins (and their data.json) into pendingPluginSuggestions on first bootstrap', async () => { fs.writeFileSync( path.join(scratch.sourceConfigDir, 'community-plugins.json'), JSON.stringify(['dataview', 'templater-obsidian', 'remote-ssh']), ); const sourceDataviewDir = path.join(scratch.sourceConfigDir, 'plugins', 'dataview'); fs.mkdirSync(sourceDataviewDir, { recursive: true }); fs.writeFileSync(path.join(sourceDataviewDir, 'data.json'), JSON.stringify({ enableInlineQueries: false })); // Templater has no data.json at source — should still surface as a suggestion with sourceData=null. const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); const result = await r.bootstrap(profile, [profile]); const data = JSON.parse(fs.readFileSync(result.layout.pluginDataFile, 'utf-8')); expect(data.pendingPluginSuggestions).toEqual([ { id: 'dataview', sourceData: { enableInlineQueries: false } }, { id: 'templater-obsidian', sourceData: null }, ]); // remote-ssh is excluded — we always install ourselves separately. }); it('does not write pendingPluginSuggestions on re-bootstrap (user has already decided)', async () => { fs.writeFileSync( path.join(scratch.sourceConfigDir, 'community-plugins.json'), JSON.stringify(['dataview', 'remote-ssh']), ); const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); const first = await r.bootstrap(profile, [profile]); // Simulate the user picking "Install dataview, ask later for everything else" then // the shadow window persisting the decision: data.json no longer has the field. const data = JSON.parse(fs.readFileSync(first.layout.pluginDataFile, 'utf-8')); delete data.pendingPluginSuggestions; fs.writeFileSync(first.layout.pluginDataFile, JSON.stringify(data, null, 2)); await r.bootstrap(profile, [profile]); const after = JSON.parse(fs.readFileSync(first.layout.pluginDataFile, 'utf-8')); expect(after.pendingPluginSuggestions).toBeUndefined(); }); it('does not include remote-ssh in pendingPluginSuggestions even if source lists it', async () => { fs.writeFileSync( path.join(scratch.sourceConfigDir, 'community-plugins.json'), JSON.stringify(['remote-ssh', 'dataview']), ); const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); const result = await r.bootstrap(profile, [profile]); const data = JSON.parse(fs.readFileSync(result.layout.pluginDataFile, 'utf-8')); expect(data.pendingPluginSuggestions.map((p: PendingPluginSuggestion) => p.id)).toEqual(['dataview']); }); it('omits pendingPluginSuggestions when source has no community-plugins.json or only remote-ssh', async () => { // (a) no community-plugins.json at source { const local = makeScratch(); try { const r = new ShadowVaultBootstrap(local.baseDir, local.sourceDir, new ObsidianRegistry(local.configPath)); const profile = makeProfile({ id: 'a' }); const result = await r.bootstrap(profile, [profile]); const data = JSON.parse(fs.readFileSync(result.layout.pluginDataFile, 'utf-8')); expect(data.pendingPluginSuggestions).toBeUndefined(); } finally { local.cleanup(); } } // (b) source has community-plugins.json with only remote-ssh { const local = makeScratch(); try { fs.writeFileSync( path.join(local.sourceConfigDir, 'community-plugins.json'), JSON.stringify(['remote-ssh']), ); const r = new ShadowVaultBootstrap(local.baseDir, local.sourceDir, new ObsidianRegistry(local.configPath)); const profile = makeProfile({ id: 'b' }); const result = await r.bootstrap(profile, [profile]); const data = JSON.parse(fs.readFileSync(result.layout.pluginDataFile, 'utf-8')); expect(data.pendingPluginSuggestions).toBeUndefined(); } finally { local.cleanup(); } } }); it('preserves shadow community-plugins.json on re-bootstrap (does not reset)', async () => { fs.writeFileSync( path.join(scratch.sourceConfigDir, 'community-plugins.json'), JSON.stringify(['dataview', 'remote-ssh']), ); const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); const first = await r.bootstrap(profile, [profile]); // Simulate the user installing templater inside the shadow window — // its id lands in the shadow's community-plugins.json. fs.writeFileSync( path.join(first.layout.configDir, 'community-plugins.json'), JSON.stringify(['templater-obsidian', 'remote-ssh']), ); const second = await r.bootstrap(profile, [profile]); const shadowList = JSON.parse(fs.readFileSync( path.join(second.layout.configDir, 'community-plugins.json'), 'utf-8', )); expect(shadowList).toEqual(['templater-obsidian', 'remote-ssh']); }); it('does NOT copy source plugin binaries (third-party plugins are downloaded by PluginMarketplaceInstaller at shadow window startup)', async () => { // Stage source with a plugin binary; bootstrap should NOT copy it // to shadow — the marketplace installer in main.ts is the only // path that materialises non-remote-ssh plugin code now. fs.writeFileSync( path.join(scratch.sourceConfigDir, 'community-plugins.json'), JSON.stringify(['dataview', 'remote-ssh']), ); const sourceDataviewDir = path.join(scratch.sourceConfigDir, 'plugins', 'dataview'); fs.mkdirSync(sourceDataviewDir, { recursive: true }); fs.writeFileSync(path.join(sourceDataviewDir, 'main.js'), '// source-side dataview bundle'); const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); const result = await r.bootstrap(profile, [profile]); // List is still seeded so the installer knows what to download, // but the binary itself is absent from shadow until the // marketplace installer runs in the shadow window. expect(fs.existsSync(path.join(result.layout.configDir, 'plugins', 'dataview'))).toBe(false); // remote-ssh itself IS installed (per-file symlink, separate path). expect(fs.existsSync(result.layout.pluginDir)).toBe(true); }); it('refreshes plugin install on bootstrap so plugin updates land immediately', async () => { const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); await r.bootstrap(profile, [profile]); // Bump the source plugin to simulate a dev rebuild. fs.writeFileSync(path.join(scratch.sourceDir, 'main.js'), '// updated bundle\n'); const result = await r.bootstrap(profile, [profile]); const installed = fs.readFileSync(path.join(result.layout.pluginDir, 'main.js'), 'utf-8'); expect(installed).toContain('updated bundle'); }); }); describe('ShadowVaultBootstrap: seedCommunityPlugins error branches', () => { let scratch: ReturnType; beforeEach(() => { scratch = makeScratch(); }); afterEach(() => { scratch.cleanup(); }); it('rewrites shadow community-plugins.json when the existing file contains invalid JSON', async () => { const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); // First bootstrap creates the shadow config dir and community-plugins.json. const first = await r.bootstrap(profile, [profile]); // Corrupt the file so the next parse fails. fs.writeFileSync( path.join(first.layout.configDir, 'community-plugins.json'), '{not valid json!!!}', ); // Second bootstrap should detect the parse failure, warn, and rewrite. const second = await r.bootstrap(profile, [profile]); const list = JSON.parse( fs.readFileSync(path.join(second.layout.configDir, 'community-plugins.json'), 'utf-8'), ); expect(list).toEqual(['remote-ssh']); }); it('rewrites shadow community-plugins.json when the file is valid JSON but not an array', async () => { const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); const first = await r.bootstrap(profile, [profile]); // Valid JSON object — not an array. fs.writeFileSync( path.join(first.layout.configDir, 'community-plugins.json'), JSON.stringify({ remote_ssh: true }), ); const second = await r.bootstrap(profile, [profile]); const list = JSON.parse( fs.readFileSync(path.join(second.layout.configDir, 'community-plugins.json'), 'utf-8'), ); expect(list).toEqual(['remote-ssh']); }); }); describe('ShadowVaultBootstrap: collectPendingPluginSuggestions edge cases', () => { let scratch: ReturnType; beforeEach(() => { scratch = makeScratch(); }); afterEach(() => { scratch.cleanup(); }); it('omits suggestions when source community-plugins.json is valid JSON but not an array', async () => { fs.writeFileSync( path.join(scratch.sourceConfigDir, 'community-plugins.json'), JSON.stringify({ corrupted: true }), ); const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); const result = await r.bootstrap(profile, [profile]); // Bootstrap omits the field entirely when there are no suggestions // (ShadowVaultBootstrap.ts:121-126 only writes the key when // `pending.length > 0`). The absent key is the "no suggestions" // signal that ShadowStartupCoordinator's no-op path relies on. const data = JSON.parse(fs.readFileSync(result.layout.pluginDataFile, 'utf-8')); expect(data.pendingPluginSuggestions).toBeUndefined(); }); it('omits suggestions when source community-plugins.json is invalid JSON', async () => { fs.writeFileSync( path.join(scratch.sourceConfigDir, 'community-plugins.json'), '{invalid!}', ); const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); const result = await r.bootstrap(profile, [profile]); const data = JSON.parse(fs.readFileSync(result.layout.pluginDataFile, 'utf-8')); expect(data.pendingPluginSuggestions).toBeUndefined(); }); it('still lists a plugin suggestion even when its data.json is invalid JSON (sourceData is null)', async () => { fs.writeFileSync( path.join(scratch.sourceConfigDir, 'community-plugins.json'), JSON.stringify(['dataview']), ); const dataviewDir = path.join(scratch.sourceConfigDir, 'plugins', 'dataview'); fs.mkdirSync(dataviewDir, { recursive: true }); fs.writeFileSync(path.join(dataviewDir, 'data.json'), '{invalid!}'); const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); const result = await r.bootstrap(profile, [profile]); const data = JSON.parse(fs.readFileSync(result.layout.pluginDataFile, 'utf-8')); expect(data.pendingPluginSuggestions).toHaveLength(1); expect(data.pendingPluginSuggestions[0].id).toBe('dataview'); expect(data.pendingPluginSuggestions[0].sourceData).toBeNull(); }); }); describe('ShadowVaultBootstrap: readBaseDataJson parse-failure fallback', () => { let scratch: ReturnType; beforeEach(() => { scratch = makeScratch(); }); afterEach(() => { scratch.cleanup(); }); it('starts from {} when shadow data.json exists but contains invalid JSON', async () => { const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); const first = await r.bootstrap(profile, [profile]); // Corrupt the shadow data.json. fs.writeFileSync(first.layout.pluginDataFile, '{not json!}'); // Second bootstrap should discard the corrupted file and rebuild from source or {}. const second = await r.bootstrap(profile, [profile]); const data = JSON.parse(fs.readFileSync(second.layout.pluginDataFile, 'utf-8')); // profiles and activeProfileId are always written — just assert they're present // and no crash occurred despite the corrupted input. expect(data.profiles).toEqual([profile]); expect(data.activeProfileId).toBe('p1'); }); }); describe('ShadowVaultBootstrap: installPlugin symlink fallback', () => { let scratch: ReturnType; beforeEach(() => { scratch = makeScratch(); }); afterEach(() => { scratch.cleanup(); vi.restoreAllMocks(); }); it('falls back to copyFileSync when symlinkSync throws, and plugin files are still present', async () => { // Simulate an environment where symlinks are not permitted (e.g. restricted Windows). vi.spyOn(fs, 'symlinkSync').mockImplementation(() => { throw Object.assign(new Error('EPERM: operation not permitted, symlink'), { code: 'EPERM' }); }); const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); const result = await r.bootstrap(profile, [profile]); expect(result.pluginInstallMethod).toBe('copy'); // The files should have been copied successfully. expect(fs.existsSync(path.join(result.layout.pluginDir, 'main.js'))).toBe(true); expect(fs.existsSync(path.join(result.layout.pluginDir, 'manifest.json'))).toBe(true); }); }); // ─── pullSharedObsidianConfig (#342 shared-config round-trip) ───────────────── describe('ShadowVaultBootstrap.pullSharedObsidianConfig', () => { let localConfigDir: string; beforeEach(() => { localConfigDir = path.join( fs.mkdtempSync(path.join(os.tmpdir(), 'shadow-pull-')), '.obsidian', ); }); afterEach(() => { try { fs.rmSync(path.dirname(localConfigDir), { recursive: true, force: true }); } catch { /* best effort */ } }); /** * Build a fake SharedConfigReader from a per-basename spec. * `content` → readable & valid; `'throw'` → read rejects; * `'invalid'` → readable but not JSON; absent key → exists()=false. */ function makeReader(spec: Record) { const resolve = (p: string) => p.slice(p.lastIndexOf('/') + 1); return { exists: vi.fn(async (p: string) => resolve(p) in spec), read: vi.fn(async (p: string) => { const v = spec[resolve(p)]; if (v === 'throw') throw new Error('SSH read failed'); if (v === 'invalid') return '{ not json'; return v; }), }; } it('pulls every present & valid file verbatim and reports them', async () => { const reader = makeReader({ 'app.json': JSON.stringify({ useMarkdownLinks: false }), 'appearance.json': JSON.stringify({ theme: 'obsidian' }), 'core-plugins.json': JSON.stringify(['file-explorer', 'search']), 'hotkeys.json': JSON.stringify({ 'editor:toggle-bold': [] }), }); const { pulled, skipped, errored } = await ShadowVaultBootstrap.pullSharedObsidianConfig( reader, '.obsidian', localConfigDir, ); expect(pulled.sort()).toEqual( [...ShadowVaultBootstrap.SHARED_OBSIDIAN_CONFIG_FILES].sort(), ); expect(skipped).toEqual([]); expect(errored).toEqual([]); expect(JSON.parse(fs.readFileSync(path.join(localConfigDir, 'app.json'), 'utf-8'))) .toEqual({ useMarkdownLinks: false }); // Atomic write must not leave a .tmp sibling behind. expect(fs.readdirSync(localConfigDir).some(f => f.endsWith('.tmp'))).toBe(false); }); it('creates localConfigDir when it does not exist yet', async () => { expect(fs.existsSync(localConfigDir)).toBe(false); await ShadowVaultBootstrap.pullSharedObsidianConfig( makeReader({ 'app.json': '{}' }), '.obsidian', localConfigDir, ); expect(fs.existsSync(localConfigDir)).toBe(true); }); it('skips a file that is absent on the remote without writing it', async () => { const { pulled, skipped, errored } = await ShadowVaultBootstrap.pullSharedObsidianConfig( makeReader({ 'appearance.json': '{}' }), '.obsidian', localConfigDir, ); expect(pulled).toEqual(['appearance.json']); expect(skipped).toContain('app.json'); // Absent-on-remote is a benign skip, NOT an error (no Notice). expect(errored).not.toContain('app.json'); expect(fs.existsSync(path.join(localConfigDir, 'app.json'))).toBe(false); }); it('skips a file whose read throws but still processes the rest', async () => { const { pulled, skipped, errored } = await ShadowVaultBootstrap.pullSharedObsidianConfig( makeReader({ 'app.json': 'throw', 'hotkeys.json': '{}' }), '.obsidian', localConfigDir, ); expect(skipped).toContain('app.json'); expect(errored).toContain('app.json'); // a read throw is an error, surfaces a Notice expect(pulled).toContain('hotkeys.json'); // loop didn't abort }); it('skips a corrupt remote file and leaves the prior local copy intact', async () => { fs.mkdirSync(localConfigDir, { recursive: true }); const healthy = JSON.stringify({ theme: 'keepme' }); fs.writeFileSync(path.join(localConfigDir, 'app.json'), healthy, 'utf-8'); const { skipped, errored } = await ShadowVaultBootstrap.pullSharedObsidianConfig( makeReader({ 'app.json': 'invalid' }), '.obsidian', localConfigDir, ); expect(skipped).toContain('app.json'); expect(errored).toContain('app.json'); // corrupt remote → surfaces a Notice expect(fs.readFileSync(path.join(localConfigDir, 'app.json'), 'utf-8')) .toBe(healthy); // NOT clobbered with broken JSON }); }); // ─── pushSharedObsidianConfig (#342 round-trip: local → remote) ─────────────── describe('ShadowVaultBootstrap.pushSharedObsidianConfig', () => { let localConfigDir: string; beforeEach(() => { localConfigDir = path.join( fs.mkdtempSync(path.join(os.tmpdir(), 'shadow-push-')), '.obsidian', ); fs.mkdirSync(localConfigDir, { recursive: true }); }); afterEach(() => { try { fs.rmSync(path.dirname(localConfigDir), { recursive: true, force: true }); } catch { /* best effort */ } }); /** Writer that records calls; `failOn` rejects for that basename. */ function makeWriter(failOn?: string) { const writes: Record = {}; return { writes, write: vi.fn(async (p: string, content: string) => { const base = p.slice(p.lastIndexOf('/') + 1); if (base === failOn) throw new Error('SSH write failed'); writes[base] = content; }), }; } it('pushes every present & valid local file verbatim', async () => { const appBody = JSON.stringify({ useMarkdownLinks: false }); fs.writeFileSync(path.join(localConfigDir, 'app.json'), appBody, 'utf-8'); fs.writeFileSync(path.join(localConfigDir, 'hotkeys.json'), '{}', 'utf-8'); const w = makeWriter(); const { pushed, skipped, errored } = await ShadowVaultBootstrap.pushSharedObsidianConfig(w, '.obsidian', localConfigDir); expect(pushed.sort()).toEqual(['app.json', 'hotkeys.json']); expect(errored).toEqual([]); expect(skipped.sort()).toEqual(['appearance.json', 'core-plugins.json']); // absent locally expect(w.writes['app.json']).toBe(appBody); // verbatim, not re-serialised expect(w.write).toHaveBeenCalledWith('.obsidian/app.json', appBody); }); it('skips files absent locally (fresh vault — not an error)', async () => { const w = makeWriter(); const { pushed, errored } = await ShadowVaultBootstrap.pushSharedObsidianConfig(w, '.obsidian', localConfigDir); expect(pushed).toEqual([]); expect(errored).toEqual([]); expect(w.write).not.toHaveBeenCalled(); }); it('does NOT push a half-written (invalid JSON) local file', async () => { fs.writeFileSync(path.join(localConfigDir, 'app.json'), '{ not json', 'utf-8'); const w = makeWriter(); const { pushed, skipped, errored } = await ShadowVaultBootstrap.pushSharedObsidianConfig(w, '.obsidian', localConfigDir); expect(pushed).not.toContain('app.json'); expect(skipped).toContain('app.json'); expect(errored).toContain('app.json'); // surfaces — settings not silently lost expect(w.write).not.toHaveBeenCalledWith('.obsidian/app.json', expect.anything()); }); it('reports a failed remote write as errored (not silently dropped)', async () => { fs.writeFileSync(path.join(localConfigDir, 'app.json'), '{"a":1}', 'utf-8'); const w = makeWriter('app.json'); const { pushed, errored } = await ShadowVaultBootstrap.pushSharedObsidianConfig(w, '.obsidian', localConfigDir); expect(pushed).not.toContain('app.json'); expect(errored).toContain('app.json'); }); }); // ─── first-run state seeding (first-open deadlock fix) ─────────────────────── describe('ShadowVaultBootstrap — seedObsidianFirstRunState', () => { let scratch: ReturnType; beforeEach(() => { scratch = makeScratch(); }); afterEach(() => { scratch.cleanup(); }); it('first bootstrap writes a NON-EMPTY app.json + core-plugins.json', async () => { const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); const result = await r.bootstrap(profile, [profile]); const appPath = path.join(result.layout.configDir, 'app.json'); const appRaw = fs.readFileSync(appPath, 'utf-8'); // Non-empty is the whole point — an empty app.json is what made // Obsidian open the shadow vault in first-run/Restricted mode. expect(appRaw.trim().length).toBeGreaterThan(0); expect(JSON.parse(appRaw)).toEqual({ promptDelete: false }); const core = JSON.parse( fs.readFileSync(path.join(result.layout.configDir, 'core-plugins.json'), 'utf-8'), ); expect(Array.isArray(core)).toBe(true); expect(core).toContain('file-explorer'); }); it('re-seeds an EMPTY app.json (the field-observed deadlock state)', async () => { const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); const result = await r.bootstrap(profile, [profile]); const appPath = path.join(result.layout.configDir, 'app.json'); fs.writeFileSync(appPath, '', 'utf-8'); // simulate the zero-byte app.json seen in the field await r.bootstrap(profile, [profile]); // re-bootstrap expect(fs.readFileSync(appPath, 'utf-8').trim().length).toBeGreaterThan(0); expect(JSON.parse(fs.readFileSync(appPath, 'utf-8'))).toEqual({ promptDelete: false }); }); it('re-seeds a "{}" app.json — Obsidian\'s ACTUAL first-run content', async () => { // The field deadlock state is NOT a zero-byte file: Obsidian // writes the literal `{}`. The old `.trim() === ''` check passed // `{}` through as "configured" and left the deadlock — this test // fails on that code and guards the fix. const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); const result = await r.bootstrap(profile, [profile]); const appPath = path.join(result.layout.configDir, 'app.json'); fs.writeFileSync(appPath, '{}', 'utf-8'); // the real first-run placeholder await r.bootstrap(profile, [profile]); // re-bootstrap expect(JSON.parse(fs.readFileSync(appPath, 'utf-8'))).toEqual({ promptDelete: false }); }); it('re-seeds an empty "[]" core-plugins.json (was asymmetric with app.json)', async () => { const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); const result = await r.bootstrap(profile, [profile]); const corePath = path.join(result.layout.configDir, 'core-plugins.json'); fs.writeFileSync(corePath, '[]', 'utf-8'); // empty array still deadlocks await r.bootstrap(profile, [profile]); const core = JSON.parse(fs.readFileSync(corePath, 'utf-8')); expect(Array.isArray(core)).toBe(true); expect(core).toContain('file-explorer'); }); it('does NOT swallow a non-ENOENT read error as "needs seed"', async () => { // A file that exists but cannot be read (here: app.json is a // DIRECTORY → EISDIR) must NOT be silently treated as absent and // overwritten — the old bare `catch { appNeedsSeed = true }` did // exactly that. It must surface, not clobber. const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); const result = await r.bootstrap(profile, [profile]); const appPath = path.join(result.layout.configDir, 'app.json'); fs.rmSync(appPath, { force: true }); fs.mkdirSync(appPath); // reading this throws EISDIR, not ENOENT // bootstrap() is `Promise.resolve(bootstrapSync())` — a non-ENOENT // read throws SYNCHRONOUSLY, before the promise is constructed. expect(() => r.bootstrap(profile, [profile])).toThrow(/EISDIR/); // and it must NOT have replaced the directory with a seeded file expect(fs.statSync(appPath).isDirectory()).toBe(true); }); it('does NOT clobber a real app.json (left by pullSharedObsidianConfig / Obsidian)', async () => { const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); const result = await r.bootstrap(profile, [profile]); const appPath = path.join(result.layout.configDir, 'app.json'); const real = JSON.stringify({ theme: 'obsidian', baseFontSize: 16 }); fs.writeFileSync(appPath, real, 'utf-8'); await r.bootstrap(profile, [profile]); // re-bootstrap must preserve it expect(fs.readFileSync(appPath, 'utf-8')).toBe(real); }); it('does NOT clobber an existing core-plugins.json', async () => { const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); const profile = makeProfile({ id: 'p1' }); const result = await r.bootstrap(profile, [profile]); const corePath = path.join(result.layout.configDir, 'core-plugins.json'); const custom = JSON.stringify(['file-explorer', 'graph']); fs.writeFileSync(corePath, custom, 'utf-8'); await r.bootstrap(profile, [profile]); expect(fs.readFileSync(corePath, 'utf-8')).toBe(custom); }); });