@affectively/aeon-pages 1.3.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 (124) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +625 -0
  3. package/examples/basic/aeon.config.ts +39 -0
  4. package/examples/basic/components/Cursor.tsx +86 -0
  5. package/examples/basic/components/OfflineIndicator.tsx +103 -0
  6. package/examples/basic/components/PresenceBar.tsx +77 -0
  7. package/examples/basic/package.json +20 -0
  8. package/examples/basic/pages/index.tsx +80 -0
  9. package/package.json +101 -0
  10. package/packages/analytics/README.md +309 -0
  11. package/packages/analytics/build.ts +35 -0
  12. package/packages/analytics/package.json +50 -0
  13. package/packages/analytics/src/click-tracker.ts +368 -0
  14. package/packages/analytics/src/context-bridge.ts +319 -0
  15. package/packages/analytics/src/data-layer.ts +302 -0
  16. package/packages/analytics/src/gtm-loader.ts +239 -0
  17. package/packages/analytics/src/index.ts +230 -0
  18. package/packages/analytics/src/merkle-tree.ts +489 -0
  19. package/packages/analytics/src/provider.tsx +300 -0
  20. package/packages/analytics/src/types.ts +320 -0
  21. package/packages/analytics/src/use-analytics.ts +296 -0
  22. package/packages/analytics/tsconfig.json +19 -0
  23. package/packages/benchmarks/src/benchmark.test.ts +691 -0
  24. package/packages/cli/dist/index.js +61899 -0
  25. package/packages/cli/package.json +43 -0
  26. package/packages/cli/src/commands/build.test.ts +682 -0
  27. package/packages/cli/src/commands/build.ts +890 -0
  28. package/packages/cli/src/commands/dev.ts +473 -0
  29. package/packages/cli/src/commands/init.ts +409 -0
  30. package/packages/cli/src/commands/start.ts +297 -0
  31. package/packages/cli/src/index.ts +105 -0
  32. package/packages/directives/src/use-aeon.ts +272 -0
  33. package/packages/mcp-server/package.json +51 -0
  34. package/packages/mcp-server/src/index.ts +178 -0
  35. package/packages/mcp-server/src/resources.ts +346 -0
  36. package/packages/mcp-server/src/tools/index.ts +36 -0
  37. package/packages/mcp-server/src/tools/navigation.ts +545 -0
  38. package/packages/mcp-server/tsconfig.json +21 -0
  39. package/packages/react/package.json +40 -0
  40. package/packages/react/src/Link.tsx +388 -0
  41. package/packages/react/src/components/InstallPrompt.tsx +286 -0
  42. package/packages/react/src/components/OfflineDiagnostics.tsx +677 -0
  43. package/packages/react/src/components/PushNotifications.tsx +453 -0
  44. package/packages/react/src/hooks/useAeonNavigation.ts +219 -0
  45. package/packages/react/src/hooks/useConflicts.ts +277 -0
  46. package/packages/react/src/hooks/useNetworkState.ts +209 -0
  47. package/packages/react/src/hooks/usePilotNavigation.ts +254 -0
  48. package/packages/react/src/hooks/useServiceWorker.ts +278 -0
  49. package/packages/react/src/hooks.ts +195 -0
  50. package/packages/react/src/index.ts +151 -0
  51. package/packages/react/src/provider.tsx +467 -0
  52. package/packages/react/tsconfig.json +19 -0
  53. package/packages/runtime/README.md +399 -0
  54. package/packages/runtime/build.ts +48 -0
  55. package/packages/runtime/package.json +71 -0
  56. package/packages/runtime/schema.sql +40 -0
  57. package/packages/runtime/src/api-routes.ts +465 -0
  58. package/packages/runtime/src/benchmark.ts +171 -0
  59. package/packages/runtime/src/cache.ts +479 -0
  60. package/packages/runtime/src/durable-object.ts +1341 -0
  61. package/packages/runtime/src/index.ts +360 -0
  62. package/packages/runtime/src/navigation.test.ts +421 -0
  63. package/packages/runtime/src/navigation.ts +422 -0
  64. package/packages/runtime/src/nextjs-adapter.ts +272 -0
  65. package/packages/runtime/src/offline/encrypted-queue.test.ts +607 -0
  66. package/packages/runtime/src/offline/encrypted-queue.ts +478 -0
  67. package/packages/runtime/src/offline/encryption.test.ts +412 -0
  68. package/packages/runtime/src/offline/encryption.ts +397 -0
  69. package/packages/runtime/src/offline/types.ts +465 -0
  70. package/packages/runtime/src/predictor.ts +371 -0
  71. package/packages/runtime/src/registry.ts +351 -0
  72. package/packages/runtime/src/router/context-extractor.ts +661 -0
  73. package/packages/runtime/src/router/esi-control-react.tsx +2053 -0
  74. package/packages/runtime/src/router/esi-control.ts +541 -0
  75. package/packages/runtime/src/router/esi-cyrano.ts +779 -0
  76. package/packages/runtime/src/router/esi-format-react.tsx +1744 -0
  77. package/packages/runtime/src/router/esi-react.tsx +1065 -0
  78. package/packages/runtime/src/router/esi-translate-observer.ts +476 -0
  79. package/packages/runtime/src/router/esi-translate-react.tsx +556 -0
  80. package/packages/runtime/src/router/esi-translate.ts +503 -0
  81. package/packages/runtime/src/router/esi.ts +666 -0
  82. package/packages/runtime/src/router/heuristic-adapter.test.ts +295 -0
  83. package/packages/runtime/src/router/heuristic-adapter.ts +557 -0
  84. package/packages/runtime/src/router/index.ts +298 -0
  85. package/packages/runtime/src/router/merkle-capability.ts +473 -0
  86. package/packages/runtime/src/router/speculation.ts +451 -0
  87. package/packages/runtime/src/router/types.ts +630 -0
  88. package/packages/runtime/src/router.test.ts +470 -0
  89. package/packages/runtime/src/router.ts +302 -0
  90. package/packages/runtime/src/server.ts +481 -0
  91. package/packages/runtime/src/service-worker-push.ts +319 -0
  92. package/packages/runtime/src/service-worker.ts +553 -0
  93. package/packages/runtime/src/skeleton-hydrate.ts +237 -0
  94. package/packages/runtime/src/speculation.test.ts +389 -0
  95. package/packages/runtime/src/speculation.ts +486 -0
  96. package/packages/runtime/src/storage.test.ts +1297 -0
  97. package/packages/runtime/src/storage.ts +1048 -0
  98. package/packages/runtime/src/sync/conflict-resolver.test.ts +528 -0
  99. package/packages/runtime/src/sync/conflict-resolver.ts +565 -0
  100. package/packages/runtime/src/sync/coordinator.test.ts +608 -0
  101. package/packages/runtime/src/sync/coordinator.ts +596 -0
  102. package/packages/runtime/src/tree-compiler.ts +295 -0
  103. package/packages/runtime/src/types.ts +728 -0
  104. package/packages/runtime/src/worker.ts +327 -0
  105. package/packages/runtime/tsconfig.json +20 -0
  106. package/packages/runtime/wasm/aeon_pages_runtime.d.ts +504 -0
  107. package/packages/runtime/wasm/aeon_pages_runtime.js +1657 -0
  108. package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm +0 -0
  109. package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm.d.ts +196 -0
  110. package/packages/runtime/wasm/package.json +21 -0
  111. package/packages/runtime/wrangler.toml +41 -0
  112. package/packages/runtime-wasm/Cargo.lock +436 -0
  113. package/packages/runtime-wasm/Cargo.toml +29 -0
  114. package/packages/runtime-wasm/pkg/aeon_pages_runtime.d.ts +480 -0
  115. package/packages/runtime-wasm/pkg/aeon_pages_runtime.js +1568 -0
  116. package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm +0 -0
  117. package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm.d.ts +192 -0
  118. package/packages/runtime-wasm/pkg/package.json +21 -0
  119. package/packages/runtime-wasm/src/hydrate.rs +352 -0
  120. package/packages/runtime-wasm/src/lib.rs +191 -0
  121. package/packages/runtime-wasm/src/render.rs +629 -0
  122. package/packages/runtime-wasm/src/router.rs +298 -0
  123. package/packages/runtime-wasm/src/skeleton.rs +430 -0
  124. package/rfcs/RFC-001-ZERO-DEPENDENCY-RENDERING.md +1446 -0
