@rozenite/network-activity-plugin 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/devtools/App.html +2 -2
  3. package/dist/devtools/assets/{App-hSoryVpJ.js → App-CEESZAW_.js} +7520 -937
  4. package/dist/devtools/assets/{App-m6xge0az.css → App-xppYUJvX.css} +246 -2
  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 +337 -61
  24. package/src/ui/components/HexView.tsx +54 -0
  25. package/src/ui/components/MetadataCard.tsx +95 -0
  26. package/src/ui/components/NetworkTimeline.tsx +422 -0
  27. package/src/ui/components/RequestList.tsx +19 -40
  28. package/src/ui/components/SidePanel.tsx +42 -1
  29. package/src/ui/components/Toolbar.tsx +13 -1
  30. package/src/ui/components/ViewToggle.tsx +44 -0
  31. package/src/ui/components/XmlTree.tsx +160 -0
  32. package/src/ui/components/__tests__/CodeBlock.test.tsx +89 -0
  33. package/src/ui/components/__tests__/HexView.test.tsx +41 -0
  34. package/src/ui/components/__tests__/MetadataCard.test.tsx +107 -0
  35. package/src/ui/components/__tests__/ViewToggle.test.tsx +80 -0
  36. package/src/ui/components/__tests__/XmlTree.test.tsx +149 -0
  37. package/src/ui/hooks/useNetworkActivitySessionExport.ts +39 -0
  38. package/src/ui/response-renderers/__tests__/binary-too-large.test.tsx +56 -0
  39. package/src/ui/response-renderers/__tests__/binary.test.tsx +96 -0
  40. package/src/ui/response-renderers/__tests__/dispatch.test.ts +124 -0
  41. package/src/ui/response-renderers/__tests__/html.test.tsx +101 -0
  42. package/src/ui/response-renderers/__tests__/image.test.tsx +73 -0
  43. package/src/ui/response-renderers/__tests__/json.test.tsx +95 -0
  44. package/src/ui/response-renderers/__tests__/svg.test.tsx +46 -0
  45. package/src/ui/response-renderers/__tests__/xml.test.tsx +100 -0
  46. package/src/ui/response-renderers/binary-too-large.tsx +36 -0
  47. package/src/ui/response-renderers/binary.tsx +31 -0
  48. package/src/ui/response-renderers/empty.tsx +14 -0
  49. package/src/ui/response-renderers/html.tsx +36 -0
  50. package/src/ui/response-renderers/image.tsx +37 -0
  51. package/src/ui/response-renderers/index.ts +55 -0
  52. package/src/ui/response-renderers/json.tsx +40 -0
  53. package/src/ui/response-renderers/svg.tsx +27 -0
  54. package/src/ui/response-renderers/text-fallback.tsx +14 -0
  55. package/src/ui/response-renderers/types.ts +38 -0
  56. package/src/ui/response-renderers/unknown.tsx +18 -0
  57. package/src/ui/response-renderers/xml.tsx +46 -0
  58. package/src/ui/state/__tests__/store.test.ts +77 -0
  59. package/src/ui/state/derived.ts +14 -0
  60. package/src/ui/state/filter.ts +49 -0
  61. package/src/ui/state/hooks.ts +2 -2
  62. package/src/ui/state/model.ts +7 -1
  63. package/src/ui/state/store.ts +63 -4
  64. package/src/ui/tabs/InitiatorTab.tsx +230 -0
  65. package/src/ui/tabs/ResponseTab.tsx +80 -97
  66. package/src/ui/tabs/__tests__/ResponseTab.test.tsx +102 -0
  67. package/src/ui/utils/__tests__/download.test.ts +115 -0
  68. package/src/ui/utils/__tests__/hex.test.ts +84 -0
  69. package/src/ui/utils/__tests__/requestFilters.test.ts +32 -0
  70. package/src/ui/utils/__tests__/sessionExport.test.ts +174 -0
  71. package/src/ui/utils/__tests__/symbolication.test.ts +207 -0
  72. package/src/ui/utils/__tests__/timelineModel.test.ts +170 -0
  73. package/src/ui/utils/download.ts +161 -0
  74. package/src/ui/utils/hex.ts +59 -0
  75. package/src/ui/utils/initiator.ts +136 -0
  76. package/src/ui/utils/requestFilters.ts +183 -0
  77. package/src/ui/utils/sessionExport.ts +185 -0
  78. package/src/ui/utils/symbolication.ts +248 -0
  79. package/src/ui/utils/timelineModel.ts +352 -0
  80. package/src/ui/views/InspectorView.tsx +43 -8
  81. package/src/utils/__tests__/getContentTypeMimeType.test.ts +34 -0
  82. package/src/utils/getContentTypeMimeType.ts +14 -0
  83. package/vite.config.ts +5 -1
  84. package/vitest.setup.ts +31 -0
