Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'import { Repo } from '@automerge/automerge-repo'import * as A from '@automerge/automerge'import type { AutomergeUrl } from '@automerge/automerge-repo'import type { PushworkDirectoryDocument, PushworkFileDocument } from '../../types/pushwork'import { DocumentManager, DocumentManagerExistingEntryError, DocumentManagerValidationError, type ListFoldState } from '../document-manager'import { encodeBlockFactKey } from '../../types-records/block-record-facts'import { readAvailableText } from './document-manager-test-helpers'import { isPushworkDirectory, isPushworkFile } from '../../types/pushwork'
const encodeListFoldKey = (nodeId: string) => `reams.ui.list-fold.v1:${JSON.stringify(nodeId)}`
describe('DocumentManager', () => { let repo: Repo let manager: DocumentManager
beforeEach(() => { repo = new Repo() manager = new DocumentManager(repo) })
afterEach(async () => { manager.dispose() await new Promise(resolve => setTimeout(resolve, 0)) await repo.shutdown() })
async function text(url: AutomergeUrl) { return readAvailableText(manager, url) }
it('creates a file with the canonical Pushwork shape', async () => { const url = manager.createFile('note.md') const handle = await repo.find(url) expect(handle.doc()).toMatchObject({ '@patchwork': { type: 'file' }, name: 'note.md', extension: 'md', mimeType: 'text/markdown', content: '', metadata: { permissions: 0o644 }, }) })
it('creates folders and publishes initialized children atomically', async () => { const folder = manager.createFolder() expect((await repo.find<PushworkDirectoryDocument>(folder)).doc()).toMatchObject({ '@patchwork': { type: 'folder' }, docs: [] }) const file = await manager.createFileInFolder(folder, 'ready.json', undefined, '{"ready":true}') expect((await text(file)).content).toBe('{"ready":true}') expect((await manager.getFolder(folder))!.docs).toEqual([expect.objectContaining({ url: file, name: 'ready.json' })]) })
it('leaves no parent entry when publication fails after child initialization', async () => { const folder = manager.createFolder() const folderHandle = await repo.find<PushworkDirectoryDocument>(folder) const originalChange = folderHandle.change.bind(folderHandle) folderHandle.change = (() => { throw new Error('publication failed') }) as typeof folderHandle.change try { await expect(manager.createFileInFolder(folder, 'failed.json', undefined, '{"ready":true}')).rejects.toThrow('publication failed') expect((await repo.find<PushworkDirectoryDocument>(folder)).doc().docs).toEqual([]) } finally { folderHandle.change = originalChange } })
it('persists list folds as independent top-level Reams keys', async () => { const url = manager.createFile('folds.md') await manager.updateContent(url, '- parent\n - child') const before = await manager.readText(url) expect(before.status).toBe('available') await manager.updateListFold(url, 'node-parent', true) const after = await manager.readText(url) expect(after).toEqual(expect.objectContaining({ content: '- parent\n - child' })) expect(await manager.readListFolds(url)).toEqual({ 'node-parent': { collapsed: true } }) await expect(manager.readMarkdown(url)).resolves.toMatchObject({ status: 'available', content: '- parent\n - child', listFolds: { 'node-parent': { collapsed: true } }, }) if (before.status === 'available' && after.status === 'available') expect(after.heads).not.toEqual(before.heads) const handle = await repo.find<PushworkFileDocument>(url) expect((handle.doc() as Record<string, unknown>)[encodeListFoldKey('node-parent')]).toBe(true) })
it('rejects list fold writes that cannot be persisted', async () => { const folderUrl = manager.createFolder() await expect(manager.updateListFold(folderUrl, 'node-parent', true)).rejects.toThrow('not a writable file') const fileUrl = manager.createFile('folds.md') await expect(manager.updateListFold(fileUrl, '', true)).rejects.toThrow('node ID is required') })
it('merges first fold writes to different nodes without a shared container conflict', () => { const base = A.from<Record<string, unknown>>({ content: '- parent\n - child' }) const left = A.change(A.clone(base, { actor: 'a'.repeat(64) }), doc => { doc[encodeListFoldKey('node-left')] = true }) const right = A.change(A.clone(base, { actor: 'b'.repeat(64) }), doc => { doc[encodeListFoldKey('node-right')] = true }) const merged = A.merge(left, right) expect(merged[encodeListFoldKey('node-left')]).toBe(true) expect(merged[encodeListFoldKey('node-right')]).toBe(true) })
it('sanitizes malformed fold entries while retaining explicit false', async () => { const url = manager.createFile('folds.md') const handle = await repo.find<PushworkFileDocument>(url) handle.change(doc => { ;(doc as unknown as Record<string, unknown>)[encodeListFoldKey('open')] = false ;(doc as unknown as Record<string, unknown>)[encodeListFoldKey('bad')] = 'nope' ;(doc as unknown as Record<string, unknown>)['reams.ui.list-fold.v1:bad'] = true }) await expect(manager.readListFolds(url)).resolves.toEqual({ open: { collapsed: false } }) })
it('attaches the fold subscription before emitting its initial handle snapshot', async () => { const url = manager.createFile('folds.md') await manager.updateListFold(url, 'node-parent', true) const snapshots: ListFoldState[] = [] const unsubscribe = await manager.subscribeListFolds(url, snapshot => snapshots.push(snapshot)) expect(snapshots).toEqual([{ 'node-parent': { collapsed: true } }]) await manager.updateListFold(url, 'node-child', false) expect(snapshots[snapshots.length - 1]).toEqual({ 'node-parent': { collapsed: true }, 'node-child': { collapsed: false } }) unsubscribe() })
it('persists multiline public.json writes as scalar text', async () => { const url = manager.createFile('live.json') const content = '{\n "fields": {\n "title": {\n "type": "text"\n }\n }\n}' await expect(manager.updateContent(url, content)).resolves.toEqual(expect.objectContaining({ content })) await expect(manager.readText(url)).resolves.toMatchObject({ status: 'available', content }) })
describe('createFile', () => { it('creates a file document with correct name', async () => { const url = manager.createFile('note.md') const handle = await repo.find<PushworkFileDocument>(url) const doc = handle.doc() expect(doc.name).toBe('note.md') })
it('sets the @patchwork type to file', async () => { const url = manager.createFile('test.md') const handle = await repo.find<PushworkFileDocument>(url) const doc = handle.doc() expect(doc['@patchwork']).toEqual({ type: 'file' }) })
it('extracts extension from filename', async () => { const url = manager.createFile('note.md') const handle = await repo.find<PushworkFileDocument>(url) expect(handle.doc().extension).toBe('md') })
it('sets extension to empty string for files without extension', async () => { const url = manager.createFile('Makefile') const handle = await repo.find<PushworkFileDocument>(url) expect(handle.doc().extension).toBe('') })
it('infers MIME type from extension', async () => { const url = manager.createFile('note.md') const handle = await repo.find<PushworkFileDocument>(url) expect(handle.doc().mimeType).toBe('text/markdown') })
it('sets content to empty string and default permissions', async () => { const url = manager.createFile('note.md') const handle = await repo.find<PushworkFileDocument>(url) expect(handle.doc().content).toBe('') expect(handle.doc().metadata).toEqual({ permissions: 0o644 }) })
it('passes isPushworkFile type guard', async () => { const url = manager.createFile('note.md') const handle = await repo.find<PushworkFileDocument>(url) expect(isPushworkFile(handle.doc())).toBe(true) })
it('returns a valid AutomergeUrl', () => { const url = manager.createFile('test.md') expect(url).toMatch(/^automerge:/) })
it('document shape matches expected structure', async () => { const url = manager.createFile('readme.txt') const handle = await repo.find<PushworkFileDocument>(url) const doc = handle.doc() expect({ '@patchwork': doc['@patchwork'], name: doc.name, extension: doc.extension, mimeType: doc.mimeType, content: doc.content, metadata: doc.metadata, }).toEqual({ '@patchwork': { type: 'file' }, name: 'readme.txt', extension: 'txt', mimeType: 'text/plain', content: '', metadata: { permissions: 0o644 }, }) }) })
describe('createFolder', () => { it('creates a folder document with empty docs array', async () => { const url = manager.createFolder() const handle = await repo.find<PushworkDirectoryDocument>(url) const doc = handle.doc() expect(doc.docs).toEqual([]) })
it('sets the @patchwork type to folder', async () => { const url = manager.createFolder() const handle = await repo.find<PushworkDirectoryDocument>(url) const doc = handle.doc() expect(doc['@patchwork']).toEqual({ type: 'folder' }) })
it('passes isPushworkDirectory type guard', async () => { const url = manager.createFolder() const handle = await repo.find<PushworkDirectoryDocument>(url) expect(isPushworkDirectory(handle.doc())).toBe(true) })
it('returns a valid AutomergeUrl', () => { const url = manager.createFolder() expect(url).toMatch(/^automerge:/) })
it('document shape matches expected structure', async () => { const url = manager.createFolder() const handle = await repo.find<PushworkDirectoryDocument>(url) const doc = handle.doc() expect({ '@patchwork': doc['@patchwork'], docs: doc.docs, }).toEqual({ '@patchwork': { type: 'folder' }, docs: [], }) }) })
describe('createFileInFolder', () => { it('creates a file and adds it to the folder', async () => { const folderUrl = manager.createFolder() const fileUrl = await manager.createFileInFolder(folderUrl, 'note.md')
const folderHandle = await repo.find<PushworkDirectoryDocument>(folderUrl) const folder = folderHandle.doc() expect(folder.docs).toHaveLength(1) expect(folder.docs[0].name).toBe('note.md') expect(folder.docs[0].type).toBe('file') expect(folder.docs[0].url).toBe(fileUrl) })
it('creates valid file document', async () => { const folderUrl = manager.createFolder() const fileUrl = await manager.createFileInFolder(folderUrl, 'test.md')
const fileHandle = await repo.find<PushworkFileDocument>(fileUrl) expect(isPushworkFile(fileHandle.doc())).toBe(true) expect(fileHandle.doc().name).toBe('test.md') })
it('initializes text content before publishing the parent entry', async () => { const folderUrl = manager.createFolder() const fileUrl = await manager.createFileInFolder(folderUrl, 'type.json', undefined, '{"ready":true}') const fileHandle = await repo.find<PushworkFileDocument>(fileUrl) const folderHandle = await repo.find<PushworkDirectoryDocument>(folderUrl) expect(fileHandle.doc().content).toBe('{"ready":true}') expect(folderHandle.doc().docs[0]?.url).toBe(fileUrl) })
it('flushes an empty child before publishing the parent entry', async () => { const folderUrl = manager.createFolder() const folderHandle = await repo.find<PushworkDirectoryDocument>(folderUrl) const flush = vi.spyOn(repo, 'flush').mockResolvedValue(undefined) let flushesAtPublication = 0 folderHandle.on('change', () => { flushesAtPublication = flush.mock.calls.length })
await manager.createFileInFolder(folderUrl, 'empty.md')
expect(flushesAtPublication).toBeGreaterThanOrEqual(1) expect(flush).toHaveBeenCalledTimes(2) })
it('leaves no parent entry when publication fails after child initialization', async () => { const folderUrl = manager.createFolder() const folderHandle = await repo.find<PushworkDirectoryDocument>(folderUrl) vi.spyOn(folderHandle, 'change').mockImplementation(() => { throw new Error('publication failed') })
await expect(manager.createFileInFolder(folderUrl, 'type.json', undefined, '{"ready":true}')).rejects.toThrow('publication failed') expect((await repo.find<PushworkDirectoryDocument>(folderUrl)).doc().docs).toEqual([]) })
it('preserves the child when the parent change emitted before its flush failed', async () => { const folderUrl = manager.createFolder() const flushFailure = new Error('parent flush failed') const flush = vi.spyOn(repo, 'flush') .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(flushFailure)
try { const creation = manager.createFileInFolder(folderUrl, 'empty.md') await expect(creation).rejects.toBe(flushFailure) await expect(creation).rejects.not.toBeInstanceOf(DocumentManagerExistingEntryError) const entry = (await repo.find<PushworkDirectoryDocument>(folderUrl)).doc().docs[0] expect(entry?.name).toBe('empty.md') expect((await repo.find<PushworkFileDocument>(entry!.url)).doc().name).toBe('empty.md') } finally { flush.mockRestore() } }) })
describe.each(['binary', 'folder'] as const)('published %s child lifetime', kind => { const name = kind === 'binary' ? 'image.png' : 'nested' const bytes = new Uint8Array([1, 2, 3]) async function create(parent: AutomergeUrl) { return kind === 'binary' ? manager.createBinaryFileInFolder(parent, name, bytes, 'image/png') : manager.createSubfolder(parent, name) }
it.each(['flush', 'observer'])('preserves a linked child when parent %s fails', async failure => { const parent = manager.createFolder() const handle = await repo.find<PushworkDirectoryDocument>(parent) const error = new Error(`parent ${failure} failed`) const deletion = vi.spyOn(repo, 'delete') const flush = vi.spyOn(repo, 'flush').mockImplementation(async ids => { if (failure === 'flush' && ids?.includes(handle.documentId)) throw error }) const observer = () => { if (failure === 'observer') throw error } handle.on('change', observer) try { await expect(create(parent)).rejects.toBe(error) const entry = handle.doc().docs[0]! expect(entry.name).toBe(name) expect(deletion).not.toHaveBeenCalled() const child = await repo.find<PushworkFileDocument | PushworkDirectoryDocument>(entry.url) expect(child.isDeleted()).toBe(false) if (kind === 'binary') expect((child.doc() as PushworkFileDocument).content).toEqual(bytes) else expect((child.doc() as PushworkDirectoryDocument).docs).toEqual([]) expect(flush.mock.calls[0]?.[0]).toContain(child.documentId) } finally { handle.off('change', observer) } }) })
it('updates arbitrary text through the canonical writer', async () => { const url = manager.createFile('note.md') await manager.updateContent(url, 'first') const before = await text(url) const result = await manager.updateContentAt(url, before.heads, 'completely different\ntext') expect(result.beforeContent).toBe('first') expect(result.afterContent).toBe('completely different\ntext') expect((await text(url)).content).toBe('completely different\ntext') })
it('persists multiline public.json writes as scalar text', async () => { const url = manager.createFile('public.json') const content = '{\n "fields": {\n "title": {\n "type": "text"\n }\n }\n}' await expect(manager.updateContent(url, content)).resolves.toMatchObject({ content }) expect((await text(url)).content).toBe(content) })
it('preserves unrelated metadata through a frontmatter write', async () => { const url = manager.createFile('frontmatter.md') const handle = await repo.find<PushworkFileDocument>(url) handle.change(doc => { doc.metadata = { permissions: 0o600 } ;(doc as unknown as { pluginMetadata: unknown }).pluginMetadata = { keep: true } }) const content = '---\ntitle: Updated\n---\n\nBody' await manager.updateContent(url, content) expect((await repo.find<PushworkFileDocument>(url)).doc().metadata).toEqual({ permissions: 0o600 }) expect((await repo.find<PushworkFileDocument>(url)).doc() as PushworkFileDocument & { pluginMetadata?: unknown }).toHaveProperty('pluginMetadata', { keep: true }) expect((await text(url)).content).toBe(content) })
it('records the exact supplied frontier for concurrent writes', async () => { const url = manager.createFile('heads.md') await manager.updateContent(url, 'one') const stale = await text(url) await manager.updateContent(url, 'two') const result = await manager.updateContentAt(url, stale.heads, 'three') expect(result.beforeHeads).toEqual(stale.heads) expect((await text(url)).content).toBe('thrtwe') })
it('supports a valid fixed-width replacement and rejects invalid hints', async () => { const url = manager.createFile('hint.md') await manager.updateContent(url, 'hello world') const before = await text(url) await manager.updateContentAt(url, before.heads, 'hello brave world', undefined, { beforeFrom: 6, beforeTo: 6, afterFrom: 6, afterTo: 12, }) const next = await text(url) await expect(manager.updateContentAt(url, next.heads, 'hello world', undefined, { beforeFrom: 0, beforeTo: 1, afterFrom: 1, afterTo: 2, })).rejects.toMatchObject({ reason: 'invalid-input' }) expect((await text(url)).content).toBe('hello brave world') })
it('makes equal-content writes no-ops', async () => { const url = manager.createFile('noop.md') await manager.updateContent(url, 'same') const before = await text(url) const result = await manager.updateContentAt(url, before.heads, 'same') expect(result.heads).toEqual(before.heads) expect(result.beforeContent).toBe(result.afterContent) })
it('applies semantic text changes while validating ranges', async () => { const url = manager.createFile('semantic.md') await manager.updateContent(url, 'old') const before = await text(url) await expect(manager.updateContentWithInlineSemanticOps(url, before.heads, 'new', { ops: [{ kind: 'add-mark', specName: 'bold', range: { start: 0, end: 3 }, attrs: null }], })).resolves.toMatchObject({ beforeContent: 'old', afterContent: 'new' }) const next = await text(url) await expect(manager.updateContentWithInlineSemanticOps(url, next.heads, 'new', { ops: [{ kind: 'add-mark', specName: 'bold', range: { start: 1.5, end: 2 }, attrs: null }], })).rejects.toThrow('Invalid inline semantic range') })
it('writes block facts and reports repeated writes as no-ops', async () => { const url = manager.createFile('facts.md', undefined, 'task #task') await manager.prepareTextFirstDocument(url) await manager.initializeTextFirstLineOwners(url, ['task']) const key = encodeBlockFactKey(manager.getTextFirstDocumentState(url).lineOwners[0]!.nodeId, 'task', 'done') expect((await manager.writeBlockRecordFact(url, key, true)).changed).toBe(true) const repeated = await manager.writeBlockRecordFact(url, key, true) expect(repeated).toMatchObject({ changed: false, beforeContent: repeated.afterContent }) expect((await manager.readBlockFacts(url))![key]).toBe(true) })
it('updates and reads binary content without treating it as text', async () => { const url = manager.createBinaryFile('image.png', new Uint8Array([1, 2]), 'image/png') await manager.updateBinaryContent(url, new Uint8Array([3, 4])) expect(await manager.readBinaryContent(url)).toEqual(new Uint8Array([3, 4])) await expect(manager.updateContent(url, 'text')).rejects.toBeInstanceOf(DocumentManagerValidationError) })
it('rejects writes to folders with a typed validation error', async () => { await expect(manager.updateContent(manager.createFolder(), 'text')).rejects.toMatchObject({ reason: 'not-file' }) })
it('renames a file document and entry while preserving content and MIME metadata', async () => { const folder = manager.createFolder() const file = await manager.createFileInFolder(folder, 'old.md', undefined, 'keep') await manager.renameFile(file, folder, 'new.txt') const fileDoc = (await repo.find<PushworkFileDocument>(file)).doc() expect(fileDoc).toMatchObject({ name: 'new.txt', extension: 'txt', mimeType: 'text/plain' }) expect((await manager.getFolder(folder))!.docs[0]!.name).toBe('new.txt') expect((await text(file)).content).toBe('keep') })
it('deletes a file entry and its actual DocHandle', async () => { const folder = manager.createFolder() const file = await manager.createFileInFolder(folder, 'delete.md') const handle = await repo.find<PushworkFileDocument>(file) await manager.deleteFile(file, folder) expect((await manager.getFolder(folder))!.docs).toEqual([]) expect(handle.isDeleted()).toBe(true) })
it('moves a file entry without changing its URL or content', async () => { const source = manager.createFolder() const destination = manager.createFolder() const file = await manager.createFileInFolder(source, 'move.md', undefined, 'content') await manager.moveFile(file, source, destination) expect((await manager.getFolder(source))!.docs).toEqual([]) expect((await manager.getFolder(destination))!.docs[0]!).toMatchObject({ url: file, name: 'move.md', type: 'file' }) expect((await text(file)).content).toBe('content') })
it('deletes a folder entry and native DocHandle while retaining children', async () => { const parent = manager.createFolder() const child = await manager.createSubfolder(parent, 'child') const file = await manager.createFileInFolder(child, 'nested.md', undefined, 'nested') const handle = await repo.find<PushworkDirectoryDocument>(child) await manager.deleteFolder(child, parent) expect((await manager.getFolder(parent))!.docs).toEqual([]) expect(handle.isDeleted()).toBe(true) expect((await text(file)).content).toBe('nested') })
it('rejects duplicate file and subfolder names across concurrent managers', async () => { const folder = manager.createFolder() await manager.createFileInFolder(folder, 'same.md') await expect(manager.createFileInFolder(folder, 'same.md')).rejects.toMatchObject({ name: 'DocumentManagerExistingEntryError', entryName: 'same.md', }) await expect(manager.createFileInFolder(folder, 'same.md')).rejects.toBeInstanceOf(DocumentManagerExistingEntryError) const manager2 = new DocumentManager(repo) try { const files = await Promise.allSettled([ manager.createFileInFolder(folder, 'race.md'), manager2.createFileInFolder(folder, 'race.md'), ]) expect(files.filter(result => result.status === 'fulfilled')).toHaveLength(1) expect((await manager.getFolder(folder))!.docs.filter(entry => entry.name === 'race.md')).toHaveLength(1) const folders = await Promise.allSettled([ manager.createSubfolder(folder, 'subrace'), manager2.createSubfolder(folder, 'subrace'), ]) expect(folders.filter(result => result.status === 'fulfilled')).toHaveLength(1) expect((await manager.getFolder(folder))!.docs.filter(entry => entry.name === 'subrace')).toHaveLength(1) } finally { manager2.dispose() } })
it('exposes an initialized Markdown node index only after preparation and initialization', async () => { const url = manager.createFile('indexed.md') await manager.updateContent(url, '# Heading\n\nBody') await manager.prepareTextFirstDocument(url) await manager.initializeTextFirstLineOwners(url, ['task']) const markdown = await manager.readMarkdown(url) expect(markdown.status).toBe('available') if (markdown.status !== 'available') throw new Error('expected Markdown') expect(markdown.nodeIndex.status).toBe('available') })})