diff --git a/packages/bruno-app/src/components/MultipartFileChipsCell/index.js b/packages/bruno-app/src/components/MultipartFileChipsCell/index.js
index 62340e766..73ee8ac4e 100644
--- a/packages/bruno-app/src/components/MultipartFileChipsCell/index.js
+++ b/packages/bruno-app/src/components/MultipartFileChipsCell/index.js
@@ -7,6 +7,44 @@ import Wrapper, { OverflowList } from './StyledWrapper';
const basename = (filePath) => (filePath ? path.basename(normalizePath(String(filePath))) : '');
+const FileEntry = ({ filePath, toolhintId, editMode, onRemove, variant }) => {
+ const [overRemove, setOverRemove] = useState(false);
+ const isChip = variant === 'chip';
+
+ return (
+
+
+
+ {basename(filePath)}
+
+ {editMode && (
+
+ )}
+
+ );
+};
+
// Keep in sync with the corresponding CSS values in StyledWrapper.js:
// MIN_CHIP_W ↔ .file-chip { min-width: 75px }
// CHIP_GAP ↔ .file-chips-row { gap: 4px }
@@ -19,6 +57,8 @@ const MultipartFileChipsCell = ({ files, onRemove, onAdd, editMode = true }) =>
const containerRef = useRef(null);
const tooltipPrefix = useRef(`mp-tip-${Math.random().toString(36).slice(2, 10)}`).current;
const [visibleCount, setVisibleCount] = useState(files.length);
+ const [summaryOpen, setSummaryOpen] = useState(false);
+ const [moreOpen, setMoreOpen] = useState(false);
useLayoutEffect(() => {
const container = containerRef.current;
@@ -59,69 +99,27 @@ const MultipartFileChipsCell = ({ files, onRemove, onAdd, editMode = true }) =>
const collapsed = visibleCount === 0 && files.length > 0;
const renderChip = (filePath, idx) => (
-
-
-
- {basename(filePath)}
-
- {editMode && (
-
- )}
-
+ editMode={editMode}
+ onRemove={onRemove}
+ />
);
const renderOverflowList = (list) => (
{list.map((p, i) => (
-
-
-
- {basename(p)}
-
- {editMode && (
-
- )}
-
+ editMode={editMode}
+ onRemove={onRemove}
+ />
))}
);
@@ -133,6 +131,8 @@ const MultipartFileChipsCell = ({ files, onRemove, onAdd, editMode = true }) =>
document.body}
+ onMount={() => setSummaryOpen(true)}
+ onHidden={() => setSummaryOpen(false)}
icon={(
)}
>
- {renderOverflowList(files)}
+ {summaryOpen ? renderOverflowList(files) : null}
>
@@ -160,6 +160,8 @@ const MultipartFileChipsCell = ({ files, onRemove, onAdd, editMode = true }) =>
document.body}
+ onMount={() => setMoreOpen(true)}
+ onHidden={() => setMoreOpen(false)}
icon={(
)}
>
- {renderOverflowList(overflow)}
+ {moreOpen ? renderOverflowList(overflow) : null}
)}
>
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/exampleReducers.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/exampleReducers.js
index 5e07f7c38..7d3d216e5 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/exampleReducers.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/exampleReducers.js
@@ -22,7 +22,7 @@ export const addResponseExample = (state, action) => {
}
// Ensure body always has a mode field (default to 'none' if not present)
- const requestBody = item.draft.request.body || {};
+ const requestBody = cloneDeep(item.draft.request.body || {});
if (!requestBody.mode) {
requestBody.mode = 'none';
}
diff --git a/tests/request/multipart-form/fixtures/collection/bruno.json b/tests/request/multipart-form/fixtures/collection/bruno.json
new file mode 100644
index 000000000..03a26e1f7
--- /dev/null
+++ b/tests/request/multipart-form/fixtures/collection/bruno.json
@@ -0,0 +1,5 @@
+{
+ "version": "1",
+ "name": "collection",
+ "type": "collection"
+}
diff --git a/tests/request/multipart-form/fixtures/collection/chip-tooltip.bru b/tests/request/multipart-form/fixtures/collection/chip-tooltip.bru
new file mode 100644
index 000000000..8a819a9b0
--- /dev/null
+++ b/tests/request/multipart-form/fixtures/collection/chip-tooltip.bru
@@ -0,0 +1,15 @@
+meta {
+ name: chip-tooltip
+ type: http
+ seq: 1
+}
+
+post {
+ url: https://api.example.com/upload
+ body: multipartForm
+ auth: none
+}
+
+body:multipart-form {
+ files: @file(alpha.txt)
+}
diff --git a/tests/request/multipart-form/init-user-data/collection-security.json b/tests/request/multipart-form/init-user-data/collection-security.json
new file mode 100644
index 000000000..e8ad3e9d7
--- /dev/null
+++ b/tests/request/multipart-form/init-user-data/collection-security.json
@@ -0,0 +1,10 @@
+{
+ "collections": [
+ {
+ "path": "{{projectRoot}}/tests/request/multipart-form/fixtures/collection",
+ "securityConfig": {
+ "jsSandboxMode": "safe"
+ }
+ }
+ ]
+}
diff --git a/tests/request/multipart-form/init-user-data/preferences.json b/tests/request/multipart-form/init-user-data/preferences.json
new file mode 100644
index 000000000..02d753451
--- /dev/null
+++ b/tests/request/multipart-form/init-user-data/preferences.json
@@ -0,0 +1,12 @@
+{
+ "maximized": false,
+ "lastOpenedCollections": [
+ "{{projectRoot}}/tests/request/multipart-form/fixtures/collection"
+ ],
+ "preferences": {
+ "onboarding": {
+ "hasLaunchedBefore": true,
+ "hasSeenWelcomeModal": true
+ }
+ }
+}
diff --git a/tests/request/multipart-form/multipart-chip-tooltips.spec.ts b/tests/request/multipart-form/multipart-chip-tooltips.spec.ts
new file mode 100644
index 000000000..15059727b
--- /dev/null
+++ b/tests/request/multipart-form/multipart-chip-tooltips.spec.ts
@@ -0,0 +1,54 @@
+import { test, expect } from '../../../playwright';
+import type { Locator } from '@playwright/test';
+import { closeAllCollections } from '../../utils/page';
+
+test.describe('Multipart Form - Chip Tooltip Swap', () => {
+ test.afterAll(async ({ pageWithUserData: page }) => {
+ await closeAllCollections(page);
+ });
+
+ test('tooltip swaps between file path and "Remove file"', async ({ pageWithUserData: page }) => {
+ await page.locator('#sidebar-collection-name').getByText('collection').click();
+ await page.locator('.collection-item-name').filter({ hasText: 'chip-tooltip' }).click();
+
+ const tooltip = page.locator('[role="tooltip"], .react-tooltip').filter({ visible: true });
+
+ const inlineChip = page.getByTestId('multipart-file-chip').first();
+ const summary = page.getByTestId('multipart-file-summary');
+ await expect(inlineChip.or(summary).first()).toBeVisible({ timeout: 15000 });
+
+ let nameTarget: Locator, removeBtn: Locator;
+ if (await summary.count()) {
+ await summary.click();
+ const row = page.getByTestId('multipart-file-overflow-row').first();
+ await expect(row).toBeVisible();
+ nameTarget = row.locator('.overflow-row-name');
+ removeBtn = row.getByTestId('multipart-file-overflow-remove');
+ } else {
+ nameTarget = inlineChip.locator('.file-chip-name');
+ removeBtn = inlineChip.getByTestId('multipart-file-chip-remove');
+ }
+
+ await test.step('Hover chip body → file path', async () => {
+ await nameTarget.hover();
+ await expect(tooltip.first()).toBeVisible({ timeout: 15000 });
+ await expect(tooltip.first()).toContainText('alpha.txt');
+ });
+
+ await test.step('Hover X → "Remove file"', async () => {
+ await removeBtn.hover();
+ await expect(tooltip.first()).toHaveText('Remove file');
+ });
+
+ await test.step('Hover back to chip body → path again', async () => {
+ await nameTarget.hover();
+ await expect(tooltip.first()).not.toHaveText('Remove file');
+ await expect(tooltip.first()).toContainText('alpha.txt');
+ });
+
+ await test.step('Only one tooltip visible at a time', async () => {
+ await removeBtn.hover();
+ await expect(tooltip).toHaveCount(1);
+ });
+ });
+});
diff --git a/tests/response-examples/fixtures/collection/multipart-example.bru b/tests/response-examples/fixtures/collection/multipart-example.bru
index 1879d04d6..d1b3907b9 100644
--- a/tests/response-examples/fixtures/collection/multipart-example.bru
+++ b/tests/response-examples/fixtures/collection/multipart-example.bru
@@ -6,7 +6,7 @@ meta {
post {
url: https://api.example.com/upload
- body: multipart-form
+ body: multipartForm
auth: none
}
diff --git a/tests/response-examples/multipart-form-chips.spec.ts b/tests/response-examples/multipart-form-chips.spec.ts
index c38735e16..776214a4e 100644
--- a/tests/response-examples/multipart-form-chips.spec.ts
+++ b/tests/response-examples/multipart-form-chips.spec.ts
@@ -17,11 +17,6 @@ test.describe('Response Example - Multipart Form File Chips', () => {
}
});
- // `pageWithUserData` reuses the Electron app across tests in the same worker
- // (it doesn't pass `closePrevious: true`), so we can't assume a clean DOM
- // between tests. This helper is idempotent: it only toggles the chevron when
- // the examples list isn't already expanded, so re-running it after a
- // previous test leaves things in either state still works.
const openMultipartExample = async (page: Page) => {
await page.locator('#sidebar-collection-name').getByText('collection').click();
@@ -45,11 +40,6 @@ test.describe('Response Example - Multipart Form File Chips', () => {
});
await test.step('All three files are present', async () => {
- // The cell can be in one of three layout modes (inline chips, `+N more`
- // overflow, or a fully collapsed `N files` summary) depending on the
- // value-column width. CI Linux runners often have a small display that
- // pushes the cell into the collapsed mode, so we read both inline chips
- // and any overflow-dropdown rows to cover every case.
const summary = page.getByTestId('multipart-file-summary');
const more = page.getByTestId('multipart-file-more');
const inlineNames = await page.getByTestId('multipart-file-chip').allTextContents();
diff --git a/tests/response-examples/save-as-example-multipart.spec.ts b/tests/response-examples/save-as-example-multipart.spec.ts
new file mode 100644
index 000000000..a26ab180a
--- /dev/null
+++ b/tests/response-examples/save-as-example-multipart.spec.ts
@@ -0,0 +1,59 @@
+import { test, expect } from '../../playwright';
+import fs from 'fs';
+import path from 'path';
+
+const fixturePath = path.join(__dirname, 'fixtures', 'collection', 'multipart-example.bru');
+
+test.describe('Response Example - multipart files preserved when creating example from request', () => {
+ // Snapshot the fixture so we restore the exact working-tree state (including
+ // any uncommitted changes), not whatever HEAD has.
+ let originalFixture: string;
+
+ test.beforeAll(() => {
+ originalFixture = fs.readFileSync(fixturePath, 'utf8');
+ });
+
+ test.afterAll(() => {
+ fs.writeFileSync(fixturePath, originalFixture);
+ });
+
+ test('file chips render real names, not "[Circular]"', async ({ pageWithUserData: page }) => {
+ await test.step('Open the multipart request', async () => {
+ await page.locator('#sidebar-collection-name').getByText('collection').click();
+ await page.locator('.collection-item-name').filter({ hasText: 'multipart-example' }).click();
+ });
+
+ await test.step('Open the 3-dot menu and pick "Create Example"', async () => {
+ const requestRow = page.locator('.collection-item-name').filter({ hasText: 'multipart-example' });
+ await requestRow.hover();
+ await requestRow.locator('.menu-icon').click({ force: true });
+ await page.locator('[role="menuitem"][data-item-id="create-example"]').click();
+ });
+
+ await test.step('Fill the modal and submit', async () => {
+ await page.getByTestId('create-example-name-input').clear();
+ await page.getByTestId('create-example-name-input').fill('Created From Request');
+ await page.getByRole('button', { name: 'Create Example' }).click();
+ });
+
+ await test.step('Example tab opens with the right title', async () => {
+ const title = page.getByTestId('response-example-title');
+ await expect(title).toBeVisible();
+ await expect(title).toContainText('Created From Request');
+ });
+
+ await test.step('File chips show real names', async () => {
+ // Read whichever layout shows up: inline chips or the collapsed summary dropdown.
+ const chips = page.getByTestId('multipart-file-chip');
+ let names = await chips.allTextContents();
+
+ if (names.length === 0) {
+ await page.getByTestId('multipart-file-summary').click();
+ names = await page.getByTestId('multipart-file-overflow-row').allTextContents();
+ }
+
+ expect(names).toEqual(['alpha.txt', 'beta.txt', 'gamma.txt']);
+ expect(names).not.toContain('[Circular]');
+ });
+ });
+});