@atom.io/template-react-realtime-text-editor 0.0.11 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,251 +1,503 @@
1
- import { act, waitFor } from "@testing-library/react"
2
- import { useO } from "atom.io/react"
3
- import type { Socket, UserKey } from "atom.io/realtime"
4
- import { useMosaic } from "atom.io/realtime-react"
5
- import { createMosaicServer } from "atom.io/realtime-server"
6
- import * as RTTest from "atom.io/realtime-testing/react"
7
-
8
- import { markdownAtomRegistration } from "../node/mosaic-atom.ts"
1
+ import { waitFor } from "@testing-library/react"
9
2
  import {
10
- type Markdown,
11
- markdownAtom,
12
- markdownWordCountSelector,
13
- type MarkdownPresence,
14
- } from "../src/collaboration/mosaic.ts"
3
+ headless,
4
+ SeededScenarioRandom,
5
+ type HeadlessRealtimeTestClient,
6
+ } from "@atom.io/realtime/testing/headless"
7
+
8
+ import { createMarkdownDocumentService } from "../node/service.ts"
9
+ import {
10
+ createMarkdownCollaborationClient,
11
+ type MarkdownCollaborationClient,
12
+ } from "../src/collaboration-client.ts"
15
13
  import type { Identity } from "../src/identities.ts"
16
14
  import { INITIAL_MARKDOWN } from "../src/initial-markdown.ts"
15
+ import { createMarkdownWorkspaceState } from "../src/workspace-state.ts"
17
16
 
18
- const JANE = { id: `jane`, name: `Jane`, color: `#7057ff` } satisfies Identity
19
- const DAVE = { id: `dave`, name: `Dave`, color: `#df527c` } satisfies Identity
17
+ const ADA = { color: `#7057ff`, id: `ada`, name: `Ada` } satisfies Identity
18
+ const LIN = { color: `#df527c`, id: `lin`, name: `Lin` } satisfies Identity
19
+ const sessions = { ada: `test:ada`, lin: `test:lin` } as const
20
20
 
21
- function identityFor(userKey: UserKey): Identity {
22
- return userKey.includes(`jane`) ? JANE : DAVE
23
- }
21
+ type RuntimeRegistry = Map<string, MarkdownCollaborationClient>
24
22
 
25
- function testClient(identity: Identity, session: string) {
26
- return function TestClient() {
27
- const mosaic = useMosaic<InstanceType<typeof Markdown>, MarkdownPresence>(
28
- markdownAtom,
29
- { actor: identity.id, session },
30
- )
31
- const document = useO(markdownAtom)
32
- const wordCount = useO(markdownWordCountSelector)
33
- const text = document.text
34
- const history = document.historyFor(identity.id)
35
- return (
36
- <main>
37
- <output data-testid="text">{text}</output>
38
- <output data-testid="word-count">{wordCount}</output>
39
- <output data-testid="status">{mosaic.status}</output>
40
- <output data-testid="presence">
41
- {mosaic.presence
42
- .map(({ actor }) => actor)
43
- .sort()
44
- .join(`,`)}
45
- </output>
46
- <button
47
- type="button"
48
- data-testid="append"
49
- onClick={() =>
50
- mosaic.change({
51
- text: `${text}[${identity.name}]`,
52
- type: `replace-text`,
53
- })
54
- }
55
- />
56
- <button
57
- type="button"
58
- data-testid="presence-update"
59
- onClick={() => {
60
- mosaic.publishPresence({
61
- color: identity.color,
62
- lastActiveAt: 1,
63
- name: identity.name,
64
- selection: document.selectionFromOffsets(0, 0),
65
- })
66
- }}
67
- />
68
- <button
69
- type="button"
70
- data-testid="undo"
71
- disabled={history.undo.length === 0}
72
- onClick={() => mosaic.change({ type: `undo` })}
73
- />
74
- <button
75
- type="button"
76
- data-testid="redo"
77
- disabled={history.redo.length === 0}
78
- onClick={() => mosaic.change({ type: `redo` })}
79
- />
80
- </main>
81
- )
82
- }
83
- }
23
+ const INITIAL = `# Bounded notes\n\n${Array.from(
24
+ { length: 10 },
25
+ (_, index) =>
26
+ `Paragraph ${index} has enough text to exercise partial residency.`,
27
+ ).join(`\n\n`)}\n`
84
28
 