@@ -0,0 +1,608 @@
1
+ /**
2
+ * Tests for Sync Coordinator
3
+ */
4
+
5
+ import { describe, test, expect, beforeEach } from 'bun:test';
6
+ import {
7
+ SyncCoordinator,
8
+ getSyncCoordinator,
9
+ createSyncCoordinator,
10
+ resetSyncCoordinator,
11
+ } from './coordinator';
12
+ import type { OfflineOperation } from '../offline/types';
13
+
14
+ // Helper to create mock operations
15
+ function createMockOperation(
16
+ overrides: Partial<OfflineOperation> = {},
17
+ ): OfflineOperation {
18
+ return {
19
+ id: `op-${Math.random().toString(36).slice(2)}`,
20
+ type: 'update',
21
+ sessionId: 'session-123',
22
+ status: 'pending',
23
+ data: { value: 'test' },
24
+ priority: 'normal',
25
+ bytesSize: 100,
26
+ createdAt: Date.now(),
27
+ failedCount: 0,
28
+ retryCount: 0,
29
+ maxRetries: 5,
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ describe('SyncCoordinator', () => {
35
+ let coordinator: SyncCoordinator;
36
+
37
+ beforeEach(() => {
38
+ resetSyncCoordinator();
39
+ coordinator = new SyncCoordinator();
40
+ });
41
+
42
+ describe('network state', () => {
43
+ test('starts with unknown state', () => {
44
+ const state = coordinator.getNetworkState();
45
+ // In test environment without navigator, state should be unknown
46
+ expect(['unknown', 'online', 'offline']).toContain(state);
47
+ });
48
+
49
+ test('setNetworkState updates state', () => {
50
+ coordinator.setNetworkState('online');
51
+ expect(coordinator.getNetworkState()).toBe('online');
52
+
53
+ coordinator.setNetworkState('offline');
54
+ expect(coordinator.getNetworkState()).toBe('offline');
55
+
56
+ coordinator.setNetworkState('poor');
57
+ expect(coordinator.getNetworkState()).toBe('poor');
58
+ });
59
+
60
+ test('emits network:changed event', () => {
61
+ let eventData: { previousState: string; newState: string } | null = null;
62
+
63
+ coordinator.on('network:changed', (data) => {
64
+ eventData = data as { previousState: string; newState: string };
65
+ });
66
+
67
+ coordinator.setNetworkState('online');
68
+
69
+ expect(eventData).not.toBeNull();
70
+ expect(eventData!.newState).toBe('online');
71
+ });
72
+
73
+ test('emits network:online when going online', () => {
74
+ let onlineEventReceived = false;
75
+
76
+ coordinator.on('network:online', () => {
77
+ onlineEventReceived = true;
78
+ });
79
+
80
+ coordinator.setNetworkState('offline');
81
+ coordinator.setNetworkState('online');
82
+
83
+ expect(onlineEventReceived).toBe(true);
84
+ });
85
+
86
+ test('emits network:offline when going offline', () => {
87
+ let offlineEventReceived = false;
88
+
89
+ coordinator.on('network:offline', () => {
90
+ offlineEventReceived = true;
91
+ });
92
+
93
+ coordinator.setNetworkState('online');
94
+ coordinator.setNetworkState('offline');
95
+
96
+ expect(offlineEventReceived).toBe(true);
97
+ });
98
+
99
+ test('does not emit event for same state', () => {
100
+ let eventCount = 0;
101
+
102
+ coordinator.on('network:changed', () => {
103
+ eventCount++;
104
+ });
105
+
106
+ coordinator.setNetworkState('online');
107
+ coordinator.setNetworkState('online');
108
+
109
+ expect(eventCount).toBe(1);
110
+ });
111
+
112
+ test('tracks state history', () => {
113
+ coordinator.setNetworkState('online');
114
+ coordinator.setNetworkState('offline');
115
+ coordinator.setNetworkState('online');
116
+
117
+ const stats = coordinator.getStats();
118
+ expect(stats.networkStateHistory.length).toBeGreaterThanOrEqual(3);
119
+ });
120
+ });
121
+
122
+ describe('bandwidth profile', () => {
123
+ test('has default bandwidth profile', () => {
124
+ const profile = coordinator.getBandwidthProfile();
125
+
126
+ expect(profile.speedKbps).toBeDefined();
127
+ expect(profile.latencyMs).toBeDefined();
128
+ expect(profile.reliability).toBeDefined();
129
+ });
130
+
131
+ test('updateBandwidthProfile updates profile', () => {
132
+ coordinator.updateBandwidthProfile({
133
+ speedKbps: 5000,
134
+ latencyMs: 20,
135
+ reliability: 0.99,
136
+ });
137
+
138
+ const profile = coordinator.getBandwidthProfile();
139
+
140
+ expect(profile.speedKbps).toBe(5000);
141
+ expect(profile.latencyMs).toBe(20);
142
+ expect(profile.reliability).toBe(0.99);
143
+ });
144
+
145
+ test('emits bandwidth:updated event', () => {
146
+ let eventReceived = false;
147
+
148
+ coordinator.on('bandwidth:updated', () => {
149
+ eventReceived = true;
150
+ });
151
+
152
+ coordinator.updateBandwidthProfile({ speedKbps: 2000 });
153
+
154
+ expect(eventReceived).toBe(true);
155
+ });
156
+
157
+ test('tracks bandwidth history', () => {
158
+ coordinator.updateBandwidthProfile({ speedKbps: 1000 });
159
+ coordinator.updateBandwidthProfile({ speedKbps: 2000 });
160
+ coordinator.updateBandwidthProfile({ speedKbps: 3000 });
161
+
162
+ const stats = coordinator.getStats();
163
+ expect(stats.bandwidthHistory.length).toBeGreaterThanOrEqual(3);
164
+ });
165
+ });
166
+
167
+ describe('createSyncBatch', () => {
168
+ test('creates batch from operations', () => {
169
+ const operations = [
170
+ createMockOperation({ priority: 'normal' }),
171
+ createMockOperation({ priority: 'high' }),
172
+ createMockOperation({ priority: 'low' }),
173
+ ];
174
+
175
+ const batch = coordinator.createSyncBatch(operations);
176
+
177
+ expect(batch.batchId).toBeDefined();
178
+ expect(batch.batchId.startsWith('batch-')).toBe(true);
179
+ expect(batch.operations).toHaveLength(3);
180
+ expect(batch.totalSize).toBeGreaterThan(0);
181
+ expect(batch.createdAt).toBeDefined();
182
+ });
183
+
184
+ test('determines batch priority from highest operation priority', () => {
185
+ const operations = [
186
+ createMockOperation({ priority: 'low' }),
187
+ createMockOperation({ priority: 'high' }),
188
+ createMockOperation({ priority: 'normal' }),
189
+ ];
190
+
191
+ const batch = coordinator.createSyncBatch(operations);
192
+
193
+ expect(batch.priority).toBe('high');
194
+ });
195
+
196
+ test('respects max batch size', () => {
197
+ const coordinator = createSyncCoordinator({ maxBatchSize: 2 });
198
+
199
+ const operations = [
200
+ createMockOperation(),
201
+ createMockOperation(),
202
+ createMockOperation(),
203
+ ];
204
+
205
+ const batch = coordinator.createSyncBatch(operations);
206
+
207
+ expect(batch.operations).toHaveLength(2);
208
+ });
209
+
210
+ test('respects max batch bytes', () => {
211
+ const coordinator = createSyncCoordinator({ maxBatchBytes: 250 });
212
+
213
+ const operations = [
214
+ createMockOperation({ bytesSize: 100 }),
215
+ createMockOperation({ bytesSize: 100 }),
216
+ createMockOperation({ bytesSize: 100 }),
217
+ ];
218
+
219
+ const batch = coordinator.createSyncBatch(operations);
220
+
221
+ expect(batch.operations.length).toBeLessThan(3);
222
+ });
223
+
224
+ test('emits batch:created event', () => {
225
+ let eventReceived = false;
226
+
227
+ coordinator.on('batch:created', () => {
228
+ eventReceived = true;
229
+ });
230
+
231
+ coordinator.createSyncBatch([createMockOperation()]);
232
+
233
+ expect(eventReceived).toBe(true);
234
+ });
235
+
236
+ test('stores batch for retrieval', () => {
237
+ const batch = coordinator.createSyncBatch([createMockOperation()]);
238
+
239
+ const retrieved = coordinator.getBatch(batch.batchId);
240
+
241
+ expect(retrieved).toBeDefined();
242
+ expect(retrieved!.batchId).toBe(batch.batchId);
243
+ });
244
+ });
245
+
246
+ describe('startSyncBatch', () => {
247
+ test('starts syncing a batch', () => {
248
+ const batch = coordinator.createSyncBatch([createMockOperation()]);
249
+
250
+ coordinator.startSyncBatch(batch.batchId);
251
+
252
+ const progress = coordinator.getCurrentProgress();
253
+ expect(progress).toBeDefined();
254
+ expect(progress!.batchId).toBe(batch.batchId);
255
+ });
256
+
257
+ test('emits batch:started event', () => {
258
+ let eventBatchId: string | null = null;
259
+
260
+ coordinator.on('batch:started', (data) => {
261
+ eventBatchId = (data as { batchId: string }).batchId;
262
+ });
263
+
264
+ const batch = coordinator.createSyncBatch([createMockOperation()]);
265
+ coordinator.startSyncBatch(batch.batchId);
266
+
267
+ expect(eventBatchId).toBe(batch.batchId);
268
+ });
269
+
270
+ test('increments totalSyncsAttempted', () => {
271
+ const batch = coordinator.createSyncBatch([createMockOperation()]);
272
+
273
+ const statsBefore = coordinator.getStats();
274
+ coordinator.startSyncBatch(batch.batchId);
275
+ const statsAfter = coordinator.getStats();
276
+
277
+ expect(statsAfter.totalSyncsAttempted).toBe(
278
+ statsBefore.totalSyncsAttempted + 1,
279
+ );
280
+ });
281
+
282
+ test('does nothing for non-existent batch', () => {
283
+ // Should not throw
284
+ coordinator.startSyncBatch('non-existent-batch');
285
+
286
+ expect(coordinator.getCurrentProgress()).toBeUndefined();
287
+ });
288
+ });
289
+
290
+ describe('updateProgress', () => {
291
+ test('updates sync progress', () => {
292
+ const batch = coordinator.createSyncBatch([
293
+ createMockOperation(),
294
+ createMockOperation(),
295
+ ]);
296
+
297
+ coordinator.startSyncBatch(batch.batchId);
298
+ coordinator.updateProgress(batch.batchId, 1, 100);
299
+
300
+ const progress = coordinator.getCurrentProgress();
301
+
302
+ expect(progress!.syncedOperations).toBe(1);
303
+ expect(progress!.bytesSynced).toBe(100);
304
+ });
305
+
306
+ test('emits batch:progress event', () => {
307
+ let eventData: { syncedOperations: number } | null = null;
308
+
309
+ coordinator.on('batch:progress', (data) => {
310
+ eventData = data as { syncedOperations: number };
311
+ });
312
+
313
+ const batch = coordinator.createSyncBatch([createMockOperation()]);
314
+ coordinator.startSyncBatch(batch.batchId);
315
+ coordinator.updateProgress(batch.batchId, 1, 50);
316
+
317
+ expect(eventData).not.toBeNull();
318
+ expect(eventData!.syncedOperations).toBe(1);
319
+ });
320
+
321
+ test('calculates estimated time remaining', () => {
322
+ coordinator.updateBandwidthProfile({ speedKbps: 1000, latencyMs: 50 });
323
+
324
+ const batch = coordinator.createSyncBatch([
325
+ createMockOperation({ bytesSize: 1000 }),
326
+ ]);
327
+
328
+ coordinator.startSyncBatch(batch.batchId);
329
+ coordinator.updateProgress(batch.batchId, 0, 0);
330
+
331
+ const progress = coordinator.getCurrentProgress();
332
+
333
+ expect(progress!.estimatedTimeRemaining).toBeDefined();
334
+ expect(progress!.estimatedTimeRemaining).toBeGreaterThan(0);
335
+ });
336
+ });
337
+
338
+ describe('completeSyncBatch', () => {
339
+ test('completes successful sync', () => {
340
+ const batch = coordinator.createSyncBatch([createMockOperation()]);
341
+ coordinator.startSyncBatch(batch.batchId);
342
+
343
+ coordinator.completeSyncBatch(batch.batchId, {
344
+ success: true,
345
+ synced: ['op-1'],
346
+ failed: [],
347
+ conflicts: [],
348
+ serverTimestamp: Date.now(),
349
+ });
350
+
351
+ const stats = coordinator.getStats();
352
+ expect(stats.successfulSyncs).toBe(1);
353
+ expect(stats.totalOperationsSynced).toBe(1);
354
+ });
355
+
356
+ test('records failed sync', () => {
357
+ const batch = coordinator.createSyncBatch([createMockOperation()]);
358
+ coordinator.startSyncBatch(batch.batchId);
359
+
360
+ coordinator.completeSyncBatch(batch.batchId, {
361
+ success: false,
362
+ synced: [],
363
+ failed: [
364
+ { operationId: 'op-1', error: 'Network error', retryable: true },
365
+ ],
366
+ conflicts: [],
367
+ serverTimestamp: Date.now(),
368
+ });
369
+
370
+ const stats = coordinator.getStats();
371
+ expect(stats.failedSyncs).toBe(1);
372
+ });
373
+
374
+ test('emits batch:completed event', () => {
375
+ let eventReceived = false;
376
+
377
+ coordinator.on('batch:completed', () => {
378
+ eventReceived = true;
379
+ });
380
+
381
+ const batch = coordinator.createSyncBatch([createMockOperation()]);
382
+ coordinator.startSyncBatch(batch.batchId);
383
+ coordinator.completeSyncBatch(batch.batchId, {
384
+ success: true,
385
+ synced: ['op-1'],
386
+ failed: [],
387
+ conflicts: [],
388
+ serverTimestamp: Date.now(),
389
+ });
390
+
391
+ expect(eventReceived).toBe(true);
392
+ });
393
+
394
+ test('clears current batch', () => {
395
+ const batch = coordinator.createSyncBatch([createMockOperation()]);
396
+ coordinator.startSyncBatch(batch.batchId);
397
+ coordinator.completeSyncBatch(batch.batchId, {
398
+ success: true,
399
+ synced: ['op-1'],
400
+ failed: [],
401
+ conflicts: [],
402
+ serverTimestamp: Date.now(),
403
+ });
404
+
405
+ expect(coordinator.getCurrentProgress()).toBeUndefined();
406
+ });
407
+ });
408
+
409
+ describe('failSyncBatch', () => {
410
+ test('emits batch:retry for retryable failures', () => {
411
+ let retryAttempt = 0;
412
+
413
+ coordinator.on('batch:retry', (data) => {
414
+ retryAttempt = (data as { attempt: number }).attempt;
415
+ });
416
+
417
+ const batch = coordinator.createSyncBatch([createMockOperation()]);
418
+ coordinator.startSyncBatch(batch.batchId);
419
+ coordinator.failSyncBatch(batch.batchId, 'Network error', true);
420
+
421
+ expect(retryAttempt).toBe(1);
422
+ });
423
+
424
+ test('emits batch:failed after max retries', () => {
425
+ const coordinator = createSyncCoordinator({ maxRetries: 2 });
426
+ let failedEventReceived = false;
427
+
428
+ coordinator.on('batch:failed', () => {
429
+ failedEventReceived = true;
430
+ });
431
+
432
+ const batch = coordinator.createSyncBatch([createMockOperation()]);
433
+
434
+ // Fail multiple times
435
+ coordinator.startSyncBatch(batch.batchId);
436
+ coordinator.failSyncBatch(batch.batchId, 'Error 1', true);
437
+ coordinator.startSyncBatch(batch.batchId);
438
+ coordinator.failSyncBatch(batch.batchId, 'Error 2', true);
439
+ coordinator.startSyncBatch(batch.batchId);
440
+ coordinator.failSyncBatch(batch.batchId, 'Error 3', true);
441
+
442
+ expect(failedEventReceived).toBe(true);
443
+ });
444
+
445
+ test('emits batch:failed for non-retryable failures', () => {
446
+ let failedEventReceived = false;
447
+
448
+ coordinator.on('batch:failed', () => {
449
+ failedEventReceived = true;
450
+ });
451
+
452
+ const batch = coordinator.createSyncBatch([createMockOperation()]);
453
+ coordinator.startSyncBatch(batch.batchId);
454
+ coordinator.failSyncBatch(batch.batchId, 'Fatal error', false);
455
+
456
+ expect(failedEventReceived).toBe(true);
457
+ });
458
+ });
459
+
460
+ describe('estimateSyncTime', () => {
461
+ test('estimates time based on bandwidth', () => {
462
+ coordinator.updateBandwidthProfile({
463
+ speedKbps: 1000, // 1 Mbps
464
+ latencyMs: 100,
465
+ });
466
+
467
+ // 1000 bytes at 1 Mbps = ~8ms, plus 100ms latency
468
+ const estimate = coordinator.estimateSyncTime(1000);
469
+
470
+ expect(estimate).toBeGreaterThan(0);
471
+ expect(estimate).toBeLessThan(5000); // Should be reasonable
472
+ });
473
+
474
+ test('larger data takes longer', () => {
475
+ coordinator.updateBandwidthProfile({
476
+ speedKbps: 1000,
477
+ latencyMs: 50,
478
+ });
479
+
480
+ const smallEstimate = coordinator.estimateSyncTime(1000);
481
+ const largeEstimate = coordinator.estimateSyncTime(100000);
482
+
483
+ expect(largeEstimate).toBeGreaterThan(smallEstimate);
484
+ });
485
+ });
486
+
487
+ describe('getPendingBatches', () => {
488
+ test('returns all pending batches', () => {
489
+ coordinator.createSyncBatch([createMockOperation()]);
490
+ coordinator.createSyncBatch([createMockOperation()]);
491
+ coordinator.createSyncBatch([createMockOperation()]);
492
+
493
+ const pending = coordinator.getPendingBatches();
494
+
495
+ expect(pending).toHaveLength(3);
496
+ });
497
+ });
498
+
499
+ describe('configure', () => {
500
+ test('updates configuration', () => {
501
+ coordinator.configure({
502
+ maxBatchSize: 50,
503
+ enableCompression: false,
504
+ });
505
+
506
+ const config = coordinator.getConfig();
507
+
508
+ expect(config.maxBatchSize).toBe(50);
509
+ expect(config.enableCompression).toBe(false);
510
+ });
511
+
512
+ test('emits config:updated event', () => {
513
+ let eventReceived = false;
514
+
515
+ coordinator.on('config:updated', () => {
516
+ eventReceived = true;
517
+ });
518
+
519
+ coordinator.configure({ maxBatchSize: 50 });
520
+
521
+ expect(eventReceived).toBe(true);
522
+ });
523
+ });
524
+
525
+ describe('adaptive batching', () => {
526
+ test('reduces batch size on poor connection', () => {
527
+ const coordinator = createSyncCoordinator({ adaptiveBatching: true });
528
+
529
+ coordinator.updateBandwidthProfile({
530
+ speedKbps: 100, // Very slow
531
+ latencyMs: 1000,
532
+ });
533
+
534
+ const config = coordinator.getConfig();
535
+
536
+ expect(config.maxBatchSize).toBeLessThan(100);
537
+ });
538
+
539
+ test('increases batch size on good connection', () => {
540
+ const coordinator = createSyncCoordinator({ adaptiveBatching: true });
541
+
542
+ coordinator.updateBandwidthProfile({
543
+ speedKbps: 10000, // Fast
544
+ latencyMs: 20,
545
+ });
546
+
547
+ const config = coordinator.getConfig();
548
+
549
+ expect(config.maxBatchSize).toBeGreaterThan(100);
550
+ });
551
+ });
552
+
553
+ describe('clear', () => {
554
+ test('clears all batches and progress', () => {
555
+ coordinator.createSyncBatch([createMockOperation()]);
556
+ coordinator.createSyncBatch([createMockOperation()]);
557
+
558
+ coordinator.clear();
559
+
560
+ expect(coordinator.getPendingBatches()).toHaveLength(0);
561
+ expect(coordinator.getCurrentProgress()).toBeUndefined();
562
+ });
563
+ });
564
+
565
+ describe('reset', () => {
566
+ test('resets all state and statistics', () => {
567
+ coordinator.createSyncBatch([createMockOperation()]);
568
+ coordinator.setNetworkState('online');
569
+ coordinator.updateBandwidthProfile({ speedKbps: 5000 });
570
+
571
+ coordinator.reset();
572
+
573
+ const stats = coordinator.getStats();
574
+ expect(stats.totalSyncsAttempted).toBe(0);
575
+ expect(stats.successfulSyncs).toBe(0);
576
+ expect(coordinator.getPendingBatches()).toHaveLength(0);
577
+ });
578
+ });
579
+ });
580
+
581
+ describe('getSyncCoordinator', () => {
582
+ beforeEach(() => {
583
+ resetSyncCoordinator();
584
+ });
585
+
586
+ test('returns singleton instance', () => {
587
+ const instance1 = getSyncCoordinator();
588
+ const instance2 = getSyncCoordinator();
589
+
590
+ expect(instance1).toBe(instance2);
591
+ });
592
+ });
593
+
594
+ describe('createSyncCoordinator', () => {
595
+ test('creates coordinator with custom config', () => {
596
+ const coordinator = createSyncCoordinator({
597
+ maxBatchSize: 50,
598
+ maxBatchBytes: 1024 * 1024,
599
+ enableCompression: false,
600
+ });
601
+
602
+ const config = coordinator.getConfig();
603
+
604
+ expect(config.maxBatchSize).toBe(50);
605
+ expect(config.maxBatchBytes).toBe(1024 * 1024);
606
+ expect(config.enableCompression).toBe(false);
607
+ });
608
+ });