@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.
- package/README.md +571 -0
- package/dist/ansi-css-Sk5mWtdK.d.ts +119 -0
- package/dist/ansi-css-V6JIHGsM.d.ts +119 -0
- package/dist/ansi-css-_3eSEU9d.d.ts +119 -0
- package/dist/chunk-3EFDH7PK.js +5235 -0
- package/dist/chunk-3RG5ZIWI.js +10 -0
- package/dist/chunk-3X5IR6WE.js +884 -0
- package/dist/chunk-4FV5ZDCE.js +5236 -0
- package/dist/chunk-4OVMSF2J.js +243 -0
- package/dist/chunk-63FEETIS.js +4048 -0
- package/dist/chunk-B43KP7XJ.js +884 -0
- package/dist/chunk-BMTJXWUV.js +655 -0
- package/dist/chunk-C3SVH4N7.js +882 -0
- package/dist/chunk-EVWR7Y47.js +874 -0
- package/dist/chunk-F6A5VWUC.js +1285 -0
- package/dist/chunk-FD7KW7GE.js +882 -0
- package/dist/chunk-GBQ6UD6I.js +655 -0
- package/dist/chunk-GMDD3M6U.js +5227 -0
- package/dist/chunk-JBHRXOXM.js +1058 -0
- package/dist/chunk-JFOO3EYO.js +1182 -0
- package/dist/chunk-JQ5H3WXL.js +1291 -0
- package/dist/chunk-JQD5NASE.js +234 -0
- package/dist/chunk-KRHJP5R7.js +592 -0
- package/dist/chunk-KWF6WVJE.js +962 -0
- package/dist/chunk-LHYQVN3H.js +1038 -0
- package/dist/chunk-M3TLQLGC.js +1032 -0
- package/dist/chunk-MVW4Q5OP.js +240 -0
- package/dist/chunk-NXCZSWLU.js +1294 -0
- package/dist/chunk-O25TNRO6.js +607 -0
- package/dist/chunk-PNECDA2I.js +884 -0
- package/dist/chunk-QIHWRLJR.js +962 -0
- package/dist/chunk-QW5YMQ7K.js +882 -0
- package/dist/chunk-R5U7XKVJ.js +16 -0
- package/dist/chunk-RP2MVQLR.js +962 -0
- package/dist/chunk-TP6RXGXA.js +1087 -0
- package/dist/chunk-TQQSTITZ.js +655 -0
- package/dist/chunk-X24GWXQV.js +1281 -0
- package/dist/components/index.d.ts +802 -0
- package/dist/components/index.js +149 -0
- package/dist/data/index.d.ts +2554 -0
- package/dist/data/index.js +51 -0
- package/dist/forms/index.d.ts +1596 -0
- package/dist/forms/index.js +464 -0
- package/dist/index-CQRFZntR.d.ts +867 -0
- package/dist/index.d.ts +579 -0
- package/dist/index.js +786 -0
- package/dist/interactive-D0JkWosD.d.ts +217 -0
- package/dist/keyboard/index.d.ts +2 -0
- package/dist/keyboard/index.js +43 -0
- package/dist/renderers/index.d.ts +546 -0
- package/dist/renderers/index.js +2157 -0
- package/dist/storybook/index.d.ts +396 -0
- package/dist/storybook/index.js +641 -0
- package/dist/theme/index.d.ts +1339 -0
- package/dist/theme/index.js +123 -0
- package/dist/types-Bxu5PAgA.d.ts +710 -0
- package/dist/types-CIlop5Ji.d.ts +701 -0
- package/dist/types-Ca8p_p5X.d.ts +710 -0
- package/package.json +90 -0
- package/src/__tests__/components/data/card.test.ts +458 -0
- package/src/__tests__/components/data/list.test.ts +473 -0
- package/src/__tests__/components/data/metrics.test.ts +541 -0
- package/src/__tests__/components/data/table.test.ts +448 -0
- package/src/__tests__/components/input/field.test.ts +555 -0
- package/src/__tests__/components/input/form.test.ts +870 -0
- package/src/__tests__/components/input/search.test.ts +1238 -0
- package/src/__tests__/components/input/select.test.ts +658 -0
- package/src/__tests__/components/navigation/breadcrumb.test.ts +923 -0
- package/src/__tests__/components/navigation/command-palette.test.ts +1095 -0
- package/src/__tests__/components/navigation/sidebar.test.ts +1018 -0
- package/src/__tests__/components/navigation/tabs.test.ts +995 -0
- package/src/__tests__/components.test.tsx +1197 -0
- package/src/__tests__/core/compiler.test.ts +986 -0
- package/src/__tests__/core/parser.test.ts +785 -0
- package/src/__tests__/core/tier-switcher.test.ts +1103 -0
- package/src/__tests__/core/types.test.ts +1398 -0
- package/src/__tests__/data/collections.test.ts +1337 -0
- package/src/__tests__/data/db.test.ts +1265 -0
- package/src/__tests__/data/reactive.test.ts +1010 -0
- package/src/__tests__/data/sync.test.ts +1614 -0
- package/src/__tests__/errors.test.ts +660 -0
- package/src/__tests__/forms/integration.test.ts +444 -0
- package/src/__tests__/integration.test.ts +905 -0
- package/src/__tests__/keyboard.test.ts +1791 -0
- package/src/__tests__/renderer.test.ts +489 -0
- package/src/__tests__/renderers/ansi-css.test.ts +948 -0
- package/src/__tests__/renderers/ansi.test.ts +1366 -0
- package/src/__tests__/renderers/ascii.test.ts +1360 -0
- package/src/__tests__/renderers/interactive.test.ts +2353 -0
- package/src/__tests__/renderers/markdown.test.ts +1483 -0
- package/src/__tests__/renderers/text.test.ts +1369 -0
- package/src/__tests__/renderers/unicode.test.ts +1307 -0
- package/src/__tests__/theme.test.ts +639 -0
- package/src/__tests__/utils/assertions.ts +685 -0
- package/src/__tests__/utils/index.ts +115 -0
- package/src/__tests__/utils/test-renderer.ts +381 -0
- package/src/__tests__/utils/utils.test.ts +560 -0
- package/src/components/containers/card.ts +56 -0
- package/src/components/containers/dialog.ts +53 -0
- package/src/components/containers/index.ts +9 -0
- package/src/components/containers/panel.ts +59 -0
- package/src/components/feedback/badge.ts +40 -0
- package/src/components/feedback/index.ts +8 -0
- package/src/components/feedback/spinner.ts +23 -0
- package/src/components/helpers.ts +81 -0
- package/src/components/index.ts +153 -0
- package/src/components/layout/breadcrumb.ts +31 -0
- package/src/components/layout/index.ts +10 -0
- package/src/components/layout/list.ts +29 -0
- package/src/components/layout/sidebar.ts +79 -0
- package/src/components/layout/table.ts +62 -0
- package/src/components/primitives/box.ts +95 -0
- package/src/components/primitives/button.ts +54 -0
- package/src/components/primitives/index.ts +11 -0
- package/src/components/primitives/input.ts +88 -0
- package/src/components/primitives/select.ts +97 -0
- package/src/components/primitives/text.ts +60 -0
- package/src/components/render.ts +155 -0
- package/src/components/templates/app.ts +43 -0
- package/src/components/templates/index.ts +8 -0
- package/src/components/templates/site.ts +54 -0
- package/src/components/types.ts +777 -0
- package/src/core/compiler.ts +718 -0
- package/src/core/parser.ts +127 -0
- package/src/core/tier-switcher.ts +607 -0
- package/src/core/types.ts +672 -0
- package/src/data/collection.ts +316 -0
- package/src/data/collections.ts +50 -0
- package/src/data/context.tsx +174 -0
- package/src/data/db.ts +127 -0
- package/src/data/hooks.ts +532 -0
- package/src/data/index.ts +138 -0
- package/src/data/reactive.ts +1225 -0
- package/src/data/saas-collections.ts +375 -0
- package/src/data/sync.ts +1213 -0
- package/src/data/types.ts +660 -0
- package/src/forms/converters.ts +512 -0
- package/src/forms/index.ts +133 -0
- package/src/forms/schemas.ts +403 -0
- package/src/forms/types.ts +476 -0
- package/src/index.ts +542 -0
- package/src/keyboard/focus.ts +748 -0
- package/src/keyboard/index.ts +96 -0
- package/src/keyboard/integration.ts +371 -0
- package/src/keyboard/manager.ts +377 -0
- package/src/keyboard/presets.ts +90 -0
- package/src/renderers/ansi-css.ts +576 -0
- package/src/renderers/ansi.ts +802 -0
- package/src/renderers/ascii.ts +680 -0
- package/src/renderers/breadcrumb.ts +480 -0
- package/src/renderers/command-palette.ts +802 -0
- package/src/renderers/components/field.ts +210 -0
- package/src/renderers/components/form.ts +327 -0
- package/src/renderers/components/index.ts +21 -0
- package/src/renderers/components/search.ts +449 -0
- package/src/renderers/components/select.ts +222 -0
- package/src/renderers/index.ts +101 -0
- package/src/renderers/interactive/component-handlers.ts +622 -0
- package/src/renderers/interactive/cursor-manager.ts +147 -0
- package/src/renderers/interactive/focus-manager.ts +279 -0
- package/src/renderers/interactive/index.ts +661 -0
- package/src/renderers/interactive/input-handler.ts +164 -0
- package/src/renderers/interactive/keyboard-handler.ts +212 -0
- package/src/renderers/interactive/mouse-handler.ts +167 -0
- package/src/renderers/interactive/state-manager.ts +109 -0
- package/src/renderers/interactive/types.ts +338 -0
- package/src/renderers/interactive-string.ts +299 -0
- package/src/renderers/interactive.ts +59 -0
- package/src/renderers/markdown.ts +950 -0
- package/src/renderers/sidebar.ts +549 -0
- package/src/renderers/tabs.ts +682 -0
- package/src/renderers/text.ts +791 -0
- package/src/renderers/unicode.ts +917 -0
- package/src/renderers/utils.ts +942 -0
- package/src/router/adapters.ts +383 -0
- package/src/router/types.ts +140 -0
- package/src/router/utils.ts +452 -0
- package/src/schemas.ts +205 -0
- package/src/storybook/index.ts +91 -0
- package/src/storybook/interactive-decorator.tsx +659 -0
- package/src/storybook/keyboard-simulator.ts +501 -0
- package/src/theme/ansi-codes.ts +80 -0
- package/src/theme/box-drawing.ts +132 -0
- package/src/theme/color-convert.ts +254 -0
- package/src/theme/color-support.ts +321 -0
- package/src/theme/index.ts +134 -0
- package/src/theme/strip-ansi.ts +50 -0
- package/src/theme/tailwind-map.ts +469 -0
- package/src/theme/text-styles.ts +206 -0
- package/src/theme/theme-system.ts +568 -0
- 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
|
+
})
|