@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.
- package/CHANGELOG.md +112 -0
- package/README.md +625 -0
- package/examples/basic/aeon.config.ts +39 -0
- package/examples/basic/components/Cursor.tsx +86 -0
- package/examples/basic/components/OfflineIndicator.tsx +103 -0
- package/examples/basic/components/PresenceBar.tsx +77 -0
- package/examples/basic/package.json +20 -0
- package/examples/basic/pages/index.tsx +80 -0
- package/package.json +101 -0
- package/packages/analytics/README.md +309 -0
- package/packages/analytics/build.ts +35 -0
- package/packages/analytics/package.json +50 -0
- package/packages/analytics/src/click-tracker.ts +368 -0
- package/packages/analytics/src/context-bridge.ts +319 -0
- package/packages/analytics/src/data-layer.ts +302 -0
- package/packages/analytics/src/gtm-loader.ts +239 -0
- package/packages/analytics/src/index.ts +230 -0
- package/packages/analytics/src/merkle-tree.ts +489 -0
- package/packages/analytics/src/provider.tsx +300 -0
- package/packages/analytics/src/types.ts +320 -0
- package/packages/analytics/src/use-analytics.ts +296 -0
- package/packages/analytics/tsconfig.json +19 -0
- package/packages/benchmarks/src/benchmark.test.ts +691 -0
- package/packages/cli/dist/index.js +61899 -0
- package/packages/cli/package.json +43 -0
- package/packages/cli/src/commands/build.test.ts +682 -0
- package/packages/cli/src/commands/build.ts +890 -0
- package/packages/cli/src/commands/dev.ts +473 -0
- package/packages/cli/src/commands/init.ts +409 -0
- package/packages/cli/src/commands/start.ts +297 -0
- package/packages/cli/src/index.ts +105 -0
- package/packages/directives/src/use-aeon.ts +272 -0
- package/packages/mcp-server/package.json +51 -0
- package/packages/mcp-server/src/index.ts +178 -0
- package/packages/mcp-server/src/resources.ts +346 -0
- package/packages/mcp-server/src/tools/index.ts +36 -0
- package/packages/mcp-server/src/tools/navigation.ts +545 -0
- package/packages/mcp-server/tsconfig.json +21 -0
- package/packages/react/package.json +40 -0
- package/packages/react/src/Link.tsx +388 -0
- package/packages/react/src/components/InstallPrompt.tsx +286 -0
- package/packages/react/src/components/OfflineDiagnostics.tsx +677 -0
- package/packages/react/src/components/PushNotifications.tsx +453 -0
- package/packages/react/src/hooks/useAeonNavigation.ts +219 -0
- package/packages/react/src/hooks/useConflicts.ts +277 -0
- package/packages/react/src/hooks/useNetworkState.ts +209 -0
- package/packages/react/src/hooks/usePilotNavigation.ts +254 -0
- package/packages/react/src/hooks/useServiceWorker.ts +278 -0
- package/packages/react/src/hooks.ts +195 -0
- package/packages/react/src/index.ts +151 -0
- package/packages/react/src/provider.tsx +467 -0
- package/packages/react/tsconfig.json +19 -0
- package/packages/runtime/README.md +399 -0
- package/packages/runtime/build.ts +48 -0
- package/packages/runtime/package.json +71 -0
- package/packages/runtime/schema.sql +40 -0
- package/packages/runtime/src/api-routes.ts +465 -0
- package/packages/runtime/src/benchmark.ts +171 -0
- package/packages/runtime/src/cache.ts +479 -0
- package/packages/runtime/src/durable-object.ts +1341 -0
- package/packages/runtime/src/index.ts +360 -0
- package/packages/runtime/src/navigation.test.ts +421 -0
- package/packages/runtime/src/navigation.ts +422 -0
- package/packages/runtime/src/nextjs-adapter.ts +272 -0
- package/packages/runtime/src/offline/encrypted-queue.test.ts +607 -0
- package/packages/runtime/src/offline/encrypted-queue.ts +478 -0
- package/packages/runtime/src/offline/encryption.test.ts +412 -0
- package/packages/runtime/src/offline/encryption.ts +397 -0
- package/packages/runtime/src/offline/types.ts +465 -0
- package/packages/runtime/src/predictor.ts +371 -0
- package/packages/runtime/src/registry.ts +351 -0
- package/packages/runtime/src/router/context-extractor.ts +661 -0
- package/packages/runtime/src/router/esi-control-react.tsx +2053 -0
- package/packages/runtime/src/router/esi-control.ts +541 -0
- package/packages/runtime/src/router/esi-cyrano.ts +779 -0
- package/packages/runtime/src/router/esi-format-react.tsx +1744 -0
- package/packages/runtime/src/router/esi-react.tsx +1065 -0
- package/packages/runtime/src/router/esi-translate-observer.ts +476 -0
- package/packages/runtime/src/router/esi-translate-react.tsx +556 -0
- package/packages/runtime/src/router/esi-translate.ts +503 -0
- package/packages/runtime/src/router/esi.ts +666 -0
- package/packages/runtime/src/router/heuristic-adapter.test.ts +295 -0
- package/packages/runtime/src/router/heuristic-adapter.ts +557 -0
- package/packages/runtime/src/router/index.ts +298 -0
- package/packages/runtime/src/router/merkle-capability.ts +473 -0
- package/packages/runtime/src/router/speculation.ts +451 -0
- package/packages/runtime/src/router/types.ts +630 -0
- package/packages/runtime/src/router.test.ts +470 -0
- package/packages/runtime/src/router.ts +302 -0
- package/packages/runtime/src/server.ts +481 -0
- package/packages/runtime/src/service-worker-push.ts +319 -0
- package/packages/runtime/src/service-worker.ts +553 -0
- package/packages/runtime/src/skeleton-hydrate.ts +237 -0
- package/packages/runtime/src/speculation.test.ts +389 -0
- package/packages/runtime/src/speculation.ts +486 -0
- package/packages/runtime/src/storage.test.ts +1297 -0
- package/packages/runtime/src/storage.ts +1048 -0
- package/packages/runtime/src/sync/conflict-resolver.test.ts +528 -0
- package/packages/runtime/src/sync/conflict-resolver.ts +565 -0
- package/packages/runtime/src/sync/coordinator.test.ts +608 -0
- package/packages/runtime/src/sync/coordinator.ts +596 -0
- package/packages/runtime/src/tree-compiler.ts +295 -0
- package/packages/runtime/src/types.ts +728 -0
- package/packages/runtime/src/worker.ts +327 -0
- package/packages/runtime/tsconfig.json +20 -0
- package/packages/runtime/wasm/aeon_pages_runtime.d.ts +504 -0
- package/packages/runtime/wasm/aeon_pages_runtime.js +1657 -0
- package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm +0 -0
- package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm.d.ts +196 -0
- package/packages/runtime/wasm/package.json +21 -0
- package/packages/runtime/wrangler.toml +41 -0
- package/packages/runtime-wasm/Cargo.lock +436 -0
- package/packages/runtime-wasm/Cargo.toml +29 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime.d.ts +480 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime.js +1568 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm +0 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm.d.ts +192 -0
- package/packages/runtime-wasm/pkg/package.json +21 -0
- package/packages/runtime-wasm/src/hydrate.rs +352 -0
- package/packages/runtime-wasm/src/lib.rs +191 -0
- package/packages/runtime-wasm/src/render.rs +629 -0
- package/packages/runtime-wasm/src/router.rs +298 -0
- package/packages/runtime-wasm/src/skeleton.rs +430 -0
- 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
|
+
});
|