@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.
@@ -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
+ });
@@ -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
+ });
@@ -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
 
@@ -152,6 +152,7 @@ export type ProcessedRequest = {
152
152
  method: HttpMethod | 'WS' | 'SSE';
153
153
  httpStatus?: number;
154
154
  contentType?: string;
155
+ ttfb?: number;
155
156
  progress?: {
156
157
  loaded: number;
157
158
  total: number;
@@ -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: eventData.timestamp - wsEntry.timestamp,
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: eventData.timestamp - sseEntry.timestamp,
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
+ });
@@ -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
+ };