Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
20 kB · 386 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387import { mkdir, readFile, writeFile } from 'node:fs/promises'import { dirname } from 'node:path'import assert from 'node:assert/strict'import { chromium } from 'playwright'
const baseUrl = process.env.REAMS_STRUCTURAL_COVERAGE_URL ?? 'http://localhost:7480/'const requestedCase = process.env.REAMS_STRUCTURAL_COVERAGE_CASEconst output = process.env.REAMS_STRUCTURAL_COVERAGE_OUTPUT ?? 'docs/verification/structural-coverage/sparse-original-report.json'const caseFile = process.env.REAMS_STRUCTURAL_COVERAGE_CASE_FILE ?? './cases.json'const cases = JSON.parse(await readFile(new URL(caseFile, import.meta.url))).casesconst selected = requestedCase ? cases.filter(item => item.id === requestedCase) : casesif (selected.length === 0) throw new Error(`Unknown structural coverage case: ${requestedCase}`)
const report = { version: 2, url: baseUrl, denominator: { cases: cases.length, modes: 2, cells: cases.length * 2, meaning: 'measured behavioral cells, not code coverage' }, status: 'running', cases: [],}
async function reachable() { try { const response = await fetch(baseUrl, { signal: AbortSignal.timeout(1500) }) return response.ok } catch { return false }}
async function state(page, file) { return page.evaluate(async file => { const app = window.__reamsApp if (!app) throw new Error('window.__reamsApp is missing') const markdown = await app.reamsDocumentManager.readMarkdown(file.url) const facts = await app.reamsDocumentManager.readBlockFacts(file.url) const handle = await app.reamsDocumentManager.findFileHandle(file.url) if (!handle) throw new Error('document handle is missing') const { inspectRichText } = await import('/src/automerge-markdown/index.ts') const opaque = Object.fromEntries(inspectRichText(handle.doc(), { path: ['content'] }).blocks().flatMap(block => { const nodeId = block.markerValue.attrs['reams.nodeId'] return typeof nodeId === 'string' && block.markerValue.opaque !== undefined ? [[nodeId, block.markerValue.opaque]] : [] })) const content = markdown.status === 'available' ? markdown.content : null const nodes = markdown.status === 'available' && markdown.nodeIndex.status === 'available' ? markdown.nodeIndex.index.nodes.map(node => ({ id: node.id, kind: node.kind, from: node.from, to: node.to, text: content.slice(node.from, node.to), })) : [] const decodedFacts = facts && typeof facts === 'object' ? Object.entries(facts).flatMap(([key, value]) => { const prefix = 'reams.record.v1:' if (!key.startsWith(prefix)) return [] let subject try { subject = JSON.parse(key.slice(prefix.length)) } catch { throw new Error(`malformed native fact key: ${key}`) } if (!Array.isArray(subject) || subject.length !== 3) throw new Error(`invalid native fact key: ${key}`) return [{ key, nodeId: subject[0], typeName: subject[1], fieldId: subject[2], value }] }).sort((a, b) => a.key.localeCompare(b.key)) : [] const editor = window.__cm6_editor_view__ if (!editor) throw new Error('window.__cm6_editor_view__ is missing') const markerTrace = window.__reamsStatefulMarkerTrace__ const projected = Object.fromEntries(nodes.map(node => { const row = app.reamsTypedNodes.getSubjectRows(file.url, { kind: 'block', nodeId: node.id }) .find(candidate => candidate.typeName === 'task') return [node.id, Object.fromEntries((row?.fields ?? []).map(field => [field.fieldId, { value: field.value, state: field.state, }]))] })) return { content, markdownStatus: markdown.status, nodeIndexStatus: markdown.nodeIndex.status, nodeIndexReason: markdown.nodeIndex.status === 'invalid' ? markdown.nodeIndex.reason : null, factsAvailable: Boolean(facts && typeof facts === 'object'), editor: editor.state.doc.toString(), mode: document.querySelector('.cm6-editor-container').getAttribute('data-mode'), nodes, facts: decodedFacts, opaque, projected, nodeIds: nodes.map(node => node.id), markerTrace: markerTrace ? JSON.parse(JSON.stringify(markerTrace)) : null, } }, file)}
function assertSparsePhase(state, item, phase) { if (item.expectNoOwners) { assert.deepEqual(state.nodeIds, [], `plain ${phase} must keep a valid empty sparse index`) assert.deepEqual(state.facts, [], `plain ${phase} must not retain orphan facts`) return } assert.ok(state.nodes.every(node => node.text.includes('#task')), `every sparse owner after ${phase} must retain active Type evidence`)}
async function waitSettled(page, expected) { await page.waitForFunction(expected => { const editor = window.__cm6_editor_view__ const diagnostics = window.__reamsInvariantDiagnostics__ if (!editor || !diagnostics) return false const sync = diagnostics.getDocumentSyncSnapshot() return editor.state.doc.toString() === expected && ['saved', 'idle'].includes(sync.savePhase) }, expected, { timeout: 10000 }) await page.waitForTimeout(100)}
async function waitExpected(page, expected, requireSave = true) { await page.waitForFunction(expected => { const view = window.__cm6_editor_view__ return Boolean(view) && view.state.doc.toString() === expected }, expected, { timeout: 10000 }) if (requireSave) await waitSettled(page, expected)}
async function setMode(page, mode) { const current = await page.locator('.cm6-editor-container').getAttribute('data-mode') if (current === mode) return await page.evaluate(() => window.__reamsInvariantDiagnostics__.setEditorMode( document.querySelector('.cm6-editor-container').getAttribute('data-mode') === 'source' ? 'live-preview' : 'source', )) await page.waitForFunction(expected => document.querySelector('.cm6-editor-container').getAttribute('data-mode') === expected, mode)}
async function createFile(page, path, content = '') { const file = await page.evaluate(async ({ path, content }) => { const app = window.__reamsApp if (app.vault.getAbstractFileByPath('types') === null) await app.vault.createFolder('types') const schema = JSON.stringify({ version: 1, name: 'Task', fields: { title: { key: 'title', name: 'Title', kind: 'text', default: '' }, done: { key: 'done', name: 'Done', kind: 'boolean', default: false }, due: { key: 'due', name: 'Due', kind: 'text', default: 'none' }, }, fieldOrder: ['title', 'done', 'due'], TaskListItem: 'done', }) + '\n' if (app.vault.getFileByPath('types/task.json') === null) await app.vault.create('types/task.json', schema) const note = await app.vault.create(path, content) await app.workspace.getLeaf(false).openFile(note) const url = app.vault.getUrlForFile(note) if (!url) throw new Error(`no URL for ${path}`) return { path: note.path, url } }, { path, content }) await page.waitForSelector('.cm6-editor-container') await page.waitForFunction(expected => { const view = window.__cm6_editor_view__ return Boolean(view) && view.state.doc.toString() === expected }, content) return file}
async function seed(page, item) { const editor = page.locator('.cm-content') await editor.click() await page.keyboard.insertText(item.seed) await waitSettled(page, item.seed)}
async function seedNonDefaultDue(page, item, file) { if (!item.seed.includes('#task')) return await setMode(page, 'live-preview') await page.waitForSelector('[aria-label="Due"]') for (const [index, value] of (item.dueValues ?? ['Friday']).entries()) { const due = page.getByLabel('Due', { exact: true }).nth(index) await due.fill(value) await due.blur() await page.evaluate(() => window.__reamsApp.awaitRecordEdits()) } await page.evaluate(() => window.__reamsApp.awaitRecordEdits()) await page.evaluate(() => window.__reamsApp.flushAllDocumentSync()) const written = await state(page, file) assert.ok(written.facts.some(fact => fact.fieldId === 'due' && (fact.value === 'Friday' || (Array.isArray(fact.value) && fact.value.includes('Friday')))), 'Due must be written as non-default Friday')}
async function seedOpaquePayloads(page, item, file) { if (!item.seed.includes('#task')) return await page.evaluate(async file => { const manager = window.__reamsApp.reamsDocumentManager const handle = await manager.findFileHandle(file.url) if (!handle) throw new Error('document handle is missing for opaque payload setup') const { inspectRichText, updateNativeBlock } = await import('/src/automerge-markdown/index.ts') handle.change(doc => { for (const block of inspectRichText(doc, { path: ['content'] }).blocks()) { const nodeId = block.markerValue.attrs['reams.nodeId'] if (typeof nodeId === 'string') { updateNativeBlock(doc, { path: ['content'], richIndex: block.richIndex, marker: { ...block.markerValue, opaque: { gate1: 'preserve', node: nodeId } }, }) } } }) }, file) await page.waitForTimeout(100)}
async function select(page, from, to = from) { assert.ok(Number.isInteger(from) && Number.isInteger(to) && from >= 0 && to >= from, `invalid selection range: ${from}, ${to}`) await page.evaluate(({ from, to }) => { const view = window.__cm6_editor_view__ if (to > view.state.doc.length) throw new Error(`selection exceeds document: ${from}, ${to}, ${view.state.doc.length}`) view.focus() view.dispatch({ selection: { anchor: from, head: to } }) }, { from, to })}
async function operate(page, item) { let from = item.from let to = item.to ?? item.from if (item.id === 'tag-only-cut') { from = await page.evaluate(token => window.__cm6_editor_view__.state.doc.toString().indexOf(token), item.token) to = from + item.token.length assert.ok(from >= 0, `token not found: ${item.token}`) } await select(page, from, to) if (item.operation === 'Backspace') await page.keyboard.press('Backspace') else if (item.operation === 'Delete') await page.keyboard.press('Delete') else if (item.id === 'heading-toggle') await page.keyboard.insertText('# ') else if (item.id === 'fence-wrap') await page.keyboard.insertText('```\n' + item.seed + '\n```') else if (item.operation === 'format-command') { await page.locator(`button[aria-label="${item.formatTitle ?? 'Bold'}"]`).last().evaluate(button => button.click()) } else if (item.operation === 'paste') { await page.evaluate(text => navigator.clipboard.writeText(text), item.paste) await page.keyboard.press('Control+V') } else if (item.id === 'nested-indent') { await page.keyboard.press('Tab') } else if (item.id === 'tag-only-cut') { await page.keyboard.press('Control+X') } else if (item.operation === 'parent-line-delete') { await page.keyboard.press('Delete') } else if (item.operation === 'split' || item.id === 'paragraph-split') { await page.keyboard.press('Enter') } else throw new Error(`unknown operation ${item.id}`)}
async function runCell(item, mode, browser) { const context = await browser.newContext({ permissions: ['clipboard-read', 'clipboard-write'] }) const page = await context.newPage() const unexpectedConnections = [] const browserErrors = [] page.on('pageerror', error => browserErrors.push(String(error))) page.on('console', entry => { if (entry.type() === 'error') browserErrors.push(entry.text()) }) page.on('request', request => { if (new URL(request.url()).port === '7030') unexpectedConnections.push(request.url()) }) page.on('websocket', socket => { if (new URL(socket.url()).port === '7030') unexpectedConnections.push(socket.url()) }) await page.route('**/*', async route => { if (new URL(route.request().url()).port === '7030') { await route.abort('blockedbyclient') return } await route.continue() }) const result = { id: item.id, mode, status: 'failed', operation: item.operation, phases: [], browserErrors } let file = null try { await page.goto(baseUrl, { waitUntil: 'domcontentloaded' }) await page.waitForSelector('.cm6-editor-container', { timeout: 15000 }) await page.waitForFunction(() => window.__reamsApp && window.__reamsInvariantDiagnostics__ && window.__cm6_editor_view__, undefined, { timeout: 15000 }) await page.evaluate(() => { window.__reamsStatefulMarkerTrace__ = {} }) file = await createFile(page, `structural-coverage-${item.id}-${mode}-${crypto.randomUUID()}.md`) await seed(page, item) await seedNonDefaultDue(page, item, file) await seedOpaquePayloads(page, item, file) await setMode(page, mode) const before = await state(page, file) result.phases.push({ phase: 'before', state: before }) assert.equal(before.content, item.seed) assert.equal(before.nodeIndexStatus, 'available', 'fixture must start with valid native ownership') assertSparsePhase(before, item, 'fixture setup') if (!item.expectNoOwners) assert.deepEqual(Object.keys(before.opaque).sort(), [...before.nodeIds].sort(), 'typed fixture must seed a non-vacuous opaque payload for every owner') if (item.assertions?.beforeCount !== undefined) assert.equal(before.nodeIds.length, item.assertions.beforeCount, 'fixture must start with the expected sparse owner count') await operate(page, item) await waitExpected(page, item.expected, item.expected !== item.seed) const action = await state(page, file) result.phases.push({ phase: 'action', state: action }) assert.equal(action.markdownStatus, 'available') assert.equal(action.nodeIndexStatus, 'available') assert.equal(action.factsAvailable, true) assert.equal(action.content, item.expected) assert.equal(action.editor, item.expected) assert.equal(action.mode, mode) assertSparsePhase(action, item, 'action') if (!item.expectNoOwners) assert.ok(action.nodeIds.length > 0, 'typed fixture must contain native record owners') if (item.assertions?.afterCount !== undefined) assert.equal(action.nodeIds.length, item.assertions.afterCount, 'action must produce the expected sparse owner count') assert.equal(new Set(action.nodeIds).size, action.nodeIds.length, 'native node IDs must be unique') assert.ok(action.facts.every(fact => action.nodeIds.includes(fact.nodeId)), 'native facts must reference current native nodes') for (const assertion of item.assertions?.preserve ?? []) { assert.equal(action.nodeIds[assertion.after], before.nodeIds[assertion.before], `owner ${assertion.before} must survive at ${assertion.after}`) } for (const index of item.assertions?.removed ?? []) { assert.ok(!action.nodeIds.includes(before.nodeIds[index]), `owner ${index} must be removed`) } for (const index of item.assertions?.fresh ?? []) { assert.ok(action.nodeIds[index] && !before.nodeIds.includes(action.nodeIds[index]), `owner ${index} must be fresh`) } const expectedOpaque = Object.fromEntries(action.nodeIds.flatMap(nodeId => before.opaque[nodeId] === undefined ? [] : [[nodeId, before.opaque[nodeId]]])) assert.deepEqual(action.opaque, expectedOpaque, 'action must preserve opaque payloads only for surviving owners') if (item.assertions?.facts) { const expectedFacts = item.assertions.facts.map(fact => ({ nodeId: action.nodeIds[fact.owner], typeName: 'task', fieldId: fact.fieldId, value: fact.value, })).sort((left, right) => `${left.nodeId}:${left.fieldId}`.localeCompare(`${right.nodeId}:${right.fieldId}`)) const actualFacts = action.facts.map(({ nodeId, typeName, fieldId, value }) => ({ nodeId, typeName, fieldId, value })) .sort((left, right) => `${left.nodeId}:${left.fieldId}`.localeCompare(`${right.nodeId}:${right.fieldId}`)) assert.deepEqual(actualFacts, expectedFacts, 'action must preserve exactly the intended explicit facts') } for (const projection of item.assertions?.projected ?? []) { const fields = action.projected[action.nodeIds[projection.owner]] assert.ok(fields, `owner ${projection.owner} must have a projected Task record`) for (const [fieldId, expected] of Object.entries(projection.fields)) { assert.deepEqual(fields[fieldId], expected, `owner ${projection.owner} must project ${fieldId} correctly`) } } await page.keyboard.press('Control+z') await waitExpected(page, item.seed, item.expected !== item.seed) const undo = await state(page, file) result.phases.push({ phase: 'undo', state: undo }) assert.equal(undo.nodeIndexStatus, 'available', 'undo must restore valid native ownership') assert.equal(undo.content, item.seed) assert.equal(undo.editor, item.seed) assertSparsePhase(undo, item, 'undo') assert.deepEqual(undo.nodeIds, before.nodeIds, 'undo must restore original identities') assert.deepEqual(undo.facts, before.facts, 'undo must restore original fields') assert.deepEqual(undo.opaque, before.opaque, 'undo must restore original opaque marker payloads') await page.keyboard.press('Control+y') await waitExpected(page, item.expected, item.expected !== item.seed) const redo = await state(page, file) result.phases.push({ phase: 'redo', state: redo }) assert.equal(redo.nodeIndexStatus, 'available', 'redo must restore valid native ownership') assert.equal(redo.content, item.expected) assert.equal(redo.editor, item.expected) assertSparsePhase(redo, item, 'redo') assert.deepEqual(redo.nodeIds, action.nodeIds, 'redo must reuse resulting identities') assert.deepEqual(redo.facts, action.facts, 'redo must restore resulting fields') assert.deepEqual(redo.opaque, action.opaque, 'redo must restore resulting opaque marker payloads') await page.reload({ waitUntil: 'domcontentloaded' }) await page.waitForSelector('.cm6-editor-container', { timeout: 15000 }) await waitSettled(page, item.expected) const reload = await state(page, file) result.phases.push({ phase: 'reload', state: reload }) assert.equal(reload.nodeIndexStatus, 'available', 'reload must retain valid native ownership') assert.equal(reload.content, item.expected) assert.equal(reload.editor, item.expected) assertSparsePhase(reload, item, 'reload') assert.deepEqual(reload.nodeIds, redo.nodeIds, 'native node IDs must survive reload') assert.deepEqual(reload.facts, redo.facts, 'native fields must survive reload') assert.deepEqual(reload.opaque, redo.opaque, 'opaque marker payloads must survive reload') assert.deepEqual(unexpectedConnections, [], 'structural coverage must not connect to sync server 7030') assert.deepEqual(browserErrors, [], 'structural editing must not throw browser errors') result.status = 'passed' } catch (error) { result.status = result.phases.length === 0 ? 'setup-error' : 'failed' result.error = String(error) if (file) { try { result.failureObservation = await state(page, file) } catch (observationError) { result.failureObservationError = String(observationError) } } result.unexpectedConnections = unexpectedConnections } finally { await context.close() } return result}
if (!(await reachable())) { report.status = 'parent-not-started-unknown-environment' report.note = `${baseUrl} was unavailable; the parent must provide the server.`} else { const browser = await chromium.launch({ executablePath: process.env.PLAYWRIGHT_CHROMIUM_BIN, args: ['--disable-dev-shm-usage'] }) for (const item of selected) for (const mode of ['source', 'live-preview']) report.cases.push(await runCell(item, mode, browser)) await browser.close() report.status = report.cases.every(cell => cell.status === 'passed') ? 'passed' : 'failed'}report.summary = { measured: report.cases.length, passed: report.cases.filter(cell => cell.status === 'passed').length, failed: report.cases.filter(cell => cell.status === 'failed').length, setupErrors: report.cases.filter(cell => cell.status === 'setup-error').length,}await mkdir(dirname(output), { recursive: true })await writeFile(output, JSON.stringify(report, null, 2) + '\n')console.log(JSON.stringify({ status: report.status, url: report.url, summary: report.summary, cases: report.cases.map(({ id, mode, status, error }) => ({ id, mode, status, ...(error ? { error } : {}) })), output }, null, 2))if (report.status !== 'passed') process.exitCode = 1