import * as nodeFs from 'fs'; import * as nodePath from 'path'; import * as os from 'os'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { VaultSyncEngine } from '../../vault/engine'; import { VaultChangeDetector } from '../../vault/change-detection'; import { VaultSnapshotManager } from '../../vault/snapshot'; import { InMemoryHabitatRepo, NodeFilesystemApi, makeFakeSessions, type DocStore, } from '../../vault/testing/fakeHabitat'; import type { DirectoryDocument, FileDocument, HabitatUri, VaultConfig, } from '../../vault/types'; import { getAutomergePayload } from '../../editors/automerge/automergeEditorBinding'; import { SyncedVaultDocumentsProvider } from './syncedVaultDocumentsProvider'; const MIRROR_DEBOUNCE_MS = 1000; 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 loadSnapshot(dir: string): { files: [string, { uri: string; head: string[]; contentHash: string }][]; directories: [string, unknown][]; } { return JSON.parse(readFile(dir, '.textile/snapshot.json')); } function snapshotFile(dir: string, relPath: string) { return loadSnapshot(dir).files.find(([p]) => p === relPath)?.[1]; } describe('SyncedVaultDocumentsProvider', () => { let tmpDir: string; let store: DocStore; let repo: InMemoryHabitatRepo; let sessions: ReturnType; let engine: VaultSyncEngine; let vault: VaultConfig; let provider: SyncedVaultDocumentsProvider; beforeEach(async () => { tmpDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'textile-syncdoc-')); store = new Map(); repo = new InMemoryHabitatRepo(store); const { uri: rootUri } = await repo.createDirectory(makeRootDirectoryDoc()); vault = { type: 'synced', rootUri, }; const fs = new NodeFilesystemApi(); const snapshots = new VaultSnapshotManager(fs); const detector = new VaultChangeDetector(fs, repo); engine = new VaultSyncEngine(fs, repo, snapshots, detector); sessions = makeFakeSessions(store); provider = new SyncedVaultDocumentsProvider({ id: 'vault-test', vault, rootPath: tmpDir, engine, snapshots, fs, acquireSession: sessions.acquire, }); }); afterEach(() => { vi.useRealTimers(); nodeFs.rmSync(tmpDir, { recursive: true, force: true }); }); it('opens an untracked on-disk file by provisioning + tracking the remote doc', async () => { writeFile(tmpDir, 'note.md', 'hello'); const handle = await provider.openDocument( nodePath.join(tmpDir, 'note.md'), ); // Snapshot now tracks the file with a uri. const entry = snapshotFile(tmpDir, 'note.md'); expect(entry).toBeDefined(); const uri = entry!.uri as HabitatUri; expect(handle.id).toBe(uri); // The remote doc was created with the on-disk content. expect( (repo.getDoc(uri) as unknown as FileDocument).content, ).toBe('hello'); // The editor binding is wired to the live session doc. const payload = getAutomergePayload(handle.getEditorBinding()); expect(payload.getDoc().content).toBe('hello'); // Vault files are titled by filename (matches the sidebar). expect(handle.getTitle()).toBe('note.md'); }); it('refuses to open a non-text file (text-only guard)', async () => { writeFile(tmpDir, 'image.png', 'not really an image'); const docCountBefore = repo.docCount(); await expect( provider.openDocument(nodePath.join(tmpDir, 'image.png')), ).rejects.toThrow(/non-text/i); // No remote doc was provisioned for the rejected file. expect(repo.docCount()).toBe(docCountBefore); }); it('pulls newer remote content to disk before binding a tracked file', async () => { // Establish shared.md locally + remotely. writeFile(tmpDir, 'shared.md', 'v1'); await engine.sync(vault, tmpDir); const uri = snapshotFile(tmpDir, 'shared.md')!.uri as HabitatUri; // Remote edit advances the doc; disk still says v1. const remote = await repo.find(uri); remote.change((d) => { (d as unknown as FileDocument).content = 'v2 from remote'; }); const remoteHeads = remote.heads(); const handle = await provider.openDocument( nodePath.join(tmpDir, 'shared.md'), ); expect(readFile(tmpDir, 'shared.md')).toBe('v2 from remote'); expect( getAutomergePayload(handle.getEditorBinding()).getDoc().content, ).toBe('v2 from remote'); expect(snapshotFile(tmpDir, 'shared.md')!.head).toEqual(remoteHeads); }); it('mirrors in-app edits to disk and keeps the snapshot consistent', async () => { vi.useFakeTimers(); writeFile(tmpDir, 'doc.md', 'v1'); await engine.sync(vault, tmpDir); const uri = snapshotFile(tmpDir, 'doc.md')!.uri as HabitatUri; await provider.openDocument(nodePath.join(tmpDir, 'doc.md')); // Edit through the live session, then let the mirror debounce fire. sessions.get(uri)!.applyChange((d) => { (d as { content: string }).content = 'edited'; }); await vi.advanceTimersByTimeAsync(MIRROR_DEBOUNCE_MS); // Mirror wrote to disk; the shared store (remote) reflects the edit too. expect(readFile(tmpDir, 'doc.md')).toBe('edited'); expect( (repo.getDoc(uri) as unknown as FileDocument).content, ).toBe('edited'); // The snapshot was updated, so a steady-state sync sees no phantom change. vi.useRealTimers(); const result = await engine.sync(vault, tmpDir); expect(result.pushed).toBe(0); }); it('release marks the session released and stops mirroring', async () => { vi.useFakeTimers(); writeFile(tmpDir, 'doc.md', 'v1'); await engine.sync(vault, tmpDir); const uri = snapshotFile(tmpDir, 'doc.md')!.uri as HabitatUri; const handle = await provider.openDocument(nodePath.join(tmpDir, 'doc.md')); handle.release(); expect(sessions.get(uri)!.released).toBe(true); // A post-release edit must not be mirrored to disk. sessions.get(uri)!.applyChange((d) => { (d as { content: string }).content = 'after release'; }); await vi.advanceTimersByTimeAsync(MIRROR_DEBOUNCE_MS); expect(readFile(tmpDir, 'doc.md')).toBe('v1'); }); it('createDocument() creates a new untitled file on disk and tracks it', async () => { const handle = await provider.createDocument(); expect(nodeFs.existsSync(nodePath.join(tmpDir, 'Untitled.md'))).toBe(true); const entry = snapshotFile(tmpDir, 'Untitled.md'); expect(entry).toBeDefined(); expect(handle.id).toBe(entry!.uri); }); it('opening the same path twice reuses one session', async () => { writeFile(tmpDir, 'note.md', 'hello'); const a = await provider.openDocument(nodePath.join(tmpDir, 'note.md')); const b = await provider.openDocument(nodePath.join(tmpDir, 'note.md')); expect(a.id).toBe(b.id); // A single FakeDocSession instance backs both handles. expect(sessions.get(a.id)).toBe(sessions.get(b.id)); }); });