import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { ObsidianVault } from '../daemon/obsidian.js'; import { createObsidianTools } from '../skills/obsidian-tools.js'; import { voiceSettings } from '../daemon/voice-settings.js'; function fixture(t, memory = true) { const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'jarvis-obsidian-')); t.after(() => fs.rmSync(parent, { recursive: true, force: true })); const root = path.join(parent, 'vault'); const vault = new ObsidianVault({ obsidianEnabled: true, obsidianMemoryEnabled: memory, obsidianVaultPath: root }); return { parent, root, vault }; } test('default is off and performs no vault creation', t => { const { root } = fixture(t); const settings = voiceSettings({ obsidianVaultPath: root }); const vault = new ObsidianVault(settings); assert.equal(settings.obsidianEnabled, false); assert.equal(settings.obsidianMemoryEnabled, false); assert.equal(vault.status().enabled, false); assert.deepEqual(createObsidianTools(vault), []); assert.throws(() => vault.initialize(), /disabled/); assert.throws(() => vault.read('Welcome.md'), /disabled/); assert.equal(fs.existsSync(root), false); }); test('initialization is explicit, refuses nonempty directories, and verifies actual memory access', t => { const { vault, root } = fixture(t); assert.equal(vault.status().ready, false); assert.equal(fs.existsSync(root), false); fs.mkdirSync(root); fs.writeFileSync(path.join(root, 'Personal.md'), 'private'); assert.throws(() => vault.initialize(), /empty directory/); fs.unlinkSync(path.join(root, 'Personal.md')); assert.equal(vault.initialize().ready, true); assert.equal(vault.initialize().ready, true); const result = vault.verify(); assert.equal(result.readWriteVerified, true); assert.equal(result.memoryVerified, true); assert.deepEqual(fs.readdirSync(path.join(root, 'memory')), []); assert.ok(result.uri.startsWith('obsidian://open?path=')); }); test('agent manages notes, attachments, folders, search, conflicts and trash restoration', t => { const { vault } = fixture(t); vault.initialize(); const tool = createObsidianTools(vault)[0]; tool.execute({ action: 'mkdir', path: 'projects' }); const first = tool.execute({ action: 'write', path: 'projects/Plan.md', content: '---\ntags: [agent]\n---\n# Plan\n[[Welcome]]' }); assert.throws(() => vault.write(first.path, 'clobber'), /Revision conflict/); const updated = vault.write(first.path, '## Revised [[Welcome]]', first.revision); assert.throws(() => vault.remove(first.path, first.revision), /Revision conflict/); assert.equal(vault.search('[[welcome]]').matches.length, 1); vault.move(first.path, 'projects/Next.md', updated.revision); assert.equal(vault.read('projects/Next.md').content, '## Revised [[Welcome]]'); const deleted = vault.remove('projects/Next.md', updated.revision); assert.equal(vault.trash()[0].id, deleted.trashId); vault.restore(deleted.trashId); assert.equal(vault.trash().length, 0); const bytes = Buffer.from([0, 255, 12, 31]); vault.write('assets/image.png', bytes.toString('base64'), undefined, 'base64'); assert.equal(vault.read('assets/image.png', 'base64').content, bytes.toString('base64')); vault.write('memory/user.md', 'Prefers tea'); assert.equal(vault.search('tea', { memory: true }).matches.length, 1); assert.throws(() => vault.remove('projects'), /ENOTEMPTY/); vault.mkdir('empty'); assert.deepEqual(vault.remove('empty'), { removedEmptyFolder: 'empty' }); }); test('traversal, hidden paths, symlinks, hardlinks and special files are not exposed', t => { const { vault, parent, root } = fixture(t); vault.initialize(); for (const bad of ['../outside.md', '/tmp/outside.md', 'a/../../outside.md', '.obsidian/plugins/code.js', 'a/.hidden', 'a\\b', 'a//b']) assert.throws(() => vault.write(bad, 'bad'), /relative vault path/); const outside = path.join(parent, 'outside.md'); fs.writeFileSync(outside, 'secret'); fs.symlinkSync(outside, path.join(root, 'link.md')); fs.linkSync(outside, path.join(root, 'hard.md')); for (const name of ['link.md', 'hard.md']) { assert.throws(() => vault.read(name), /links/); assert.throws(() => vault.write(name, 'bad'), /links/); } assert.equal(vault.list().entries.some(e => /link|hard/.test(e.path)), false); fs.symlinkSync(parent, path.join(root, 'escape')); assert.throws(() => vault.mkdir('escape/new'), /links/); assert.equal(fs.readFileSync(outside, 'utf8'), 'secret'); }); test('memory opt-out covers direct paths, listing, search, and trash; disabling revokes existing tool', t => { const { vault, root } = fixture(t); vault.initialize(); const note = vault.write('memory/secret.md', 'secret'); const deleted = vault.remove(note.path, note.revision); vault.write('memory/kept.md', 'secret'); const tool = createObsidianTools(vault)[0]; vault.configure({ obsidianEnabled: true, obsidianMemoryEnabled: false, obsidianVaultPath: root }); assert.throws(() => vault.read('memory/kept.md'), /memory access is disabled/); assert.throws(() => vault.restore(deleted.trashId, 'leak.md'), /memory access is disabled/); assert.equal(vault.trash().length, 0); assert.equal(vault.search('secret').matches.length, 0); assert.equal(vault.verify().memoryVerified, false); vault.configure({ obsidianEnabled: false, obsidianVaultPath: root }); assert.throws(() => tool.execute({ action: 'list' }), /disabled/); }); test('large files are readable, oversized writes and colliding moves/restore are rejected', t => { const { vault, root } = fixture(t); vault.initialize(); assert.throws(() => vault.write('big.md', 'a'.repeat(2 * 1024 * 1024 + 1)), /limit/); fs.writeFileSync(path.join(root, 'big.md'), Buffer.alloc(2 * 1024 * 1024 + 1)); assert.equal(vault.read('big.md').complete, false); assert.equal(vault.read('big.md', 'base64', { offset: 2 * 1024 * 1024 }).complete, true); const note = vault.write('a.md', 'a'); vault.write('b.md', 'b'); assert.throws(() => vault.move('a.md', 'b.md', note.revision), /already exists/); const trash = vault.remove('a.md', note.revision); assert.throws(() => vault.restore(trash.trashId, 'b.md'), /already exists/); }); test('read pages recover every byte, survive JSON rendering and detect changes', t => { const { vault, root } = fixture(t); vault.initialize(); const content = ('hello 😀\n\t"\\'.repeat(500)) + 'THE END'; fs.writeFileSync(path.join(root, 'long.md'), content); let offset = 0; let revision; let recovered = ''; do { const page = vault.execute({ action: 'read', path: 'long.md', offset, revision }); assert.ok(JSON.stringify(page).length < 16000); recovered += page.content; revision = page.revision; offset = page.nextOffset; assert.equal(page.complete, offset === null); } while (offset !== null); assert.equal(recovered, content); fs.appendFileSync(path.join(root, 'long.md'), '!'); assert.throws(() => vault.read('long.md', 'utf8', { offset: 1024, revision }), /Revision conflict/); assert.throws(() => vault.read('long.md', 'utf8', { offset: -1 }), /Invalid read page/); const binary = Buffer.alloc(5000, 255); fs.writeFileSync(path.join(root, 'blob.bin'), binary); const parts = []; offset = 0; do { const p = vault.read('blob.bin', 'base64', { offset }); parts.push(Buffer.from(p.content, 'base64')); offset = p.nextOffset; } while (offset !== null); assert.deepEqual(Buffer.concat(parts), binary); }); test('listing and memory recall continue beyond the first page and search note tails', t => { const { vault } = fixture(t); vault.initialize(); for (let i = 0; i < 25; i++) vault.write(`note-${String(i).padStart(2, '0')}.md`, 'x'.repeat(3000) + 'remember-me'); let offset = 0; const matches = []; do { const page = vault.execute({ action: 'memory_search', query: 'remember-me', offset }); matches.push(...page.matches); offset = page.nextOffset; } while (offset !== null); assert.equal(matches.length, 25); assert.equal(new Set(matches.map(m => m.path)).size, 25); });