@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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import type { ReactElement } from 'react';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { render, screen } from '@testing-library/react';
|
|
5
|
+
import '@testing-library/jest-dom/vitest';
|
|
6
|
+
import { binaryTooLargeRenderer } from '../binary-too-large';
|
|
7
|
+
|
|
8
|
+
const ctx = {
|
|
9
|
+
contentType: 'image/jpeg',
|
|
10
|
+
url: 'https://example.com/huge.jpg',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
describe('binaryTooLargeRenderer', () => {
|
|
14
|
+
it('declares only the raw view — no toggle should appear', () => {
|
|
15
|
+
expect(binaryTooLargeRenderer.views).toEqual(['raw']);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('does not support override', () => {
|
|
19
|
+
expect(binaryTooLargeRenderer.supportsOverride).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('formats sub-1MB sizes in KB', () => {
|
|
23
|
+
render(
|
|
24
|
+
binaryTooLargeRenderer.render({
|
|
25
|
+
view: 'raw',
|
|
26
|
+
body: { kind: 'binary-too-large', size: 800 * 1024 },
|
|
27
|
+
ctx,
|
|
28
|
+
}) as ReactElement,
|
|
29
|
+
);
|
|
30
|
+
expect(screen.getByText(/800\.0 KB/)).toBeInTheDocument();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('formats multi-megabyte sizes in MB', () => {
|
|
34
|
+
render(
|
|
35
|
+
binaryTooLargeRenderer.render({
|
|
36
|
+
view: 'raw',
|
|
37
|
+
body: { kind: 'binary-too-large', size: 12_500_000 },
|
|
38
|
+
ctx,
|
|
39
|
+
}) as ReactElement,
|
|
40
|
+
);
|
|
41
|
+
expect(screen.getByText(/11\.9 MB/)).toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('surfaces the size inside the "Response too large for preview" message', () => {
|
|
45
|
+
render(
|
|
46
|
+
binaryTooLargeRenderer.render({
|
|
47
|
+
view: 'raw',
|
|
48
|
+
body: { kind: 'binary-too-large', size: 6_000_000 },
|
|
49
|
+
ctx,
|
|
50
|
+
}) as ReactElement,
|
|
51
|
+
);
|
|
52
|
+
expect(
|
|
53
|
+
screen.getByText(/Response too large for preview/),
|
|
54
|
+
).toBeInTheDocument();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import type { ReactElement } from 'react';
|
|
3
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
4
|
+
import { render, screen } from '@testing-library/react';
|
|
5
|
+
import { binaryRenderer } from '../binary';
|
|
6
|
+
import type { RenderCtx } from '../types';
|
|
7
|
+
|
|
8
|
+
const ctx: RenderCtx = {
|
|
9
|
+
contentType: 'application/pdf',
|
|
10
|
+
url: 'https://example.com/files/report.pdf',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const renderBinary = (base64 = 'AQID', override: Partial<RenderCtx> = {}) =>
|
|
14
|
+
render(
|
|
15
|
+
binaryRenderer.render({
|
|
16
|
+
view: 'raw',
|
|
17
|
+
body: { kind: 'binary', base64 },
|
|
18
|
+
ctx: { ...ctx, ...override },
|
|
19
|
+
}) as ReactElement,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
describe('binaryRenderer', () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
Object.defineProperty(URL, 'createObjectURL', {
|
|
25
|
+
configurable: true,
|
|
26
|
+
writable: true,
|
|
27
|
+
value: vi.fn(() => 'blob:fake-url'),
|
|
28
|
+
});
|
|
29
|
+
Object.defineProperty(URL, 'revokeObjectURL', {
|
|
30
|
+
configurable: true,
|
|
31
|
+
writable: true,
|
|
32
|
+
value: vi.fn(),
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('declares only a raw view — no toggle should appear', () => {
|
|
37
|
+
expect(binaryRenderer.views).toEqual(['raw']);
|
|
38
|
+
expect(binaryRenderer.defaultView).toBe('raw');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('does not support override', () => {
|
|
42
|
+
expect(binaryRenderer.supportsOverride).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('matches binary bodies whose content-type is not an image', () => {
|
|
46
|
+
expect(
|
|
47
|
+
binaryRenderer.matches('application/pdf', {
|
|
48
|
+
kind: 'binary',
|
|
49
|
+
base64: 'AQID',
|
|
50
|
+
}),
|
|
51
|
+
).toBe(true);
|
|
52
|
+
expect(
|
|
53
|
+
binaryRenderer.matches('application/octet-stream', {
|
|
54
|
+
kind: 'binary',
|
|
55
|
+
base64: 'AQID',
|
|
56
|
+
}),
|
|
57
|
+
).toBe(true);
|
|
58
|
+
expect(
|
|
59
|
+
binaryRenderer.matches('audio/mpeg', {
|
|
60
|
+
kind: 'binary',
|
|
61
|
+
base64: 'AQID',
|
|
62
|
+
}),
|
|
63
|
+
).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('declines image binary bodies (those belong to imageRenderer)', () => {
|
|
67
|
+
expect(
|
|
68
|
+
binaryRenderer.matches('image/png', {
|
|
69
|
+
kind: 'binary',
|
|
70
|
+
base64: 'AQID',
|
|
71
|
+
}),
|
|
72
|
+
).toBe(false);
|
|
73
|
+
expect(
|
|
74
|
+
binaryRenderer.matches('image/jpeg', {
|
|
75
|
+
kind: 'binary',
|
|
76
|
+
base64: 'AQID',
|
|
77
|
+
}),
|
|
78
|
+
).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('declines text bodies', () => {
|
|
82
|
+
expect(binaryRenderer.matches('text/plain', 'hello')).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('renders the metadata card + hex view together', () => {
|
|
86
|
+
renderBinary('AQID');
|
|
87
|
+
// Metadata card: size + filename from URL.
|
|
88
|
+
expect(screen.getByText('Size')).toBeInTheDocument();
|
|
89
|
+
expect(screen.getByText('3 bytes')).toBeInTheDocument();
|
|
90
|
+
expect(screen.getByText('report.pdf')).toBeInTheDocument();
|
|
91
|
+
// Hex view: offset, hex pair, ASCII column.
|
|
92
|
+
expect(screen.getByText('00000000')).toBeInTheDocument();
|
|
93
|
+
expect(screen.getByText('01 02 03')).toBeInTheDocument();
|
|
94
|
+
expect(screen.getByText('|...|')).toBeInTheDocument();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { findRenderer, renderers } from '../index';
|
|
3
|
+
import type { ResponseBody } from '../../../shared/client';
|
|
4
|
+
|
|
5
|
+
describe('findRenderer', () => {
|
|
6
|
+
it('routes null body to the empty renderer', () => {
|
|
7
|
+
expect(findRenderer('text/plain', null).id).toBe('empty');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('routes binary-too-large bodies to the binary-too-large renderer', () => {
|
|
11
|
+
const body: ResponseBody = { kind: 'binary-too-large', size: 9_999_999 };
|
|
12
|
+
expect(findRenderer('image/png', body).id).toBe('binary-too-large');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('routes image/svg+xml strings to the svg renderer (not the image renderer)', () => {
|
|
16
|
+
expect(findRenderer('image/svg+xml', '<svg/>').id).toBe('svg');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('routes image/png binary bodies to the image renderer', () => {
|
|
20
|
+
const body: ResponseBody = { kind: 'binary', base64: 'AQID' };
|
|
21
|
+
expect(findRenderer('image/png', body).id).toBe('image');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('routes image/jpeg binary bodies to the image renderer', () => {
|
|
25
|
+
const body: ResponseBody = { kind: 'binary', base64: 'AQID' };
|
|
26
|
+
expect(findRenderer('image/jpeg', body).id).toBe('image');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('routes non-image binary bodies to the binary renderer', () => {
|
|
30
|
+
const body: ResponseBody = { kind: 'binary', base64: 'AQID' };
|
|
31
|
+
expect(findRenderer('application/pdf', body).id).toBe('binary');
|
|
32
|
+
expect(findRenderer('application/octet-stream', body).id).toBe('binary');
|
|
33
|
+
expect(findRenderer('font/woff2', body).id).toBe('binary');
|
|
34
|
+
expect(findRenderer('audio/mpeg', body).id).toBe('binary');
|
|
35
|
+
expect(findRenderer('video/mp4', body).id).toBe('binary');
|
|
36
|
+
expect(findRenderer('application/zip', body).id).toBe('binary');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('routes JSON content-types to the json renderer', () => {
|
|
40
|
+
expect(findRenderer('application/json', '{}').id).toBe('json');
|
|
41
|
+
expect(findRenderer('application/json; charset=utf-8', '{}').id).toBe(
|
|
42
|
+
'json',
|
|
43
|
+
);
|
|
44
|
+
expect(findRenderer('application/ld+json', '{}').id).toBe('json');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('routes text/html (and its charset/case variants) to the html renderer', () => {
|
|
48
|
+
expect(findRenderer('text/html', '<p/>').id).toBe('html');
|
|
49
|
+
expect(findRenderer('text/html; charset=utf-8', '<p/>').id).toBe('html');
|
|
50
|
+
// Case-insensitive — proves we use normalizeContentType, not startsWith.
|
|
51
|
+
expect(findRenderer('text/HTML', '<p/>').id).toBe('html');
|
|
52
|
+
// Empty-string HTML body is still claimed by html (matcher is
|
|
53
|
+
// content-type-driven; the renderer happily produces a blank iframe).
|
|
54
|
+
expect(findRenderer('text/html', '').id).toBe('html');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('routes XML content-types (and RFC 7303 +xml variants) to the xml renderer', () => {
|
|
58
|
+
expect(findRenderer('application/xml', '<x/>').id).toBe('xml');
|
|
59
|
+
expect(findRenderer('text/xml', '<x/>').id).toBe('xml');
|
|
60
|
+
expect(findRenderer('application/atom+xml', '<feed/>').id).toBe('xml');
|
|
61
|
+
expect(findRenderer('application/rss+xml', '<rss/>').id).toBe('xml');
|
|
62
|
+
expect(findRenderer('application/soap+xml; charset=utf-8', '<e/>').id).toBe(
|
|
63
|
+
'xml',
|
|
64
|
+
);
|
|
65
|
+
expect(findRenderer('application/xhtml+xml', '<html/>').id).toBe('xml');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('keeps image/svg+xml routed to svg even though it would match the +xml suffix', () => {
|
|
69
|
+
// SVG matches isXmlContentType (it ends with +xml), but the registry
|
|
70
|
+
// order places svgRenderer earlier — first hit wins.
|
|
71
|
+
expect(findRenderer('image/svg+xml', '<svg/>').id).toBe('svg');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('routes text/plain, application/javascript to the text fallback', () => {
|
|
75
|
+
expect(findRenderer('text/plain', 'hi').id).toBe('text-fallback');
|
|
76
|
+
expect(findRenderer('application/javascript', 'var a;').id).toBe(
|
|
77
|
+
'text-fallback',
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('routes string bodies with unknown content-types to the text fallback', () => {
|
|
82
|
+
expect(findRenderer('application/x-weird', 'hello').id).toBe(
|
|
83
|
+
'text-fallback',
|
|
84
|
+
);
|
|
85
|
+
expect(findRenderer('', 'hello').id).toBe('text-fallback');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('falls through to the unknown renderer for unhandled non-null object bodies', () => {
|
|
89
|
+
// Build an off-union body shape to confirm the defensive last
|
|
90
|
+
// resort fires — should never happen in practice.
|
|
91
|
+
const offUnion = { kind: 'something-else' } as unknown as ResponseBody;
|
|
92
|
+
expect(findRenderer('application/octet-stream', offUnion).id).toBe(
|
|
93
|
+
'unknown',
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('places more specific matchers ahead of more general ones in the array', () => {
|
|
98
|
+
const ids = renderers.map((r) => r.id);
|
|
99
|
+
// svg must precede image; binary-too-large must precede image;
|
|
100
|
+
// image must precede binary (both claim body.kind === 'binary');
|
|
101
|
+
// empty must precede everything; unknown is the last entry.
|
|
102
|
+
expect(ids.indexOf('svg')).toBeLessThan(ids.indexOf('image'));
|
|
103
|
+
expect(ids.indexOf('binary-too-large')).toBeLessThan(ids.indexOf('image'));
|
|
104
|
+
expect(ids.indexOf('image')).toBeLessThan(ids.indexOf('binary'));
|
|
105
|
+
// html must precede text-fallback, otherwise text-fallback would
|
|
106
|
+
// claim every text/html body before html ever ran.
|
|
107
|
+
expect(ids.indexOf('html')).toBeLessThan(ids.indexOf('text-fallback'));
|
|
108
|
+
// svg must precede xml because image/svg+xml matches both
|
|
109
|
+
// predicates — registry order is the tiebreaker.
|
|
110
|
+
expect(ids.indexOf('svg')).toBeLessThan(ids.indexOf('xml'));
|
|
111
|
+
// xml must precede text-fallback, otherwise text-fallback would
|
|
112
|
+
// claim every XML-ish body before xml ever ran.
|
|
113
|
+
expect(ids.indexOf('xml')).toBeLessThan(ids.indexOf('text-fallback'));
|
|
114
|
+
expect(ids.indexOf('empty')).toBe(0);
|
|
115
|
+
expect(ids.indexOf('unknown')).toBe(ids.length - 1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('every renderer advertises a defaultView that appears in its views (or has empty views)', () => {
|
|
119
|
+
for (const renderer of renderers) {
|
|
120
|
+
if (renderer.views.length === 0) continue;
|
|
121
|
+
expect(renderer.views).toContain(renderer.defaultView);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|