Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324// src/sync/__tests__/use-document-sync-reactive.test.ts//// Layer-2 (workspace integration) tests for useDocumentSync's reactive// read-side. Drives the real hook through @testing-library/react renderHook// against a BroadcastChannel two-repo harness + a real EditorView. See// plan #34 Task 4 Step 1 and Step 5.import { describe, it, expect, vi, afterEach } from 'vitest'import { renderHook } from '@testing-library/react'import { EditorView } from '@codemirror/view'import { EditorState } from '@codemirror/state'import type { AutomergeUrl } from '@automerge/automerge-repo'import { createBCSyncHarness, applyRemoteContent, waitForContent, type BCSyncHarness,} from './support/bc-sync-harness'import { useDocumentSync } from '../use-document-sync'import { readText } from '../../testing/document-read-helpers'
// BC sync round-trips, debounced saver windows, and Automerge text-CRDT// merges add up. The default 5s per-test timeout is too tight for the// multi-step flows below.const TEST_TIMEOUT = 15_000
let harness: BCSyncHarness | undefined
async function setupSyncedDoc(initialContent: string): Promise<{ url: AutomergeUrl cleanup: () => Promise<void>}> { harness = createBCSyncHarness() const url = harness.managerA.createFile('note.md') await harness.managerA.updateContent(url, initialContent) await waitForContent(harness.managerB, url, initialContent) return { url, cleanup: async () => { await harness?.cleanup() harness = undefined }, }}
afterEach(async () => { await harness?.cleanup() harness = undefined})
describe('useDocumentSync reactive read-side', () => { it('applies a remote change to the supplied EditorView', { timeout: TEST_TIMEOUT }, async () => { const { url, cleanup } = await setupSyncedDoc('initial') const view = new EditorView({ state: EditorState.create({ doc: 'initial', extensions: [] }), }) const { result, unmount } = renderHook(() => useDocumentSync({ documentManager: harness!.managerB, docUrl: url, }), )
await vi.waitFor(() => expect(result.current.isLoading).toBe(false), { timeout: 5000, interval: 25, }) result.current.bindView(view) await vi.waitFor(() => expect(result.current.isSyncReady).toBe(true))
await applyRemoteContent(harness!, url, 'initial + tail') await vi.waitFor( () => expect(view.state.doc.toString()).toBe('initial + tail'), { timeout: 5000, interval: 25 }, )
unmount() view.destroy() await cleanup() })
it('applies a burst of remote changes in order', { timeout: TEST_TIMEOUT }, async () => { const { url, cleanup } = await setupSyncedDoc('start') const view = new EditorView({ state: EditorState.create({ doc: 'start', extensions: [] }), }) const { result, unmount } = renderHook(() => useDocumentSync({ documentManager: harness!.managerB, docUrl: url, }), )
await vi.waitFor(() => expect(result.current.isLoading).toBe(false), { timeout: 5000, interval: 25, }) result.current.bindView(view) await vi.waitFor(() => expect(result.current.isSyncReady).toBe(true))
await applyRemoteContent(harness!, url, 'start one') await applyRemoteContent(harness!, url, 'start one two') await applyRemoteContent(harness!, url, 'start one two three') await vi.waitFor( () => expect(view.state.doc.toString()).toBe('start one two three'), { timeout: 5000, interval: 25 }, )
unmount() view.destroy() await cleanup() })
it('does not re-echo a local CM transaction into a bound view', { timeout: TEST_TIMEOUT }, async () => { const { url, cleanup } = await setupSyncedDoc('word') const onRemoteApplied = vi.fn() const view = new EditorView({ state: EditorState.create({ doc: 'word', extensions: [EditorView.updateListener.of(update => result.current.onDocumentUpdate(update))] }), }) const { result, unmount } = renderHook(() => useDocumentSync({ documentManager: harness!.managerB, docUrl: url, onRemoteApplied, }), )
await vi.waitFor(() => expect(result.current.isLoading).toBe(false), { timeout: 5000, interval: 25, }) result.current.bindView(view) await vi.waitFor(() => expect(result.current.isSyncReady).toBe(true), { timeout: 5000, interval: 25, })
view.dispatch({ changes: { from: 4, insert: 'X' } }) await result.current.flushPendingPlain()
expect(onRemoteApplied).not.toHaveBeenCalled() expect(view.state.doc.toString()).toBe('wordX') expect((await readText(harness!.managerB, url)).content).toBe('wordX')
unmount() view.destroy() await cleanup() })
it("preserves B's unsaved keystroke when A's remote change arrives mid-window", { timeout: TEST_TIMEOUT }, async () => { // The B-2 lost-keystroke invariant. B has typed X into the buffer but // the debounced saver has not yet fired. A's remote change arrives via // BC sync. Automerge has not seen B's X. The reactive layer must dispatch // ONLY A's contribution (computed from patchInfo.before.content → // patchInfo.after.content), letting CM6 map B's local X through the // splice. B's X must NEVER transiently disappear during the merge. // // Strict-invariant variant per L2-1: record EVERY intermediate doc state // via an updateListener, then assert that every recorded state from the // moment X is inserted onward contains 'X'. `vi.waitFor` would mask a // transient disappearance because it retries the predicate until it // passes; a recorded-history check catches a flicker. const observedStates: string[] = [] const { url, cleanup } = await setupSyncedDoc('hello') const view = new EditorView({ state: EditorState.create({ doc: 'hello', extensions: [ EditorView.updateListener.of((update) => result.current.onDocumentUpdate(update)), EditorView.updateListener.of((u) => { if (u.docChanged) observedStates.push(u.state.doc.toString()) }), ], }), }) const { result, unmount } = renderHook(() => useDocumentSync({ documentManager: harness!.managerB, docUrl: url, }), ) await vi.waitFor(() => expect(result.current.isLoading).toBe(false), { timeout: 5000, interval: 25, }) result.current.bindView(view) await vi.waitFor(() => expect(result.current.isSyncReady).toBe(true))
// B types X through the actual controller update listener. view.dispatch({ changes: { from: 5, insert: 'X' } }) expect(view.state.doc.toString()).toBe('helloX')
// A inserts Y at offset 0. Automerge has not seen B's X. await applyRemoteContent(harness!, url, 'Yhello')
// Settlement gate: wait for the converged state to arrive. await vi.waitFor( () => expect(view.state.doc.toString()).toBe('YhelloX'), { timeout: 2000, interval: 25 }, )
// STRICT INVARIANT: every recorded state from the X-insert onward must // contain 'X'. If the reactive layer clobbered B's buffer (e.g. by // replacing the doc with A's full content), the resulting ViewUpdate // would surface a state WITHOUT 'X' — and we'd catch it here. const xInsertIdx = observedStates.findIndex((s) => s.includes('X')) expect(xInsertIdx, 'expected to find a state containing X').toBeGreaterThanOrEqual(0) for (let i = xInsertIdx; i < observedStates.length; i++) { expect(observedStates[i], `state[${i}]=${JSON.stringify(observedStates[i])} should contain X`).toContain('X') } // And we did observe at least one further state past the X-insert // (otherwise the remote apply never materialised and this assertion // would be vacuously satisfied). expect(observedStates.length, 'expected a remote-apply state past the X-insert').toBeGreaterThan(xInsertIdx + 0) // Final state matches the converged expectation. expect(observedStates[observedStates.length - 1]).toBe('YhelloX')
unmount() view.destroy() await cleanup() })
it('tears down the reactive subscription on docUrl change and stops applying changes to the old doc', { timeout: TEST_TIMEOUT }, async () => { const { cleanup } = await setupSyncedDoc('init-A') const folderUrl = harness!.managerA.createFolder() const urlA = await harness!.managerA.createFileInFolder(folderUrl, 'a.md') const urlB = await harness!.managerA.createFileInFolder(folderUrl, 'b.md') await harness!.managerA.updateContent(urlA, 'init-A') await harness!.managerA.updateContent(urlB, 'init-B') await waitForContent(harness!.managerB, urlA, 'init-A') await waitForContent(harness!.managerB, urlB, 'init-B')
const view = new EditorView({ state: EditorState.create({ doc: 'init-A', extensions: [] }), }) const { result, rerender, unmount } = renderHook( ({ docUrl }) => useDocumentSync({ documentManager: harness!.managerB, docUrl, }), { initialProps: { docUrl: urlA } }, ) await vi.waitFor(() => expect(result.current.isLoading).toBe(false), { timeout: 5000, interval: 25, }) result.current.bindView(view) await vi.waitFor(() => expect(result.current.isSyncReady).toBe(true))
await applyRemoteContent(harness!, urlA, 'init-A first') await vi.waitFor( () => expect(view.state.doc.toString()).toBe('init-A first'), { timeout: 5000, interval: 25 }, )
// Switch the bound docUrl. The reactive subscription on urlA tears down. rerender({ docUrl: urlB }) await vi.waitFor(() => expect(result.current.isLoading).toBe(false), { timeout: 5000, interval: 25, }) result.current.bindView(view) await vi.waitFor(() => expect(result.current.isSyncReady).toBe(true)) await vi.waitFor( () => expect(view.state.doc.toString()).toBe('init-B'), { timeout: 5000, interval: 25 }, )
// Drive a further remote change on urlA. The retained view is now bound // to urlB and must NOT be touched by urlA changes any longer. const viewBefore = view.state.doc.toString() await applyRemoteContent(harness!, urlA, 'init-A second') // Wait long enough for any leaked subscription to fire. await new Promise(r => setTimeout(r, 300)) expect(view.state.doc.toString()).toBe(viewBefore)
unmount() view.destroy() await cleanup() })
// Regression test for the production mount-order bug discovered during // browser dogfooding: MarkdownLeafBinding renders <CM6Editor> only AFTER // `content` finishes loading, so the editor mounts on a later React // commit than the parent. If the hook required the view to be present // at mount time (or polled for it via a state-driven accessor with a // missing re-trigger), the subscription would never attach. // // The current API is imperative: the consumer calls `bindView(view)` // from the editor's mount callback. This test mirrors production by // (a) mounting the hook first without binding a view, (b) creating the // view later, (c) calling `bindView(view)` at that moment, and // (d) driving a remote change AFTER the bind and asserting it lands // in the view. it( 'attaches the subscription when bindView is called after mount', { timeout: TEST_TIMEOUT }, async () => { const { url, cleanup } = await setupSyncedDoc('initial') const { result, unmount } = renderHook(() => useDocumentSync({ documentManager: harness!.managerB, docUrl: url, }), )
await vi.waitFor(() => expect(result.current.isLoading).toBe(false), { timeout: 5000, interval: 25, })
// The view mounts LATER — simulating <CM6Editor> commit after the // parent's loading-placeholder render. const view = new EditorView({ state: EditorState.create({ doc: 'initial', extensions: [] }), }) result.current.bindView(view) await vi.waitFor(() => expect(result.current.isSyncReady).toBe(true))
await applyRemoteContent(harness!, url, 'initial + tail') await vi.waitFor( () => expect(view.state.doc.toString()).toBe('initial + tail'), { timeout: 5000, interval: 25 }, )
unmount() view.destroy() await cleanup() }, )})