Prototype/simplify request creation (#6295)

* feat: add dropdown for quick request creation in tab bar

- Create reusable CreateUntitledRequest component with customizable trigger
- Add generateUniqueRequestName utility for unique request naming
- Replace modal-based request creation with dropdown in tab bar
- Support HTTP, GraphQL, WebSocket, and gRPC request types
- Generate unique names (Untitled, Untitled1, etc.) automatically
- Create requests at collection root level

* Update request creation and collection components

* Fix dropdown positioning and styling when appended to document.body

- Change appendTo from 'parent' to document.body for absolute positioning
- Add comprehensive styling via onShow handler to ensure proper width, padding, text color, and opacity
- Add global styles as fallback for dropdown elements
- Ensure dropdown overlaps parent without expanding it

* Update RequestTabs and Collection components

* Add curl paste detection and parsing for HTTP requests

* Fix generateUniqueRequestName to check filesystem for existing files

* feat: add placeholder text to HTTP request URL input

Add helpful placeholder text 'Enter URL or paste a cURL request' to the HTTP request URL input field. This guides users on how to use the input field, indicating they can either enter a URL directly or paste a cURL command which will be automatically parsed.

* Simplify request creation in collection menu

* fix: fixed issues with cURL paste for GraphQL requests in the URL input bar

* fix: added icons to create request dropdown

* fix: fixed the icon | text gap in dropdown

* fix: removed unnecessary updates on the Dropdown Component

* added onCreate to Dropdown to remove unwanted diffs

* fix: simplified the generateUniqueRequestName function. ai writes complex code

* chore: formatting and removed unnecessary diffs

* Update packages/bruno-app/src/components/RequestPane/QueryUrl/index.js

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* chore: format

* Fix failing E2E tests by updating to new request creation flow

- Replace #create-new-tab selector with new dropdown flow using createUntitledRequest helper
- Update generateUniqueRequestName to handle .bru, .yml, and .yaml file extensions
- Add createUntitledRequest helper function with optional URL and tag parameters
- Update all failing tests to use the new helper function
- Fix selectors from .collection-item-name to .item-name where needed
- All 13 previously failing tests now pass

* chore: removed unused import

---------

Co-authored-by: Sid <siddharth@usebruno.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Chirag Chandrashekhar
2025-12-09 19:08:52 +05:30
committed by GitHub
parent 03e8f2d67d
commit f6363389d0
11 changed files with 689 additions and 120 deletions

View File

@@ -0,0 +1,8 @@
import styled from 'styled-components';
const Wrapper = styled.div`
position: relative;
display: inline-block;
`;
export default Wrapper;

View File

@@ -0,0 +1,172 @@
import React, { useRef, forwardRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Dropdown from 'components/Dropdown';
import { newHttpRequest, newGrpcRequest, newWsRequest } from 'providers/ReduxStore/slices/collections/actions';
import { generateUniqueRequestName } from 'utils/collections';
import { sanitizeName } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { IconApi, IconBrandGraphql, IconPlugConnected, IconCode, IconPlus } from '@tabler/icons';
const CreateUntitledRequest = ({ collectionUid, itemUid = null, onRequestCreated, placement = 'bottom' }) => {
const dispatch = useDispatch();
const collections = useSelector((state) => state.collections.collections);
const collection = collections?.find((c) => c.uid === collectionUid);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
if (!collection) {
return null;
}
const handleCreateHttpRequest = async () => {
dropdownTippyRef.current?.hide();
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
const filename = sanitizeName(uniqueName);
dispatch(
newHttpRequest({
requestName: uniqueName,
filename: filename,
requestType: 'http-request',
requestUrl: '',
requestMethod: 'GET',
collectionUid: collection.uid,
itemUid: itemUid
})
)
.then(() => {
toast.success('New request created!');
onRequestCreated?.();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
};
const handleCreateGraphQLRequest = async () => {
dropdownTippyRef.current?.hide();
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
const filename = sanitizeName(uniqueName);
dispatch(
newHttpRequest({
requestName: uniqueName,
filename: filename,
requestType: 'graphql-request',
requestUrl: '',
requestMethod: 'POST',
collectionUid: collection.uid,
itemUid: itemUid,
body: {
mode: 'graphql',
graphql: {
query: '',
variables: ''
}
}
})
)
.then(() => {
toast.success('New request created!');
onRequestCreated?.();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
};
const handleCreateWebSocketRequest = async () => {
dropdownTippyRef.current?.hide();
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
const filename = sanitizeName(uniqueName);
dispatch(
newWsRequest({
requestName: uniqueName,
filename: filename,
requestUrl: '',
requestMethod: 'ws',
collectionUid: collection.uid,
itemUid: itemUid
})
)
.then(() => {
toast.success('New request created!');
onRequestCreated?.();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
};
const handleCreateGrpcRequest = async () => {
dropdownTippyRef.current?.hide();
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
const filename = sanitizeName(uniqueName);
dispatch(
newGrpcRequest({
requestName: uniqueName,
filename: filename,
requestUrl: '',
collectionUid: collection.uid,
itemUid: itemUid
})
)
.then(() => {
toast.success('New request created!');
onRequestCreated?.();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
};
return (
<Dropdown onCreate={onDropdownCreate} icon={<IconPlus size={16} strokeWidth={2} />} placement={placement}>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleCreateHttpRequest();
}}
>
<span className="dropdown-icon">
<IconApi size={16} strokeWidth={2} />
</span>
HTTP
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleCreateGraphQLRequest();
}}
>
<span className="dropdown-icon">
<IconBrandGraphql size={16} strokeWidth={2} />
</span>
GraphQL
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleCreateWebSocketRequest();
}}
>
<span className="dropdown-icon">
<IconPlugConnected size={16} strokeWidth={2} />
</span>
WebSocket
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleCreateGrpcRequest();
}}
>
<span className="dropdown-icon">
<IconCode size={16} strokeWidth={2} />
</span>
gRPC
</div>
</Dropdown>
);
};
export default CreateUntitledRequest;

View File

@@ -1,8 +1,19 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import { requestUrlChanged, updateRequestMethod } from 'providers/ReduxStore/slices/collections';
import { cancelRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import {
requestUrlChanged,
updateRequestMethod,
setRequestHeaders,
updateRequestBodyMode,
updateRequestBody,
updateRequestGraphqlQuery,
updateRequestGraphqlVariables,
updateRequestAuthMode,
updateAuth
} from 'providers/ReduxStore/slices/collections';
import { saveRequest, cancelRequest } from 'providers/ReduxStore/slices/collections/actions';
import { getRequestFromCurlCommand } from 'utils/curl';
import HttpMethodSelector from './HttpMethodSelector';
import { useTheme } from 'providers/Theme';
import { IconDeviceFloppy, IconArrowRight, IconCode, IconSquareRoundedX } from '@tabler/icons';
@@ -81,12 +92,289 @@ const QueryUrl = ({ item, collection, handleRun }) => {
}
};
const handleGraphqlPaste = useCallback((event) => {
if (item.type !== 'graphql-request') {
return;
}
const clipboardData = event.clipboardData || window.clipboardData;
const pastedData = clipboardData.getData('Text');
const curlCommandRegex = /^\s*curl\s/i;
if (!curlCommandRegex.test(pastedData)) {
toast.error('Invalid cURL command');
return;
}
event.preventDefault();
try {
const request = getRequestFromCurlCommand(pastedData, 'graphql-request');
if (!request || !request.url) {
toast.error('Invalid cURL command');
return;
}
// Update URL
dispatch(requestUrlChanged({
itemUid: item.uid,
collectionUid: collection.uid,
url: request.url
}));
// Update method
dispatch(updateRequestMethod({
method: request.method.toUpperCase(), // Convert to uppercase
itemUid: item.uid,
collectionUid: collection.uid
}));
// Update headers
if (request.headers && request.headers.length > 0) {
dispatch(setRequestHeaders({
collectionUid: collection.uid,
itemUid: item.uid,
headers: request.headers
}));
}
// Update body
if (request.body) {
const bodyMode = request.body.mode;
if (bodyMode === 'graphql') {
dispatch(updateRequestGraphqlQuery({
itemUid: item.uid,
collectionUid: collection.uid,
query: request.body.graphql.query
}));
let variables = request.body.graphql.variables;
try {
variables = JSON.parse(variables);
} catch (error) {
// Keep variables as-is if JSON parsing fails
}
dispatch(updateRequestGraphqlVariables({
itemUid: item.uid,
collectionUid: collection.uid,
variables: variables
}));
}
toast.success('GraphQL query imported successfully');
}
} catch (error) {
console.error('Error parsing cURL command:', error);
toast.error('Failed to parse GraphQL query');
}
}, [dispatch, item.uid, collection.uid]);
const handleHttpPaste = useCallback((event) => {
// Only enable curl paste detection for HTTP requests
if (item.type !== 'http-request') {
return;
}
const clipboardData = event.clipboardData || window.clipboardData;
const pastedData = clipboardData.getData('Text');
// Check if pasted data looks like a cURL command
const curlCommandRegex = /^\s*curl\s/i;
if (!curlCommandRegex.test(pastedData)) {
// Not a curl command, allow normal paste behavior
return;
}
// Prevent the default paste behavior
event.preventDefault();
try {
// Parse the curl command
const request = getRequestFromCurlCommand(pastedData);
if (!request || !request.url) {
toast.error('Invalid cURL command');
return;
}
// Update URL
dispatch(
requestUrlChanged({
itemUid: item.uid,
collectionUid: collection.uid,
url: request.url
})
);
// Update method
if (request.method) {
dispatch(
updateRequestMethod({
method: request.method.toUpperCase(), // Convert to uppercase
itemUid: item.uid,
collectionUid: collection.uid
})
);
}
// Update headers
if (request.headers && request.headers.length > 0) {
dispatch(
setRequestHeaders({
collectionUid: collection.uid,
itemUid: item.uid,
headers: request.headers
})
);
}
// Update body
if (request.body) {
const bodyMode = request.body.mode;
// Set body mode first
dispatch(
updateRequestBodyMode({
itemUid: item.uid,
collectionUid: collection.uid,
mode: bodyMode
})
);
// Set body content based on mode
if (bodyMode === 'json' && request.body.json) {
dispatch(
updateRequestBody({
itemUid: item.uid,
collectionUid: collection.uid,
content: request.body.json
})
);
} else if (bodyMode === 'text' && request.body.text) {
dispatch(
updateRequestBody({
itemUid: item.uid,
collectionUid: collection.uid,
content: request.body.text
})
);
} else if (bodyMode === 'xml' && request.body.xml) {
dispatch(
updateRequestBody({
itemUid: item.uid,
collectionUid: collection.uid,
content: request.body.xml
})
);
} else if (bodyMode === 'graphql' && request.body.graphql) {
if (request.body.graphql.query) {
dispatch(
updateRequestGraphqlQuery({
itemUid: item.uid,
collectionUid: collection.uid,
query: request.body.graphql.query
})
);
}
if (request.body.graphql.variables) {
dispatch(
updateRequestGraphqlVariables({
itemUid: item.uid,
collectionUid: collection.uid,
variables: request.body.graphql.variables
})
);
}
} else if (bodyMode === 'formUrlEncoded' && request.body.formUrlEncoded) {
// For formUrlEncoded, we need to set each param individually
// This is a limitation - we'd need to clear existing params first
// For now, we'll set the body mode and the user can manually adjust
// TODO: Implement proper formUrlEncoded param setting
} else if (bodyMode === 'multipartForm' && request.body.multipartForm) {
// For multipartForm, similar limitation
// TODO: Implement proper multipartForm param setting
}
}
// Update auth
if (request.auth) {
const authMode = request.auth.mode;
if (authMode) {
dispatch(
updateRequestAuthMode({
itemUid: item.uid,
collectionUid: collection.uid,
mode: authMode
})
);
// Set auth content based on mode
if (request.auth.basic) {
dispatch(
updateAuth({
mode: 'basic',
collectionUid: collection.uid,
itemUid: item.uid,
content: request.auth.basic
})
);
} else if (request.auth.bearer) {
dispatch(
updateAuth({
mode: 'bearer',
collectionUid: collection.uid,
itemUid: item.uid,
content: request.auth.bearer
})
);
} else if (request.auth.digest) {
dispatch(
updateAuth({
mode: 'digest',
collectionUid: collection.uid,
itemUid: item.uid,
content: request.auth.digest
})
);
} else if (request.auth.ntlm) {
dispatch(
updateAuth({
mode: 'ntlm',
collectionUid: collection.uid,
itemUid: item.uid,
content: request.auth.ntlm
})
);
} else if (request.auth.awsv4) {
dispatch(
updateAuth({
mode: 'awsv4',
collectionUid: collection.uid,
itemUid: item.uid,
content: request.auth.awsv4
})
);
} else if (request.auth.apikey) {
dispatch(
updateAuth({
mode: 'apikey',
collectionUid: collection.uid,
itemUid: item.uid,
content: request.auth.apikey
})
);
}
}
}
toast.success('cURL command imported successfully');
} catch (error) {
console.error('Error parsing cURL command:', error);
toast.error('Failed to parse cURL command');
}
},
[dispatch, item.uid, item.type, collection.uid]
);
const handleCancelRequest = (e) => {
e.preventDefault();
e.stopPropagation();
dispatch(cancelRequest(item.cancelTokenUid, item, collection));
};
return (
<StyledWrapper className="flex items-center">
<div className="flex flex-1 items-center h-full method-selector-container">
@@ -110,10 +398,12 @@ const QueryUrl = ({ item, collection, handleRun }) => {
<SingleLineEditor
ref={editorRef}
value={url}
placeholder="Enter URL or paste a cURL request"
onSave={(finalValue) => onSave(finalValue)}
theme={storedTheme}
onChange={(newValue) => onUrlChange(newValue)}
onRun={handleRun}
onPaste={item.type === 'http-request' ? handleHttpPaste : item.type === 'graphql-request' ? handleGraphqlPaste : null}
collection={collection}
highlightPathParams={true}
item={item}

View File

@@ -10,6 +10,7 @@ import CollectionToolBar from './CollectionToolBar';
import RequestTab from './RequestTab';
import StyledWrapper from './StyledWrapper';
import DraggableTab from './DraggableTab';
import CreateUntitledRequest from 'components/CreateUntitledRequest';
const RequestTabs = () => {
const dispatch = useDispatch();
@@ -78,8 +79,6 @@ const RequestTabs = () => {
);
};
const createNewTab = () => setNewRequestModalOpen(true);
if (!activeTabUid) {
return null;
}
@@ -178,19 +177,16 @@ const RequestTabs = () => {
</div>
</li>
) : null}
<li className="select-none short-tab" id="create-new-tab" onClick={createNewTab}>
<div className="flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
</svg>
</div>
</li>
<div className="flex items-center short-tab">
{activeCollection && (
<CreateUntitledRequest
collectionUid={activeCollection.uid}
itemUid={null}
placement="bottom-start"
/>
)}
</div>
{/* Moved to post mvp */}
{/* <li className="select-none new-tab choose-request">
<div className="flex items-center">

View File

@@ -1629,3 +1629,53 @@ export const isVariableSecret = (scopeInfo) => {
return false;
};
/**
* Generate a unique request name by checking existing filenames in the collection and filesystem
* @param {Object} collection - The collection object
* @param {string} baseName - The base name (default: 'Untitled')
* @param {string} itemUid - The parent item UID (null for root level, folder UID for folder level)
* @returns {Promise<string>} - A unique request name (Untitled, Untitled1, Untitled2, etc.)
*/
export const generateUniqueRequestName = async (collection, baseName = 'Untitled', itemUid = null) => {
if (!collection) {
return baseName;
}
const trim = require('lodash/trim');
const parentItem = itemUid ? findItemInCollection(collection, itemUid) : null;
const parentItems = parentItem ? (parentItem.items || []) : (collection.items || []);
const baseNamePattern = new RegExp(`^${baseName}(\\d+)?$`);
// Support .bru, .yml, and .yaml file extensions
const requestExtensions = /\.(bru|yml|yaml)$/i;
const matchingItems = parentItems
.filter((item) => {
if (item.type === 'folder') return false;
const filename = trim(item.filename);
if (!requestExtensions.test(filename)) return false;
const filenameWithoutExt = filename.replace(requestExtensions, '');
return baseNamePattern.test(filenameWithoutExt);
})
.map((item) => {
const filenameWithoutExt = trim(item.filename).replace(requestExtensions, '');
const match = filenameWithoutExt.match(baseNamePattern);
if (!match) return null;
const number = match[1] ? parseInt(match[1], 10) : 0;
return { name: filenameWithoutExt, number: isNaN(number) ? null : number };
})
.filter((item) => item !== null && item.number !== null);
if (matchingItems.length === 0) {
return baseName;
}
const sortedMatches = matchingItems.sort((a, b) => a.number - b.number);
const lastElement = sortedMatches[sortedMatches.length - 1];
const nextNumber = lastElement.number + 1;
return `${baseName}${nextNumber}`;
};

View File

@@ -1,5 +1,5 @@
import { test, expect } from '../../../playwright';
import { closeAllCollections } from '../../utils/page';
import { closeAllCollections, createUntitledRequest } from '../../utils/page';
test.describe('Create collection', () => {
test.afterEach(async ({ page }) => {
@@ -24,12 +24,13 @@ test.describe('Create collection', () => {
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
// Create a new request
await page.locator('#create-new-tab').getByRole('img').click();
await page.getByPlaceholder('Request Name').fill('r1');
await page.locator('#new-request-url .CodeMirror').click();
// Create a new request using the new dropdown flow
await createUntitledRequest(page, { requestType: 'HTTP' });
// Set the URL
await page.locator('#request-url .CodeMirror').click();
await page.locator('textarea').fill('http://localhost:8081');
await page.getByRole('button', { name: 'Create' }).click();
await page.locator('#send-request').getByTitle('Save Request').click();
// Send a request
await page.locator('#request-url .CodeMirror').click();

View File

@@ -1,5 +1,5 @@
import { test, expect } from '../../../playwright';
import { closeAllCollections, createCollection } from '../../utils/page';
import { closeAllCollections, createCollection, createUntitledRequest } from '../../utils/page';
test.describe('Cross-Collection Drag and Drop', () => {
test.afterEach(async ({ page }) => {
@@ -11,14 +11,15 @@ test.describe('Cross-Collection Drag and Drop', () => {
// Create first collection - open with sandbox mode
await createCollection(page, 'source-collection', await createTmpDir('source-collection'), { openWithSandboxMode: 'safe' });
// Create a request in the first collection
await page.locator('#create-new-tab').getByRole('img').click();
await page.getByPlaceholder('Request Name').fill('test-request');
await page.locator('#new-request-url .CodeMirror').click();
await page.locator('textarea').fill('https://echo.usebruno.com');
await page.getByRole('button', { name: 'Create' }).click();
// Create a request in the first collection using the new dropdown flow
await createUntitledRequest(page, { requestType: 'HTTP' });
await expect(page.locator('.collection-item-name').filter({ hasText: 'test-request' })).toBeVisible();
// Set the URL
await page.locator('#request-url .CodeMirror').click();
await page.locator('textarea').fill('https://echo.usebruno.com');
await page.locator('#send-request').getByTitle('Save Request').click();
await expect(page.locator('.item-name').filter({ hasText: /^Untitled/ })).toBeVisible();
// Create second collection - open with sandbox mode
await createCollection(page, 'target-collection', await createTmpDir('target-collection'), { openWithSandboxMode: 'safe' });
@@ -27,7 +28,7 @@ test.describe('Cross-Collection Drag and Drop', () => {
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'target-collection' })).toBeVisible();
// Locate the request in source collection
const sourceRequest = page.locator('.collection-item-name').filter({ hasText: 'test-request' });
const sourceRequest = page.locator('.item-name').filter({ hasText: /^Untitled/ }).first();
await expect(sourceRequest).toBeVisible();
// Locate the target collection area (the collection name element)
@@ -47,7 +48,7 @@ test.describe('Cross-Collection Drag and Drop', () => {
.filter({ hasText: 'target-collection' })
.locator('..');
await expect(
targetCollectionContainer.locator('.collection-item-name').filter({ hasText: 'test-request' })
targetCollectionContainer.locator('.item-name').filter({ hasText: /^Untitled/ })
).toBeVisible();
// Verify the request is no longer in the source collection
@@ -56,7 +57,7 @@ test.describe('Cross-Collection Drag and Drop', () => {
.filter({ hasText: 'source-collection' })
.locator('..');
await expect(
sourceCollectionContainer.locator('.collection-item-name').filter({ hasText: 'test-request' })
sourceCollectionContainer.locator('.item-name').filter({ hasText: /^Untitled/ })
).not.toBeVisible();
});
@@ -67,29 +68,31 @@ test.describe('Cross-Collection Drag and Drop', () => {
// Create first collection (source-collection)
await createCollection(page, 'source-collection', await createTmpDir('source-collection'), { openWithSandboxMode: 'safe' });
// Create a request in the first collection (request-1)
await page.locator('#create-new-tab').getByRole('img').click();
await page.getByPlaceholder('Request Name').fill('request-1');
await page.locator('#new-request-url .CodeMirror').click();
await page.locator('textarea').fill('https://echo.usebruno.com');
await page.getByRole('button', { name: 'Create' }).click();
// Create a request in the first collection using the new dropdown flow
await createUntitledRequest(page, { requestType: 'HTTP' });
// check if request-1 is created and visible in sidebar
await expect(page.locator('.collection-item-name').filter({ hasText: 'request-1' })).toBeVisible();
// Set the URL
await page.locator('#request-url .CodeMirror').click();
await page.locator('textarea').fill('https://echo.usebruno.com');
await page.locator('#send-request').getByTitle('Save Request').click();
// check if untitled request is created and visible in sidebar
await expect(page.locator('.item-name').filter({ hasText: /^Untitled/ })).toBeVisible();
// Create second collection (target-collection)
await createCollection(page, 'target-collection', await createTmpDir('target-collection'), { openWithSandboxMode: 'safe' });
// Create a request in the target collection with the same name (request-1)
await page.locator('#create-new-tab').getByRole('img').click();
await page.getByPlaceholder('Request Name').fill('request-1');
await page.locator('#new-request-url .CodeMirror').click();
// Create a request in the target collection using the new dropdown flow
await createUntitledRequest(page, { requestType: 'HTTP' });
// Set the URL
await page.locator('#request-url .CodeMirror').click();
await page.locator('textarea').fill('https://echo.usebruno.com');
await page.getByRole('button', { name: 'Create' }).click();
await page.locator('#send-request').getByTitle('Save Request').click();
// Go back to source collection to drag the request
await page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' }).click();
const sourceRequest = page.locator('.collection-item-name').filter({ hasText: 'request-1' }).first();
const sourceRequest = page.locator('.item-name').filter({ hasText: /^Untitled/ }).first();
await expect(sourceRequest).toBeVisible();
// Locate the target collection area
@@ -108,7 +111,7 @@ test.describe('Cross-Collection Drag and Drop', () => {
.filter({ hasText: 'target-collection' })
.locator('..');
await expect(
targetCollectionContainer.locator('.collection-item-name').filter({ hasText: 'request-1' })
targetCollectionContainer.locator('.item-name').filter({ hasText: /^Untitled/ })
).toBeVisible();
const sourceCollectionContainer = page
@@ -116,7 +119,7 @@ test.describe('Cross-Collection Drag and Drop', () => {
.filter({ hasText: 'source-collection' })
.locator('..');
await expect(
sourceCollectionContainer.locator('.collection-item-name').filter({ hasText: 'request-1' })
sourceCollectionContainer.locator('.item-name').filter({ hasText: /^Untitled/ })
).toBeVisible();
});
});

View File

@@ -1,5 +1,5 @@
import { test, expect } from '../../../playwright';
import { closeAllCollections } from '../../utils/page';
import { closeAllCollections, createUntitledRequest } from '../../utils/page';
test.describe('Tag persistence', () => {
test.afterEach(async ({ page }) => {
@@ -20,57 +20,48 @@ test.describe('Tag persistence', () => {
await page.locator('#sidebar-collection-name').filter({ hasText: 'test-collection' }).click();
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForTimeout(1000);
// Create three requests, each with URL and tag (auto-saved after each is completely created)
// The createUntitledRequest function now waits for each request to be fully created
// before returning, ensuring unique names are generated
await createUntitledRequest(page, {
requestType: 'HTTP',
url: 'https://httpfaker.org/api/echo',
tag: 'smoke'
});
await createUntitledRequest(page, {
requestType: 'HTTP',
url: 'https://httpfaker.org/api/echo',
tag: 'smoke'
});
await createUntitledRequest(page, {
requestType: 'HTTP',
url: 'https://httpfaker.org/api/echo',
tag: 'smoke'
});
// Create a new request
await page.locator('#create-new-tab').getByRole('img').click();
await page.getByRole('textbox', { name: 'Request Name' }).fill('request-1');
await page.locator('#new-request-url textarea').fill('https://httpfaker.org/api/echo');
await page.getByRole('button', { name: 'Create' }).click();
// Wait for all 3 requests to be visible in the sidebar
const untitledRequests = page.locator('.item-name').filter({ hasText: /^Untitled/ });
await expect(untitledRequests).toHaveCount(3);
// create another request
await page.locator('#create-new-tab').getByRole('img').click();
await page.getByRole('textbox', { name: 'Request Name' }).fill('request-2');
await page.locator('#new-request-url textarea').fill('https://httpfaker.org/api/echo');
await page.getByRole('button', { name: 'Create' }).click();
// create another request
await page.locator('#create-new-tab').getByRole('img').click();
await page.getByRole('textbox', { name: 'Request Name' }).fill('request-3');
await page.locator('#new-request-url textarea').fill('https://httpfaker.org/api/echo');
await page.getByRole('button', { name: 'Create' }).click();
await page.waitForTimeout(200);
// Add a tag to the request
await page.getByRole('tab', { name: 'Settings' }).click();
await page.waitForTimeout(200);
const tagInput = await page.getByTestId('tag-input').getByRole('textbox');
await tagInput.fill('smoke');
await tagInput.press('Enter');
await page.waitForTimeout(200);
// Verify the tag was added
await expect(page.locator('.tag-item', { hasText: 'smoke' })).toBeVisible();
await page.keyboard.press('Meta+s');
// Move the request-3 request to just above request-1 within the same collection
const r3Request = page.locator('.collection-item-name').filter({ hasText: 'request-3' });
const r1Request = page.locator('.collection-item-name').filter({ hasText: 'request-1' });
// Move the last untitled request to just above the first untitled request within the same collection
const r3Request = untitledRequests.nth(2); // Third request (0-indexed)
const r1Request = untitledRequests.first(); // First request
await expect(r3Request).toBeVisible();
await expect(r1Request).toBeVisible();
// Perform drag and drop operation to move request-3 below request-1 using source position
// Perform drag and drop operation to move the last request above the first using source position
await r3Request.dragTo(r1Request, {
targetPosition: { x: 0, y: 1 }
});
// Verify the requests are still in the collection and request-3 is now above request-1
await expect(page.locator('.collection-item-name').filter({ hasText: 'request-3' })).toBeVisible();
await expect(page.locator('.collection-item-name').filter({ hasText: 'request-1' })).toBeVisible();
// Verify the requests are still in the collection
await expect(untitledRequests).toHaveCount(3);
// Click on request-3 to verify the tag persisted after the move
await page.locator('.collection-item-name').filter({ hasText: 'request-3' }).click();
await page.locator('.request-tab.active').filter({ hasText: 'request-3' }).waitFor({ state: 'visible' });
// Click on the moved request (now first) to verify the tag persisted after the move
await untitledRequests.first().click();
await page.locator('.request-tab.active').waitFor({ state: 'visible' });
await page.getByRole('tab', { name: 'Settings' }).click();
await page.waitForTimeout(200);
// Verify the tag is still present after the move

View File

@@ -1,5 +1,5 @@
import { test, expect } from '../../../playwright';
import { closeAllCollections } from '../../utils/page';
import { closeAllCollections, createUntitledRequest } from '../../utils/page';
test.describe('Code Generation URL Encoding', () => {
test.afterEach(async ({ page }) => {
@@ -33,15 +33,14 @@ test.describe('Code Generation URL Encoding', () => {
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('#create-new-tab').getByRole('img').click();
await page.getByPlaceholder('Request Name').fill('unencoded-request');
await page.locator('#new-request-url .CodeMirror').click();
await page.locator('textarea').fill('http://base.source?name=John Doe');
await page.getByRole('button', { name: 'Create' }).click();
// Create a new request using the new dropdown flow
await createUntitledRequest(page, {
requestType: 'HTTP',
url: 'http://base.source?name=John Doe'
});
await expect(page.locator('.collection-item-name').filter({ hasText: 'unencoded-request' })).toBeVisible();
await page.locator('.collection-item-name').filter({ hasText: 'unencoded-request' }).click();
// Find the untitled request and click on it
await page.locator('.item-name').filter({ hasText: /^Untitled/ }).first().click();
await page.locator('#send-request .infotip').first().click();
@@ -79,15 +78,14 @@ test.describe('Code Generation URL Encoding', () => {
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('#create-new-tab').getByRole('img').click();
await page.getByPlaceholder('Request Name').fill('encoded-request');
await page.locator('#new-request-url .CodeMirror').click();
await page.locator('textarea').fill('http://base.source?name=John%20Doe');
await page.getByRole('button', { name: 'Create' }).click();
// Create a new request using the new dropdown flow
await createUntitledRequest(page, {
requestType: 'HTTP',
url: 'http://base.source?name=John%20Doe'
});
await expect(page.locator('.collection-item-name').filter({ hasText: 'encoded-request' })).toBeVisible();
await page.locator('.collection-item-name').filter({ hasText: 'encoded-request' }).click();
// Find the untitled request and click on it
await page.locator('.item-name').filter({ hasText: /^Untitled/ }).first().click();
await page.locator('#send-request .infotip').first().click();

View File

@@ -1,5 +1,5 @@
import { test, expect } from '../../playwright';
import { closeAllCollections, createCollection } from '../utils/page/actions';
import { closeAllCollections, createCollection, createUntitledRequest } from '../utils/page/actions';
test.describe('Large Response Crash/High Memory Usage Prevention', () => {
// Increase timeout to 1 minute for all tests in this describe block, default is 30 seconds.
@@ -17,14 +17,11 @@ test.describe('Large Response Crash/High Memory Usage Prevention', () => {
// Create collection
await createCollection(page, collectionName, await createTmpDir(collectionName), { openWithSandboxMode: 'safe' });
// Create request
await page.locator('#create-new-tab').getByRole('img').click();
const createRequestModal = page.locator('.bruno-modal-card').filter({ hasText: 'New Request' });
await createRequestModal.getByPlaceholder('Request Name').fill('size-check');
await createRequestModal.locator('#new-request-url .CodeMirror').click();
await createRequestModal.locator('textarea').fill('https://samples.json-format.com/employees/json/employees_50MB.json');
await createRequestModal.getByRole('button', { name: 'Create' }).click();
// Create request using the new dropdown flow
await createUntitledRequest(page, {
requestType: 'HTTP',
url: 'https://samples.json-format.com/employees/json/employees_50MB.json'
});
// Send request
const sendButton = page.getByTestId('send-arrow-icon');

View File

@@ -89,6 +89,68 @@ type CreateRequestOptions = {
inFolder?: boolean;
};
type CreateUntitledRequestOptions = {
requestType?: 'HTTP' | 'GraphQL' | 'WebSocket' | 'gRPC';
requestName?: string;
url?: string;
tag?: string;
};
/**
* Create an untitled request using the new dropdown flow (from tabs area)
* @param page - The page object
* @param options - Optional settings (requestType, url, tag)
* @returns void
*/
const createUntitledRequest = async (
page: Page,
options: CreateUntitledRequestOptions = {}
) => {
const { requestType = 'HTTP', url, tag } = options;
await test.step(`Create untitled ${requestType} request${url ? ' with URL' : ''}${tag ? ' with tag' : ''}`, async () => {
// Click the + icon to open the dropdown
const createButton = page.locator('.short-tab').locator('svg').first();
await createButton.waitFor({ state: 'visible' });
await createButton.click();
// Select the request type from dropdown
await page.locator('.tippy-box .dropdown-item').filter({ hasText: requestType }).waitFor({ state: 'visible' });
await page.locator('.tippy-box .dropdown-item').filter({ hasText: requestType }).click();
// Wait for the request tab to be active
await page.locator('.request-tab.active').waitFor({ state: 'visible' });
await page.waitForTimeout(300);
// Fill URL if provided
if (url) {
await page.locator('#request-url .CodeMirror').click();
await page.locator('#request-url textarea').fill(url);
await page.locator('#send-request').getByTitle('Save Request').click();
await page.waitForTimeout(200);
}
// Add tag if provided
if (tag) {
await page.getByRole('tab', { name: 'Settings' }).click();
await page.waitForTimeout(200);
const tagInput = await page.getByTestId('tag-input').getByRole('textbox');
await tagInput.fill(tag);
await tagInput.press('Enter');
await page.waitForTimeout(200);
await expect(page.locator('.tag-item', { hasText: tag })).toBeVisible();
await page.keyboard.press('Meta+s');
await page.waitForTimeout(200);
}
// Wait for toast message to ensure request creation is complete
// This helps prevent race conditions when creating multiple requests
await expect(page.getByText('New request created!')).toBeVisible({ timeout: 10000 }).catch(() => {
// Toast might have already disappeared, that's okay
});
});
};
/**
* Create a request in a collection or folder
* @param page - The page object
@@ -521,6 +583,7 @@ export {
openCollectionAndAcceptSandbox,
createCollection,
createRequest,
createUntitledRequest,
deleteRequest,
importCollection,
removeCollection,
@@ -539,4 +602,4 @@ export {
expectResponseContains
};
export type { SandboxMode, EnvironmentType, EnvironmentVariable, CreateCollectionOptions, ImportCollectionOptions, CreateRequestOptions };
export type { SandboxMode, EnvironmentType, EnvironmentVariable, CreateCollectionOptions, ImportCollectionOptions, CreateRequestOptions, CreateUntitledRequestOptions };