@mdxui/terminal 2.0.0

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.
Files changed (191) hide show
  1. package/README.md +571 -0
  2. package/dist/ansi-css-Sk5mWtdK.d.ts +119 -0
  3. package/dist/ansi-css-V6JIHGsM.d.ts +119 -0
  4. package/dist/ansi-css-_3eSEU9d.d.ts +119 -0
  5. package/dist/chunk-3EFDH7PK.js +5235 -0
  6. package/dist/chunk-3RG5ZIWI.js +10 -0
  7. package/dist/chunk-3X5IR6WE.js +884 -0
  8. package/dist/chunk-4FV5ZDCE.js +5236 -0
  9. package/dist/chunk-4OVMSF2J.js +243 -0
  10. package/dist/chunk-63FEETIS.js +4048 -0
  11. package/dist/chunk-B43KP7XJ.js +884 -0
  12. package/dist/chunk-BMTJXWUV.js +655 -0
  13. package/dist/chunk-C3SVH4N7.js +882 -0
  14. package/dist/chunk-EVWR7Y47.js +874 -0
  15. package/dist/chunk-F6A5VWUC.js +1285 -0
  16. package/dist/chunk-FD7KW7GE.js +882 -0
  17. package/dist/chunk-GBQ6UD6I.js +655 -0
  18. package/dist/chunk-GMDD3M6U.js +5227 -0
  19. package/dist/chunk-JBHRXOXM.js +1058 -0
  20. package/dist/chunk-JFOO3EYO.js +1182 -0
  21. package/dist/chunk-JQ5H3WXL.js +1291 -0
  22. package/dist/chunk-JQD5NASE.js +234 -0
  23. package/dist/chunk-KRHJP5R7.js +592 -0
  24. package/dist/chunk-KWF6WVJE.js +962 -0
  25. package/dist/chunk-LHYQVN3H.js +1038 -0
  26. package/dist/chunk-M3TLQLGC.js +1032 -0
  27. package/dist/chunk-MVW4Q5OP.js +240 -0
  28. package/dist/chunk-NXCZSWLU.js +1294 -0
  29. package/dist/chunk-O25TNRO6.js +607 -0
  30. package/dist/chunk-PNECDA2I.js +884 -0
  31. package/dist/chunk-QIHWRLJR.js +962 -0
  32. package/dist/chunk-QW5YMQ7K.js +882 -0
  33. package/dist/chunk-R5U7XKVJ.js +16 -0
  34. package/dist/chunk-RP2MVQLR.js +962 -0
  35. package/dist/chunk-TP6RXGXA.js +1087 -0
  36. package/dist/chunk-TQQSTITZ.js +655 -0
  37. package/dist/chunk-X24GWXQV.js +1281 -0
  38. package/dist/components/index.d.ts +802 -0
  39. package/dist/components/index.js +149 -0
  40. package/dist/data/index.d.ts +2554 -0
  41. package/dist/data/index.js +51 -0
  42. package/dist/forms/index.d.ts +1596 -0
  43. package/dist/forms/index.js +464 -0
  44. package/dist/index-CQRFZntR.d.ts +867 -0
  45. package/dist/index.d.ts +579 -0
  46. package/dist/index.js +786 -0
  47. package/dist/interactive-D0JkWosD.d.ts +217 -0
  48. package/dist/keyboard/index.d.ts +2 -0
  49. package/dist/keyboard/index.js +43 -0
  50. package/dist/renderers/index.d.ts +546 -0
  51. package/dist/renderers/index.js +2157 -0
  52. package/dist/storybook/index.d.ts +396 -0
  53. package/dist/storybook/index.js +641 -0
  54. package/dist/theme/index.d.ts +1339 -0
  55. package/dist/theme/index.js +123 -0
  56. package/dist/types-Bxu5PAgA.d.ts +710 -0
  57. package/dist/types-CIlop5Ji.d.ts +701 -0
  58. package/dist/types-Ca8p_p5X.d.ts +710 -0
  59. package/package.json +90 -0
  60. package/src/__tests__/components/data/card.test.ts +458 -0
  61. package/src/__tests__/components/data/list.test.ts +473 -0
  62. package/src/__tests__/components/data/metrics.test.ts +541 -0
  63. package/src/__tests__/components/data/table.test.ts +448 -0
  64. package/src/__tests__/components/input/field.test.ts +555 -0
  65. package/src/__tests__/components/input/form.test.ts +870 -0
  66. package/src/__tests__/components/input/search.test.ts +1238 -0
  67. package/src/__tests__/components/input/select.test.ts +658 -0
  68. package/src/__tests__/components/navigation/breadcrumb.test.ts +923 -0
  69. package/src/__tests__/components/navigation/command-palette.test.ts +1095 -0
  70. package/src/__tests__/components/navigation/sidebar.test.ts +1018 -0
  71. package/src/__tests__/components/navigation/tabs.test.ts +995 -0
  72. package/src/__tests__/components.test.tsx +1197 -0
  73. package/src/__tests__/core/compiler.test.ts +986 -0
  74. package/src/__tests__/core/parser.test.ts +785 -0
  75. package/src/__tests__/core/tier-switcher.test.ts +1103 -0
  76. package/src/__tests__/core/types.test.ts +1398 -0
  77. package/src/__tests__/data/collections.test.ts +1337 -0
  78. package/src/__tests__/data/db.test.ts +1265 -0
  79. package/src/__tests__/data/reactive.test.ts +1010 -0
  80. package/src/__tests__/data/sync.test.ts +1614 -0
  81. package/src/__tests__/errors.test.ts +660 -0
  82. package/src/__tests__/forms/integration.test.ts +444 -0
  83. package/src/__tests__/integration.test.ts +905 -0
  84. package/src/__tests__/keyboard.test.ts +1791 -0
  85. package/src/__tests__/renderer.test.ts +489 -0
  86. package/src/__tests__/renderers/ansi-css.test.ts +948 -0
  87. package/src/__tests__/renderers/ansi.test.ts +1366 -0
  88. package/src/__tests__/renderers/ascii.test.ts +1360 -0
  89. package/src/__tests__/renderers/interactive.test.ts +2353 -0
  90. package/src/__tests__/renderers/markdown.test.ts +1483 -0
  91. package/src/__tests__/renderers/text.test.ts +1369 -0
  92. package/src/__tests__/renderers/unicode.test.ts +1307 -0
  93. package/src/__tests__/theme.test.ts +639 -0
  94. package/src/__tests__/utils/assertions.ts +685 -0
  95. package/src/__tests__/utils/index.ts +115 -0
  96. package/src/__tests__/utils/test-renderer.ts +381 -0
  97. package/src/__tests__/utils/utils.test.ts +560 -0
  98. package/src/components/containers/card.ts +56 -0
  99. package/src/components/containers/dialog.ts +53 -0
  100. package/src/components/containers/index.ts +9 -0
  101. package/src/components/containers/panel.ts +59 -0
  102. package/src/components/feedback/badge.ts +40 -0
  103. package/src/components/feedback/index.ts +8 -0
  104. package/src/components/feedback/spinner.ts +23 -0
  105. package/src/components/helpers.ts +81 -0
  106. package/src/components/index.ts +153 -0
  107. package/src/components/layout/breadcrumb.ts +31 -0
  108. package/src/components/layout/index.ts +10 -0
  109. package/src/components/layout/list.ts +29 -0
  110. package/src/components/layout/sidebar.ts +79 -0
  111. package/src/components/layout/table.ts +62 -0
  112. package/src/components/primitives/box.ts +95 -0
  113. package/src/components/primitives/button.ts +54 -0
  114. package/src/components/primitives/index.ts +11 -0
  115. package/src/components/primitives/input.ts +88 -0
  116. package/src/components/primitives/select.ts +97 -0
  117. package/src/components/primitives/text.ts +60 -0
  118. package/src/components/render.ts +155 -0
  119. package/src/components/templates/app.ts +43 -0
  120. package/src/components/templates/index.ts +8 -0
  121. package/src/components/templates/site.ts +54 -0
  122. package/src/components/types.ts +777 -0
  123. package/src/core/compiler.ts +718 -0
  124. package/src/core/parser.ts +127 -0
  125. package/src/core/tier-switcher.ts +607 -0
  126. package/src/core/types.ts +672 -0
  127. package/src/data/collection.ts +316 -0
  128. package/src/data/collections.ts +50 -0
  129. package/src/data/context.tsx +174 -0
  130. package/src/data/db.ts +127 -0
  131. package/src/data/hooks.ts +532 -0
  132. package/src/data/index.ts +138 -0
  133. package/src/data/reactive.ts +1225 -0
  134. package/src/data/saas-collections.ts +375 -0
  135. package/src/data/sync.ts +1213 -0
  136. package/src/data/types.ts +660 -0
  137. package/src/forms/converters.ts +512 -0
  138. package/src/forms/index.ts +133 -0
  139. package/src/forms/schemas.ts +403 -0
  140. package/src/forms/types.ts +476 -0
  141. package/src/index.ts +542 -0
  142. package/src/keyboard/focus.ts +748 -0
  143. package/src/keyboard/index.ts +96 -0
  144. package/src/keyboard/integration.ts +371 -0
  145. package/src/keyboard/manager.ts +377 -0
  146. package/src/keyboard/presets.ts +90 -0
  147. package/src/renderers/ansi-css.ts +576 -0
  148. package/src/renderers/ansi.ts +802 -0
  149. package/src/renderers/ascii.ts +680 -0
  150. package/src/renderers/breadcrumb.ts +480 -0
  151. package/src/renderers/command-palette.ts +802 -0
  152. package/src/renderers/components/field.ts +210 -0
  153. package/src/renderers/components/form.ts +327 -0
  154. package/src/renderers/components/index.ts +21 -0
  155. package/src/renderers/components/search.ts +449 -0
  156. package/src/renderers/components/select.ts +222 -0
  157. package/src/renderers/index.ts +101 -0
  158. package/src/renderers/interactive/component-handlers.ts +622 -0
  159. package/src/renderers/interactive/cursor-manager.ts +147 -0
  160. package/src/renderers/interactive/focus-manager.ts +279 -0
  161. package/src/renderers/interactive/index.ts +661 -0
  162. package/src/renderers/interactive/input-handler.ts +164 -0
  163. package/src/renderers/interactive/keyboard-handler.ts +212 -0
  164. package/src/renderers/interactive/mouse-handler.ts +167 -0
  165. package/src/renderers/interactive/state-manager.ts +109 -0
  166. package/src/renderers/interactive/types.ts +338 -0
  167. package/src/renderers/interactive-string.ts +299 -0
  168. package/src/renderers/interactive.ts +59 -0
  169. package/src/renderers/markdown.ts +950 -0
  170. package/src/renderers/sidebar.ts +549 -0
  171. package/src/renderers/tabs.ts +682 -0
  172. package/src/renderers/text.ts +791 -0
  173. package/src/renderers/unicode.ts +917 -0
  174. package/src/renderers/utils.ts +942 -0
  175. package/src/router/adapters.ts +383 -0
  176. package/src/router/types.ts +140 -0
  177. package/src/router/utils.ts +452 -0
  178. package/src/schemas.ts +205 -0
  179. package/src/storybook/index.ts +91 -0
  180. package/src/storybook/interactive-decorator.tsx +659 -0
  181. package/src/storybook/keyboard-simulator.ts +501 -0
  182. package/src/theme/ansi-codes.ts +80 -0
  183. package/src/theme/box-drawing.ts +132 -0
  184. package/src/theme/color-convert.ts +254 -0
  185. package/src/theme/color-support.ts +321 -0
  186. package/src/theme/index.ts +134 -0
  187. package/src/theme/strip-ansi.ts +50 -0
  188. package/src/theme/tailwind-map.ts +469 -0
  189. package/src/theme/text-styles.ts +206 -0
  190. package/src/theme/theme-system.ts +568 -0
  191. package/src/types.ts +103 -0
