@rozenite/network-activity-plugin 1.9.0 → 1.10.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 +43 -0
- package/dist/devtools/App.html +2 -2
- package/dist/devtools/assets/{App-m6xge0az.css → App-CUXU0mup.css} +152 -2
- package/dist/devtools/assets/{App-hSoryVpJ.js → App-DsimzJvx.js} +6827 -970
- package/dist/react-native/chunks/boot-recording.cjs +138 -14
- package/dist/react-native/chunks/boot-recording.js +138 -14
- package/dist/react-native/chunks/get-nitro-module.cjs +4 -1
- package/dist/react-native/chunks/get-nitro-module.js +4 -1
- package/dist/react-native/chunks/useNetworkActivityDevTools.require.cjs +20 -1
- package/dist/react-native/chunks/useNetworkActivityDevTools.require.js +20 -1
- package/dist/react-native/index.d.ts +37 -1
- package/dist/rozenite.json +1 -1
- package/dist/sdk/index.d.ts +37 -1
- package/package.json +12 -7
- package/src/react-native/agent/use-network-activity-agent-tools.ts +22 -4
- package/src/react-native/http/__tests__/http-utils.test.ts +228 -0
- package/src/react-native/http/http-utils.ts +208 -25
- package/src/react-native/network-inspector.ts +2 -2
- package/src/react-native/nitro-fetch/get-nitro-module.ts +5 -1
- package/src/react-native/nitro-fetch/nitro-network-inspector.ts +8 -2
- package/src/shared/http-events.ts +40 -1
- package/src/ui/components/CodeBlock.tsx +45 -1
- package/src/ui/components/FilterBar.tsx +366 -58
- package/src/ui/components/HexView.tsx +54 -0
- package/src/ui/components/MetadataCard.tsx +95 -0
- package/src/ui/components/RequestList.tsx +192 -34
- package/src/ui/components/SidePanel.tsx +42 -1
- package/src/ui/components/ViewToggle.tsx +44 -0
- package/src/ui/components/XmlTree.tsx +160 -0
- package/src/ui/components/__tests__/CodeBlock.test.tsx +89 -0
- package/src/ui/components/__tests__/HexView.test.tsx +41 -0
- package/src/ui/components/__tests__/MetadataCard.test.tsx +107 -0
- package/src/ui/components/__tests__/ViewToggle.test.tsx +80 -0
- package/src/ui/components/__tests__/XmlTree.test.tsx +149 -0
- package/src/ui/response-renderers/__tests__/binary-too-large.test.tsx +56 -0
- package/src/ui/response-renderers/__tests__/binary.test.tsx +96 -0
- package/src/ui/response-renderers/__tests__/dispatch.test.ts +124 -0
- package/src/ui/response-renderers/__tests__/html.test.tsx +101 -0
- package/src/ui/response-renderers/__tests__/image.test.tsx +73 -0
- package/src/ui/response-renderers/__tests__/json.test.tsx +95 -0
- package/src/ui/response-renderers/__tests__/svg.test.tsx +46 -0
- package/src/ui/response-renderers/__tests__/xml.test.tsx +100 -0
- package/src/ui/response-renderers/binary-too-large.tsx +36 -0
- package/src/ui/response-renderers/binary.tsx +31 -0
- package/src/ui/response-renderers/empty.tsx +14 -0
- package/src/ui/response-renderers/html.tsx +36 -0
- package/src/ui/response-renderers/image.tsx +37 -0
- package/src/ui/response-renderers/index.ts +55 -0
- package/src/ui/response-renderers/json.tsx +40 -0
- package/src/ui/response-renderers/svg.tsx +27 -0
- package/src/ui/response-renderers/text-fallback.tsx +14 -0
- package/src/ui/response-renderers/types.ts +38 -0
- package/src/ui/response-renderers/unknown.tsx +18 -0
- package/src/ui/response-renderers/xml.tsx +46 -0
- package/src/ui/state/derived.ts +12 -0
- package/src/ui/state/model.ts +6 -1
- package/src/ui/state/store.ts +39 -2
- package/src/ui/tabs/InitiatorTab.tsx +230 -0
- package/src/ui/tabs/ResponseTab.tsx +80 -97
- package/src/ui/tabs/__tests__/ResponseTab.test.tsx +102 -0
- package/src/ui/utils/__tests__/download.test.ts +115 -0
- package/src/ui/utils/__tests__/hex.test.ts +84 -0
- package/src/ui/utils/__tests__/symbolication.test.ts +207 -0
- package/src/ui/utils/download.ts +154 -0
- package/src/ui/utils/hex.ts +59 -0
- package/src/ui/utils/initiator.ts +136 -0
- package/src/ui/utils/symbolication.ts +248 -0
- package/src/ui/views/InspectorView.tsx +8 -5
- package/src/utils/__tests__/getContentTypeMimeType.test.ts +34 -0
- package/src/utils/getContentTypeMimeType.ts +14 -0
- package/vite.config.ts +5 -1
- package/vitest.setup.ts +31 -0
|
@@ -8,8 +8,13 @@ import {
|
|
|
8
8
|
SortingState,
|
|
9
9
|
useReactTable,
|
|
10
10
|
} from '@tanstack/react-table';
|
|
11
|
-
import { ProcessedRequest } from '../state/model';
|
|
12
|
-
import {
|
|
11
|
+
import type { ProcessedRequest } from '../state/model';
|
|
12
|
+
import type {
|
|
13
|
+
HttpMethod,
|
|
14
|
+
NetworkEventSource,
|
|
15
|
+
RequestId,
|
|
16
|
+
RequestOverride,
|
|
17
|
+
} from '../../shared/client';
|
|
13
18
|
import {
|
|
14
19
|
useNetworkActivityActions,
|
|
15
20
|
useOverrides,
|
|
@@ -20,18 +25,22 @@ import {
|
|
|
20
25
|
import { getStatusColor } from '../utils/getStatusColor';
|
|
21
26
|
import { FilterState } from './FilterBar';
|
|
22
27
|
import { isNumber } from '../../utils/typeChecks';
|
|
23
|
-
import type { NetworkEventSource } from '../../shared/client';
|
|
24
28
|
|
|
25
29
|
type NetworkRequest = {
|
|
26
30
|
id: RequestId;
|
|
27
31
|
name: string;
|
|
28
32
|
status: string | number;
|
|
29
|
-
|
|
33
|
+
statusCode?: number;
|
|
34
|
+
statusState: ProcessedRequest['status'];
|
|
35
|
+
method: ProcessedRequest['method'];
|
|
30
36
|
domain: string;
|
|
31
37
|
path: string;
|
|
38
|
+
contentType?: string;
|
|
32
39
|
size: string;
|
|
40
|
+
sizeBytes: number | null;
|
|
33
41
|
time: string;
|
|
34
|
-
|
|
42
|
+
durationMs: number;
|
|
43
|
+
type: ProcessedRequest['type'];
|
|
35
44
|
source?: NetworkEventSource;
|
|
36
45
|
startTime: string;
|
|
37
46
|
hasOverride: boolean;
|
|
@@ -139,6 +148,175 @@ const sortTime: SortingFn<NetworkRequest> = (rowA, rowB, columnId) => {
|
|
|
139
148
|
return getNumericValue(a) - getNumericValue(b);
|
|
140
149
|
};
|
|
141
150
|
|
|
151
|
+
const parseThreshold = (value: string): number | null => {
|
|
152
|
+
const normalizedValue = value.trim();
|
|
153
|
+
if (!normalizedValue) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const parsedValue = Number(normalizedValue);
|
|
158
|
+
return Number.isFinite(parsedValue) ? parsedValue : null;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const matchesStatusFilter = (
|
|
162
|
+
statusCode: number | undefined,
|
|
163
|
+
statusFilter: string,
|
|
164
|
+
) => {
|
|
165
|
+
const normalizedFilter = statusFilter.trim().toLowerCase();
|
|
166
|
+
if (!normalizedFilter) {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (statusCode === undefined) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const statusRangeMatch = normalizedFilter.match(/^(\d{3})\s*-\s*(\d{3})$/);
|
|
175
|
+
if (statusRangeMatch) {
|
|
176
|
+
const min = Number(statusRangeMatch[1]);
|
|
177
|
+
const max = Number(statusRangeMatch[2]);
|
|
178
|
+
return statusCode >= min && statusCode <= max;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const statusClassMatch = normalizedFilter.match(/^([1-5])xx$/);
|
|
182
|
+
if (statusClassMatch) {
|
|
183
|
+
return Math.floor(statusCode / 100) === Number(statusClassMatch[1]);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const comparisonMatch = normalizedFilter.match(/^(>=|<=|>|<)\s*(\d{3})$/);
|
|
187
|
+
if (comparisonMatch) {
|
|
188
|
+
const value = Number(comparisonMatch[2]);
|
|
189
|
+
switch (comparisonMatch[1]) {
|
|
190
|
+
case '>=':
|
|
191
|
+
return statusCode >= value;
|
|
192
|
+
case '<=':
|
|
193
|
+
return statusCode <= value;
|
|
194
|
+
case '>':
|
|
195
|
+
return statusCode > value;
|
|
196
|
+
case '<':
|
|
197
|
+
return statusCode < value;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return statusCode === Number(normalizedFilter);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const isInFlightStatus = (status: string) => {
|
|
205
|
+
return ['pending', 'loading', 'connecting', 'open'].includes(status);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const isFailedStatus = (status: string) => {
|
|
209
|
+
return ['failed', 'error'].includes(status);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const isHttpMethod = (method: NetworkRequest['method']): method is HttpMethod =>
|
|
213
|
+
method !== 'WS' && method !== 'SSE';
|
|
214
|
+
|
|
215
|
+
const filterNetworkRequests = (
|
|
216
|
+
requests: NetworkRequest[],
|
|
217
|
+
filter: FilterState,
|
|
218
|
+
) => {
|
|
219
|
+
const searchText = filter.text.trim().toLowerCase();
|
|
220
|
+
const domainFilter = filter.advanced.domain.trim().toLowerCase();
|
|
221
|
+
const contentTypeFilter = filter.advanced.contentType.trim().toLowerCase();
|
|
222
|
+
const minSize = parseThreshold(filter.advanced.minSize);
|
|
223
|
+
const maxSize = parseThreshold(filter.advanced.maxSize);
|
|
224
|
+
const minDuration = parseThreshold(filter.advanced.minDuration);
|
|
225
|
+
const maxDuration = parseThreshold(filter.advanced.maxDuration);
|
|
226
|
+
|
|
227
|
+
return requests.filter((request) => {
|
|
228
|
+
if (filter.types.size > 0 && !filter.types.has(request.type)) {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (
|
|
233
|
+
filter.advanced.methods.size > 0 &&
|
|
234
|
+
(!isHttpMethod(request.method) ||
|
|
235
|
+
!filter.advanced.methods.has(request.method))
|
|
236
|
+
) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (
|
|
241
|
+
filter.advanced.sources.size > 0 &&
|
|
242
|
+
(!request.source || !filter.advanced.sources.has(request.source))
|
|
243
|
+
) {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!matchesStatusFilter(request.statusCode, filter.advanced.status)) {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (domainFilter && !request.domain.toLowerCase().includes(domainFilter)) {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (
|
|
256
|
+
contentTypeFilter &&
|
|
257
|
+
!request.contentType?.toLowerCase().includes(contentTypeFilter)
|
|
258
|
+
) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (filter.advanced.failedOnly && !isFailedStatus(request.statusState)) {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (
|
|
267
|
+
filter.advanced.inFlightOnly &&
|
|
268
|
+
!isInFlightStatus(request.statusState)
|
|
269
|
+
) {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (filter.advanced.overriddenOnly && !request.hasOverride) {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (
|
|
278
|
+
minSize !== null &&
|
|
279
|
+
(request.sizeBytes === null || request.sizeBytes < minSize)
|
|
280
|
+
) {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (
|
|
285
|
+
maxSize !== null &&
|
|
286
|
+
(request.sizeBytes === null || request.sizeBytes > maxSize)
|
|
287
|
+
) {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (minDuration !== null && request.durationMs < minDuration) {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (maxDuration !== null && request.durationMs > maxDuration) {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (searchText) {
|
|
300
|
+
const searchableFields = [
|
|
301
|
+
request.name,
|
|
302
|
+
request.method,
|
|
303
|
+
request.status,
|
|
304
|
+
request.domain,
|
|
305
|
+
request.path,
|
|
306
|
+
request.source,
|
|
307
|
+
request.type,
|
|
308
|
+
request.contentType,
|
|
309
|
+
]
|
|
310
|
+
.join(' ')
|
|
311
|
+
.toLowerCase();
|
|
312
|
+
|
|
313
|
+
return searchableFields.includes(searchText);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return true;
|
|
317
|
+
});
|
|
318
|
+
};
|
|
319
|
+
|
|
142
320
|
const processNetworkRequests = (
|
|
143
321
|
processedRequests: ProcessedRequest[],
|
|
144
322
|
overrides: Map<string, RequestOverride>,
|
|
@@ -161,11 +339,16 @@ const processNetworkRequests = (
|
|
|
161
339
|
id: request.id,
|
|
162
340
|
name: generateName(request.name, showEntirePathAsName),
|
|
163
341
|
status: statusDisplay,
|
|
342
|
+
statusCode: request.httpStatus || undefined,
|
|
343
|
+
statusState: request.status,
|
|
164
344
|
method: request.method,
|
|
165
345
|
domain,
|
|
166
346
|
path,
|
|
347
|
+
contentType: request.contentType,
|
|
167
348
|
size: isNumber(request.size) ? formatSize(request.size) : '—',
|
|
349
|
+
sizeBytes: isNumber(request.size) ? request.size : null,
|
|
168
350
|
time: formatDuration(duration),
|
|
351
|
+
durationMs: duration,
|
|
169
352
|
type: request.type,
|
|
170
353
|
source: request.source,
|
|
171
354
|
startTime: formatStartTime(request.timestamp),
|
|
@@ -256,39 +439,14 @@ export const RequestList = ({ filter }: RequestListProps) => {
|
|
|
256
439
|
const overrides = useOverrides();
|
|
257
440
|
const clientUISettings = useClientUISettings();
|
|
258
441
|
|
|
259
|
-
// Filter requests based on current filter state
|
|
260
|
-
const filteredRequests = useMemo(() => {
|
|
261
|
-
return processedRequests.filter((request) => {
|
|
262
|
-
// Type filter
|
|
263
|
-
if (!filter.types.has(request.type)) {
|
|
264
|
-
return false;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Text filter
|
|
268
|
-
if (filter.text) {
|
|
269
|
-
const searchText = filter.text.toLowerCase();
|
|
270
|
-
const searchableFields = [
|
|
271
|
-
request.name,
|
|
272
|
-
request.method,
|
|
273
|
-
request.status.toString(),
|
|
274
|
-
]
|
|
275
|
-
.join(' ')
|
|
276
|
-
.toLowerCase();
|
|
277
|
-
|
|
278
|
-
return searchableFields.includes(searchText);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
return true;
|
|
282
|
-
});
|
|
283
|
-
}, [processedRequests, filter]);
|
|
284
|
-
|
|
285
442
|
const requests = useMemo(() => {
|
|
286
|
-
|
|
287
|
-
|
|
443
|
+
const allRequests = processNetworkRequests(
|
|
444
|
+
processedRequests,
|
|
288
445
|
overrides,
|
|
289
446
|
clientUISettings?.showUrlAsName,
|
|
290
447
|
);
|
|
291
|
-
|
|
448
|
+
return filterNetworkRequests(allRequests, filter);
|
|
449
|
+
}, [processedRequests, overrides, clientUISettings?.showUrlAsName, filter]);
|
|
292
450
|
|
|
293
451
|
const table = useReactTable({
|
|
294
452
|
data: requests,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
1
2
|
import { Badge } from './Badge';
|
|
2
3
|
import { Button } from './Button';
|
|
3
4
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from './Tabs';
|
|
@@ -6,6 +7,7 @@ import { RequestTab } from '../tabs/RequestTab';
|
|
|
6
7
|
import { ResponseTab } from '../tabs/ResponseTab';
|
|
7
8
|
import { CookiesTab } from '../tabs/CookiesTab';
|
|
8
9
|
import { TimingTab } from '../tabs/TimingTab';
|
|
10
|
+
import { InitiatorTab } from '../tabs/InitiatorTab';
|
|
9
11
|
import { X } from 'lucide-react';
|
|
10
12
|
import {
|
|
11
13
|
useNetworkActivityActions,
|
|
@@ -17,6 +19,7 @@ import { NetworkEntry as OldNetworkEntry } from '../types';
|
|
|
17
19
|
import { getStatusColor } from '../utils/getStatusColor';
|
|
18
20
|
import { MessagesTab } from '../tabs/MessagesTab';
|
|
19
21
|
import { SSEMessagesTab } from '../tabs/SSEMessagesTab';
|
|
22
|
+
import type { ResponseView } from '../response-renderers';
|
|
20
23
|
|
|
21
24
|
const getTypeColor = (type: string) => {
|
|
22
25
|
const colors: Record<string, string> = {
|
|
@@ -87,6 +90,14 @@ export const SidePanel = () => {
|
|
|
87
90
|
const selectedRequest = useSelectedRequest();
|
|
88
91
|
const client = useNetworkActivityStore((state) => state._client);
|
|
89
92
|
const overrides = useOverrides();
|
|
93
|
+
// Sticky Preview/Raw preference. Lives here, not in ResponseTab,
|
|
94
|
+
// because the `<Tabs key={selectedRequest.id}>` below intentionally
|
|
95
|
+
// remounts the Tabs subtree on every request switch (so the active
|
|
96
|
+
// inner tab resets). SidePanel itself stays mounted across request
|
|
97
|
+
// switches, so the preference survives — flipping to Raw on one
|
|
98
|
+
// response keeps Raw selected for every subsequent response whose
|
|
99
|
+
// renderer supports it. Resets when the panel is closed.
|
|
100
|
+
const [preferredView, setPreferredView] = useState<ResponseView>('preview');
|
|
90
101
|
|
|
91
102
|
const onClose = (): void => {
|
|
92
103
|
actions.setSelectedRequest(null);
|
|
@@ -166,6 +177,12 @@ export const SidePanel = () => {
|
|
|
166
177
|
>
|
|
167
178
|
Cookies
|
|
168
179
|
</TabsTrigger>
|
|
180
|
+
<TabsTrigger
|
|
181
|
+
value="initiator"
|
|
182
|
+
className="data-[state=active]:bg-gray-700"
|
|
183
|
+
>
|
|
184
|
+
Initiator
|
|
185
|
+
</TabsTrigger>
|
|
169
186
|
<TabsTrigger
|
|
170
187
|
value="timing"
|
|
171
188
|
className="data-[state=active]:bg-gray-700"
|
|
@@ -197,6 +214,12 @@ export const SidePanel = () => {
|
|
|
197
214
|
>
|
|
198
215
|
Messages
|
|
199
216
|
</TabsTrigger>
|
|
217
|
+
<TabsTrigger
|
|
218
|
+
value="initiator"
|
|
219
|
+
className="data-[state=active]:bg-gray-700"
|
|
220
|
+
>
|
|
221
|
+
Initiator
|
|
222
|
+
</TabsTrigger>
|
|
200
223
|
</>
|
|
201
224
|
);
|
|
202
225
|
}
|
|
@@ -229,6 +252,8 @@ export const SidePanel = () => {
|
|
|
229
252
|
<ResponseTab
|
|
230
253
|
selectedRequest={httpDetails}
|
|
231
254
|
supportsOverrides={supportsOverrides}
|
|
255
|
+
preferredView={preferredView}
|
|
256
|
+
onPreferredViewChange={setPreferredView}
|
|
232
257
|
onRequestResponseBody={(requestId) => {
|
|
233
258
|
if (client) {
|
|
234
259
|
client.send('get-response-body', {
|
|
@@ -243,6 +268,10 @@ export const SidePanel = () => {
|
|
|
243
268
|
<CookiesTab selectedRequest={httpDetails} />
|
|
244
269
|
</TabsContent>
|
|
245
270
|
|
|
271
|
+
<TabsContent value="initiator" className="flex-1 m-0 overflow-hidden">
|
|
272
|
+
<InitiatorTab selectedRequest={httpDetails} />
|
|
273
|
+
</TabsContent>
|
|
274
|
+
|
|
246
275
|
<TabsContent value="timing" className="flex-1 m-0 overflow-hidden">
|
|
247
276
|
<TimingTab selectedRequest={httpDetails} />
|
|
248
277
|
</TabsContent>
|
|
@@ -275,6 +304,10 @@ export const SidePanel = () => {
|
|
|
275
304
|
<SSEMessagesTab selectedRequest={sseDetails} />
|
|
276
305
|
</TabsContent>
|
|
277
306
|
|
|
307
|
+
<TabsContent value="initiator" className="flex-1 m-0 overflow-hidden">
|
|
308
|
+
<InitiatorTab selectedRequest={sseDetails} />
|
|
309
|
+
</TabsContent>
|
|
310
|
+
|
|
278
311
|
<TabsContent value="cookies" className="flex-1 m-0 overflow-hidden">
|
|
279
312
|
<CookiesTab selectedRequest={sseDetails} />
|
|
280
313
|
</TabsContent>
|
|
@@ -324,7 +357,15 @@ export const SidePanel = () => {
|
|
|
324
357
|
}
|
|
325
358
|
className="h-full flex flex-col"
|
|
326
359
|
>
|
|
327
|
-
<TabsList
|
|
360
|
+
<TabsList
|
|
361
|
+
className={`grid w-full ${
|
|
362
|
+
selectedRequest.type === 'http'
|
|
363
|
+
? 'grid-cols-6'
|
|
364
|
+
: selectedRequest.type === 'sse'
|
|
365
|
+
? 'grid-cols-4'
|
|
366
|
+
: 'grid-cols-1'
|
|
367
|
+
} bg-gray-800 rounded-none border-b border-gray-700`}
|
|
368
|
+
>
|
|
328
369
|
{getTabsListTriggers()}
|
|
329
370
|
</TabsList>
|
|
330
371
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ResponseView } from '../response-renderers';
|
|
2
|
+
import { cn } from '../utils/cn';
|
|
3
|
+
|
|
4
|
+
const VIEW_LABELS: Record<ResponseView, string> = {
|
|
5
|
+
preview: 'Preview',
|
|
6
|
+
raw: 'Raw',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type ViewToggleProps = {
|
|
10
|
+
views: ResponseView[];
|
|
11
|
+
value: ResponseView;
|
|
12
|
+
onChange: (view: ResponseView) => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const ViewToggle = ({ views, value, onChange }: ViewToggleProps) => {
|
|
16
|
+
if (views.length <= 1) return null;
|
|
17
|
+
return (
|
|
18
|
+
<div
|
|
19
|
+
role="tablist"
|
|
20
|
+
className="flex items-center rounded-md border border-gray-700 overflow-hidden ms-2"
|
|
21
|
+
>
|
|
22
|
+
{views.map((v) => (
|
|
23
|
+
<button
|
|
24
|
+
key={v}
|
|
25
|
+
type="button"
|
|
26
|
+
role="tab"
|
|
27
|
+
aria-selected={value === v}
|
|
28
|
+
onClick={(e) => {
|
|
29
|
+
e.stopPropagation();
|
|
30
|
+
onChange(v);
|
|
31
|
+
}}
|
|
32
|
+
className={cn(
|
|
33
|
+
'px-2 py-0.5 text-xs transition-colors',
|
|
34
|
+
value === v
|
|
35
|
+
? 'bg-blue-600 text-white'
|
|
36
|
+
: 'text-gray-300 hover:bg-gray-700',
|
|
37
|
+
)}
|
|
38
|
+
>
|
|
39
|
+
{VIEW_LABELS[v]}
|
|
40
|
+
</button>
|
|
41
|
+
))}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { ChevronDown, ChevronRight } from 'lucide-react';
|
|
3
|
+
import { JsonTreeCopyableItem } from './JsonTreeCopyableItem';
|
|
4
|
+
|
|
5
|
+
export type XmlTreeProps = {
|
|
6
|
+
root: Element;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const XmlTree = ({ root }: XmlTreeProps) => {
|
|
10
|
+
return (
|
|
11
|
+
<div className="font-mono text-sm text-gray-200">
|
|
12
|
+
<XmlNode node={root} depth={0} />
|
|
13
|
+
</div>
|
|
14
|
+
);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type XmlNodeProps = {
|
|
18
|
+
node: Node;
|
|
19
|
+
depth: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Whitespace-only text between sibling elements is DOM-pretty-print
|
|
23
|
+
// noise. Filter it so a 10-element document doesn't render as 21 nodes.
|
|
24
|
+
// Mixed content like `<p>Hello <b>world</b>!</p>` survives because its
|
|
25
|
+
// fragments contain non-whitespace.
|
|
26
|
+
const isWhitespaceOnlyText = (node: Node): boolean =>
|
|
27
|
+
node.nodeType === Node.TEXT_NODE && /^\s*$/.test(node.nodeValue ?? '');
|
|
28
|
+
|
|
29
|
+
const renderableChildren = (node: Node): Node[] =>
|
|
30
|
+
Array.from(node.childNodes).filter((child) => !isWhitespaceOnlyText(child));
|
|
31
|
+
|
|
32
|
+
const XmlNode = ({ node, depth }: XmlNodeProps) => {
|
|
33
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
34
|
+
return <XmlElementNode element={node as Element} depth={depth} />;
|
|
35
|
+
}
|
|
36
|
+
if (node.nodeType === Node.CDATA_SECTION_NODE) {
|
|
37
|
+
return <XmlCDataNode cdata={node as CDATASection} depth={depth} />;
|
|
38
|
+
}
|
|
39
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
40
|
+
return <XmlTextNode text={node as Text} depth={depth} />;
|
|
41
|
+
}
|
|
42
|
+
// Comments, processing instructions, DOCTYPE — intentionally not
|
|
43
|
+
// rendered in the tree. They're rare in API responses; the Raw view
|
|
44
|
+
// still shows them verbatim.
|
|
45
|
+
return null;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type XmlElementNodeProps = {
|
|
49
|
+
element: Element;
|
|
50
|
+
depth: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const XmlElementNode = ({ element, depth }: XmlElementNodeProps) => {
|
|
54
|
+
const [expanded, setExpanded] = useState(true);
|
|
55
|
+
const children = renderableChildren(element);
|
|
56
|
+
const hasChildren = children.length > 0;
|
|
57
|
+
const tagName = element.nodeName;
|
|
58
|
+
const attributes = Array.from(element.attributes);
|
|
59
|
+
|
|
60
|
+
// Self-closing if no renderable children.
|
|
61
|
+
const isSelfClosing = !hasChildren;
|
|
62
|
+
|
|
63
|
+
const serializeSubtree = () => new XMLSerializer().serializeToString(element);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div style={{ paddingLeft: depth === 0 ? 0 : `1rem` }}>
|
|
67
|
+
<div className="flex items-start">
|
|
68
|
+
{hasChildren ? (
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
onClick={() => setExpanded((v) => !v)}
|
|
72
|
+
className="mt-0.5 mr-1 -ml-5 text-gray-500 hover:text-gray-300 transition-colors"
|
|
73
|
+
aria-label={expanded ? 'Collapse' : 'Expand'}
|
|
74
|
+
>
|
|
75
|
+
{expanded ? (
|
|
76
|
+
<ChevronDown className="h-3 w-3" />
|
|
77
|
+
) : (
|
|
78
|
+
<ChevronRight className="h-3 w-3" />
|
|
79
|
+
)}
|
|
80
|
+
</button>
|
|
81
|
+
) : null}
|
|
82
|
+
<JsonTreeCopyableItem getCopyableValue={serializeSubtree}>
|
|
83
|
+
<span>
|
|
84
|
+
<span className="text-gray-500"><</span>
|
|
85
|
+
<span className="text-blue-400">{tagName}</span>
|
|
86
|
+
{attributes.map((attr) => (
|
|
87
|
+
<XmlAttribute key={attr.name} attr={attr} />
|
|
88
|
+
))}
|
|
89
|
+
<span className="text-gray-500">{isSelfClosing ? ' />' : '>'}</span>
|
|
90
|
+
</span>
|
|
91
|
+
</JsonTreeCopyableItem>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{hasChildren ? (
|
|
95
|
+
<>
|
|
96
|
+
<div style={{ display: expanded ? 'block' : 'none' }}>
|
|
97
|
+
{children.map((child, idx) => (
|
|
98
|
+
<XmlNode key={idx} node={child} depth={depth + 1} />
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
{/* Collapsed inline closing tag preview — gives a visual hint
|
|
102
|
+
of what was collapsed. */}
|
|
103
|
+
{!expanded ? <span className="text-gray-500 ml-1">…</span> : null}
|
|
104
|
+
<div>
|
|
105
|
+
<span className="text-gray-500"></</span>
|
|
106
|
+
<span className="text-blue-400">{tagName}</span>
|
|
107
|
+
<span className="text-gray-500">></span>
|
|
108
|
+
</div>
|
|
109
|
+
</>
|
|
110
|
+
) : null}
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const XmlAttribute = ({ attr }: { attr: Attr }) => (
|
|
116
|
+
<>
|
|
117
|
+
<span> </span>
|
|
118
|
+
<span className="text-amber-400">{attr.name}</span>
|
|
119
|
+
<span className="text-gray-500">=</span>
|
|
120
|
+
<span className="text-gray-500">"</span>
|
|
121
|
+
<span className="text-green-400">{attr.value}</span>
|
|
122
|
+
<span className="text-gray-500">"</span>
|
|
123
|
+
</>
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
type XmlTextNodeProps = {
|
|
127
|
+
text: Text;
|
|
128
|
+
depth: number;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const XmlTextNode = ({ text, depth }: XmlTextNodeProps) => {
|
|
132
|
+
const value = text.nodeValue ?? '';
|
|
133
|
+
return (
|
|
134
|
+
<div style={{ paddingLeft: depth === 0 ? 0 : `1rem` }}>
|
|
135
|
+
<JsonTreeCopyableItem getCopyableValue={() => value}>
|
|
136
|
+
<span className="text-gray-200">{value}</span>
|
|
137
|
+
</JsonTreeCopyableItem>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
type XmlCDataNodeProps = {
|
|
143
|
+
cdata: CDATASection;
|
|
144
|
+
depth: number;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const XmlCDataNode = ({ cdata, depth }: XmlCDataNodeProps) => {
|
|
148
|
+
const value = cdata.nodeValue ?? '';
|
|
149
|
+
return (
|
|
150
|
+
<div style={{ paddingLeft: depth === 0 ? 0 : `1rem` }}>
|
|
151
|
+
<JsonTreeCopyableItem getCopyableValue={() => value}>
|
|
152
|
+
<span>
|
|
153
|
+
<span className="text-purple-400"><![CDATA[</span>
|
|
154
|
+
<span className="text-gray-200 whitespace-pre-wrap">{value}</span>
|
|
155
|
+
<span className="text-purple-400">]]></span>
|
|
156
|
+
</span>
|
|
157
|
+
</JsonTreeCopyableItem>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { render, screen } from '@testing-library/react';
|
|
4
|
+
import '@testing-library/jest-dom/vitest';
|
|
5
|
+
import { CodeBlock } from '../CodeBlock';
|
|
6
|
+
|
|
7
|
+
describe('CodeBlock', () => {
|
|
8
|
+
it('renders small string content as a <pre> (no virtualization)', () => {
|
|
9
|
+
const { container } = render(<CodeBlock>{'a'.repeat(100)}</CodeBlock>);
|
|
10
|
+
expect(container.querySelector('pre')).toBeInTheDocument();
|
|
11
|
+
expect(screen.queryByTestId('virtuoso-mock')).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('stays flat at exactly the 50_000-character boundary (inclusive)', () => {
|
|
15
|
+
// The branch condition is `> 50_000`, so length === 50_000 must
|
|
16
|
+
// still render as <pre>. Locks the inclusive-boundary semantics.
|
|
17
|
+
const { container } = render(<CodeBlock>{'a'.repeat(50_000)}</CodeBlock>);
|
|
18
|
+
expect(container.querySelector('pre')).toBeInTheDocument();
|
|
19
|
+
expect(screen.queryByTestId('virtuoso-mock')).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('switches to virtualized rendering at 50_001 characters', () => {
|
|
23
|
+
const { container } = render(<CodeBlock>{'a'.repeat(50_001)}</CodeBlock>);
|
|
24
|
+
expect(screen.getByTestId('virtuoso-mock')).toBeInTheDocument();
|
|
25
|
+
// No <pre> emitted by CodeBlock when on the virtualized path.
|
|
26
|
+
expect(container.querySelector('pre')).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('preserves the body content across the virtualization threshold', () => {
|
|
30
|
+
// Build a body whose head and tail are recognizable tokens. The
|
|
31
|
+
// vi.mock passthrough renders every row, so both ends should be
|
|
32
|
+
// visible in the DOM after virtualization kicks in.
|
|
33
|
+
const filler = 'x'.repeat(50_000);
|
|
34
|
+
const body = `START\n${filler}\nEND`;
|
|
35
|
+
render(<CodeBlock>{body}</CodeBlock>);
|
|
36
|
+
expect(screen.getByTestId('virtuoso-mock')).toBeInTheDocument();
|
|
37
|
+
expect(screen.getByText('START')).toBeInTheDocument();
|
|
38
|
+
expect(screen.getByText('END')).toBeInTheDocument();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('splits virtualized content into one row per newline', () => {
|
|
42
|
+
const lines = ['line-a', 'line-b', 'line-c'];
|
|
43
|
+
// Pad with a single long line so total length crosses the threshold,
|
|
44
|
+
// keeping the lines themselves short and matchable.
|
|
45
|
+
const body = `${lines.join('\n')}\n${'y'.repeat(50_001)}`;
|
|
46
|
+
render(<CodeBlock>{body}</CodeBlock>);
|
|
47
|
+
expect(screen.getByText('line-a')).toBeInTheDocument();
|
|
48
|
+
expect(screen.getByText('line-b')).toBeInTheDocument();
|
|
49
|
+
expect(screen.getByText('line-c')).toBeInTheDocument();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('renders React-element children unchanged regardless of nested content size', () => {
|
|
53
|
+
// typeof children !== 'string' MUST take precedence — even if the
|
|
54
|
+
// wrapped element contains an enormous string internally, CodeBlock
|
|
55
|
+
// should stay on the flat <pre> path because the children prop
|
|
56
|
+
// itself is a React element, not a string.
|
|
57
|
+
const huge = 'z'.repeat(50_001);
|
|
58
|
+
const { container } = render(
|
|
59
|
+
<CodeBlock>
|
|
60
|
+
<div data-testid="custom-child">{huge}</div>
|
|
61
|
+
</CodeBlock>,
|
|
62
|
+
);
|
|
63
|
+
expect(container.querySelector('pre')).toBeInTheDocument();
|
|
64
|
+
expect(screen.queryByTestId('virtuoso-mock')).toBeNull();
|
|
65
|
+
expect(screen.getByTestId('custom-child')).toBeInTheDocument();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('forwards className to the flat <pre> branch', () => {
|
|
69
|
+
const { container } = render(
|
|
70
|
+
<CodeBlock className="extra-class">{'short'}</CodeBlock>,
|
|
71
|
+
);
|
|
72
|
+
const pre = container.querySelector('pre');
|
|
73
|
+
expect(pre).toBeInTheDocument();
|
|
74
|
+
expect(pre?.className).toContain('extra-class');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('forwards className to the virtualized branch via Virtuoso', () => {
|
|
78
|
+
const { container } = render(
|
|
79
|
+
<CodeBlock className="extra-class">{'a'.repeat(50_001)}</CodeBlock>,
|
|
80
|
+
);
|
|
81
|
+
const mockRoot = container.querySelector('[data-testid="virtuoso-mock"]');
|
|
82
|
+
expect(mockRoot).toBeInTheDocument();
|
|
83
|
+
// The vi.mock passthrough forwards `className` onto the wrapper so
|
|
84
|
+
// we can assert the prop reached Virtuoso. The real Virtuoso applies
|
|
85
|
+
// it to its outer scroll container, giving the same dark-bg /
|
|
86
|
+
// monospace / border styling as the flat <pre> branch.
|
|
87
|
+
expect(mockRoot?.className).toContain('extra-class');
|
|
88
|
+
});
|
|
89
|
+
});
|