@rozenite/network-activity-plugin 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/dist/devtools/App.html +2 -2
- package/dist/devtools/assets/{App-hSoryVpJ.js → App-CEESZAW_.js} +7520 -937
- package/dist/devtools/assets/{App-m6xge0az.css → App-xppYUJvX.css} +246 -2
- package/dist/react-native/chunks/boot-recording.cjs +138 -14
- package/dist/react-native/chunks/boot-recording.js +138 -14
- package/dist/react-native/chunks/get-nitro-module.cjs +4 -1
- package/dist/react-native/chunks/get-nitro-module.js +4 -1
- package/dist/react-native/chunks/useNetworkActivityDevTools.require.cjs +20 -1
- package/dist/react-native/chunks/useNetworkActivityDevTools.require.js +20 -1
- package/dist/react-native/index.d.ts +37 -1
- package/dist/rozenite.json +1 -1
- package/dist/sdk/index.d.ts +37 -1
- package/package.json +12 -7
- package/src/react-native/agent/use-network-activity-agent-tools.ts +22 -4
- package/src/react-native/http/__tests__/http-utils.test.ts +228 -0
- package/src/react-native/http/http-utils.ts +208 -25
- package/src/react-native/network-inspector.ts +2 -2
- package/src/react-native/nitro-fetch/get-nitro-module.ts +5 -1
- package/src/react-native/nitro-fetch/nitro-network-inspector.ts +8 -2
- package/src/shared/http-events.ts +40 -1
- package/src/ui/components/CodeBlock.tsx +45 -1
- package/src/ui/components/FilterBar.tsx +337 -61
- package/src/ui/components/HexView.tsx +54 -0
- package/src/ui/components/MetadataCard.tsx +95 -0
- package/src/ui/components/NetworkTimeline.tsx +422 -0
- package/src/ui/components/RequestList.tsx +19 -40
- package/src/ui/components/SidePanel.tsx +42 -1
- package/src/ui/components/Toolbar.tsx +13 -1
- package/src/ui/components/ViewToggle.tsx +44 -0
- package/src/ui/components/XmlTree.tsx +160 -0
- package/src/ui/components/__tests__/CodeBlock.test.tsx +89 -0
- package/src/ui/components/__tests__/HexView.test.tsx +41 -0
- package/src/ui/components/__tests__/MetadataCard.test.tsx +107 -0
- package/src/ui/components/__tests__/ViewToggle.test.tsx +80 -0
- package/src/ui/components/__tests__/XmlTree.test.tsx +149 -0
- package/src/ui/hooks/useNetworkActivitySessionExport.ts +39 -0
- package/src/ui/response-renderers/__tests__/binary-too-large.test.tsx +56 -0
- package/src/ui/response-renderers/__tests__/binary.test.tsx +96 -0
- package/src/ui/response-renderers/__tests__/dispatch.test.ts +124 -0
- package/src/ui/response-renderers/__tests__/html.test.tsx +101 -0
- package/src/ui/response-renderers/__tests__/image.test.tsx +73 -0
- package/src/ui/response-renderers/__tests__/json.test.tsx +95 -0
- package/src/ui/response-renderers/__tests__/svg.test.tsx +46 -0
- package/src/ui/response-renderers/__tests__/xml.test.tsx +100 -0
- package/src/ui/response-renderers/binary-too-large.tsx +36 -0
- package/src/ui/response-renderers/binary.tsx +31 -0
- package/src/ui/response-renderers/empty.tsx +14 -0
- package/src/ui/response-renderers/html.tsx +36 -0
- package/src/ui/response-renderers/image.tsx +37 -0
- package/src/ui/response-renderers/index.ts +55 -0
- package/src/ui/response-renderers/json.tsx +40 -0
- package/src/ui/response-renderers/svg.tsx +27 -0
- package/src/ui/response-renderers/text-fallback.tsx +14 -0
- package/src/ui/response-renderers/types.ts +38 -0
- package/src/ui/response-renderers/unknown.tsx +18 -0
- package/src/ui/response-renderers/xml.tsx +46 -0
- package/src/ui/state/__tests__/store.test.ts +77 -0
- package/src/ui/state/derived.ts +14 -0
- package/src/ui/state/filter.ts +49 -0
- package/src/ui/state/hooks.ts +2 -2
- package/src/ui/state/model.ts +7 -1
- package/src/ui/state/store.ts +63 -4
- package/src/ui/tabs/InitiatorTab.tsx +230 -0
- package/src/ui/tabs/ResponseTab.tsx +80 -97
- package/src/ui/tabs/__tests__/ResponseTab.test.tsx +102 -0
- package/src/ui/utils/__tests__/download.test.ts +115 -0
- package/src/ui/utils/__tests__/hex.test.ts +84 -0
- package/src/ui/utils/__tests__/requestFilters.test.ts +32 -0
- package/src/ui/utils/__tests__/sessionExport.test.ts +174 -0
- package/src/ui/utils/__tests__/symbolication.test.ts +207 -0
- package/src/ui/utils/__tests__/timelineModel.test.ts +170 -0
- package/src/ui/utils/download.ts +161 -0
- package/src/ui/utils/hex.ts +59 -0
- package/src/ui/utils/initiator.ts +136 -0
- package/src/ui/utils/requestFilters.ts +183 -0
- package/src/ui/utils/sessionExport.ts +185 -0
- package/src/ui/utils/symbolication.ts +248 -0
- package/src/ui/utils/timelineModel.ts +352 -0
- package/src/ui/views/InspectorView.tsx +43 -8
- package/src/utils/__tests__/getContentTypeMimeType.test.ts +34 -0
- package/src/utils/getContentTypeMimeType.ts +14 -0
- package/vite.config.ts +5 -1
- package/vitest.setup.ts +31 -0
|
@@ -0,0 +1,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
|
+
});
|
package/src/ui/state/derived.ts
CHANGED
|
@@ -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
|
|