@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,101 @@
|
|
|
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 { htmlRenderer } from '../html';
|
|
7
|
+
import type { RenderCtx } from '../types';
|
|
8
|
+
|
|
9
|
+
const baseCtx: RenderCtx = {
|
|
10
|
+
contentType: 'text/html',
|
|
11
|
+
url: 'https://example.com/page.html',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const HTML_BODY = '<!DOCTYPE html><html><body><h1>Hello</h1></body></html>';
|
|
15
|
+
|
|
16
|
+
const renderHtml = (view: 'preview' | 'raw', body = HTML_BODY) =>
|
|
17
|
+
render(htmlRenderer.render({ view, body, ctx: baseCtx }) as ReactElement);
|
|
18
|
+
|
|
19
|
+
const getIframe = (container: HTMLElement): HTMLIFrameElement => {
|
|
20
|
+
const iframe = container.querySelector('iframe');
|
|
21
|
+
if (!iframe) throw new Error('expected an <iframe> in the rendered output');
|
|
22
|
+
return iframe;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('htmlRenderer', () => {
|
|
26
|
+
it('declares both preview and raw views with preview as default', () => {
|
|
27
|
+
expect(htmlRenderer.views).toEqual(['preview', 'raw']);
|
|
28
|
+
expect(htmlRenderer.defaultView).toBe('preview');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('supports override (HTML bodies are strings — the editor works on them)', () => {
|
|
32
|
+
expect(htmlRenderer.supportsOverride).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('preview view', () => {
|
|
36
|
+
it('renders a sandboxed iframe', () => {
|
|
37
|
+
const { container } = renderHtml('preview');
|
|
38
|
+
const iframe = getIframe(container);
|
|
39
|
+
// `sandbox=""` (empty value = all restrictions applied) is the
|
|
40
|
+
// primary defense. Loosening this would let scripts run in the
|
|
41
|
+
// captured response — never do it.
|
|
42
|
+
expect(iframe.getAttribute('sandbox')).toBe('');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('prepends the CSP meta tag to the srcdoc before the body', () => {
|
|
46
|
+
const { container } = renderHtml('preview');
|
|
47
|
+
const srcdoc = getIframe(container).getAttribute('srcdoc') ?? '';
|
|
48
|
+
expect(
|
|
49
|
+
srcdoc.startsWith('<meta http-equiv="Content-Security-Policy"'),
|
|
50
|
+
).toBe(true);
|
|
51
|
+
expect(srcdoc).toContain("default-src 'none'");
|
|
52
|
+
expect(srcdoc).toContain("style-src 'unsafe-inline'");
|
|
53
|
+
expect(srcdoc).toContain('img-src data:');
|
|
54
|
+
expect(srcdoc.endsWith(HTML_BODY)).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('preserves the body verbatim — no stripping, no escaping', () => {
|
|
58
|
+
const body = '<p>1 < 2</p><!-- comment --><span attr="x">y</span>';
|
|
59
|
+
const { container } = renderHtml('preview', body);
|
|
60
|
+
const srcdoc = getIframe(container).getAttribute('srcdoc') ?? '';
|
|
61
|
+
expect(srcdoc).toContain(body);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('raw view', () => {
|
|
66
|
+
it('renders the HTML source as text and does not render an iframe', () => {
|
|
67
|
+
const { container } = renderHtml('raw');
|
|
68
|
+
expect(container.querySelector('iframe')).toBeNull();
|
|
69
|
+
expect(screen.getByText(HTML_BODY)).toBeInTheDocument();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('xss attempt regression', () => {
|
|
74
|
+
// jsdom does not execute iframe srcdoc, so we can't assert "the
|
|
75
|
+
// script did not run." Instead we lock the *attributes* that prevent
|
|
76
|
+
// execution: `sandbox=""` stays empty, the CSP meta is present, and
|
|
77
|
+
// the script tag survives unmodified in srcdoc (we don't pre-strip —
|
|
78
|
+
// sandbox blocks execution at the iframe boundary). A future change
|
|
79
|
+
// that loosens `sandbox` or drops the CSP prefix will fail this test.
|
|
80
|
+
const XSS_BODY =
|
|
81
|
+
'<html><body><script>window.__xssFired = true; alert(1)</script><p>after</p></body></html>';
|
|
82
|
+
|
|
83
|
+
it('keeps sandbox empty even for HTML containing <script>', () => {
|
|
84
|
+
const { container } = renderHtml('preview', XSS_BODY);
|
|
85
|
+
expect(getIframe(container).getAttribute('sandbox')).toBe('');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('keeps the CSP meta in srcdoc even for HTML containing <script>', () => {
|
|
89
|
+
const { container } = renderHtml('preview', XSS_BODY);
|
|
90
|
+
const srcdoc = getIframe(container).getAttribute('srcdoc') ?? '';
|
|
91
|
+
expect(srcdoc).toContain('Content-Security-Policy');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('does not strip script tags from the body — sandbox is the boundary', () => {
|
|
95
|
+
const { container } = renderHtml('preview', XSS_BODY);
|
|
96
|
+
const srcdoc = getIframe(container).getAttribute('srcdoc') ?? '';
|
|
97
|
+
expect(srcdoc).toContain('<script>');
|
|
98
|
+
expect(srcdoc).toContain('window.__xssFired');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
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 '@testing-library/jest-dom/vitest';
|
|
6
|
+
import { imageRenderer } from '../image';
|
|
7
|
+
import type { RenderCtx } from '../types';
|
|
8
|
+
|
|
9
|
+
const ctx: RenderCtx = {
|
|
10
|
+
contentType: 'image/png',
|
|
11
|
+
url: 'https://example.com/cat.png',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const renderImage = (
|
|
15
|
+
view: 'preview' | 'raw',
|
|
16
|
+
base64 = 'AQID',
|
|
17
|
+
override: Partial<RenderCtx> = {},
|
|
18
|
+
) =>
|
|
19
|
+
render(
|
|
20
|
+
imageRenderer.render({
|
|
21
|
+
view,
|
|
22
|
+
body: { kind: 'binary', base64 },
|
|
23
|
+
ctx: { ...ctx, ...override },
|
|
24
|
+
}) as ReactElement,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
describe('imageRenderer', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
Object.defineProperty(URL, 'createObjectURL', {
|
|
30
|
+
configurable: true,
|
|
31
|
+
writable: true,
|
|
32
|
+
value: vi.fn(() => 'blob:fake-url'),
|
|
33
|
+
});
|
|
34
|
+
Object.defineProperty(URL, 'revokeObjectURL', {
|
|
35
|
+
configurable: true,
|
|
36
|
+
writable: true,
|
|
37
|
+
value: vi.fn(),
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('declares both preview and raw views with preview as default', () => {
|
|
42
|
+
expect(imageRenderer.views).toEqual(['preview', 'raw']);
|
|
43
|
+
expect(imageRenderer.defaultView).toBe('preview');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('does not support override (binary bodies cannot round-trip through the override editor)', () => {
|
|
47
|
+
expect(imageRenderer.supportsOverride).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('renders an <img> with the correct base64 data URL in preview', () => {
|
|
51
|
+
renderImage('preview', 'iVBORw0K');
|
|
52
|
+
const img = screen.getByRole('img');
|
|
53
|
+
expect(img).toHaveAttribute('src', 'data:image/png;base64,iVBORw0K');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('uses the ctx content-type in the data URL prefix, not a hard-coded one', () => {
|
|
57
|
+
renderImage('preview', 'AQID', { contentType: 'image/jpeg' });
|
|
58
|
+
const img = screen.getByRole('img');
|
|
59
|
+
expect(img).toHaveAttribute('src', 'data:image/jpeg;base64,AQID');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('renders metadata card + hex view (no <img>) in raw view', () => {
|
|
63
|
+
renderImage('raw', 'AQID');
|
|
64
|
+
expect(screen.queryByRole('img')).toBeNull();
|
|
65
|
+
// Metadata card: size + filename derived from URL.
|
|
66
|
+
expect(screen.getByText('Size')).toBeInTheDocument();
|
|
67
|
+
expect(screen.getByText('3 bytes')).toBeInTheDocument();
|
|
68
|
+
expect(screen.getByText('cat.png')).toBeInTheDocument();
|
|
69
|
+
// HexView surfaces the bytes.
|
|
70
|
+
expect(screen.getByText('00000000')).toBeInTheDocument();
|
|
71
|
+
expect(screen.getByText('01 02 03')).toBeInTheDocument();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
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 { jsonRenderer } from '../json';
|
|
7
|
+
import type { RenderCtx } from '../types';
|
|
8
|
+
|
|
9
|
+
const baseCtx: RenderCtx = {
|
|
10
|
+
contentType: 'application/json',
|
|
11
|
+
url: 'https://example.com/data.json',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const renderJson = (view: 'preview' | 'raw', body: string) =>
|
|
15
|
+
render(jsonRenderer.render({ view, body, ctx: baseCtx }) as ReactElement);
|
|
16
|
+
|
|
17
|
+
const MINIFIED = '{"name":"alice","tags":["a","b"],"meta":{"id":42}}';
|
|
18
|
+
|
|
19
|
+
describe('jsonRenderer', () => {
|
|
20
|
+
it('declares both preview and raw views with preview as default', () => {
|
|
21
|
+
expect(jsonRenderer.views).toEqual(['preview', 'raw']);
|
|
22
|
+
expect(jsonRenderer.defaultView).toBe('preview');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('supports override (JSON bodies are strings)', () => {
|
|
26
|
+
expect(jsonRenderer.supportsOverride).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('preview view', () => {
|
|
30
|
+
it('renders the parsed JSON as a tree with keys and values visible', () => {
|
|
31
|
+
renderJson('preview', MINIFIED);
|
|
32
|
+
// react-json-tree renders the keys; we just need to confirm
|
|
33
|
+
// some tree-shaped content is present, not the raw source.
|
|
34
|
+
expect(screen.queryByText(MINIFIED)).toBeNull();
|
|
35
|
+
// The values appear somewhere in the tree.
|
|
36
|
+
expect(screen.getAllByText(/alice/).length).toBeGreaterThan(0);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('raw view', () => {
|
|
41
|
+
it('renders the JSON pretty-printed with 2-space indent, not the literal body', () => {
|
|
42
|
+
const { container } = render(
|
|
43
|
+
jsonRenderer.render({
|
|
44
|
+
view: 'raw',
|
|
45
|
+
body: MINIFIED,
|
|
46
|
+
ctx: baseCtx,
|
|
47
|
+
}) as ReactElement,
|
|
48
|
+
);
|
|
49
|
+
const text = container.textContent ?? '';
|
|
50
|
+
// The literal minified source should NOT appear verbatim — the
|
|
51
|
+
// raw view re-serializes with indentation.
|
|
52
|
+
expect(text).not.toContain(MINIFIED);
|
|
53
|
+
// The re-serialized output must be multi-line (proves indent
|
|
54
|
+
// happened) and must contain 2-space indented keys.
|
|
55
|
+
expect(text).toMatch(/\n/);
|
|
56
|
+
expect(text).toMatch(/^ {2}"name"/m);
|
|
57
|
+
// Sanity: round-tripping back through JSON.parse yields the
|
|
58
|
+
// same logical value.
|
|
59
|
+
expect(JSON.parse(text)).toEqual(JSON.parse(MINIFIED));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('uses exactly 2 spaces per indent level', () => {
|
|
63
|
+
const { container } = render(
|
|
64
|
+
jsonRenderer.render({
|
|
65
|
+
view: 'raw',
|
|
66
|
+
body: '{"a":{"b":1}}',
|
|
67
|
+
ctx: baseCtx,
|
|
68
|
+
}) as ReactElement,
|
|
69
|
+
);
|
|
70
|
+
const text = container.textContent ?? '';
|
|
71
|
+
// Nested key 'b' should be indented 4 spaces (two levels × 2).
|
|
72
|
+
expect(text).toMatch(/^ {4}"b"/m);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('malformed JSON', () => {
|
|
77
|
+
it('falls back to source + warning in preview view', () => {
|
|
78
|
+
const MALFORMED = '{ not valid json ]';
|
|
79
|
+
renderJson('preview', MALFORMED);
|
|
80
|
+
expect(screen.getByText(MALFORMED)).toBeInTheDocument();
|
|
81
|
+
expect(
|
|
82
|
+
screen.getByText(/Failed to parse as JSON, showing as raw text/),
|
|
83
|
+
).toBeInTheDocument();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('falls back to source + warning in raw view too (ignores active view)', () => {
|
|
87
|
+
const MALFORMED = '{ not valid json ]';
|
|
88
|
+
renderJson('raw', MALFORMED);
|
|
89
|
+
expect(screen.getByText(MALFORMED)).toBeInTheDocument();
|
|
90
|
+
expect(
|
|
91
|
+
screen.getByText(/Failed to parse as JSON, showing as raw text/),
|
|
92
|
+
).toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
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 { svgRenderer } from '../svg';
|
|
7
|
+
import type { RenderCtx } from '../types';
|
|
8
|
+
|
|
9
|
+
const ctx: RenderCtx = {
|
|
10
|
+
contentType: 'image/svg+xml',
|
|
11
|
+
url: 'https://example.com/icon.svg',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const SVG_SOURCE =
|
|
15
|
+
'<svg xmlns="http://www.w3.org/2000/svg"><circle r="5"/></svg>';
|
|
16
|
+
|
|
17
|
+
const renderSvg = (view: 'preview' | 'raw', body = SVG_SOURCE) =>
|
|
18
|
+
render(svgRenderer.render({ view, body, ctx }) as ReactElement);
|
|
19
|
+
|
|
20
|
+
describe('svgRenderer', () => {
|
|
21
|
+
it('declares both preview and raw views with preview as default', () => {
|
|
22
|
+
expect(svgRenderer.views).toEqual(['preview', 'raw']);
|
|
23
|
+
expect(svgRenderer.defaultView).toBe('preview');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('does not support override (keeps parity with binary image semantics)', () => {
|
|
27
|
+
expect(svgRenderer.supportsOverride).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('renders an <img> with a utf8 data URL in preview — never base64', () => {
|
|
31
|
+
renderSvg('preview');
|
|
32
|
+
const img = screen.getByRole('img');
|
|
33
|
+
const src = img.getAttribute('src') ?? '';
|
|
34
|
+
expect(src.startsWith('data:image/svg+xml;utf8,')).toBe(true);
|
|
35
|
+
// The encoded payload must round-trip back to the original source —
|
|
36
|
+
// proves we URL-encoded the SVG text rather than dropping it.
|
|
37
|
+
const encoded = src.slice('data:image/svg+xml;utf8,'.length);
|
|
38
|
+
expect(decodeURIComponent(encoded)).toBe(SVG_SOURCE);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('renders the SVG source verbatim (not base64) in raw view', () => {
|
|
42
|
+
renderSvg('raw');
|
|
43
|
+
expect(screen.queryByRole('img')).toBeNull();
|
|
44
|
+
expect(screen.getByText(SVG_SOURCE)).toBeInTheDocument();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
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 { xmlRenderer } from '../xml';
|
|
7
|
+
import type { RenderCtx } from '../types';
|
|
8
|
+
|
|
9
|
+
const baseCtx: RenderCtx = {
|
|
10
|
+
contentType: 'application/xml',
|
|
11
|
+
url: 'https://example.com/feed.xml',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const renderXml = (view: 'preview' | 'raw', body: string) =>
|
|
15
|
+
render(xmlRenderer.render({ view, body, ctx: baseCtx }) as ReactElement);
|
|
16
|
+
|
|
17
|
+
const VALID_XML =
|
|
18
|
+
'<feed xmlns="http://www.w3.org/2005/Atom"><title>Demo</title></feed>';
|
|
19
|
+
|
|
20
|
+
describe('xmlRenderer', () => {
|
|
21
|
+
it('declares both preview and raw views with preview as default', () => {
|
|
22
|
+
expect(xmlRenderer.views).toEqual(['preview', 'raw']);
|
|
23
|
+
expect(xmlRenderer.defaultView).toBe('preview');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('supports override (XML bodies are strings — the editor works on them)', () => {
|
|
27
|
+
expect(xmlRenderer.supportsOverride).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('preview view', () => {
|
|
31
|
+
it('renders the parsed XML tree (tag names visible)', () => {
|
|
32
|
+
renderXml('preview', VALID_XML);
|
|
33
|
+
// Each non-self-closing element renders both an open and a close
|
|
34
|
+
// tag with the same name — two occurrences each is expected.
|
|
35
|
+
expect(screen.getAllByText('feed').length).toBeGreaterThan(0);
|
|
36
|
+
expect(screen.getAllByText('title').length).toBeGreaterThan(0);
|
|
37
|
+
expect(screen.getByText('Demo')).toBeInTheDocument();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('raw view', () => {
|
|
42
|
+
it('renders the XML source as text and does not render a tree', () => {
|
|
43
|
+
renderXml('raw', VALID_XML);
|
|
44
|
+
// The full source should be present as a CodeBlock text node.
|
|
45
|
+
expect(screen.getByText(VALID_XML)).toBeInTheDocument();
|
|
46
|
+
// The tag-name span (used by the tree, with the standalone text
|
|
47
|
+
// "feed" inside a styled span) should not appear when raw — the
|
|
48
|
+
// raw source contains "feed" only inside the larger XML literal.
|
|
49
|
+
const standaloneFeed = screen
|
|
50
|
+
.queryAllByText('feed')
|
|
51
|
+
.filter((el) => el.tagName.toLowerCase() === 'span');
|
|
52
|
+
expect(standaloneFeed).toHaveLength(0);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('malformed XML', () => {
|
|
57
|
+
it('falls back to source + warning when parser produces a parsererror element', () => {
|
|
58
|
+
// Forces the Chrome-style failure where DOMParser returns a
|
|
59
|
+
// Document whose documentElement IS the parsererror element.
|
|
60
|
+
const MALFORMED = '<feed><unclosed></feed>';
|
|
61
|
+
renderXml('preview', MALFORMED);
|
|
62
|
+
expect(screen.getByText(MALFORMED)).toBeInTheDocument();
|
|
63
|
+
expect(
|
|
64
|
+
screen.getByText(/Failed to parse as XML, showing as raw text/),
|
|
65
|
+
).toBeInTheDocument();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('also flags Firefox-style nested parsererror (namespace-aware detection)', () => {
|
|
69
|
+
// Build a Document by hand that mimics Firefox's failure shape:
|
|
70
|
+
// a valid documentElement that nests a <parsererror> in the
|
|
71
|
+
// Firefox XML-parser-error namespace. The hasParseError helper
|
|
72
|
+
// must detect this via getElementsByTagNameNS, not just by
|
|
73
|
+
// checking documentElement.nodeName.
|
|
74
|
+
//
|
|
75
|
+
// We can't easily force the Firefox parser path inside jsdom,
|
|
76
|
+
// but we can verify the detector via a hand-crafted body that
|
|
77
|
+
// includes the FF namespace as XML — when DOMParser parses it,
|
|
78
|
+
// the resulting document will contain a parsererror in that NS.
|
|
79
|
+
const FIREFOX_LIKE =
|
|
80
|
+
'<root xmlns:px="http://www.mozilla.org/newlayout/xml/parsererror.xml"><px:parsererror>broken</px:parsererror></root>';
|
|
81
|
+
renderXml('preview', FIREFOX_LIKE);
|
|
82
|
+
expect(
|
|
83
|
+
screen.getByText(/Failed to parse as XML, showing as raw text/),
|
|
84
|
+
).toBeInTheDocument();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('content-type acceptance', () => {
|
|
89
|
+
it('renders an application/xhtml+xml body as a tree', () => {
|
|
90
|
+
const XHTML =
|
|
91
|
+
'<html xmlns="http://www.w3.org/1999/xhtml"><body><p>Hi</p></body></html>';
|
|
92
|
+
renderXml('preview', XHTML);
|
|
93
|
+
// Each element renders open + close, so 2x per tag.
|
|
94
|
+
expect(screen.getAllByText('html').length).toBeGreaterThan(0);
|
|
95
|
+
expect(screen.getAllByText('body').length).toBeGreaterThan(0);
|
|
96
|
+
expect(screen.getAllByText('p').length).toBeGreaterThan(0);
|
|
97
|
+
expect(screen.getByText('Hi')).toBeInTheDocument();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ResponseRenderer } from './types';
|
|
2
|
+
|
|
3
|
+
const formatBytes = (bytes: number): string => {
|
|
4
|
+
if (bytes >= 1024 * 1024) {
|
|
5
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
6
|
+
}
|
|
7
|
+
if (bytes >= 1024) {
|
|
8
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
9
|
+
}
|
|
10
|
+
return `${bytes} bytes`;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const binaryTooLargeRenderer: ResponseRenderer = {
|
|
14
|
+
id: 'binary-too-large',
|
|
15
|
+
matches: (_contentType, body) =>
|
|
16
|
+
typeof body === 'object' &&
|
|
17
|
+
body !== null &&
|
|
18
|
+
body.kind === 'binary-too-large',
|
|
19
|
+
views: ['raw'],
|
|
20
|
+
defaultView: 'raw',
|
|
21
|
+
supportsOverride: false,
|
|
22
|
+
render: ({ body }) => {
|
|
23
|
+
if (
|
|
24
|
+
typeof body !== 'object' ||
|
|
25
|
+
body === null ||
|
|
26
|
+
body.kind !== 'binary-too-large'
|
|
27
|
+
) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return (
|
|
31
|
+
<div className="text-sm text-gray-400">
|
|
32
|
+
Response too large for preview ({formatBytes(body.size)})
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { HexView } from '../components/HexView';
|
|
2
|
+
import { MetadataCard } from '../components/MetadataCard';
|
|
3
|
+
import { base64ToBytes } from '../utils/download';
|
|
4
|
+
import type { ResponseRenderer } from './types';
|
|
5
|
+
|
|
6
|
+
// Non-image binary: PDF, audio, video, fonts, application/octet-stream,
|
|
7
|
+
// anything else the server returns as bytes that isn't an image and
|
|
8
|
+
// isn't text-friendly. The Raw view is the only sensible surface here
|
|
9
|
+
// — there's no "preview" of a font or a zip the way there is of a PNG.
|
|
10
|
+
export const binaryRenderer: ResponseRenderer = {
|
|
11
|
+
id: 'binary',
|
|
12
|
+
matches: (contentType, body) =>
|
|
13
|
+
typeof body === 'object' &&
|
|
14
|
+
body !== null &&
|
|
15
|
+
body.kind === 'binary' &&
|
|
16
|
+
!contentType.startsWith('image/'),
|
|
17
|
+
views: ['raw'],
|
|
18
|
+
defaultView: 'raw',
|
|
19
|
+
supportsOverride: false,
|
|
20
|
+
render: ({ body, ctx }) => {
|
|
21
|
+
if (typeof body !== 'object' || body === null || body.kind !== 'binary') {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return (
|
|
25
|
+
<div className="space-y-3">
|
|
26
|
+
<MetadataCard body={body} ctx={ctx} />
|
|
27
|
+
<HexView bytes={base64ToBytes(body.base64)} />
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ResponseRenderer } from './types';
|
|
2
|
+
|
|
3
|
+
export const emptyRenderer: ResponseRenderer = {
|
|
4
|
+
id: 'empty',
|
|
5
|
+
matches: (_contentType, body) => body === null,
|
|
6
|
+
views: [],
|
|
7
|
+
defaultView: 'raw',
|
|
8
|
+
supportsOverride: false,
|
|
9
|
+
render: () => (
|
|
10
|
+
<div className="text-sm text-gray-400">
|
|
11
|
+
No response body available for this request
|
|
12
|
+
</div>
|
|
13
|
+
),
|
|
14
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { CodeBlock } from '../components/CodeBlock';
|
|
2
|
+
import { normalizeContentType } from '../../utils/getContentTypeMimeType';
|
|
3
|
+
import type { ResponseRenderer } from './types';
|
|
4
|
+
|
|
5
|
+
// Primary defense is `sandbox=""` (empty value = all restrictions on),
|
|
6
|
+
// which blocks script execution and gives the iframe a unique origin.
|
|
7
|
+
// CSP is defense-in-depth against subresource fetches that sandbox does
|
|
8
|
+
// not block (external images, stylesheets, fonts) — without it, previewing
|
|
9
|
+
// arbitrary HTML would fire requests from the developer's browser to
|
|
10
|
+
// whatever URLs the captured response references.
|
|
11
|
+
const CSP_META =
|
|
12
|
+
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\'; img-src data:;">';
|
|
13
|
+
|
|
14
|
+
export const htmlRenderer: ResponseRenderer = {
|
|
15
|
+
id: 'html',
|
|
16
|
+
matches: (contentType, body) =>
|
|
17
|
+
typeof body === 'string' &&
|
|
18
|
+
normalizeContentType(contentType) === 'text/html',
|
|
19
|
+
views: ['preview', 'raw'],
|
|
20
|
+
defaultView: 'preview',
|
|
21
|
+
supportsOverride: true,
|
|
22
|
+
render: ({ view, body }) => {
|
|
23
|
+
if (typeof body !== 'string') return null;
|
|
24
|
+
if (view === 'raw') {
|
|
25
|
+
return <CodeBlock>{body}</CodeBlock>;
|
|
26
|
+
}
|
|
27
|
+
return (
|
|
28
|
+
<iframe
|
|
29
|
+
title="HTML response preview"
|
|
30
|
+
sandbox=""
|
|
31
|
+
srcDoc={CSP_META + body}
|
|
32
|
+
className="w-full h-[500px] bg-white border border-gray-700 rounded-md"
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { HexView } from '../components/HexView';
|
|
2
|
+
import { MetadataCard } from '../components/MetadataCard';
|
|
3
|
+
import { base64ToBytes } from '../utils/download';
|
|
4
|
+
import type { ResponseRenderer } from './types';
|
|
5
|
+
|
|
6
|
+
export const imageRenderer: ResponseRenderer = {
|
|
7
|
+
id: 'image',
|
|
8
|
+
matches: (contentType, body) =>
|
|
9
|
+
typeof body === 'object' &&
|
|
10
|
+
body !== null &&
|
|
11
|
+
body.kind === 'binary' &&
|
|
12
|
+
contentType.startsWith('image/'),
|
|
13
|
+
views: ['preview', 'raw'],
|
|
14
|
+
defaultView: 'preview',
|
|
15
|
+
supportsOverride: false,
|
|
16
|
+
render: ({ view, body, ctx }) => {
|
|
17
|
+
if (typeof body !== 'object' || body === null || body.kind !== 'binary') {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
if (view === 'preview') {
|
|
21
|
+
const dataUrl = `data:${ctx.contentType};base64,${body.base64}`;
|
|
22
|
+
return (
|
|
23
|
+
<img
|
|
24
|
+
src={dataUrl}
|
|
25
|
+
alt="Response image"
|
|
26
|
+
className="max-w-full max-h-[400px] object-contain bg-gray-800 rounded-md border border-gray-700 p-2"
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
return (
|
|
31
|
+
<div className="space-y-3">
|
|
32
|
+
<MetadataCard body={body} ctx={ctx} />
|
|
33
|
+
<HexView bytes={base64ToBytes(body.base64)} />
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { binaryRenderer } from './binary';
|
|
2
|
+
import { binaryTooLargeRenderer } from './binary-too-large';
|
|
3
|
+
import { emptyRenderer } from './empty';
|
|
4
|
+
import { htmlRenderer } from './html';
|
|
5
|
+
import { imageRenderer } from './image';
|
|
6
|
+
import { jsonRenderer } from './json';
|
|
7
|
+
import { svgRenderer } from './svg';
|
|
8
|
+
import { textFallbackRenderer } from './text-fallback';
|
|
9
|
+
import { unknownRenderer } from './unknown';
|
|
10
|
+
import { xmlRenderer } from './xml';
|
|
11
|
+
import type { ResponseBody } from '../../shared/client';
|
|
12
|
+
import type { ResponseRenderer } from './types';
|
|
13
|
+
|
|
14
|
+
export type {
|
|
15
|
+
RenderCtx,
|
|
16
|
+
ResponseRenderer,
|
|
17
|
+
ResponseView,
|
|
18
|
+
RenderArgs,
|
|
19
|
+
} from './types';
|
|
20
|
+
|
|
21
|
+
// Order matters: matches() is evaluated top to bottom, first hit wins.
|
|
22
|
+
// More specific predicates must come before more general ones — e.g.
|
|
23
|
+
// SVG must match before generic image/* (SVG bodies are strings, but
|
|
24
|
+
// the image renderer wouldn't claim them either; keeping SVG first is
|
|
25
|
+
// belt-and-suspenders). Binary-too-large precedes any binary handler.
|
|
26
|
+
// imageRenderer must precede binaryRenderer — both claim
|
|
27
|
+
// `body.kind === 'binary'`, but image scopes itself to `image/*` and
|
|
28
|
+
// binary takes everything else. text-fallback catches every remaining
|
|
29
|
+
// string body; unknown is the defensive last-resort.
|
|
30
|
+
export const renderers: ResponseRenderer[] = [
|
|
31
|
+
emptyRenderer,
|
|
32
|
+
binaryTooLargeRenderer,
|
|
33
|
+
svgRenderer,
|
|
34
|
+
imageRenderer,
|
|
35
|
+
binaryRenderer,
|
|
36
|
+
jsonRenderer,
|
|
37
|
+
htmlRenderer,
|
|
38
|
+
xmlRenderer,
|
|
39
|
+
textFallbackRenderer,
|
|
40
|
+
unknownRenderer,
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export const findRenderer = (
|
|
44
|
+
contentType: string,
|
|
45
|
+
body: ResponseBody,
|
|
46
|
+
): ResponseRenderer => {
|
|
47
|
+
for (const renderer of renderers) {
|
|
48
|
+
if (renderer.matches(contentType, body)) {
|
|
49
|
+
return renderer;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// `unknownRenderer` is the last entry with matches: () => true, so we
|
|
53
|
+
// never reach this — but TypeScript can't see that.
|
|
54
|
+
return unknownRenderer;
|
|
55
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { CodeBlock } from '../components/CodeBlock';
|
|
2
|
+
import { JsonTree } from '../components/JsonTree';
|
|
3
|
+
import { isJsonContentType } from '../../utils/getContentTypeMimeType';
|
|
4
|
+
import type { ResponseRenderer } from './types';
|
|
5
|
+
|
|
6
|
+
export const jsonRenderer: ResponseRenderer = {
|
|
7
|
+
id: 'json',
|
|
8
|
+
matches: (contentType, body) =>
|
|
9
|
+
typeof body === 'string' && isJsonContentType(contentType),
|
|
10
|
+
views: ['preview', 'raw'],
|
|
11
|
+
defaultView: 'preview',
|
|
12
|
+
supportsOverride: true,
|
|
13
|
+
render: ({ view, body }) => {
|
|
14
|
+
if (typeof body !== 'string') return null;
|
|
15
|
+
let parsed: unknown;
|
|
16
|
+
try {
|
|
17
|
+
parsed = JSON.parse(body);
|
|
18
|
+
} catch {
|
|
19
|
+
return (
|
|
20
|
+
<>
|
|
21
|
+
<CodeBlock>{body}</CodeBlock>
|
|
22
|
+
<div className="text-xs text-gray-500 mt-1">
|
|
23
|
+
⚠️ Failed to parse as JSON, showing as raw text
|
|
24
|
+
</div>
|
|
25
|
+
</>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (view === 'raw') {
|
|
29
|
+
// Pretty-print regardless of the wire format. APIs commonly ship
|
|
30
|
+
// minified JSON, and re-serializing with 2-space indent is what
|
|
31
|
+
// makes the Raw view readable.
|
|
32
|
+
return <CodeBlock>{JSON.stringify(parsed, null, 2)}</CodeBlock>;
|
|
33
|
+
}
|
|
34
|
+
return (
|
|
35
|
+
<CodeBlock>
|
|
36
|
+
<JsonTree data={parsed} />
|
|
37
|
+
</CodeBlock>
|
|
38
|
+
);
|
|
39
|
+
},
|
|
40
|
+
};
|