mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
Feat/response examples testing fixes (#5974)
* fix: cloning feat: add response saving limit fix: status code stays OK when i save an example * fix: lint * fix: newly created examples are not opening in new tab * fix: playwright tests * fix: use itemUId to fins item * fix: response save
This commit is contained in:
@@ -1,18 +1,23 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { IconBookmark } from '@tabler/icons';
|
||||
import { addResponseExample } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
|
||||
import { uuid } from 'utils/common';
|
||||
import toast from 'react-hot-toast';
|
||||
import CreateExampleModal from 'components/ResponseExample/CreateExampleModal';
|
||||
import { getBodyType, processResponseContent } from 'utils/responseBodyProcessor';
|
||||
import { getBodyType } from 'utils/responseBodyProcessor';
|
||||
import classnames from 'classnames';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ResponseBookmark = ({ item, collection }) => {
|
||||
const ResponseBookmark = ({ item, collection, responseSize }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [showSaveResponseExampleModal, setShowSaveResponseExampleModal] = useState(false);
|
||||
const response = item.response || {};
|
||||
|
||||
const isResponseTooLarge = responseSize >= 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
// Only show for HTTP requests
|
||||
if (item.type !== 'http-request') {
|
||||
return null;
|
||||
@@ -50,10 +55,16 @@ const ResponseBookmark = ({ item, collection }) => {
|
||||
toast.error('No valid response to save as example');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isResponseTooLarge) {
|
||||
toast.error('Response size exceeds 5MB limit. Cannot save as example.');
|
||||
return;
|
||||
}
|
||||
|
||||
setShowSaveResponseExampleModal(true);
|
||||
};
|
||||
|
||||
const saveAsExample = (name, description = '') => {
|
||||
const saveAsExample = async (name, description = '') => {
|
||||
// Convert headers object to array format expected by schema
|
||||
const headersArray = response.headers && typeof response.headers === 'object'
|
||||
? Object.entries(response.headers).map(([name, value]) => ({
|
||||
@@ -80,12 +91,33 @@ const ResponseBookmark = ({ item, collection }) => {
|
||||
description: description
|
||||
};
|
||||
|
||||
// Calculate the index where the example will be saved
|
||||
// This will be the length of the examples array after adding the new one
|
||||
const existingExamples = item.draft?.examples || item.examples || [];
|
||||
const exampleIndex = existingExamples.length;
|
||||
const exampleUid = uuid();
|
||||
|
||||
dispatch(addResponseExample({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
example: exampleData
|
||||
example: {
|
||||
...exampleData,
|
||||
uid: exampleUid
|
||||
}
|
||||
}));
|
||||
dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
// Save the request
|
||||
await dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
// Task middleware will track this and open the example in a new tab once the file is reloaded
|
||||
dispatch(insertTaskIntoQueue({
|
||||
uid: exampleUid,
|
||||
type: 'OPEN_EXAMPLE',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
exampleIndex: exampleIndex
|
||||
}));
|
||||
|
||||
setShowSaveResponseExampleModal(false);
|
||||
toast.success(`Example "${name}" created successfully`);
|
||||
};
|
||||
@@ -95,9 +127,15 @@ const ResponseBookmark = ({ item, collection }) => {
|
||||
<StyledWrapper className="ml-2 flex items-center">
|
||||
<button
|
||||
onClick={handleSaveClick}
|
||||
disabled={!response || response.error}
|
||||
title="Save current response as example"
|
||||
className="p-1"
|
||||
disabled={isResponseTooLarge}
|
||||
title={
|
||||
isResponseTooLarge
|
||||
? 'Response size exceeds 5MB limit. Cannot save as example.'
|
||||
: 'Save current response as example'
|
||||
}
|
||||
className={classnames('p-1', {
|
||||
'opacity-50 cursor-not-allowed': isResponseTooLarge
|
||||
})}
|
||||
data-testid="response-bookmark-btn"
|
||||
>
|
||||
<IconBookmark size={16} strokeWidth={1.5} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import find from 'lodash/find';
|
||||
import classnames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -51,7 +51,22 @@ const ResponsePane = ({ item, collection }) => {
|
||||
};
|
||||
|
||||
const response = item.response || {};
|
||||
const responseSize = response.size || 0;
|
||||
|
||||
const responseSize = useMemo(() => {
|
||||
if (typeof response.size === 'number') {
|
||||
return response.size;
|
||||
}
|
||||
|
||||
if (!response.dataBuffer) return 0;
|
||||
|
||||
try {
|
||||
// dataBuffer is base64 encoded, so we need to calculate the actual size
|
||||
const buffer = Buffer.from(response.dataBuffer, 'base64');
|
||||
return buffer.length;
|
||||
} catch (error) {
|
||||
return 0;
|
||||
}
|
||||
}, [response.size, response.dataBuffer]);
|
||||
|
||||
const getTabPanel = (tab) => {
|
||||
switch (tab) {
|
||||
@@ -168,7 +183,7 @@ const ResponsePane = ({ item, collection }) => {
|
||||
<>
|
||||
<ResponseClear item={item} collection={collection} />
|
||||
<ResponseSave item={item} />
|
||||
<ResponseBookmark item={item} collection={collection} />
|
||||
<ResponseBookmark item={item} collection={collection} responseSize={responseSize} />
|
||||
<StatusCode status={response.status} />
|
||||
<ResponseTime duration={response.duration} />
|
||||
<ResponseSize size={responseSize} />
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import React, { useState, useRef, forwardRef, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import { deleteResponseExample, updateResponseExample, addResponseExample } from 'providers/ReduxStore/slices/collections';
|
||||
import {
|
||||
updateResponseExample,
|
||||
cloneResponseExample
|
||||
} from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
|
||||
import { uuid } from 'utils/common';
|
||||
import { IconDots } from '@tabler/icons';
|
||||
import ExampleIcon from 'components/Icons/ExampleIcon';
|
||||
import range from 'lodash/range';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import classnames from 'classnames';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import Modal from 'components/Modal';
|
||||
@@ -53,23 +57,31 @@ const ExampleItem = ({ example, item, collection }) => {
|
||||
setEditName(example.name);
|
||||
}, [example.name]);
|
||||
|
||||
const handleClone = () => {
|
||||
// Only pass response-related data - the reducer will automatically capture current request state
|
||||
const clonedExample = {
|
||||
name: `${example.name} (Copy)`,
|
||||
status: example.response?.status || example.status,
|
||||
statusText: example.response?.statusText || example.statusText,
|
||||
headers: cloneDeep(example.response?.headers || example.headers),
|
||||
body: cloneDeep(example.response?.body || example.body),
|
||||
description: example.description
|
||||
};
|
||||
const handleClone = async () => {
|
||||
// Calculate the index where the cloned example will be saved
|
||||
// It will be at the end of the examples array
|
||||
const existingExamples = item.draft?.examples || item.examples || [];
|
||||
const clonedExampleIndex = existingExamples.length;
|
||||
const clonedExampleUid = uuid();
|
||||
|
||||
dispatch(addResponseExample({
|
||||
dispatch(cloneResponseExample({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
example: clonedExample
|
||||
exampleUid: example.uid,
|
||||
clonedUid: clonedExampleUid
|
||||
}));
|
||||
|
||||
// Save the request
|
||||
await dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
// Task middleware will track this and open the example in a new tab once the file is reloaded
|
||||
dispatch(insertTaskIntoQueue({
|
||||
uid: clonedExampleUid,
|
||||
type: 'OPEN_EXAMPLE',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
exampleIndex: clonedExampleIndex
|
||||
}));
|
||||
dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
if (dropdownTippyRef.current) {
|
||||
dropdownTippyRef.current.hide();
|
||||
@@ -149,9 +161,7 @@ const ExampleItem = ({ example, item, collection }) => {
|
||||
>
|
||||
<div style={{ width: 16, minWidth: 16 }}></div>
|
||||
<ExampleIcon size={16} color="currentColor" className="mr-2 text-gray-400 flex-shrink-0" />
|
||||
<span className="item-name truncate text-gray-700 dark:text-gray-300 ">
|
||||
{example.name}
|
||||
</span>
|
||||
<span className="item-name truncate text-gray-700 dark:text-gray-300 ">{example.name}</span>
|
||||
</div>
|
||||
<div className="menu-icon pr-2">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useSelector, useDispatch } from 'react-redux';
|
||||
import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import { handleCollectionItemDrop, sendRequest, showInFolder, pasteItem, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { toggleCollectionItem, addResponseExample } from 'providers/ReduxStore/slices/collections';
|
||||
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
|
||||
import { uuid } from 'utils/common';
|
||||
import { copyRequest } from 'providers/ReduxStore/slices/app';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import NewRequest from 'components/Sidebar/NewRequest';
|
||||
@@ -300,7 +302,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateExample = (name, description = '') => {
|
||||
const handleCreateExample = async (name, description = '') => {
|
||||
// Create example with default values
|
||||
const exampleData = {
|
||||
name: name,
|
||||
@@ -314,13 +316,32 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate the index where the example will be saved
|
||||
const existingExamples = item.draft?.examples || item.examples || [];
|
||||
const exampleIndex = existingExamples.length;
|
||||
const exampleUid = uuid();
|
||||
|
||||
dispatch(addResponseExample({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collectionUid,
|
||||
example: exampleData
|
||||
example: {
|
||||
...exampleData,
|
||||
uid: exampleUid
|
||||
}
|
||||
}));
|
||||
|
||||
// Save the request
|
||||
await dispatch(saveRequest(item.uid, collectionUid));
|
||||
|
||||
// Task middleware will track this and open the example in a new tab once the file is reloaded
|
||||
dispatch(insertTaskIntoQueue({
|
||||
uid: exampleUid,
|
||||
type: 'OPEN_EXAMPLE',
|
||||
collectionUid: collectionUid,
|
||||
itemUid: item.uid,
|
||||
exampleIndex: exampleIndex
|
||||
}));
|
||||
|
||||
dispatch(saveRequest(item.uid, collectionUid));
|
||||
toast.success(`Example "${name}" created successfully`);
|
||||
setCreateExampleModalOpen(false);
|
||||
};
|
||||
|
||||
@@ -4,8 +4,8 @@ import filter from 'lodash/filter';
|
||||
import { createListenerMiddleware } from '@reduxjs/toolkit';
|
||||
import { removeTaskFromQueue, hideHomePage } from 'providers/ReduxStore/slices/app';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { collectionAddFileEvent } from 'providers/ReduxStore/slices/collections';
|
||||
import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab } from 'utils/collections/index';
|
||||
import { collectionAddFileEvent, collectionChangeFileEvent } from 'providers/ReduxStore/slices/collections';
|
||||
import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab, findItemInCollectionByItemUid } from 'utils/collections/index';
|
||||
import { taskTypes } from './utils';
|
||||
|
||||
const taskMiddleware = createListenerMiddleware();
|
||||
@@ -51,4 +51,46 @@ taskMiddleware.startListening({
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* When an example is created or cloned, a task to open the example is added to the queue.
|
||||
* We wait for the File IO to complete, after which the "collectionChangeFileEvent" gets dispatched.
|
||||
* This middleware listens for the event and checks if there is a task in the queue that matches
|
||||
* the collectionUid, itemPathname, and exampleIndex. If there is a match, we open the example
|
||||
* tab and remove the task from the queue.
|
||||
*/
|
||||
taskMiddleware.startListening({
|
||||
actionCreator: collectionChangeFileEvent,
|
||||
effect: (action, listenerApi) => {
|
||||
const state = listenerApi.getState();
|
||||
const collectionUid = get(action, 'payload.file.meta.collectionUid');
|
||||
|
||||
const openExampleTasks = filter(state.app.taskQueue, { type: taskTypes.OPEN_EXAMPLE });
|
||||
each(openExampleTasks, (task) => {
|
||||
if (collectionUid === task.collectionUid) {
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
if (collection && collection.mountStatus === 'mounted' && !collection.isLoading) {
|
||||
const item = findItemInCollectionByItemUid(collection, task.itemUid);
|
||||
if (item && item.examples && item.examples.length > task.exampleIndex) {
|
||||
const example = item.examples[task.exampleIndex];
|
||||
if (example) {
|
||||
listenerApi.dispatch(addTab({
|
||||
uid: example.uid,
|
||||
exampleUid: example.uid,
|
||||
collectionUid: collection.uid,
|
||||
type: 'response-example',
|
||||
itemUid: item.uid
|
||||
}));
|
||||
listenerApi.dispatch(hideHomePage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
listenerApi.dispatch(removeTaskFromQueue({
|
||||
taskUid: task.uid
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default taskMiddleware;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export const taskTypes = {
|
||||
OPEN_REQUEST: 'OPEN_REQUEST'
|
||||
OPEN_REQUEST: 'OPEN_REQUEST',
|
||||
OPEN_EXAMPLE: 'OPEN_EXAMPLE'
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { parseQueryParams, buildQueryString as stringifyQueryParams } from '@use
|
||||
import { uuid } from 'utils/common';
|
||||
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
|
||||
import { parsePathParams, splitOnFirst, interpolateUrlPathParams } from 'utils/url';
|
||||
import statusCodePhraseMap from 'components/ResponsePane/StatusCode/get-status-code-phrase';
|
||||
|
||||
export const addResponseExample = (state, action) => {
|
||||
const { itemUid, collectionUid, example } = action.payload;
|
||||
@@ -27,7 +28,7 @@ export const addResponseExample = (state, action) => {
|
||||
}
|
||||
|
||||
const newExample = {
|
||||
uid: uuid(),
|
||||
uid: example.uid || uuid(),
|
||||
itemUid: item.uid,
|
||||
name: example.name,
|
||||
description: example.description,
|
||||
@@ -41,7 +42,7 @@ export const addResponseExample = (state, action) => {
|
||||
},
|
||||
response: {
|
||||
status: String(example.status ?? ''),
|
||||
statusText: String(example.statusText ?? ''),
|
||||
statusText: String(example.statusText ?? (example.status ? (statusCodePhraseMap[Number(example.status)] ?? '') : '')),
|
||||
headers: (example.headers || []).map((header) => ({
|
||||
uid: uuid(),
|
||||
name: String(header.name),
|
||||
@@ -56,6 +57,84 @@ export const addResponseExample = (state, action) => {
|
||||
item.draft.examples.push(newExample);
|
||||
};
|
||||
|
||||
export const cloneResponseExample = (state, action) => {
|
||||
const { itemUid, collectionUid, exampleUid, clonedUid } = action.payload;
|
||||
const collection = findCollectionByUid(state.collections, collectionUid);
|
||||
|
||||
if (!collection) return;
|
||||
|
||||
const item = findItemInCollection(collection, itemUid);
|
||||
if (!item) return;
|
||||
|
||||
if (!item.draft) {
|
||||
item.draft = cloneDeep(item);
|
||||
}
|
||||
|
||||
if (!item.draft.examples) {
|
||||
item.draft.examples = item.examples ? cloneDeep(item.examples) : [];
|
||||
}
|
||||
|
||||
const originalExample = item.draft.examples.find((e) => e.uid === exampleUid);
|
||||
|
||||
if (!originalExample) return;
|
||||
|
||||
const clonedExample = cloneDeep(originalExample);
|
||||
|
||||
clonedExample.uid = clonedUid || uuid();
|
||||
|
||||
clonedExample.name = `${originalExample.name} (Copy)`;
|
||||
|
||||
if (clonedExample.request && clonedExample.request.body) {
|
||||
if (!clonedExample.request.body.mode) {
|
||||
clonedExample.request.body.mode = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (clonedExample.request && clonedExample.request.headers) {
|
||||
clonedExample.request.headers = clonedExample.request.headers.map((header) => ({
|
||||
...header,
|
||||
uid: uuid()
|
||||
}));
|
||||
}
|
||||
|
||||
if (clonedExample.response && clonedExample.response.headers) {
|
||||
clonedExample.response.headers = clonedExample.response.headers.map((header) => ({
|
||||
...header,
|
||||
uid: uuid()
|
||||
}));
|
||||
}
|
||||
|
||||
if (clonedExample.request && clonedExample.request.params) {
|
||||
clonedExample.request.params = clonedExample.request.params.map((param) => ({
|
||||
...param,
|
||||
uid: uuid()
|
||||
}));
|
||||
}
|
||||
|
||||
if (clonedExample.request && clonedExample.request.body) {
|
||||
if (clonedExample.request.body.multipartForm) {
|
||||
clonedExample.request.body.multipartForm = clonedExample.request.body.multipartForm.map((param) => ({
|
||||
...param,
|
||||
uid: uuid()
|
||||
}));
|
||||
}
|
||||
if (clonedExample.request.body.formUrlEncoded) {
|
||||
clonedExample.request.body.formUrlEncoded = clonedExample.request.body.formUrlEncoded.map((param) => ({
|
||||
...param,
|
||||
uid: uuid()
|
||||
}));
|
||||
}
|
||||
if (clonedExample.request.body.file) {
|
||||
clonedExample.request.body.file = clonedExample.request.body.file.map((param) => ({
|
||||
...param,
|
||||
uid: uuid()
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
item.draft.examples.push(clonedExample);
|
||||
};
|
||||
|
||||
export const updateResponseExample = (state, action) => {
|
||||
const { itemUid, collectionUid, exampleUid, example: details } = action.payload;
|
||||
const collection = findCollectionByUid(state.collections, collectionUid);
|
||||
|
||||
@@ -2924,6 +2924,7 @@ export const collectionsSlice = createSlice({
|
||||
|
||||
/* Response Example Actions */
|
||||
addResponseExample: exampleReducers.addResponseExample,
|
||||
cloneResponseExample: exampleReducers.cloneResponseExample,
|
||||
updateResponseExample: exampleReducers.updateResponseExample,
|
||||
deleteResponseExample: exampleReducers.deleteResponseExample,
|
||||
cancelResponseExampleEdit: exampleReducers.cancelResponseExampleEdit,
|
||||
@@ -3099,6 +3100,7 @@ export const {
|
||||
|
||||
/* Response Example Actions - Start */
|
||||
addResponseExample,
|
||||
cloneResponseExample,
|
||||
updateResponseExample,
|
||||
deleteResponseExample,
|
||||
cancelResponseExampleEdit,
|
||||
|
||||
@@ -101,6 +101,11 @@ export const findItemInCollectionByPathname = (collection, pathname) => {
|
||||
return findItemByPathname(flattenedItems, pathname);
|
||||
};
|
||||
|
||||
export const findItemInCollectionByItemUid = (collection, itemUid) => {
|
||||
let flattenedItems = flattenItems(collection.items);
|
||||
return findItem(flattenedItems, itemUid);
|
||||
};
|
||||
|
||||
export const findParentItemInCollectionByPathname = (collection, pathname) => {
|
||||
let flattenedItems = flattenItems(collection.items);
|
||||
|
||||
|
||||
@@ -1342,8 +1342,8 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
ipcMain.handle('renderer:convert-postman-to-bruno', async (event, postmanCollection) => {
|
||||
try {
|
||||
// Convert Postman collection to Bruno format
|
||||
const brunoCollection = await postmanToBruno(postmanCollection, { useWorkers: true});
|
||||
|
||||
const brunoCollection = await postmanToBruno(postmanCollection, { useWorkers: true });
|
||||
|
||||
return brunoCollection;
|
||||
} catch (error) {
|
||||
console.error('Error converting Postman to Bruno:', error);
|
||||
|
||||
@@ -27,7 +27,7 @@ test.describe.serial('Create and Delete Response Examples', () => {
|
||||
await page.getByTestId('create-example-name-input').fill('Test Example from Bookmark');
|
||||
await page.getByTestId('create-example-description-input').fill('This is a test example created from response bookmark');
|
||||
await page.getByRole('button', { name: 'Create Example' }).click();
|
||||
await expect(page.getByText('Test Example from Bookmark')).toBeVisible();
|
||||
await expect(page.getByTestId('response-example-title')).toHaveText('create-example / Test Example from Bookmark');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ test.describe.serial('Create and Delete Response Examples', () => {
|
||||
|
||||
test('should close modal when cancelled', async ({ pageWithUserData: page }) => {
|
||||
await test.step('Test modal cancellation', async () => {
|
||||
await page.locator('.collection-item-name').getByText('create-example').click();
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 });
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
Reference in New Issue
Block a user