85
- function scenario() {
86
- const collaboration = createMosaicServer({
87
- registrations: [markdownAtomRegistration],
29
+ async function scenario(
30
+ options: {
31
+ readonly initialText?: string
32
+ readonly presenceRenewalMs?: number
33
+ readonly presenceTtlMs?: number
34
+ } = {},
35
+ ) {
36
+ const service = await createMarkdownDocumentService({
37
+ initialText: options.initialText ?? INITIAL,
38
+ presenceTtlMs: options.presenceTtlMs,
88
39
  })
89
- const room = RTTest.multiClient({
90
- scenarioId: `mosaic-markdown`,
91
- server: ({ sessionId, socket, userKey }) => {
92
- const dispose = collaboration.connect({
93
- actor: identityFor(userKey).id,
94
- session: sessionId,
95
- socket: socket as unknown as Socket,
96
- })
40
+ const runtimes: RuntimeRegistry = new Map()
41
+ const room = headless({
42
+ scenarioId: `mosaic-markdown-domain`,
43
+ server: (tools) => {
44
+ const binding = tools.work.track(
45
+ service.bindSocket({
46
+ actor: tools.userKey.replace(/^user::/u, ``),
47
+ session: tools.sessionId,
48
+ socket: tools.socket,
49
+ }),
50
+ `bind Markdown Domain socket`,
51
+ )
97
52
  return () => {
98
- void dispose()
53
+ void binding.then((cleanup) => cleanup())
99
54
  }
100
55
  },
101
- clients: {
102
- jane: testClient(JANE, `test:jane`),
103
- dave: testClient(DAVE, `test:dave`),
104
- },
105
56
  })
106
57
  return {
107
- ...room,
108
- teardown: async (): Promise<void> => {
58
+ room,
59
+ runtimes,
60
+ service,
61
+ presenceRenewalMs: options.presenceRenewalMs,
62
+ async teardown() {
63
+ for (const runtime of runtimes.values()) runtime[Symbol.dispose]()
64
+ runtimes.clear()
109
65
  await room.teardown()
110
- await collaboration.dispose()
66
+ service[Symbol.dispose]()
111
67
  },
112
68
  }
113
69
  }
114
70
 
115
- const initJane = (room: ReturnType<typeof scenario>) =>
116
- room.clients.jane.init({
117
- sessionId: `test:jane`,
118
- userKey: `user::jane`,
119
- })
71
+ async function initialize(room: Awaited<ReturnType<typeof scenario>>): Promise<{
72
+ ada: HeadlessRealtimeTestClient
73
+ lin: HeadlessRealtimeTestClient
74
+ }> {
75
+ const harness = {
76
+ ada: room.room.createClient({
77
+ name: `ada`,
78
+ sessionId: sessions.ada,
79
+ userKey: `user::ada`,
80
+ }),
81
+ lin: room.room.createClient({
82
+ name: `lin`,
83
+ sessionId: sessions.lin,
84
+ userKey: `user::lin`,
85
+ }),
86
+ }
87
+ const [ada, lin] = await Promise.all([
88
+ createMarkdownCollaborationClient({
89
+ identity: ADA,
90
+ presenceRenewalMs: room.presenceRenewalMs,
91
+ sessionId: sessions.ada,
92
+ silo: harness.ada.silo,
93
+ socket: harness.ada.socket,
94
+ }),
95
+ createMarkdownCollaborationClient({
96
+ identity: LIN,
97
+ presenceRenewalMs: room.presenceRenewalMs,
98
+ sessionId: sessions.lin,
99
+ silo: harness.lin.silo,
100
+ socket: harness.lin.socket,
101
+ }),
102
+ ])
103
+ room.runtimes.set(sessions.ada, ada)
104
+ room.runtimes.set(sessions.lin, lin)
105
+ return harness
106
+ }
120
107
 
121
- const initDave = (room: ReturnType<typeof scenario>) =>
122
- room.clients.dave.init({
123
- sessionId: `test:dave`,
124
- userKey: `user::dave`,
125
- })
108
+ async function live(room: Awaited<ReturnType<typeof scenario>>) {
109
+ await waitFor(
110
+ () => {
111
+ expect(room.runtimes.get(sessions.ada)?.status().connection).toBe(`live`)
112
+ expect(room.runtimes.get(sessions.lin)?.status().connection).toBe(`live`)
113
+ },
114
+ { timeout: 5_000 },
115
+ )
116
+ return {
117
+ ada: room.runtimes.get(sessions.ada)!,
118
+ lin: room.runtimes.get(sessions.lin)!,
119
+ }
120
+ }
126
121
 
127
- async function expectText(
128
- client: RTTest.RealtimeTestClient,
129
- text: string,
130
- ): Promise<void> {
131
- await waitFor(() => {
132
- expect(client.renderResult.getByTestId(`text`).textContent).toBe(text)
122
+ describe(`incremental realtime Markdown Domain`, () => {
123
+ test.each([0x51_0c_10, 0xc0_ff_ee, 0x12_34_56])(
124
+ `seed %i: single-writer replacements converge to a plain-text oracle`,
125
+ async (seed) => {
126
+ const setup = await scenario({ initialText: INITIAL_MARKDOWN })
127
+ try {
128
+ await initialize(setup)
129
+ const clients = await live(setup)
130
+ const random = new SeededScenarioRandom(seed)
131
+ const alphabet = [`x`, `y`, ` `, `!`, `😀`] as const
132
+ const segmenter = new Intl.Segmenter(undefined, {
133
+ granularity: `grapheme`,
134
+ })
135
+ let oracle = INITIAL_MARKDOWN
136
+
137
+ // Exercise the real client/server protocol without keyboard travel or
138
+ // wall-clock sleeps. Native editing and painting stay in Playwright.
139
+ for (let step = 0; step < 100; step++) {
140
+ const boundaries = [0]
141
+ for (const { index, segment } of segmenter.segment(oracle)) {
142
+ boundaries.push(index + segment.length)
143
+ }
144
+ const startIndex = random.integer(boundaries.length)
145
+ const removable = Math.min(3, boundaries.length - 1 - startIndex)
146
+ const removed = removable === 0 ? 0 : random.integer(removable + 1)
147
+ const start = boundaries[startIndex]
148
+ const end = boundaries[startIndex + removed]
149
+ let inserted: string = random.pick(alphabet)
150
+ // Submit actual edits; the command protocol rejects no-op replacements.
151
+ if (oracle.slice(start, end) === inserted) inserted += `x`
152
+ const writer = clients.ada
153
+ const anchor = await writer.projection.positionAtOffset(start)
154
+ const head = await writer.projection.positionAtOffset(end)
155
+ const context = `seed ${seed.toString(16)} step ${step}: replace ${start}..${end} with ${JSON.stringify(inserted)}`
156
+ await writer.replace({ selection: { anchor, head }, text: inserted })
157
+ oracle = `${oracle.slice(0, start)}${inserted}${oracle.slice(end)}`
158
+ await setup.room.waitForIdle()
159
+ expect(setup.service.materialize(), context).toBe(oracle)
160
+ for (const client of [clients.ada, clients.lin]) {
161
+ expect(await client.projection.materialize(), context).toBe(oracle)
162
+ }
163
+ }
164
+ } finally {
165
+ await setup.teardown()
166
+ }
167
+ },
168
+ )
169
+
170
+ test(`the store-owned workspace hydrates its initial viewport`, async () => {
171
+ const setup = await scenario()
172
+ try {
173
+ const harness = await initialize(setup)
174
+ const clients = await live(setup)
175
+ const observeErrors: unknown[] = []
176
+ const originalObserveRange = clients.ada.projection.observeRange
177
+ const observeRange = vi
178
+ .spyOn(clients.ada.projection, `observeRange`)
179
+ .mockImplementation(async (...parameters) => {
180
+ try {
181
+ return await originalObserveRange(...parameters)
182
+ } catch (error) {
183
+ observeErrors.push(error)
184
+ throw error
185
+ }
186
+ })
187
+ const workspace = createMarkdownWorkspaceState({
188
+ client: clients.ada,
189
+ silo: harness.ada.silo,
190
+ })
191
+ try {
192
+ await waitFor(() => {
193
+ expect(harness.ada.silo.getState(workspace.totalLength)).toBe(
194
+ INITIAL.length,
195
+ )
196
+ })
197
+ expect(observeRange.mock.calls.map(([range]) => range)).toEqual([
198
+ { end: INITIAL.length, kind: `utf16-range`, start: 0 },
199
+ ])
200
+ await waitFor(() => {
201
+ expect(observeErrors).toEqual([])
202
+ expect(observeRange).toHaveBeenCalled()
203
+ expect(harness.ada.silo.getState(workspace.view)).toMatchObject({
204
+ projection: { text: INITIAL },
205
+ status: `ready`,
206
+ })
207
+ })
208
+ } finally {
209
+ workspace[Symbol.dispose]()
210
+ }
211
+ } finally {
212
+ await setup.teardown()
213
+ }
133
214
  })
134
- }
135
215
 
136
- async function expectStatus(
137
- client: RTTest.RealtimeTestClient,
138
- status: string,
139
- ): Promise<void> {
140
- await waitFor(() => {
141
- expect(client.renderResult.getByTestId(`status`).textContent).toBe(status)
216
+ test(`rejects invalid presence renewal intervals before allocating a Domain`, async () => {
217
+ await expect(
218
+ createMarkdownCollaborationClient({
219
+ identity: ADA,
220
+ presenceRenewalMs: 0,
221
+ sessionId: `invalid-presence-renewal`,
222
+ silo: {} as never,
223
+ socket: {} as never,
224
+ }),
225
+ ).rejects.toThrow(`positive integer`)
142
226
  })
143
- }
144
227
 
145
- describe(`realtime collaborative Markdown`, () => {
146
- test(`offline concurrent edits rebase and converge for both clients`, async () => {
147
- const room = scenario()
148
- const jane = initJane(room)
149
- const dave = initDave(room)
150
- await expectText(jane, INITIAL_MARKDOWN)
151
- await expectText(dave, INITIAL_MARKDOWN)
152
-
153
- act(() => {
154
- jane.socket.disconnect()
155
- dave.socket.disconnect()
156
- })
157
- await expectStatus(jane, `offline`)
158
- await expectStatus(dave, `offline`)
159
- act(() => {
160
- jane.renderResult.getByTestId(`append`).click()
161
- dave.renderResult.getByTestId(`append`).click()
162
- })
228
+ test(`hydrates viewport first, converges contention/offline work, and keeps selective history foreign-safe`, async () => {
229
+ const setup = await scenario()
230
+ try {
231
+ const harness = await initialize(setup)
232
+ const clients = await live(setup)
233
+ const length = await clients.ada.projection.readLength()
234
+ const [first, last] = await Promise.all([
235
+ clients.ada.projection.acquireRange({
236
+ end: 512,
237
+ kind: `utf16-range`,
238
+ start: 0,
239
+ }),
240
+ clients.lin.projection.acquireRange({
241
+ end: length,
242
+ kind: `utf16-range`,
243
+ start: length - 512,
244
+ }),
245
+ ])
246
+ expect(first.read().text).toBe(INITIAL.slice(0, 512))
247
+ expect(last.read().text).toBe(INITIAL.slice(-512))
248
+ expect(setup.service.instrumentation.materializations).toBe(0)
249
+ expect(clients.ada.residency.state.residentMemberCount).toBeLessThan(12)
250
+ expect(clients.lin.residency.state.residentMemberCount).toBeLessThan(12)
251
+ expect(
252
+ await clients.ada.residency.resident(
253
+ clients.ada.domain.address(`source`),
254
+ ),
255
+ ).toBeNull()
163
256
 
164
- act(() => {
165
- jane.socket.connect()
166
- dave.socket.connect()
167
- })
168
- await waitFor(() => {
169
- const janeText = jane.renderResult.getByTestId(`text`).textContent ?? ``
170
- const daveText = dave.renderResult.getByTestId(`text`).textContent ?? ``
171
- expect(janeText).toBe(daveText)
172
- expect(janeText).toContain(`[Jane]`)
173
- expect(janeText).toContain(`[Dave]`)
174
- expect(jane.renderResult.getByTestId(`word-count`).textContent).toBe(
175
- dave.renderResult.getByTestId(`word-count`).textContent,
176
- )
177
- })
178
- await room.teardown()
179
- })
257
+ const shrinkStart = await clients.ada.projection.positionAtOffset(2)
258
+ const shrinkEnd = await clients.ada.projection.positionAtOffset(28)
259
+ await clients.ada.replace({
260
+ selection: { anchor: shrinkStart, head: shrinkEnd },
261
+ text: `[Ada-shrinks]`,
262
+ })
263
+ expect(first.read().text).toContain(`[Ada-shrinks]`)
264
+ expect(first.read().text).not.toContain(`Field notes for the launch`)
180
265
 
181
- test(`one identity's undo preserves a later foreign edit`, async () => {
182
- const room = scenario()
183
- const jane = initJane(room)
184
- const dave = initDave(room)
185
- await expectStatus(jane, `live`)
186
- await expectStatus(dave, `live`)
266
+ const sameBoundary = await clients.ada.projection.positionAtOffset(16)
267
+ await Promise.all([
268
+ clients.ada.replace({
269
+ selection: { anchor: sameBoundary, head: sameBoundary },
270
+ text: `[Ada]`,
271
+ }),
272
+ clients.lin.replace({
273
+ selection: { anchor: sameBoundary, head: sameBoundary },
274
+ text: `[Lin]`,
275
+ }),
276
+ ])
277
+ const contended = await clients.ada.projection.materialize()
278
+ expect(contended).toContain(`[Ada]`)
279
+ expect(contended).toContain(`[Lin]`)
280
+ await waitFor(() => {
281
+ expect(first.read().text).toContain(`[Ada]`)
282
+ expect(first.read().text).toContain(`[Lin]`)
283
+ })
284
+ await waitFor(async () => {
285
+ expect(await clients.ada.projection.readLength()).toBe(contended.length)
286
+ })
187
287
 
188
- act(() => {
189
- jane.renderResult.getByTestId(`append`).click()
190
- })
191
- await waitFor(() => {
192
- expect(dave.renderResult.getByTestId(`text`).textContent).toContain(
193
- `[Jane]`,
288
+ const adaEnd = await clients.ada.projection.positionAtOffset(
289
+ contended.length,
194
290
  )
195
- })
196
- act(() => {
197
- dave.renderResult.getByTestId(`append`).click()
198
- })
199
- await waitFor(() => {
200
- expect(jane.renderResult.getByTestId(`text`).textContent).toContain(
201
- `[Dave]`,
291
+ await clients.ada.replace({
292
+ selection: { anchor: adaEnd, head: adaEnd },
293
+ text: `[Ada-history]`,
294
+ })
295
+ const afterAda = await clients.lin.projection.materialize()
296
+ await waitFor(async () => {
297
+ expect(await clients.lin.projection.readLength()).toBe(afterAda.length)
298
+ })
299
+ const linEnd = await clients.lin.projection.positionAtOffset(
300
+ afterAda.length,
202
301
  )
203
- })
302
+ await clients.lin.replace({
303
+ selection: { anchor: linEnd, head: linEnd },
304
+ text: `[Lin-foreign]`,
305
+ })
306
+ expect(await clients.ada.undo()).toBe(true)
307
+ const undone = await clients.lin.projection.materialize()
308
+ expect(undone).not.toContain(`[Ada-history]`)
309
+ expect(undone).toContain(`[Lin-foreign]`)
310
+ expect(await clients.ada.redo()).toBe(true)
311
+ await last.release()
204
312
 
205
- act(() => {
206
- jane.renderResult.getByTestId(`undo`).click()
207
- })
208
- await waitFor(() => {
209
- const text = jane.renderResult.getByTestId(`text`).textContent ?? ``
210
- expect(text).not.toContain(`[Jane]`)
211
- expect(text).toContain(`[Dave]`)
212
- expect(dave.renderResult.getByTestId(`text`).textContent).toBe(text)
213
- })
313
+ harness.ada.socket.disconnect()
314
+ const offlineAnchor = await clients.ada.projection.positionAtOffset(4)
315
+ const pending = clients.ada.replace({
316
+ selection: { anchor: offlineAnchor, head: offlineAnchor },
317
+ text: `[offline]`,
318
+ })
319
+ const foreignAnchor = await clients.lin.projection.positionAtOffset(4)
320
+ await clients.lin.replace({
321
+ selection: { anchor: foreignAnchor, head: foreignAnchor },
322
+ text: `[online]`,
323
+ })
324
+ harness.ada.socket.connect()
325
+ await waitFor(
326
+ () => {
327
+ expect(clients.ada.status().connection).toBe(`live`)
328
+ },
329
+ {
330
+ timeout: 5_000,
331
+ },
332
+ )
333
+ await pending
334
+ await setup.room.waitForIdle()
335
+ await first.release()
336
+ const reconnected = await clients.lin.projection.materialize()
337
+ expect(reconnected).toContain(`[offline]`)
338
+ expect(reconnected).toContain(`[online]`)
339
+ expect(setup.service.instrumentation.lastBatchOperations).toBeLessThan(16)
214
340
 
215
- act(() => {
216
- jane.renderResult.getByTestId(`redo`).click()
217
- })
218
- await waitFor(() => {
219
- const text = jane.renderResult.getByTestId(`text`).textContent ?? ``
220
- expect(text).toContain(`[Jane]`)
221
- expect(text).toContain(`[Dave]`)
222
- expect(dave.renderResult.getByTestId(`text`).textContent).toBe(text)
223
- })
224
- await room.teardown()
341
+ const resetStart = await clients.ada.projection.positionAtOffset(0)
342
+ const resetEnd = await clients.ada.projection.positionAtOffset(
343
+ reconnected.length,
344
+ )
345
+ await clients.ada.replace({
346
+ selection: { anchor: resetStart, head: resetEnd },
347
+ text: ``,
348
+ })
349
+ await waitFor(async () => {
350
+ expect(await clients.lin.projection.readLength()).toBe(0)
351
+ })
352
+ expect(await clients.lin.projection.materialize()).toBe(``)
353
+ await expect(setup.service.positionAtOffset(1)).rejects.toThrow(
354
+ `outside the Mosaic text index`,
355
+ )
356
+ const empty = await clients.ada.projection.positionAtOffset(0)
357
+ expect(empty).toEqual({ affinity: `left`, offset: 0, runId: null })
358
+ await clients.ada.replace({
359
+ selection: { anchor: empty, head: empty },
360
+ text: `[reset-all]`,
361
+ })
362
+ await waitFor(async () => {
363
+ expect(await clients.lin.projection.materialize()).toBe(`[reset-all]`)
364
+ })
365
+ } finally {
366
+ await setup.teardown()
367
+ }
225
368
  })
226
369
 
227
- test(`presence is model-aware and removed on disconnect`, async () => {
228
- const room = scenario()
229
- const jane = initJane(room)
230
- const dave = initDave(room)
231
- await expectStatus(jane, `live`)
232
- await expectStatus(dave, `live`)
233
- act(() => {
234
- jane.renderResult.getByTestId(`presence-update`).click()
235
- dave.renderResult.getByTestId(`presence-update`).click()
370
+ test(`presence is logical/ephemeral and import is one authorized resnapshot cut`, async () => {
371
+ const setup = await scenario({
372
+ presenceRenewalMs: 20,
373
+ presenceTtlMs: 80,
236
374
  })
237
- await waitFor(() => {
238
- expect(jane.renderResult.getByTestId(`presence`).textContent).toBe(
239
- `dave,jane`,
375
+ try {
376
+ const harness = await initialize(setup)
377
+ const clients = await live(setup)
378
+ const caretOffset = INITIAL.lastIndexOf(`partial residency`)
379
+ const caret = await clients.ada.projection.positionAtOffset(caretOffset)
380
+ await clients.ada.publishPresence({
381
+ color: ADA.color,
382
+ name: ADA.name,
383
+ selection: { anchor: caret, head: caret },
384
+ viewport: null,
385
+ })
386
+ await setup.room.waitForIdle()
387
+ await clients.lin.presence.flush()
388
+ expect(
389
+ clients.lin.presence.state.presence.some(
390
+ (envelope) => envelope.kind === `update` && envelope.actor === ADA.id,
391
+ ),
392
+ ).toBe(true)
393
+ const documentStart = await clients.lin.projection.positionAtOffset(0)
394
+ const prefix = `[Lin-prefix]`
395
+ await clients.lin.replace({
396
+ selection: { anchor: documentStart, head: documentStart },
397
+ text: prefix,
398
+ })
399
+ await waitFor(async () => {
400
+ expect(await clients.lin.projection.resolvePosition(caret)).toBe(
401
+ caretOffset + prefix.length,
402
+ )
403
+ })
404
+ await new Promise((resolve) => setTimeout(resolve, 200))
405
+ await setup.room.waitForIdle()
406
+ await clients.lin.presence.flush()
407
+ expect(
408
+ clients.lin.presence.state.presence.some(
409
+ (envelope) => envelope.kind === `update` && envelope.actor === ADA.id,
410
+ ),
411
+ ).toBe(true)
412
+ harness.ada.socket.disconnect()
413
+ await waitFor(() => {
414
+ expect(
415
+ clients.lin.presence.state.presence.some(
416
+ (envelope) =>
417
+ envelope.kind === `update` && envelope.actor === ADA.id,
418
+ ),
419
+ ).toBe(false)
420
+ })
421
+
422
+ const importedRange = await clients.lin.projection.acquireRange({
423
+ end: 16,
424
+ kind: `utf16-range`,
425
+ start: 0,
426
+ })
427
+ const revision = setup.service.revision
428
+ await expect(
429
+ setup.service.command({
430
+ actor: LIN.id,
431
+ command: {
432
+ gestureId: `unauthorized-import`,
433
+ sequence: 1,
434
+ text: `denied`,
435
+ type: `import`,
436
+ },
437
+ session: `lin-admin`,
438
+ }),
439
+ ).rejects.toThrow(`authorized`)
440
+ await setup.service.command({
441
+ actor: ADA.id,
442
+ command: {
443
+ gestureId: `authorized-import`,
444
+ sequence: 1,
445
+ text: `# Imported once\n\n${`x`.repeat(100_000)}`,
446
+ type: `import`,
447
+ },
448
+ session: `ada-admin`,
449
+ })
450
+ expect(setup.service.revision).toBe(revision + 1)
451
+ expect(setup.service.materialize().startsWith(`# Imported once`)).toBe(
452
+ true,
240
453
  )
241
- })
454
+ expect(setup.service.instrumentation.lastBatchOperations).toBeGreaterThan(
455
+ 4,
456
+ )
457
+ await waitFor(() => {
458
+ expect(importedRange.read().text.startsWith(`# Imported once`)).toBe(
459
+ true,
460
+ )
461
+ })
462
+ await setup.service.command({
463
+ actor: ADA.id,
464
+ command: {
465
+ gestureId: `authorized-merge`,
466
+ sequence: 2,
467
+ text: `# Compacted again\n`,
468
+ type: `import`,
469
+ },
470
+ session: `ada-admin`,
471
+ })
472
+ expect(setup.service.revision).toBe(revision + 2)
473
+ await waitFor(() => {
474
+ expect(importedRange.read().text).toBe(`# Compacted agai`)
475
+ })
476
+ await importedRange.release()
477
+ } finally {
478
+ await setup.teardown()
479
+ }
480
+ })
242
481
 
243
- act(() => {
244
- dave.socket.disconnect()
245
- })
246
- await waitFor(() => {
247
- expect(jane.renderResult.getByTestId(`presence`).textContent).toBe(`jane`)
248
- })
249
- await room.teardown()
482
+ test(`settles an offline command when its collaboration client is disposed`, async () => {
483
+ const setup = await scenario()
484
+ try {
485
+ const harness = await initialize(setup)
486
+ const clients = await live(setup)
487
+ const caret = await clients.ada.projection.positionAtOffset(4)
488
+ harness.ada.socket.disconnect()
489
+ await waitFor(() => {
490
+ expect(clients.ada.status().connection).toBe(`offline`)
491
+ })
492
+ const pending = clients.ada.replace({
493
+ selection: { anchor: caret, head: caret },
494
+ text: `[abandoned]`,
495
+ })
496
+ await Promise.resolve()
497
+ clients.ada[Symbol.dispose]()
498
+ await expect(pending).rejects.toThrow(`disposed`)
499
+ } finally {
500
+ await setup.teardown()
501
+ }
250
502
  })
251
503
  })