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