@rozenite/network-activity-plugin 1.10.0 → 1.11.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 +17 -0
- package/dist/devtools/App.html +2 -2
- package/dist/devtools/assets/{App-DsimzJvx.js → App-CEESZAW_.js} +985 -259
- package/dist/devtools/assets/{App-CUXU0mup.css → App-xppYUJvX.css} +94 -0
- package/dist/rozenite.json +1 -1
- package/package.json +6 -6
- package/src/ui/components/FilterBar.tsx +13 -45
- package/src/ui/components/NetworkTimeline.tsx +422 -0
- package/src/ui/components/RequestList.tsx +6 -185
- package/src/ui/components/Toolbar.tsx +13 -1
- package/src/ui/hooks/useNetworkActivitySessionExport.ts +39 -0
- package/src/ui/state/__tests__/store.test.ts +77 -0
- package/src/ui/state/derived.ts +2 -0
- package/src/ui/state/filter.ts +49 -0
- package/src/ui/state/hooks.ts +2 -2
- package/src/ui/state/model.ts +1 -0
- package/src/ui/state/store.ts +24 -2
- package/src/ui/utils/__tests__/requestFilters.test.ts +32 -0
- package/src/ui/utils/__tests__/sessionExport.test.ts +174 -0
- package/src/ui/utils/__tests__/timelineModel.test.ts +170 -0
- package/src/ui/utils/download.ts +7 -0
- package/src/ui/utils/requestFilters.ts +183 -0
- package/src/ui/utils/sessionExport.ts +185 -0
- package/src/ui/utils/timelineModel.ts +352 -0
- package/src/ui/views/InspectorView.tsx +40 -8
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { Button } from './Button';
|
|
2
|
-
import { Circle, Square, Trash2 } from 'lucide-react';
|
|
2
|
+
import { Circle, Download, Square, Trash2 } from 'lucide-react';
|
|
3
3
|
import { useIsRecording, useNetworkActivityActions } from '../state/hooks';
|
|
4
|
+
import { useNetworkActivitySessionExport } from '../hooks/useNetworkActivitySessionExport';
|
|
4
5
|
|
|
5
6
|
export const Toolbar = () => {
|
|
6
7
|
const actions = useNetworkActivityActions();
|
|
7
8
|
const isRecording = useIsRecording();
|
|
9
|
+
const { canExportSession, exportSession } = useNetworkActivitySessionExport();
|
|
8
10
|
|
|
9
11
|
const onToggleRecording = (): void => {
|
|
10
12
|
actions.setRecording(!isRecording);
|
|
@@ -41,6 +43,16 @@ export const Toolbar = () => {
|
|
|
41
43
|
>
|
|
42
44
|
<Trash2 className="h-4 w-4" />
|
|
43
45
|
</Button>
|
|
46
|
+
<Button
|
|
47
|
+
variant="ghost"
|
|
48
|
+
size="sm"
|
|
49
|
+
onClick={exportSession}
|
|
50
|
+
disabled={!canExportSession}
|
|
51
|
+
className="ml-auto h-8 w-8 p-0 text-gray-400 hover:text-blue-400"
|
|
52
|
+
title="Export session"
|
|
53
|
+
>
|
|
54
|
+
<Download className="h-4 w-4" />
|
|
55
|
+
</Button>
|
|
44
56
|
</div>
|
|
45
57
|
);
|
|
46
58
|
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useCallback } from 'react';
|
|
2
|
+
import { useNetworkActivityStore } from '../state/hooks';
|
|
3
|
+
import { store } from '../state/store';
|
|
4
|
+
import { downloadJson } from '../utils/download';
|
|
5
|
+
import {
|
|
6
|
+
createNetworkActivitySessionExport,
|
|
7
|
+
getNetworkActivitySessionExportFileName,
|
|
8
|
+
} from '../utils/sessionExport';
|
|
9
|
+
|
|
10
|
+
export const useNetworkActivitySessionExport = () => {
|
|
11
|
+
const canExportSession = useNetworkActivityStore(
|
|
12
|
+
(state) => state.networkEntries.size > 0,
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const exportSession = useCallback(() => {
|
|
16
|
+
const { networkEntries, websocketMessages } = store.getState();
|
|
17
|
+
|
|
18
|
+
if (networkEntries.size === 0) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const exportedAt = new Date();
|
|
23
|
+
const exportData = createNetworkActivitySessionExport(
|
|
24
|
+
networkEntries,
|
|
25
|
+
websocketMessages,
|
|
26
|
+
exportedAt,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
downloadJson(
|
|
30
|
+
exportData,
|
|
31
|
+
getNetworkActivitySessionExportFileName(exportedAt),
|
|
32
|
+
);
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
canExportSession,
|
|
37
|
+
exportSession,
|
|
38
|
+
};
|
|
39
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { createNetworkActivityStore as createStoreType } from '../store';
|
|
3
|
+
|
|
4
|
+
let createNetworkActivityStore: typeof createStoreType;
|
|
5
|
+
|
|
6
|
+
beforeAll(async () => {
|
|
7
|
+
const storage = new Map<string, string>();
|
|
8
|
+
|
|
9
|
+
vi.stubGlobal('localStorage', {
|
|
10
|
+
getItem: (key: string) => storage.get(key) ?? null,
|
|
11
|
+
setItem: (key: string, value: string) => {
|
|
12
|
+
storage.set(key, value);
|
|
13
|
+
},
|
|
14
|
+
removeItem: (key: string) => {
|
|
15
|
+
storage.delete(key);
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
({ createNetworkActivityStore } = await import('../store'));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('network activity store', () => {
|
|
23
|
+
it('records elapsed duration for failed HTTP requests', () => {
|
|
24
|
+
const store = createNetworkActivityStore();
|
|
25
|
+
|
|
26
|
+
store.getState().handleEvent('request-sent', {
|
|
27
|
+
requestId: 'request-1',
|
|
28
|
+
timestamp: 100,
|
|
29
|
+
request: {
|
|
30
|
+
url: 'https://example.com/api',
|
|
31
|
+
method: 'GET',
|
|
32
|
+
headers: {},
|
|
33
|
+
},
|
|
34
|
+
initiator: {
|
|
35
|
+
type: 'script',
|
|
36
|
+
},
|
|
37
|
+
type: 'Fetch',
|
|
38
|
+
});
|
|
39
|
+
store.getState().handleEvent('request-failed', {
|
|
40
|
+
requestId: 'request-1',
|
|
41
|
+
timestamp: 250,
|
|
42
|
+
type: 'Fetch',
|
|
43
|
+
error: 'Network request failed',
|
|
44
|
+
canceled: false,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(store.getState().networkEntries.get('request-1')).toMatchObject({
|
|
48
|
+
status: 'failed',
|
|
49
|
+
duration: 150,
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('records elapsed duration for websocket errors', () => {
|
|
54
|
+
const store = createNetworkActivityStore();
|
|
55
|
+
|
|
56
|
+
store.getState().handleEvent('websocket-connect', {
|
|
57
|
+
type: 'websocket-connect',
|
|
58
|
+
url: 'wss://example.com/socket',
|
|
59
|
+
socketId: 'socket-1',
|
|
60
|
+
timestamp: 100,
|
|
61
|
+
protocols: null,
|
|
62
|
+
options: [],
|
|
63
|
+
});
|
|
64
|
+
store.getState().handleEvent('websocket-error', {
|
|
65
|
+
type: 'websocket-error',
|
|
66
|
+
url: 'wss://example.com/socket',
|
|
67
|
+
socketId: 'socket-1',
|
|
68
|
+
timestamp: 175,
|
|
69
|
+
error: 'Socket failed',
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(store.getState().networkEntries.get('ws-socket-1')).toMatchObject({
|
|
73
|
+
status: 'error',
|
|
74
|
+
duration: 75,
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
package/src/ui/state/derived.ts
CHANGED
|
@@ -27,6 +27,7 @@ export const getProcessedRequests = memoize((state: NetworkActivityState) => {
|
|
|
27
27
|
method: httpEntry.request.method,
|
|
28
28
|
httpStatus: httpEntry.response?.status,
|
|
29
29
|
contentType: httpEntry.response?.contentType,
|
|
30
|
+
ttfb: httpEntry.ttfb,
|
|
30
31
|
progress: httpEntry.progress,
|
|
31
32
|
});
|
|
32
33
|
} else if (entry.type === 'websocket') {
|
|
@@ -95,6 +96,7 @@ export const getRequestSummary = (
|
|
|
95
96
|
method: httpEntry.request.method,
|
|
96
97
|
httpStatus: httpEntry.response?.status || 0,
|
|
97
98
|
contentType: httpEntry.response?.contentType,
|
|
99
|
+
ttfb: httpEntry.ttfb,
|
|
98
100
|
progress: httpEntry.progress,
|
|
99
101
|
};
|
|
100
102
|
} else if (entry.type === 'websocket') {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { HttpMethod, NetworkEventSource } from '../../shared/client';
|
|
2
|
+
|
|
3
|
+
export type RequestTypeFilter = 'http' | 'websocket' | 'sse';
|
|
4
|
+
|
|
5
|
+
export type AdvancedFilterState = {
|
|
6
|
+
methods: Set<HttpMethod>;
|
|
7
|
+
sources: Set<NetworkEventSource>;
|
|
8
|
+
status: string;
|
|
9
|
+
domain: string;
|
|
10
|
+
contentType: string;
|
|
11
|
+
failedOnly: boolean;
|
|
12
|
+
inFlightOnly: boolean;
|
|
13
|
+
overriddenOnly: boolean;
|
|
14
|
+
minSize: string;
|
|
15
|
+
maxSize: string;
|
|
16
|
+
minDuration: string;
|
|
17
|
+
maxDuration: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type FilterState = {
|
|
21
|
+
text: string;
|
|
22
|
+
types: Set<RequestTypeFilter>;
|
|
23
|
+
advanced: AdvancedFilterState;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_REQUEST_TYPES: RequestTypeFilter[] = [
|
|
27
|
+
'http',
|
|
28
|
+
'websocket',
|
|
29
|
+
'sse',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export const createDefaultFilter = (): FilterState => ({
|
|
33
|
+
text: '',
|
|
34
|
+
types: new Set(DEFAULT_REQUEST_TYPES),
|
|
35
|
+
advanced: {
|
|
36
|
+
methods: new Set(),
|
|
37
|
+
sources: new Set(),
|
|
38
|
+
status: '',
|
|
39
|
+
domain: '',
|
|
40
|
+
contentType: '',
|
|
41
|
+
failedOnly: false,
|
|
42
|
+
inFlightOnly: false,
|
|
43
|
+
overriddenOnly: false,
|
|
44
|
+
minSize: '',
|
|
45
|
+
maxSize: '',
|
|
46
|
+
minDuration: '',
|
|
47
|
+
maxDuration: '',
|
|
48
|
+
},
|
|
49
|
+
});
|
package/src/ui/state/hooks.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { NetworkActivityState } from './store';
|
|
|
4
4
|
import { getProcessedRequests, getSelectedRequest } from './derived';
|
|
5
5
|
|
|
6
6
|
export const useNetworkActivityStore = <T>(
|
|
7
|
-
selector: (state: NetworkActivityState) => T
|
|
7
|
+
selector: (state: NetworkActivityState) => T,
|
|
8
8
|
): T => {
|
|
9
9
|
return useStore(store, selector);
|
|
10
10
|
};
|
|
@@ -39,7 +39,7 @@ export const useNetworkActivityClientManagement = () => {
|
|
|
39
39
|
|
|
40
40
|
export const useWebSocketMessages = (requestId: string) => {
|
|
41
41
|
return useNetworkActivityStore(
|
|
42
|
-
(state) => state.websocketMessages.get(requestId) || []
|
|
42
|
+
(state) => state.websocketMessages.get(requestId) || [],
|
|
43
43
|
);
|
|
44
44
|
};
|
|
45
45
|
|
package/src/ui/state/model.ts
CHANGED
package/src/ui/state/store.ts
CHANGED
|
@@ -26,6 +26,10 @@ const MAX_SSE_MESSAGES_PER_CONNECTION = 32;
|
|
|
26
26
|
|
|
27
27
|
const STORE_VERSION = 1;
|
|
28
28
|
|
|
29
|
+
const getElapsedDuration = (endTimestamp: number, startTimestamp: number) => {
|
|
30
|
+
return Math.max(endTimestamp - startTimestamp, 0);
|
|
31
|
+
};
|
|
32
|
+
|
|
29
33
|
export interface NetworkActivityState {
|
|
30
34
|
// State
|
|
31
35
|
isRecording: boolean;
|
|
@@ -302,6 +306,10 @@ export const createNetworkActivityStore = () =>
|
|
|
302
306
|
const updatedEntry: HttpNetworkEntry = {
|
|
303
307
|
...httpEntry,
|
|
304
308
|
status: 'failed',
|
|
309
|
+
duration: getElapsedDuration(
|
|
310
|
+
eventData.timestamp,
|
|
311
|
+
httpEntry.timestamp,
|
|
312
|
+
),
|
|
305
313
|
error: eventData.error,
|
|
306
314
|
};
|
|
307
315
|
|
|
@@ -414,7 +422,10 @@ export const createNetworkActivityStore = () =>
|
|
|
414
422
|
status: 'closed',
|
|
415
423
|
closeCode: eventData.code,
|
|
416
424
|
closeReason: eventData.reason,
|
|
417
|
-
duration:
|
|
425
|
+
duration: getElapsedDuration(
|
|
426
|
+
eventData.timestamp,
|
|
427
|
+
wsEntry.timestamp,
|
|
428
|
+
),
|
|
418
429
|
};
|
|
419
430
|
|
|
420
431
|
const newEntries = new Map(state.networkEntries);
|
|
@@ -495,6 +506,10 @@ export const createNetworkActivityStore = () =>
|
|
|
495
506
|
const updatedEntry: WebSocketNetworkEntry = {
|
|
496
507
|
...wsEntry,
|
|
497
508
|
status: 'error',
|
|
509
|
+
duration: getElapsedDuration(
|
|
510
|
+
eventData.timestamp,
|
|
511
|
+
wsEntry.timestamp,
|
|
512
|
+
),
|
|
498
513
|
error: eventData.error,
|
|
499
514
|
};
|
|
500
515
|
|
|
@@ -591,6 +606,10 @@ export const createNetworkActivityStore = () =>
|
|
|
591
606
|
const updatedEntry: SSENetworkEntry = {
|
|
592
607
|
...sseEntry,
|
|
593
608
|
status: 'error',
|
|
609
|
+
duration: getElapsedDuration(
|
|
610
|
+
eventData.timestamp,
|
|
611
|
+
sseEntry.timestamp,
|
|
612
|
+
),
|
|
594
613
|
error: eventData.error.message,
|
|
595
614
|
};
|
|
596
615
|
|
|
@@ -611,7 +630,10 @@ export const createNetworkActivityStore = () =>
|
|
|
611
630
|
const updatedEntry: SSENetworkEntry = {
|
|
612
631
|
...sseEntry,
|
|
613
632
|
status: 'closed',
|
|
614
|
-
duration:
|
|
633
|
+
duration: getElapsedDuration(
|
|
634
|
+
eventData.timestamp,
|
|
635
|
+
sseEntry.timestamp,
|
|
636
|
+
),
|
|
615
637
|
};
|
|
616
638
|
|
|
617
639
|
const newEntries = new Map(state.networkEntries);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createDefaultFilter } from '../../state/filter';
|
|
3
|
+
import type { FilterState } from '../../state/filter';
|
|
4
|
+
import type { ProcessedRequest } from '../../state/model';
|
|
5
|
+
import { matchesRequestFilter } from '../requestFilters';
|
|
6
|
+
|
|
7
|
+
const allTypesFilter = (text: string): FilterState => ({
|
|
8
|
+
...createDefaultFilter(),
|
|
9
|
+
text,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const request: ProcessedRequest = {
|
|
13
|
+
id: 'request-1',
|
|
14
|
+
type: 'http',
|
|
15
|
+
name: 'https://example.com/users',
|
|
16
|
+
status: 'finished',
|
|
17
|
+
timestamp: 0,
|
|
18
|
+
duration: 100,
|
|
19
|
+
size: null,
|
|
20
|
+
method: 'GET',
|
|
21
|
+
httpStatus: 404,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe('matchesRequestFilter', () => {
|
|
25
|
+
it('matches HTTP status codes', () => {
|
|
26
|
+
expect(matchesRequestFilter(request, allTypesFilter('404'))).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('ignores whitespace-only text filters', () => {
|
|
30
|
+
expect(matchesRequestFilter(request, allTypesFilter(' '))).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type {
|
|
3
|
+
HttpNetworkEntry,
|
|
4
|
+
NetworkEntry,
|
|
5
|
+
RequestId,
|
|
6
|
+
SSENetworkEntry,
|
|
7
|
+
WebSocketMessage,
|
|
8
|
+
WebSocketNetworkEntry,
|
|
9
|
+
} from '../../state/model';
|
|
10
|
+
import {
|
|
11
|
+
createNetworkActivitySessionExport,
|
|
12
|
+
getNetworkActivitySessionExportFileName,
|
|
13
|
+
} from '../sessionExport';
|
|
14
|
+
|
|
15
|
+
const httpEntry: HttpNetworkEntry = {
|
|
16
|
+
id: 'request-1',
|
|
17
|
+
type: 'http',
|
|
18
|
+
timestamp: 100,
|
|
19
|
+
duration: 50,
|
|
20
|
+
source: 'builtin',
|
|
21
|
+
request: {
|
|
22
|
+
url: 'https://example.com/api',
|
|
23
|
+
method: 'GET',
|
|
24
|
+
headers: {
|
|
25
|
+
accept: 'application/json',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
response: {
|
|
29
|
+
url: 'https://example.com/api',
|
|
30
|
+
status: 200,
|
|
31
|
+
statusText: 'OK',
|
|
32
|
+
headers: {
|
|
33
|
+
'content-type': 'application/json',
|
|
34
|
+
},
|
|
35
|
+
contentType: 'application/json',
|
|
36
|
+
size: 17,
|
|
37
|
+
responseTime: 150,
|
|
38
|
+
body: {
|
|
39
|
+
type: 'application/json',
|
|
40
|
+
data: '{"ok":true}',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
status: 'finished',
|
|
44
|
+
ttfb: 20,
|
|
45
|
+
size: 17,
|
|
46
|
+
resourceType: 'Fetch',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const websocketEntry: WebSocketNetworkEntry = {
|
|
50
|
+
id: 'ws-socket-1',
|
|
51
|
+
type: 'websocket',
|
|
52
|
+
timestamp: 200,
|
|
53
|
+
duration: 100,
|
|
54
|
+
source: 'builtin',
|
|
55
|
+
connection: {
|
|
56
|
+
url: 'wss://example.com/socket',
|
|
57
|
+
socketId: 'socket-1',
|
|
58
|
+
protocols: ['chat'],
|
|
59
|
+
options: [],
|
|
60
|
+
},
|
|
61
|
+
status: 'closed',
|
|
62
|
+
closeCode: 1000,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const sseEntry: SSENetworkEntry = {
|
|
66
|
+
id: 'request-sse',
|
|
67
|
+
type: 'sse',
|
|
68
|
+
timestamp: 300,
|
|
69
|
+
duration: 200,
|
|
70
|
+
source: 'builtin',
|
|
71
|
+
request: {
|
|
72
|
+
url: 'https://example.com/events',
|
|
73
|
+
method: 'GET',
|
|
74
|
+
headers: {},
|
|
75
|
+
},
|
|
76
|
+
response: {
|
|
77
|
+
url: 'https://example.com/events',
|
|
78
|
+
status: 200,
|
|
79
|
+
statusText: 'OK',
|
|
80
|
+
headers: {
|
|
81
|
+
'content-type': 'text/event-stream',
|
|
82
|
+
},
|
|
83
|
+
contentType: 'text/event-stream',
|
|
84
|
+
size: 0,
|
|
85
|
+
responseTime: 310,
|
|
86
|
+
},
|
|
87
|
+
status: 'closed',
|
|
88
|
+
messages: [
|
|
89
|
+
{
|
|
90
|
+
id: 'sse-message-1',
|
|
91
|
+
type: 'message',
|
|
92
|
+
data: 'hello',
|
|
93
|
+
timestamp: 320,
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const websocketMessages: WebSocketMessage[] = [
|
|
99
|
+
{
|
|
100
|
+
id: 'websocket-message-1',
|
|
101
|
+
direction: 'sent',
|
|
102
|
+
data: 'ping',
|
|
103
|
+
messageType: 'text',
|
|
104
|
+
timestamp: 210,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: 'websocket-message-2',
|
|
108
|
+
direction: 'received',
|
|
109
|
+
data: 'pong',
|
|
110
|
+
messageType: 'text',
|
|
111
|
+
timestamp: 220,
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
describe('sessionExport', () => {
|
|
116
|
+
it('exports captured HTTP and realtime session entries', () => {
|
|
117
|
+
const networkEntries = new Map<RequestId, NetworkEntry>([
|
|
118
|
+
[sseEntry.id, sseEntry],
|
|
119
|
+
[httpEntry.id, httpEntry],
|
|
120
|
+
[websocketEntry.id, websocketEntry],
|
|
121
|
+
]);
|
|
122
|
+
const exportData = createNetworkActivitySessionExport(
|
|
123
|
+
networkEntries,
|
|
124
|
+
new Map([[websocketEntry.id, websocketMessages]]),
|
|
125
|
+
new Date('2026-05-14T10:00:00.000Z'),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(exportData).toMatchObject({
|
|
129
|
+
schemaVersion: 1,
|
|
130
|
+
tool: 'rozenite-network-activity',
|
|
131
|
+
exportedAt: '2026-05-14T10:00:00.000Z',
|
|
132
|
+
summary: {
|
|
133
|
+
totalEntries: 3,
|
|
134
|
+
httpRequests: 1,
|
|
135
|
+
webSocketConnections: 1,
|
|
136
|
+
sseConnections: 1,
|
|
137
|
+
realtimeMessages: 3,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
expect(exportData.entries.map((entry) => entry.id)).toEqual([
|
|
141
|
+
httpEntry.id,
|
|
142
|
+
websocketEntry.id,
|
|
143
|
+
sseEntry.id,
|
|
144
|
+
]);
|
|
145
|
+
expect(exportData.entries[0]).toMatchObject({
|
|
146
|
+
type: 'http',
|
|
147
|
+
request: {
|
|
148
|
+
url: 'https://example.com/api',
|
|
149
|
+
},
|
|
150
|
+
response: {
|
|
151
|
+
status: 200,
|
|
152
|
+
body: {
|
|
153
|
+
data: '{"ok":true}',
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
expect(exportData.entries[1]).toMatchObject({
|
|
158
|
+
type: 'websocket',
|
|
159
|
+
messages: websocketMessages,
|
|
160
|
+
});
|
|
161
|
+
expect(exportData.entries[2]).toMatchObject({
|
|
162
|
+
type: 'sse',
|
|
163
|
+
messages: sseEntry.messages,
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('creates filesystem-friendly export filenames', () => {
|
|
168
|
+
expect(
|
|
169
|
+
getNetworkActivitySessionExportFileName(
|
|
170
|
+
new Date('2026-05-14T10:00:00.123Z'),
|
|
171
|
+
),
|
|
172
|
+
).toBe('rozenite-network-session-2026-05-14T10-00-00Z.json');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { ProcessedRequest } from '../../state/model';
|
|
3
|
+
import {
|
|
4
|
+
formatTimelineOffset,
|
|
5
|
+
getTimelineModel,
|
|
6
|
+
getTimelineRequestEndTime,
|
|
7
|
+
getTimelineTicks,
|
|
8
|
+
isRequestActive,
|
|
9
|
+
requestOverlapsTimelineRange,
|
|
10
|
+
TIMELINE_LAYOUT,
|
|
11
|
+
} from '../timelineModel';
|
|
12
|
+
|
|
13
|
+
const createRequest = (
|
|
14
|
+
overrides: Partial<ProcessedRequest> = {},
|
|
15
|
+
): ProcessedRequest => ({
|
|
16
|
+
id: 'request-1',
|
|
17
|
+
type: 'http',
|
|
18
|
+
name: 'https://example.com/api',
|
|
19
|
+
status: 'finished',
|
|
20
|
+
timestamp: 0,
|
|
21
|
+
duration: 100,
|
|
22
|
+
size: null,
|
|
23
|
+
method: 'GET',
|
|
24
|
+
...overrides,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('timelineModel', () => {
|
|
28
|
+
it('formats minute offsets without rolling seconds up to 60', () => {
|
|
29
|
+
expect(formatTimelineOffset(119_900)).toBe('2m 00s');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('treats websocket closing state as active', () => {
|
|
33
|
+
expect(
|
|
34
|
+
isRequestActive(
|
|
35
|
+
createRequest({
|
|
36
|
+
type: 'websocket',
|
|
37
|
+
status: 'closing',
|
|
38
|
+
method: 'WS',
|
|
39
|
+
}),
|
|
40
|
+
),
|
|
41
|
+
).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('uses the earliest ending lane when all lanes are occupied', () => {
|
|
45
|
+
const requests = Array.from(
|
|
46
|
+
{ length: TIMELINE_LAYOUT.laneCount },
|
|
47
|
+
(_, index) =>
|
|
48
|
+
createRequest({
|
|
49
|
+
id: `request-${index}`,
|
|
50
|
+
timestamp: 0,
|
|
51
|
+
duration: index === 1 ? 100 : 1000,
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const overflowingRequest = createRequest({
|
|
56
|
+
id: 'overflowing-request',
|
|
57
|
+
timestamp: 50,
|
|
58
|
+
duration: 200,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const model = getTimelineModel([...requests, overflowingRequest], 0);
|
|
62
|
+
const overflowingRow = model.rows.find(
|
|
63
|
+
(row) => row.request.id === overflowingRequest.id,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(overflowingRow?.lane).toBe(1);
|
|
67
|
+
expect(overflowingRow?.isOverflowingLane).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('keeps long-session tick counts near the target count', () => {
|
|
71
|
+
const oneHour = 60 * 60 * 1000;
|
|
72
|
+
const model = getTimelineModel(
|
|
73
|
+
[
|
|
74
|
+
createRequest({
|
|
75
|
+
duration: oneHour,
|
|
76
|
+
}),
|
|
77
|
+
],
|
|
78
|
+
0,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(model.ticks.length).toBeLessThanOrEqual(
|
|
82
|
+
TIMELINE_LAYOUT.tickTargetCount + 2,
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('does not add a duplicate final tick label', () => {
|
|
87
|
+
const ticks = getTimelineTicks(1548, {
|
|
88
|
+
...TIMELINE_LAYOUT,
|
|
89
|
+
tickTargetCount: 7,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(ticks.map((tick) => tick.label)).toEqual([
|
|
93
|
+
'0 ms',
|
|
94
|
+
'250 ms',
|
|
95
|
+
'500 ms',
|
|
96
|
+
'750 ms',
|
|
97
|
+
'1.0 s',
|
|
98
|
+
'1.3 s',
|
|
99
|
+
'1.5 s',
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('caps rendered rows for large recordings', () => {
|
|
104
|
+
const maxRenderedRequests = 3;
|
|
105
|
+
const model = getTimelineModel(
|
|
106
|
+
Array.from({ length: 5 }, (_, index) =>
|
|
107
|
+
createRequest({
|
|
108
|
+
id: `request-${index}`,
|
|
109
|
+
timestamp: index,
|
|
110
|
+
}),
|
|
111
|
+
),
|
|
112
|
+
0,
|
|
113
|
+
{
|
|
114
|
+
...TIMELINE_LAYOUT,
|
|
115
|
+
maxRenderedRequests,
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
expect(model.rows.map((row) => row.request.id)).toEqual([
|
|
120
|
+
'request-2',
|
|
121
|
+
'request-3',
|
|
122
|
+
'request-4',
|
|
123
|
+
]);
|
|
124
|
+
expect(model.totalRequestCount).toBe(5);
|
|
125
|
+
expect(model.hiddenRequestCount).toBe(2);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('caps websocket and SSE duration in the timeline model', () => {
|
|
129
|
+
const now = 60_000;
|
|
130
|
+
const websocketRequest = createRequest({
|
|
131
|
+
type: 'websocket',
|
|
132
|
+
status: 'open',
|
|
133
|
+
method: 'WS',
|
|
134
|
+
timestamp: 0,
|
|
135
|
+
duration: undefined,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(getTimelineRequestEndTime(websocketRequest, now)).toBe(
|
|
139
|
+
TIMELINE_LAYOUT.streamingRequestMaxDurationMs,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const model = getTimelineModel([websocketRequest], now);
|
|
143
|
+
|
|
144
|
+
expect(model.rows[0].duration).toBe(
|
|
145
|
+
TIMELINE_LAYOUT.streamingRequestMaxDurationMs,
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('matches requests that overlap a selected timeline range', () => {
|
|
150
|
+
const request = createRequest({
|
|
151
|
+
timestamp: 1000,
|
|
152
|
+
duration: 400,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(
|
|
156
|
+
requestOverlapsTimelineRange(
|
|
157
|
+
request,
|
|
158
|
+
{ startTime: 1200, endTime: 1400 },
|
|
159
|
+
0,
|
|
160
|
+
),
|
|
161
|
+
).toBe(true);
|
|
162
|
+
expect(
|
|
163
|
+
requestOverlapsTimelineRange(
|
|
164
|
+
request,
|
|
165
|
+
{ startTime: 1500, endTime: 1600 },
|
|
166
|
+
0,
|
|
167
|
+
),
|
|
168
|
+
).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
});
|
package/src/ui/utils/download.ts
CHANGED
|
@@ -152,3 +152,10 @@ export const downloadBlob = (blob: Blob, filename: string): void => {
|
|
|
152
152
|
// the request when the URL disappears mid-click.
|
|
153
153
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
|
|
154
154
|
};
|
|
155
|
+
|
|
156
|
+
export const downloadJson = (data: unknown, filename: string): void => {
|
|
157
|
+
downloadBlob(
|
|
158
|
+
new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }),
|
|
159
|
+
filename,
|
|
160
|
+
);
|
|
161
|
+
};
|