@rozenite/network-activity-plugin 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/devtools/App.html +2 -2
  3. package/dist/devtools/assets/{App-m6xge0az.css → App-CUXU0mup.css} +152 -2
  4. package/dist/devtools/assets/{App-hSoryVpJ.js → App-DsimzJvx.js} +6827 -970
  5. package/dist/react-native/chunks/boot-recording.cjs +138 -14
  6. package/dist/react-native/chunks/boot-recording.js +138 -14
  7. package/dist/react-native/chunks/get-nitro-module.cjs +4 -1
  8. package/dist/react-native/chunks/get-nitro-module.js +4 -1
  9. package/dist/react-native/chunks/useNetworkActivityDevTools.require.cjs +20 -1
  10. package/dist/react-native/chunks/useNetworkActivityDevTools.require.js +20 -1
  11. package/dist/react-native/index.d.ts +37 -1
  12. package/dist/rozenite.json +1 -1
  13. package/dist/sdk/index.d.ts +37 -1
  14. package/package.json +12 -7
  15. package/src/react-native/agent/use-network-activity-agent-tools.ts +22 -4
  16. package/src/react-native/http/__tests__/http-utils.test.ts +228 -0
  17. package/src/react-native/http/http-utils.ts +208 -25
  18. package/src/react-native/network-inspector.ts +2 -2
  19. package/src/react-native/nitro-fetch/get-nitro-module.ts +5 -1
  20. package/src/react-native/nitro-fetch/nitro-network-inspector.ts +8 -2
  21. package/src/shared/http-events.ts +40 -1
  22. package/src/ui/components/CodeBlock.tsx +45 -1
  23. package/src/ui/components/FilterBar.tsx +366 -58
  24. package/src/ui/components/HexView.tsx +54 -0
  25. package/src/ui/components/MetadataCard.tsx +95 -0
  26. package/src/ui/components/RequestList.tsx +192 -34
  27. package/src/ui/components/SidePanel.tsx +42 -1
  28. package/src/ui/components/ViewToggle.tsx +44 -0
  29. package/src/ui/components/XmlTree.tsx +160 -0
  30. package/src/ui/components/__tests__/CodeBlock.test.tsx +89 -0
  31. package/src/ui/components/__tests__/HexView.test.tsx +41 -0
  32. package/src/ui/components/__tests__/MetadataCard.test.tsx +107 -0
  33. package/src/ui/components/__tests__/ViewToggle.test.tsx +80 -0
  34. package/src/ui/components/__tests__/XmlTree.test.tsx +149 -0
  35. package/src/ui/response-renderers/__tests__/binary-too-large.test.tsx +56 -0
  36. package/src/ui/response-renderers/__tests__/binary.test.tsx +96 -0
  37. package/src/ui/response-renderers/__tests__/dispatch.test.ts +124 -0
  38. package/src/ui/response-renderers/__tests__/html.test.tsx +101 -0
  39. package/src/ui/response-renderers/__tests__/image.test.tsx +73 -0
  40. package/src/ui/response-renderers/__tests__/json.test.tsx +95 -0
  41. package/src/ui/response-renderers/__tests__/svg.test.tsx +46 -0
  42. package/src/ui/response-renderers/__tests__/xml.test.tsx +100 -0
  43. package/src/ui/response-renderers/binary-too-large.tsx +36 -0
  44. package/src/ui/response-renderers/binary.tsx +31 -0
  45. package/src/ui/response-renderers/empty.tsx +14 -0
  46. package/src/ui/response-renderers/html.tsx +36 -0
  47. package/src/ui/response-renderers/image.tsx +37 -0
  48. package/src/ui/response-renderers/index.ts +55 -0
  49. package/src/ui/response-renderers/json.tsx +40 -0
  50. package/src/ui/response-renderers/svg.tsx +27 -0
  51. package/src/ui/response-renderers/text-fallback.tsx +14 -0
  52. package/src/ui/response-renderers/types.ts +38 -0
  53. package/src/ui/response-renderers/unknown.tsx +18 -0
  54. package/src/ui/response-renderers/xml.tsx +46 -0
  55. package/src/ui/state/derived.ts +12 -0
  56. package/src/ui/state/model.ts +6 -1
  57. package/src/ui/state/store.ts +39 -2
  58. package/src/ui/tabs/InitiatorTab.tsx +230 -0
  59. package/src/ui/tabs/ResponseTab.tsx +80 -97
  60. package/src/ui/tabs/__tests__/ResponseTab.test.tsx +102 -0
  61. package/src/ui/utils/__tests__/download.test.ts +115 -0
  62. package/src/ui/utils/__tests__/hex.test.ts +84 -0
  63. package/src/ui/utils/__tests__/symbolication.test.ts +207 -0
  64. package/src/ui/utils/download.ts +154 -0
  65. package/src/ui/utils/hex.ts +59 -0
  66. package/src/ui/utils/initiator.ts +136 -0
  67. package/src/ui/utils/symbolication.ts +248 -0
  68. package/src/ui/views/InspectorView.tsx +8 -5
  69. package/src/utils/__tests__/getContentTypeMimeType.test.ts +34 -0
  70. package/src/utils/getContentTypeMimeType.ts +14 -0
  71. package/vite.config.ts +5 -1
  72. package/vitest.setup.ts +31 -0
@@ -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 &lt; 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
+ };