sotashimozono_obsidian-remo.../plugin/tests/ShadowVaultBootstrap.test.ts
Souta 46430471d8 fix(shadow): address #354 review — detect "{}" app.json first-run state
- CRITICAL: seedObsidianFirstRunState used `.trim() === ''`, which
  passes Obsidian's ACTUAL first-run content (the literal `{}`) through
  as "configured" — the deadlock the fix was supposed to close stayed
  open. Replace with a parse-based predicate: seed when absent, blank,
  unparseable, or an empty object/array; a real config (>=1 key/elem)
  is still never clobbered.
- IMPORTANT: the bare `catch { appNeedsSeed = true }` swallowed EACCES
  / EISDIR as "absent" and would overwrite a file it merely failed to
  read. Now only ENOENT means "absent"; other errors rethrow.
- IMPORTANT: core-plugins.json used `!existsSync` only — a zero-byte
  or `[]` file still deadlocked (asymmetric with app.json). Both files
  now go through the same needsFirstRunSeed predicate.
- Tests: add the `{}` app.json case (fails on the old code — the real
  regression guard the zero-byte-only test never caught), the empty
  `[]` core-plugins.json case, and a non-ENOENT (EISDIR) no-clobber
  case. The reviewed "RED now GREEN" stale comment does not exist in
  the file (nothing to remove).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 23:50:36 +09:00

901 lines
42 KiB
TypeScript

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<typeof import('fs')>('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> = {}): 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:
*
* <root>/source-vault/.obsidian/
* community-plugins.json ← optional, tests opt in
* plugins/
* remote-ssh/main.js, manifest.json ← always staged
* <other-id>/... ← 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 `<root>/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<typeof makeScratch>;
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<typeof makeScratch>;
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<typeof makeScratch>;
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<typeof makeScratch>;
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<typeof makeScratch>;
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<string, string | 'throw' | 'invalid'>) {
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<string, string> = {};
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<typeof makeScratch>;
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);
});
});