mirror of
https://github.com/Kong/insomnia.git
synced 2026-10-12 11:44:45 -04:00
fix: large responses now download correctly [INS-2365] (#9807)
* fix: large responses now download correctly * fix: linting * chore: add e2e testing to prevent future regressions * fix: cloud sync test flaky (#10238) * fix: cloud sync test flaky * fix * fix args to be more concrete * Feat/basic components m0 (#10230) * refactor: components infra * fix * fix: data-* class * rename cn to cls * chore: emit and upload junit test results per shard (#10253) * chore: emit junit reports per e2e tests shard * chore: include retries in junit reporter * chore: test some stuff * chore: test again * chore: revert test * fix(import): always show collection picker (#10262) * feat(undo): broaden undo-history persistence via a stable historyKey (#10233) * feat: add a tooltip for show/hide secret on auth tab (#10263) * feat(templating): vetted npm libraries in the sandbox — uuid + ajv (M3) (#10244) * fix: stop reusing blank-row ids in key-value editors [INS-2598] (#10260) * fix: ensure unique blank IDs for environment key-value pairs to prevent stale state * test: add validation for blank row behavior after deleting committed rows in environment table editor * fix: restore visible keyboard focus indicators across the app (#10268) * feat: enhance focus-visible styles for input elements * feat: simplify focus-visible styles for form elements * update test * fix: route v3 spaces SDK through proxy-aware fetch (#10265) * feat: show error when fails download --------- Co-authored-by: Curry Yang <163384738+CurryYangxx@users.noreply.github.com> Co-authored-by: Fares Osman <43153226+fiosman@users.noreply.github.com> Co-authored-by: Ryan Willis <ryan.willis@konghq.com> Co-authored-by: Jack Kavanagh <jackkav@gmail.com> Co-authored-by: Shelby <13246465+shelby-moore@users.noreply.github.com> Co-authored-by: Pavlos Koutoglou <pkoutoglou@gmail.com> Co-authored-by: Alison Sabuwala <alison.sabuwala1024@gmail.com>
This commit is contained in:
9 files changed
+465
-45
No files matched your search
@@ -0,0 +1,50 @@
|
||||
type: collection.insomnia.rest/5.0
|
||||
schema_version: "5.1"
|
||||
name: Response Download Tests
|
||||
meta:
|
||||
id: wrk_response_download_tests_001
|
||||
created: 1776178027509
|
||||
modified: 1776178027509
|
||||
collection:
|
||||
- url: http://127.0.0.1:4010/pets/1
|
||||
name: JSON Request
|
||||
meta:
|
||||
id: req_0d0ec3bafe584da3a3ecfc668bf2824a
|
||||
created: 1776178029290
|
||||
modified: 1776178033770
|
||||
isPrivate: false
|
||||
description: ""
|
||||
sortKey: -1776178029290
|
||||
method: GET
|
||||
headers:
|
||||
- name: Content-Type
|
||||
value: application/json
|
||||
settings:
|
||||
renderRequestBody: true
|
||||
encodeUrl: true
|
||||
followRedirects: global
|
||||
cookies:
|
||||
send: true
|
||||
store: true
|
||||
rebuildPath: true
|
||||
- url: http://127.0.0.1:4010/large-json
|
||||
name: Large JSON Request
|
||||
meta:
|
||||
id: req_10813c5f0ea14388a5141987645e2e94
|
||||
created: 1776178064890
|
||||
modified: 1776178068539
|
||||
isPrivate: false
|
||||
description: ""
|
||||
sortKey: -1776178064890
|
||||
method: GET
|
||||
headers:
|
||||
- name: Content-Type
|
||||
value: application/json
|
||||
settings:
|
||||
renderRequestBody: true
|
||||
encodeUrl: true
|
||||
followRedirects: global
|
||||
cookies:
|
||||
send: true
|
||||
store: true
|
||||
rebuildPath: true
|
||||
@@ -54,6 +54,15 @@ app.get('/pets/:id', (req, res) => {
|
||||
res.status(200).send({ id: req.params.id });
|
||||
});
|
||||
|
||||
app.get('/large-json', (_req, res) => {
|
||||
const items = Array.from({ length: 100_000 }, (_, i) => ({
|
||||
id: i,
|
||||
name: `item-${i}`,
|
||||
value: 'x'.repeat(100),
|
||||
}));
|
||||
res.status(200).json({ items });
|
||||
});
|
||||
|
||||
app.get('/builds/check/*', (_req, res) => {
|
||||
res.status(200).send({
|
||||
url: 'https://github.com/Kong/insomnia/releases/download/core@2023.5.6/Insomnia.Core-2023.5.6.zip',
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { test } from '../../playwright/test';
|
||||
import {
|
||||
cleanupExportDir,
|
||||
createTempExportDir,
|
||||
mockSaveDialogForFile,
|
||||
readExportedFile,
|
||||
waitForExportFiles,
|
||||
} from '../../playwright/utils';
|
||||
|
||||
// Reads only the head of a file, so asserting on multi-MB exports stays cheap.
|
||||
function readFirstBytes(filePath: string, length: number): string {
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
const buffer = Buffer.alloc(length);
|
||||
const bytesRead = fs.readSync(fd, buffer, 0, length, 0);
|
||||
return buffer.subarray(0, bytesRead).toString('utf8');
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Response Downloads', () => {
|
||||
test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms');
|
||||
|
||||
const FIXTURE = 'response-download-collection.yaml';
|
||||
|
||||
test('Can export raw response body', async ({ insomnia, app, page }) => {
|
||||
await insomnia.projectPage.importFixture(FIXTURE);
|
||||
await insomnia.navigationSidebar.clickRequestOrFolder('JSON Request');
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK');
|
||||
|
||||
const tempDir = createTempExportDir();
|
||||
const exportPath = path.join(tempDir, 'response-raw.json');
|
||||
try {
|
||||
await mockSaveDialogForFile(app, exportPath);
|
||||
await page.getByRole('button', { name: 'Preview' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Export raw response' }).click();
|
||||
await waitForExportFiles(tempDir, 1);
|
||||
const content = readExportedFile(exportPath);
|
||||
expect.soft(content).toContain('"id"');
|
||||
expect.soft(content).toContain('"1"');
|
||||
} finally {
|
||||
cleanupExportDir(tempDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('Can export prettified JSON response', async ({ insomnia, app, page }) => {
|
||||
await insomnia.projectPage.importFixture(FIXTURE);
|
||||
await insomnia.navigationSidebar.clickRequestOrFolder('JSON Request');
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK');
|
||||
|
||||
const tempDir = createTempExportDir();
|
||||
const exportPath = path.join(tempDir, 'response-pretty.json');
|
||||
try {
|
||||
await mockSaveDialogForFile(app, exportPath);
|
||||
await page.getByRole('button', { name: 'Preview' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Export prettified response' }).click();
|
||||
await waitForExportFiles(tempDir, 1);
|
||||
const content = readExportedFile(exportPath);
|
||||
const parsed = JSON.parse(content);
|
||||
expect.soft(parsed.id).toBe('1');
|
||||
expect.soft(content.length).toBeGreaterThan('{"id":"1"}'.length);
|
||||
} finally {
|
||||
cleanupExportDir(tempDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('Can export HTTP debug file', async ({ insomnia, app, page }) => {
|
||||
await insomnia.projectPage.importFixture(FIXTURE);
|
||||
await insomnia.navigationSidebar.clickRequestOrFolder('JSON Request');
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK');
|
||||
|
||||
const tempDir = createTempExportDir();
|
||||
const exportPath = path.join(tempDir, 'response-debug.txt');
|
||||
try {
|
||||
await mockSaveDialogForFile(app, exportPath);
|
||||
await page.getByRole('button', { name: 'Preview' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Export HTTP debug' }).click();
|
||||
await waitForExportFiles(tempDir, 1);
|
||||
const content = readExportedFile(exportPath);
|
||||
expect.soft(content).toContain('Content-Type');
|
||||
expect.soft(content).toContain('"id"');
|
||||
} finally {
|
||||
cleanupExportDir(tempDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('Can export raw large response body', async ({ insomnia, app, page }) => {
|
||||
await insomnia.projectPage.importFixture(FIXTURE);
|
||||
await insomnia.navigationSidebar.clickRequestOrFolder('Large JSON Request');
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK');
|
||||
|
||||
const tempDir = createTempExportDir();
|
||||
const exportPath = path.join(tempDir, 'response-large.json');
|
||||
try {
|
||||
await mockSaveDialogForFile(app, exportPath);
|
||||
await page.getByRole('button', { name: 'Preview' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Export raw response' }).click();
|
||||
await waitForExportFiles(tempDir, 1, 30_000);
|
||||
const { size } = fs.statSync(exportPath);
|
||||
expect.soft(size).toBeGreaterThan(5 * 1024 * 1024);
|
||||
} finally {
|
||||
cleanupExportDir(tempDir);
|
||||
}
|
||||
});
|
||||
|
||||
// The "Save To File" button only ever renders for responses over LARGE_RESPONSE_MB, which are
|
||||
// exactly the ones with no bodyBuffer, so it has to take the streaming raw path.
|
||||
test('Can save a large response via the Save To File button', async ({ insomnia, app, page }) => {
|
||||
await insomnia.projectPage.importFixture(FIXTURE);
|
||||
await insomnia.navigationSidebar.clickRequestOrFolder('Large JSON Request');
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK');
|
||||
|
||||
const tempDir = createTempExportDir();
|
||||
const exportPath = path.join(tempDir, 'response-save-to-file.json');
|
||||
try {
|
||||
await mockSaveDialogForFile(app, exportPath);
|
||||
await page.getByRole('button', { name: 'Save To File' }).click();
|
||||
await waitForExportFiles(tempDir, 1, 30_000);
|
||||
const { size } = fs.statSync(exportPath);
|
||||
expect.soft(size).toBeGreaterThan(5 * 1024 * 1024);
|
||||
// Raw bytes, not prettified: a prettified body would start with '{\n\t"items"'.
|
||||
expect.soft(readFirstBytes(exportPath, 16)).toContain('{"items"');
|
||||
} finally {
|
||||
cleanupExportDir(tempDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('Prettified export is disabled for large JSON response', async ({ insomnia, page }) => {
|
||||
await insomnia.projectPage.importFixture(FIXTURE);
|
||||
await insomnia.navigationSidebar.clickRequestOrFolder('Large JSON Request');
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK');
|
||||
|
||||
await page.getByRole('button', { name: 'Preview' }).click();
|
||||
const prettifyItem = page.getByRole('menuitem', { name: 'Export prettified response' });
|
||||
await expect.soft(prettifyItem).toContainText('must be <5MB');
|
||||
// The label alone would still pass if the item stayed clickable. This class is driven by the
|
||||
// same isDisabled that MenuItem uses to drop onAction, so it proves the guard is wired up.
|
||||
await expect.soft(prettifyItem).toHaveClass(/cursor-not-allowed/);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { getPreviewModeName, PREVIEW_MODE_SOURCE, PREVIEW_MODES } from 'insomnia
|
||||
import React, { type FC, useCallback } from 'react';
|
||||
import { Button } from 'react-aria-components';
|
||||
|
||||
import { LARGE_RESPONSE_MB } from '~/common/constants';
|
||||
import { bodyBufferToUtf8 } from '~/common/utils/utf8-bytes';
|
||||
|
||||
import {
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
} from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId';
|
||||
import { useRequestMetaPatcher } from '../../hooks/use-request';
|
||||
import { Dropdown, DropdownItem, DropdownSection, ItemContent } from '../base/dropdown';
|
||||
import { showError } from '../modals';
|
||||
|
||||
interface Props {
|
||||
download: (pretty: boolean) => any;
|
||||
@@ -36,26 +38,33 @@ export const PreviewModeDropdown: FC<Props> = ({ download, copyToClipboard }) =>
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await window.main.exportHarCurrentRequest({
|
||||
requestId: activeRequest._id,
|
||||
responseId: activeResponse._id,
|
||||
});
|
||||
const har = JSON.stringify(data, null, '\t');
|
||||
try {
|
||||
const data = await window.main.exportHarCurrentRequest({
|
||||
requestId: activeRequest._id,
|
||||
responseId: activeResponse._id,
|
||||
});
|
||||
const har = JSON.stringify(data, null, '\t');
|
||||
|
||||
const { filePath } = await window.dialog.showSaveDialog({
|
||||
title: 'Export As HAR',
|
||||
buttonLabel: 'Save',
|
||||
defaultPath: `${activeRequest.name.replace(/ +/g, '_')}-${Date.now()}.har`,
|
||||
});
|
||||
const { filePath } = await window.dialog.showSaveDialog({
|
||||
title: 'Export As HAR',
|
||||
buttonLabel: 'Save',
|
||||
defaultPath: `${activeRequest.name.replace(/ +/g, '_')}-${Date.now()}.har`,
|
||||
});
|
||||
|
||||
if (!filePath) {
|
||||
return;
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
await window.main.writeFile({
|
||||
path: filePath,
|
||||
content: har,
|
||||
});
|
||||
} catch (err) {
|
||||
showError({
|
||||
title: 'Failed to export as HAR',
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
});
|
||||
}
|
||||
|
||||
await window.main.writeFile({
|
||||
path: filePath,
|
||||
content: har,
|
||||
});
|
||||
}, [activeRequest, activeResponse]);
|
||||
|
||||
const exportDebugFile = useCallback(async () => {
|
||||
@@ -64,30 +73,40 @@ export const PreviewModeDropdown: FC<Props> = ({ download, copyToClipboard }) =>
|
||||
return;
|
||||
}
|
||||
|
||||
const timeline = await services.helpers.getResponseTimeline(activeResponse);
|
||||
const headers = timeline
|
||||
.filter(v => v.name === 'HeaderIn')
|
||||
.map(v => v.value)
|
||||
.join('');
|
||||
try {
|
||||
const timeline = await services.helpers.getResponseTimeline(activeResponse);
|
||||
const headers = timeline
|
||||
.filter(v => v.name === 'HeaderIn')
|
||||
.map(v => v.value)
|
||||
.join('');
|
||||
|
||||
const { canceled, filePath } = await window.dialog.showSaveDialog({
|
||||
title: 'Save Full Response',
|
||||
buttonLabel: 'Save',
|
||||
defaultPath: `${activeRequest.name.replace(/ +/g, '_')}-${Date.now()}.txt`,
|
||||
});
|
||||
const { canceled, filePath } = await window.dialog.showSaveDialog({
|
||||
title: 'Save Full Response',
|
||||
buttonLabel: 'Save',
|
||||
defaultPath: `${activeRequest.name.replace(/ +/g, '_')}-${Date.now()}.txt`,
|
||||
});
|
||||
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
if (canceled || !filePath || !activeResponse.bodyBuffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (filePath && activeResponse.bodyBuffer) {
|
||||
await window.main.writeFile({
|
||||
path: filePath,
|
||||
content: headers + '\n' + bodyBufferToUtf8(activeResponse.bodyBuffer) || '',
|
||||
});
|
||||
} catch (err) {
|
||||
showError({
|
||||
title: 'Failed to export HTTP debug',
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
});
|
||||
}
|
||||
}, [activeRequest, activeResponse]);
|
||||
const shouldPrettifyOption = activeResponse?.contentType.includes('json');
|
||||
// Prettifying and the debug export both need the whole body as a string in renderer memory, so
|
||||
// they stay bounded. The raw export has no such limit: it streams from disk in the main process.
|
||||
const isLargeResponse = activeResponse
|
||||
? Math.max(activeResponse.bytesContent ?? 0, activeResponse.bytesRead ?? 0) > LARGE_RESPONSE_MB * 1024 * 1024
|
||||
: false;
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
@@ -119,11 +138,25 @@ export const PreviewModeDropdown: FC<Props> = ({ download, copyToClipboard }) =>
|
||||
</DropdownItem>
|
||||
<DropdownItem aria-label="Export prettified response">
|
||||
{shouldPrettifyOption && (
|
||||
<ItemContent icon="save" label="Export prettified response" onClick={handleDownloadPrettify} />
|
||||
<ItemContent
|
||||
icon="save"
|
||||
label={
|
||||
isLargeResponse ? `Export prettified response (must be <${LARGE_RESPONSE_MB}MB)` : 'Export prettified response'
|
||||
}
|
||||
isDisabled={isLargeResponse}
|
||||
className={isLargeResponse ? 'opacity-50' : ''}
|
||||
onClick={handleDownloadPrettify}
|
||||
/>
|
||||
)}
|
||||
</DropdownItem>
|
||||
<DropdownItem aria-label="Export HTTP debug">
|
||||
<ItemContent icon="bug" label="Export HTTP debug" onClick={exportDebugFile} />
|
||||
<ItemContent
|
||||
icon="bug"
|
||||
label={isLargeResponse ? `Export HTTP debug (must be <${LARGE_RESPONSE_MB}MB)` : 'Export HTTP debug'}
|
||||
isDisabled={isLargeResponse}
|
||||
className={isLargeResponse ? 'opacity-50' : ''}
|
||||
onClick={exportDebugFile}
|
||||
/>
|
||||
</DropdownItem>
|
||||
<DropdownItem aria-label="Export as HAR">
|
||||
<ItemContent icon="save" label="Export as HAR" onClick={exportAsHAR} />
|
||||
|
||||
@@ -4,11 +4,12 @@ import { downloadResponseBody } from '../response-pane-utils';
|
||||
|
||||
const mockWriteFile = vi.fn();
|
||||
const mockShowSaveDialog = vi.fn();
|
||||
const mockWriteResponseBodyToFile = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('window', {
|
||||
dialog: { showSaveDialog: mockShowSaveDialog },
|
||||
main: { writeFile: mockWriteFile },
|
||||
main: { writeFile: mockWriteFile, writeResponseBodyToFile: mockWriteResponseBodyToFile },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,4 +123,126 @@ describe('downloadResponseBody', () => {
|
||||
expect(content.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('large responses (bodyBuffer absent, body on disk)', () => {
|
||||
it('copies the body from disk instead of writing an empty file', async () => {
|
||||
mockShowSaveDialog.mockResolvedValue({ canceled: false, filePath: '/tmp/large.json' });
|
||||
|
||||
// The loader leaves bodyBuffer undefined above LARGE_RESPONSE_MB; before this fix that
|
||||
// produced a 0-byte export.
|
||||
await downloadResponseBody(
|
||||
{ name: 'My Request' },
|
||||
{ contentType: 'application/json', bodyPath: '/responses/abc.response' },
|
||||
false,
|
||||
);
|
||||
|
||||
expect(mockWriteResponseBodyToFile).toHaveBeenCalledWith({
|
||||
sourcePath: '/responses/abc.response',
|
||||
destinationPath: '/tmp/large.json',
|
||||
bodyCompression: null,
|
||||
});
|
||||
expect(mockWriteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards zip compression so the copy is gunzipped', async () => {
|
||||
mockShowSaveDialog.mockResolvedValue({ canceled: false, filePath: '/tmp/large.json' });
|
||||
|
||||
await downloadResponseBody(
|
||||
{ name: 'My Request' },
|
||||
{ contentType: 'application/json', bodyPath: '/responses/abc.response', bodyCompression: 'zip' },
|
||||
false,
|
||||
);
|
||||
|
||||
expect(mockWriteResponseBodyToFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ bodyCompression: 'zip' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('treats __NEEDS_MIGRATION__ compression as uncompressed', async () => {
|
||||
mockShowSaveDialog.mockResolvedValue({ canceled: false, filePath: '/tmp/large.json' });
|
||||
|
||||
await downloadResponseBody(
|
||||
{
|
||||
name: 'My Request',
|
||||
},
|
||||
{
|
||||
contentType: 'application/json',
|
||||
bodyPath: '/responses/abc.response',
|
||||
bodyCompression: '__NEEDS_MIGRATION__',
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
expect(mockWriteResponseBodyToFile).toHaveBeenCalledWith(expect.objectContaining({ bodyCompression: null }));
|
||||
});
|
||||
|
||||
it('prefers the streaming copy over an in-memory buffer for the raw export', async () => {
|
||||
mockShowSaveDialog.mockResolvedValue({ canceled: false, filePath: '/tmp/out.json' });
|
||||
|
||||
await downloadResponseBody(
|
||||
{ name: 'My Request' },
|
||||
{
|
||||
contentType: 'application/json',
|
||||
bodyBuffer: new TextEncoder().encode('{}'),
|
||||
bodyPath: '/responses/abc.response',
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
expect(mockWriteResponseBodyToFile).toHaveBeenCalledOnce();
|
||||
expect(mockWriteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('prettify falls back to reading the body from disk', () => {
|
||||
it('prettifies a disk-backed body via getBodyBuffer', async () => {
|
||||
mockShowSaveDialog.mockResolvedValue({ canceled: false, filePath: '/tmp/out.json' });
|
||||
const getBodyBuffer = vi.fn().mockResolvedValue(new TextEncoder().encode('{"b":2,"a":1}'));
|
||||
|
||||
await downloadResponseBody(
|
||||
{ name: 'My Request' },
|
||||
{ contentType: 'application/json', bodyPath: '/responses/abc.response' },
|
||||
true,
|
||||
getBodyBuffer,
|
||||
);
|
||||
|
||||
expect(getBodyBuffer).toHaveBeenCalledOnce();
|
||||
expect(mockWriteResponseBodyToFile).not.toHaveBeenCalled();
|
||||
const { content } = mockWriteFile.mock.calls[0][0];
|
||||
expect(typeof content).toBe('string');
|
||||
expect(content).toContain('"b": 2');
|
||||
});
|
||||
|
||||
it('throws the read-failure message when getBodyBuffer returns a string', async () => {
|
||||
mockShowSaveDialog.mockResolvedValue({ canceled: false, filePath: '/tmp/out.json' });
|
||||
const getBodyBuffer = vi.fn().mockResolvedValue('Failed to read response body');
|
||||
|
||||
await expect(
|
||||
downloadResponseBody(
|
||||
{ name: 'My Request' },
|
||||
{ contentType: 'application/json', bodyPath: '/responses/abc.response' },
|
||||
true,
|
||||
getBodyBuffer,
|
||||
),
|
||||
).rejects.toThrow('Failed to read response body');
|
||||
|
||||
expect(mockWriteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws rather than writing an empty file when the disk read yields nothing', async () => {
|
||||
mockShowSaveDialog.mockResolvedValue({ canceled: false, filePath: '/tmp/out.json' });
|
||||
const getBodyBuffer = vi.fn().mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
downloadResponseBody(
|
||||
{ name: 'My Request' },
|
||||
{ contentType: 'application/json', bodyPath: '/responses/abc.response' },
|
||||
true,
|
||||
getBodyBuffer,
|
||||
),
|
||||
).rejects.toThrow('Failed to read response body from filesystem');
|
||||
|
||||
expect(mockWriteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,17 @@ import { jsonPrettify } from '~/ui/utils/prettify/json';
|
||||
|
||||
export async function downloadResponseBody(
|
||||
activeRequest: { name: string } | null | undefined,
|
||||
activeResponse: { contentType: string; bodyBuffer?: Uint8Array | null } | null | undefined,
|
||||
activeResponse:
|
||||
| {
|
||||
contentType: string;
|
||||
bodyBuffer?: Uint8Array | null;
|
||||
bodyPath?: string;
|
||||
bodyCompression?: 'zip' | null | '__NEEDS_MIGRATION__';
|
||||
}
|
||||
| null
|
||||
| undefined,
|
||||
prettify: boolean,
|
||||
getBodyBuffer?: () => Promise<Uint8Array | string | null>,
|
||||
) {
|
||||
if (!activeResponse || !activeRequest) {
|
||||
console.warn('Nothing to download');
|
||||
@@ -24,12 +33,41 @@ export async function downloadResponseBody(
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
if (prettify && contentType.includes('json')) {
|
||||
await window.main.writeFile({
|
||||
path: outputPath,
|
||||
content: jsonPrettify(bodyBufferToUtf8(activeResponse.bodyBuffer)) || '',
|
||||
|
||||
// The loader only populates bodyBuffer for responses under LARGE_RESPONSE_MB, so reading it
|
||||
// here would write an empty file for exactly the large responses this export needs to handle.
|
||||
// Copy from disk in the main process instead, which also keeps the body out of renderer memory.
|
||||
if (!prettify && activeResponse.bodyPath) {
|
||||
await window.main.writeResponseBodyToFile({
|
||||
sourcePath: activeResponse.bodyPath,
|
||||
destinationPath: outputPath,
|
||||
bodyCompression: activeResponse.bodyCompression === 'zip' ? 'zip' : null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await window.main.writeFile({ path: outputPath, content: activeResponse.bodyBuffer ?? new Uint8Array(0) });
|
||||
|
||||
let bodyBuffer = activeResponse.bodyBuffer ?? null;
|
||||
|
||||
if (!bodyBuffer && getBodyBuffer) {
|
||||
const diskBodyBuffer = await getBodyBuffer();
|
||||
if (typeof diskBodyBuffer === 'string') {
|
||||
throw new TypeError(diskBodyBuffer);
|
||||
}
|
||||
if (diskBodyBuffer) {
|
||||
bodyBuffer = diskBodyBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bodyBuffer && activeResponse.bodyPath) {
|
||||
throw new Error('Failed to read response body from filesystem');
|
||||
}
|
||||
|
||||
if (prettify && contentType.includes('json')) {
|
||||
await window.main.writeFile({
|
||||
path: outputPath,
|
||||
content: jsonPrettify(bodyBufferToUtf8(bodyBuffer)) || '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
await window.main.writeFile({ path: outputPath, content: bodyBuffer ?? new Uint8Array(0) });
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { PreviewModeDropdown } from '../dropdowns/preview-mode-dropdown';
|
||||
import { ResponseHistoryDropdown } from '../dropdowns/response-history-dropdown';
|
||||
import { MockResponseExtractor } from '../editors/mock-response-extractor';
|
||||
import { ErrorBoundary } from '../error-boundary';
|
||||
import { showError } from '../modals';
|
||||
import { ResponseTimer } from '../response-timer';
|
||||
import { SizeTag } from '../tags/size-tag';
|
||||
import { StatusTag } from '../tags/status-tag';
|
||||
@@ -73,7 +74,21 @@ export const ResponsePane: FC<Props> = ({ activeRequestId }) => {
|
||||
const { isExecuting, steps } = useExecutionState({ requestId: activeRequest._id });
|
||||
|
||||
const handleDownloadResponseBody = useCallback(
|
||||
(prettify: boolean) => downloadResponseBody(activeRequest, activeResponse, prettify),
|
||||
async (prettify: boolean) => {
|
||||
try {
|
||||
await downloadResponseBody(
|
||||
activeRequest,
|
||||
activeResponse,
|
||||
prettify,
|
||||
activeResponse ? () => services.helpers.getResponseBodyBuffer(activeResponse) : undefined,
|
||||
);
|
||||
} catch (err) {
|
||||
showError({
|
||||
title: 'Failed to save response body',
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
});
|
||||
}
|
||||
},
|
||||
[activeRequest, activeResponse],
|
||||
);
|
||||
const [timeline, setTimeline] = useState<ResponseTimelineEntry[]>([]);
|
||||
|
||||
@@ -15,7 +15,7 @@ import { ResponseHeadersViewer } from './response-headers-viewer';
|
||||
import { ResponseViewer } from './response-viewer';
|
||||
|
||||
interface Props {
|
||||
download: (...args: any[]) => any;
|
||||
download: (prettify: boolean) => void;
|
||||
responseId: string;
|
||||
bodyBuffer: Uint8Array | null;
|
||||
contentType: string;
|
||||
|
||||
@@ -56,7 +56,7 @@ export interface ResponseViewerProps {
|
||||
contentType: string;
|
||||
disableHtmlPreviewJs: boolean;
|
||||
disablePreviewLinks: boolean;
|
||||
download: (...args: any[]) => any;
|
||||
download: (prettify: boolean) => void;
|
||||
editorFontSize: number;
|
||||
filter: string;
|
||||
filterHistory: string[];
|
||||
@@ -192,7 +192,7 @@ export const ResponseViewer = ({
|
||||
{hugeResponse ? (
|
||||
<Fragment>
|
||||
<p className="pad faint">Responses over {HUGE_RESPONSE_MB}MB cannot be shown</p>
|
||||
<button onClick={download} className="btn btn--clicky inline-block">
|
||||
<button onClick={() => download(false)} className="btn btn--clicky inline-block">
|
||||
Save Response To File
|
||||
</button>
|
||||
</Fragment>
|
||||
@@ -200,7 +200,7 @@ export const ResponseViewer = ({
|
||||
<Fragment>
|
||||
<p className="pad faint">Response over {LARGE_RESPONSE_MB}MB hidden for performance reasons</p>
|
||||
<div>
|
||||
<button onClick={download} className="btn btn--clicky margin-xs inline-block">
|
||||
<button onClick={() => download(false)} className="btn btn--clicky margin-xs inline-block">
|
||||
Save To File
|
||||
</button>
|
||||
<button
|
||||
|
||||
Reference in new issue
Block a user