Files
bruno/tests/request/binary-file/binary-file-upload.spec.ts
2026-05-15 13:47:36 +05:30

194 lines
8.0 KiB
TypeScript

import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { test, expect } from '../../../playwright';
import {
closeAllCollections,
createCollection,
createRequest,
openRequest,
selectRequestPaneTab,
sendRequest
} from '../../utils/page';
import { buildCommonLocators } from '../../utils/page/locators';
const selectAllShortcut = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
/**
* E2E test for File / Binary request body uploads.
* Regression test for the bug where a file body with content-type containing
* "json" was JSON-stringified during interpolation, so the server received the
* Node ReadStream metadata (~342 bytes) instead of the file contents.
*
* Server-side endpoints:
* POST /api/file-binary/binary-upload-json — application/json
* POST /api/file-binary/binary-upload-octet-stream — application/octet-stream
*
* Both echo back { bytesReceived, sha256, looksLikeSerializedNodeStream, ... }
* so we can assert the upload arrived byte-exact.
*/
test.describe.serial('File / Binary body upload', () => {
const collectionName = 'binary-file-upload';
const jsonRequestName = 'json-upload';
const octetRequestName = 'octet-upload';
let tmpDir: string;
let jsonFilePath: string;
let octetFilePath: string;
let jsonFileSha256: string;
let octetFileSha256: string;
let jsonFileSize: number;
let octetFileSize: number;
test.beforeAll(async ({ page, electronApp, createTmpDir }) => {
tmpDir = await createTmpDir('binary-file-upload');
// The JSON file is intentionally larger than the 20 MiB streaming
// threshold (STREAMING_FILE_SIZE_THRESHOLD in prepare-request.js) so the
// body is sent as an fs.ReadStream — this is the exact code path that
// produced the bug. Anything <= 20 MiB would go through the Buffer path,
// which was never broken.
const LARGE_JSON_BYTES = 80 * 1024 * 1024; // 25 MiB > 20 MiB threshold
const jsonBuffer = Buffer.alloc(LARGE_JSON_BYTES, 'a');
jsonFilePath = path.join(tmpDir, 'payload.json');
await fs.promises.writeFile(jsonFilePath, jsonBuffer);
jsonFileSize = LARGE_JSON_BYTES;
jsonFileSha256 = crypto.createHash('sha256').update(jsonBuffer).digest('hex');
const octetContent = 'plain octet-stream payload\n';
octetFilePath = path.join(tmpDir, 'payload.bin');
await fs.promises.writeFile(octetFilePath, octetContent);
octetFileSize = Buffer.byteLength(octetContent);
octetFileSha256 = crypto.createHash('sha256').update(octetContent).digest('hex');
// Stash and replace the native file picker dialog so FilePickerEditor's
// Browse button resolves to a path of our choosing. The currentSelection
// is updated per-test so each test picks the right file.
await electronApp.evaluate(({ dialog }) => {
(dialog as any).__originalShowOpenDialog = dialog.showOpenDialog;
(dialog as any).__currentSelection = '';
dialog.showOpenDialog = async () => ({
canceled: false,
filePaths: [(dialog as any).__currentSelection]
});
});
await test.step('Create collection and requests', async () => {
await createCollection(page, collectionName, tmpDir);
await createRequest(page, jsonRequestName, collectionName, {
url: 'http://localhost:8081/api/file-binary/binary-upload-json',
method: 'POST',
inFolder: false
});
await createRequest(page, octetRequestName, collectionName, {
url: 'http://localhost:8081/api/file-binary/binary-upload-octet-stream',
method: 'POST',
inFolder: false
});
});
});
test.afterAll(async ({ page, electronApp }) => {
await electronApp.evaluate(({ dialog }) => {
if ((dialog as any).__originalShowOpenDialog) {
dialog.showOpenDialog = (dialog as any).__originalShowOpenDialog;
delete (dialog as any).__originalShowOpenDialog;
delete (dialog as any).__currentSelection;
}
});
await closeAllCollections(page);
});
// Switches the body to File / Binary, adds a row, picks the file, and
// (optionally) overrides the content-type. Bruno's `updateFile` reducer
// auto-fills content-type from the file extension via mime.contentType
// (.json → application/json; charset=utf-8, .bin → application/octet-stream),
// so when we want a different value (e.g. plain "application/json") we
// must select-all and replace — typing into the prefilled cell would
// splice into it at the caret and produce a corrupted header.
const configureFileBody = async (
page: import('@playwright/test').Page,
overrideContentType?: string
) => {
await selectRequestPaneTab(page, 'Body');
const locators = buildCommonLocators(page);
await locators.request.bodyModeSelector().click();
await page.locator('.dropdown-item').filter({ hasText: 'File / Binary' }).click();
await test.step('Add file row and pick the file', async () => {
await page.getByRole('button', { name: /Add File/i }).click();
await page.locator('.file-picker-btn').first().click();
await expect(page.locator('.file-picker-selected').first()).toBeVisible({ timeout: 5000 });
});
if (overrideContentType) {
await test.step(`Override content-type to "${overrideContentType}"`, async () => {
// Second column in the FileBody table is the content-type editor (SingleLineEditor / CodeMirror)
const contentTypeCell = page.locator('table tbody tr').first().locator('td').nth(1);
await contentTypeCell.locator('.CodeMirror').click();
await page.keyboard.press(selectAllShortcut);
await page.keyboard.press('Backspace');
await page.keyboard.type(overrideContentType);
// Commit the value and blur the editor so the new content-type is persisted
await page.keyboard.press('Tab');
});
}
};
test('JSON content-type: file bytes are sent verbatim, not as serialized stream metadata', async ({
page,
electronApp
}) => {
await electronApp.evaluate(({ dialog }, filePath: string) => {
(dialog as any).__currentSelection = filePath;
}, jsonFilePath);
await openRequest(page, collectionName, jsonRequestName, { persist: true });
// Override the auto-detected "application/json; charset=utf-8" with plain
// "application/json" — that's the exact header the bug repro used.
await configureFileBody(page, 'application/json');
await sendRequest(page, 200, 30000);
const locators = buildCommonLocators(page);
const responseText = await locators.response.previewContainer().innerText();
expect(responseText).toContain('"contentType": "application/json"');
const jsonBytesReceivedMatch = responseText.match(/"bytesReceived":\s*(\d+)/);
expect(jsonBytesReceivedMatch).not.toBeNull();
expect(Number(jsonBytesReceivedMatch![1])).toBe(jsonFileSize);
expect(responseText).toContain(`"sha256": "${jsonFileSha256}"`);
expect(responseText).toContain('"looksLikeSerializedNodeStream": false');
// Sanity check: the bug would have produced these stream-metadata fields
expect(responseText).not.toContain('"_readableState"');
});
test('octet-stream content-type: file bytes are sent verbatim (control case)', async ({
page,
electronApp
}) => {
await electronApp.evaluate(({ dialog }, filePath: string) => {
(dialog as any).__currentSelection = filePath;
}, octetFilePath);
await openRequest(page, collectionName, octetRequestName, { persist: true });
await configureFileBody(page);
await sendRequest(page, 200, 30000);
const locators = buildCommonLocators(page);
const responseText = await locators.response.previewContainer().innerText();
expect(responseText).toContain('application/octet-stream');
const octetBytesReceivedMatch = responseText.match(/"bytesReceived":\s*(\d+)/);
expect(octetBytesReceivedMatch).not.toBeNull();
expect(Number(octetBytesReceivedMatch![1])).toBe(octetFileSize);
expect(responseText).toContain(`"sha256": "${octetFileSha256}"`);
expect(responseText).toContain('"looksLikeSerializedNodeStream": false');
});
});