@atom.io/template-react-realtime-text-editor 0.0.1

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,251 @@
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"
7
+
8
+ import { markdownAtomRegistration } from "../node/mosaic-atom.ts"
9
+ import {
10
+ Markdown,
11
+ markdownAtom,
12
+ markdownWordCountSelector,
13
+ type MarkdownPresence,
14
+ } from "../src/collaboration/mosaic.ts"
15
+ import type { Identity } from "../src/identities.ts"
16
+ import { INITIAL_MARKDOWN } from "../src/initial-markdown.ts"
17
+
18
+ const JANE = { id: `jane`, name: `Jane`, color: `#7057ff` } satisfies Identity
19
+ const DAVE = { id: `dave`, name: `Dave`, color: `#df527c` } satisfies Identity
20
+
21
+ function identityFor(userKey: UserKey): Identity {
22
+ return userKey.includes(`jane`) ? JANE : DAVE
23
+ }
24
+
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
+ }
84
+
85
+ function scenario() {
86
+ const collaboration = createMosaicServer({
87
+ registrations: [markdownAtomRegistration],
88
+ })
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
+ })
97
+ return () => {
98
+ void dispose()
99
+ }
100
+ },
101
+ clients: {
102
+ jane: testClient(JANE, `test:jane`),
103
+ dave: testClient(DAVE, `test:dave`),
104
+ },
105
+ })
106
+ return {
107
+ ...room,
108
+ teardown: async (): Promise<void> => {
109
+ await room.teardown()
110
+ await collaboration.dispose()
111
+ },
112
+ }
113
+ }
114
+
115
+ const initJane = (room: ReturnType<typeof scenario>) =>
116
+ room.clients.jane.init({
117
+ sessionId: `test:jane`,
118
+ userKey: `user::jane`,
119
+ })
120
+
121
+ const initDave = (room: ReturnType<typeof scenario>) =>
122
+ room.clients.dave.init({
123
+ sessionId: `test:dave`,
124
+ userKey: `user::dave`,
125
+ })
126
+
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)
133
+ })
134
+ }
135
+
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)
142
+ })
143
+ }
144
+
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
+ })
163
+
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
+ })
180
+
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`)
187
+
188
+ act(() => {
189
+ jane.renderResult.getByTestId(`append`).click()
190
+ })
191
+ await waitFor(() => {
192
+ expect(dave.renderResult.getByTestId(`text`).textContent).toContain(
193
+ `[Jane]`,
194
+ )
195
+ })
196
+ act(() => {
197
+ dave.renderResult.getByTestId(`append`).click()
198
+ })
199
+ await waitFor(() => {
200
+ expect(jane.renderResult.getByTestId(`text`).textContent).toContain(
201
+ `[Dave]`,
202
+ )
203
+ })
204
+
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
+ })
214
+
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()
225
+ })
226
+
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()
236
+ })
237
+ await waitFor(() => {
238
+ expect(jane.renderResult.getByTestId(`presence`).textContent).toBe(
239
+ `dave,jane`,
240
+ )
241
+ })
242
+
243
+ act(() => {
244
+ dave.socket.disconnect()
245
+ })
246
+ await waitFor(() => {
247
+ expect(jane.renderResult.getByTestId(`presence`).textContent).toBe(`jane`)
248
+ })
249
+ await room.teardown()
250
+ })
251
+ })
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4
+ "target": "ES2024",
5
+ "useDefineForClassFields": true,
6
+ "lib": ["ES2024", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "types": ["vite/client", "lasertag/css-modules", "lasertag/react-jsx"],
9
+ "skipLibCheck": true,
10
+ "moduleResolution": "bundler",
11
+ "allowImportingTsExtensions": true,
12
+ "verbatimModuleSyntax": true,
13
+ "moduleDetection": "force",
14
+ "noEmit": true,
15
+ "jsx": "react-jsx",
16
+ "strict": true,
17
+ "noUnusedLocals": true,
18
+ "noUnusedParameters": true,
19
+ "erasableSyntaxOnly": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noUncheckedSideEffectImports": true
22
+ },
23
+ "include": ["src"]
24
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" },
6
+ { "path": "./tsconfig.test.json" }
7
+ ]
8
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
+ "target": "ES2024",
5
+ "lib": ["ES2024"],
6
+ "module": "ESNext",
7
+ "types": ["node"],
8
+ "skipLibCheck": true,
9
+ "moduleResolution": "bundler",
10
+ "allowImportingTsExtensions": true,
11
+ "verbatimModuleSyntax": true,
12
+ "moduleDetection": "force",
13
+ "noEmit": true,
14
+ "strict": true,
15
+ "noUnusedLocals": true,
16
+ "noUnusedParameters": true,
17
+ "erasableSyntaxOnly": true,
18
+ "noFallthroughCasesInSwitch": true,
19
+ "noUncheckedSideEffectImports": true
20
+ },
21
+ "include": ["node", "vite.config.ts", "vitest.config.ts", "eslint.config.ts"]
22
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "./tsconfig.app.json",
3
+ "compilerOptions": {
4
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.test.tsbuildinfo",
5
+ "types": [
6
+ "node",
7
+ "vitest/globals",
8
+ "lasertag/css-modules",
9
+ "lasertag/react-jsx"
10
+ ]
11
+ },
12
+ "include": ["node/mosaic-atom.ts", "src", "tests"]
13
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,22 @@
1
+ import react from "@vitejs/plugin-react"
2
+ import { defineConfig } from "vite"
3
+
4
+ const BACKEND_URL = `http://localhost:3000`
5
+
6
+ const proxyBackend = () => ({
7
+ [`/health`]: {
8
+ changeOrigin: true,
9
+ target: BACKEND_URL,
10
+ },
11
+ [`/socket.io`]: {
12
+ changeOrigin: true,
13
+ target: BACKEND_URL,
14
+ ws: true,
15
+ },
16
+ })
17
+
18
+ export default defineConfig({
19
+ plugins: [react()],
20
+ preview: { proxy: proxyBackend() },
21
+ server: { proxy: proxyBackend() },
22
+ })
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from "vitest/config"
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: `happy-dom`,
6
+ globals: true,
7
+ include: [`tests/**/*.test.tsx`],
8
+ testTimeout: 10_000,
9
+ },
10
+ })