Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
7.5 kB · 193 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194// tests/integration/pushwork-sync/e2e/reactive-doc-sync-e2e.test.ts//// Layer-3 browser invariant for the reactive read-side. Two Playwright// browser contexts both attached to the same sync server + vault root URL.// Confirms:// - A types into the active editor; B's editor sees the change live.// - Concurrent typing in both converges with no lost characters.// - When A inserts strictly before B's cursor, B's cursor advances by// the insert length (CM6 ChangeSet default mapping).//// Gated on `RUN_BROWSER_TESTS=1` like the rest of the pushwork-sync e2e// suite. See plan #34 Task 7.
import { afterAll, beforeAll, describe, expect, it } from 'vitest'import type { ConsoleMessage } from 'playwright'
import { getInvariantTestConfig, shouldRunInvariantTests,} from '../../../invariants/support/env'import { openInvariantSession, type InvariantBrowserSession,} from '../../../invariants/support/playwright'
import { withFreshHarness, type HarnessHandles } from './support/harness-lifecycle'import { gotoVaultWithLocalStorage, openFileFromTree, readEditorCursor, readEditorText, setEditorCursor, typeIntoActiveEditor, waitForFileInTree, waitForSyncConnected,} from './support/reams-page'import { isConsoleErrorAllowed } from './support/console-error-allowlist'
const config = getInvariantTestConfig()
describe.skipIf(!shouldRunInvariantTests())('reactive doc sync (two-browser, sync server)', () => { let harness: HarnessHandles | null = null let sessionA: InvariantBrowserSession | null = null let sessionB: InvariantBrowserSession | null = null const consoleErrors: string[] = []
beforeAll(async () => { harness = await withFreshHarness('basic') }, 120_000)
afterAll(async () => { if (sessionA) await sessionA.close() if (sessionB) await sessionB.close() if (harness) await harness.cleanup() const unexpected = consoleErrors.filter(m => !isConsoleErrorAllowed(m)) if (unexpected.length > 0) { throw new Error( `Unexpected console.error in reactive-doc-sync-e2e (${unexpected.length}):\n` + unexpected.map(m => ` - ${m}`).join('\n'), ) } })
function wireConsoleListener(session: InvariantBrowserSession): void { session.page.on('console', (msg: ConsoleMessage) => { if (msg.type() !== 'error') return const loc = msg.location() const url = loc?.url ? ` [${loc.url}]` : '' consoleErrors.push(`${msg.text()}${url}`) }) }
async function openTwoSessionsOnNoteWithSeed(seed: string): Promise<{ docPath: string }> { if (!harness) throw new Error('harness not initialized') // Seed the doc via pushwork (writes to the harness workspace, then // pushwork-syncs to the live sync server). const docPath = `reactive-${Date.now()}.md` await harness.writeFile(docPath, seed) await harness.pushworkSync()
sessionA = await openInvariantSession(config, 'reactive-doc-sync-e2e-a') sessionB = await openInvariantSession(config, 'reactive-doc-sync-e2e-b') wireConsoleListener(sessionA) wireConsoleListener(sessionB) await gotoVaultWithLocalStorage(sessionA.page, config.devServerUrl, { syncServerUrl: harness.syncServerUrl, vaultRootUrl: harness.vaultRootUrl, }) await gotoVaultWithLocalStorage(sessionB.page, config.devServerUrl, { syncServerUrl: harness.syncServerUrl, vaultRootUrl: harness.vaultRootUrl, }) await waitForSyncConnected(sessionA.page) await waitForSyncConnected(sessionB.page)
// Confirm both sessions see the seeded file in their trees. await waitForFileInTree(sessionA.page, docPath) await waitForFileInTree(sessionB.page, docPath)
await openFileFromTree(sessionA.page, docPath) await openFileFromTree(sessionB.page, docPath) return { docPath } }
it('A types, B sees the change live', { timeout: 180_000 }, async () => { await openTwoSessionsOnNoteWithSeed('seed-content\n') if (!sessionA || !sessionB) throw new Error('sessions not opened')
// Type in A; assert B converges. await typeIntoActiveEditor(sessionA.page, 'hello-from-A. ')
// Poll for B's editor to reflect A's contribution. Generous window — // sync RTT through a local server is typically <500ms; 8s budgets // for slow CI. let lastB = '' const deadline = Date.now() + 10_000 while (Date.now() < deadline) { lastB = await readEditorText(sessionB.page) if (lastB.includes('hello-from-A. ')) break await new Promise(r => setTimeout(r, 250)) } expect(lastB).toContain('hello-from-A. ') })
it('concurrent typing converges with no lost characters', { timeout: 180_000 }, async () => { await openTwoSessionsOnNoteWithSeed('start\n') if (!sessionA || !sessionB) throw new Error('sessions not opened')
// Drive both sessions interleaved: A types 5 chars, B types 5 chars, // repeat. typeIntoActiveEditor focuses + appends + waits for a save. const A_FRAGMENTS = ['AAAA1 ', 'AAAA2 ', 'AAAA3 '] const B_FRAGMENTS = ['BBBB1 ', 'BBBB2 ', 'BBBB3 '] for (let i = 0; i < 3; i++) { await typeIntoActiveEditor(sessionA.page, A_FRAGMENTS[i]!) await typeIntoActiveEditor(sessionB.page, B_FRAGMENTS[i]!) }
// Wait for convergence. const deadline = Date.now() + 15_000 while (Date.now() < deadline) { const a = await readEditorText(sessionA.page) const b = await readEditorText(sessionB.page) if (a === b && A_FRAGMENTS.every(f => a.includes(f)) && B_FRAGMENTS.every(f => a.includes(f))) { break } await new Promise(r => setTimeout(r, 250)) } const a = await readEditorText(sessionA.page) const b = await readEditorText(sessionB.page) expect(a).toBe(b) for (const f of [...A_FRAGMENTS, ...B_FRAGMENTS]) { expect(a).toContain(f) } })
it("B's cursor advances when A inserts before it", { timeout: 180_000 }, async () => { await openTwoSessionsOnNoteWithSeed('hello world\n') if (!sessionA || !sessionB) throw new Error('sessions not opened')
// Place B's cursor at the end of "hello world" (offset 11). Read it // back to confirm the dispatch landed. await setEditorCursor(sessionB.page, 11) const beforeB = await readEditorCursor(sessionB.page) expect(beforeB).toBe(11)
// A types " INSERTED" at offset 0 (end of doc — but End key is // pressed in typeIntoActiveEditor; we need an insertion strictly // BEFORE B's cursor, so we'll insert at the beginning via a // direct evaluate to bypass the End-key behavior). await sessionA.page.evaluate(() => { const v = (window as unknown as { __cm6_editor_view__?: unknown }).__cm6_editor_view__ as | { dispatch: (spec: unknown) => void } | undefined if (!v) throw new Error('CM6 view not available on session A') v.dispatch({ changes: { from: 0, insert: 'PREPEND ' } }) })
// Wait for B's text to reflect A's prepend. const deadline = Date.now() + 10_000 while (Date.now() < deadline) { const text = await readEditorText(sessionB.page) if (text.includes('PREPEND ')) break await new Promise(r => setTimeout(r, 250)) } const text = await readEditorText(sessionB.page) expect(text.startsWith('PREPEND hello world')).toBe(true)
// B's cursor should have advanced by the length of 'PREPEND ' (8). const afterB = await readEditorCursor(sessionB.page) expect(afterB).toBe(11 + 'PREPEND '.length) })})