From a8085dcba445398e15c7d9917ee8905b9f84d039 Mon Sep 17 00:00:00 2001 From: Arora Date: Mon, 13 Apr 2026 11:08:42 -0400 Subject: [PATCH 1/3] fix: large responses now download correctly --- .../dropdowns/preview-mode-dropdown.tsx | 53 ++++++++-- .../__tests__/response-pane-utils.test.ts | 100 +++++++++++++++--- .../components/panes/response-pane-utils.ts | 42 +++++++- 3 files changed, 166 insertions(+), 29 deletions(-) diff --git a/packages/insomnia/src/ui/components/dropdowns/preview-mode-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/preview-mode-dropdown.tsx index 70bd3a1b7b6e..8813c31c69f6 100644 --- a/packages/insomnia/src/ui/components/dropdowns/preview-mode-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/preview-mode-dropdown.tsx @@ -2,9 +2,10 @@ import React, { type FC, useCallback } from 'react'; import { Button } from 'react-aria-components'; import { models } from '~/insomnia-data'; -import { getTimeline } from '~/models/helpers/response-operations'; +import { getBodyBuffer, getTimeline } from '~/models/helpers/response-operations'; -import { getPreviewModeName, PREVIEW_MODE_SOURCE, PREVIEW_MODES } from '../../../common/constants'; +import { getPreviewModeName, LARGE_RESPONSE_MB, PREVIEW_MODE_SOURCE, PREVIEW_MODES } from '../../../common/constants'; +import { showToast } from '../toast-notification'; import { exportHarCurrentRequest } from '../../../common/har'; import { type RequestLoaderData, @@ -78,14 +79,36 @@ export const PreviewModeDropdown: FC = ({ download, copyToClipboard }) => return; } - if (filePath && activeResponse.bodyBuffer) { - await window.main.writeFile({ - path: filePath, - content: headers + '\n' + activeResponse.bodyBuffer.toString('utf8') || '', - }); + if (filePath) { + try { + let body: Buffer; + if (activeResponse.bodyBuffer) { + body = activeResponse.bodyBuffer; + } else if (activeResponse.bodyPath) { + const raw = await getBodyBuffer(activeResponse); + body = typeof raw === 'string' ? Buffer.from(raw) : raw; + } else { + body = Buffer.alloc(0); + } + await window.main.writeFile({ + path: filePath, + content: headers + '\n' + body.toString('utf8'), + }); + } catch (error) { + console.error('Failed to read response body for debug export', error); + showToast({ + icon: 'circle-exclamation', + title: 'Export failed', + description: 'Could not read the response body from disk.', + status: 'error', + }); + } } }, [activeRequest, activeResponse]); - const shouldPrettifyOption = activeResponse?.contentType.includes('json'); + const isJsonResponse = activeResponse?.contentType.includes('json'); + const isLargeResponse = activeResponse + ? Math.max(activeResponse.bytesContent ?? 0, activeResponse.bytesRead ?? 0) > LARGE_RESPONSE_MB * 1024 * 1024 + : false; return ( = ({ download, copyToClipboard }) => - {shouldPrettifyOption && ( - + {isJsonResponse && ( + )} diff --git a/packages/insomnia/src/ui/components/panes/__tests__/response-pane-utils.test.ts b/packages/insomnia/src/ui/components/panes/__tests__/response-pane-utils.test.ts index a1891df1cfc0..fabf546e1783 100644 --- a/packages/insomnia/src/ui/components/panes/__tests__/response-pane-utils.test.ts +++ b/packages/insomnia/src/ui/components/panes/__tests__/response-pane-utils.test.ts @@ -1,7 +1,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getBodyBuffer } from '../../../../models/helpers/response-operations'; +import { showToast } from '../../../../ui/components/toast-notification'; + import { downloadResponseBody } from '../response-pane-utils'; +vi.mock('../../../../models/helpers/response-operations', () => ({ + getBodyBuffer: vi.fn(), +})); + +vi.mock('../../../../ui/components/toast-notification', () => ({ + showToast: vi.fn(), +})); + +const mockGetBodyBuffer = vi.mocked(getBodyBuffer); +const mockShowToast = vi.mocked(showToast); + const mockWriteFile = vi.fn(); const mockShowSaveDialog = vi.fn(); @@ -32,11 +46,7 @@ describe('downloadResponseBody', () => { it('warns and skips the dialog when activeRequest is null', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - await downloadResponseBody( - null, - { contentType: 'application/json', bodyBuffer: Buffer.from('{}') }, - false, - ); + await downloadResponseBody(null, { contentType: 'application/json', bodyBuffer: Buffer.from('{}') }, false); expect(warnSpy).toHaveBeenCalledWith('Nothing to download'); expect(mockShowSaveDialog).not.toHaveBeenCalled(); @@ -86,11 +96,7 @@ describe('downloadResponseBody', () => { // PNG magic bytes — would be corrupted by a UTF-8 round-trip const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); - await downloadResponseBody( - { name: 'My Request' }, - { contentType: 'image/png', bodyBuffer: binaryData }, - false, - ); + await downloadResponseBody({ name: 'My Request' }, { contentType: 'image/png', bodyBuffer: binaryData }, false); expect(mockWriteFile).toHaveBeenCalledOnce(); const { path, content } = mockWriteFile.mock.calls[0][0]; @@ -103,11 +109,7 @@ describe('downloadResponseBody', () => { mockShowSaveDialog.mockResolvedValue({ canceled: false, filePath: '/tmp/out.txt' }); const textData = Buffer.from('Hello, World!'); - await downloadResponseBody( - { name: 'My Request' }, - { contentType: 'text/plain', bodyBuffer: textData }, - true, - ); + await downloadResponseBody({ name: 'My Request' }, { contentType: 'text/plain', bodyBuffer: textData }, true); expect(mockWriteFile).toHaveBeenCalledOnce(); const { content } = mockWriteFile.mock.calls[0][0]; @@ -130,4 +132,72 @@ describe('downloadResponseBody', () => { expect(content.length).toBe(0); }); }); + + describe('large response fallback (bodyBuffer undefined, reads from disk)', () => { + it('reads from disk via getBodyBuffer when bodyBuffer is undefined and writes raw Buffer', async () => { + mockShowSaveDialog.mockResolvedValue({ canceled: false, filePath: '/tmp/out.json' }); + const diskBody = Buffer.from('{"large": true}'); + mockGetBodyBuffer.mockResolvedValue(diskBody); + + await downloadResponseBody( + { name: 'My Request' }, + { contentType: 'application/json', bodyPath: '/fake/body.response', bodyCompression: null }, + false, + ); + + expect(mockGetBodyBuffer).toHaveBeenCalledOnce(); + expect(mockWriteFile).toHaveBeenCalledOnce(); + const { content } = mockWriteFile.mock.calls[0][0]; + expect(Buffer.isBuffer(content)).toBe(true); + expect(content).toEqual(diskBody); + }); + + it('writes empty Buffer when getBodyBuffer returns an empty buffer', async () => { + mockShowSaveDialog.mockResolvedValue({ canceled: false, filePath: '/tmp/out.bin' }); + mockGetBodyBuffer.mockResolvedValue(Buffer.alloc(0)); + + await downloadResponseBody( + { name: 'My Request' }, + { contentType: 'application/octet-stream', bodyPath: '/fake/body.response', bodyCompression: null }, + false, + ); + + expect(mockWriteFile).toHaveBeenCalledOnce(); + const { content } = mockWriteFile.mock.calls[0][0]; + expect(Buffer.isBuffer(content)).toBe(true); + expect(content.length).toBe(0); + }); + + it('does not crash and does not write when getBodyBuffer rejects, and shows an error toast', async () => { + mockShowSaveDialog.mockResolvedValue({ canceled: false, filePath: '/tmp/out.bin' }); + mockGetBodyBuffer.mockRejectedValue(new Error('disk read failed')); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await downloadResponseBody( + { name: 'My Request' }, + { contentType: 'application/octet-stream', bodyPath: '/fake/body.response', bodyCompression: null }, + false, + ); + + expect(errorSpy).toHaveBeenCalledWith('Failed to read response body for export', expect.any(Error)); + expect(mockShowToast).toHaveBeenCalledOnce(); + expect(mockWriteFile).not.toHaveBeenCalled(); + }); + + it('handles getBodyBuffer returning a string by converting to Buffer', async () => { + mockShowSaveDialog.mockResolvedValue({ canceled: false, filePath: '/tmp/out.txt' }); + mockGetBodyBuffer.mockResolvedValue('string response body'); + + await downloadResponseBody( + { name: 'My Request' }, + { contentType: 'text/plain', bodyPath: '/fake/body.response', bodyCompression: null }, + false, + ); + + expect(mockWriteFile).toHaveBeenCalledOnce(); + const { content } = mockWriteFile.mock.calls[0][0]; + expect(Buffer.isBuffer(content)).toBe(true); + expect(content.toString('utf8')).toBe('string response body'); + }); + }); }); diff --git a/packages/insomnia/src/ui/components/panes/response-pane-utils.ts b/packages/insomnia/src/ui/components/panes/response-pane-utils.ts index d041cc3f5e6f..4912b76f98bd 100644 --- a/packages/insomnia/src/ui/components/panes/response-pane-utils.ts +++ b/packages/insomnia/src/ui/components/panes/response-pane-utils.ts @@ -1,10 +1,21 @@ import { extension as mimeExtension } from 'mime-types'; +import type { Compression } from '~/insomnia-data'; +import { getBodyBuffer } from '~/models/helpers/response-operations'; +import { showToast } from '~/ui/components/toast-notification'; import { jsonPrettify } from '~/utils/prettify/json'; export async function downloadResponseBody( activeRequest: { name: string } | null | undefined, - activeResponse: { contentType: string; bodyBuffer?: Buffer | null } | null | undefined, + activeResponse: + | { + contentType: string; + bodyBuffer?: Buffer | null; + bodyPath?: string; + bodyCompression?: Compression; + } + | null + | undefined, prettify: boolean, ) { if (!activeResponse || !activeRequest) { @@ -23,12 +34,35 @@ export async function downloadResponseBody( if (canceled) { return; } - if (prettify && contentType.includes('json')) { + + let body: Buffer; + try { + if (activeResponse.bodyBuffer) { + body = activeResponse.bodyBuffer; + } else if (activeResponse.bodyPath) { + const raw = await getBodyBuffer(activeResponse); + body = typeof raw === 'string' ? Buffer.from(raw) : raw; + } else { + console.warn('Response has no bodyBuffer or bodyPath; writing empty file'); + body = Buffer.alloc(0); + } + } catch (error) { + console.error('Failed to read response body for export', error); + showToast({ + icon: 'circle-exclamation', + title: 'Export failed', + description: 'Could not read the response body from disk.', + status: 'error', + }); + return; + } + + if (prettify && activeResponse.bodyBuffer && contentType.includes('json')) { await window.main.writeFile({ path: outputPath, - content: jsonPrettify(activeResponse.bodyBuffer?.toString('utf8')) || '', + content: jsonPrettify(body.toString('utf8')) || '', }); return; } - await window.main.writeFile({ path: outputPath, content: activeResponse.bodyBuffer ?? Buffer.alloc(0) }); + await window.main.writeFile({ path: outputPath, content: body }); } From 42c4a1af9e87b27f68fe268f2d50198070bc41a0 Mon Sep 17 00:00:00 2001 From: Arora Date: Mon, 13 Apr 2026 14:22:54 -0400 Subject: [PATCH 2/3] fix: linting --- .../src/ui/components/dropdowns/preview-mode-dropdown.tsx | 2 +- .../ui/components/panes/__tests__/response-pane-utils.test.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/insomnia/src/ui/components/dropdowns/preview-mode-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/preview-mode-dropdown.tsx index 8813c31c69f6..fad5abb27035 100644 --- a/packages/insomnia/src/ui/components/dropdowns/preview-mode-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/preview-mode-dropdown.tsx @@ -5,7 +5,6 @@ import { models } from '~/insomnia-data'; import { getBodyBuffer, getTimeline } from '~/models/helpers/response-operations'; import { getPreviewModeName, LARGE_RESPONSE_MB, PREVIEW_MODE_SOURCE, PREVIEW_MODES } from '../../../common/constants'; -import { showToast } from '../toast-notification'; import { exportHarCurrentRequest } from '../../../common/har'; import { type RequestLoaderData, @@ -13,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 { showToast } from '../toast-notification'; interface Props { download: (pretty: boolean) => any; diff --git a/packages/insomnia/src/ui/components/panes/__tests__/response-pane-utils.test.ts b/packages/insomnia/src/ui/components/panes/__tests__/response-pane-utils.test.ts index fabf546e1783..1f42b60b6758 100644 --- a/packages/insomnia/src/ui/components/panes/__tests__/response-pane-utils.test.ts +++ b/packages/insomnia/src/ui/components/panes/__tests__/response-pane-utils.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getBodyBuffer } from '../../../../models/helpers/response-operations'; import { showToast } from '../../../../ui/components/toast-notification'; - import { downloadResponseBody } from '../response-pane-utils'; vi.mock('../../../../models/helpers/response-operations', () => ({ From 8a79b638b46f1064881612ee4ca588404605ece7 Mon Sep 17 00:00:00 2001 From: Arora Date: Tue, 14 Apr 2026 11:01:16 -0400 Subject: [PATCH 3/3] chore: add e2e testing to prevent future regressions --- .../response-download-collection.yaml | 50 ++++++++ packages/insomnia-smoke-test/server/index.ts | 9 ++ .../tests/smoke/response-downloads.test.ts | 114 ++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 packages/insomnia-smoke-test/fixtures/response-download-collection.yaml create mode 100644 packages/insomnia-smoke-test/tests/smoke/response-downloads.test.ts diff --git a/packages/insomnia-smoke-test/fixtures/response-download-collection.yaml b/packages/insomnia-smoke-test/fixtures/response-download-collection.yaml new file mode 100644 index 000000000000..53315dad7940 --- /dev/null +++ b/packages/insomnia-smoke-test/fixtures/response-download-collection.yaml @@ -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 diff --git a/packages/insomnia-smoke-test/server/index.ts b/packages/insomnia-smoke-test/server/index.ts index f3536537c573..b48e917c6a4c 100644 --- a/packages/insomnia-smoke-test/server/index.ts +++ b/packages/insomnia-smoke-test/server/index.ts @@ -39,6 +39,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', diff --git a/packages/insomnia-smoke-test/tests/smoke/response-downloads.test.ts b/packages/insomnia-smoke-test/tests/smoke/response-downloads.test.ts new file mode 100644 index 000000000000..62baf5fb2e94 --- /dev/null +++ b/packages/insomnia-smoke-test/tests/smoke/response-downloads.test.ts @@ -0,0 +1,114 @@ +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'; + +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 page.getByLabel('Request Collection').getByTestId('JSON Request').press('Enter'); + 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 page.getByLabel('Request Collection').getByTestId('JSON Request').press('Enter'); + 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 page.getByLabel('Request Collection').getByTestId('JSON Request').press('Enter'); + 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 page.getByLabel('Request Collection').getByTestId('Large JSON Request').press('Enter'); + 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); + } + }); + + test('Prettified export is disabled for large JSON response', async ({ insomnia, page }) => { + await insomnia.projectPage.importFixture(FIXTURE); + await page.getByLabel('Request Collection').getByTestId('Large JSON Request').press('Enter'); + 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'); + }); +});