@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.
@@ -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
+ };
@@ -0,0 +1,183 @@
1
+ import type { FilterState } from '../state/filter';
2
+ import type { ProcessedRequest } from '../state/model';
3
+ import type { HttpMethod } from '../../shared/client';
4
+
5
+ export type RequestFilterOptions = {
6
+ hasOverride?: boolean;
7
+ };
8
+
9
+ const parseThreshold = (value: string): number | null => {
10
+ const normalizedValue = value.trim();
11
+ if (!normalizedValue) {
12
+ return null;
13
+ }
14
+
15
+ const parsedValue = Number(normalizedValue);
16
+ return Number.isFinite(parsedValue) ? parsedValue : null;
17
+ };
18
+
19
+ const matchesStatusFilter = (
20
+ statusCode: number | undefined,
21
+ statusFilter: string,
22
+ ) => {
23
+ const normalizedFilter = statusFilter.trim().toLowerCase();
24
+ if (!normalizedFilter) {
25
+ return true;
26
+ }
27
+
28
+ if (statusCode === undefined) {
29
+ return false;
30
+ }
31
+
32
+ const statusRangeMatch = normalizedFilter.match(/^(\d{3})\s*-\s*(\d{3})$/);
33
+ if (statusRangeMatch) {
34
+ const min = Number(statusRangeMatch[1]);
35
+ const max = Number(statusRangeMatch[2]);
36
+ return statusCode >= min && statusCode <= max;
37
+ }
38
+
39
+ const statusClassMatch = normalizedFilter.match(/^([1-5])xx$/);
40
+ if (statusClassMatch) {
41
+ return Math.floor(statusCode / 100) === Number(statusClassMatch[1]);
42
+ }
43
+
44
+ const comparisonMatch = normalizedFilter.match(/^(>=|<=|>|<)\s*(\d{3})$/);
45
+ if (comparisonMatch) {
46
+ const value = Number(comparisonMatch[2]);
47
+ switch (comparisonMatch[1]) {
48
+ case '>=':
49
+ return statusCode >= value;
50
+ case '<=':
51
+ return statusCode <= value;
52
+ case '>':
53
+ return statusCode > value;
54
+ case '<':
55
+ return statusCode < value;
56
+ }
57
+ }
58
+
59
+ return statusCode === Number(normalizedFilter);
60
+ };
61
+
62
+ const isInFlightStatus = (status: ProcessedRequest['status']) => {
63
+ return ['pending', 'loading', 'connecting', 'open'].includes(status);
64
+ };
65
+
66
+ const isFailedStatus = (status: ProcessedRequest['status']) => {
67
+ return ['failed', 'error'].includes(status);
68
+ };
69
+
70
+ const isHttpMethod = (
71
+ method: ProcessedRequest['method'],
72
+ ): method is HttpMethod => method !== 'WS' && method !== 'SSE';
73
+
74
+ const extractDomainAndPath = (url: string) => {
75
+ try {
76
+ const { hostname, pathname, search, hash, port } = new URL(url);
77
+
78
+ return {
79
+ domain: `${hostname}${port ? `:${port}` : ''}`,
80
+ path: `${pathname}${search}${hash}`,
81
+ };
82
+ } catch {
83
+ return { domain: 'unknown', path: url };
84
+ }
85
+ };
86
+
87
+ export const matchesRequestFilter = (
88
+ request: ProcessedRequest,
89
+ filter: FilterState,
90
+ options: RequestFilterOptions = {},
91
+ ) => {
92
+ if (filter.types.size > 0 && !filter.types.has(request.type)) {
93
+ return false;
94
+ }
95
+
96
+ if (
97
+ filter.advanced.methods.size > 0 &&
98
+ (!isHttpMethod(request.method) ||
99
+ !filter.advanced.methods.has(request.method))
100
+ ) {
101
+ return false;
102
+ }
103
+
104
+ if (
105
+ filter.advanced.sources.size > 0 &&
106
+ (!request.source || !filter.advanced.sources.has(request.source))
107
+ ) {
108
+ return false;
109
+ }
110
+
111
+ if (!matchesStatusFilter(request.httpStatus, filter.advanced.status)) {
112
+ return false;
113
+ }
114
+
115
+ const { domain, path } = extractDomainAndPath(request.name);
116
+ const domainFilter = filter.advanced.domain.trim().toLowerCase();
117
+ if (domainFilter && !domain.toLowerCase().includes(domainFilter)) {
118
+ return false;
119
+ }
120
+
121
+ const contentTypeFilter = filter.advanced.contentType.trim().toLowerCase();
122
+ if (
123
+ contentTypeFilter &&
124
+ !request.contentType?.toLowerCase().includes(contentTypeFilter)
125
+ ) {
126
+ return false;
127
+ }
128
+
129
+ if (filter.advanced.failedOnly && !isFailedStatus(request.status)) {
130
+ return false;
131
+ }
132
+
133
+ if (filter.advanced.inFlightOnly && !isInFlightStatus(request.status)) {
134
+ return false;
135
+ }
136
+
137
+ if (filter.advanced.overriddenOnly && !options.hasOverride) {
138
+ return false;
139
+ }
140
+
141
+ const minSize = parseThreshold(filter.advanced.minSize);
142
+ if (minSize !== null && (request.size === null || request.size < minSize)) {
143
+ return false;
144
+ }
145
+
146
+ const maxSize = parseThreshold(filter.advanced.maxSize);
147
+ if (maxSize !== null && (request.size === null || request.size > maxSize)) {
148
+ return false;
149
+ }
150
+
151
+ const duration = request.duration || 0;
152
+ const minDuration = parseThreshold(filter.advanced.minDuration);
153
+ if (minDuration !== null && duration < minDuration) {
154
+ return false;
155
+ }
156
+
157
+ const maxDuration = parseThreshold(filter.advanced.maxDuration);
158
+ if (maxDuration !== null && duration > maxDuration) {
159
+ return false;
160
+ }
161
+
162
+ const searchText = filter.text.trim().toLowerCase();
163
+ if (!searchText) {
164
+ return true;
165
+ }
166
+
167
+ const searchableFields = [
168
+ request.name,
169
+ request.method,
170
+ request.status,
171
+ request.httpStatus,
172
+ request.source,
173
+ request.type,
174
+ request.contentType,
175
+ domain,
176
+ path,
177
+ ]
178
+ .filter((value) => value !== undefined && value !== null)
179
+ .join(' ')
180
+ .toLowerCase();
181
+
182
+ return searchableFields.includes(searchText);
183
+ };
@@ -0,0 +1,185 @@
1
+ import type {
2
+ HttpNetworkEntry,
3
+ NetworkEntry,
4
+ RequestId,
5
+ SSENetworkEntry,
6
+ WebSocketMessage,
7
+ WebSocketNetworkEntry,
8
+ } from '../state/model';
9
+
10
+ const EXPORT_SCHEMA_VERSION = 1;
11
+
12
+ type ExportedHttpEntry = {
13
+ id: RequestId;
14
+ type: 'http';
15
+ source?: NetworkEntry['source'];
16
+ timestamp: number;
17
+ duration: number | null;
18
+ status: HttpNetworkEntry['status'];
19
+ error?: string;
20
+ canceled?: boolean;
21
+ request: HttpNetworkEntry['request'];
22
+ response: HttpNetworkEntry['response'] | null;
23
+ size: number | null;
24
+ ttfb: number | null;
25
+ initiator?: HttpNetworkEntry['initiator'];
26
+ resourceType?: HttpNetworkEntry['resourceType'];
27
+ progress?: HttpNetworkEntry['progress'];
28
+ };
29
+
30
+ type ExportedWebSocketEntry = {
31
+ id: RequestId;
32
+ type: 'websocket';
33
+ source?: NetworkEntry['source'];
34
+ timestamp: number;
35
+ duration: number | null;
36
+ status: WebSocketNetworkEntry['status'];
37
+ connection: WebSocketNetworkEntry['connection'];
38
+ error?: string;
39
+ closeCode?: number;
40
+ closeReason?: string;
41
+ messages: WebSocketMessage[];
42
+ };
43
+
44
+ type ExportedSSEEntry = {
45
+ id: RequestId;
46
+ type: 'sse';
47
+ source?: NetworkEntry['source'];
48
+ timestamp: number;
49
+ duration: number | null;
50
+ status: SSENetworkEntry['status'];
51
+ error?: string;
52
+ request: SSENetworkEntry['request'];
53
+ response: SSENetworkEntry['response'] | null;
54
+ initiator?: SSENetworkEntry['initiator'];
55
+ resourceType?: SSENetworkEntry['resourceType'];
56
+ messages: SSENetworkEntry['messages'];
57
+ };
58
+
59
+ export type ExportedNetworkEntry =
60
+ | ExportedHttpEntry
61
+ | ExportedWebSocketEntry
62
+ | ExportedSSEEntry;
63
+
64
+ export type NetworkActivitySessionExport = {
65
+ schemaVersion: typeof EXPORT_SCHEMA_VERSION;
66
+ tool: 'rozenite-network-activity';
67
+ exportedAt: string;
68
+ summary: {
69
+ totalEntries: number;
70
+ httpRequests: number;
71
+ webSocketConnections: number;
72
+ sseConnections: number;
73
+ realtimeMessages: number;
74
+ };
75
+ entries: ExportedNetworkEntry[];
76
+ };
77
+
78
+ const getDuration = (duration: number | undefined) => duration ?? null;
79
+
80
+ const serializeHttpEntry = (entry: HttpNetworkEntry): ExportedHttpEntry => ({
81
+ id: entry.id,
82
+ type: 'http',
83
+ source: entry.source,
84
+ timestamp: entry.timestamp,
85
+ duration: getDuration(entry.duration),
86
+ status: entry.status,
87
+ error: entry.error,
88
+ canceled: entry.canceled,
89
+ request: entry.request,
90
+ response: entry.response ?? null,
91
+ size: entry.size ?? null,
92
+ ttfb: entry.ttfb ?? null,
93
+ initiator: entry.initiator,
94
+ resourceType: entry.resourceType,
95
+ progress: entry.progress,
96
+ });
97
+
98
+ const serializeWebSocketEntry = (
99
+ entry: WebSocketNetworkEntry,
100
+ websocketMessages: Map<RequestId, WebSocketMessage[]>,
101
+ ): ExportedWebSocketEntry => ({
102
+ id: entry.id,
103
+ type: 'websocket',
104
+ source: entry.source,
105
+ timestamp: entry.timestamp,
106
+ duration: getDuration(entry.duration),
107
+ status: entry.status,
108
+ connection: entry.connection,
109
+ error: entry.error,
110
+ closeCode: entry.closeCode,
111
+ closeReason: entry.closeReason,
112
+ messages: websocketMessages.get(entry.id) ?? [],
113
+ });
114
+
115
+ const serializeSSEEntry = (entry: SSENetworkEntry): ExportedSSEEntry => ({
116
+ id: entry.id,
117
+ type: 'sse',
118
+ source: entry.source,
119
+ timestamp: entry.timestamp,
120
+ duration: getDuration(entry.duration),
121
+ status: entry.status,
122
+ error: entry.error,
123
+ request: entry.request,
124
+ response: entry.response ?? null,
125
+ initiator: entry.initiator,
126
+ resourceType: entry.resourceType,
127
+ messages: entry.messages,
128
+ });
129
+
130
+ const serializeEntry = (
131
+ entry: NetworkEntry,
132
+ websocketMessages: Map<RequestId, WebSocketMessage[]>,
133
+ ): ExportedNetworkEntry => {
134
+ switch (entry.type) {
135
+ case 'http':
136
+ return serializeHttpEntry(entry);
137
+ case 'websocket':
138
+ return serializeWebSocketEntry(entry, websocketMessages);
139
+ case 'sse':
140
+ return serializeSSEEntry(entry);
141
+ }
142
+ };
143
+
144
+ export const createNetworkActivitySessionExport = (
145
+ networkEntries: Map<RequestId, NetworkEntry>,
146
+ websocketMessages: Map<RequestId, WebSocketMessage[]>,
147
+ exportedAt = new Date(),
148
+ ): NetworkActivitySessionExport => {
149
+ const entries = Array.from(networkEntries.values())
150
+ .sort((a, b) => a.timestamp - b.timestamp)
151
+ .map((entry) => serializeEntry(entry, websocketMessages));
152
+
153
+ return {
154
+ schemaVersion: EXPORT_SCHEMA_VERSION,
155
+ tool: 'rozenite-network-activity',
156
+ exportedAt: exportedAt.toISOString(),
157
+ summary: {
158
+ totalEntries: entries.length,
159
+ httpRequests: entries.filter((entry) => entry.type === 'http').length,
160
+ webSocketConnections: entries.filter(
161
+ (entry) => entry.type === 'websocket',
162
+ ).length,
163
+ sseConnections: entries.filter((entry) => entry.type === 'sse').length,
164
+ realtimeMessages: entries.reduce((count, entry) => {
165
+ if (entry.type === 'websocket' || entry.type === 'sse') {
166
+ return count + entry.messages.length;
167
+ }
168
+
169
+ return count;
170
+ }, 0),
171
+ },
172
+ entries,
173
+ };
174
+ };
175
+
176
+ export const getNetworkActivitySessionExportFileName = (
177
+ exportedAt = new Date(),
178
+ ) => {
179
+ const timestamp = exportedAt
180
+ .toISOString()
181
+ .replace(/\.\d{3}Z$/, 'Z')
182
+ .replace(/[:]/g, '-');
183
+
184
+ return `rozenite-network-session-${timestamp}.json`;
185
+ };
@@ -37,9 +37,12 @@ const getGeneratedFrameLocation = (frame: InitiatorStackFrame) => ({
37
37
  const isGeneratedBundleUrl = (url: string) =>
38
38
  /[^/]+\.bundle(?:[/?#]|$)/.test(url);
39
39
 
40
+ const isMetroSymbolicatableUrl = (url?: string) =>
41
+ url?.startsWith('http') ?? false;
42
+
40
43
  const canSymbolicateStack = (stack?: InitiatorStackFrame[]) =>
41
44
  stack?.some((frame) =>
42
- getGeneratedFrameLocation(frame).url?.startsWith('http'),
45
+ isMetroSymbolicatableUrl(getGeneratedFrameLocation(frame).url),
43
46
  ) ?? false;
44
47
 
45
48
  const toReactNativeStackFrame = (
@@ -47,7 +50,7 @@ const toReactNativeStackFrame = (
47
50
  ): ReactNativeStackFrame | null => {
48
51
  const generatedLocation = getGeneratedFrameLocation(frame);
49
52
 
50
- if (!generatedLocation.url) {
53
+ if (!isMetroSymbolicatableUrl(generatedLocation.url)) {
51
54
  return null;
52
55
  }
53
56
 
@@ -195,21 +198,45 @@ export const symbolicateInitiator = async (
195
198
  return null;
196
199
  }
197
200
 
198
- const generatedStackFrames =
199
- initiator.stack
200
- ?.map(toReactNativeStackFrame)
201
- .filter((frame): frame is ReactNativeStackFrame => frame !== null) ?? [];
201
+ const originalStack = initiator.stack ?? [];
202
+ const generatedStackFrames = originalStack.flatMap(
203
+ (originalFrame, originalIndex) => {
204
+ const frame = toReactNativeStackFrame(originalFrame);
205
+ return frame ? [{ frame, originalIndex }] : [];
206
+ },
207
+ );
202
208
 
203
209
  if (generatedStackFrames.length === 0) {
204
210
  return null;
205
211
  }
206
212
 
207
213
  try {
208
- const symbolicatedStackTrace =
209
- await symbolicateStackTrace(generatedStackFrames);
214
+ const symbolicatedStackTrace = await symbolicateStackTrace(
215
+ generatedStackFrames.map((entry) => entry.frame),
216
+ );
210
217
 
211
- const symbolicatedStack = symbolicatedStackTrace.stack.map((frame, index) =>
212
- fromSymbolicatedStackFrame(frame, initiator.stack?.[index]),
218
+ const symbolicatedFramesByOriginalIndex = new Map<
219
+ number,
220
+ InitiatorStackFrame
221
+ >();
222
+
223
+ symbolicatedStackTrace.stack.forEach((frame, index) => {
224
+ const generatedFrame = generatedStackFrames[index];
225
+ if (!generatedFrame) {
226
+ return;
227
+ }
228
+
229
+ symbolicatedFramesByOriginalIndex.set(
230
+ generatedFrame.originalIndex,
231
+ fromSymbolicatedStackFrame(
232
+ frame,
233
+ originalStack[generatedFrame.originalIndex],
234
+ ),
235
+ );
236
+ });
237
+
238
+ const symbolicatedStack = originalStack.map(
239
+ (frame, index) => symbolicatedFramesByOriginalIndex.get(index) ?? frame,
213
240
  );
214
241
  const sourceFrame = getPreferredSourceFrame(
215
242
  symbolicatedStack,