@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/devtools/App.html +2 -2
  3. package/dist/devtools/assets/{App-m6xge0az.css → App-CUXU0mup.css} +152 -2
  4. package/dist/devtools/assets/{App-hSoryVpJ.js → App-DsimzJvx.js} +6827 -970
  5. package/dist/react-native/chunks/boot-recording.cjs +138 -14
  6. package/dist/react-native/chunks/boot-recording.js +138 -14
  7. package/dist/react-native/chunks/get-nitro-module.cjs +4 -1
  8. package/dist/react-native/chunks/get-nitro-module.js +4 -1
  9. package/dist/react-native/chunks/useNetworkActivityDevTools.require.cjs +20 -1
  10. package/dist/react-native/chunks/useNetworkActivityDevTools.require.js +20 -1
  11. package/dist/react-native/index.d.ts +37 -1
  12. package/dist/rozenite.json +1 -1
  13. package/dist/sdk/index.d.ts +37 -1
  14. package/package.json +12 -7
  15. package/src/react-native/agent/use-network-activity-agent-tools.ts +22 -4
  16. package/src/react-native/http/__tests__/http-utils.test.ts +228 -0
  17. package/src/react-native/http/http-utils.ts +208 -25
  18. package/src/react-native/network-inspector.ts +2 -2
  19. package/src/react-native/nitro-fetch/get-nitro-module.ts +5 -1
  20. package/src/react-native/nitro-fetch/nitro-network-inspector.ts +8 -2
  21. package/src/shared/http-events.ts +40 -1
  22. package/src/ui/components/CodeBlock.tsx +45 -1
  23. package/src/ui/components/FilterBar.tsx +366 -58
  24. package/src/ui/components/HexView.tsx +54 -0
  25. package/src/ui/components/MetadataCard.tsx +95 -0
  26. package/src/ui/components/RequestList.tsx +192 -34
  27. package/src/ui/components/SidePanel.tsx +42 -1
  28. package/src/ui/components/ViewToggle.tsx +44 -0
  29. package/src/ui/components/XmlTree.tsx +160 -0
  30. package/src/ui/components/__tests__/CodeBlock.test.tsx +89 -0
  31. package/src/ui/components/__tests__/HexView.test.tsx +41 -0
  32. package/src/ui/components/__tests__/MetadataCard.test.tsx +107 -0
  33. package/src/ui/components/__tests__/ViewToggle.test.tsx +80 -0
  34. package/src/ui/components/__tests__/XmlTree.test.tsx +149 -0
  35. package/src/ui/response-renderers/__tests__/binary-too-large.test.tsx +56 -0
  36. package/src/ui/response-renderers/__tests__/binary.test.tsx +96 -0
  37. package/src/ui/response-renderers/__tests__/dispatch.test.ts +124 -0
  38. package/src/ui/response-renderers/__tests__/html.test.tsx +101 -0
  39. package/src/ui/response-renderers/__tests__/image.test.tsx +73 -0
  40. package/src/ui/response-renderers/__tests__/json.test.tsx +95 -0
  41. package/src/ui/response-renderers/__tests__/svg.test.tsx +46 -0
  42. package/src/ui/response-renderers/__tests__/xml.test.tsx +100 -0
  43. package/src/ui/response-renderers/binary-too-large.tsx +36 -0
  44. package/src/ui/response-renderers/binary.tsx +31 -0
  45. package/src/ui/response-renderers/empty.tsx +14 -0
  46. package/src/ui/response-renderers/html.tsx +36 -0
  47. package/src/ui/response-renderers/image.tsx +37 -0
  48. package/src/ui/response-renderers/index.ts +55 -0
  49. package/src/ui/response-renderers/json.tsx +40 -0
  50. package/src/ui/response-renderers/svg.tsx +27 -0
  51. package/src/ui/response-renderers/text-fallback.tsx +14 -0
  52. package/src/ui/response-renderers/types.ts +38 -0
  53. package/src/ui/response-renderers/unknown.tsx +18 -0
  54. package/src/ui/response-renderers/xml.tsx +46 -0
  55. package/src/ui/state/derived.ts +12 -0
  56. package/src/ui/state/model.ts +6 -1
  57. package/src/ui/state/store.ts +39 -2
  58. package/src/ui/tabs/InitiatorTab.tsx +230 -0
  59. package/src/ui/tabs/ResponseTab.tsx +80 -97
  60. package/src/ui/tabs/__tests__/ResponseTab.test.tsx +102 -0
  61. package/src/ui/utils/__tests__/download.test.ts +115 -0
  62. package/src/ui/utils/__tests__/hex.test.ts +84 -0
  63. package/src/ui/utils/__tests__/symbolication.test.ts +207 -0
  64. package/src/ui/utils/download.ts +154 -0
  65. package/src/ui/utils/hex.ts +59 -0
  66. package/src/ui/utils/initiator.ts +136 -0
  67. package/src/ui/utils/symbolication.ts +248 -0
  68. package/src/ui/views/InspectorView.tsx +8 -5
  69. package/src/utils/__tests__/getContentTypeMimeType.test.ts +34 -0
  70. package/src/utils/getContentTypeMimeType.ts +14 -0
  71. package/vite.config.ts +5 -1
  72. 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 { RequestId, RequestOverride } from '../../shared/client';
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
- method: string;
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
- type: string;
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
- return processNetworkRequests(
287
- filteredRequests,
443
+ const allRequests = processNetworkRequests(
444
+ processedRequests,
288
445
  overrides,
289
446
  clientUISettings?.showUrlAsName,
290
447
  );
291
- }, [filteredRequests, overrides, clientUISettings?.showUrlAsName]);
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 className="grid w-full grid-cols-5 bg-gray-800 rounded-none border-b border-gray-700">
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">&lt;</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">&lt;/</span>
106
+ <span className="text-blue-400">{tagName}</span>
107
+ <span className="text-gray-500">&gt;</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">&quot;</span>
121
+ <span className="text-green-400">{attr.value}</span>
122
+ <span className="text-gray-500">&quot;</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">&lt;![CDATA[</span>
154
+ <span className="text-gray-200 whitespace-pre-wrap">{value}</span>
155
+ <span className="text-purple-400">]]&gt;</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
+ });