@rozenite/network-activity-plugin 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/dist/devtools/App.html +2 -2
- package/dist/devtools/assets/{App-hSoryVpJ.js → App-CEESZAW_.js} +7520 -937
- package/dist/devtools/assets/{App-m6xge0az.css → App-xppYUJvX.css} +246 -2
- 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 +337 -61
- package/src/ui/components/HexView.tsx +54 -0
- package/src/ui/components/MetadataCard.tsx +95 -0
- package/src/ui/components/NetworkTimeline.tsx +422 -0
- package/src/ui/components/RequestList.tsx +19 -40
- package/src/ui/components/SidePanel.tsx +42 -1
- package/src/ui/components/Toolbar.tsx +13 -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/hooks/useNetworkActivitySessionExport.ts +39 -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/__tests__/store.test.ts +77 -0
- package/src/ui/state/derived.ts +14 -0
- package/src/ui/state/filter.ts +49 -0
- package/src/ui/state/hooks.ts +2 -2
- package/src/ui/state/model.ts +7 -1
- package/src/ui/state/store.ts +63 -4
- 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__/requestFilters.test.ts +32 -0
- package/src/ui/utils/__tests__/sessionExport.test.ts +174 -0
- package/src/ui/utils/__tests__/symbolication.test.ts +207 -0
- package/src/ui/utils/__tests__/timelineModel.test.ts +170 -0
- package/src/ui/utils/download.ts +161 -0
- package/src/ui/utils/hex.ts +59 -0
- package/src/ui/utils/initiator.ts +136 -0
- package/src/ui/utils/requestFilters.ts +183 -0
- package/src/ui/utils/sessionExport.ts +185 -0
- package/src/ui/utils/symbolication.ts +248 -0
- package/src/ui/utils/timelineModel.ts +352 -0
- package/src/ui/views/InspectorView.tsx +43 -8
- 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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { getDefaultNormalizer, render, screen } from '@testing-library/react';
|
|
4
|
+
import { HexView } from '../HexView';
|
|
5
|
+
|
|
6
|
+
// Preserve the post-byte-8 double-space gap when matching hex rows —
|
|
7
|
+
// RTL's default normalizer collapses it.
|
|
8
|
+
const preserveWhitespace = {
|
|
9
|
+
normalizer: getDefaultNormalizer({ collapseWhitespace: false }),
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe('HexView', () => {
|
|
13
|
+
it('renders a "no bytes" placeholder for an empty buffer', () => {
|
|
14
|
+
render(<HexView bytes={new Uint8Array(0)} />);
|
|
15
|
+
expect(screen.getByText(/no bytes to display/i)).toBeInTheDocument();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('renders a row for a single-byte buffer', () => {
|
|
19
|
+
render(<HexView bytes={new Uint8Array([0x41])} />);
|
|
20
|
+
// Offset + hex pair are present somewhere in the rendered output.
|
|
21
|
+
expect(screen.getByText('00000000')).toBeInTheDocument();
|
|
22
|
+
expect(screen.getByText('41')).toBeInTheDocument();
|
|
23
|
+
// ASCII column wraps the printable representation in pipes.
|
|
24
|
+
expect(screen.getByText('|A|')).toBeInTheDocument();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('renders a fully-formatted row with the post-byte-8 gap and ASCII column', () => {
|
|
28
|
+
const bytes = new Uint8Array([
|
|
29
|
+
0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x21,
|
|
30
|
+
0x0a, 0x0d, 0x09, 0xff,
|
|
31
|
+
]);
|
|
32
|
+
render(<HexView bytes={bytes} />);
|
|
33
|
+
expect(
|
|
34
|
+
screen.getByText(
|
|
35
|
+
'48 65 6C 6C 6F 20 77 6F 72 6C 64 21 0A 0D 09 FF',
|
|
36
|
+
preserveWhitespace,
|
|
37
|
+
),
|
|
38
|
+
).toBeInTheDocument();
|
|
39
|
+
expect(screen.getByText('|Hello world!....|')).toBeInTheDocument();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
4
|
+
import { MetadataCard } from '../MetadataCard';
|
|
5
|
+
import type { RenderCtx } from '../../response-renderers/types';
|
|
6
|
+
|
|
7
|
+
const baseCtx: RenderCtx = {
|
|
8
|
+
contentType: 'application/pdf',
|
|
9
|
+
url: 'https://example.com/api/files/report.pdf',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe('MetadataCard', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
// jsdom doesn't implement URL.createObjectURL — stub it for the
|
|
15
|
+
// download click test.
|
|
16
|
+
Object.defineProperty(URL, 'createObjectURL', {
|
|
17
|
+
configurable: true,
|
|
18
|
+
writable: true,
|
|
19
|
+
value: vi.fn(() => 'blob:fake-url'),
|
|
20
|
+
});
|
|
21
|
+
Object.defineProperty(URL, 'revokeObjectURL', {
|
|
22
|
+
configurable: true,
|
|
23
|
+
writable: true,
|
|
24
|
+
value: vi.fn(),
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('shows the decoded byte size for a binary body', () => {
|
|
29
|
+
// "AQID" decodes to 3 bytes.
|
|
30
|
+
render(
|
|
31
|
+
<MetadataCard body={{ kind: 'binary', base64: 'AQID' }} ctx={baseCtx} />,
|
|
32
|
+
);
|
|
33
|
+
expect(screen.getByText('Size')).toBeInTheDocument();
|
|
34
|
+
expect(screen.getByText('3 bytes')).toBeInTheDocument();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('surfaces the Content-Length header when present', () => {
|
|
38
|
+
render(
|
|
39
|
+
<MetadataCard
|
|
40
|
+
body={{ kind: 'binary', base64: 'AQID' }}
|
|
41
|
+
ctx={{ ...baseCtx, headers: { 'Content-Length': '8192' } }}
|
|
42
|
+
/>,
|
|
43
|
+
);
|
|
44
|
+
expect(screen.getByText('Content-Length')).toBeInTheDocument();
|
|
45
|
+
expect(screen.getByText('8192')).toBeInTheDocument();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('uses the Content-Disposition filename when available', () => {
|
|
49
|
+
render(
|
|
50
|
+
<MetadataCard
|
|
51
|
+
body={{ kind: 'binary', base64: 'AQID' }}
|
|
52
|
+
ctx={{
|
|
53
|
+
...baseCtx,
|
|
54
|
+
headers: {
|
|
55
|
+
'Content-Disposition': 'attachment; filename="custom.pdf"',
|
|
56
|
+
},
|
|
57
|
+
}}
|
|
58
|
+
/>,
|
|
59
|
+
);
|
|
60
|
+
expect(screen.getByText('Filename')).toBeInTheDocument();
|
|
61
|
+
expect(screen.getByText('custom.pdf')).toBeInTheDocument();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('falls back to the URL last path segment for the filename', () => {
|
|
65
|
+
render(
|
|
66
|
+
<MetadataCard body={{ kind: 'binary', base64: 'AQID' }} ctx={baseCtx} />,
|
|
67
|
+
);
|
|
68
|
+
expect(screen.getByText('report.pdf')).toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('shows an enabled Download button for a binary body', () => {
|
|
72
|
+
render(
|
|
73
|
+
<MetadataCard body={{ kind: 'binary', base64: 'AQID' }} ctx={baseCtx} />,
|
|
74
|
+
);
|
|
75
|
+
const button = screen.getByRole('button', { name: /download/i });
|
|
76
|
+
expect(button).not.toBeDisabled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('fires the download flow when the button is clicked', () => {
|
|
80
|
+
render(
|
|
81
|
+
<MetadataCard body={{ kind: 'binary', base64: 'AQID' }} ctx={baseCtx} />,
|
|
82
|
+
);
|
|
83
|
+
fireEvent.click(screen.getByRole('button', { name: /download/i }));
|
|
84
|
+
expect(URL.createObjectURL).toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('disables Download for the binary-too-large variant', () => {
|
|
88
|
+
render(
|
|
89
|
+
<MetadataCard
|
|
90
|
+
body={{ kind: 'binary-too-large', size: 9_999_999 }}
|
|
91
|
+
ctx={baseCtx}
|
|
92
|
+
/>,
|
|
93
|
+
);
|
|
94
|
+
const button = screen.getByRole('button', { name: /download/i });
|
|
95
|
+
expect(button).toBeDisabled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('renders the body.size for binary-too-large entries', () => {
|
|
99
|
+
render(
|
|
100
|
+
<MetadataCard
|
|
101
|
+
body={{ kind: 'binary-too-large', size: 8 * 1024 * 1024 }}
|
|
102
|
+
ctx={baseCtx}
|
|
103
|
+
/>,
|
|
104
|
+
);
|
|
105
|
+
expect(screen.getByText('8.0 MB')).toBeInTheDocument();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
4
|
+
import '@testing-library/jest-dom/vitest';
|
|
5
|
+
import { ViewToggle } from '../ViewToggle';
|
|
6
|
+
|
|
7
|
+
describe('ViewToggle', () => {
|
|
8
|
+
it('renders nothing when only one view is available (adaptive)', () => {
|
|
9
|
+
const { container } = render(
|
|
10
|
+
<ViewToggle views={['raw']} value="raw" onChange={() => {}} />,
|
|
11
|
+
);
|
|
12
|
+
expect(container.firstChild).toBeNull();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('renders nothing when the renderer offers no views', () => {
|
|
16
|
+
const { container } = render(
|
|
17
|
+
<ViewToggle views={[]} value="raw" onChange={() => {}} />,
|
|
18
|
+
);
|
|
19
|
+
expect(container.firstChild).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('renders both labels when preview and raw are available', () => {
|
|
23
|
+
render(
|
|
24
|
+
<ViewToggle
|
|
25
|
+
views={['preview', 'raw']}
|
|
26
|
+
value="preview"
|
|
27
|
+
onChange={() => {}}
|
|
28
|
+
/>,
|
|
29
|
+
);
|
|
30
|
+
expect(screen.getByRole('tab', { name: 'Preview' })).toBeInTheDocument();
|
|
31
|
+
expect(screen.getByRole('tab', { name: 'Raw' })).toBeInTheDocument();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('marks the active view as aria-selected', () => {
|
|
35
|
+
render(
|
|
36
|
+
<ViewToggle views={['preview', 'raw']} value="raw" onChange={() => {}} />,
|
|
37
|
+
);
|
|
38
|
+
expect(screen.getByRole('tab', { name: 'Raw' })).toHaveAttribute(
|
|
39
|
+
'aria-selected',
|
|
40
|
+
'true',
|
|
41
|
+
);
|
|
42
|
+
expect(screen.getByRole('tab', { name: 'Preview' })).toHaveAttribute(
|
|
43
|
+
'aria-selected',
|
|
44
|
+
'false',
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('fires onChange with the clicked view', () => {
|
|
49
|
+
const onChange = vi.fn();
|
|
50
|
+
render(
|
|
51
|
+
<ViewToggle
|
|
52
|
+
views={['preview', 'raw']}
|
|
53
|
+
value="preview"
|
|
54
|
+
onChange={onChange}
|
|
55
|
+
/>,
|
|
56
|
+
);
|
|
57
|
+
fireEvent.click(screen.getByRole('tab', { name: 'Raw' }));
|
|
58
|
+
expect(onChange).toHaveBeenCalledExactlyOnceWith('raw');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('stops click propagation so toggle clicks do not collapse the parent Section', () => {
|
|
62
|
+
// The toggle lives inside the Section header, which is itself a
|
|
63
|
+
// <button> that collapses on click — without stopPropagation, every
|
|
64
|
+
// toggle click would also collapse the section.
|
|
65
|
+
const onChange = vi.fn();
|
|
66
|
+
const onParentClick = vi.fn();
|
|
67
|
+
render(
|
|
68
|
+
<div onClick={onParentClick}>
|
|
69
|
+
<ViewToggle
|
|
70
|
+
views={['preview', 'raw']}
|
|
71
|
+
value="preview"
|
|
72
|
+
onChange={onChange}
|
|
73
|
+
/>
|
|
74
|
+
</div>,
|
|
75
|
+
);
|
|
76
|
+
fireEvent.click(screen.getByRole('tab', { name: 'Raw' }));
|
|
77
|
+
expect(onChange).toHaveBeenCalled();
|
|
78
|
+
expect(onParentClick).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
4
|
+
import '@testing-library/jest-dom/vitest';
|
|
5
|
+
import { XmlTree } from '../XmlTree';
|
|
6
|
+
|
|
7
|
+
const parseXml = (source: string): Element => {
|
|
8
|
+
const doc = new DOMParser().parseFromString(source, 'application/xml');
|
|
9
|
+
return doc.documentElement;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe('XmlTree', () => {
|
|
13
|
+
it('renders the root tag name', () => {
|
|
14
|
+
render(<XmlTree root={parseXml('<feed/>')} />);
|
|
15
|
+
// Self-closing — no closing tag, so a single occurrence is expected.
|
|
16
|
+
expect(screen.getAllByText('feed')).toHaveLength(1);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('renders attributes inline with the open tag', () => {
|
|
20
|
+
render(<XmlTree root={parseXml('<entry id="42" lang="en"/>')} />);
|
|
21
|
+
expect(screen.getByText('id')).toBeInTheDocument();
|
|
22
|
+
expect(screen.getByText('42')).toBeInTheDocument();
|
|
23
|
+
expect(screen.getByText('lang')).toBeInTheDocument();
|
|
24
|
+
expect(screen.getByText('en')).toBeInTheDocument();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('renders non-whitespace text content', () => {
|
|
28
|
+
render(<XmlTree root={parseXml('<title>Hello world</title>')} />);
|
|
29
|
+
expect(screen.getByText('Hello world')).toBeInTheDocument();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('filters whitespace-only text nodes between sibling elements', () => {
|
|
33
|
+
// Pretty-printed XML has whitespace text nodes between every sibling
|
|
34
|
+
// element. After filtering, only the two <item> elements survive —
|
|
35
|
+
// no stray text nodes containing the source's `\n ` indentation.
|
|
36
|
+
const { container } = render(
|
|
37
|
+
<XmlTree
|
|
38
|
+
root={parseXml('<list>\n <item>a</item>\n <item>b</item>\n</list>')}
|
|
39
|
+
/>,
|
|
40
|
+
);
|
|
41
|
+
// Each <item> renders as open + close tag, so 2 items × 2 = 4
|
|
42
|
+
// occurrences of "item" text.
|
|
43
|
+
expect(screen.getAllByText('item')).toHaveLength(4);
|
|
44
|
+
// The leaf text values are present.
|
|
45
|
+
expect(screen.getByText('a')).toBeInTheDocument();
|
|
46
|
+
expect(screen.getByText('b')).toBeInTheDocument();
|
|
47
|
+
// The original source's whitespace indentation between <item>
|
|
48
|
+
// siblings should not appear as a standalone text node. The exact
|
|
49
|
+
// pattern "\n " (newline + two spaces) was between siblings; if
|
|
50
|
+
// we'd rendered it, the textContent would contain that fragment
|
|
51
|
+
// outside of the element tags.
|
|
52
|
+
const allText = container.textContent ?? '';
|
|
53
|
+
expect(allText).not.toMatch(/<\/item>\s*\n\s+<item>/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('renders CDATA content wrapped in <![CDATA[ ... ]]> markers', () => {
|
|
57
|
+
render(
|
|
58
|
+
<XmlTree root={parseXml('<content><![CDATA[<p>html</p>]]></content>')} />,
|
|
59
|
+
);
|
|
60
|
+
expect(screen.getByText('<![CDATA[')).toBeInTheDocument();
|
|
61
|
+
expect(screen.getByText(']]>')).toBeInTheDocument();
|
|
62
|
+
expect(screen.getByText('<p>html</p>')).toBeInTheDocument();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('recursively renders nested elements', () => {
|
|
66
|
+
render(
|
|
67
|
+
<XmlTree
|
|
68
|
+
root={parseXml('<feed><entry><title>T</title></entry></feed>')}
|
|
69
|
+
/>,
|
|
70
|
+
);
|
|
71
|
+
// Each non-self-closing element renders open + close, hence 2x.
|
|
72
|
+
expect(screen.getAllByText('feed')).toHaveLength(2);
|
|
73
|
+
expect(screen.getAllByText('entry')).toHaveLength(2);
|
|
74
|
+
expect(screen.getAllByText('title')).toHaveLength(2);
|
|
75
|
+
expect(screen.getByText('T')).toBeInTheDocument();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('hides children via display:none on collapse (does not unmount)', () => {
|
|
79
|
+
// The whole point of using display:none rather than `{!collapsed
|
|
80
|
+
// && <Children/>}` is that nested collapse state survives a parent
|
|
81
|
+
// collapse/expand cycle. Verify the mechanism directly at the DOM
|
|
82
|
+
// level: after collapsing, the inner element's tag name STILL
|
|
83
|
+
// appears in the rendered tree (it's in the DOM, just hidden).
|
|
84
|
+
const { container } = render(
|
|
85
|
+
<XmlTree root={parseXml('<outer><inner>v</inner></outer>')} />,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// Both elements visible initially.
|
|
89
|
+
expect(container.textContent).toContain('inner');
|
|
90
|
+
|
|
91
|
+
// Collapse outer. There is one chevron button on outer (since
|
|
92
|
+
// outer has a renderable child); click it.
|
|
93
|
+
const chevronButtons = container.querySelectorAll('button');
|
|
94
|
+
fireEvent.click(chevronButtons[0]);
|
|
95
|
+
|
|
96
|
+
// Inner's tag name MUST still be in the DOM textContent — that's
|
|
97
|
+
// the unmount-vs-display-none assertion. If we used unmount, the
|
|
98
|
+
// string "inner" would have vanished entirely.
|
|
99
|
+
expect(container.textContent).toContain('inner');
|
|
100
|
+
|
|
101
|
+
// The children container should have display:none applied so the
|
|
102
|
+
// inner element is visually hidden.
|
|
103
|
+
const hiddenContainers = container.querySelectorAll(
|
|
104
|
+
'div[style*="display: none"]',
|
|
105
|
+
);
|
|
106
|
+
expect(hiddenContainers.length).toBeGreaterThan(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('toggles the chevron icon aria-label between Collapse and Expand', () => {
|
|
110
|
+
const { container } = render(
|
|
111
|
+
<XmlTree root={parseXml('<outer><inner/></outer>')} />,
|
|
112
|
+
);
|
|
113
|
+
const button = container.querySelector('button');
|
|
114
|
+
if (!button) throw new Error('expected a chevron button');
|
|
115
|
+
expect(button.getAttribute('aria-label')).toBe('Collapse');
|
|
116
|
+
fireEvent.click(button);
|
|
117
|
+
expect(button.getAttribute('aria-label')).toBe('Expand');
|
|
118
|
+
fireEvent.click(button);
|
|
119
|
+
expect(button.getAttribute('aria-label')).toBe('Collapse');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('renders a self-closing form when the element has no renderable children', () => {
|
|
123
|
+
const { container } = render(<XmlTree root={parseXml('<empty/>')} />);
|
|
124
|
+
// Self-closing should render as `<empty />` — i.e. there should be
|
|
125
|
+
// no separate closing tag span. Look for the inline " />" marker.
|
|
126
|
+
expect(container.textContent).toContain('/>');
|
|
127
|
+
// No closing-tag line — i.e. no `</empty>` rendered.
|
|
128
|
+
expect(container.textContent ?? '').not.toContain('</empty>');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('renders a closing tag for elements with children', () => {
|
|
132
|
+
const { container } = render(
|
|
133
|
+
<XmlTree root={parseXml('<wrap><child/></wrap>')} />,
|
|
134
|
+
);
|
|
135
|
+
// wrap should have `</wrap>` closing tag visible.
|
|
136
|
+
expect(container.textContent ?? '').toContain('</');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('preserves namespace prefixes in element names', () => {
|
|
140
|
+
render(
|
|
141
|
+
<XmlTree
|
|
142
|
+
root={parseXml(
|
|
143
|
+
'<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/"><media:thumbnail/></feed>',
|
|
144
|
+
)}
|
|
145
|
+
/>,
|
|
146
|
+
);
|
|
147
|
+
expect(screen.getByText('media:thumbnail')).toBeInTheDocument();
|
|
148
|
+
});
|
|
149
|
+
});
|