Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220import * 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<typeof makeFakeSessions>; 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<FileDocument>(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<FileDocument>(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<FileDocument>(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)); });});