From f6363389d06b25191acfa75eb3ec3825640806e2 Mon Sep 17 00:00:00 2001 From: Chirag Chandrashekhar Date: Tue, 9 Dec 2025 19:08:52 +0530 Subject: [PATCH] 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 Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../CreateUntitledRequest/StyledWrapper.js | 8 + .../components/CreateUntitledRequest/index.js | 172 ++++++++++ .../components/RequestPane/QueryUrl/index.js | 298 +++++++++++++++++- .../src/components/RequestTabs/index.js | 26 +- .../bruno-app/src/utils/collections/index.js | 50 +++ .../create/create-collection.spec.ts | 13 +- ...cross-collection-drag-drop-request.spec.ts | 57 ++-- .../moving-requests/tag-persistence.spec.ts | 73 ++--- tests/request/encoding/curl-encoding.spec.ts | 32 +- .../large-response-crash-prevention.spec.ts | 15 +- tests/utils/page/actions.ts | 65 +++- 11 files changed, 689 insertions(+), 120 deletions(-) create mode 100644 packages/bruno-app/src/components/CreateUntitledRequest/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/CreateUntitledRequest/index.js diff --git a/packages/bruno-app/src/components/CreateUntitledRequest/StyledWrapper.js b/packages/bruno-app/src/components/CreateUntitledRequest/StyledWrapper.js new file mode 100644 index 000000000..4abf9e5c7 --- /dev/null +++ b/packages/bruno-app/src/components/CreateUntitledRequest/StyledWrapper.js @@ -0,0 +1,8 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + position: relative; + display: inline-block; +`; + +export default Wrapper; diff --git a/packages/bruno-app/src/components/CreateUntitledRequest/index.js b/packages/bruno-app/src/components/CreateUntitledRequest/index.js new file mode 100644 index 000000000..42e82e7f9 --- /dev/null +++ b/packages/bruno-app/src/components/CreateUntitledRequest/index.js @@ -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 ( + } placement={placement}> +
{ + dropdownTippyRef.current.hide(); + handleCreateHttpRequest(); + }} + > + + + + HTTP +
+
{ + dropdownTippyRef.current.hide(); + handleCreateGraphQLRequest(); + }} + > + + + + GraphQL +
+
{ + dropdownTippyRef.current.hide(); + handleCreateWebSocketRequest(); + }} + > + + + + WebSocket +
+
{ + dropdownTippyRef.current.hide(); + handleCreateGrpcRequest(); + }} + > + + + + gRPC +
+
+ ); +}; + +export default CreateUntitledRequest; diff --git a/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js b/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js index 645f85c63..5b269baaf 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js +++ b/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js @@ -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 (
@@ -110,10 +398,12 @@ const QueryUrl = ({ item, collection, handleRun }) => { 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} diff --git a/packages/bruno-app/src/components/RequestTabs/index.js b/packages/bruno-app/src/components/RequestTabs/index.js index 871258c42..a98217240 100644 --- a/packages/bruno-app/src/components/RequestTabs/index.js +++ b/packages/bruno-app/src/components/RequestTabs/index.js @@ -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 = () => {
) : null} -
  • -
    - - - -
    -
  • +
    + + {activeCollection && ( + + )} +
    {/* Moved to post mvp */} {/*
  • diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index 04331b527..64ff1740b 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -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} - 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}`; +}; diff --git a/tests/collection/create/create-collection.spec.ts b/tests/collection/create/create-collection.spec.ts index 5620e8af9..0e31277f1 100644 --- a/tests/collection/create/create-collection.spec.ts +++ b/tests/collection/create/create-collection.spec.ts @@ -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(); diff --git a/tests/collection/moving-requests/cross-collection-drag-drop-request.spec.ts b/tests/collection/moving-requests/cross-collection-drag-drop-request.spec.ts index 7c6e2438c..47120acad 100644 --- a/tests/collection/moving-requests/cross-collection-drag-drop-request.spec.ts +++ b/tests/collection/moving-requests/cross-collection-drag-drop-request.spec.ts @@ -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(); }); }); diff --git a/tests/collection/moving-requests/tag-persistence.spec.ts b/tests/collection/moving-requests/tag-persistence.spec.ts index 920e20cc0..cb1e52f15 100644 --- a/tests/collection/moving-requests/tag-persistence.spec.ts +++ b/tests/collection/moving-requests/tag-persistence.spec.ts @@ -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 diff --git a/tests/request/encoding/curl-encoding.spec.ts b/tests/request/encoding/curl-encoding.spec.ts index b7b05f906..0bb8c47fb 100644 --- a/tests/request/encoding/curl-encoding.spec.ts +++ b/tests/request/encoding/curl-encoding.spec.ts @@ -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(); diff --git a/tests/response/large-response-crash-prevention.spec.ts b/tests/response/large-response-crash-prevention.spec.ts index c638365d9..39df5105c 100644 --- a/tests/response/large-response-crash-prevention.spec.ts +++ b/tests/response/large-response-crash-prevention.spec.ts @@ -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'); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index 720905bd1..3efabdf6f 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -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 };