@@ -0,0 +1,1614 @@
1
+ /**
2
+ * @mdxui/terminal DO Sync Adapter Tests
3
+ *
4
+ * TDD RED Phase: These tests define the contract for the DO Sync Adapter.
5
+ * All tests should FAIL initially because the implementation doesn't exist yet.
6
+ *
7
+ * The DO Sync Adapter provides:
8
+ * - createDOSync() - Creates a sync adapter for @dotdo/react
9
+ * - WebSocket connection lifecycle management
10
+ * - Bidirectional data synchronization
11
+ * - Auth header injection for authenticated requests
12
+ * - Optimistic updates with server confirmation
13
+ * - Automatic reconnection with exponential backoff
14
+ */
15
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
16
+ import type { SyncAdapter } from '../../data/types'
17
+
18
+ // ============================================================================
19
+ // Mock WebSocket Setup
20
+ // ============================================================================
21
+
22
+ class MockWebSocket {
23
+ url: string
24
+ readyState: number = 0 // CONNECTING
25
+ onopen: ((event: Event) => void) | null = null
26
+ onclose: ((event: CloseEvent) => void) | null = null
27
+ onerror: ((event: Event) => void) | null = null
28
+ onmessage: ((event: MessageEvent) => void) | null = null
29
+
30
+ static instances: MockWebSocket[] = []
31
+ static lastInstance: MockWebSocket | null = null
32
+
33
+ constructor(url: string) {
34
+ this.url = url
35
+ MockWebSocket.instances.push(this)
36
+ MockWebSocket.lastInstance = this
37
+ }
38
+
39
+ send(data: string): void {
40
+ // No-op for mock
41
+ }
42
+
43
+ close(): void {
44
+ this.readyState = 3 // CLOSED
45
+ if (this.onclose) {
46
+ this.onclose(new CloseEvent('close'))
47
+ }
48
+ }
49
+
50
+ // Simulate connection opened
51
+ simulateOpen(): void {
52
+ this.readyState = 1 // OPEN
53
+ if (this.onopen) {
54
+ this.onopen(new Event('open'))
55
+ }
56
+ }
57
+
58
+ // Simulate connection error
59
+ simulateError(message?: string): void {
60
+ if (this.onerror) {
61
+ this.onerror(new Event('error', { detail: message }))
62
+ }
63
+ }
64
+
65
+ // Simulate receiving message from server
66
+ simulateMessage(data: any): void {
67
+ if (this.onmessage) {
68
+ this.onmessage(new MessageEvent('message', { data: JSON.stringify(data) }))
69
+ }
70
+ }
71
+
72
+ // Static methods to reset/access instances
73
+ static reset(): void {
74
+ MockWebSocket.instances = []
75
+ MockWebSocket.lastInstance = null
76
+ }
77
+
78
+ static getInstance(index: number = 0): MockWebSocket | undefined {
79
+ return MockWebSocket.instances[index]
80
+ }
81
+
82
+ static getLastInstance(): MockWebSocket | null {
83
+ return MockWebSocket.lastInstance
84
+ }
85
+ }
86
+
87
+ // Override global WebSocket before importing the module
88
+ const originalWebSocket = globalThis.WebSocket as any
89
+ Object.defineProperty(globalThis, 'WebSocket', {
90
+ value: MockWebSocket,
91
+ writable: true,
92
+ configurable: true,
93
+ })
94
+
95
+ // ============================================================================
96
+ // createDOSync() Configuration Tests
97
+ // ============================================================================
98
+
99
+ describe('@mdxui/terminal DO Sync Adapter - createDOSync configuration', () => {
100
+ beforeEach(() => {
101
+ MockWebSocket.reset()
102
+ })
103
+
104
+ afterEach(() => {
105
+ MockWebSocket.reset()
106
+ })
107
+
108
+ it('creates a sync adapter with namespace URL', async () => {
109
+ const { createDOSync } = await import('../../data/sync')
110
+
111
+ const adapter = createDOSync({
112
+ namespaceUrl: 'https://api.example.com/namespace',
113
+ })
114
+
115
+ expect(adapter).toBeDefined()
116
+ expect(typeof adapter.push).toBe('function')
117
+ expect(typeof adapter.pull).toBe('function')
118
+ expect(typeof adapter.subscribe).toBe('function')
119
+ })
120
+
121
+ it('accepts optional auth token in configuration', async () => {
122
+ const { createDOSync } = await import('../../data/sync')
123
+
124
+ const adapter = createDOSync({
125
+ namespaceUrl: 'https://api.example.com/namespace',
126
+ authToken: 'test-token-12345',
127
+ })
128
+
129
+ expect(adapter).toBeDefined()
130
+ })
131
+
132
+ it('accepts optional reconnection options', async () => {
133
+ const { createDOSync } = await import('../../data/sync')
134
+
135
+ const adapter = createDOSync({
136
+ namespaceUrl: 'https://api.example.com/namespace',
137
+ reconnect: {
138
+ enabled: true,
139
+ maxAttempts: 5,
140
+ initialDelay: 1000,
141
+ maxDelay: 30000,
142
+ },
143
+ })
144
+
145
+ expect(adapter).toBeDefined()
146
+ })
147
+
148
+ it('accepts optional conflict resolution strategy', async () => {
149
+ const { createDOSync } = await import('../../data/sync')
150
+
151
+ const adapter = createDOSync({
152
+ namespaceUrl: 'https://api.example.com/namespace',
153
+ conflictResolution: 'server-wins',
154
+ })
155
+
156
+ expect(adapter).toBeDefined()
157
+ })
158
+
159
+ it('throws error if namespace URL is missing', async () => {
160
+ const { createDOSync } = await import('../../data/sync')
161
+
162
+ expect(() => {
163
+ createDOSync({
164
+ namespaceUrl: '',
165
+ })
166
+ }).toThrow()
167
+ })
168
+
169
+ it('validates namespace URL format', async () => {
170
+ const { createDOSync } = await import('../../data/sync')
171
+
172
+ expect(() => {
173
+ createDOSync({
174
+ namespaceUrl: 'not-a-valid-url',
175
+ })
176
+ }).toThrow()
177
+ })
178
+ })
179
+
180
+ // ============================================================================
181
+ // WebSocket Connection Lifecycle Tests
182
+ // ============================================================================
183
+
184
+ describe('@mdxui/terminal DO Sync Adapter - WebSocket lifecycle', () => {
185
+ beforeEach(() => {
186
+ MockWebSocket.reset()
187
+ })
188
+
189
+ afterEach(() => {
190
+ MockWebSocket.reset()
191
+ })
192
+
193
+ it('establishes WebSocket connection on first sync operation', async () => {
194
+ const { createDOSync } = await import('../../data/sync')
195
+
196
+ const adapter = createDOSync({
197
+ namespaceUrl: 'https://api.example.com/namespace',
198
+ })
199
+
200
+ // Trigger a sync operation
201
+ const promise = adapter.push([])
202
+
203
+ // WebSocket should be created
204
+ const ws = MockWebSocket.getLastInstance()
205
+ expect(ws).toBeDefined()
206
+ expect(ws?.url).toContain('api.example.com')
207
+ })
208
+
209
+ it('reuses existing WebSocket connection for subsequent operations', async () => {
210
+ const { createDOSync } = await import('../../data/sync')
211
+
212
+ const adapter = createDOSync({
213
+ namespaceUrl: 'https://api.example.com/namespace',
214
+ })
215
+
216
+ // First operation
217
+ const promise1 = adapter.push([])
218
+ const firstWs = MockWebSocket.getLastInstance()
219
+ const instanceCountAfterFirst = MockWebSocket.instances.length
220
+
221
+ // Simulate connection opening
222
+ firstWs?.simulateOpen()
223
+
224
+ // Wait for microtasks to flush
225
+ await Promise.resolve()
226
+
227
+ // Second operation
228
+ const promise2 = adapter.push([])
229
+ const instanceCountAfterSecond = MockWebSocket.instances.length
230
+
231
+ // Should NOT create a new WebSocket instance
232
+ expect(instanceCountAfterSecond).toBe(instanceCountAfterFirst)
233
+ })
234
+
235
+ it('handles connection established event', async () => {
236
+ const { createDOSync } = await import('../../data/sync')
237
+
238
+ const adapter = createDOSync({
239
+ namespaceUrl: 'https://api.example.com/namespace',
240
+ })
241
+
242
+ const pushPromise = adapter.push([])
243
+ const ws = MockWebSocket.getLastInstance()
244
+
245
+ expect(ws).toBeDefined()
246
+ // Should have onopen handler set
247
+ expect(ws?.onopen).toBeDefined()
248
+
249
+ // Simulate successful connection
250
+ ws?.simulateOpen()
251
+
252
+ // Wait for microtasks to flush (ensureConnected's .then() needs to run)
253
+ await Promise.resolve()
254
+
255
+ // Promise should eventually resolve (empty push resolves immediately after connection)
256
+ await expect(pushPromise).resolves.not.toThrow()
257
+ })
258
+
259
+ it('handles connection closed event', async () => {
260
+ const { createDOSync } = await import('../../data/sync')
261
+
262
+ const adapter = createDOSync({
263
+ namespaceUrl: 'https://api.example.com/namespace',
264
+ })
265
+
266
+ const pushPromise = adapter.push([])
267
+ const ws = MockWebSocket.getLastInstance()
268
+
269
+ ws?.simulateOpen()
270
+ ws?.close()
271
+
272
+ // After close, new connection should be established for next operation
273
+ const promise2 = adapter.push([])
274
+ const newWs = MockWebSocket.getInstance(1) // Second WebSocket instance
275
+
276
+ expect(newWs).toBeDefined()
277
+ expect(newWs).not.toBe(ws)
278
+ })
279
+
280
+ it('cleans up resources on disconnect', async () => {
281
+ const { createDOSync } = await import('../../data/sync')
282
+
283
+ const adapter = createDOSync({
284
+ namespaceUrl: 'https://api.example.com/namespace',
285
+ })
286
+
287
+ const pushPromise = adapter.push([])
288
+ const ws = MockWebSocket.getLastInstance()
289
+
290
+ ws?.simulateOpen()
291
+
292
+ // Call close if available
293
+ if ('close' in adapter) {
294
+ (adapter as any).close?.()
295
+ }
296
+
297
+ // WebSocket should be closed
298
+ expect(ws?.readyState).toBe(3) // CLOSED
299
+ })
300
+ })
301
+
302
+ // ============================================================================
303
+ // Data Synchronization Flow Tests
304
+ // ============================================================================
305
+
306
+ describe('@mdxui/terminal DO Sync Adapter - data synchronization', () => {
307
+ beforeEach(() => {
308
+ MockWebSocket.reset()
309
+ })
310
+
311
+ afterEach(() => {
312
+ MockWebSocket.reset()
313
+ })
314
+
315
+ it('pushes local changes to remote server', async () => {
316
+ const { createDOSync } = await import('../../data/sync')
317
+
318
+ const adapter = createDOSync({
319
+ namespaceUrl: 'https://api.example.com/namespace',
320
+ })
321
+
322
+ const changes = [
323
+ {
324
+ collection: 'users',
325
+ operation: 'insert',
326
+ data: { id: '1', name: 'Alice' },
327
+ },
328
+ ]
329
+
330
+ const pushPromise = adapter.push(changes)
331
+ const ws = MockWebSocket.getLastInstance()
332
+
333
+ ws?.simulateOpen()
334
+ // Wait for microtasks to flush before sending server response
335
+ await Promise.resolve()
336
+ ws?.simulateMessage({ type: 'ack', id: 'push-1', status: 'success' })
337
+
338
+ await expect(pushPromise).resolves.not.toThrow()
339
+ })
340
+
341
+ it('sends correct message format on push', async () => {
342
+ const { createDOSync } = await import('../../data/sync')
343
+
344
+ const adapter = createDOSync({
345
+ namespaceUrl: 'https://api.example.com/namespace',
346
+ })
347
+
348
+ const changes = [
349
+ {
350
+ collection: 'users',
351
+ operation: 'insert',
352
+ data: { id: '1', name: 'Alice' },
353
+ },
354
+ ]
355
+
356
+ const sendSpy = vi.spyOn(MockWebSocket.prototype, 'send')
357
+
358
+ const pushPromise = adapter.push(changes)
359
+ const ws = MockWebSocket.getLastInstance()
360
+
361
+ ws?.simulateOpen()
362
+ await Promise.resolve()
363
+ ws?.simulateMessage({ type: 'ack', id: 'push-1', status: 'success' })
364
+
365
+ await pushPromise
366
+
367
+ expect(sendSpy).toHaveBeenCalled()
368
+ const sentData = JSON.parse(sendSpy.mock.calls[0][0])
369
+ expect(sentData.type).toBe('push')
370
+ expect(sentData.changes).toEqual(changes)
371
+
372
+ sendSpy.mockRestore()
373
+ })
374
+
375
+ it('pulls remote changes from server', async () => {
376
+ const { createDOSync } = await import('../../data/sync')
377
+
378
+ const adapter = createDOSync({
379
+ namespaceUrl: 'https://api.example.com/namespace',
380
+ })
381
+
382
+ const pullPromise = adapter.pull()
383
+ const ws = MockWebSocket.getLastInstance()
384
+
385
+ const remoteChanges = [
386
+ {
387
+ collection: 'users',
388
+ operation: 'insert',
389
+ data: { id: '2', name: 'Bob' },
390
+ },
391
+ ]
392
+
393
+ ws?.simulateOpen()
394
+ await Promise.resolve()
395
+ ws?.simulateMessage({ type: 'pull-response', changes: remoteChanges })
396
+
397
+ const result = await pullPromise
398
+
399
+ expect(result).toEqual(remoteChanges)
400
+ })
401
+
402
+ it('subscribes to remote changes via WebSocket', async () => {
403
+ const { createDOSync } = await import('../../data/sync')
404
+
405
+ const adapter = createDOSync({
406
+ namespaceUrl: 'https://api.example.com/namespace',
407
+ })
408
+
409
+ const callback = vi.fn()
410
+ const unsubscribe = adapter.subscribe(callback)
411
+
412
+ const ws = MockWebSocket.getLastInstance()
413
+ ws?.simulateOpen()
414
+
415
+ const remoteChanges = [
416
+ {
417
+ collection: 'todos',
418
+ operation: 'update',
419
+ data: { id: '1', completed: true },
420
+ },
421
+ ]
422
+
423
+ ws?.simulateMessage({ type: 'sync', changes: remoteChanges })
424
+
425
+ expect(callback).toHaveBeenCalledWith(remoteChanges)
426
+
427
+ // Unsubscribe should stop receiving messages
428
+ unsubscribe()
429
+ ws?.simulateMessage({ type: 'sync', changes: [] })
430
+
431
+ // Callback should not be called again
432
+ expect(callback).toHaveBeenCalledTimes(1)
433
+ })
434
+
435
+ it('handles batch changes in push operation', async () => {
436
+ const { createDOSync } = await import('../../data/sync')
437
+
438
+ const adapter = createDOSync({
439
+ namespaceUrl: 'https://api.example.com/namespace',
440
+ })
441
+
442
+ const changes = [
443
+ { collection: 'users', operation: 'insert', data: { id: '1', name: 'Alice' } },
444
+ { collection: 'users', operation: 'insert', data: { id: '2', name: 'Bob' } },
445
+ { collection: 'todos', operation: 'insert', data: { id: '1', title: 'Task 1' } },
446
+ ]
447
+
448
+ const pushPromise = adapter.push(changes)
449
+ const ws = MockWebSocket.getLastInstance()
450
+
451
+ ws?.simulateOpen()
452
+ await Promise.resolve()
453
+ ws?.simulateMessage({ type: 'ack', id: 'push-1', status: 'success' })
454
+
455
+ await expect(pushPromise).resolves.not.toThrow()
456
+ })
457
+
458
+ it('handles empty changes array', async () => {
459
+ const { createDOSync } = await import('../../data/sync')
460
+
461
+ const adapter = createDOSync({
462
+ namespaceUrl: 'https://api.example.com/namespace',
463
+ })
464
+
465
+ const pushPromise = adapter.push([])
466
+ const ws = MockWebSocket.getLastInstance()
467
+
468
+ ws?.simulateOpen()
469
+ await Promise.resolve()
470
+ // Empty push resolves immediately - no ACK needed
471
+
472
+ await expect(pushPromise).resolves.not.toThrow()
473
+ })
474
+ })
475
+
476
+ // ============================================================================
477
+ // Error Handling Tests
478
+ // ============================================================================
479
+
480
+ describe('@mdxui/terminal DO Sync Adapter - error handling', () => {
481
+ beforeEach(() => {
482
+ MockWebSocket.reset()
483
+ })
484
+
485
+ afterEach(() => {
486
+ MockWebSocket.reset()
487
+ })
488
+
489
+ it('queues push and resolves on WebSocket error (offline resilience)', async () => {
490
+ const { createDOSync } = await import('../../data/sync')
491
+
492
+ const adapter = createDOSync({
493
+ namespaceUrl: 'https://api.example.com/namespace',
494
+ })
495
+
496
+ const changes = [{ collection: 'users', operation: 'insert' }]
497
+ const pushPromise = adapter.push(changes)
498
+ const ws = MockWebSocket.getLastInstance()
499
+
500
+ ws?.simulateError('Connection refused')
501
+
502
+ // Should resolve after queueing (offline resilience)
503
+ await expect(pushPromise).resolves.not.toThrow()
504
+
505
+ // Mutation should be queued
506
+ const queue = adapter.getQueuedMutations()
507
+ expect(queue.length).toBe(1)
508
+ expect(queue[0].changes).toEqual(changes)
509
+ })
510
+
511
+ it('rejects push promise on server error response', async () => {
512
+ const { createDOSync } = await import('../../data/sync')
513
+
514
+ const adapter = createDOSync({
515
+ namespaceUrl: 'https://api.example.com/namespace',
516
+ })
517
+
518
+ const pushPromise = adapter.push([{ collection: 'users', operation: 'insert' }])
519
+ const ws = MockWebSocket.getLastInstance()
520
+
521
+ ws?.simulateOpen()
522
+ await Promise.resolve()
523
+ ws?.simulateMessage({
524
+ type: 'ack',
525
+ id: 'push-1',
526
+ status: 'error',
527
+ error: 'Validation failed',
528
+ })
529
+
530
+ await expect(pushPromise).rejects.toThrow('Validation failed')
531
+ })
532
+
533
+ it('rejects pull promise on connection failure', async () => {
534
+ const { createDOSync } = await import('../../data/sync')
535
+
536
+ const adapter = createDOSync({
537
+ namespaceUrl: 'https://api.example.com/namespace',
538
+ })
539
+
540
+ const pullPromise = adapter.pull()
541
+ const ws = MockWebSocket.getLastInstance()
542
+
543
+ ws?.simulateError('Network error')
544
+
545
+ await expect(pullPromise).rejects.toThrow()
546
+ })
547
+
548
+ it('handles timeout on push operation', async () => {
549
+ const { createDOSync } = await import('../../data/sync')
550
+
551
+ const adapter = createDOSync({
552
+ namespaceUrl: 'https://api.example.com/namespace',
553
+ requestTimeout: 100,
554
+ })
555
+
556
+ const pushPromise = adapter.push([{ collection: 'users', operation: 'insert' }])
557
+ const ws = MockWebSocket.getLastInstance()
558
+
559
+ ws?.simulateOpen()
560
+ // Don't send response - let it timeout
561
+
562
+ await expect(pushPromise).rejects.toThrow()
563
+ })
564
+
565
+ it('handles malformed JSON in message', async () => {
566
+ const { createDOSync } = await import('../../data/sync')
567
+
568
+ const adapter = createDOSync({
569
+ namespaceUrl: 'https://api.example.com/namespace',
570
+ })
571
+
572
+ const callback = vi.fn()
573
+ adapter.subscribe(callback)
574
+
575
+ const ws = MockWebSocket.getLastInstance()
576
+ ws?.simulateOpen()
577
+
578
+ // Manually set invalid onmessage to simulate malformed data
579
+ const originalOnMessage = ws?.onmessage
580
+ if (ws) {
581
+ ws.onmessage = (event: MessageEvent) => {
582
+ // Try to handle malformed JSON
583
+ try {
584
+ JSON.parse('{invalid json}')
585
+ } catch (e) {
586
+ // Should handle gracefully without crashing
587
+ }
588
+ }
589
+ }
590
+
591
+ // Should not throw
592
+ expect(() => {
593
+ ws?.onmessage?.(new MessageEvent('message', { data: '{invalid' }))
594
+ }).not.toThrow()
595
+
596
+ if (ws && originalOnMessage) {
597
+ ws.onmessage = originalOnMessage
598
+ }
599
+ })
600
+ })
601
+
602
+ // ============================================================================
603
+ // Automatic Reconnection Tests
604
+ // ============================================================================
605
+
606
+ describe('@mdxui/terminal DO Sync Adapter - automatic reconnection', () => {
607
+ beforeEach(() => {
608
+ MockWebSocket.reset()
609
+ vi.useFakeTimers()
610
+ })
611
+
612
+ afterEach(() => {
613
+ MockWebSocket.reset()
614
+ vi.useRealTimers()
615
+ })
616
+
617
+ it('automatically reconnects on connection loss', async () => {
618
+ const { createDOSync } = await import('../../data/sync')
619
+
620
+ const adapter = createDOSync({
621
+ namespaceUrl: 'https://api.example.com/namespace',
622
+ reconnect: { enabled: true, initialDelay: 100, maxDelay: 1000 },
623
+ })
624
+
625
+ const pushPromise = adapter.push([])
626
+ let ws = MockWebSocket.getLastInstance()
627
+
628
+ ws?.simulateOpen()
629
+ ws?.close() // Simulate connection loss
630
+
631
+ // Should attempt reconnection
632
+ vi.advanceTimersByTime(100)
633
+
634
+ ws = MockWebSocket.getInstance(1)
635
+ expect(ws).toBeDefined()
636
+ })
637
+
638
+ it('implements exponential backoff for reconnection attempts', async () => {
639
+ const { createDOSync } = await import('../../data/sync')
640
+
641
+ const adapter = createDOSync({
642
+ namespaceUrl: 'https://api.example.com/namespace',
643
+ reconnect: { enabled: true, initialDelay: 100, maxDelay: 5000 },
644
+ })
645
+
646
+ const pushPromise = adapter.push([])
647
+ let ws = MockWebSocket.getLastInstance()
648
+
649
+ ws?.simulateOpen()
650
+ ws?.close()
651
+
652
+ // First retry should be at initialDelay
653
+ vi.advanceTimersByTime(100)
654
+ let attemptCount = MockWebSocket.instances.length
655
+ expect(attemptCount).toBeGreaterThan(1)
656
+
657
+ // Close again to trigger second reconnection
658
+ ws = MockWebSocket.getLastInstance()
659
+ ws?.close()
660
+
661
+ // Second retry should have longer delay
662
+ vi.advanceTimersByTime(200)
663
+ let newAttemptCount = MockWebSocket.instances.length
664
+
665
+ // Should have more instances
666
+ expect(newAttemptCount).toBeGreaterThan(attemptCount)
667
+ })
668
+
669
+ it('respects max reconnection attempts', async () => {
670
+ const { createDOSync } = await import('../../data/sync')
671
+
672
+ const adapter = createDOSync({
673
+ namespaceUrl: 'https://api.example.com/namespace',
674
+ reconnect: { enabled: true, maxAttempts: 3, initialDelay: 50 },
675
+ })
676
+
677
+ // Use non-empty push so it can be properly tracked
678
+ const changes = [{ operation: 'test' }]
679
+ const pushPromise = adapter.push(changes)
680
+ let ws = MockWebSocket.getLastInstance()
681
+
682
+ // Simulate first connection failure - this queues the mutation
683
+ ws?.simulateError('Connection failed')
684
+
685
+ // Await the push (should resolve after queueing)
686
+ await pushPromise
687
+
688
+ // Now simulate reconnection failures
689
+ for (let i = 0; i < 4; i++) {
690
+ vi.advanceTimersByTime(50)
691
+ ws = MockWebSocket.getLastInstance()
692
+ if (ws && ws.readyState !== 3) {
693
+ ws.simulateError('Connection failed')
694
+ }
695
+ }
696
+
697
+ // After maxAttempts, should stop trying
698
+ const instanceCount = MockWebSocket.instances.length
699
+ vi.advanceTimersByTime(1000)
700
+
701
+ // No new instances should be created beyond maxAttempts
702
+ expect(MockWebSocket.instances.length).toBeLessThanOrEqual(instanceCount + 1)
703
+ })
704
+
705
+ it('stops reconnection attempts after successful connection', async () => {
706
+ const { createDOSync } = await import('../../data/sync')
707
+
708
+ const adapter = createDOSync({
709
+ namespaceUrl: 'https://api.example.com/namespace',
710
+ reconnect: { enabled: true, initialDelay: 100 },
711
+ })
712
+
713
+ const pushPromise = adapter.push([])
714
+ const ws = MockWebSocket.getLastInstance()
715
+
716
+ ws?.simulateOpen()
717
+ await Promise.resolve()
718
+ // Empty push - no ACK needed
719
+
720
+ await pushPromise
721
+
722
+ // Close connection
723
+ ws?.close()
724
+
725
+ // Wait for close handling
726
+ await Promise.resolve()
727
+
728
+ // Should reconnect on next operation
729
+ const push2 = adapter.push([])
730
+ const ws2 = MockWebSocket.getLastInstance()
731
+
732
+ expect(ws2).toBeDefined()
733
+ expect(ws2).not.toBe(ws)
734
+ })
735
+ })
736
+
737
+ // ============================================================================
738
+ // Auth Header Injection Tests
739
+ // ============================================================================
740
+
741
+ describe('@mdxui/terminal DO Sync Adapter - auth header injection', () => {
742
+ beforeEach(() => {
743
+ MockWebSocket.reset()
744
+ })
745
+
746
+ afterEach(() => {
747
+ MockWebSocket.reset()
748
+ })
749
+
750
+ it('injects auth token into WebSocket connection header', async () => {
751
+ const { createDOSync } = await import('../../data/sync')
752
+
753
+ const adapter = createDOSync({
754
+ namespaceUrl: 'https://api.example.com/namespace',
755
+ authToken: 'bearer-token-xyz',
756
+ })
757
+
758
+ const pushPromise = adapter.push([])
759
+ const ws = MockWebSocket.getLastInstance()
760
+
761
+ expect(ws?.url).toContain('token=bearer-token-xyz')
762
+ })
763
+
764
+ it('sends auth header in push message', async () => {
765
+ const { createDOSync } = await import('../../data/sync')
766
+
767
+ const adapter = createDOSync({
768
+ namespaceUrl: 'https://api.example.com/namespace',
769
+ authToken: 'secret-token-123',
770
+ })
771
+
772
+ const sendSpy = vi.spyOn(MockWebSocket.prototype, 'send')
773
+
774
+ const pushPromise = adapter.push([{ collection: 'users', operation: 'insert' }])
775
+ const ws = MockWebSocket.getLastInstance()
776
+
777
+ ws?.simulateOpen()
778
+ await Promise.resolve()
779
+ ws?.simulateMessage({ type: 'ack', id: 'push-1', status: 'success' })
780
+
781
+ await pushPromise
782
+
783
+ const sentData = JSON.parse(sendSpy.mock.calls[0][0])
784
+ expect(sentData.auth).toBeDefined()
785
+ expect(sentData.auth.token).toBe('secret-token-123')
786
+
787
+ sendSpy.mockRestore()
788
+ })
789
+
790
+ it('does not include auth token if not provided', async () => {
791
+ const { createDOSync } = await import('../../data/sync')
792
+
793
+ const adapter = createDOSync({
794
+ namespaceUrl: 'https://api.example.com/namespace',
795
+ })
796
+
797
+ const sendSpy = vi.spyOn(MockWebSocket.prototype, 'send')
798
+
799
+ const pushPromise = adapter.push([])
800
+ const ws = MockWebSocket.getLastInstance()
801
+
802
+ ws?.simulateOpen()
803
+ await Promise.resolve()
804
+ // Empty push - no ACK needed
805
+
806
+ await pushPromise
807
+
808
+ const sentData = JSON.parse(sendSpy.mock.calls[0][0])
809
+ expect(sentData.auth).toBeUndefined()
810
+
811
+ sendSpy.mockRestore()
812
+ })
813
+
814
+ it('updates auth token dynamically', async () => {
815
+ const { createDOSync } = await import('../../data/sync')
816
+
817
+ const adapter = createDOSync({
818
+ namespaceUrl: 'https://api.example.com/namespace',
819
+ authToken: 'old-token',
820
+ })
821
+
822
+ // Update token
823
+ if ('setAuthToken' in adapter) {
824
+ (adapter as any).setAuthToken('new-token')
825
+ }
826
+
827
+ const sendSpy = vi.spyOn(MockWebSocket.prototype, 'send')
828
+
829
+ const pushPromise = adapter.push([])
830
+ const ws = MockWebSocket.getLastInstance()
831
+
832
+ ws?.simulateOpen()
833
+ await Promise.resolve()
834
+ // Empty push - no ACK needed
835
+
836
+ await pushPromise
837
+
838
+ const sentData = JSON.parse(sendSpy.mock.calls[0][0])
839
+ expect(sentData.auth?.token).toBe('new-token')
840
+
841
+ sendSpy.mockRestore()
842
+ })
843
+ })
844
+
845
+ // ============================================================================
846
+ // Optimistic Updates with Server Confirmation Tests
847
+ // ============================================================================
848
+
849
+ describe('@mdxui/terminal DO Sync Adapter - optimistic updates', () => {
850
+ beforeEach(() => {
851
+ MockWebSocket.reset()
852
+ })
853
+
854
+ afterEach(() => {
855
+ MockWebSocket.reset()
856
+ })
857
+
858
+ it('confirms received changes with server acknowledgment', async () => {
859
+ const { createDOSync } = await import('../../data/sync')
860
+
861
+ const adapter = createDOSync({
862
+ namespaceUrl: 'https://api.example.com/namespace',
863
+ })
864
+
865
+ const changes = [
866
+ {
867
+ collection: 'users',
868
+ operation: 'insert',
869
+ id: 'change-1',
870
+ data: { id: '1', name: 'Alice' },
871
+ },
872
+ ]
873
+
874
+ const pushPromise = adapter.push(changes)
875
+ const ws = MockWebSocket.getLastInstance()
876
+
877
+ ws?.simulateOpen()
878
+ await Promise.resolve()
879
+ ws?.simulateMessage({
880
+ type: 'ack',
881
+ id: 'push-1',
882
+ status: 'success',
883
+ confirmedChanges: ['change-1'],
884
+ })
885
+
886
+ await expect(pushPromise).resolves.not.toThrow()
887
+ })
888
+
889
+ it('handles partial server confirmation', async () => {
890
+ const { createDOSync } = await import('../../data/sync')
891
+
892
+ const adapter = createDOSync({
893
+ namespaceUrl: 'https://api.example.com/namespace',
894
+ })
895
+
896
+ const changes = [
897
+ { collection: 'users', operation: 'insert', id: 'change-1', data: { id: '1' } },
898
+ { collection: 'users', operation: 'insert', id: 'change-2', data: { id: '2' } },
899
+ ]
900
+
901
+ const pushPromise = adapter.push(changes)
902
+ const ws = MockWebSocket.getLastInstance()
903
+
904
+ ws?.simulateOpen()
905
+ await Promise.resolve()
906
+ ws?.simulateMessage({
907
+ type: 'ack',
908
+ id: 'push-1',
909
+ status: 'partial',
910
+ confirmedChanges: ['change-1'],
911
+ failedChanges: [{ id: 'change-2', reason: 'Duplicate key' }],
912
+ })
913
+
914
+ // Should resolve but may include error info
915
+ await expect(pushPromise).resolves.not.toThrow()
916
+ })
917
+
918
+ it('detects server-side conflicts', async () => {
919
+ const { createDOSync } = await import('../../data/sync')
920
+
921
+ const adapter = createDOSync({
922
+ namespaceUrl: 'https://api.example.com/namespace',
923
+ conflictResolution: 'throw',
924
+ })
925
+
926
+ const changes = [
927
+ { collection: 'users', operation: 'update', id: 'change-1', data: { id: '1', name: 'Alice' } },
928
+ ]
929
+
930
+ const pushPromise = adapter.push(changes)
931
+ const ws = MockWebSocket.getLastInstance()
932
+
933
+ ws?.simulateOpen()
934
+ await Promise.resolve()
935
+ ws?.simulateMessage({
936
+ type: 'ack',
937
+ id: 'push-1',
938
+ status: 'conflict',
939
+ conflicts: [{ id: 'change-1', serverVersion: { name: 'Bob' } }],
940
+ })
941
+
942
+ // Should reject or handle based on conflict resolution strategy
943
+ await expect(pushPromise).rejects.toThrow()
944
+ })
945
+
946
+ it('implements server-wins conflict resolution', async () => {
947
+ const { createDOSync } = await import('../../data/sync')
948
+
949
+ const adapter = createDOSync({
950
+ namespaceUrl: 'https://api.example.com/namespace',
951
+ conflictResolution: 'server-wins',
952
+ })
953
+
954
+ const changes = [
955
+ { collection: 'users', operation: 'update', id: 'change-1', data: { id: '1', name: 'Alice' } },
956
+ ]
957
+
958
+ const pushPromise = adapter.push(changes)
959
+ const ws = MockWebSocket.getLastInstance()
960
+
961
+ ws?.simulateOpen()
962
+ await Promise.resolve()
963
+ ws?.simulateMessage({
964
+ type: 'ack',
965
+ id: 'push-1',
966
+ status: 'conflict',
967
+ conflicts: [{ id: 'change-1', serverVersion: { name: 'Bob' } }],
968
+ resolution: 'server-wins',
969
+ })
970
+
971
+ await expect(pushPromise).resolves.not.toThrow()
972
+ })
973
+
974
+ it('implements client-wins conflict resolution', async () => {
975
+ const { createDOSync } = await import('../../data/sync')
976
+
977
+ const adapter = createDOSync({
978
+ namespaceUrl: 'https://api.example.com/namespace',
979
+ conflictResolution: 'client-wins',
980
+ })
981
+
982
+ const changes = [
983
+ { collection: 'users', operation: 'update', id: 'change-1', data: { id: '1', name: 'Alice' } },
984
+ ]
985
+
986
+ const pushPromise = adapter.push(changes)
987
+ const ws = MockWebSocket.getLastInstance()
988
+
989
+ ws?.simulateOpen()
990
+ await Promise.resolve()
991
+ ws?.simulateMessage({
992
+ type: 'ack',
993
+ id: 'push-1',
994
+ status: 'conflict',
995
+ conflicts: [{ id: 'change-1', serverVersion: { name: 'Bob' } }],
996
+ resolution: 'client-wins',
997
+ })
998
+
999
+ await expect(pushPromise).resolves.not.toThrow()
1000
+ })
1001
+
1002
+ it('implements merge conflict resolution', async () => {
1003
+ const { createDOSync } = await import('../../data/sync')
1004
+
1005
+ const adapter = createDOSync({
1006
+ namespaceUrl: 'https://api.example.com/namespace',
1007
+ conflictResolution: 'merge',
1008
+ })
1009
+
1010
+ const changes = [
1011
+ {
1012
+ collection: 'users',
1013
+ operation: 'update',
1014
+ id: 'change-1',
1015
+ data: { id: '1', name: 'Alice', age: 30 },
1016
+ },
1017
+ ]
1018
+
1019
+ const pushPromise = adapter.push(changes)
1020
+ const ws = MockWebSocket.getLastInstance()
1021
+
1022
+ ws?.simulateOpen()
1023
+ await Promise.resolve()
1024
+ ws?.simulateMessage({
1025
+ type: 'ack',
1026
+ id: 'push-1',
1027
+ status: 'conflict',
1028
+ conflicts: [{ id: 'change-1', serverVersion: { name: 'Bob', email: 'bob@example.com' } }],
1029
+ resolution: 'merge',
1030
+ mergedVersion: { name: 'Alice', age: 30, email: 'bob@example.com' },
1031
+ })
1032
+
1033
+ await expect(pushPromise).resolves.not.toThrow()
1034
+ })
1035
+
1036
+ it('handles custom conflict resolution callback', async () => {
1037
+ const { createDOSync } = await import('../../data/sync')
1038
+
1039
+ const conflictResolver = vi.fn().mockResolvedValue({ resolved: true })
1040
+
1041
+ const adapter = createDOSync({
1042
+ namespaceUrl: 'https://api.example.com/namespace',
1043
+ conflictResolution: 'custom',
1044
+ onConflict: conflictResolver,
1045
+ })
1046
+
1047
+ const changes = [
1048
+ { collection: 'users', operation: 'update', id: 'change-1', data: { id: '1', name: 'Alice' } },
1049
+ ]
1050
+
1051
+ const pushPromise = adapter.push(changes)
1052
+ const ws = MockWebSocket.getLastInstance()
1053
+
1054
+ ws?.simulateOpen()
1055
+ await Promise.resolve()
1056
+ ws?.simulateMessage({
1057
+ type: 'ack',
1058
+ id: 'push-1',
1059
+ status: 'conflict',
1060
+ conflicts: [{ id: 'change-1', serverVersion: { name: 'Bob' } }],
1061
+ })
1062
+
1063
+ await pushPromise
1064
+
1065
+ // Custom resolver should be called
1066
+ expect(conflictResolver).toHaveBeenCalled()
1067
+ })
1068
+ })
1069
+
1070
+ // ============================================================================
1071
+ // Integration Tests
1072
+ // ============================================================================
1073
+
1074
+ describe('@mdxui/terminal DO Sync Adapter - integration', () => {
1075
+ beforeEach(() => {
1076
+ MockWebSocket.reset()
1077
+ })
1078
+
1079
+ afterEach(() => {
1080
+ MockWebSocket.reset()
1081
+ })
1082
+
1083
+ it('implements full SyncAdapter interface', async () => {
1084
+ const { createDOSync } = await import('../../data/sync')
1085
+
1086
+ const adapter = createDOSync({
1087
+ namespaceUrl: 'https://api.example.com/namespace',
1088
+ })
1089
+
1090
+ // Check interface compliance
1091
+ expect(typeof adapter.push).toBe('function')
1092
+ expect(typeof adapter.pull).toBe('function')
1093
+ expect(typeof adapter.subscribe).toBe('function')
1094
+ })
1095
+
1096
+ it('coordinates multiple sync operations', async () => {
1097
+ const { createDOSync } = await import('../../data/sync')
1098
+
1099
+ const adapter = createDOSync({
1100
+ namespaceUrl: 'https://api.example.com/namespace',
1101
+ })
1102
+
1103
+ const pushPromise = adapter.push([{ collection: 'users', operation: 'insert' }])
1104
+ const pullPromise = adapter.pull()
1105
+
1106
+ const ws = MockWebSocket.getLastInstance()
1107
+
1108
+ ws?.simulateOpen()
1109
+ await Promise.resolve()
1110
+ ws?.simulateMessage({ type: 'ack', id: 'push-1', status: 'success' })
1111
+ ws?.simulateMessage({
1112
+ type: 'pull-response',
1113
+ changes: [{ collection: 'todos', operation: 'insert' }],
1114
+ })
1115
+
1116
+ await Promise.all([pushPromise, pullPromise])
1117
+
1118
+ expect(true).toBe(true) // Both should succeed
1119
+ })
1120
+
1121
+ it('maintains subscription while performing push/pull', async () => {
1122
+ const { createDOSync } = await import('../../data/sync')
1123
+
1124
+ const adapter = createDOSync({
1125
+ namespaceUrl: 'https://api.example.com/namespace',
1126
+ })
1127
+
1128
+ const callback = vi.fn()
1129
+ const unsubscribe = adapter.subscribe(callback)
1130
+
1131
+ const ws = MockWebSocket.getLastInstance()
1132
+ ws?.simulateOpen()
1133
+ await Promise.resolve()
1134
+
1135
+ // Perform push (empty - no ACK needed)
1136
+ const pushPromise = adapter.push([])
1137
+ await Promise.resolve()
1138
+ await pushPromise
1139
+
1140
+ // Subscription should still work
1141
+ ws?.simulateMessage({ type: 'sync', changes: [{ collection: 'users', operation: 'insert' }] })
1142
+
1143
+ expect(callback).toHaveBeenCalled()
1144
+
1145
+ unsubscribe()
1146
+ })
1147
+ })
1148
+
1149
+ // ============================================================================
1150
+ // Connection State Observable Tests
1151
+ // ============================================================================
1152
+
1153
+ describe('@mdxui/terminal DO Sync Adapter - connection state observable', () => {
1154
+ beforeEach(() => {
1155
+ MockWebSocket.reset()
1156
+ })
1157
+
1158
+ afterEach(() => {
1159
+ MockWebSocket.reset()
1160
+ })
1161
+
1162
+ it('starts in disconnected state', async () => {
1163
+ const { createDOSync } = await import('../../data/sync')
1164
+
1165
+ const adapter = createDOSync({
1166
+ namespaceUrl: 'https://api.example.com/namespace',
1167
+ })
1168
+
1169
+ expect(adapter.getConnectionState()).toBe('disconnected')
1170
+ })
1171
+
1172
+ it('transitions to connecting state when initiating connection', async () => {
1173
+ const { createDOSync } = await import('../../data/sync')
1174
+
1175
+ const adapter = createDOSync({
1176
+ namespaceUrl: 'https://api.example.com/namespace',
1177
+ })
1178
+
1179
+ const stateChanges: string[] = []
1180
+ adapter.onConnectionStateChange((state) => {
1181
+ stateChanges.push(state)
1182
+ })
1183
+
1184
+ // Initiate connection by subscribing
1185
+ adapter.subscribe(() => {})
1186
+
1187
+ expect(stateChanges).toContain('connecting')
1188
+ })
1189
+
1190
+ it('transitions to connected state on successful connection', async () => {
1191
+ const { createDOSync } = await import('../../data/sync')
1192
+
1193
+ const adapter = createDOSync({
1194
+ namespaceUrl: 'https://api.example.com/namespace',
1195
+ })
1196
+
1197
+ const stateChanges: string[] = []
1198
+ adapter.onConnectionStateChange((state) => {
1199
+ stateChanges.push(state)
1200
+ })
1201
+
1202
+ const pushPromise = adapter.push([])
1203
+ const ws = MockWebSocket.getLastInstance()
1204
+ ws?.simulateOpen()
1205
+
1206
+ expect(stateChanges).toContain('connected')
1207
+ expect(adapter.getConnectionState()).toBe('connected')
1208
+ })
1209
+
1210
+ it('transitions to disconnected state on connection error', async () => {
1211
+ const { createDOSync } = await import('../../data/sync')
1212
+
1213
+ const adapter = createDOSync({
1214
+ namespaceUrl: 'https://api.example.com/namespace',
1215
+ })
1216
+
1217
+ const stateChanges: string[] = []
1218
+ adapter.onConnectionStateChange((state) => {
1219
+ stateChanges.push(state)
1220
+ })
1221
+
1222
+ const pushPromise = adapter.push([])
1223
+ const ws = MockWebSocket.getLastInstance()
1224
+ ws?.simulateOpen()
1225
+ ws?.simulateError('Connection lost')
1226
+
1227
+ expect(stateChanges).toContain('disconnected')
1228
+ })
1229
+
1230
+ it('transitions to reconnecting state during reconnection attempts', async () => {
1231
+ vi.useFakeTimers()
1232
+ const { createDOSync } = await import('../../data/sync')
1233
+
1234
+ const adapter = createDOSync({
1235
+ namespaceUrl: 'https://api.example.com/namespace',
1236
+ reconnect: { enabled: true, initialDelay: 100 },
1237
+ })
1238
+
1239
+ const stateChanges: string[] = []
1240
+ adapter.onConnectionStateChange((state) => {
1241
+ stateChanges.push(state)
1242
+ })
1243
+
1244
+ const pushPromise = adapter.push([])
1245
+ let ws = MockWebSocket.getLastInstance()
1246
+ ws?.simulateOpen()
1247
+ ws?.close()
1248
+
1249
+ // Should be in reconnecting after close
1250
+ expect(stateChanges).toContain('reconnecting')
1251
+
1252
+ vi.useRealTimers()
1253
+ })
1254
+
1255
+ it('allows multiple state observers', async () => {
1256
+ const { createDOSync } = await import('../../data/sync')
1257
+
1258
+ const adapter = createDOSync({
1259
+ namespaceUrl: 'https://api.example.com/namespace',
1260
+ })
1261
+
1262
+ const observer1 = vi.fn()
1263
+ const observer2 = vi.fn()
1264
+
1265
+ adapter.onConnectionStateChange(observer1)
1266
+ adapter.onConnectionStateChange(observer2)
1267
+
1268
+ adapter.subscribe(() => {})
1269
+ const ws = MockWebSocket.getLastInstance()
1270
+ ws?.simulateOpen()
1271
+
1272
+ expect(observer1).toHaveBeenCalled()
1273
+ expect(observer2).toHaveBeenCalled()
1274
+ })
1275
+
1276
+ it('allows unsubscribing from state changes', async () => {
1277
+ const { createDOSync } = await import('../../data/sync')
1278
+
1279
+ const adapter = createDOSync({
1280
+ namespaceUrl: 'https://api.example.com/namespace',
1281
+ })
1282
+
1283
+ const observer = vi.fn()
1284
+ const unsubscribe = adapter.onConnectionStateChange(observer)
1285
+
1286
+ adapter.subscribe(() => {})
1287
+ const ws = MockWebSocket.getLastInstance()
1288
+
1289
+ // First state change
1290
+ ws?.simulateOpen()
1291
+ const callCount = observer.mock.calls.length
1292
+
1293
+ // Unsubscribe
1294
+ unsubscribe()
1295
+
1296
+ // Trigger more state changes
1297
+ ws?.close()
1298
+
1299
+ // Observer should not be called after unsubscribe
1300
+ expect(observer.mock.calls.length).toBe(callCount)
1301
+ })
1302
+ })
1303
+
1304
+ // ============================================================================
1305
+ // Offline Mutation Queue Tests
1306
+ // ============================================================================
1307
+
1308
+ describe('@mdxui/terminal DO Sync Adapter - offline mutation queue', () => {
1309
+ beforeEach(() => {
1310
+ MockWebSocket.reset()
1311
+ })
1312
+
1313
+ afterEach(() => {
1314
+ MockWebSocket.reset()
1315
+ })
1316
+
1317
+ it('starts with empty queue', async () => {
1318
+ const { createDOSync } = await import('../../data/sync')
1319
+
1320
+ const adapter = createDOSync({
1321
+ namespaceUrl: 'https://api.example.com/namespace',
1322
+ })
1323
+
1324
+ expect(adapter.getQueuedMutations()).toHaveLength(0)
1325
+ expect(adapter.getQueueStats()).toEqual({ count: 0, oldestAt: null })
1326
+ })
1327
+
1328
+ it('queues mutations when connection fails', async () => {
1329
+ const { createDOSync } = await import('../../data/sync')
1330
+
1331
+ const adapter = createDOSync({
1332
+ namespaceUrl: 'https://api.example.com/namespace',
1333
+ })
1334
+
1335
+ const changes = [{ collection: 'users', operation: 'insert', data: { id: '1' } }]
1336
+
1337
+ // Push will fail because connection immediately fails
1338
+ const pushPromise = adapter.push(changes)
1339
+ const ws = MockWebSocket.getLastInstance()
1340
+ ws?.simulateError('Connection failed')
1341
+
1342
+ // Should resolve after queueing
1343
+ await pushPromise
1344
+
1345
+ // Check queue
1346
+ const queue = adapter.getQueuedMutations()
1347
+ expect(queue.length).toBe(1)
1348
+ expect(queue[0].changes).toEqual(changes)
1349
+ })
1350
+
1351
+ it('includes queue statistics', async () => {
1352
+ const { createDOSync } = await import('../../data/sync')
1353
+
1354
+ const adapter = createDOSync({
1355
+ namespaceUrl: 'https://api.example.com/namespace',
1356
+ })
1357
+
1358
+ const changes = [{ collection: 'users', operation: 'insert', data: { id: '1' } }]
1359
+
1360
+ const pushPromise = adapter.push(changes)
1361
+ const ws = MockWebSocket.getLastInstance()
1362
+ ws?.simulateError('Connection failed')
1363
+
1364
+ await pushPromise
1365
+
1366
+ const stats = adapter.getQueueStats()
1367
+ expect(stats.count).toBe(1)
1368
+ expect(stats.oldestAt).toBeTypeOf('number')
1369
+ expect(stats.oldestAt).toBeLessThanOrEqual(Date.now())
1370
+ })
1371
+
1372
+ it('flushes queue on successful reconnection', async () => {
1373
+ vi.useFakeTimers()
1374
+ const { createDOSync } = await import('../../data/sync')
1375
+
1376
+ const adapter = createDOSync({
1377
+ namespaceUrl: 'https://api.example.com/namespace',
1378
+ reconnect: { enabled: true, initialDelay: 100 },
1379
+ })
1380
+
1381
+ const changes = [{ collection: 'users', operation: 'insert', data: { id: '1' } }]
1382
+
1383
+ // First push fails and gets queued
1384
+ const pushPromise = adapter.push(changes)
1385
+ let ws = MockWebSocket.getLastInstance()
1386
+ ws?.simulateError('Connection failed')
1387
+
1388
+ await pushPromise
1389
+
1390
+ // Verify queued
1391
+ expect(adapter.getQueuedMutations().length).toBe(1)
1392
+
1393
+ // Advance timer to trigger reconnection
1394
+ vi.advanceTimersByTime(100)
1395
+
1396
+ // Get the new WebSocket and simulate successful connection
1397
+ ws = MockWebSocket.getLastInstance()
1398
+ ws?.simulateOpen()
1399
+
1400
+ // Simulate ACK for the flushed mutation
1401
+ ws?.simulateMessage({ type: 'ack', id: 'push-2', status: 'success' })
1402
+
1403
+ // Allow promise to settle
1404
+ await vi.runAllTimersAsync()
1405
+
1406
+ // Queue should be empty after successful flush
1407
+ expect(adapter.getQueuedMutations().length).toBe(0)
1408
+
1409
+ vi.useRealTimers()
1410
+ })
1411
+
1412
+ it('keeps mutations in queue if flush fails', async () => {
1413
+ vi.useFakeTimers()
1414
+ const { createDOSync } = await import('../../data/sync')
1415
+
1416
+ const adapter = createDOSync({
1417
+ namespaceUrl: 'https://api.example.com/namespace',
1418
+ reconnect: { enabled: true, initialDelay: 100 },
1419
+ requestTimeout: 50,
1420
+ })
1421
+
1422
+ const changes = [{ collection: 'users', operation: 'insert', data: { id: '1' } }]
1423
+
1424
+ // First push fails and gets queued
1425
+ const pushPromise = adapter.push(changes)
1426
+ let ws = MockWebSocket.getLastInstance()
1427
+ ws?.simulateError('Connection failed')
1428
+
1429
+ await pushPromise
1430
+
1431
+ // Verify queued
1432
+ expect(adapter.getQueuedMutations().length).toBe(1)
1433
+
1434
+ // Advance timer to trigger reconnection
1435
+ vi.advanceTimersByTime(100)
1436
+
1437
+ // Get the new WebSocket and simulate successful connection
1438
+ ws = MockWebSocket.getLastInstance()
1439
+ ws?.simulateOpen()
1440
+
1441
+ // Don't send ACK - let it timeout
1442
+ await vi.advanceTimersByTimeAsync(100)
1443
+
1444
+ // Queue should still have the mutation
1445
+ const queue = adapter.getQueuedMutations()
1446
+ expect(queue.length).toBe(1)
1447
+ expect(queue[0].retryCount).toBeGreaterThan(0)
1448
+
1449
+ vi.useRealTimers()
1450
+ })
1451
+
1452
+ it('preserves mutation order in queue', async () => {
1453
+ const { createDOSync } = await import('../../data/sync')
1454
+
1455
+ const adapter = createDOSync({
1456
+ namespaceUrl: 'https://api.example.com/namespace',
1457
+ })
1458
+
1459
+ const changes1 = [{ id: 'mutation-1' }]
1460
+ const changes2 = [{ id: 'mutation-2' }]
1461
+ const changes3 = [{ id: 'mutation-3' }]
1462
+
1463
+ // All pushes fail
1464
+ const p1 = adapter.push(changes1)
1465
+ let ws = MockWebSocket.getLastInstance()
1466
+ ws?.simulateError('fail')
1467
+ await p1
1468
+
1469
+ const p2 = adapter.push(changes2)
1470
+ ws = MockWebSocket.getLastInstance()
1471
+ ws?.simulateError('fail')
1472
+ await p2
1473
+
1474
+ const p3 = adapter.push(changes3)
1475
+ ws = MockWebSocket.getLastInstance()
1476
+ ws?.simulateError('fail')
1477
+ await p3
1478
+
1479
+ const queue = adapter.getQueuedMutations()
1480
+ expect(queue.length).toBe(3)
1481
+
1482
+ // Verify order by timestamp
1483
+ const timestamps = queue.map((m) => m.queuedAt)
1484
+ expect(timestamps[0]).toBeLessThanOrEqual(timestamps[1])
1485
+ expect(timestamps[1]).toBeLessThanOrEqual(timestamps[2])
1486
+ })
1487
+ })
1488
+
1489
+ // ============================================================================
1490
+ // Resilience Integration Tests
1491
+ // ============================================================================
1492
+
1493
+ describe('@mdxui/terminal DO Sync Adapter - resilience integration', () => {
1494
+ beforeEach(() => {
1495
+ MockWebSocket.reset()
1496
+ vi.useFakeTimers()
1497
+ })
1498
+
1499
+ afterEach(() => {
1500
+ MockWebSocket.reset()
1501
+ vi.useRealTimers()
1502
+ })
1503
+
1504
+ it('full offline to online cycle with queued mutations', async () => {
1505
+ const { createDOSync } = await import('../../data/sync')
1506
+
1507
+ const adapter = createDOSync({
1508
+ namespaceUrl: 'https://api.example.com/namespace',
1509
+ reconnect: { enabled: true, initialDelay: 100 },
1510
+ })
1511
+
1512
+ const stateChanges: string[] = []
1513
+ adapter.onConnectionStateChange((state) => {
1514
+ stateChanges.push(state)
1515
+ })
1516
+
1517
+ // Start disconnected
1518
+ expect(adapter.getConnectionState()).toBe('disconnected')
1519
+
1520
+ // Initial push fails
1521
+ const changes = [{ collection: 'users', operation: 'insert', data: { id: '1' } }]
1522
+ const pushPromise = adapter.push(changes)
1523
+ let ws = MockWebSocket.getLastInstance()
1524
+ ws?.simulateError('fail')
1525
+ await pushPromise
1526
+
1527
+ // Should be queued
1528
+ expect(adapter.getQueuedMutations().length).toBe(1)
1529
+
1530
+ // Wait for reconnection attempt
1531
+ vi.advanceTimersByTime(100)
1532
+
1533
+ // New connection succeeds
1534
+ ws = MockWebSocket.getLastInstance()
1535
+ ws?.simulateOpen()
1536
+
1537
+ // Flush happens, ACK received
1538
+ ws?.simulateMessage({ type: 'ack', id: 'push-2', status: 'success' })
1539
+
1540
+ await vi.runAllTimersAsync()
1541
+
1542
+ // Queue should be empty
1543
+ expect(adapter.getQueuedMutations().length).toBe(0)
1544
+ expect(adapter.getConnectionState()).toBe('connected')
1545
+
1546
+ // Verify state transitions happened
1547
+ expect(stateChanges).toContain('connecting')
1548
+ expect(stateChanges).toContain('disconnected')
1549
+ expect(stateChanges).toContain('connected')
1550
+ })
1551
+
1552
+ it('handles rapid connection/disconnection cycles', async () => {
1553
+ const { createDOSync } = await import('../../data/sync')
1554
+
1555
+ const adapter = createDOSync({
1556
+ namespaceUrl: 'https://api.example.com/namespace',
1557
+ reconnect: { enabled: true, initialDelay: 50, maxDelay: 200 },
1558
+ })
1559
+
1560
+ const stateChanges: string[] = []
1561
+ adapter.onConnectionStateChange((state) => {
1562
+ stateChanges.push(state)
1563
+ })
1564
+
1565
+ // Simulate multiple rapid disconnections
1566
+ for (let i = 0; i < 3; i++) {
1567
+ adapter.push([])
1568
+ const ws = MockWebSocket.getLastInstance()
1569
+ ws?.simulateOpen()
1570
+ ws?.close()
1571
+ vi.advanceTimersByTime(50)
1572
+ }
1573
+
1574
+ // Should have experienced multiple state transitions
1575
+ const reconnectingCount = stateChanges.filter((s) => s === 'reconnecting').length
1576
+ expect(reconnectingCount).toBeGreaterThan(0)
1577
+ })
1578
+
1579
+ it('maintains data integrity across connection drops', async () => {
1580
+ const { createDOSync } = await import('../../data/sync')
1581
+
1582
+ const adapter = createDOSync({
1583
+ namespaceUrl: 'https://api.example.com/namespace',
1584
+ reconnect: { enabled: true, initialDelay: 100 },
1585
+ })
1586
+
1587
+ // Queue a single mutation during offline
1588
+ const changes = [{ id: 'user-1', name: 'Alice' }]
1589
+ const p = adapter.push(changes)
1590
+ const ws1 = MockWebSocket.getLastInstance()
1591
+ ws1?.simulateError('offline')
1592
+ await p
1593
+
1594
+ // Should be queued
1595
+ expect(adapter.getQueuedMutations().length).toBe(1)
1596
+
1597
+ // Reconnect
1598
+ vi.advanceTimersByTime(100)
1599
+ const ws = MockWebSocket.getLastInstance()
1600
+ ws?.simulateOpen()
1601
+
1602
+ // The flush will push with the next messageId (push-2)
1603
+ // Wait a tick for flush to start
1604
+ await vi.advanceTimersByTimeAsync(1)
1605
+
1606
+ // ACK the flushed mutation
1607
+ ws?.simulateMessage({ type: 'ack', id: 'push-2', status: 'success' })
1608
+
1609
+ await vi.runAllTimersAsync()
1610
+
1611
+ // Queue should be empty - data synced
1612
+ expect(adapter.getQueuedMutations().length).toBe(0)
1613
+ })
1614
+ })