@rozenite/network-activity-plugin 1.10.0 → 1.12.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
+ });
@@ -126,6 +126,79 @@ describe('symbolication', () => {
126
126
  });
127
127
  });
128
128
 
129
+ it('keeps internal Hermes frames out of the Metro symbolication request', async () => {
130
+ const initiator: Initiator = {
131
+ type: 'script',
132
+ generatedUrl: 'address at InternalBytecode.js',
133
+ generatedLineNumber: 1,
134
+ generatedColumnNumber: 12345,
135
+ symbolicationStatus: 'pending',
136
+ stack: [
137
+ {
138
+ functionName: 'anonymous',
139
+ generatedUrl: 'address at InternalBytecode.js',
140
+ generatedLineNumber: 1,
141
+ generatedColumnNumber: 12345,
142
+ },
143
+ {
144
+ functionName: 'loadUsers',
145
+ generatedUrl: 'http://localhost:8081/index.bundle',
146
+ generatedLineNumber: 1,
147
+ generatedColumnNumber: 200,
148
+ },
149
+ ],
150
+ };
151
+ const symbolicateStackTrace = vi.fn().mockResolvedValue({
152
+ stack: [
153
+ {
154
+ methodName: 'loadUsers',
155
+ file: 'apps/playground/src/app/api.ts',
156
+ lineNumber: 30,
157
+ column: 6,
158
+ },
159
+ ],
160
+ });
161
+
162
+ const symbolicatedInitiator = await symbolicateInitiator(
163
+ initiator,
164
+ symbolicateStackTrace,
165
+ );
166
+
167
+ expect(symbolicateStackTrace).toHaveBeenCalledWith([
168
+ {
169
+ methodName: 'loadUsers',
170
+ file: 'http://localhost:8081/index.bundle',
171
+ lineNumber: 1,
172
+ column: 200,
173
+ },
174
+ ]);
175
+ expect(symbolicatedInitiator).toMatchObject({
176
+ type: 'script',
177
+ functionName: 'loadUsers',
178
+ url: 'apps/playground/src/app/api.ts',
179
+ lineNumber: 30,
180
+ columnNumber: 6,
181
+ symbolicationStatus: 'complete',
182
+ stack: [
183
+ {
184
+ functionName: 'anonymous',
185
+ generatedUrl: 'address at InternalBytecode.js',
186
+ generatedLineNumber: 1,
187
+ generatedColumnNumber: 12345,
188
+ },
189
+ {
190
+ functionName: 'loadUsers',
191
+ url: 'apps/playground/src/app/api.ts',
192
+ lineNumber: 30,
193
+ columnNumber: 6,
194
+ generatedUrl: 'http://localhost:8081/index.bundle',
195
+ generatedLineNumber: 1,
196
+ generatedColumnNumber: 200,
197
+ },
198
+ ],
199
+ });
200
+ });
201
+
129
202
  it('reports symbolication failures on the initiator', async () => {
130
203
  const initiator: Initiator = {
131
204
  type: 'script',