import * as A from '@automerge/automerge' import { Repo } from '@automerge/automerge-repo' import { describe, expect, it, vi } from 'vitest' import { DocumentManager } from '../../services/document-manager' import { encodeBlockFactKey } from '../../types-records/block-record-facts' import { TextFirstSyncController } from '../text-first-sync-controller' import { EditorState } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { history } from '@codemirror/commands' import { lineAttachmentExtension, lineAttachmentOwners } from '../../editor/line-attachment-extension' describe('TextFirstSyncController catalog safety', () => { it.each(['disabled', 'catalog-refresh'] as const)('accepts %s option updates while document preparation is pending', async mode => { const repo = new Repo({}) const manager = new DocumentManager(repo) const url = manager.createFile('loading.md', undefined, 'plain') let release!: () => void const gate = new Promise(resolve => { release = resolve }) const prepare = manager.prepareTextFirstDocument.bind(manager) vi.spyOn(manager, 'prepareTextFirstDocument').mockImplementation(async id => { await gate; return prepare(id) }) const options = { documentManager: manager, docUrl: url, typesRecordsEnabled: mode !== 'disabled', catalogReady: true } const controller = new TextFirstSyncController(options) try { controller.start() if (mode === 'catalog-refresh') await controller.updateOptions({ ...options, catalogReady: false }) await expect(controller.updateOptions(options)).resolves.toBeUndefined() expect(controller.getSnapshot().loadPhase).toBe('loading') release() await viWaitFor(() => expect(controller.getSnapshot().loadPhase).toBe('ready')) expect(controller.onChange('plain!')).toBe('plain!') } finally { release() await controller.dispose() manager.dispose() await repo.shutdown() } }) it.each(['unresolved', 'disabled'] as const)('guards all bound-editor write entrances while policy is %s', async mode => { const repo = new Repo({}) const manager = new DocumentManager(repo) const text = 'Persisted #task' const url = manager.createFile('bound.md', undefined, text) const ready = { documentManager: manager, docUrl: url, recognizedTypeNames: ['task'], catalogReady: true, typesRecordsEnabled: true } const controller = new TextFirstSyncController(ready) const view = new EditorView({ state: EditorState.create({ doc: text, extensions: [history(), lineAttachmentExtension, EditorView.updateListener.of(update => controller.onDocumentUpdate(update)), ] }), parent: document.body }) try { controller.start() await viWaitFor(() => expect(controller.getSnapshot().loadPhase).toBe('ready')) controller.bindView(view) const owner = manager.getTextFirstDocumentState(url).lineOwners[0]! const key = encodeBlockFactKey(owner.nodeId, 'task', 'title') const handle = (await manager.findFileHandle(url))! handle.change(draft => { (draft as Record)[key] = 'kept' }) await controller.flushPendingPlain() const heads = A.getHeads(handle.doc()) await controller.updateOptions({ ...ready, catalogReady: mode !== 'unresolved', typesRecordsEnabled: mode !== 'disabled' }) await controller.onSemanticChange('Removed', null) expect(await controller.prepareExternalWrite()).toBeNull() view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: 'Removed' } }) await viWaitFor(() => expect(view.state.doc.toString()).toBe(text)) expect(A.getHeads(handle.doc())).toEqual(heads) expect(handle.doc()).toHaveProperty(key, 'kept') await controller.updateOptions(ready) expect(manager.getTextFirstDocumentState(url).lineOwners[0]?.nodeId).toBe(owner.nodeId) const release = await controller.prepareExternalWrite() expect(release).not.toBeNull() release?.() } finally { await controller.dispose() view.destroy() manager.dispose() await repo.shutdown() } }) it('defers unresolved catalog maintenance and distinguishes an authoritative empty catalog', async () => { const repo = new Repo({}) const manager = new DocumentManager(repo) const url = manager.createFile('note.md', undefined, 'Persisted #task') const controller = new TextFirstSyncController({ documentManager: manager, docUrl: url, recognizedTypeNames: [], catalogReady: false, typesRecordsEnabled: true, }) try { await manager.prepareTextFirstDocument(url) await manager.initializeTextFirstLineOwners(url, ['task']) const prepared = manager.getTextFirstDocumentState(url) const owner = prepared.lineOwners[0]! const key = encodeBlockFactKey(owner.nodeId, 'task', 'title') const handle = (await manager.findFileHandle(url))! handle.change(draft => { (draft as Record)[key] = 'kept' }) const loadingHeads = A.getHeads(handle.doc()) controller.start() expect(A.getHeads(handle.doc())).toEqual(loadingHeads) expect(await manager.readBlockFacts(url)).toHaveProperty(key) await controller.updateOptions({ documentManager: manager, docUrl: url, recognizedTypeNames: ['task'], catalogReady: true, typesRecordsEnabled: true }) await viWaitFor(() => expect(controller.getSnapshot().loadPhase).toBe('ready')) expect(manager.getTextFirstDocumentState(url).lineOwners[0]?.nodeId).toBe(owner.nodeId) expect(await manager.readBlockFacts(url)).toHaveProperty(key) await controller.updateOptions({ documentManager: manager, docUrl: url, recognizedTypeNames: [], catalogReady: false, typesRecordsEnabled: true }) expect(controller.onChange('Persisted')).toBeNull() expect(await manager.readBlockFacts(url)).toHaveProperty(key) expect(manager.getTextFirstDocumentState(url).lineOwners[0]?.nodeId).toBe(owner.nodeId) await controller.updateOptions({ documentManager: manager, docUrl: url, recognizedTypeNames: ['task'], catalogReady: true, typesRecordsEnabled: true }) expect(manager.getTextFirstDocumentState(url).lineOwners[0]?.nodeId).toBe(owner.nodeId) expect(await manager.readBlockFacts(url)).toHaveProperty(key) await controller.updateOptions({ documentManager: manager, docUrl: url, recognizedTypeNames: [], catalogReady: true, typesRecordsEnabled: true }) expect(manager.getTextFirstDocumentState(url).lineOwners).toEqual([]) expect(await manager.readBlockFacts(url)).not.toHaveProperty(key) } finally { await controller.dispose() manager.dispose() await repo.shutdown() } }) it('restores policy errors without clearing an unrelated stale-source failure', async () => { const repo = new Repo({}) const manager = new DocumentManager(repo) const url = manager.createFile('policy.md', undefined, 'Persisted #task') const options = { documentManager: manager, docUrl: url, recognizedTypeNames: ['task'], catalogReady: true, typesRecordsEnabled: true } const controller = new TextFirstSyncController(options) try { controller.start() await viWaitFor(() => expect(controller.getSnapshot().loadPhase).toBe('ready')) await controller.updateOptions({ ...options, catalogReady: false }) expect(controller.getSnapshot().savePhase).toBe('error') await controller.updateOptions(options) expect(controller.getSnapshot().savePhase).toBe('idle') expect(controller.onChange('Changed', 'wrong source')).toBeNull() expect(controller.getSnapshot().saveStatus).toEqual({ state: 'error', message: 'Type editor source is based on stale document text' }) await controller.updateOptions(options) expect(controller.getSnapshot().saveStatus).toEqual({ state: 'error', message: 'Type editor source is based on stale document text' }) } finally { await controller.dispose() manager.dispose() await repo.shutdown() } }) it('reinstalls the attachment preparer when Types change from disabled to enabled', async () => { const repo = new Repo({}) const manager = new DocumentManager(repo) const text = 'Persisted #task' const url = manager.createFile('re-enable.md', undefined, text) await manager.prepareTextFirstDocument(url) await manager.initializeTextFirstLineOwners(url, ['task']) const disabled = { documentManager: manager, docUrl: url, recognizedTypeNames: ['task'], catalogReady: true, typesRecordsEnabled: false } const controller = new TextFirstSyncController(disabled) const view = new EditorView({ state: EditorState.create({ doc: text, extensions: [history(), lineAttachmentExtension, EditorView.updateListener.of(update => controller.onDocumentUpdate(update))] }), parent: document.body }) try { controller.start() await viWaitFor(() => expect(controller.getSnapshot().loadPhase).toBe('ready')) controller.bindView(view) expect(lineAttachmentOwners(view.state)).toHaveLength(1) await controller.updateOptions({ ...disabled, typesRecordsEnabled: true }) expect(lineAttachmentOwners(view.state)).toHaveLength(1) view.dispatch({ changes: { from: 10, to: text.length, insert: '' } }) expect(manager.getTextFirstDocumentState(url).content).toBe('Persisted ') expect(manager.getTextFirstDocumentState(url).lineOwners).toEqual([]) } finally { await controller.dispose() view.destroy() manager.dispose() await repo.shutdown() } }) }) async function viWaitFor(assertion: () => void): Promise { const deadline = Date.now() + 5000 while (true) { try { assertion(); return } catch (error) { if (Date.now() >= deadline) throw error await new Promise(resolve => setTimeout(resolve, 10)) } } }