import * as nodeFs from 'fs'; import * as nodePath from 'path'; import * as os from 'os'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { VaultSyncEngine } from './engine'; import { VaultChangeDetector } from './change-detection'; import { VaultSnapshotManager } from './snapshot'; import { InMemoryHabitatRepo, NodeFilesystemApi } from './testing/fakeHabitat'; import type { DirectoryDocument, FileDocument, HabitatUri, VaultConfig, } from './types'; // --------------------------------------------------------------------------- // Test fixture helpers // --------------------------------------------------------------------------- function makeRootDirectoryDoc(): DirectoryDocument { return { '@patchwork': { type: 'folder' }, docs: [], name: 'root' }; } function writeFile(dir: string, relPath: string, content: string): void { const full = nodePath.join(dir, relPath); nodeFs.mkdirSync(nodePath.dirname(full), { recursive: true }); nodeFs.writeFileSync(full, content, 'utf-8'); } function readFile(dir: string, relPath: string): string { return nodeFs.readFileSync(nodePath.join(dir, relPath), 'utf-8'); } function fileExists(dir: string, relPath: string): boolean { return nodeFs.existsSync(nodePath.join(dir, relPath)); } // --------------------------------------------------------------------------- // Test suite // --------------------------------------------------------------------------- describe('VaultSyncEngine', () => { let tmpDir: string; let repo: InMemoryHabitatRepo; let rootUri: HabitatUri; let vaultConfig: VaultConfig; let engine: VaultSyncEngine; let fs: NodeFilesystemApi; beforeEach(async () => { tmpDir = nodeFs.mkdtempSync( nodePath.join(os.tmpdir(), 'textile-vault-test-'), ); repo = new InMemoryHabitatRepo(); const { uri } = await repo.createDirectory(makeRootDirectoryDoc()); rootUri = uri; vaultConfig = { type: 'synced', rootUri, }; fs = new NodeFilesystemApi(); const snapshotManager = new VaultSnapshotManager(fs); const detector = new VaultChangeDetector(fs, repo); engine = new VaultSyncEngine(fs, repo, snapshotManager, detector); }); afterEach(() => { nodeFs.rmSync(tmpDir, { recursive: true, force: true }); }); // ------------------------------------------------------------------------- // No-op cases // ------------------------------------------------------------------------- it('returns zeros for a local vault without touching the filesystem', async () => { const localConfig: VaultConfig = { ...vaultConfig, type: 'local' }; const result = await engine.sync(localConfig, tmpDir); expect(result).toEqual({ pushed: 0, pulled: 0, moves: 0 }); expect(nodeFs.existsSync(nodePath.join(tmpDir, '.textile'))).toBe(false); }); it('syncs an empty vault with no changes and saves a snapshot', async () => { const result = await engine.sync(vaultConfig, tmpDir); expect(result).toEqual({ pushed: 0, pulled: 0, moves: 0 }); expect(fileExists(tmpDir, '.textile/snapshot.json')).toBe(true); }); it('second sync with no changes is idempotent', async () => { await engine.sync(vaultConfig, tmpDir); const docCountAfterFirst = repo.docCount(); const result = await engine.sync(vaultConfig, tmpDir); expect(result).toEqual({ pushed: 0, pulled: 0, moves: 0 }); expect(repo.docCount()).toBe(docCountAfterFirst); }); // ------------------------------------------------------------------------- // Push: local → remote // ------------------------------------------------------------------------- it('pushes a new root-level file to the repo', async () => { writeFile(tmpDir, 'hello.md', '# Hello'); const result = await engine.sync(vaultConfig, tmpDir); expect(result.pushed).toBe(1); expect(result.pulled).toBe(0); // Snapshot records the file const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); const snapshot = JSON.parse(snapshotRaw); const fileEntry = snapshot.files.find(([p]: [string]) => p === 'hello.md'); expect(fileEntry).toBeDefined(); // Repo has a file doc for it const fileUri = fileEntry[1].uri as HabitatUri; const doc = repo.getDoc(fileUri); expect(doc).toBeDefined(); expect((doc as unknown as FileDocument).content).toBe('# Hello'); }); it('pushes a new file inside a new subdirectory, creating directory docs', async () => { writeFile(tmpDir, 'notes/ideas.md', 'some ideas'); const result = await engine.sync(vaultConfig, tmpDir); expect(result.pushed).toBe(1); const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); const snapshot = JSON.parse(snapshotRaw); // Both the file and its parent directory are in the snapshot const fileEntry = snapshot.files.find( ([p]: [string]) => p === 'notes/ideas.md', ); expect(fileEntry).toBeDefined(); const dirEntry = snapshot.directories.find( ([p]: [string]) => p === 'notes', ); expect(dirEntry).toBeDefined(); }); it('pushes an edit to an existing file', async () => { writeFile(tmpDir, 'doc.md', 'original'); await engine.sync(vaultConfig, tmpDir); writeFile(tmpDir, 'doc.md', 'updated'); const result = await engine.sync(vaultConfig, tmpDir); expect(result.pushed).toBe(1); const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); const snapshot = JSON.parse(snapshotRaw); const fileEntry = snapshot.files.find(([p]: [string]) => p === 'doc.md'); const fileUri = fileEntry[1].uri as HabitatUri; const doc = repo.getDoc(fileUri); expect((doc as unknown as FileDocument).content).toBe('updated'); }); it('removes a deleted file from the snapshot', async () => { writeFile(tmpDir, 'gone.md', 'bye'); await engine.sync(vaultConfig, tmpDir); nodeFs.rmSync(nodePath.join(tmpDir, 'gone.md')); const result = await engine.sync(vaultConfig, tmpDir); expect(result.pushed).toBe(1); const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); const snapshot = JSON.parse(snapshotRaw); const fileEntry = snapshot.files.find(([p]: [string]) => p === 'gone.md'); expect(fileEntry).toBeUndefined(); }); // ------------------------------------------------------------------------- // Move detection // ------------------------------------------------------------------------- it('detects a rename as a move, reusing the existing doc URI', async () => { writeFile( tmpDir, 'original.md', 'content that is long enough for similarity', ); await engine.sync(vaultConfig, tmpDir); const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); const snapshot = JSON.parse(snapshotRaw); const originalUri = snapshot.files.find( ([p]: [string]) => p === 'original.md', )[1].uri; // Rename: remove old, create new with same content nodeFs.rmSync(nodePath.join(tmpDir, 'original.md')); writeFile( tmpDir, 'renamed.md', 'content that is long enough for similarity', ); const result = await engine.sync(vaultConfig, tmpDir); expect(result.moves).toBe(1); const snapshotRaw2 = readFile(tmpDir, '.textile/snapshot.json'); const snapshot2 = JSON.parse(snapshotRaw2); const renamedEntry = snapshot2.files.find( ([p]: [string]) => p === 'renamed.md', ); expect(renamedEntry).toBeDefined(); // Same URI as the original — doc was updated in place, not recreated expect(renamedEntry[1].uri).toBe(originalUri); // Old path is gone expect( snapshot2.files.find(([p]: [string]) => p === 'original.md'), ).toBeUndefined(); }); // ------------------------------------------------------------------------- // First-open clone: remote → local (one time only) // ------------------------------------------------------------------------- it('clones a pre-existing remote file to the filesystem on first sync', async () => { // Seed a remote file + directory entry BEFORE the first sync (no snapshot). const { uri: fileUri } = await repo.createFile({ '@patchwork': { type: 'file' }, name: 'remote.md', extension: 'md', mimeType: 'text/markdown', content: 'from remote', metadata: { permissions: 0o644 }, }); const rootHandle = await repo.find(rootUri); rootHandle.change((d) => { (d as unknown as DirectoryDocument).docs.push({ name: 'remote.md', type: 'md', url: fileUri, }); }); const result = await engine.sync(vaultConfig, tmpDir); expect(result.pulled).toBe(1); expect(fileExists(tmpDir, 'remote.md')).toBe(true); expect(readFile(tmpDir, 'remote.md')).toBe('from remote'); }); it('does not pull remote edits to tracked files during steady-state sync', async () => { // Establish the file both locally and in the snapshot (first sync clones // nothing; this push creates the doc + snapshot entry). writeFile(tmpDir, 'shared.md', 'v1'); await engine.sync(vaultConfig, tmpDir); const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); const snapshot = JSON.parse(snapshotRaw); const fileUri = snapshot.files.find(([p]: [string]) => p === 'shared.md')[1] .uri as HabitatUri; // Remote edit lands on the doc. const handle = await repo.find(fileUri); handle.change((d) => { (d as unknown as FileDocument).content = 'v2 from remote'; }); // A steady-state sync must not walk/pull — disk stays at v1. const result = await engine.sync(vaultConfig, tmpDir); expect(result.pulled).toBe(0); expect(readFile(tmpDir, 'shared.md')).toBe('v1'); }); // ------------------------------------------------------------------------- // Per-file pull on open // ------------------------------------------------------------------------- it('openFile pulls the merged remote content to disk and advances the snapshot head', async () => { writeFile(tmpDir, 'shared.md', 'v1'); await engine.sync(vaultConfig, tmpDir); const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); const snapshot = JSON.parse(snapshotRaw); const fileUri = snapshot.files.find(([p]: [string]) => p === 'shared.md')[1] .uri as HabitatUri; const handle = await repo.find(fileUri); handle.change((d) => { (d as unknown as FileDocument).content = 'v2 from remote'; }); const remoteHeads = handle.heads(); await engine.openFile(vaultConfig, tmpDir, 'shared.md'); expect(readFile(tmpDir, 'shared.md')).toBe('v2 from remote'); const snapshotRaw2 = readFile(tmpDir, '.textile/snapshot.json'); const snapshot2 = JSON.parse(snapshotRaw2); const entry = snapshot2.files.find(([p]: [string]) => p === 'shared.md')[1]; expect(entry.head).toEqual(remoteHeads); }); it('openFile is a no-op for an untracked file', async () => { await engine.sync(vaultConfig, tmpDir); await engine.openFile(vaultConfig, tmpDir, 'never-seen.md'); expect(fileExists(tmpDir, 'never-seen.md')).toBe(false); }); // ------------------------------------------------------------------------- // Per-file push (save/edit events) // ------------------------------------------------------------------------- it('pushFile creates a new file doc and registers it in the root directory', async () => { await engine.sync(vaultConfig, tmpDir); // establish snapshot const docCountBefore = repo.docCount(); writeFile(tmpDir, 'note.md', '# Note'); await engine.pushFile(vaultConfig, tmpDir, 'note.md'); // A file doc was created. expect(repo.docCount()).toBe(docCountBefore + 1); const snapshot = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); const fileEntry = snapshot.files.find(([p]: [string]) => p === 'note.md'); expect(fileEntry).toBeDefined(); const fileUri = fileEntry[1].uri as HabitatUri; expect( (repo.getDoc(fileUri) as unknown as FileDocument).content, ).toBe('# Note'); // The root directory doc references it. const rootDoc = repo.getDoc( rootUri, ) as unknown as DirectoryDocument; expect(rootDoc.docs.some((e) => e.url === fileUri)).toBe(true); }); it('pushFile updates an existing file in place without recreating the doc', async () => { writeFile(tmpDir, 'doc.md', 'v1'); await engine.sync(vaultConfig, tmpDir); const snap1 = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); const uriBefore = snap1.files.find(([p]: [string]) => p === 'doc.md')[1] .uri; const docCountBefore = repo.docCount(); writeFile(tmpDir, 'doc.md', 'v2'); await engine.pushFile(vaultConfig, tmpDir, 'doc.md'); expect(repo.docCount()).toBe(docCountBefore); // no new doc const snap2 = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); const entry = snap2.files.find(([p]: [string]) => p === 'doc.md')[1]; expect(entry.uri).toBe(uriBefore); // same doc, edited in place expect( (repo.getDoc(uriBefore) as unknown as FileDocument).content, ).toBe('v2'); }); it('pushFile is a no-op when the file content is unchanged', async () => { writeFile(tmpDir, 'doc.md', 'same'); await engine.sync(vaultConfig, tmpDir); const snapBefore = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); const headBefore = snapBefore.files.find( ([p]: [string]) => p === 'doc.md', )[1].head; await engine.pushFile(vaultConfig, tmpDir, 'doc.md'); const snapAfter = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); const headAfter = snapAfter.files.find(([p]: [string]) => p === 'doc.md')[1] .head; expect(headAfter).toEqual(headBefore); // doc was not touched }); it('pushFile creates intermediate directory docs for a nested new file', async () => { await engine.sync(vaultConfig, tmpDir); writeFile(tmpDir, 'notes/ideas.md', 'some ideas'); await engine.pushFile(vaultConfig, tmpDir, 'notes/ideas.md'); const snapshot = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); expect( snapshot.files.find(([p]: [string]) => p === 'notes/ideas.md'), ).toBeDefined(); const dirEntry = snapshot.directories.find( ([p]: [string]) => p === 'notes', ); expect(dirEntry).toBeDefined(); // Parent dir doc references the file; root dir doc references the subdir. const dirUri = dirEntry[1].uri as HabitatUri; const dirDoc = repo.getDoc( dirUri, ) as unknown as DirectoryDocument; expect(dirDoc.docs.some((e) => e.name === 'ideas.md')).toBe(true); const rootDoc = repo.getDoc( rootUri, ) as unknown as DirectoryDocument; expect(rootDoc.docs.some((e) => e.url === dirUri)).toBe(true); }); it('pushFile is a no-op for a local vault', async () => { const localConfig: VaultConfig = { ...vaultConfig, type: 'local' }; writeFile(tmpDir, 'note.md', 'x'); await engine.pushFile(localConfig, tmpDir, 'note.md'); expect(fileExists(tmpDir, '.textile/snapshot.json')).toBe(false); expect(repo.docCount()).toBe(1); // only the root dir from beforeEach }); // ------------------------------------------------------------------------- // Snapshot correctness // ------------------------------------------------------------------------- it('snapshot head matches the repo doc heads after a push', async () => { writeFile(tmpDir, 'tracked.md', 'hello'); await engine.sync(vaultConfig, tmpDir); const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); const snapshot = JSON.parse(snapshotRaw); const fileEntry = snapshot.files.find( ([p]: [string]) => p === 'tracked.md', )[1]; const fileUri = fileEntry.uri as HabitatUri; const handle = await repo.find(fileUri); expect(fileEntry.head).toEqual(handle.heads()); }); it('snapshot contentHash matches the actual file content after a push', async () => { writeFile(tmpDir, 'tracked.md', 'hello world'); await engine.sync(vaultConfig, tmpDir); const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); const snapshot = JSON.parse(snapshotRaw); const fileEntry = snapshot.files.find( ([p]: [string]) => p === 'tracked.md', )[1]; // Compute expected hash the same way the engine does const { createHash } = await import('crypto'); const expected = 'sha256:' + createHash('sha256').update('hello world', 'utf8').digest('hex'); expect(fileEntry.contentHash).toBe(expected); }); });