@atom.io/template-react-realtime-text-editor 0.0.11 → 0.0.12
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.
- package/CHANGELOG.md +16 -0
- package/INNOVATIONS.md +242 -113
- package/README.md +75 -39
- package/eslint.config.ts +1 -1
- package/index.html +1 -0
- package/node/http-server.ts +81 -0
- package/node/server.ts +12 -60
- package/node/service.ts +310 -0
- package/package.json +30 -21
- package/playwright.config.ts +34 -0
- package/src/LexicalMarkdownEditor.css +88 -0
- package/src/LexicalMarkdownEditor.module.css +8 -0
- package/src/LexicalMarkdownEditor.tsx +43 -0
- package/src/MarkdownWorkspace.module.css +154 -98
- package/src/MarkdownWorkspace.tsx +163 -296
- package/src/collaboration-client.ts +238 -0
- package/src/document-domain.ts +169 -0
- package/src/globals.css +9 -2
- package/src/incremental-markdown.ts +279 -0
- package/src/main.tsx +30 -9
- package/src/protocol.ts +35 -0
- package/src/virtualization.ts +53 -0
- package/src/workspace-state.ts +275 -0
- package/tests/browser/editor-conformance.spec.ts +806 -0
- package/tests/collaboration.test.tsx +465 -213
- package/tests/incremental-markdown.test.tsx +101 -0
- package/tsconfig.app.json +1 -1
- package/tsconfig.node.json +8 -2
- package/tsconfig.test.json +1 -1
- package/vite.config.ts +13 -9
- package/node/mosaic-atom.ts +0 -88
- package/src/collaboration/mosaic.ts +0 -46
|
@@ -1,251 +1,503 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
19
|
-
const
|
|
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
|
-
|
|
22
|
-
return userKey.includes(`jane`) ? JANE : DAVE
|
|
23
|
-
}
|
|
21
|
+
type RuntimeRegistry = Map<string, MarkdownCollaborationClient>
|
|
24
22
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
66
|
+
service[Symbol.dispose]()
|
|
111
67
|
},
|
|
112
68
|
}
|
|
113
69
|
}
|
|
114
70
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
)
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
expect(
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
})
|