@@ -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
+ };
@@ -0,0 +1,27 @@
1
+ import { CodeBlock } from '../components/CodeBlock';
2
+ import type { ResponseRenderer } from './types';
3
+
4
+ export const svgRenderer: ResponseRenderer = {
5
+ id: 'svg',
6
+ matches: (contentType, body) =>
7
+ typeof body === 'string' && contentType.startsWith('image/svg+xml'),
8
+ views: ['preview', 'raw'],
9
+ defaultView: 'preview',
10
+ supportsOverride: false,
11
+ render: ({ view, body }) => {
12
+ if (typeof body !== 'string') return null;
13
+ if (view === 'preview') {
14
+ // <img>-embedded SVG runs no scripts in any major browser — this
15
+ // is the safe way to render SVG from untrusted servers.
16
+ const dataUrl = `data:image/svg+xml;utf8,${encodeURIComponent(body)}`;
17
+ return (
18
+ <img
19
+ src={dataUrl}
20
+ alt="SVG response"
21
+ className="max-w-full max-h-[400px] object-contain bg-gray-800 rounded-md border border-gray-700 p-2"
22
+ />
23
+ );
24
+ }
25
+ return <CodeBlock>{body}</CodeBlock>;
26
+ },
27
+ };
@@ -0,0 +1,14 @@
1
+ import { CodeBlock } from '../components/CodeBlock';
2
+ import type { ResponseRenderer } from './types';
3
+
4
+ export const textFallbackRenderer: ResponseRenderer = {
5
+ id: 'text-fallback',
6
+ matches: (_contentType, body) => typeof body === 'string',
7
+ views: ['raw'],
8
+ defaultView: 'raw',
9
+ supportsOverride: true,
10
+ render: ({ body }) => {
11
+ if (typeof body !== 'string') return null;
12
+ return <CodeBlock>{body}</CodeBlock>;
13
+ },
14
+ };
@@ -0,0 +1,38 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { HttpHeaders, ResponseBody } from '../../shared/client';
3
+
4
+ export type ResponseView = 'preview' | 'raw';
5
+
6
+ export type RenderCtx = {
7
+ contentType: string;
8
+ url: string;
9
+ // Response headers, used for fields like Content-Length and
10
+ // Content-Disposition filename. Optional so renderers can be
11
+ // tested with minimal fixtures.
12
+ headers?: HttpHeaders;
13
+ // Response size in bytes as reported by capture (may differ from
14
+ // the decoded base64 length when the server gzips on the wire).
15
+ size?: number;
16
+ };
17
+
18
+ export type RenderArgs = {
19
+ view: ResponseView;
20
+ body: ResponseBody;
21
+ ctx: RenderCtx;
22
+ };
23
+
24
+ // A response-format renderer: a self-contained entry the dispatcher
25
+ // picks based on `matches`. Order in the `renderers` array is matching
26
+ // priority — first match wins, so narrower predicates come before
27
+ // wider fallbacks.
28
+ export type ResponseRenderer = {
29
+ id: string;
30
+ matches: (contentType: string, body: ResponseBody) => boolean;
31
+ // Empty when the renderer has no toggleable views (e.g. a single
32
+ // placeholder for empty bodies). The Preview/Raw toggle is hidden
33
+ // unless `views.length > 1`.
34
+ views: ResponseView[];
35
+ defaultView: ResponseView;
36
+ supportsOverride: boolean;
37
+ render: (args: RenderArgs) => ReactNode;
38
+ };
@@ -0,0 +1,18 @@
1
+ import type { ResponseRenderer } from './types';
2
+
3
+ // Defensive last-resort match — every preceding renderer should already
4
+ // have claimed any well-formed body. Reaching this renderer means the
5
+ // wire format produced a shape no one handles, which is a bug somewhere
6
+ // upstream. The label exposes the content-type for the bug report.
7
+ export const unknownRenderer: ResponseRenderer = {
8
+ id: 'unknown',
9
+ matches: () => true,
10
+ views: [],
11
+ defaultView: 'raw',
12
+ supportsOverride: false,
13
+ render: ({ ctx }) => (
14
+ <div className="text-sm text-gray-400">
15
+ Could not display response (Content-Type: {ctx.contentType || 'unknown'})
16
+ </div>
17
+ ),
18
+ };
@@ -0,0 +1,46 @@
1
+ import { CodeBlock } from '../components/CodeBlock';
2
+ import { XmlTree } from '../components/XmlTree';
3
+ import { isXmlContentType } from '../../utils/getContentTypeMimeType';
4
+ import type { ResponseRenderer } from './types';
5
+
6
+ // DOMParser does not throw on malformed XML — instead it produces a
7
+ // Document containing a `<parsererror>` element, but with different
8
+ // placement across engines: Chrome/Safari put it as `documentElement`;
9
+ // Firefox nests it under a dedicated namespace. Cover both.
10
+ const FIREFOX_PARSERERROR_NS =
11
+ 'http://www.mozilla.org/newlayout/xml/parsererror.xml';
12
+
13
+ const hasParseError = (doc: Document): boolean =>
14
+ doc.documentElement.nodeName === 'parsererror' ||
15
+ doc.getElementsByTagNameNS(FIREFOX_PARSERERROR_NS, 'parsererror').length > 0;
16
+
17
+ export const xmlRenderer: ResponseRenderer = {
18
+ id: 'xml',
19
+ matches: (contentType, body) =>
20
+ typeof body === 'string' && isXmlContentType(contentType),
21
+ views: ['preview', 'raw'],
22
+ defaultView: 'preview',
23
+ supportsOverride: true,
24
+ render: ({ view, body }) => {
25
+ if (typeof body !== 'string') return null;
26
+ const doc = new DOMParser().parseFromString(body, 'application/xml');
27
+ if (hasParseError(doc)) {
28
+ return (
29
+ <>
30
+ <CodeBlock>{body}</CodeBlock>
31
+ <div className="text-xs text-gray-500 mt-1">
32
+ ⚠️ Failed to parse as XML, showing as raw text
33
+ </div>
34
+ </>
35
+ );
36
+ }
37
+ if (view === 'raw') {
38
+ return <CodeBlock>{body}</CodeBlock>;
39
+ }
40
+ return (
41
+ <CodeBlock>
42
+ <XmlTree root={doc.documentElement} />
43
+ </CodeBlock>
44
+ );
45
+ },
46
+ };
@@ -0,0 +1,77 @@
1
+ import { beforeAll, describe, expect, it, vi } from 'vitest';
2
+ import type { createNetworkActivityStore as createStoreType } from '../store';
3
+
4
+ let createNetworkActivityStore: typeof createStoreType;
5
+
6
+ beforeAll(async () => {
7
+ const storage = new Map<string, string>();
8
+
9
+ vi.stubGlobal('localStorage', {
10
+ getItem: (key: string) => storage.get(key) ?? null,
11
+ setItem: (key: string, value: string) => {
12
+ storage.set(key, value);
13
+ },
14
+ removeItem: (key: string) => {
15
+ storage.delete(key);
16
+ },
17
+ });
18
+
19
+ ({ createNetworkActivityStore } = await import('../store'));
20
+ });
21
+
22
+ describe('network activity store', () => {
23
+ it('records elapsed duration for failed HTTP requests', () => {
24
+ const store = createNetworkActivityStore();
25
+
26
+ store.getState().handleEvent('request-sent', {
27
+ requestId: 'request-1',
28
+ timestamp: 100,
29
+ request: {
30
+ url: 'https://example.com/api',
31
+ method: 'GET',
32
+ headers: {},
33
+ },
34
+ initiator: {
35
+ type: 'script',
36
+ },
37
+ type: 'Fetch',
38
+ });
39
+ store.getState().handleEvent('request-failed', {
40
+ requestId: 'request-1',
41
+ timestamp: 250,
42
+ type: 'Fetch',
43
+ error: 'Network request failed',
44
+ canceled: false,
45
+ });
46
+
47
+ expect(store.getState().networkEntries.get('request-1')).toMatchObject({
48
+ status: 'failed',
49
+ duration: 150,
50
+ });
51
+ });
52
+
53
+ it('records elapsed duration for websocket errors', () => {
54
+ const store = createNetworkActivityStore();
55
+
56
+ store.getState().handleEvent('websocket-connect', {
57
+ type: 'websocket-connect',
58
+ url: 'wss://example.com/socket',
59
+ socketId: 'socket-1',
60
+ timestamp: 100,
61
+ protocols: null,
62
+ options: [],
63
+ });
64
+ store.getState().handleEvent('websocket-error', {
65
+ type: 'websocket-error',
66
+ url: 'wss://example.com/socket',
67
+ socketId: 'socket-1',
68
+ timestamp: 175,
69
+ error: 'Socket failed',
70
+ });
71
+
72
+ expect(store.getState().networkEntries.get('ws-socket-1')).toMatchObject({
73
+ status: 'error',
74
+ duration: 75,
75
+ });
76
+ });
77
+ });
@@ -18,6 +18,7 @@ export const getProcessedRequests = memoize((state: NetworkActivityState) => {
18
18
  id: httpEntry.id,
19
19
  type: 'http',
20
20
  source: httpEntry.source,
21
+ initiator: httpEntry.initiator,
21
22
  name: httpEntry.request.url,
22
23
  status: httpEntry.status,
23
24
  timestamp: httpEntry.timestamp,
@@ -25,6 +26,8 @@ export const getProcessedRequests = memoize((state: NetworkActivityState) => {
25
26
  size: httpEntry.size ?? null,
26
27
  method: httpEntry.request.method,
27
28
  httpStatus: httpEntry.response?.status,
29
+ contentType: httpEntry.response?.contentType,
30
+ ttfb: httpEntry.ttfb,
28
31
  progress: httpEntry.progress,
29
32
  });
30
33
  } else if (entry.type === 'websocket') {
@@ -40,12 +43,15 @@ export const getProcessedRequests = memoize((state: NetworkActivityState) => {
40
43
  size: null,
41
44
  method: 'WS',
42
45
  httpStatus: 0,
46
+ contentType: undefined,
43
47
  });
44
48
  } else if (entry.type === 'sse') {
45
49
  const sseEntry = entry as SSENetworkEntry;
46
50
  requests.push({
47
51
  id: sseEntry.id,
48
52
  type: 'sse',
53
+ source: sseEntry.source,
54
+ initiator: sseEntry.initiator,
49
55
  name: sseEntry.request.url,
50
56
  status: sseEntry.status,
51
57
  timestamp: sseEntry.timestamp,
@@ -53,6 +59,7 @@ export const getProcessedRequests = memoize((state: NetworkActivityState) => {
53
59
  size: null,
54
60
  method: 'SSE',
55
61
  httpStatus: 0,
62
+ contentType: sseEntry.response?.contentType,
56
63
  });
57
64
  }
58
65
  }
@@ -80,6 +87,7 @@ export const getRequestSummary = (
80
87
  id: httpEntry.id,
81
88
  type: 'http',
82
89
  source: httpEntry.source,
90
+ initiator: httpEntry.initiator,
83
91
  name: httpEntry.request.url,
84
92
  status: httpEntry.status,
85
93
  timestamp: httpEntry.timestamp,
@@ -87,6 +95,8 @@ export const getRequestSummary = (
87
95
  size: httpEntry.size ?? null,
88
96
  method: httpEntry.request.method,
89
97
  httpStatus: httpEntry.response?.status || 0,
98
+ contentType: httpEntry.response?.contentType,
99
+ ttfb: httpEntry.ttfb,
90
100
  progress: httpEntry.progress,
91
101
  };
92
102
  } else if (entry.type === 'websocket') {
@@ -102,12 +112,15 @@ export const getRequestSummary = (
102
112
  size: null,
103
113
  method: 'WS',
104
114
  httpStatus: 0,
115
+ contentType: undefined,
105
116
  };
106
117
  } else if (entry.type === 'sse') {
107
118
  const sseEntry = entry as SSENetworkEntry;
108
119
  return {
109
120
  id: sseEntry.id,
110
121
  type: 'sse',
122
+ source: sseEntry.source,
123
+ initiator: sseEntry.initiator,
111
124
  name: sseEntry.request.url,
112
125
  status: sseEntry.status,
113
126
  timestamp: sseEntry.timestamp,
@@ -115,6 +128,7 @@ export const getRequestSummary = (
115
128
  size: null,
116
129
  method: 'SSE',
117
130
  httpStatus: 0,
131
+ contentType: sseEntry.response?.contentType,
118
132
  };
119
133
  }
120
134