@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.
@@ -0,0 +1,806 @@
1
+ import {
2
+ expect,
3
+ test,
4
+ type Browser,
5
+ type BrowserContext,
6
+ type Page,
7
+ } from "@playwright/test"
8
+
9
+ import {
10
+ createMarkdownCollaborationHttpServer,
11
+ type MarkdownCollaborationHttpServer,
12
+ } from "../../node/http-server.ts"
13
+
14
+ const EDITOR = `[data-lexical-editor="true"]`
15
+ const WRITER_MARKER = `[WRITER-PREFIX]`
16
+ const BACKEND_PORT = 3_027
17
+
18
+ let backend: MarkdownCollaborationHttpServer
19
+
20
+ type Clients = {
21
+ readonly maya: Page
22
+ readonly mayaContext: BrowserContext
23
+ readonly theo: Page
24
+ readonly theoContext: BrowserContext
25
+ }
26
+
27
+ type CursorSample = {
28
+ readonly end: number | null
29
+ readonly kind: number
30
+ readonly length: number
31
+ readonly phase: number
32
+ readonly rect: {
33
+ readonly height: number
34
+ readonly left: number
35
+ readonly top: number
36
+ } | null
37
+ }
38
+
39
+ type ColorSchemeSnapshot = {
40
+ readonly editorBackground: string
41
+ readonly editorInk: string
42
+ readonly footerBackground: string
43
+ readonly pageBackground: string
44
+ }
45
+
46
+ type CursorSamplerWindow = Window & {
47
+ __mosaicCursorFrame?: number
48
+ __mosaicCursorSamples?: CursorSample[]
49
+ }
50
+
51
+ type VisibleCursorSample = {
52
+ readonly caret: {
53
+ readonly height: number
54
+ readonly left: number
55
+ readonly top: number
56
+ } | null
57
+ readonly expected: {
58
+ readonly height: number
59
+ readonly left: number
60
+ readonly top: number
61
+ } | null
62
+ readonly phase: number
63
+ readonly reason: `animation-frame` | `mutation`
64
+ readonly overlayCount: number
65
+ readonly selectionEnd: number | null
66
+ readonly session: string | null
67
+ readonly targetIndex: number
68
+ readonly textLength: number
69
+ }
70
+
71
+ type VisibleCursorSamplerWindow = Window & {
72
+ __mosaicVisibleCursorFrame?: number
73
+ __mosaicVisibleCursorObserver?: MutationObserver
74
+ __mosaicVisibleCursorPhase?: number
75
+ __mosaicVisibleCursorSamples?: VisibleCursorSample[]
76
+ }
77
+
78
+ type WriterOrderObservation = {
79
+ readonly activeElement: string | null
80
+ readonly actual: string
81
+ readonly anchorOffset: number | null
82
+ readonly anchorText: string | null
83
+ readonly character: string
84
+ readonly collapsed: boolean | null
85
+ readonly expected: string
86
+ readonly index: number
87
+ readonly phase: `immediate` | `paced`
88
+ readonly status: string
89
+ }
90
+
91
+ async function openClients(browser: Browser): Promise<Clients> {
92
+ const mayaContext = await browser.newContext()
93
+ const theoContext = await browser.newContext()
94
+ const maya = await mayaContext.newPage()
95
+ const theo = await theoContext.newPage()
96
+ await Promise.all([maya.goto(`/?as=maya`), theo.goto(`/?as=theo`)])
97
+ await Promise.all([
98
+ maya.locator(EDITOR).waitFor({ state: `visible` }),
99
+ theo.locator(EDITOR).waitFor({ state: `visible` }),
100
+ ])
101
+ await Promise.all([waitForSaved(maya), waitForSaved(theo)])
102
+ return { maya, mayaContext, theo, theoContext }
103
+ }
104
+
105
+ async function closeClients(clients: Clients): Promise<void> {
106
+ await Promise.all([clients.mayaContext.close(), clients.theoContext.close()])
107
+ }
108
+
109
+ async function sourceText(page: Page): Promise<string> {
110
+ const text = await page.locator(EDITOR).innerText()
111
+ // Chromium exposes Lexical's managed empty-paragraph <br> as one visual
112
+ // newline even though the editor model contains zero UTF-16 units.
113
+ return text === `\n` ? `` : text
114
+ }
115
+
116
+ async function colorSchemeSnapshot(page: Page): Promise<ColorSchemeSnapshot> {
117
+ return page.evaluate(() => {
118
+ const editorPane = document.querySelector(`editor-pane`)
119
+ const editor = document.querySelector(`[data-lexical-editor="true"]`)
120
+ const footer = document.querySelector(`footer`)
121
+ if (editorPane === null || editor === null || footer === null) {
122
+ throw new Error(`The themed editor surface is not ready.`)
123
+ }
124
+ return {
125
+ editorBackground: getComputedStyle(editorPane).backgroundColor,
126
+ editorInk: getComputedStyle(editor).color,
127
+ footerBackground: getComputedStyle(footer).backgroundColor,
128
+ pageBackground: getComputedStyle(document.documentElement).backgroundColor,
129
+ }
130
+ })
131
+ }
132
+
133
+ async function waitForSaved(page: Page): Promise<void> {
134
+ await expect(page.locator(`footer strong`)).toHaveText(`All changes saved`, {
135
+ timeout: 10_000,
136
+ })
137
+ }
138
+
139
+ async function waitForText(page: Page, expected: string): Promise<void> {
140
+ await expect.poll(() => sourceText(page), { timeout: 10_000 }).toBe(expected)
141
+ }
142
+
143
+ async function moveCaret(page: Page, offset: number): Promise<void> {
144
+ if (!Number.isSafeInteger(offset) || offset < 0) {
145
+ throw new Error(`Caret offset must be a non-negative integer.`)
146
+ }
147
+ await page.locator(EDITOR).click()
148
+ await page.keyboard.press(`ControlOrMeta+Home`)
149
+ for (let index = 0; index < offset; index++) {
150
+ await page.keyboard.press(`ArrowRight`)
151
+ }
152
+ }
153
+
154
+ async function observeWriterOrder(
155
+ page: Page,
156
+ input: Omit<
157
+ WriterOrderObservation,
158
+ | `activeElement`
159
+ | `actual`
160
+ | `anchorOffset`
161
+ | `anchorText`
162
+ | `collapsed`
163
+ | `status`
164
+ >,
165
+ ): Promise<WriterOrderObservation> {
166
+ const browser = await page.evaluate((editorSelector) => {
167
+ const root = document.querySelector<HTMLElement>(editorSelector)
168
+ const selection = window.getSelection()
169
+ const anchor = selection?.anchorNode
170
+ return {
171
+ activeElement:
172
+ document.activeElement instanceof HTMLElement
173
+ ? (document.activeElement.getAttribute(`data-lexical-editor`) ??
174
+ document.activeElement.tagName)
175
+ : null,
176
+ actual: root?.innerText === `\n` ? `` : (root?.innerText ?? ``),
177
+ anchorOffset: selection?.anchorOffset ?? null,
178
+ anchorText: anchor instanceof Text ? anchor.data : null,
179
+ collapsed: selection?.isCollapsed ?? null,
180
+ status: document.querySelector(`footer strong`)?.textContent ?? ``,
181
+ }
182
+ }, EDITOR)
183
+ return { ...input, ...browser }
184
+ }
185
+
186
+ // Cursor geometry tests need a semantic starting point, not hundreds of
187
+ // ArrowRight round trips. The actual edits and subsequent arrow keys stay native.
188
+ async function moveCaretToText(page: Page, text: string): Promise<void> {
189
+ await page.locator(EDITOR).evaluate((root, target) => {
190
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
191
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
192
+ const offset = node.textContent?.indexOf(target) ?? -1
193
+ if (offset < 0) continue
194
+ ;(root as HTMLElement).focus()
195
+ window.getSelection()!.setBaseAndExtent(node, offset, node, offset)
196
+ return
197
+ }
198
+ throw new Error(`Caret target not found: ${target}`)
199
+ }, text)
200
+ }
201
+
202
+ async function selectGraphemeRange(
203
+ page: Page,
204
+ startIndex: number,
205
+ endIndex: number,
206
+ ): Promise<void> {
207
+ await moveCaret(page, startIndex)
208
+ for (let index = startIndex; index < endIndex; index++) {
209
+ await page.keyboard.press(`Shift+ArrowRight`)
210
+ }
211
+ }
212
+
213
+ async function focusLineStart(page: Page, prefix: string): Promise<void> {
214
+ const line = page
215
+ .locator(`${EDITOR} [data-lexical-text="true"]`)
216
+ .filter({ hasText: new RegExp(`^${prefix.replaceAll(`.`, `\\.`)}`) })
217
+ .first()
218
+ await expect(line).toBeVisible()
219
+ // Click the first rendered row, not the locator's visual center: long logical
220
+ // lines can wrap, and Home operates on the clicked visual row.
221
+ await line.click({ position: { x: 2, y: 2 } })
222
+ await page.keyboard.press(`Home`)
223
+ }
224
+
225
+ async function typeLiteral(
226
+ page: Page,
227
+ value: string,
228
+ delay = 35,
229
+ ): Promise<void> {
230
+ for (const character of value) {
231
+ if (character === `\n`) await page.keyboard.press(`Enter`)
232
+ else await page.keyboard.type(character)
233
+ if (delay > 0) await page.waitForTimeout(delay)
234
+ }
235
+ }
236
+
237
+ async function startCursorSampler(
238
+ page: Page,
239
+ session: string,
240
+ semanticTarget: string,
241
+ ): Promise<void> {
242
+ await page.evaluate(
243
+ ({ exactSession, targetText }) => {
244
+ const target = window as CursorSamplerWindow
245
+ target.__mosaicCursorSamples = []
246
+ let phase = 0
247
+ const sample = (): void => {
248
+ const root = document.querySelector<HTMLElement>(
249
+ `[data-lexical-editor="true"]`,
250
+ )
251
+ const text = root?.innerText ?? ``
252
+ const overlay = document.querySelector<HTMLElement>(
253
+ `collaborator-presence[data-session="${exactSession}"]`,
254
+ )
255
+ const caret = overlay?.querySelector<HTMLElement>(`collaborator-caret`)
256
+ const rect = caret?.getBoundingClientRect()
257
+ target.__mosaicCursorSamples!.push({
258
+ end:
259
+ overlay === null
260
+ ? null
261
+ : Number(overlay.getAttribute(`data-selection-end`)),
262
+ kind: text.lastIndexOf(targetText),
263
+ length: text.length,
264
+ phase: phase++,
265
+ rect:
266
+ rect === undefined
267
+ ? null
268
+ : { height: rect.height, left: rect.left, top: rect.top },
269
+ })
270
+ target.__mosaicCursorFrame = requestAnimationFrame(sample)
271
+ }
272
+ target.__mosaicCursorFrame = requestAnimationFrame(sample)
273
+ },
274
+ { exactSession: session, targetText: semanticTarget },
275
+ )
276
+ }
277
+
278
+ async function stopCursorSampler(page: Page): Promise<readonly CursorSample[]> {
279
+ return page.evaluate(() => {
280
+ const target = window as CursorSamplerWindow
281
+ if (target.__mosaicCursorFrame !== undefined) {
282
+ cancelAnimationFrame(target.__mosaicCursorFrame)
283
+ }
284
+ return target.__mosaicCursorSamples ?? []
285
+ })
286
+ }
287
+
288
+ async function startVisibleCursorSampler(
289
+ page: Page,
290
+ collaborator: string,
291
+ semanticTarget: string,
292
+ ): Promise<void> {
293
+ await page.evaluate(
294
+ ({ collaboratorName, targetText }) => {
295
+ const target = window as VisibleCursorSamplerWindow
296
+ target.__mosaicVisibleCursorSamples = []
297
+ target.__mosaicVisibleCursorPhase = 0
298
+
299
+ const expectedCaret = (): DOMRect | null => {
300
+ const root = document.querySelector<HTMLElement>(
301
+ `[data-lexical-editor="true"]`,
302
+ )
303
+ if (root === null) return null
304
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
305
+ let match: { readonly index: number; readonly node: Text } | null = null
306
+ for (
307
+ let node = walker.nextNode();
308
+ node !== null;
309
+ node = walker.nextNode()
310
+ ) {
311
+ if (!(node instanceof Text)) continue
312
+ const index = node.data.lastIndexOf(targetText)
313
+ if (index >= 0) match = { index, node }
314
+ }
315
+ if (match === null) return null
316
+ const range = document.createRange()
317
+ range.setStart(match.node, match.index)
318
+ range.setEnd(match.node, match.index + 1)
319
+ return range.getBoundingClientRect()
320
+ }
321
+
322
+ const capture = (reason: `animation-frame` | `mutation`): void => {
323
+ const root = document.querySelector<HTMLElement>(
324
+ `[data-lexical-editor="true"]`,
325
+ )
326
+ const expected = expectedCaret()
327
+ const overlays = document.querySelectorAll<HTMLElement>(
328
+ `collaborator-presence[data-collaborator="${collaboratorName}"]`,
329
+ )
330
+ const matchedOverlays: readonly (HTMLElement | null)[] =
331
+ overlays.length === 0 ? [null] : [...overlays]
332
+ for (const overlay of matchedOverlays) {
333
+ const caret = overlay?.querySelector<HTMLElement>(`collaborator-caret`)
334
+ const caretRect = caret?.getBoundingClientRect()
335
+ const selectionEnd = overlay?.getAttribute(`data-selection-end`)
336
+ target.__mosaicVisibleCursorSamples!.push({
337
+ caret:
338
+ caretRect === undefined
339
+ ? null
340
+ : {
341
+ height: caretRect.height,
342
+ left: caretRect.left,
343
+ top: caretRect.top,
344
+ },
345
+ expected:
346
+ expected === null
347
+ ? null
348
+ : {
349
+ height: expected.height,
350
+ left: expected.left,
351
+ top: expected.top,
352
+ },
353
+ phase: target.__mosaicVisibleCursorPhase ?? 0,
354
+ reason,
355
+ overlayCount: overlays.length,
356
+ selectionEnd:
357
+ selectionEnd !== null && Number.isFinite(Number(selectionEnd))
358
+ ? Number(selectionEnd)
359
+ : null,
360
+ session: overlay?.getAttribute(`data-session`) ?? null,
361
+ targetIndex: root?.innerText.lastIndexOf(targetText) ?? -1,
362
+ textLength: root?.innerText.length ?? 0,
363
+ })
364
+ }
365
+ target.__mosaicVisibleCursorPhase =
366
+ (target.__mosaicVisibleCursorPhase ?? 0) + 1
367
+ }
368
+
369
+ target.__mosaicVisibleCursorObserver = new MutationObserver(() => {
370
+ capture(`mutation`)
371
+ })
372
+ const overlayRoot = document.querySelector(`collaborator-overlays`)
373
+ if (overlayRoot !== null) {
374
+ target.__mosaicVisibleCursorObserver.observe(overlayRoot, {
375
+ attributeFilter: [
376
+ `data-selection-end`,
377
+ `data-selection-start`,
378
+ `style`,
379
+ ],
380
+ attributes: true,
381
+ childList: true,
382
+ subtree: true,
383
+ })
384
+ }
385
+ const frame = (): void => {
386
+ capture(`animation-frame`)
387
+ target.__mosaicVisibleCursorFrame = requestAnimationFrame(frame)
388
+ }
389
+ capture(`mutation`)
390
+ target.__mosaicVisibleCursorFrame = requestAnimationFrame(frame)
391
+ },
392
+ { collaboratorName: collaborator, targetText: semanticTarget },
393
+ )
394
+ }
395
+
396
+ async function stopVisibleCursorSampler(
397
+ page: Page,
398
+ ): Promise<readonly VisibleCursorSample[]> {
399
+ return page.evaluate(() => {
400
+ const target = window as VisibleCursorSamplerWindow
401
+ if (target.__mosaicVisibleCursorFrame !== undefined) {
402
+ cancelAnimationFrame(target.__mosaicVisibleCursorFrame)
403
+ }
404
+ target.__mosaicVisibleCursorObserver?.disconnect()
405
+ return target.__mosaicVisibleCursorSamples ?? []
406
+ })
407
+ }
408
+
409
+ test.describe(`Mosaic text editor browser conformance`, () => {
410
+ test.beforeEach(async () => {
411
+ backend = await createMarkdownCollaborationHttpServer({ port: BACKEND_PORT })
412
+ })
413
+
414
+ test.afterEach(async () => {
415
+ await backend.close()
416
+ })
417
+
418
+ test(`starts dark and follows the system light color scheme`, async ({
419
+ page,
420
+ }) => {
421
+ await page.emulateMedia({ colorScheme: `dark` })
422
+ await page.goto(`/?as=maya`)
423
+ await expect(page.locator(`meta[name="color-scheme"]`)).toHaveAttribute(
424
+ `content`,
425
+ `dark light`,
426
+ )
427
+ await page.locator(EDITOR).waitFor({ state: `visible` })
428
+ await waitForSaved(page)
429
+ expect(await colorSchemeSnapshot(page)).toEqual({
430
+ editorBackground: `rgb(28, 25, 34)`,
431
+ editorInk: `rgb(229, 223, 234)`,
432
+ footerBackground: `rgb(25, 23, 31)`,
433
+ pageBackground: `rgb(18, 16, 22)`,
434
+ })
435
+
436
+ await page.emulateMedia({ colorScheme: `light` })
437
+ await expect
438
+ .poll(() => colorSchemeSnapshot(page))
439
+ .toEqual({
440
+ editorBackground: `rgb(255, 254, 250)`,
441
+ editorInk: `rgb(56, 53, 66)`,
442
+ footerBackground: `rgb(250, 249, 252)`,
443
+ pageBackground: `rgb(243, 242, 247)`,
444
+ })
445
+ })
446
+
447
+ test(`delete-all and immediate retyping preserve exact intent`, async ({
448
+ browser,
449
+ }) => {
450
+ const clients = await openClients(browser)
451
+ try {
452
+ expect((await sourceText(clients.maya)).length).toBeGreaterThan(0)
453
+ const editor = clients.maya.locator(EDITOR)
454
+ await editor.click()
455
+ await clients.maya.keyboard.press(`ControlOrMeta+A`)
456
+ await clients.maya.keyboard.press(`Backspace`)
457
+ expect(await sourceText(clients.maya)).toBe(``)
458
+
459
+ const replacement = `[RESET-ALL] fresh text`
460
+ let expected = ``
461
+ for (const character of replacement) {
462
+ await clients.maya.keyboard.type(character)
463
+ expected += character
464
+ expect(await sourceText(clients.maya)).toBe(expected)
465
+ await clients.maya.waitForTimeout(45)
466
+ }
467
+ await waitForSaved(clients.maya)
468
+ await waitForText(clients.theo, replacement)
469
+ await clients.theo.reload()
470
+ await clients.theo.locator(EDITOR).waitFor({ state: `visible` })
471
+ await waitForText(clients.theo, replacement)
472
+ } finally {
473
+ await closeClients(clients)
474
+ }
475
+ })
476
+
477
+ test(`typing from a settled empty-row boundary stays on that row`, async ({
478
+ browser,
479
+ }) => {
480
+ const clients = await openClients(browser)
481
+ try {
482
+ await focusLineStart(clients.maya, `1.`)
483
+ await clients.maya.keyboard.press(`Enter`)
484
+ await clients.maya.keyboard.press(`Enter`)
485
+ await clients.maya.keyboard.press(`Enter`)
486
+ await waitForSaved(clients.maya)
487
+
488
+ const afterBlanks = await sourceText(clients.maya)
489
+ const list = afterBlanks.indexOf(`1.`)
490
+ expect(list).toBeGreaterThan(0)
491
+ await focusLineStart(clients.maya, `1.`)
492
+ await clients.maya.keyboard.press(`Enter`)
493
+ await waitForSaved(clients.maya)
494
+ await clients.maya.keyboard.press(`ArrowUp`)
495
+ await typeLiteral(clients.maya, `[EMPTY-LINE]`, 70)
496
+ await waitForSaved(clients.maya)
497
+
498
+ const settled = await sourceText(clients.maya)
499
+ expect(settled).toContain(`\n[EMPTY-LINE]\n1.`)
500
+ expect(settled).not.toContain(`[EMPTY-LINE]1.`)
501
+ await waitForText(clients.theo, settled)
502
+ await clients.theo.reload()
503
+ await clients.theo.locator(EDITOR).waitFor({ state: `visible` })
504
+ await waitForText(clients.theo, settled)
505
+ } finally {
506
+ await closeClients(clients)
507
+ }
508
+ })
509
+
510
+ test(`native grapheme selection and replacement preserve exact text`, async ({
511
+ browser,
512
+ }) => {
513
+ const clients = await openClients(browser)
514
+ try {
515
+ await clients.maya.locator(EDITOR).click()
516
+ await clients.maya.keyboard.press(`ControlOrMeta+A`)
517
+ await clients.maya.keyboard.press(`Backspace`)
518
+ await waitForText(clients.maya, ``)
519
+ await waitForSaved(clients.maya)
520
+ await clients.maya.keyboard.insertText(`a😀bc`)
521
+ await waitForText(clients.maya, `a😀bc`)
522
+ await waitForSaved(clients.maya)
523
+
524
+ // Keep native keyboard selection across a surrogate pair in the browser;
525
+ // the larger seeded oracle lives in collaboration.test.tsx.
526
+ const replacements = [
527
+ { start: 1, end: 2, text: `x`, expected: `axbc` },
528
+ { start: 2, end: 2, text: `😀`, expected: `ax😀bc` },
529
+ { start: 1, end: 4, text: `!`, expected: `a!c` },
530
+ ] as const
531
+ for (const replacement of replacements) {
532
+ await selectGraphemeRange(
533
+ clients.maya,
534
+ replacement.start,
535
+ replacement.end,
536
+ )
537
+ await clients.maya.keyboard.insertText(replacement.text)
538
+ expect(await sourceText(clients.maya)).toBe(replacement.expected)
539
+ }
540
+ await waitForSaved(clients.maya)
541
+ await waitForText(clients.theo, `a!c`)
542
+ } finally {
543
+ await closeClients(clients)
544
+ }
545
+ })
546
+
547
+ test(`human-paced typing on a new line preserves keystroke order`, async ({
548
+ browser,
549
+ }) => {
550
+ test.setTimeout(60_000)
551
+ const clients = await openClients(browser)
552
+ try {
553
+ const editor = clients.maya.locator(EDITOR)
554
+ await editor.click()
555
+ await clients.maya.keyboard.press(`ControlOrMeta+A`)
556
+ await clients.maya.keyboard.press(`Backspace`)
557
+ await waitForText(clients.maya, ``)
558
+ await waitForSaved(clients.maya)
559
+ const fixture = `anchor\n\nsentinel`
560
+ await clients.maya.keyboard.insertText(fixture)
561
+ await waitForText(clients.maya, fixture)
562
+ await waitForSaved(clients.maya)
563
+ await waitForText(clients.theo, fixture)
564
+ const insertion = fixture.indexOf(`\n`) + 1
565
+ await moveCaret(clients.maya, insertion)
566
+ const phrase = `The quick brown fox jumps over the lazy dog`
567
+ const observations: WriterOrderObservation[] = []
568
+ let typed = ``
569
+
570
+ for (const [index, character] of [...phrase].entries()) {
571
+ await clients.maya.keyboard.type(character)
572
+ typed += character
573
+ const expected = `${fixture.slice(0, insertion)}${typed}${fixture.slice(insertion)}`
574
+ observations.push(
575
+ await observeWriterOrder(clients.maya, {
576
+ character,
577
+ expected,
578
+ index,
579
+ phase: `immediate`,
580
+ }),
581
+ )
582
+ await clients.maya.waitForTimeout(200)
583
+ observations.push(
584
+ await observeWriterOrder(clients.maya, {
585
+ character,
586
+ expected,
587
+ index,
588
+ phase: `paced`,
589
+ }),
590
+ )
591
+
592
+ const firstFailure = observations.find(
593
+ (observation) => observation.actual !== observation.expected,
594
+ )
595
+ expect(
596
+ firstFailure,
597
+ `A human-paced key was committed out of order. First failure:\n${JSON.stringify(firstFailure, null, 2)}\nRecent writer trace:\n${JSON.stringify(observations.slice(-8), null, 2)}`,
598
+ ).toBeUndefined()
599
+ }
600
+
601
+ const expected = `${fixture.slice(0, insertion)}${phrase}${fixture.slice(insertion)}`
602
+ await waitForSaved(clients.maya)
603
+ await waitForText(clients.theo, expected)
604
+ expect(await sourceText(clients.maya)).toBe(expected)
605
+ } finally {
606
+ await closeClients(clients)
607
+ }
608
+ })
609
+
610
+ test(`remote cursors follow their semantic target in every frame`, async ({
611
+ browser,
612
+ }) => {
613
+ const clients = await openClients(browser)
614
+ try {
615
+ const fixture = await sourceText(clients.maya)
616
+ const kind = fixture.lastIndexOf(`kind`)
617
+ await moveCaretToText(clients.theo, `kind`)
618
+ await clients.theo.keyboard.press(`ArrowRight`)
619
+ await clients.theo.keyboard.press(`ArrowLeft`)
620
+ const presence = clients.maya.locator(
621
+ `collaborator-presence[data-collaborator="Theo Brooks"]`,
622
+ )
623
+ await expect(presence).toHaveCount(1)
624
+ await expect(presence).toHaveAttribute(`data-selection-end`, String(kind))
625
+ const session = await presence.getAttribute(`data-session`)
626
+ expect(session).not.toBeNull()
627
+ if (session === null) throw new Error(`Theo's presence has no session.`)
628
+
629
+ await startCursorSampler(clients.maya, session, `kind`)
630
+
631
+ await moveCaret(clients.maya, 0)
632
+ await typeLiteral(clients.maya, WRITER_MARKER, 80)
633
+ await clients.maya.waitForTimeout(500)
634
+ const samples = await stopCursorSampler(clients.maya)
635
+ expect(samples.length).toBeGreaterThan(WRITER_MARKER.length)
636
+ const invalid = samples.filter(
637
+ (sample) =>
638
+ sample.end === null ||
639
+ sample.rect === null ||
640
+ sample.rect.height === 0 ||
641
+ sample.end !== sample.kind ||
642
+ sample.end === sample.length,
643
+ )
644
+ expect(invalid, JSON.stringify(invalid.slice(0, 12), null, 2)).toEqual([])
645
+
646
+ const expected = `${WRITER_MARKER}${fixture}`
647
+ await waitForSaved(clients.maya)
648
+ await waitForText(clients.theo, expected)
649
+ expect(await sourceText(clients.maya)).toBe(expected)
650
+ expect(await clients.theo.locator(`footer strong`).textContent()).toBe(
651
+ `All changes saved`,
652
+ )
653
+ } finally {
654
+ await closeClients(clients)
655
+ }
656
+ })
657
+
658
+ test(`every painted remote caret remains on its semantic glyph`, async ({
659
+ browser,
660
+ }) => {
661
+ const clients = await openClients(browser)
662
+ try {
663
+ const marker = `CURSOR!!`
664
+ const fixture = await sourceText(clients.maya)
665
+ const rollout = fixture.lastIndexOf(`rollout`)
666
+ expect(rollout).toBeGreaterThan(0)
667
+ await moveCaretToText(clients.theo, `rollout`)
668
+ await clients.theo.keyboard.press(`ArrowRight`)
669
+ await clients.theo.keyboard.press(`ArrowLeft`)
670
+
671
+ const presence = clients.maya.locator(
672
+ `collaborator-presence[data-collaborator="Theo Brooks"]`,
673
+ )
674
+ await expect(presence).toHaveCount(1)
675
+ await expect(presence).toHaveAttribute(
676
+ `data-selection-end`,
677
+ String(rollout),
678
+ )
679
+
680
+ const devtools = await clients.mayaContext.newCDPSession(clients.maya)
681
+ await devtools.send(`Network.enable`)
682
+ await devtools.send(`Network.emulateNetworkConditions`, {
683
+ downloadThroughput: 20_000_000,
684
+ latency: 100,
685
+ offline: false,
686
+ uploadThroughput: 20_000_000,
687
+ })
688
+ await devtools.send(`Emulation.setCPUThrottlingRate`, { rate: 3 })
689
+ await startVisibleCursorSampler(clients.maya, `Theo Brooks`, `rollout`)
690
+ await moveCaret(clients.maya, 0)
691
+ let typed = ``
692
+ for (const character of marker) {
693
+ await clients.maya.keyboard.type(character)
694
+ typed += character
695
+ await expect
696
+ .poll(() => sourceText(clients.maya), { timeout: 2_000 })
697
+ .toBe(`${typed}${fixture}`)
698
+ await clients.maya.waitForTimeout(150)
699
+ }
700
+ await clients.maya.waitForTimeout(750)
701
+ const samples = await stopVisibleCursorSampler(clients.maya)
702
+ expect(samples.length).toBeGreaterThan(30)
703
+ const expectedText = `${marker}${fixture}`
704
+
705
+ const invalid = samples.filter((sample) => {
706
+ if (
707
+ sample.overlayCount !== 1 ||
708
+ sample.caret === null ||
709
+ sample.expected === null ||
710
+ sample.selectionEnd !== sample.targetIndex
711
+ ) {
712
+ return true
713
+ }
714
+ return (
715
+ Math.abs(sample.caret.left - sample.expected.left) > 1 ||
716
+ Math.abs(sample.caret.top - sample.expected.top) > 1
717
+ )
718
+ })
719
+ const terminal = invalid.filter(
720
+ (sample) =>
721
+ sample.caret !== null &&
722
+ sample.expected !== null &&
723
+ sample.caret.top > sample.expected.top + 50 &&
724
+ sample.selectionEnd !== null &&
725
+ sample.selectionEnd >= sample.textLength - 1,
726
+ )
727
+ expect(
728
+ invalid.length,
729
+ `A user can see every painted cursor, including a stale duplicate or one-frame terminal projection. ${invalid.length}/${samples.length} samples were invalid and ${terminal.length} painted at the document terminus. First failures:\n${JSON.stringify(invalid.slice(0, 6), null, 2)}`,
730
+ ).toBe(0)
731
+
732
+ await waitForSaved(clients.maya)
733
+ await waitForText(clients.theo, expectedText)
734
+ expect(await sourceText(clients.maya)).toBe(expectedText)
735
+ } finally {
736
+ await closeClients(clients)
737
+ }
738
+ })
739
+
740
+ test(`an active remote writer never paints at the document terminus`, async ({
741
+ browser,
742
+ }) => {
743
+ const clients = await openClients(browser)
744
+ try {
745
+ const fixture = await sourceText(clients.maya)
746
+ const rollout = fixture.lastIndexOf(`rollout`)
747
+ expect(rollout).toBeGreaterThan(0)
748
+ await moveCaretToText(clients.theo, `rollout`)
749
+ await clients.theo.keyboard.press(`ArrowRight`)
750
+ await clients.theo.keyboard.press(`ArrowLeft`)
751
+ const presence = clients.maya.locator(
752
+ `collaborator-presence[data-collaborator="Theo Brooks"]`,
753
+ )
754
+ await expect(presence).toHaveCount(1)
755
+ await expect(presence).toHaveAttribute(
756
+ `data-selection-end`,
757
+ String(rollout),
758
+ )
759
+ const session = await presence.getAttribute(`data-session`)
760
+ expect(session).not.toBeNull()
761
+ if (session === null) throw new Error(`Theo's presence has no session.`)
762
+
763
+ const devtools = await clients.mayaContext.newCDPSession(clients.maya)
764
+ await devtools.send(`Network.enable`)
765
+ await devtools.send(`Network.emulateNetworkConditions`, {
766
+ downloadThroughput: 20_000_000,
767
+ latency: 55,
768
+ offline: false,
769
+ uploadThroughput: 20_000_000,
770
+ })
771
+ await devtools.send(`Emulation.setCPUThrottlingRate`, { rate: 3 })
772
+
773
+ await startCursorSampler(clients.maya, session, `rollout`)
774
+ let typed = ``
775
+ for (const character of `[ACTIVE-THEO]`) {
776
+ await clients.theo.keyboard.type(character)
777
+ typed += character
778
+ const expected = `${fixture.slice(0, rollout)}${typed}${fixture.slice(rollout)}`
779
+ expect(await sourceText(clients.theo)).toBe(expected)
780
+ await clients.theo.waitForTimeout(120)
781
+ expect(await sourceText(clients.theo)).toBe(expected)
782
+ }
783
+ await clients.theo.waitForTimeout(600)
784
+ const samples = await stopCursorSampler(clients.maya)
785
+ expect(samples.length).toBeGreaterThan(`[ACTIVE-THEO]`.length)
786
+ const terminal = samples.filter(
787
+ (sample) =>
788
+ sample.end !== null &&
789
+ sample.kind >= 0 &&
790
+ sample.end === sample.length,
791
+ )
792
+ expect(terminal, JSON.stringify(terminal, null, 2)).toEqual([])
793
+ const expected = `${fixture.slice(0, rollout)}[ACTIVE-THEO]${fixture.slice(rollout)}`
794
+ await Promise.all([
795
+ waitForText(clients.maya, expected),
796
+ waitForText(clients.theo, expected),
797
+ ])
798
+ await expect(presence).toHaveAttribute(
799
+ `data-selection-end`,
800
+ String(rollout + `[ACTIVE-THEO]`.length),
801
+ )
802
+ } finally {
803
+ await closeClients(clients)
804
+ }
805
+ })
806
+ })