@rozenite/network-activity-plugin 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/dist/devtools/App.html +2 -2
- package/dist/devtools/assets/{App-m6xge0az.css → App-CUXU0mup.css} +152 -2
- package/dist/devtools/assets/{App-hSoryVpJ.js → App-DsimzJvx.js} +6827 -970
- package/dist/react-native/chunks/boot-recording.cjs +138 -14
- package/dist/react-native/chunks/boot-recording.js +138 -14
- package/dist/react-native/chunks/get-nitro-module.cjs +4 -1
- package/dist/react-native/chunks/get-nitro-module.js +4 -1
- package/dist/react-native/chunks/useNetworkActivityDevTools.require.cjs +20 -1
- package/dist/react-native/chunks/useNetworkActivityDevTools.require.js +20 -1
- package/dist/react-native/index.d.ts +37 -1
- package/dist/rozenite.json +1 -1
- package/dist/sdk/index.d.ts +37 -1
- package/package.json +12 -7
- package/src/react-native/agent/use-network-activity-agent-tools.ts +22 -4
- package/src/react-native/http/__tests__/http-utils.test.ts +228 -0
- package/src/react-native/http/http-utils.ts +208 -25
- package/src/react-native/network-inspector.ts +2 -2
- package/src/react-native/nitro-fetch/get-nitro-module.ts +5 -1
- package/src/react-native/nitro-fetch/nitro-network-inspector.ts +8 -2
- package/src/shared/http-events.ts +40 -1
- package/src/ui/components/CodeBlock.tsx +45 -1
- package/src/ui/components/FilterBar.tsx +366 -58
- package/src/ui/components/HexView.tsx +54 -0
- package/src/ui/components/MetadataCard.tsx +95 -0
- package/src/ui/components/RequestList.tsx +192 -34
- package/src/ui/components/SidePanel.tsx +42 -1
- package/src/ui/components/ViewToggle.tsx +44 -0
- package/src/ui/components/XmlTree.tsx +160 -0
- package/src/ui/components/__tests__/CodeBlock.test.tsx +89 -0
- package/src/ui/components/__tests__/HexView.test.tsx +41 -0
- package/src/ui/components/__tests__/MetadataCard.test.tsx +107 -0
- package/src/ui/components/__tests__/ViewToggle.test.tsx +80 -0
- package/src/ui/components/__tests__/XmlTree.test.tsx +149 -0
- package/src/ui/response-renderers/__tests__/binary-too-large.test.tsx +56 -0
- package/src/ui/response-renderers/__tests__/binary.test.tsx +96 -0
- package/src/ui/response-renderers/__tests__/dispatch.test.ts +124 -0
- package/src/ui/response-renderers/__tests__/html.test.tsx +101 -0
- package/src/ui/response-renderers/__tests__/image.test.tsx +73 -0
- package/src/ui/response-renderers/__tests__/json.test.tsx +95 -0
- package/src/ui/response-renderers/__tests__/svg.test.tsx +46 -0
- package/src/ui/response-renderers/__tests__/xml.test.tsx +100 -0
- package/src/ui/response-renderers/binary-too-large.tsx +36 -0
- package/src/ui/response-renderers/binary.tsx +31 -0
- package/src/ui/response-renderers/empty.tsx +14 -0
- package/src/ui/response-renderers/html.tsx +36 -0
- package/src/ui/response-renderers/image.tsx +37 -0
- package/src/ui/response-renderers/index.ts +55 -0
- package/src/ui/response-renderers/json.tsx +40 -0
- package/src/ui/response-renderers/svg.tsx +27 -0
- package/src/ui/response-renderers/text-fallback.tsx +14 -0
- package/src/ui/response-renderers/types.ts +38 -0
- package/src/ui/response-renderers/unknown.tsx +18 -0
- package/src/ui/response-renderers/xml.tsx +46 -0
- package/src/ui/state/derived.ts +12 -0
- package/src/ui/state/model.ts +6 -1
- package/src/ui/state/store.ts +39 -2
- package/src/ui/tabs/InitiatorTab.tsx +230 -0
- package/src/ui/tabs/ResponseTab.tsx +80 -97
- package/src/ui/tabs/__tests__/ResponseTab.test.tsx +102 -0
- package/src/ui/utils/__tests__/download.test.ts +115 -0
- package/src/ui/utils/__tests__/hex.test.ts +84 -0
- package/src/ui/utils/__tests__/symbolication.test.ts +207 -0
- package/src/ui/utils/download.ts +154 -0
- package/src/ui/utils/hex.ts +59 -0
- package/src/ui/utils/initiator.ts +136 -0
- package/src/ui/utils/symbolication.ts +248 -0
- package/src/ui/views/InspectorView.tsx +8 -5
- package/src/utils/__tests__/getContentTypeMimeType.test.ts +34 -0
- package/src/utils/getContentTypeMimeType.ts +14 -0
- package/vite.config.ts +5 -1
- package/vitest.setup.ts +31 -0
|
@@ -1,52 +1,44 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { ScrollArea } from '../components/ScrollArea';
|
|
3
|
-
import { JsonTree } from '../components/JsonTree';
|
|
4
3
|
import { HttpNetworkEntry } from '../state/model';
|
|
5
4
|
import { Section } from '../components/Section';
|
|
6
5
|
import { KeyValueGrid } from '../components/KeyValueGrid';
|
|
7
|
-
import { CodeBlock } from '../components/CodeBlock';
|
|
8
6
|
import { useOverrides } from '../state/hooks';
|
|
9
7
|
import { RequestOverride } from '../../shared/client';
|
|
10
8
|
import { OverrideResponse } from '../components/OverrideResponse';
|
|
11
9
|
import { Button } from '../components/Button';
|
|
12
10
|
import { Pencil } from 'lucide-react';
|
|
13
|
-
import {
|
|
11
|
+
import { ViewToggle } from '../components/ViewToggle';
|
|
12
|
+
import {
|
|
13
|
+
findRenderer,
|
|
14
|
+
type RenderCtx,
|
|
15
|
+
type ResponseView,
|
|
16
|
+
} from '../response-renderers';
|
|
14
17
|
|
|
15
18
|
export type ResponseTabProps = {
|
|
16
19
|
selectedRequest: HttpNetworkEntry;
|
|
17
20
|
supportsOverrides?: boolean;
|
|
21
|
+
// Sticky Preview/Raw preference is owned by the parent (SidePanel)
|
|
22
|
+
// so it survives the `<Tabs key={selectedRequest.id}>` remount that
|
|
23
|
+
// happens on every request switch. Renderers without the requested
|
|
24
|
+
// view fall back to their own `defaultView`.
|
|
25
|
+
preferredView: ResponseView;
|
|
26
|
+
onPreferredViewChange: (view: ResponseView) => void;
|
|
18
27
|
onRequestResponseBody: (requestId: string) => void;
|
|
19
28
|
};
|
|
20
29
|
|
|
21
|
-
type ResponseBodySectionProps = {
|
|
22
|
-
action?: React.ReactNode;
|
|
23
|
-
children: React.ReactNode;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
const RenderResponseBodySection = ({
|
|
27
|
-
children,
|
|
28
|
-
action,
|
|
29
|
-
}: ResponseBodySectionProps) => {
|
|
30
|
-
return (
|
|
31
|
-
<Section title="Response Body" collapsible={false} action={action}>
|
|
32
|
-
<div className="space-y-4">{children}</div>
|
|
33
|
-
</Section>
|
|
34
|
-
);
|
|
35
|
-
};
|
|
36
|
-
|
|
37
30
|
export const ResponseTab = ({
|
|
38
31
|
selectedRequest,
|
|
39
32
|
supportsOverrides = true,
|
|
33
|
+
preferredView,
|
|
34
|
+
onPreferredViewChange,
|
|
40
35
|
onRequestResponseBody,
|
|
41
36
|
}: ResponseTabProps) => {
|
|
42
37
|
const onRequestResponseBodyRef = useRef(onRequestResponseBody);
|
|
43
38
|
const overrides = useOverrides();
|
|
44
39
|
const [initialOverride, setInitialOverride] = useState<
|
|
45
40
|
RequestOverride | undefined
|
|
46
|
-
>(() =>
|
|
47
|
-
const override = overrides.get(selectedRequest.request.url);
|
|
48
|
-
return override;
|
|
49
|
-
});
|
|
41
|
+
>(() => overrides.get(selectedRequest.request.url));
|
|
50
42
|
|
|
51
43
|
useEffect(() => {
|
|
52
44
|
onRequestResponseBodyRef.current = onRequestResponseBody;
|
|
@@ -61,7 +53,7 @@ export const ResponseTab = ({
|
|
|
61
53
|
const responseBody = selectedRequest.response?.body;
|
|
62
54
|
|
|
63
55
|
const renderResponseBody = () => {
|
|
64
|
-
if (!responseBody
|
|
56
|
+
if (!responseBody) {
|
|
65
57
|
return (
|
|
66
58
|
<div className="text-sm text-gray-400">
|
|
67
59
|
No response body available for this request
|
|
@@ -70,97 +62,88 @@ export const ResponseTab = ({
|
|
|
70
62
|
}
|
|
71
63
|
|
|
72
64
|
const { type, data } = responseBody;
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
65
|
+
const renderer = findRenderer(type, data);
|
|
66
|
+
const activeView = renderer.views.includes(preferredView)
|
|
67
|
+
? preferredView
|
|
68
|
+
: renderer.defaultView;
|
|
69
|
+
const ctx: RenderCtx = {
|
|
70
|
+
contentType: type,
|
|
71
|
+
url: selectedRequest.request.url,
|
|
72
|
+
headers: selectedRequest.response?.headers,
|
|
73
|
+
size: selectedRequest.response?.size,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Override engaged: replace the whole panel with the override editor.
|
|
77
|
+
// Only reachable for renderers that support override AND when the
|
|
78
|
+
// user has clicked into the override flow.
|
|
79
|
+
if (
|
|
80
|
+
supportsOverrides &&
|
|
81
|
+
renderer.supportsOverride &&
|
|
82
|
+
initialOverride !== undefined
|
|
83
|
+
) {
|
|
84
|
+
return (
|
|
85
|
+
<OverrideResponse
|
|
86
|
+
selectedRequest={selectedRequest}
|
|
87
|
+
initialOverride={initialOverride}
|
|
88
|
+
onClear={() => setInitialOverride(undefined)}
|
|
89
|
+
/>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
86
92
|
|
|
87
|
-
const
|
|
93
|
+
const canOverride =
|
|
94
|
+
renderer.supportsOverride &&
|
|
95
|
+
supportsOverrides &&
|
|
96
|
+
typeof data === 'string';
|
|
97
|
+
const overrideAction = canOverride ? (
|
|
88
98
|
<Button
|
|
89
99
|
variant="ghost"
|
|
90
100
|
size="xs"
|
|
91
101
|
className="text-violet-300 hover:text-violet-300"
|
|
92
|
-
onClick={() =>
|
|
102
|
+
onClick={(e) => {
|
|
103
|
+
e.stopPropagation();
|
|
93
104
|
setInitialOverride({
|
|
94
105
|
body: data,
|
|
95
|
-
status:
|
|
96
|
-
})
|
|
97
|
-
}
|
|
106
|
+
status: selectedRequest.response?.status,
|
|
107
|
+
});
|
|
108
|
+
}}
|
|
98
109
|
>
|
|
99
110
|
<Pencil className="h-2 w-2" />
|
|
100
111
|
Override
|
|
101
112
|
</Button>
|
|
102
113
|
) : null;
|
|
103
114
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
<
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
115
|
+
const toggle =
|
|
116
|
+
renderer.views.length > 1 ? (
|
|
117
|
+
<ViewToggle
|
|
118
|
+
views={renderer.views}
|
|
119
|
+
value={activeView}
|
|
120
|
+
onChange={onPreferredViewChange}
|
|
110
121
|
/>
|
|
111
|
-
);
|
|
112
|
-
}
|
|
122
|
+
) : null;
|
|
113
123
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
<CodeBlock>
|
|
122
|
-
<JsonTree data={jsonData} />
|
|
123
|
-
</CodeBlock>
|
|
124
|
-
);
|
|
125
|
-
} catch {
|
|
126
|
-
bodyContent = (
|
|
127
|
-
<>
|
|
128
|
-
<CodeBlock>{data}</CodeBlock>
|
|
129
|
-
<div className="text-xs text-gray-500 mt-1">
|
|
130
|
-
⚠️ Failed to parse as JSON, showing as raw text
|
|
131
|
-
</div>
|
|
132
|
-
</>
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return (
|
|
137
|
-
<RenderResponseBodySection action={overrideAction}>
|
|
138
|
-
{contentTypeGrid}
|
|
139
|
-
{bodyContent}
|
|
140
|
-
</RenderResponseBodySection>
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (
|
|
145
|
-
type.startsWith('text/') ||
|
|
146
|
-
type.startsWith('application/xml') ||
|
|
147
|
-
type.startsWith('application/javascript')
|
|
148
|
-
) {
|
|
149
|
-
return (
|
|
150
|
-
<RenderResponseBodySection action={overrideAction}>
|
|
151
|
-
{contentTypeGrid}
|
|
152
|
-
<CodeBlock>{data}</CodeBlock>
|
|
153
|
-
</RenderResponseBodySection>
|
|
154
|
-
);
|
|
155
|
-
}
|
|
124
|
+
const sectionAction =
|
|
125
|
+
toggle || overrideAction ? (
|
|
126
|
+
<div className="flex items-center gap-1">
|
|
127
|
+
{toggle}
|
|
128
|
+
{overrideAction}
|
|
129
|
+
</div>
|
|
130
|
+
) : null;
|
|
156
131
|
|
|
157
132
|
return (
|
|
158
|
-
<
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
133
|
+
<Section title="Response Body" collapsible={false} action={sectionAction}>
|
|
134
|
+
<div className="space-y-4">
|
|
135
|
+
<KeyValueGrid
|
|
136
|
+
items={[
|
|
137
|
+
{
|
|
138
|
+
key: 'Content-Type',
|
|
139
|
+
value: type,
|
|
140
|
+
valueClassName: 'text-blue-400',
|
|
141
|
+
},
|
|
142
|
+
]}
|
|
143
|
+
/>
|
|
144
|
+
{renderer.render({ view: activeView, body: data, ctx })}
|
|
162
145
|
</div>
|
|
163
|
-
</
|
|
146
|
+
</Section>
|
|
164
147
|
);
|
|
165
148
|
};
|
|
166
149
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
5
|
+
import '@testing-library/jest-dom/vitest';
|
|
6
|
+
import { ResponseTab } from '../ResponseTab';
|
|
7
|
+
import type { HttpNetworkEntry } from '../../state/model';
|
|
8
|
+
import type { ResponseView } from '../../response-renderers';
|
|
9
|
+
|
|
10
|
+
const makeSvgEntry = (id: string, source: string): HttpNetworkEntry => ({
|
|
11
|
+
id,
|
|
12
|
+
type: 'http',
|
|
13
|
+
timestamp: 0,
|
|
14
|
+
status: 'finished',
|
|
15
|
+
request: {
|
|
16
|
+
url: `https://example.com/${id}.svg`,
|
|
17
|
+
method: 'GET',
|
|
18
|
+
headers: {},
|
|
19
|
+
},
|
|
20
|
+
response: {
|
|
21
|
+
url: `https://example.com/${id}.svg`,
|
|
22
|
+
status: 200,
|
|
23
|
+
statusText: 'OK',
|
|
24
|
+
headers: {},
|
|
25
|
+
contentType: 'image/svg+xml',
|
|
26
|
+
size: source.length,
|
|
27
|
+
responseTime: 0,
|
|
28
|
+
body: { type: 'image/svg+xml', data: source },
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Mirrors the SidePanel structure that surfaced the original bug:
|
|
33
|
+
// `preferredView` lives in the parent; an outer wrapper is keyed on
|
|
34
|
+
// `selectedRequest.id` to match `SidePanel.tsx`'s
|
|
35
|
+
// `<Tabs key={selectedRequest.id}>` semantics (the wrapper forces a
|
|
36
|
+
// remount of ResponseTab on every request switch, which is intentional
|
|
37
|
+
// for other reasons — see SidePanel comments).
|
|
38
|
+
const StickyPreferenceHarness = ({
|
|
39
|
+
initialRequest,
|
|
40
|
+
nextRequest,
|
|
41
|
+
}: {
|
|
42
|
+
initialRequest: HttpNetworkEntry;
|
|
43
|
+
nextRequest: HttpNetworkEntry;
|
|
44
|
+
}) => {
|
|
45
|
+
const [request, setRequest] = useState<HttpNetworkEntry>(initialRequest);
|
|
46
|
+
const [preferredView, setPreferredView] = useState<ResponseView>('preview');
|
|
47
|
+
return (
|
|
48
|
+
<>
|
|
49
|
+
<button onClick={() => setRequest(nextRequest)}>switch-request</button>
|
|
50
|
+
<div key={request.id}>
|
|
51
|
+
<ResponseTab
|
|
52
|
+
selectedRequest={request}
|
|
53
|
+
preferredView={preferredView}
|
|
54
|
+
onPreferredViewChange={setPreferredView}
|
|
55
|
+
onRequestResponseBody={() => {}}
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
</>
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
describe('ResponseTab — sticky Preview/Raw preference', () => {
|
|
63
|
+
it('persists the toggle across a keyed remount triggered by a request switch', () => {
|
|
64
|
+
const first = makeSvgEntry('first', '<svg id="first"/>');
|
|
65
|
+
const second = makeSvgEntry('second', '<svg id="second"/>');
|
|
66
|
+
|
|
67
|
+
render(
|
|
68
|
+
<StickyPreferenceHarness initialRequest={first} nextRequest={second} />,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// Preview is the default view for the SVG renderer — an inline <img>
|
|
72
|
+
// is rendered, no <pre> source view yet.
|
|
73
|
+
expect(screen.getByRole('img')).toBeInTheDocument();
|
|
74
|
+
|
|
75
|
+
// Flip to Raw via the ViewToggle.
|
|
76
|
+
fireEvent.click(screen.getByRole('tab', { name: 'Raw' }));
|
|
77
|
+
expect(screen.queryByRole('img')).toBeNull();
|
|
78
|
+
expect(screen.getByText('<svg id="first"/>')).toBeInTheDocument();
|
|
79
|
+
expect(screen.getByRole('tab', { name: 'Raw' })).toHaveAttribute(
|
|
80
|
+
'aria-selected',
|
|
81
|
+
'true',
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Switch to a different request. The wrapper's `key` changes, so the
|
|
85
|
+
// ResponseTab subtree is unmounted and a fresh instance mounts —
|
|
86
|
+
// exactly the situation that caused the original sticky-preference
|
|
87
|
+
// bug when state lived inside ResponseTab.
|
|
88
|
+
fireEvent.click(screen.getByRole('button', { name: 'switch-request' }));
|
|
89
|
+
|
|
90
|
+
// The new request's body should be visible (proves the remount
|
|
91
|
+
// happened and we are now rendering the second entry).
|
|
92
|
+
expect(screen.getByText('<svg id="second"/>')).toBeInTheDocument();
|
|
93
|
+
// And — the regression guard — the toggle should still be on Raw,
|
|
94
|
+
// not reset to the renderer's Preview default. The lifted state in
|
|
95
|
+
// the parent harness survived the keyed remount.
|
|
96
|
+
expect(screen.queryByRole('img')).toBeNull();
|
|
97
|
+
expect(screen.getByRole('tab', { name: 'Raw' })).toHaveAttribute(
|
|
98
|
+
'aria-selected',
|
|
99
|
+
'true',
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
base64ToBlob,
|
|
5
|
+
base64ToBytes,
|
|
6
|
+
deriveFilename,
|
|
7
|
+
readHeader,
|
|
8
|
+
} from '../download';
|
|
9
|
+
|
|
10
|
+
describe('base64ToBytes', () => {
|
|
11
|
+
it('decodes "AQID" to the 3-byte sequence [1, 2, 3]', () => {
|
|
12
|
+
expect(Array.from(base64ToBytes('AQID'))).toEqual([1, 2, 3]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns an empty array for the empty string', () => {
|
|
16
|
+
expect(base64ToBytes('').byteLength).toBe(0);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('base64ToBlob', () => {
|
|
21
|
+
it('produces a Blob with the right content-type and byte length', () => {
|
|
22
|
+
const blob = base64ToBlob('AQID', 'image/png');
|
|
23
|
+
expect(blob.type).toBe('image/png');
|
|
24
|
+
expect(blob.size).toBe(3);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('falls back to application/octet-stream when no content-type is given', () => {
|
|
28
|
+
const blob = base64ToBlob('AQID', '');
|
|
29
|
+
expect(blob.type).toBe('application/octet-stream');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('readHeader', () => {
|
|
34
|
+
it('returns the value for a case-insensitive lookup', () => {
|
|
35
|
+
expect(readHeader({ 'Content-Length': '42' }, 'content-length')).toBe('42');
|
|
36
|
+
expect(readHeader({ 'content-type': 'text/html' }, 'Content-Type')).toBe(
|
|
37
|
+
'text/html',
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('returns the first value when the header carries an array', () => {
|
|
42
|
+
expect(readHeader({ 'Set-Cookie': ['a=1', 'b=2'] }, 'Set-Cookie')).toBe(
|
|
43
|
+
'a=1',
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns undefined for missing headers or missing headers map', () => {
|
|
48
|
+
expect(readHeader(undefined, 'Content-Type')).toBeUndefined();
|
|
49
|
+
expect(readHeader({}, 'Content-Type')).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('deriveFilename', () => {
|
|
54
|
+
it('prefers Content-Disposition filename when present', () => {
|
|
55
|
+
expect(
|
|
56
|
+
deriveFilename({
|
|
57
|
+
headers: {
|
|
58
|
+
'Content-Disposition': 'attachment; filename="report.pdf"',
|
|
59
|
+
},
|
|
60
|
+
url: 'https://example.com/api/download?id=42',
|
|
61
|
+
contentType: 'application/pdf',
|
|
62
|
+
}),
|
|
63
|
+
).toBe('report.pdf');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('prefers RFC 5987 filename* when both forms are present', () => {
|
|
67
|
+
expect(
|
|
68
|
+
deriveFilename({
|
|
69
|
+
headers: {
|
|
70
|
+
'Content-Disposition':
|
|
71
|
+
'attachment; filename="fallback.bin"; filename*=UTF-8\'\'spaced%20name.bin',
|
|
72
|
+
},
|
|
73
|
+
url: 'https://example.com/api/x',
|
|
74
|
+
contentType: 'application/octet-stream',
|
|
75
|
+
}),
|
|
76
|
+
).toBe('spaced name.bin');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('falls back to the URL last path segment when no Content-Disposition', () => {
|
|
80
|
+
expect(
|
|
81
|
+
deriveFilename({
|
|
82
|
+
url: 'https://cdn.example.com/files/sub/document.pdf?ver=2',
|
|
83
|
+
contentType: 'application/pdf',
|
|
84
|
+
}),
|
|
85
|
+
).toBe('document.pdf');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('uses the .bin fallback for unrecognised content-types when nothing else matches', () => {
|
|
89
|
+
// No headers, opaque URL (no usable path segments), unknown content-type.
|
|
90
|
+
expect(
|
|
91
|
+
deriveFilename({
|
|
92
|
+
url: 'https://api.example.com/',
|
|
93
|
+
contentType: 'application/x-protobuf',
|
|
94
|
+
}),
|
|
95
|
+
).toBe('response.bin');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('uses a known extension from the content-type map when no name elsewhere', () => {
|
|
99
|
+
expect(
|
|
100
|
+
deriveFilename({
|
|
101
|
+
url: 'https://api.example.com/',
|
|
102
|
+
contentType: 'image/png',
|
|
103
|
+
}),
|
|
104
|
+
).toBe('response.png');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('strips parameters off the content-type before lookup', () => {
|
|
108
|
+
expect(
|
|
109
|
+
deriveFilename({
|
|
110
|
+
url: 'https://api.example.com/',
|
|
111
|
+
contentType: 'application/json; charset=utf-8',
|
|
112
|
+
}),
|
|
113
|
+
).toBe('response.json');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
BYTES_PER_HEX_ROW,
|
|
4
|
+
formatHexRow,
|
|
5
|
+
rowCountForByteLength,
|
|
6
|
+
toAsciiChar,
|
|
7
|
+
toHexPair,
|
|
8
|
+
} from '../hex';
|
|
9
|
+
|
|
10
|
+
describe('toHexPair', () => {
|
|
11
|
+
it('uppercases and zero-pads to two characters', () => {
|
|
12
|
+
expect(toHexPair(0)).toBe('00');
|
|
13
|
+
expect(toHexPair(0x0a)).toBe('0A');
|
|
14
|
+
expect(toHexPair(0xff)).toBe('FF');
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('toAsciiChar', () => {
|
|
19
|
+
it('renders printable ASCII characters verbatim', () => {
|
|
20
|
+
expect(toAsciiChar(0x20)).toBe(' ');
|
|
21
|
+
expect(toAsciiChar(0x41)).toBe('A');
|
|
22
|
+
expect(toAsciiChar(0x7e)).toBe('~');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('renders non-printable bytes as a dot', () => {
|
|
26
|
+
expect(toAsciiChar(0x00)).toBe('.');
|
|
27
|
+
expect(toAsciiChar(0x1f)).toBe('.');
|
|
28
|
+
expect(toAsciiChar(0x7f)).toBe('.');
|
|
29
|
+
expect(toAsciiChar(0xff)).toBe('.');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('formatHexRow', () => {
|
|
34
|
+
it('formats a full row with the post-byte-8 double-space gap', () => {
|
|
35
|
+
const bytes = new Uint8Array([
|
|
36
|
+
0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x21,
|
|
37
|
+
0x0a, 0x0d, 0x09, 0xff,
|
|
38
|
+
]);
|
|
39
|
+
const row = formatHexRow(bytes, 0);
|
|
40
|
+
expect(row.offset).toBe('00000000');
|
|
41
|
+
expect(row.hex).toBe('48 65 6C 6C 6F 20 77 6F 72 6C 64 21 0A 0D 09 FF');
|
|
42
|
+
// "Hello wo" + "rld!" + non-printables.
|
|
43
|
+
expect(row.ascii).toBe('Hello world!....');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('formats a short trailing row without padding the hex column', () => {
|
|
47
|
+
const bytes = new Uint8Array([0x68, 0x65, 0x6c]);
|
|
48
|
+
const row = formatHexRow(bytes, 0);
|
|
49
|
+
expect(row.offset).toBe('00000000');
|
|
50
|
+
expect(row.hex).toBe('68 65 6C');
|
|
51
|
+
expect(row.ascii).toBe('hel');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('respects the rowStart offset in the rendered offset column', () => {
|
|
55
|
+
const bytes = new Uint8Array(48);
|
|
56
|
+
const row = formatHexRow(bytes, 32);
|
|
57
|
+
expect(row.offset).toBe('00000020');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('preserves the half-row gap on a row that has at least one byte in the right group', () => {
|
|
61
|
+
const bytes = new Uint8Array(BYTES_PER_HEX_ROW);
|
|
62
|
+
bytes[BYTES_PER_HEX_ROW - 1] = 0xab;
|
|
63
|
+
const row = formatHexRow(bytes, 0);
|
|
64
|
+
// 8 zero pairs + double-space + 7 zero pairs + AB
|
|
65
|
+
expect(row.hex.split(' ')).toHaveLength(2);
|
|
66
|
+
expect(row.hex.endsWith('AB')).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('rowCountForByteLength', () => {
|
|
71
|
+
it('returns 0 for an empty buffer', () => {
|
|
72
|
+
expect(rowCountForByteLength(0)).toBe(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns 1 for any non-empty buffer up to BYTES_PER_HEX_ROW', () => {
|
|
76
|
+
expect(rowCountForByteLength(1)).toBe(1);
|
|
77
|
+
expect(rowCountForByteLength(BYTES_PER_HEX_ROW)).toBe(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('rounds up partial rows', () => {
|
|
81
|
+
expect(rowCountForByteLength(BYTES_PER_HEX_ROW + 1)).toBe(2);
|
|
82
|
+
expect(rowCountForByteLength(BYTES_PER_HEX_ROW * 2 + 5)).toBe(3);
|
|
83
|
+
});
|
|
84
|
+
});
|