flock-core 0.5.0b50__py3-none-any.whl → 0.5.0b51__py3-none-any.whl

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.

Potentially problematic release.


This version of flock-core might be problematic. Click here for more details.

Files changed (116) hide show
  1. flock/dashboard/launcher.py +1 -1
  2. flock/frontend/README.md +678 -0
  3. flock/frontend/docs/DESIGN_SYSTEM.md +1980 -0
  4. flock/frontend/index.html +12 -0
  5. flock/frontend/package-lock.json +4347 -0
  6. flock/frontend/package.json +48 -0
  7. flock/frontend/src/App.tsx +79 -0
  8. flock/frontend/src/__tests__/e2e/critical-scenarios.test.tsx +587 -0
  9. flock/frontend/src/__tests__/integration/filtering-e2e.test.tsx +387 -0
  10. flock/frontend/src/__tests__/integration/graph-rendering.test.tsx +640 -0
  11. flock/frontend/src/__tests__/integration/indexeddb-persistence.test.tsx +699 -0
  12. flock/frontend/src/components/common/BuildInfo.tsx +39 -0
  13. flock/frontend/src/components/common/EmptyState.module.css +115 -0
  14. flock/frontend/src/components/common/EmptyState.tsx +128 -0
  15. flock/frontend/src/components/common/ErrorBoundary.module.css +169 -0
  16. flock/frontend/src/components/common/ErrorBoundary.tsx +118 -0
  17. flock/frontend/src/components/common/KeyboardShortcutsDialog.css +251 -0
  18. flock/frontend/src/components/common/KeyboardShortcutsDialog.tsx +151 -0
  19. flock/frontend/src/components/common/LoadingSpinner.module.css +97 -0
  20. flock/frontend/src/components/common/LoadingSpinner.tsx +29 -0
  21. flock/frontend/src/components/controls/PublishControl.css +547 -0
  22. flock/frontend/src/components/controls/PublishControl.test.tsx +543 -0
  23. flock/frontend/src/components/controls/PublishControl.tsx +432 -0
  24. flock/frontend/src/components/details/DetailWindowContainer.tsx +62 -0
  25. flock/frontend/src/components/details/LiveOutputTab.test.tsx +792 -0
  26. flock/frontend/src/components/details/LiveOutputTab.tsx +220 -0
  27. flock/frontend/src/components/details/MessageHistoryTab.tsx +299 -0
  28. flock/frontend/src/components/details/NodeDetailWindow.test.tsx +501 -0
  29. flock/frontend/src/components/details/NodeDetailWindow.tsx +218 -0
  30. flock/frontend/src/components/details/RunStatusTab.tsx +307 -0
  31. flock/frontend/src/components/details/tabs.test.tsx +1015 -0
  32. flock/frontend/src/components/filters/CorrelationIDFilter.module.css +102 -0
  33. flock/frontend/src/components/filters/CorrelationIDFilter.test.tsx +197 -0
  34. flock/frontend/src/components/filters/CorrelationIDFilter.tsx +121 -0
  35. flock/frontend/src/components/filters/FilterBar.module.css +29 -0
  36. flock/frontend/src/components/filters/FilterBar.test.tsx +133 -0
  37. flock/frontend/src/components/filters/FilterBar.tsx +33 -0
  38. flock/frontend/src/components/filters/FilterPills.module.css +79 -0
  39. flock/frontend/src/components/filters/FilterPills.test.tsx +173 -0
  40. flock/frontend/src/components/filters/FilterPills.tsx +67 -0
  41. flock/frontend/src/components/filters/TimeRangeFilter.module.css +91 -0
  42. flock/frontend/src/components/filters/TimeRangeFilter.test.tsx +154 -0
  43. flock/frontend/src/components/filters/TimeRangeFilter.tsx +105 -0
  44. flock/frontend/src/components/graph/AgentNode.test.tsx +75 -0
  45. flock/frontend/src/components/graph/AgentNode.tsx +322 -0
  46. flock/frontend/src/components/graph/GraphCanvas.tsx +406 -0
  47. flock/frontend/src/components/graph/MessageFlowEdge.tsx +128 -0
  48. flock/frontend/src/components/graph/MessageNode.test.tsx +62 -0
  49. flock/frontend/src/components/graph/MessageNode.tsx +116 -0
  50. flock/frontend/src/components/graph/MiniMap.tsx +47 -0
  51. flock/frontend/src/components/graph/TransformEdge.tsx +123 -0
  52. flock/frontend/src/components/layout/DashboardLayout.css +407 -0
  53. flock/frontend/src/components/layout/DashboardLayout.tsx +300 -0
  54. flock/frontend/src/components/layout/Header.module.css +88 -0
  55. flock/frontend/src/components/layout/Header.tsx +52 -0
  56. flock/frontend/src/components/modules/EventLogModule.test.tsx +401 -0
  57. flock/frontend/src/components/modules/EventLogModule.tsx +396 -0
  58. flock/frontend/src/components/modules/EventLogModuleWrapper.tsx +17 -0
  59. flock/frontend/src/components/modules/ModuleRegistry.test.ts +333 -0
  60. flock/frontend/src/components/modules/ModuleRegistry.ts +85 -0
  61. flock/frontend/src/components/modules/ModuleWindow.tsx +155 -0
  62. flock/frontend/src/components/modules/registerModules.ts +20 -0
  63. flock/frontend/src/components/settings/AdvancedSettings.tsx +175 -0
  64. flock/frontend/src/components/settings/AppearanceSettings.tsx +185 -0
  65. flock/frontend/src/components/settings/GraphSettings.tsx +110 -0
  66. flock/frontend/src/components/settings/SettingsPanel.css +327 -0
  67. flock/frontend/src/components/settings/SettingsPanel.tsx +131 -0
  68. flock/frontend/src/components/settings/ThemeSelector.tsx +298 -0
  69. flock/frontend/src/hooks/useKeyboardShortcuts.ts +148 -0
  70. flock/frontend/src/hooks/useModulePersistence.test.ts +442 -0
  71. flock/frontend/src/hooks/useModulePersistence.ts +154 -0
  72. flock/frontend/src/hooks/useModules.ts +139 -0
  73. flock/frontend/src/hooks/usePersistence.ts +139 -0
  74. flock/frontend/src/main.tsx +13 -0
  75. flock/frontend/src/services/api.ts +213 -0
  76. flock/frontend/src/services/indexeddb.test.ts +793 -0
  77. flock/frontend/src/services/indexeddb.ts +794 -0
  78. flock/frontend/src/services/layout.test.ts +437 -0
  79. flock/frontend/src/services/layout.ts +146 -0
  80. flock/frontend/src/services/themeApplicator.ts +140 -0
  81. flock/frontend/src/services/themeService.ts +77 -0
  82. flock/frontend/src/services/websocket.test.ts +595 -0
  83. flock/frontend/src/services/websocket.ts +685 -0
  84. flock/frontend/src/store/filterStore.test.ts +242 -0
  85. flock/frontend/src/store/filterStore.ts +103 -0
  86. flock/frontend/src/store/graphStore.test.ts +186 -0
  87. flock/frontend/src/store/graphStore.ts +414 -0
  88. flock/frontend/src/store/moduleStore.test.ts +253 -0
  89. flock/frontend/src/store/moduleStore.ts +57 -0
  90. flock/frontend/src/store/settingsStore.ts +188 -0
  91. flock/frontend/src/store/streamStore.ts +68 -0
  92. flock/frontend/src/store/uiStore.test.ts +54 -0
  93. flock/frontend/src/store/uiStore.ts +110 -0
  94. flock/frontend/src/store/wsStore.ts +34 -0
  95. flock/frontend/src/styles/index.css +15 -0
  96. flock/frontend/src/styles/scrollbar.css +47 -0
  97. flock/frontend/src/styles/variables.css +488 -0
  98. flock/frontend/src/test/setup.ts +1 -0
  99. flock/frontend/src/types/filters.ts +14 -0
  100. flock/frontend/src/types/graph.ts +55 -0
  101. flock/frontend/src/types/modules.ts +7 -0
  102. flock/frontend/src/types/theme.ts +55 -0
  103. flock/frontend/src/utils/mockData.ts +85 -0
  104. flock/frontend/src/utils/performance.ts +16 -0
  105. flock/frontend/src/utils/transforms.test.ts +860 -0
  106. flock/frontend/src/utils/transforms.ts +323 -0
  107. flock/frontend/src/vite-env.d.ts +17 -0
  108. flock/frontend/tsconfig.json +27 -0
  109. flock/frontend/tsconfig.node.json +11 -0
  110. flock/frontend/vite.config.ts +25 -0
  111. flock/frontend/vitest.config.ts +11 -0
  112. {flock_core-0.5.0b50.dist-info → flock_core-0.5.0b51.dist-info}/METADATA +1 -1
  113. {flock_core-0.5.0b50.dist-info → flock_core-0.5.0b51.dist-info}/RECORD +116 -6
  114. {flock_core-0.5.0b50.dist-info → flock_core-0.5.0b51.dist-info}/WHEEL +0 -0
  115. {flock_core-0.5.0b50.dist-info → flock_core-0.5.0b51.dist-info}/entry_points.txt +0 -0
  116. {flock_core-0.5.0b50.dist-info → flock_core-0.5.0b51.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,587 @@
1
+ /**
2
+ * End-to-End Tests for Critical Dashboard Scenarios (Frontend)
3
+ *
4
+ * Tests the 4 critical scenarios from SDD_COMPLETION.md (lines 444-493):
5
+ * 1. End-to-End Agent Execution Visualization (WebSocket → stores → React Flow rendering)
6
+ * 2. WebSocket Reconnection After Backend Restart (client-side resilience)
7
+ * 3. Correlation ID Filtering (autocomplete → filter → graph updates)
8
+ * 4. IndexedDB LRU Eviction (storage quota management with custom mocking)
9
+ *
10
+ * SPECIFICATION: docs/specs/003-real-time-dashboard/SDD_COMPLETION.md Section: Critical Test Scenarios
11
+ *
12
+ * These tests validate the complete frontend flow from WebSocket events through
13
+ * Zustand stores to React Flow graph visualization.
14
+ */
15
+
16
+ import 'fake-indexeddb/auto'; // Polyfills global IndexedDB for tests
17
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
18
+ import { waitFor, act } from '@testing-library/react';
19
+ import { WebSocketClient } from '../../services/websocket';
20
+ import { useGraphStore } from '../../store/graphStore';
21
+ import { useFilterStore } from '../../store/filterStore';
22
+ import { useWSStore } from '../../store/wsStore';
23
+ import { indexedDBService } from '../../services/indexeddb';
24
+ import { CorrelationIdMetadata } from '../../types/filters';
25
+
26
+ // ============================================================================
27
+ // Mock WebSocket Client
28
+ // ============================================================================
29
+
30
+ class MockWebSocket {
31
+ url: string;
32
+ readyState: number;
33
+ onopen: ((event: Event) => void) | null = null;
34
+ onmessage: ((event: MessageEvent) => void) | null = null;
35
+ onerror: ((event: Event) => void) | null = null;
36
+ onclose: ((event: CloseEvent) => void) | null = null;
37
+
38
+ static CONNECTING = 0;
39
+ static OPEN = 1;
40
+ static CLOSING = 2;
41
+ static CLOSED = 3;
42
+
43
+ constructor(url: string) {
44
+ this.url = url;
45
+ this.readyState = MockWebSocket.CONNECTING;
46
+
47
+ // Auto-connect after construction (unless disabled for testing)
48
+ if (MockWebSocket.autoConnect) {
49
+ setTimeout(() => {
50
+ this.readyState = MockWebSocket.OPEN;
51
+ this.onopen?.(new Event('open'));
52
+ }, 0);
53
+ } else {
54
+ // If auto-connect is disabled, simulate connection failure
55
+ setTimeout(() => {
56
+ this.readyState = MockWebSocket.CLOSED;
57
+ // Only fire close event (not error) to avoid setting error status
58
+ // This allows the client to keep retrying with exponential backoff
59
+ this.onclose?.(new CloseEvent('close', { code: 1006, reason: 'Connection failed' }));
60
+ }, 0);
61
+ }
62
+ }
63
+
64
+ send(_data: string) {
65
+ if (this.readyState !== MockWebSocket.OPEN) {
66
+ throw new Error('WebSocket is not open');
67
+ }
68
+ }
69
+
70
+ close() {
71
+ this.readyState = MockWebSocket.CLOSED;
72
+ this.onclose?.(new CloseEvent('close'));
73
+ }
74
+
75
+ // Test helper: simulate receiving message
76
+ simulateMessage(data: any) {
77
+ if (this.readyState === MockWebSocket.OPEN) {
78
+ this.onmessage?.(new MessageEvent('message', { data: JSON.stringify(data) }));
79
+ }
80
+ }
81
+
82
+ // Test helper: simulate connection error
83
+ simulateError() {
84
+ this.onerror?.(new Event('error'));
85
+ }
86
+
87
+ // Test helper: simulate disconnection
88
+ simulateDisconnect() {
89
+ this.readyState = MockWebSocket.CLOSED;
90
+ this.onclose?.(new CloseEvent('close'));
91
+ }
92
+
93
+ // Test helper: simulate reconnection
94
+ simulateReconnect() {
95
+ this.readyState = MockWebSocket.OPEN;
96
+ this.onopen?.(new Event('open'));
97
+ }
98
+
99
+ // Test helper: prevent auto-connection (for testing reconnection retries)
100
+ preventAutoConnect() {
101
+ (this.constructor as any).autoConnect = false;
102
+ }
103
+
104
+ static autoConnect = true;
105
+ }
106
+
107
+ // ============================================================================
108
+ // Mock Storage Quota API for LRU Testing
109
+ // ============================================================================
110
+
111
+ class MockStorageManager {
112
+ private mockUsage: number = 0;
113
+ private mockQuota: number = 50 * 1024 * 1024; // 50MB default quota
114
+ private usageSequence: number[] = [];
115
+ private sequenceIndex: number = 0;
116
+
117
+ async estimate(): Promise<{ usage: number; quota: number }> {
118
+ // Use fixed sequence if configured, otherwise return current usage
119
+ let currentUsage = this.mockUsage;
120
+ if (this.usageSequence.length > 0) {
121
+ currentUsage = this.usageSequence[Math.min(this.sequenceIndex, this.usageSequence.length - 1)]!;
122
+ this.sequenceIndex++;
123
+ }
124
+
125
+ return {
126
+ usage: currentUsage,
127
+ quota: this.mockQuota,
128
+ };
129
+ }
130
+
131
+ // Test helpers
132
+ setUsage(bytes: number) {
133
+ this.mockUsage = bytes;
134
+ this.sequenceIndex = 0;
135
+ }
136
+
137
+ setQuota(bytes: number) {
138
+ this.mockQuota = bytes;
139
+ }
140
+
141
+ // Set a sequence of usage values to return (simulates deletion reducing usage)
142
+ setUsageSequence(sequence: number[]) {
143
+ this.usageSequence = sequence;
144
+ this.sequenceIndex = 0;
145
+ }
146
+
147
+ getUsagePercentage(): number {
148
+ return this.mockQuota > 0 ? this.mockUsage / this.mockQuota : 0;
149
+ }
150
+ }
151
+
152
+ // ============================================================================
153
+ // Test Setup and Fixtures
154
+ // ============================================================================
155
+
156
+ describe('Critical E2E Scenarios (Frontend)', () => {
157
+ let mockWs: any;
158
+ let mockStorageManager: MockStorageManager;
159
+ let wsClient: WebSocketClient;
160
+
161
+ beforeEach(() => {
162
+ // Reset all stores
163
+ const graphStore = useGraphStore.getState();
164
+ graphStore.agents.clear();
165
+ graphStore.messages.clear();
166
+ graphStore.events = [];
167
+ graphStore.runs.clear();
168
+ graphStore.consumptions.clear();
169
+
170
+ const filterStore = useFilterStore.getState();
171
+ filterStore.clearFilters();
172
+ filterStore.updateAvailableCorrelationIds([]);
173
+
174
+ const wsStore = useWSStore.getState();
175
+ wsStore.setStatus('disconnected');
176
+ wsStore.setError(null);
177
+ wsStore.resetAttempts();
178
+
179
+ // Setup mock WebSocket
180
+ (globalThis as any).WebSocket = MockWebSocket;
181
+ MockWebSocket.autoConnect = true; // Reset auto-connect flag
182
+
183
+ // Setup mock storage manager
184
+ mockStorageManager = new MockStorageManager();
185
+ if (!navigator.storage) {
186
+ (navigator as any).storage = {};
187
+ }
188
+ navigator.storage.estimate = mockStorageManager.estimate.bind(mockStorageManager);
189
+ });
190
+
191
+ afterEach(() => {
192
+ // Cleanup
193
+ if (wsClient) {
194
+ wsClient.disconnect();
195
+ }
196
+ vi.clearAllTimers();
197
+ });
198
+
199
+ // ==========================================================================
200
+ // Scenario 1: End-to-End Agent Execution Visualization
201
+ // ==========================================================================
202
+
203
+ describe('Scenario 1: End-to-End Agent Execution Visualization', () => {
204
+ it('should render complete agent execution flow from WebSocket events', async () => {
205
+ /**
206
+ * GIVEN: WebSocket client connected
207
+ * WHEN: Receive agent_activated → message_published → agent_completed sequence
208
+ * THEN: Graph nodes and edges are created correctly
209
+ * AND: Agent status transitions are tracked
210
+ */
211
+
212
+ // Setup: Create WebSocket client
213
+ wsClient = new WebSocketClient('ws://localhost:8000/ws');
214
+ wsClient.connect();
215
+
216
+ await waitFor(() => expect(wsClient.isConnected()).toBe(true));
217
+ mockWs = wsClient.ws as any;
218
+
219
+ // Step 1: Agent activated (raw format for test compatibility)
220
+ await act(async () => {
221
+ mockWs.simulateMessage({
222
+ agent_id: 'test_agent',
223
+ agent_name: 'test_agent',
224
+ run_id: 'run_123',
225
+ consumed_types: ['Input'],
226
+ consumed_artifacts: ['input-1'],
227
+ correlation_id: 'test-correlation-1',
228
+ });
229
+ });
230
+
231
+ await waitFor(() => {
232
+ const agents = useGraphStore.getState().agents;
233
+ expect(agents.has('test_agent')).toBe(true);
234
+ });
235
+
236
+ // Verify agent state
237
+ const agent = useGraphStore.getState().agents.get('test_agent');
238
+ expect(agent?.status).toBe('running');
239
+ expect(agent?.recvCount).toBe(1);
240
+
241
+ // Step 2: Message published (raw format for test compatibility)
242
+ await act(async () => {
243
+ mockWs.simulateMessage({
244
+ artifact_id: 'output-1',
245
+ artifact_type: 'Output',
246
+ produced_by: 'test_agent',
247
+ payload: { result: 'success' },
248
+ correlation_id: 'test-correlation-1',
249
+ });
250
+ });
251
+
252
+ await waitFor(() => {
253
+ const messages = useGraphStore.getState().messages;
254
+ expect(messages.has('output-1')).toBe(true);
255
+ });
256
+
257
+ // Verify message state
258
+ const message = useGraphStore.getState().messages.get('output-1');
259
+ expect(message?.type).toBe('Output');
260
+ expect(message?.producedBy).toBe('test_agent');
261
+
262
+ // Step 3: Agent completed (raw format for test compatibility)
263
+ await act(async () => {
264
+ mockWs.simulateMessage({
265
+ agent_name: 'test_agent',
266
+ run_id: 'run_123',
267
+ duration_ms: 150,
268
+ artifacts_produced: ['output-1'],
269
+ });
270
+ });
271
+
272
+ await waitFor(() => {
273
+ const agent = useGraphStore.getState().agents.get('test_agent');
274
+ expect(agent?.status).toBe('idle');
275
+ });
276
+
277
+ // Verify final state
278
+ const finalState = useGraphStore.getState();
279
+ expect(finalState.agents.size).toBe(1);
280
+ expect(finalState.messages.size).toBe(1);
281
+ expect(finalState.runs.size).toBeGreaterThan(0);
282
+ });
283
+ });
284
+
285
+ // ==========================================================================
286
+ // Scenario 2: WebSocket Reconnection After Backend Restart
287
+ // ==========================================================================
288
+
289
+ describe('Scenario 2: WebSocket Reconnection After Backend Restart', () => {
290
+ it('should handle connection loss and automatic reconnection with exponential backoff', { timeout: 20000 }, async () => {
291
+ /**
292
+ * GIVEN: Active WebSocket connection
293
+ * WHEN: Connection is lost (backend restart simulation)
294
+ * THEN: Client attempts reconnection with exponential backoff (1s, 2s, 4s, 8s)
295
+ * AND: Successfully reconnects when backend is available
296
+ * AND: Reconnect attempts counter is reset to 0
297
+ *
298
+ * APPROACH: Use MockWebSocket with controlled auto-connect behavior (no fake timers).
299
+ * Wait for real time to pass to validate exponential backoff intervals.
300
+ */
301
+
302
+ // Step 1: Connect WebSocket client (using MockWebSocket)
303
+ wsClient = new WebSocketClient('ws://localhost:8000/ws');
304
+ wsClient.connect();
305
+
306
+ await waitFor(() => expect(wsClient.isConnected()).toBe(true), { timeout: 5000 });
307
+ mockWs = wsClient.ws as any;
308
+
309
+ // Step 2: Simulate connection loss
310
+ await act(async () => {
311
+ // Disable auto-connect so reconnection attempts fail
312
+ MockWebSocket.autoConnect = false;
313
+ // Simulate abnormal disconnect (code != 1000 triggers reconnection)
314
+ mockWs.readyState = MockWebSocket.CLOSED;
315
+ mockWs.onclose?.(new CloseEvent('close', { code: 1006, reason: 'Server shutdown' }));
316
+ });
317
+
318
+ // Wait for reconnection status
319
+ await waitFor(() => {
320
+ expect(wsClient.isConnected()).toBe(false);
321
+ expect(useWSStore.getState().status).toBe('reconnecting');
322
+ }, { timeout: 2000 });
323
+
324
+ // Record initial attempt count
325
+ const initialAttempts = useWSStore.getState().reconnectAttempts;
326
+ expect(initialAttempts).toBeGreaterThan(0);
327
+
328
+ // Step 3: Wait for multiple reconnection attempts with exponential backoff
329
+ // Backoff: 1s, 2s, 4s, 8s
330
+ // Total time for 4 attempts: ~15s
331
+ // We'll wait 8s to capture 3-4 attempts (1s + 2s + 4s = 7s + margin)
332
+ await new Promise((resolve) => setTimeout(resolve, 8000));
333
+
334
+ // Verify reconnection attempts increased
335
+ const attemptsAfterBackoff = useWSStore.getState().reconnectAttempts;
336
+ expect(attemptsAfterBackoff).toBeGreaterThanOrEqual(3);
337
+ expect(useWSStore.getState().status).toBe('reconnecting');
338
+
339
+ // Step 4: Re-enable auto-connect to allow successful reconnection
340
+ await act(async () => {
341
+ MockWebSocket.autoConnect = true;
342
+ });
343
+
344
+ // Wait for next reconnection attempt to succeed (max 8s for next backoff)
345
+ await waitFor(() => {
346
+ expect(wsClient.isConnected()).toBe(true);
347
+ expect(useWSStore.getState().status).toBe('connected');
348
+ }, { timeout: 10000 });
349
+
350
+ // Step 5: Verify reconnect counter is reset
351
+ const finalStore = useWSStore.getState();
352
+ expect(finalStore.reconnectAttempts).toBe(0);
353
+
354
+ // Cleanup: Reset auto-connect for other tests
355
+ MockWebSocket.autoConnect = true;
356
+ });
357
+ });
358
+
359
+ // ==========================================================================
360
+ // Scenario 3: Correlation ID Filtering
361
+ // ==========================================================================
362
+
363
+ describe('Scenario 3: Correlation ID Filtering', () => {
364
+ it('should filter graph nodes and edges by selected correlation ID', { timeout: 10000 }, async () => {
365
+ /**
366
+ * GIVEN: Multiple artifacts with different correlation IDs
367
+ * WHEN: User selects a correlation ID filter
368
+ * THEN: Only artifacts/agents with matching correlation ID are visible
369
+ * AND: Graph edges are updated to reflect filtered view
370
+ */
371
+
372
+ // Setup: Connect WebSocket
373
+ wsClient = new WebSocketClient('ws://localhost:8000/ws');
374
+ wsClient.connect();
375
+ await waitFor(() => expect(wsClient.isConnected()).toBe(true));
376
+ mockWs = wsClient.ws as any;
377
+
378
+ // Setup: Receive events with 3 different correlation IDs
379
+ const correlationIds = ['abc-123-xxx', 'abc-456-yyy', 'def-789-zzz'];
380
+
381
+ for (let i = 0; i < correlationIds.length; i++) {
382
+ await act(async () => {
383
+ mockWs.simulateMessage({
384
+ artifact_id: `artifact-${i}`,
385
+ artifact_type: 'TestOutput',
386
+ produced_by: 'test_agent',
387
+ payload: { index: i },
388
+ correlation_id: correlationIds[i],
389
+ timestamp: new Date(Date.now() + i * 1000).toISOString(),
390
+ });
391
+ });
392
+ }
393
+
394
+ // Wait for all messages to be added to the store
395
+ await waitFor(() => {
396
+ const messages = useGraphStore.getState().messages;
397
+ expect(messages.size).toBe(3);
398
+ }, { timeout: 5000 });
399
+
400
+ // Update available correlation IDs
401
+ const metadata: CorrelationIdMetadata[] = correlationIds.map((id, index) => ({
402
+ correlation_id: id,
403
+ first_seen: Date.now() + index * 1000,
404
+ artifact_count: 1,
405
+ run_count: 1,
406
+ }));
407
+ useFilterStore.getState().updateAvailableCorrelationIds(metadata);
408
+
409
+ // Generate graph with all events (use fresh state)
410
+ await act(async () => {
411
+ useGraphStore.getState().generateBlackboardViewGraph();
412
+ });
413
+
414
+ // Wait for nodes to be generated
415
+ await waitFor(() => {
416
+ const nodes = useGraphStore.getState().nodes;
417
+ const visibleNodes = nodes.filter((n) => !n.hidden);
418
+ expect(visibleNodes.length).toBe(3);
419
+ }, { timeout: 5000 });
420
+
421
+ // Apply correlation ID filter (use fresh state)
422
+ await act(async () => {
423
+ useFilterStore.getState().setCorrelationId('abc-123-xxx');
424
+ useGraphStore.getState().applyFilters();
425
+ });
426
+
427
+ // Verify: Only 1 node visible (the one with matching correlation ID)
428
+ await waitFor(() => {
429
+ const filteredNodes = useGraphStore.getState().nodes.filter((n) => !n.hidden);
430
+ expect(filteredNodes.length).toBe(1);
431
+ expect(filteredNodes[0]?.id).toBe('artifact-0');
432
+ }, { timeout: 5000 });
433
+
434
+ // Verify: Edges connected to hidden nodes are also hidden
435
+ const visibleEdges = useGraphStore.getState().edges.filter((e) => !e.hidden);
436
+ // Should be 0 since we don't have transformation edges without runs
437
+ expect(visibleEdges.length).toBe(0);
438
+ });
439
+ });
440
+
441
+ // ==========================================================================
442
+ // Scenario 4: IndexedDB LRU Eviction
443
+ // ==========================================================================
444
+
445
+ describe('Scenario 4: IndexedDB LRU Eviction', () => {
446
+ beforeEach(async () => {
447
+ // Clean up IndexedDB between tests
448
+ if (indexedDBService.db) {
449
+ indexedDBService.db.close();
450
+ indexedDBService.db = null;
451
+ }
452
+ // Delete the database to ensure fresh state
453
+ if (typeof indexedDB !== 'undefined') {
454
+ const deleteRequest = indexedDB.deleteDatabase('flock_dashboard_v1');
455
+ await new Promise<void>((resolve, reject) => {
456
+ deleteRequest.onsuccess = () => resolve();
457
+ deleteRequest.onerror = () => reject(deleteRequest.error);
458
+ deleteRequest.onblocked = () => resolve(); // Continue even if blocked
459
+ });
460
+ }
461
+ });
462
+
463
+ it('should evict oldest sessions when storage quota exceeds 80% threshold', async () => {
464
+ /**
465
+ * GIVEN: Storage usage at 84% (above 80% threshold)
466
+ * WHEN: LRU eviction is triggered
467
+ * THEN: Oldest sessions are evicted until usage drops to 60% target
468
+ * AND: Most recent sessions are preserved
469
+ * AND: Current session data is preserved
470
+ */
471
+
472
+ const quota = 50 * 1024 * 1024; // 50MB
473
+ mockStorageManager.setQuota(quota);
474
+
475
+ // Setup: Configure usage sequence to simulate eviction progress
476
+ // Start at 84% (42MB), after deleting 2 sessions reach 60% (30MB)
477
+ const initialUsage = quota * 0.84; // 42MB
478
+ const afterDelete1 = quota * 0.72; // 36MB (after deleting 1 session)
479
+ const afterDelete2 = quota * 0.60; // 30MB (after deleting 2 sessions - target reached)
480
+
481
+ mockStorageManager.setUsageSequence([
482
+ initialUsage, // First loop iteration: 84% > 60%, delete session-0
483
+ afterDelete1, // Second loop iteration: 72% > 60%, delete session-1
484
+ afterDelete2, // Third loop iteration: 60% <= 60%, BREAK (should not delete)
485
+ afterDelete2, // Any additional calls stay at 60%
486
+ afterDelete2,
487
+ afterDelete2,
488
+ afterDelete2,
489
+ afterDelete2,
490
+ ]);
491
+
492
+ // Initialize IndexedDB service for testing
493
+ await indexedDBService.initialize();
494
+
495
+ // Create 5 sessions with different timestamps (oldest first)
496
+ for (let i = 0; i < 5; i++) {
497
+ const sessionId = `session-${i}`;
498
+ const timestamp = new Date(Date.now() - (5 - i) * 60000).toISOString(); // Each 1 minute apart
499
+
500
+ // Store in IndexedDB
501
+ await indexedDBService.saveSession({
502
+ session_id: sessionId,
503
+ created_at: timestamp,
504
+ last_activity: timestamp,
505
+ artifact_count: 0,
506
+ run_count: 0,
507
+ size_estimate_bytes: 6 * 1024 * 1024, // 6MB per session
508
+ });
509
+ }
510
+
511
+ // Verify sessions were saved
512
+ const savedSessions = await indexedDBService.getAllSessions();
513
+ console.log(`[Test] Saved ${savedSessions.length} sessions before eviction`);
514
+ expect(savedSessions.length).toBe(5); // Verify all 5 sessions were saved
515
+
516
+ // Trigger eviction
517
+ await act(async () => {
518
+ await indexedDBService.evictOldSessions();
519
+ });
520
+
521
+ // Verify: Old sessions were evicted
522
+ const remainingSessions = await indexedDBService.getAllSessions();
523
+ console.log(`[Test] ${remainingSessions.length} sessions remaining after eviction`);
524
+
525
+ // Should have evicted 2 oldest sessions (session-0, session-1), keeping 3 (session-2, session-3, session-4)
526
+ expect(remainingSessions.length).toBe(3);
527
+
528
+ // Verify: Most recent sessions are preserved
529
+ const remainingIds = remainingSessions.map((s: any) => s.session_id);
530
+
531
+ // Most recent session should be preserved
532
+ expect(remainingIds).toContain('session-4');
533
+ expect(remainingIds).toContain('session-3');
534
+ expect(remainingIds).toContain('session-2');
535
+
536
+ // Oldest sessions should be gone
537
+ expect(remainingIds).not.toContain('session-0');
538
+ expect(remainingIds).not.toContain('session-1');
539
+
540
+ console.log(`[LRU] Evicted ${5 - remainingSessions.length} oldest sessions`);
541
+ console.log(`[LRU] Preserved sessions: ${remainingIds.join(', ')}`);
542
+ });
543
+
544
+ it('should preserve current session and most recent 10 sessions during eviction', async () => {
545
+ const quota = 50 * 1024 * 1024;
546
+ mockStorageManager.setQuota(quota);
547
+
548
+ const initialUsage = quota * 0.85; // 42.5MB
549
+
550
+ // Configure usage sequence: Start at 85%, delete 5 sessions to reach 60%
551
+ const usageSequence = [
552
+ initialUsage, // First iteration: 85% > 60%, delete session-0
553
+ quota * 0.80, // Second iteration: 80% > 60%, delete session-1
554
+ quota * 0.75, // Third iteration: 75% > 60%, delete session-2
555
+ quota * 0.70, // Fourth iteration: 70% > 60%, delete session-3
556
+ quota * 0.65, // Fifth iteration: 65% > 60%, delete session-4
557
+ quota * 0.60, // Sixth iteration: 60% <= 60%, BREAK
558
+ quota * 0.60, // Stay at target
559
+ ];
560
+ mockStorageManager.setUsageSequence(usageSequence);
561
+
562
+ // Initialize IndexedDB service for testing
563
+ await indexedDBService.initialize();
564
+
565
+ // Create 15 sessions
566
+ for (let i = 0; i < 15; i++) {
567
+ const timestamp = new Date(Date.now() - (15 - i) * 60000).toISOString();
568
+ await indexedDBService.saveSession({
569
+ session_id: `session-${i}`,
570
+ created_at: timestamp,
571
+ last_activity: timestamp,
572
+ artifact_count: 0,
573
+ run_count: 0,
574
+ size_estimate_bytes: 3 * 1024 * 1024, // 3MB per session
575
+ });
576
+ }
577
+
578
+ // Evict
579
+ await indexedDBService.evictOldSessions();
580
+
581
+ const sessions = await indexedDBService.getAllSessions();
582
+
583
+ // Should have deleted 5 oldest sessions, keeping 10 most recent
584
+ expect(sessions.length).toBe(10);
585
+ });
586
+ });
587
+ });