Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790// src/plugin/__tests__/vault.properties.test.ts// Property-based tests for Vault layer invariants under the storage-authoritative model.//// Design notes (responding to adversary review):// - We do NOT pin fast-check's seed; CI should explore the input space.// - `numRuns` is high enough to make shrinking productive on a real failure.// - `fileNameArb` includes adversarial cases: dotfiles, names with dots,// unicode, longish names.// - `slashyPathArb` covers leading, trailing, and embedded extra slashes// on the full path so we test normalizePath() honestly. Suffixes (e.g.,// ".md") are appended BEFORE adding the trailing slash, so the trailing// slash applies to the final path the way a real caller would write it.// - Mock DocumentManager mirrors real DM in the dimensions the tests// exercise (storage of content, child lists, deletion side effects).// Comments call out what it does NOT model (Automerge async sync, CRDT// conflict races). Tests do not rely on those dimensions.import { describe, it, expect, vi } from 'vitest'import fc from 'fast-check'import type { AutomergeUrl } from '@automerge/automerge-repo'import { Vault } from '../vault'import { FileIndex } from '../../services/file-index'import { TFile, createTFile, createTFolder, type FileStats } from '../files'import type { DocumentManager } from '../../services/document-manager'
const ROOT_URL = 'automerge:root' as AutomergeUrlconst stat: FileStats = { ctime: 0, mtime: 0, size: 0 }
// Adversarial file names. We deliberately include:// - leading-dot names (dotfiles): computeBasename has special handling// - names with multiple dots: ambiguous extension semantics// - unicode characters: NFC vs NFD normalization differences// - longish names: stress char-length assumptions//// We exclude what the Obsidian/Reams contract genuinely refuses:// path separators, null bytes, '.', '..', and pure-whitespace names.const fileNameArb = fc.oneof( fc.string({ minLength: 1, maxLength: 30 }), // Force some dotfile cases fc.string({ minLength: 1, maxLength: 8 }).map(s => `.${s}`), // Force some unicode cases fc.constantFrom('café', 'naïve', '日本語', 'αβγ'),).filter(n => !n.includes('/') && !n.includes('\0') && !n.includes('\\') && n.trim().length > 0 && n !== '.' && n !== '..')
// Slashy path variants applied to the FULL final path (suffix included).// Yields the path a caller might write together with its normalized form.//// We construct the path as: [leading-slash?] seg / extra-slashes / seg [trailing-slash?]// then build both the slashy and normalized forms identically except for// added slashes. This way appending suffixes is not an issue — the test// constructs the full path including suffix inside the arbitrary.const slashyPathArb = fc.tuple( fc.boolean(), // leading slash fc.array(fileNameArb, { minLength: 1, maxLength: 3 }), fc.boolean(), // trailing slash fc.array(fc.integer({ min: 0, max: 2 }), { minLength: 1, maxLength: 3 }), // extra slashes between segments).map(([leadingSlash, parts, trailingSlash, extraSlashes]) => { let path = '' if (leadingSlash) path += '/' for (let i = 0; i < parts.length; i++) { if (i > 0) { path += '/' for (let j = 0; j < (extraSlashes[i - 1] ?? 0); j++) { path += '/' } } path += parts[i] } if (trailingSlash) path += '/' return { slashyPath: path, normalizedPath: parts.join('/') }})
function createMockDocumentManager(): DocumentManager { // Storage model: map of URL → entry. Folders track children; files track content. // // NOT MODELED: // - Automerge async sync delays // - CRDT-induced concurrent writes from peers (e.g., assertStorageChildVacant // has a TOCTOU window with real DM that this mock cannot exhibit) // Tests in this file deliberately do not depend on those properties. const storage = new Map<AutomergeUrl, { type: 'file' | 'folder'; name: string; children?: AutomergeUrl[]; content?: string }>() storage.set(ROOT_URL, { type: 'folder', name: '', children: [] })
let urlCounter = 0 const nextUrl = (): AutomergeUrl => `automerge:item${++urlCounter}` as AutomergeUrl
const removeChild = (parentUrl: AutomergeUrl, url: AutomergeUrl): void => { const parent = storage.get(parentUrl) if (parent?.children) { const idx = parent.children.indexOf(url) if (idx !== -1) parent.children.splice(idx, 1) } }
const addChild = (parentUrl: AutomergeUrl, url: AutomergeUrl): void => { const parent = storage.get(parentUrl) if (parent?.children) parent.children.push(url) }
return { createFileInFolder: vi.fn().mockImplementation(async (parentUrl: AutomergeUrl, name: string) => { const url = nextUrl() storage.set(url, { type: 'file', name, content: '' }) addChild(parentUrl, url) return url }), createSubfolder: vi.fn().mockImplementation(async (parentUrl: AutomergeUrl, name: string) => { const url = nextUrl() storage.set(url, { type: 'folder', name, children: [] }) addChild(parentUrl, url) return url }), createBinaryFileInFolder: vi.fn().mockImplementation(async (parentUrl: AutomergeUrl, name: string) => { const url = nextUrl() storage.set(url, { type: 'file', name, content: '' }) addChild(parentUrl, url) return url }), listFolder: vi.fn().mockImplementation(async (folderUrl: AutomergeUrl) => { const folder = storage.get(folderUrl) if (!folder?.children) return [] return folder.children .map(url => { const entry = storage.get(url) if (!entry) return null return { url, name: entry.name, type: entry.type } }) .filter(Boolean) }), readText: vi.fn().mockImplementation(async (url: AutomergeUrl) => { const entry = storage.get(url) return entry?.type === 'file' ? { status: 'available' as const, documentId: url, heads: ['h0'], content: entry.content ?? '' } : { status: 'not-found' as const, documentId: url } }), updateContent: vi.fn().mockImplementation(async (url: AutomergeUrl, content: string) => { const entry = storage.get(url) if (entry) entry.content = content }), updateBinaryContent: vi.fn().mockResolvedValue(undefined), readBinaryContent: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3])), deleteFile: vi.fn().mockImplementation(async (url: AutomergeUrl, parentUrl: AutomergeUrl) => { storage.delete(url) removeChild(parentUrl, url) }), deleteFolder: vi.fn().mockImplementation(async (url: AutomergeUrl, parentUrl: AutomergeUrl) => { storage.delete(url) removeChild(parentUrl, url) }), // Real DM signature: renameFile/renameEntry(url, folderUrl, newName). // The middle arg (folderUrl) is unused by the mock but the position must // match the real signature or Vault.rename's 3-arg call would silently // write the folder URL as the new name. renameFile: vi.fn().mockImplementation(async (url: AutomergeUrl, _folderUrl: AutomergeUrl, newName: string) => { const entry = storage.get(url) if (entry) entry.name = newName }), renameEntry: vi.fn().mockImplementation(async (url: AutomergeUrl, _folderUrl: AutomergeUrl, newName: string) => { const entry = storage.get(url) if (entry) entry.name = newName }), moveFile: vi.fn().mockImplementation(async (url: AutomergeUrl, oldParentUrl: AutomergeUrl, newParentUrl: AutomergeUrl) => { removeChild(oldParentUrl, url) addChild(newParentUrl, url) }), } as unknown as DocumentManager}
function makeVault(): Vault { const dm = createMockDocumentManager() return new Vault('Test', new FileIndex(dm, ROOT_URL), dm)}
// Higher run counts than fast-check's 100 default. Property tests in this// file run in < 1s each at 200 runs; CI cost is negligible. Tighter loops// (Invariant 5 examples) keep their explicit lower counts.const RUNS = 200
describe('Vault property-based tests — storage-authoritative model invariants', () => {
// ----------------------------------------------------------------------- // Invariant 1: Object-safe APIs reject stale objects. // // A "stale" object is a TFile / TFolder constructed outside the Vault's // create/adopt paths, so it is NOT registered in the FileIndex. Any // object-taking Vault API must reject these via identity check, not // by path lookup. We test multiple stale scenarios: // 1a. A stale file at a path where a DIFFERENT live entry exists // 1b. A stale file at a path with NO live entry (orphan) // 1c. A stale file at the path of a DELETED entry // 1d. A stale TFolder passed where TFile is expected (type confusion) // ----------------------------------------------------------------------- describe('Invariant 1: Object-safe APIs reject stale objects', () => {
// 1a. stale file colliding with a live entry it('read/modify/delete/rename reject stale file colliding with live entry', async () => { await fc.assert(fc.asyncProperty(fileNameArb, async (name) => { const vault = makeVault() const live = await vault.create(`${name}.md`, 'live content') const stale = createTFile(`${name}.md`, vault.getRoot(), stat) stale.vault = vault
await expect(vault.read(stale)).rejects.toThrow('File not found') await expect(vault.modify(stale, 'evil')).rejects.toThrow('File not found') await expect(vault.delete(stale)).rejects.toThrow('File not found') await expect(vault.rename(stale, `${name}-other.md`)).rejects.toThrow('File not found')
// Live file untouched expect(vault.getFileByPath(`${name}.md`)).toBe(live) expect(await vault.read(live)).toBe('live content') }), { numRuns: RUNS }) })
// 1b. stale file at a path that NEVER existed (orphan) it('read/modify/delete reject stale file at a never-existed path', async () => { await fc.assert(fc.asyncProperty(fileNameArb, async (name) => { const vault = makeVault() // No file is ever created at this path. const orphan = createTFile(`${name}.md`, vault.getRoot(), stat) orphan.vault = vault
await expect(vault.read(orphan)).rejects.toThrow('File not found') await expect(vault.modify(orphan, 'x')).rejects.toThrow('File not found') await expect(vault.delete(orphan)).rejects.toThrow('File not found')
// Index still has no entry expect(vault.getFileByPath(`${name}.md`)).toBeNull() }), { numRuns: RUNS }) })
// 1c. stale reference to a deleted file it('reusing a deleted TFile reference is rejected by all object APIs', async () => { await fc.assert(fc.asyncProperty(fileNameArb, async (name) => { const vault = makeVault() const file = await vault.create(`${name}.md`, 'content') await vault.delete(file) // `file` is now stale (its URL has been removed from the index).
await expect(vault.read(file)).rejects.toThrow('File not found') await expect(vault.modify(file, 'x')).rejects.toThrow('File not found') await expect(vault.delete(file)).rejects.toThrow('File not found') await expect(vault.rename(file, `${name}-renamed.md`)).rejects.toThrow('File not found') }), { numRuns: RUNS }) })
// 1d. Type confusion: a LIVE folder passed where a TFile is expected. // The folder is registered in the FileIndex, so getUrlForFile() returns // a valid URL. resolveIndexedFile must then reject on the entry.type !== // 'file' branch with a DISTINCT error message ("Expected file, got // folder") so callers don't waste time chasing a "File not found" red // herring. it('passing a live TFolder to file APIs is rejected with type-confusion error', async () => { await fc.assert(fc.asyncProperty(fileNameArb, async (name) => { const vault = makeVault() const folder = await vault.createFolder(name) // REGISTERED, not stale // Cast through unknown to bypass TS so we exercise runtime safety. const folderAsFile = folder as unknown as TFile
// ALL file-only APIs that route through resolveIndexedFile must // reject with the distinct type-confusion message. Covers async // and sync entry points. await expect(vault.read(folderAsFile)).rejects.toThrow(/Expected file, got folder/) await expect(vault.cachedRead(folderAsFile)).rejects.toThrow(/Expected file, got folder/) await expect(vault.readBinary(folderAsFile)).rejects.toThrow(/Expected file, got folder/) await expect(vault.modify(folderAsFile, 'x')).rejects.toThrow(/Expected file, got folder/) await expect(vault.process(folderAsFile, (s) => s)).rejects.toThrow(/Expected file, got folder/) await expect(vault.append(folderAsFile, 'x')).rejects.toThrow(/Expected file, got folder/) await expect(vault.modifyBinary(folderAsFile, new ArrayBuffer(4))).rejects.toThrow(/Expected file, got folder/) await expect(vault.appendBinary(folderAsFile, new ArrayBuffer(4))).rejects.toThrow(/Expected file, got folder/) // Sync entry point — assertion shape is different. expect(() => vault.getResourcePath(folderAsFile)).toThrow(/Expected file, got folder/) }), { numRuns: RUNS }) })
// 1e. stale-object rejection extends to convenience wrappers (process, // append, modifyBinary, modify) that internally route through // resolveIndexedFile. it('process/append/modifyBinary reject stale file objects', async () => { await fc.assert(fc.asyncProperty(fileNameArb, async (name) => { const vault = makeVault() await vault.create(`${name}.md`, 'live') const stale = createTFile(`${name}.md`, vault.getRoot(), stat) stale.vault = vault
await expect(vault.process(stale, (s) => s + '!')).rejects.toThrow('File not found') await expect(vault.append(stale, 'x')).rejects.toThrow('File not found') await expect(vault.modifyBinary(stale, new ArrayBuffer(8))).rejects.toThrow('File not found') }), { numRuns: RUNS }) }) })
// ----------------------------------------------------------------------- // Invariant 2: Adapter path normalization is consistent across slashy // spellings (leading, trailing, multiple internal slashes). // ----------------------------------------------------------------------- describe('Invariant 2: Adapter normalizes slashy paths consistently', () => {
it('exists/stat/read converge across slashy spellings of the same path', async () => { await fc.assert(fc.asyncProperty(slashyPathArb, async ({ slashyPath, normalizedPath }) => { const vault = makeVault() await vault.create(normalizedPath, 'content')
// exists const existsSlashy = await vault.adapter.exists(slashyPath) const existsNormalized = await vault.adapter.exists(normalizedPath) expect(existsSlashy).toBe(existsNormalized) expect(existsSlashy).toBe(true)
// stat const statSlashy = await vault.adapter.stat(slashyPath) const statNormalized = await vault.adapter.stat(normalizedPath) expect(statSlashy?.type).toBe(statNormalized?.type)
// read const readSlashy = await vault.adapter.read(slashyPath) const readNormalized = await vault.adapter.read(normalizedPath) expect(readSlashy).toBe(readNormalized) expect(readSlashy).toBe('content') }), { numRuns: RUNS }) }) })
// ----------------------------------------------------------------------- // Invariant 3: No file/folder aliasing through create/rename paths. // Two slashy spellings of the same logical path must not create two // separate index entries. // ----------------------------------------------------------------------- describe('Invariant 3: No file/folder aliasing through create/rename paths', () => {
it('create() with different slashy spellings of same path throws on second call', async () => { await fc.assert(fc.asyncProperty(slashyPathArb, async ({ slashyPath, normalizedPath }) => { const vault = makeVault() await vault.create(normalizedPath, 'first') await expect(vault.create(slashyPath, 'second')).rejects.toThrow(/already exists/)
// Strict cardinality check: exactly one file total expect(vault.getMarkdownFiles().concat(vault.getFiles())).toBeDefined() // getFiles() returns all files in the vault. We expect exactly one. const allFiles = vault.getFiles() expect(allFiles).toHaveLength(1) expect(allFiles[0].path).toBe(normalizedPath) }), { numRuns: RUNS }) })
it('createFolder() with different slashy spellings returns same folder on second call', async () => { await fc.assert(fc.asyncProperty(slashyPathArb, async ({ slashyPath, normalizedPath }) => { const vault = makeVault() const folder1 = await vault.createFolder(normalizedPath) const folder2 = await vault.createFolder(slashyPath)
expect(folder2).toBe(folder1) expect(folder2.path).toBe(normalizedPath) }), { numRuns: RUNS }) })
it('rename() to an existing live path throws', async () => { await fc.assert(fc.asyncProperty(fileNameArb, fileNameArb, async (name1, name2) => { fc.pre(name1 !== name2)
const vault = makeVault() await vault.create(`${name1}.md`, 'first') const file2 = await vault.create(`${name2}.md`, 'second')
await expect(vault.rename(file2, `${name1}.md`)).rejects.toThrow()
expect(vault.getFileByPath(`${name1}.md`)).not.toBeNull() expect(vault.getFileByPath(`${name2}.md`)).toBe(file2) }), { numRuns: RUNS }) })
// Reams treats `Foo.md` and `foo.md` as distinct files. Each file is its // own Automerge document with its own URL; the local Automerge replica is // case-sensitive. We rely on Automerge's CRDT merge semantics for cross- // peer reconciliation; we do NOT enforce filesystem-style case-collision // rules at the vault API. it('create() permits case-only sibling names (treated as distinct files)', async () => { const lowerNameArb = fc.string({ minLength: 1, maxLength: 10 }) .filter(n => /^[a-z]+$/.test(n)) await fc.assert(fc.asyncProperty(lowerNameArb, async (name) => { const upperName = name.charAt(0).toUpperCase() + name.slice(1) fc.pre(upperName !== name)
const vault = makeVault() const lower = await vault.create(`${name}.md`, 'lower') const upper = await vault.create(`${upperName}.md`, 'upper')
// Both files coexist with distinct content. expect(vault.getFileByPath(`${name}.md`)).toBe(lower) expect(vault.getFileByPath(`${upperName}.md`)).toBe(upper) expect(await vault.read(lower)).toBe('lower') expect(await vault.read(upper)).toBe('upper') }), { numRuns: 100 }) }) })
// ----------------------------------------------------------------------- // Invariant 4: Index and tree converge to storage truth after operations. // ----------------------------------------------------------------------- describe('Invariant 4: Index/tree converge to storage truth after operations', () => {
it('create+delete leaves clean index state (no zombie entries)', async () => { await fc.assert(fc.asyncProperty( fc.array(fileNameArb, { minLength: 1, maxLength: 10 }), async (names) => { const vault = makeVault() const uniqueNames = [...new Set(names)]
const files: TFile[] = [] for (const name of uniqueNames) { files.push(await vault.create(`${name}.md`, 'content')) }
const toDelete = files.slice(0, Math.floor(files.length / 2)) for (const file of toDelete) { await vault.delete(file) }
const remaining = vault.getMarkdownFiles() expect(remaining.length).toBe(files.length - toDelete.length)
for (const deleted of toDelete) { expect(vault.getFileByPath(deleted.path)).toBeNull() }
for (const file of files.slice(toDelete.length)) { expect(vault.getFileByPath(file.path)).toBe(file) } } ), { numRuns: RUNS }) })
it('folder delete (recursive) removes all descendants from index', async () => { await fc.assert(fc.asyncProperty( fc.integer({ min: 1, max: 5 }), async (fileCount) => { const vault = makeVault()
const folder = await vault.createFolder('testfolder') const files: TFile[] = [] for (let i = 0; i < fileCount; i++) { files.push(await vault.create(`testfolder/file${i}.md`, 'content')) }
for (const file of files) { expect(vault.getFileByPath(file.path)).toBe(file) }
// Recursive delete requires force=true. await vault.delete(folder, true)
expect(vault.getFolderByPath('testfolder')).toBeNull() for (const file of files) { expect(vault.getFileByPath(file.path)).toBeNull() }
expect(vault.getMarkdownFiles()).toHaveLength(0) } ), { numRuns: RUNS }) })
it('rename updates both index and file.path', async () => { await fc.assert(fc.asyncProperty(fileNameArb, fileNameArb, async (oldName, newName) => { fc.pre(oldName !== newName)
const vault = makeVault() const file = await vault.create(`${oldName}.md`, 'content') await vault.rename(file, `${newName}.md`)
expect(file.path).toBe(`${newName}.md`) expect(file.name).toBe(`${newName}.md`) expect(vault.getFileByPath(`${newName}.md`)).toBe(file) expect(vault.getFileByPath(`${oldName}.md`)).toBeNull() }), { numRuns: RUNS }) })
// Cross-folder rename (move) — covers BOTH parent change and name change // simultaneously. This is where the rename() code has two branches // (parentChanged + nameChanged); exercising both together catches event // ordering and TOCTOU bugs that single-branch tests miss. it('cross-folder rename with new name updates parent, name, path, and index', async () => { await fc.assert(fc.asyncProperty(fileNameArb, fileNameArb, fileNameArb, fileNameArb, async (folderA, folderB, oldFileName, newFileName) => { fc.pre(folderA !== folderB) fc.pre(folderA !== oldFileName && folderA !== newFileName) fc.pre(folderB !== oldFileName && folderB !== newFileName) fc.pre(oldFileName !== newFileName)
const vault = makeVault() const a = await vault.createFolder(folderA) const b = await vault.createFolder(folderB) const file = await vault.create(`${folderA}/${oldFileName}.md`, 'content')
expect(file.parent).toBe(a) expect(vault.getFileByPath(`${folderA}/${oldFileName}.md`)).toBe(file)
await vault.rename(file, `${folderB}/${newFileName}.md`)
// Both parent and name changed expect(file.path).toBe(`${folderB}/${newFileName}.md`) expect(file.name).toBe(`${newFileName}.md`) expect(file.parent).toBe(b) expect(vault.getFileByPath(`${folderB}/${newFileName}.md`)).toBe(file) expect(vault.getFileByPath(`${folderA}/${oldFileName}.md`)).toBeNull() expect(vault.getFileByPath(`${folderA}/${newFileName}.md`)).toBeNull() expect(vault.getFileByPath(`${folderB}/${oldFileName}.md`)).toBeNull() // file no longer appears in folderA.children expect(a.children).not.toContain(file) expect(b.children).toContain(file) } ), { numRuns: 100 }) })
// Cross-folder rename with SAME filename — verifies parent-only change. it('cross-folder rename with same name updates parent and path', async () => { await fc.assert(fc.asyncProperty(fileNameArb, fileNameArb, fileNameArb, async (folderA, folderB, fileName) => { fc.pre(folderA !== folderB) fc.pre(folderA !== fileName && folderB !== fileName)
const vault = makeVault() const a = await vault.createFolder(folderA) const b = await vault.createFolder(folderB) const file = await vault.create(`${folderA}/${fileName}.md`, 'content')
await vault.rename(file, `${folderB}/${fileName}.md`)
expect(file.path).toBe(`${folderB}/${fileName}.md`) expect(file.parent).toBe(b) expect(vault.getFileByPath(`${folderB}/${fileName}.md`)).toBe(file) expect(vault.getFileByPath(`${folderA}/${fileName}.md`)).toBeNull() expect(a.children).not.toContain(file) expect(b.children).toContain(file) } ), { numRuns: 100 }) })
// Folder rename must fire rename events for the folder AND its descendants, // exactly once each, with parent rename before descendants (Obsidian // contract: plugins iterate children only after seeing parent's rename). it('folder rename fires exactly one rename event per affected entry, parent first', async () => { await fc.assert(fc.asyncProperty(fileNameArb, fileNameArb, async (oldFolderName, newFolderName) => { fc.pre(oldFolderName !== newFolderName)
const vault = makeVault() const folder = await vault.createFolder(oldFolderName) const child1 = await vault.create(`${oldFolderName}/a.md`, 'a') const child2 = await vault.create(`${oldFolderName}/b.md`, 'b')
const renameEvents: Array<{ path: string; oldPath: string }> = [] vault.on('rename', (file, oldPath) => { renameEvents.push({ path: (file as { path: string }).path, oldPath }) })
await vault.rename(folder, newFolderName)
// Exact count: folder + 2 children = 3 events. No spurious extras // from adoption paths or duplicate firing. expect(renameEvents).toHaveLength(3)
// Parent must fire FIRST so plugins see the folder rename before // any descendant rename. expect(renameEvents[0]).toEqual({ path: newFolderName, oldPath: oldFolderName })
// Both children must appear in events 1 and 2 (order between them // is not specified by contract). const childEvents = renameEvents.slice(1) const expectedChildPairs = new Set([ `${newFolderName}/a.md|${oldFolderName}/a.md`, `${newFolderName}/b.md|${oldFolderName}/b.md`, ]) for (const e of childEvents) { expect(expectedChildPairs.has(`${e.path}|${e.oldPath}`)).toBe(true) }
// Underlying files have updated paths expect(child1.path).toBe(`${newFolderName}/a.md`) expect(child2.path).toBe(`${newFolderName}/b.md`) } ), { numRuns: 100 }) })
// B2 regression: rekey() previously iterated urlIndex in insertion order // to update descendant paths. When a file had been MOVED into its current // parent via updateParentUrl (which mutates without re-keying the Map), // its insertion position predated its new parent's insertion position. // The loop would then call updateFilePath() on the moved file before its // parent had been path-updated, producing a stale path. Fix: traverse // top-down via the live parent chain rather than urlIndex insertion order. it('rename(ancestor) after move correctly updates moved descendant path', async () => { const vault = makeVault() const a = await vault.createFolder('A') const foo = await vault.create('A/foo.md', 'x') await vault.createFolder('A/B')
// Move foo.md from A to A/B. This calls updateParentUrl, which mutates // the entry's parentUrl without re-inserting in urlIndex. After this, // urlIndex insertion order is: A, foo.md, B — but foo.md.parent is now B. await vault.rename(foo, 'A/B/foo.md') expect(foo.path).toBe('A/B/foo.md') expect(vault.getFileByPath('A/B/foo.md')).toBe(foo)
// Now rename A → Z. The rekey loop must process B before foo.md so // that when foo.md's path is recomputed, its parent (B) already has // the new path 'Z/B'. await vault.rename(a, 'Z')
expect(a.path).toBe('Z') expect(foo.path).toBe('Z/B/foo.md') expect(vault.getFileByPath('Z/B/foo.md')).toBe(foo) expect(vault.getFileByPath('A/B/foo.md')).toBeNull() })
// Variant: deeper move-then-rename to stress the traversal at depth. it('rename(top ancestor) after deep move correctly updates all descendant paths', async () => { const vault = makeVault() const a = await vault.createFolder('A') await vault.createFolder('A/B') await vault.createFolder('A/B/C') const leaf = await vault.create('A/leaf.md', 'l')
// Move leaf.md into A/B/C await vault.rename(leaf, 'A/B/C/leaf.md') expect(leaf.path).toBe('A/B/C/leaf.md')
// Rename A → Z. Expect every descendant including the moved leaf to // get the correct new path. await vault.rename(a, 'Z') expect(vault.getFolderByPath('Z')).toBe(a) expect(vault.getFolderByPath('Z/B')).not.toBeNull() expect(vault.getFolderByPath('Z/B/C')).not.toBeNull() expect(vault.getFileByPath('Z/B/C/leaf.md')).toBe(leaf) expect(leaf.path).toBe('Z/B/C/leaf.md') }) })
// ----------------------------------------------------------------------- // Invariant 5: Root folder protection. // ----------------------------------------------------------------------- describe('Invariant 5: Root folder protection', () => { it('delete() on root always throws', async () => { const vault = makeVault() await expect(vault.delete(vault.getRoot())).rejects.toThrow('Cannot delete vault root') })
it('rename() on root always throws', async () => { const vault = makeVault() await expect(vault.rename(vault.getRoot(), 'newname')).rejects.toThrow('Cannot rename root folder') })
it('adapter.rmdir() on root/slash/empty always throws', async () => { await fc.assert(fc.asyncProperty( fc.constantFrom('', '/', '///', '////'), fc.boolean(), async (rootVariant, recursive) => { const vault = makeVault() await expect(vault.adapter.rmdir(rootVariant, recursive)).rejects.toThrow('Cannot delete vault root') } ), { numRuns: 20 }) })
it('stale path-empty folder object cannot bypass root protection', async () => { const vault = makeVault() const staleRoot = createTFolder('', null) staleRoot.vault = vault await expect(vault.delete(staleRoot)).rejects.toThrow('Cannot delete vault root') })
// Even a stale folder with a fake non-null parent but empty name is // still root-shaped and must be rejected. it('stale folder with non-null parent but empty name is treated as root', async () => { const vault = makeVault() const fakeParent = createTFolder('parent', null) const staleEmptyName = createTFolder('', fakeParent) staleEmptyName.vault = vault // Either reject as root, or reject as not-indexed — both are acceptable; // what is NOT acceptable is silent success. await expect(vault.delete(staleEmptyName)).rejects.toThrow(/Cannot delete vault root|File not found/) }) })
// ----------------------------------------------------------------------- // Invariant 6: delete() respects the force parameter (Obsidian contract). // delete(folder, force=false) on a non-empty folder must throw. // ----------------------------------------------------------------------- describe('Invariant 6: delete() force parameter respects Obsidian contract', () => { it('delete(non-empty folder, force=false) throws and preserves contents', async () => { await fc.assert(fc.asyncProperty(fileNameArb, fileNameArb, async (folderName, fileName) => { fc.pre(folderName !== fileName)
const vault = makeVault() const folder = await vault.createFolder(folderName) const file = await vault.create(`${folderName}/${fileName}.md`, 'content')
await expect(vault.delete(folder, false)).rejects.toThrow(/not empty/i) await expect(vault.delete(folder)).rejects.toThrow(/not empty/i) // default
// Folder and file still present expect(vault.getFolderByPath(folderName)).toBe(folder) expect(vault.getFileByPath(`${folderName}/${fileName}.md`)).toBe(file) } ), { numRuns: 100 }) })
it('delete(empty folder, force=false) succeeds', async () => { await fc.assert(fc.asyncProperty(fileNameArb, async (folderName) => { const vault = makeVault() const folder = await vault.createFolder(folderName) await vault.delete(folder, false) expect(vault.getFolderByPath(folderName)).toBeNull() } ), { numRuns: 100 }) })
// Regression: delete(non-empty, force=false) MUST be side-effect-free. // It must not adopt descendants, fire rename events, mutate parent/child // arrays, or otherwise alter index/tree state. The earlier implementation // called collectStorageBackedDescendants which has side effects, then // threw — leaking state changes for a rejected operation. it('delete(non-empty folder, force=false) has no side effects', async () => { await fc.assert(fc.asyncProperty(fileNameArb, fileNameArb, async (folderName, fileName) => { fc.pre(folderName !== fileName)
const vault = makeVault() const folder = await vault.createFolder(folderName) const file = await vault.create(`${folderName}/${fileName}.md`, 'content')
// Snapshot pre-throw state const renameEvents: unknown[] = [] const deleteEvents: unknown[] = [] const createEvents: unknown[] = [] vault.on('rename', (...args) => renameEvents.push(args)) vault.on('delete', (...args) => deleteEvents.push(args)) vault.on('create', (...args) => createEvents.push(args))
const folderChildrenBefore = [...folder.children] const fileParentBefore = file.parent const fileParentChildrenBefore = fileParentBefore ? [...fileParentBefore.children] : [] const filePathBefore = file.path
await expect(vault.delete(folder, false)).rejects.toThrow(/not empty/i)
// No events fired expect(renameEvents).toHaveLength(0) expect(deleteEvents).toHaveLength(0) expect(createEvents).toHaveLength(0) // Tree structure unchanged (folder.children, file.parent identity, // and file.parent.children — the last guards against silent // re-parenting via stray adoption paths) expect(folder.children).toEqual(folderChildrenBefore) expect(file.parent).toBe(fileParentBefore) expect(file.path).toBe(filePathBefore) if (fileParentBefore) { expect(fileParentBefore.children).toEqual(fileParentChildrenBefore) } // Index unchanged expect(vault.getFolderByPath(folderName)).toBe(folder) expect(vault.getFileByPath(`${folderName}/${fileName}.md`)).toBe(file) } ), { numRuns: 100 }) })
it('delete(non-empty folder, force=true) succeeds and removes contents', async () => { await fc.assert(fc.asyncProperty(fileNameArb, fileNameArb, async (folderName, fileName) => { fc.pre(folderName !== fileName)
const vault = makeVault() const folder = await vault.createFolder(folderName) await vault.create(`${folderName}/${fileName}.md`, 'content')
await vault.delete(folder, true) expect(vault.getFolderByPath(folderName)).toBeNull() expect(vault.getFileByPath(`${folderName}/${fileName}.md`)).toBeNull() } ), { numRuns: 100 }) })
})})