diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollection/FullscreenLoader/index.js b/packages/bruno-app/src/components/Sidebar/ImportCollection/FullscreenLoader/index.js new file mode 100644 index 000000000..d5a26ad22 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/ImportCollection/FullscreenLoader/index.js @@ -0,0 +1,43 @@ +import { useState, useEffect } from 'react'; +import { IconLoader2 } from '@tabler/icons'; + +// Messages to cycle through while loading +const loadingMessages = [ + 'Processing collection...', + 'Analyzing requests...', + 'Translating scripts...', + 'Preparing collection...', + 'Almost done...' +]; + +const FullscreenLoader = ({ isLoading }) => { + const [loadingMessage, setLoadingMessage] = useState(''); + + useEffect(() => { + if (!isLoading) return; + + let messageIndex = 0; + const interval = setInterval(() => { + messageIndex = (messageIndex + 1) % loadingMessages.length; + setLoadingMessage(loadingMessages[messageIndex]); + }, 2000); + + setLoadingMessage(loadingMessages[0]); + + return () => clearInterval(interval); + }, [isLoading]); + + return ( +
+
+ +

{loadingMessage}

+

+ This may take a moment depending on the collection size +

+
+
+ ); +}; + +export default FullscreenLoader; diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js b/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js index 1db027ce9..83d0902ea 100644 --- a/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js +++ b/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js @@ -1,12 +1,14 @@ import React, { useState, useEffect, useRef } from 'react'; -import { IconLoader2, IconFileImport } from '@tabler/icons'; +import { IconFileImport } from '@tabler/icons'; import { toastError } from 'utils/common/error'; import Modal from 'components/Modal'; import jsyaml from 'js-yaml'; import { postmanToBruno, isPostmanCollection } from 'utils/importers/postman-collection'; import { convertInsomniaToBruno, isInsomniaCollection } from 'utils/importers/insomnia-collection'; -import { convertOpenapiToBruno, isOpenApiSpec } from 'utils/importers/openapi-collection'; +import { isOpenApiSpec, convertOpenapiToBruno } from 'utils/importers/openapi-collection'; import { processBrunoCollection } from 'utils/importers/bruno-collection'; +import ImportSettings from 'components/Sidebar/ImportSettings'; +import FullscreenLoader from './FullscreenLoader/index'; const convertFileToObject = async (file) => { const text = await file.text(); @@ -26,60 +28,22 @@ const convertFileToObject = async (file) => { } }; -const FullscreenLoader = ({ isLoading }) => { - const [loadingMessage, setLoadingMessage] = useState(''); - - // Messages to cycle through while loading - const loadingMessages = [ - 'Processing collection...', - 'Analyzing requests...', - 'Translating scripts...', - 'Preparing collection...', - 'Almost done...' - ]; - - useEffect(() => { - if (!isLoading) return; - - let messageIndex = 0; - const interval = setInterval(() => { - messageIndex = (messageIndex + 1) % loadingMessages.length; - setLoadingMessage(loadingMessages[messageIndex]); - }, 2000); - - setLoadingMessage(loadingMessages[0]); - - return () => clearInterval(interval); - }, [isLoading]); - - return ( -
-
- -

- {loadingMessage} -

-

- This may take a moment depending on the collection size -

-
-
- ); -}; - const ImportCollection = ({ onClose, handleSubmit }) => { const [isLoading, setIsLoading] = useState(false); const [dragActive, setDragActive] = useState(false); + const [showImportSettings, setShowImportSettings] = useState(false); + const [openApiData, setOpenApiData] = useState(null); + const [groupingType, setGroupingType] = useState('tags'); const fileInputRef = useRef(null); const handleDrag = (e) => { e.preventDefault(); e.stopPropagation(); - + if (e.dataTransfer) { e.dataTransfer.dropEffect = 'copy'; } - + if (e.type === 'dragenter' || e.type === 'dragover') { setDragActive(true); } else if (e.type === 'dragleave') { @@ -87,30 +51,43 @@ const ImportCollection = ({ onClose, handleSubmit }) => { } }; + const handleImportSettings = () => { + try { + const collection = convertOpenapiToBruno(openApiData, { groupBy: groupingType }); + handleSubmit({ collection }); + } catch (err) { + console.error(err); + toastError(err, 'Failed to process OpenAPI specification'); + } + }; + const processFile = async (file) => { setIsLoading(true); try { const data = await convertFileToObject(file); - + if (!data) { throw new Error('Failed to parse file content'); } - + + // Check if it's an OpenAPI spec and show settings + if (isOpenApiSpec(data)) { + setOpenApiData(data); + setIsLoading(false); + setShowImportSettings(true); + return; + } + let collection; - + if (isPostmanCollection(data)) { collection = await postmanToBruno(data); - } - else if (isInsomniaCollection(data)) { + } else if (isInsomniaCollection(data)) { collection = convertInsomniaToBruno(data); - } - else if (isOpenApiSpec(data)) { - collection = convertOpenapiToBruno(data); - } - else { + } else { collection = await processBrunoCollection(data); } - + handleSubmit({ collection }); } catch (err) { toastError(err, 'Import collection failed'); @@ -123,7 +100,7 @@ const ImportCollection = ({ onClose, handleSubmit }) => { e.preventDefault(); e.stopPropagation(); setDragActive(false); - + if (e.dataTransfer.files && e.dataTransfer.files[0]) { await processFile(e.dataTransfer.files[0]); } @@ -143,19 +120,23 @@ const ImportCollection = ({ onClose, handleSubmit }) => { return ; } - const acceptedFileTypes = [ - '.json', - '.yaml', - '.yml', - 'application/json', - 'application/yaml', - 'application/x-yaml' - ] + const acceptedFileTypes = ['.json', '.yaml', '.yml', 'application/json', 'application/yaml', 'application/x-yaml']; + + if (showImportSettings) { + return ( + + ); + } return (
-
+

Import from file

{ onDrop={handleDrop} className={` border-2 border-dashed rounded-lg p-6 transition-colors duration-200 - ${dragActive - ? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/20' - : 'border-gray-200 dark:border-gray-700' - } + ${dragActive ? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/20' : 'border-gray-200 dark:border-gray-700'} `} >
- props.theme.sidebar.badge.bg}; + border-radius: 4px; + padding: 0.4rem; + cursor: pointer; + border: 1px solid ${(props) => props.theme.sidebar.badge.border || 'transparent'}; + } + + .current-group:hover { + background-color: ${(props) => props.theme.sidebar.badge.hoverBg || props.theme.sidebar.badge.bg}; + } + + /* Fix dropdown positioning */ + [data-tippy-root] { + left: 0 !important; + } +`; + +export default Wrapper; diff --git a/packages/bruno-app/src/components/Sidebar/ImportSettings/index.js b/packages/bruno-app/src/components/Sidebar/ImportSettings/index.js new file mode 100644 index 000000000..e08a0eab8 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/ImportSettings/index.js @@ -0,0 +1,84 @@ +import React, { useRef, forwardRef } from 'react'; +import { IconCaretDown } from '@tabler/icons'; +import Dropdown from 'components/Dropdown'; +import Modal from 'components/Modal'; +import Portal from 'components/Portal'; +import StyledWrapper from './StyledWrapper'; + +const groupingOptions = [ + { value: 'tags', label: 'Tags', description: 'Group requests by OpenAPI tags', testId: 'grouping-option-tags' }, + { value: 'path', label: 'Paths', description: 'Group requests by URL path structure', testId: 'grouping-option-path' } +]; + +const ImportSettings = ({ + groupingType, + setGroupingType, + onClose, + onConfirm +}) => { + const dropdownTippyRef = useRef(); + + const onDropdownCreate = (ref) => { + dropdownTippyRef.current = ref; + }; + + const GroupingDropdownIcon = forwardRef((props, ref) => { + const selectedOption = groupingOptions.find((option) => option.value === groupingType); + return ( +
+
+
{selectedOption.label}
+
+ +
+ ); + }); + + return ( + + + +
+
+

Folder arrangement

+

+ Select whether to create folders according to the spec's paths or tags. +

+
+ +
+ } placement="bottom-start"> + {groupingOptions.map((option) => ( +
{ + dropdownTippyRef?.current?.hide(); + setGroupingType(option.value); + }} + > + {option.label} +
+ ))} +
+
+
+
+
+
+ ); +}; + +export default ImportSettings; diff --git a/packages/bruno-app/src/utils/importers/openapi-collection.js b/packages/bruno-app/src/utils/importers/openapi-collection.js index 5e619d97b..c28b6f926 100644 --- a/packages/bruno-app/src/utils/importers/openapi-collection.js +++ b/packages/bruno-app/src/utils/importers/openapi-collection.js @@ -1,9 +1,9 @@ import { BrunoError } from 'utils/common/error'; import { openApiToBruno } from '@usebruno/converters'; -export const convertOpenapiToBruno = (data) => { +export const convertOpenapiToBruno = (data, options = {}) => { try { - return openApiToBruno(data); + return openApiToBruno(data, options); } catch (err) { console.error('Error converting OpenAPI to Bruno:', err); throw new BrunoError('Import collection failed: ' + err.message); diff --git a/packages/bruno-cli/src/commands/import.js b/packages/bruno-cli/src/commands/import.js index dd12a8bc3..a7d4e8041 100644 --- a/packages/bruno-cli/src/commands/import.js +++ b/packages/bruno-cli/src/commands/import.js @@ -45,12 +45,21 @@ const builder = (yargs) => { describe: 'Skip SSL certificate verification when fetching from URLs', default: false }) + .option('group-by', { + alias: 'g', + describe: 'How to group the imported requests: "tags" groups by OpenAPI tags, "path" groups by URL path structure', + type: 'string', + choices: ['tags', 'path'], + default: 'tags' + }) .example('$0 import openapi --source api.yml --output ~/Desktop/my-collection --collection-name "My API"') .example('$0 import openapi -s api.yml -o ~/Desktop/my-collection -n "My API"') .example('$0 import openapi --source https://example.com/api-spec.json --output ~/Desktop --collection-name "Remote API"') .example('$0 import openapi --source https://self-signed.example.com/api.json --insecure --output ~/Desktop') .example('$0 import openapi --source api.yml --output-file ~/Desktop/my-collection.json --collection-name "My API"') - .example('$0 import openapi -s api.yml -f ~/Desktop/my-collection.json -n "My API"'); + .example('$0 import openapi -s api.yml -f ~/Desktop/my-collection.json -n "My API"') + .example('$0 import openapi --source api.yml --output ~/Desktop/my-collection --group-by path') + .example('$0 import openapi -s api.yml -o ~/Desktop/my-collection -g tags'); }; const isUrl = (str) => { @@ -132,7 +141,7 @@ const readOpenApiFile = async (source, options = {}) => { const handler = async (argv) => { try { - const { type, source, output, outputFile, collectionName, insecure } = argv; + const { type, source, output, outputFile, collectionName, insecure, groupBy } = argv; if (!type || type !== 'openapi') { console.error(chalk.red('Only OpenAPI import is supported currently')); @@ -161,7 +170,7 @@ const handler = async (argv) => { console.log(chalk.yellow('Converting OpenAPI specification to Bruno format...')); // Convert OpenAPI to Bruno format - let brunoCollection = openApiToBruno(openApiSpec); + let brunoCollection = openApiToBruno(openApiSpec, { groupBy }); // Override collection name if provided if (collectionName) { diff --git a/packages/bruno-converters/src/openapi/openapi-to-bruno.js b/packages/bruno-converters/src/openapi/openapi-to-bruno.js index 27811dc6c..4e3d1b019 100644 --- a/packages/bruno-converters/src/openapi/openapi-to-bruno.js +++ b/packages/bruno-converters/src/openapi/openapi-to-bruno.js @@ -394,6 +394,95 @@ const groupRequestsByTags = (requests) => { return [groups, ungrouped]; }; +const groupRequestsByPath = (requests) => { + const pathGroups = {}; + + // Group requests by their path segments + requests.forEach((request) => { + // Use original path for grouping to preserve {id} format + const pathToUse = request.originalPath || request.path; + const pathSegments = pathToUse.split('/').filter((segment) => segment !== ''); + + if (pathSegments.length === 0) { + // Handle root path or paths with only parameters + const groupName = 'Root'; + if (!pathGroups[groupName]) { + pathGroups[groupName] = { + name: groupName, + requests: [], + subGroups: {} + }; + } + pathGroups[groupName].requests.push(request); + return; + } + + // Use the first segment as the main group + let groupName = pathSegments[0]; + + if (!pathGroups[groupName]) { + pathGroups[groupName] = { + name: groupName, + requests: [], + subGroups: {} + }; + } + + // If there's only one meaningful segment, add to main group + if (pathSegments.length <= 1) { + pathGroups[groupName].requests.push(request); + } else { + // For deeper paths, create sub-groups + let currentGroup = pathGroups[groupName]; + for (let i = 1; i < pathSegments.length; i++) { + let subGroupName = pathSegments[i]; + + if (!currentGroup.subGroups[subGroupName]) { + currentGroup.subGroups[subGroupName] = { + name: subGroupName, + requests: [], + subGroups: {} + }; + } + currentGroup = currentGroup.subGroups[subGroupName]; + } + currentGroup.requests.push(request); + } + }); + + // Convert the nested structure to Bruno folder format + const buildFolderStructure = (group) => { + // Create a new usedNames set for each folder/subfolder scope + const localUsedNames = new Set(); + const items = group.requests.map((req) => transformOpenapiRequestItem(req, localUsedNames)); + + // Add sub-folders + const subFolders = []; + Object.values(group.subGroups).forEach((subGroup) => { + const subFolderItems = buildFolderStructure(subGroup); + if (subFolderItems.length > 0) { + subFolders.push({ + uid: uuid(), + name: subGroup.name, + type: 'folder', + items: subFolderItems + }); + } + }); + + return [...items, ...subFolders]; + }; + + const folders = Object.values(pathGroups).map((group) => ({ + uid: uuid(), + name: group.name, + type: 'folder', + items: buildFolderStructure(group) + })); + + return folders; +}; + const getDefaultUrl = (serverObject) => { let url = serverObject.url; if (serverObject.variables) { @@ -439,9 +528,8 @@ const openAPIRuntimeExpressionToScript = (expression) => { return expression; }; -export const parseOpenApiCollection = (data) => { +export const parseOpenApiCollection = (data, options = {}) => { const usedNames = new Set(); - const brunoCollection = { name: '', uid: uuid(), @@ -504,9 +592,10 @@ export const parseOpenApiCollection = (data) => { return { method: method, path: path.replace(/{([^}]+)}/g, ':$1'), // Replace placeholders enclosed in curly braces with colons + originalPath: path, // Keep original path for grouping operationObject: operationObject, global: { - server: '{{baseUrl}}', + server: '{{baseUrl}}', security: securityConfig } }; @@ -514,6 +603,13 @@ export const parseOpenApiCollection = (data) => { }) .reduce((acc, val) => acc.concat(val), []); // flatten + // Support both tag-based and path-based grouping + const groupingType = options.groupBy || 'tags'; + + if (groupingType === 'path') { + brunoCollection.items = groupRequestsByPath(allRequests); + } else { + // Default tag-based grouping let [groups, ungroupedRequests] = groupRequestsByTags(allRequests); let brunoFolders = groups.map((group) => { return { @@ -535,13 +631,14 @@ export const parseOpenApiCollection = (data) => { name: group.name } }, - items: group.requests.map(req => transformOpenapiRequestItem(req, usedNames)), + items: group.requests.map((req) => transformOpenapiRequestItem(req, usedNames)) }; }); - let ungroupedItems = ungroupedRequests.map(req => transformOpenapiRequestItem(req, usedNames)); + let ungroupedItems = ungroupedRequests.map((req) => transformOpenapiRequestItem(req, usedNames)); let brunoCollectionItems = brunoFolders.concat(ungroupedItems); brunoCollection.items = brunoCollectionItems; + } // Determine collection-level authentication based on global security requirements const buildCollectionAuth = (scheme) => { @@ -648,13 +745,13 @@ export const parseOpenApiCollection = (data) => { } }; -export const openApiToBruno = (openApiSpecification) => { +export const openApiToBruno = (openApiSpecification, options = {}) => { try { if(typeof openApiSpecification !== 'object') { openApiSpecification = jsyaml.load(openApiSpecification); } - const collection = parseOpenApiCollection(openApiSpecification); + const collection = parseOpenApiCollection(openApiSpecification, options); const transformedCollection = transformItemsInCollection(collection); const hydratedCollection = hydrateSeqInCollection(transformedCollection); const validatedCollection = validateSchema(hydratedCollection); diff --git a/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-import-grouping.spec.js b/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-import-grouping.spec.js new file mode 100644 index 000000000..7cfd03f68 --- /dev/null +++ b/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-import-grouping.spec.js @@ -0,0 +1,79 @@ +import { describe, it, expect } from '@jest/globals'; +import openApiToBruno from '../../../src/openapi/openapi-to-bruno'; + +const openApiSpec = { + openapi: '3.0.0', + info: { title: 'Parameter API', version: '1.0.0' }, + servers: [{ url: 'https://api.example.com' }], + paths: { + '/{id}': { + get: { + summary: 'Get by ID', + operationId: 'getById', + responses: { 200: { description: 'OK' } } + } + }, + '/{id}/{subId}': { + get: { + summary: 'Get by ID and sub ID', + operationId: 'getByIdAndSubId', + responses: { 200: { description: 'OK' } } + } + } + } +}; + +describe('openapi-import-grouping', () => { + it('should handle path based grouping', () => { + const result = openApiToBruno(openApiSpec, { groupBy: 'path' }); + + // Should have one folder containing both requests + expect(result.items).toHaveLength(1); + + const folder = result.items[0]; + expect(folder.name).toBe('{id}'); + expect(folder.type).toBe('folder'); + + // Folder should contain one request and one subfolder + expect(folder.items).toHaveLength(2); + + const requests = folder.items.filter((item) => item.type === 'http-request'); + const subfolders = folder.items.filter((item) => item.type === 'folder'); + + expect(requests).toHaveLength(1); + expect(subfolders).toHaveLength(1); + + // Check request name + expect(requests[0].name).toBe('Get by ID'); + expect(requests[0].request.url).toBe('{{baseUrl}}/:id'); + + // Check subfolder + expect(subfolders[0].name).toBe('{subId}'); + expect(subfolders[0].type).toBe('folder'); + expect(subfolders[0].items).toHaveLength(1); + expect(subfolders[0].items[0].name).toBe('Get by ID and sub ID'); + expect(subfolders[0].items[0].request.url).toBe('{{baseUrl}}/:id/:subId'); + }); + + it('should handle tag based grouping', () => { + const result = openApiToBruno(openApiSpec, { groupBy: 'tags' }); + + // With tags grouping, requests without tags should be ungrouped + expect(result.items).toHaveLength(2); + + // Both should be individual requests (not in folders) + result.items.forEach((item) => { + expect(item.type).toBe('http-request'); + }); + + // Check request names + const requestNames = result.items.map((req) => req.name); + expect(requestNames).toContain('Get by ID'); + expect(requestNames).toContain('Get by ID and sub ID'); + + // Check request URLs + const urls = result.items.map((req) => req.request.url); + expect(urls).toContain('{{baseUrl}}/:id'); + expect(urls).toContain('{{baseUrl}}/:id/:subId'); + }); +}); diff --git a/packages/bruno-converters/tests/openapi/openapi-to-bruno/path-based-grouping-duplicate-names.spec.js b/packages/bruno-converters/tests/openapi/openapi-to-bruno/path-based-grouping-duplicate-names.spec.js new file mode 100644 index 000000000..8ddabab93 --- /dev/null +++ b/packages/bruno-converters/tests/openapi/openapi-to-bruno/path-based-grouping-duplicate-names.spec.js @@ -0,0 +1,96 @@ +import { describe, it, expect } from '@jest/globals'; +import openApiToBruno from '../../../src/openapi/openapi-to-bruno'; + +describe('OpenAPI Path-Based Grouping - Duplicate Names', () => { + it('should not add suffixes to duplicate operation names in different folders', () => { + const openApiSpec = { + openapi: '3.0.0', + info: { + title: 'Duplicate Names Test API', + version: '1.0.0' + }, + servers: [ + { + url: 'https://api.example.com/v1' + } + ], + paths: { + // Users folder - should have "Get User Details" operation + '/users/{id}': { + get: { + summary: 'Get User Details', + operationId: 'getUserDetails', + responses: { + 200: { + description: 'User details' + } + } + } + }, + // Products folder - should also have "Get User Details" operation (different context) + '/products/{id}/owner': { + get: { + summary: 'Get User Details', + operationId: 'getProductOwnerDetails', + responses: { + 200: { + description: 'Product owner details' + } + } + } + }, + // Orders folder - should also have "Get User Details" operation (different context) + '/orders/{orderId}/customer': { + get: { + summary: 'Get User Details', + operationId: 'getOrderCustomerDetails', + responses: { + 200: { + description: 'Order customer details' + } + } + } + } + } + }; + + const result = openApiToBruno(openApiSpec, { groupBy: 'path' }); + + // Find the folders + const usersFolder = result.items.find((item) => item.name === 'users'); + const productsFolder = result.items.find((item) => item.name === 'products'); + const ordersFolder = result.items.find((item) => item.name === 'orders'); + + // Find requests in each folder + // Users folder: /users/{id} -> should have request directly in users folder + const usersIdFolder = usersFolder.items.find((item) => item.name === '{id}'); + expect(usersIdFolder).toBeDefined(); + const getUserDetailsRequest = usersIdFolder.items.find((item) => item.type === 'http-request'); + + // Products folder: /products/{id}/owner -> should have request in products/{id}/owner + const productsIdFolder = productsFolder.items.find((item) => item.name === '{id}'); + expect(productsIdFolder).toBeDefined(); + const productsOwnerFolder = productsIdFolder.items.find((item) => item.name === 'owner'); + expect(productsOwnerFolder).toBeDefined(); + const getProductOwnerRequest = productsOwnerFolder.items.find((item) => item.type === 'http-request'); + + // Orders folder: /orders/{orderId}/customer -> should have request in orders/{orderId}/customer + const ordersIdFolder = ordersFolder.items.find((item) => item.name === '{orderId}'); + expect(ordersIdFolder).toBeDefined(); + const ordersCustomerFolder = ordersIdFolder.items.find((item) => item.name === 'customer'); + expect(ordersCustomerFolder).toBeDefined(); + const getOrderCustomerRequest = ordersCustomerFolder.items.find((item) => item.type === 'http-request'); + + expect(getUserDetailsRequest).toBeDefined(); + expect(getProductOwnerRequest).toBeDefined(); + expect(getOrderCustomerRequest).toBeDefined(); + + // CRITICAL ASSERTIONS: Names should NOT have suffixes + // Each folder should have its own namespace, so duplicate names across folders should be allowed + + // All requests should have clean names (NO suffixes like "(GET)" or "(1)") + expect(getUserDetailsRequest.name).toBe('Get User Details'); + expect(getProductOwnerRequest.name).toBe('Get User Details'); + expect(getOrderCustomerRequest.name).toBe('Get User Details'); + }); +}); diff --git a/tests/import/openapi/cli/fixtures/openapi.json b/tests/import/openapi/cli/fixtures/openapi.json new file mode 100644 index 000000000..9fb47bfca --- /dev/null +++ b/tests/import/openapi/cli/fixtures/openapi.json @@ -0,0 +1,212 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Simple Test API", + "version": "1.0.0", + "description": "A simple API for testing groupBy functionality" + }, + "servers": [ + { + "url": "https://api.example.com", + "description": "Example server" + } + ], + "paths": { + "/users": { + "get": { + "summary": "Get all users", + "operationId": "getUsers", + "tags": ["users"], + "responses": { + "200": { + "description": "List of users", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" } + } + } + } + } + } + } + } + }, + "post": { + "summary": "Create user", + "operationId": "createUser", + "tags": ["users"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { "type": "string" } + } + } + } + } + }, + "responses": { + "201": { + "description": "User created" + } + } + } + }, + "/users/{id}": { + "get": { + "summary": "Get user by ID", + "operationId": "getUserById", + "tags": ["users"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "User details" + } + } + }, + "put": { + "summary": "Update user", + "operationId": "updateUser", + "tags": ["users"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { "type": "string" } + } + } + } + } + }, + "responses": { + "200": { + "description": "User updated" + } + } + } + }, + "/products": { + "get": { + "summary": "Get all products", + "operationId": "getProducts", + "tags": ["products"], + "responses": { + "200": { + "description": "List of products" + } + } + } + }, + "/products/{id}": { + "get": { + "summary": "Get product by ID", + "operationId": "getProductById", + "tags": ["products"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Product details" + } + } + } + }, + "/orders": { + "get": { + "summary": "Get all orders", + "operationId": "getOrders", + "tags": ["orders"], + "responses": { + "200": { + "description": "List of orders" + } + } + }, + "post": { + "summary": "Create order", + "operationId": "createOrder", + "tags": ["orders"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { "type": "integer" }, + "productId": { "type": "integer" } + } + } + } + } + }, + "responses": { + "201": { + "description": "Order created" + } + } + } + }, + "/orders/{id}": { + "get": { + "summary": "Get order by ID", + "operationId": "getOrderById", + "tags": ["orders"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Order details" + } + } + } + } + } +} diff --git a/tests/import/openapi/cli/group-by-import.spec.ts b/tests/import/openapi/cli/group-by-import.spec.ts new file mode 100644 index 000000000..093cef920 --- /dev/null +++ b/tests/import/openapi/cli/group-by-import.spec.ts @@ -0,0 +1,78 @@ +import { test, expect } from '../../../../playwright'; +import { execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +test.describe('OpenAPI Import GroupBy Tests', () => { + test('CLI: Import OpenAPI with tags grouping', async ({ createTmpDir }) => { + const outputDir = await createTmpDir('openapi-tags'); + const jsonOutputPath = path.join(outputDir, 'petstore-tags.json'); + + // Run OpenAPI import with tags grouping using JSON output + const cliPath = path.resolve(__dirname, '../../../../packages/bruno-cli/bin/bru.js'); + const specPath = path.resolve(__dirname, './fixtures/openapi.json'); + const command = `node "${cliPath}" import openapi --source "${specPath}" --output-file "${jsonOutputPath}" --collection-name "Simple API (Tags)" --group-by tags`; + + try { + execSync(command, { stdio: 'pipe' }); + } catch (error) { + // Continue with test even if import fails + } + + // Verify JSON file was created + expect(fs.existsSync(jsonOutputPath)).toBe(true); + + // Read and verify collection structure + const jsonCollection = JSON.parse(fs.readFileSync(jsonOutputPath, 'utf8')); + expect(jsonCollection.name).toBe('Simple API (Tags)'); + + // Verify tags grouping creates folders by OpenAPI tags + const folders = jsonCollection.items.filter((item) => item.type === 'folder'); + expect(folders.length).toBe(3); + + const folderNames = folders.map((folder) => folder.name); + expect(folderNames).toContain('users'); + expect(folderNames).toContain('products'); + expect(folderNames).toContain('orders'); + + // Verify tags grouping doesn't create {id} folders + const hasIdFolders = folders.some((folder) => folder.items?.some((item) => item.name === '{id}')); + expect(hasIdFolders).toBe(false); + }); + + test('CLI: Import OpenAPI with path grouping', async ({ createTmpDir }) => { + const outputDir = await createTmpDir('openapi-path'); + const jsonOutputPath = path.join(outputDir, 'petstore-path.json'); + + // Run OpenAPI import with path grouping using JSON output + const cliPath = path.resolve(__dirname, '../../../../packages/bruno-cli/bin/bru.js'); + const specPath = path.resolve(__dirname, './fixtures/openapi.json'); + const command = `node "${cliPath}" import openapi --source "${specPath}" --output-file "${jsonOutputPath}" --collection-name "Simple API (Path)" --group-by path`; + + try { + execSync(command, { stdio: 'pipe' }); + } catch (error) { + // Continue with test even if import fails + } + + // Verify JSON file was created + expect(fs.existsSync(jsonOutputPath)).toBe(true); + + // Read and verify collection structure + const jsonCollection = JSON.parse(fs.readFileSync(jsonOutputPath, 'utf8')); + expect(jsonCollection.name).toBe('Simple API (Path)'); + + // Verify path grouping creates folders by URL path structure + const folders = jsonCollection.items.filter((item) => item.type === 'folder'); + expect(folders.length).toBe(3); // users, products, orders + + const folderNames = folders.map((folder) => folder.name); + expect(folderNames).toContain('users'); + expect(folderNames).toContain('products'); + expect(folderNames).toContain('orders'); + + // Verify path grouping creates {id} folders for parameterized paths + const hasIdFolders = folders.some((folder) => folder.items?.some((item) => item.name === '{id}')); + expect(hasIdFolders).toBe(true); + }); +}); diff --git a/tests/import/openapi/duplicate-operation-names-fix.spec.ts b/tests/import/openapi/duplicate-operation-names-fix.spec.ts index cc6e38742..8b7374968 100644 --- a/tests/import/openapi/duplicate-operation-names-fix.spec.ts +++ b/tests/import/openapi/duplicate-operation-names-fix.spec.ts @@ -1,7 +1,13 @@ import { test, expect } from '../../../playwright'; import * as path from 'path'; +import { closeAllCollections } from '../../utils/page'; test.describe('OpenAPI Duplicate Names Handling', () => { + test.afterEach(async ({ page }) => { + // cleanup: close all collections + await closeAllCollections(page); + }); + test('should handle duplicate operation names', async ({ page, createTmpDir }) => { const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-duplicate-operation-name.yaml'); @@ -18,6 +24,13 @@ test.describe('OpenAPI Duplicate Names Handling', () => { // wait for the file processing to complete await page.locator('#import-collection-loader').waitFor({ state: 'hidden' }); + // verify that the import settings modal appears + const settingsModal = page.getByTestId('import-settings-modal'); + await expect(settingsModal.locator('.bruno-modal-header-title')).toContainText('OpenAPI Import Settings'); + + // click the Import button in the settings modal footer + await settingsModal.getByRole('button', { name: 'Import' }).click(); + // verify that the collection location modal appears (OpenAPI files go directly to location modal) const locationModal = page.getByTestId('import-collection-location-modal'); // verify the collection name is correctly parsed despite duplicate operation names @@ -37,14 +50,5 @@ test.describe('OpenAPI Duplicate Names Handling', () => { // verify that all 3 requests were imported correctly despite duplicate operation names await expect(page.locator('#collection-duplicate-test-collection .collection-item-name')).toHaveCount(3); - - // cleanup: close the collection - await page - .locator('.collection-name') - .filter({ has: page.locator('#sidebar-collection-name:has-text("Duplicate Test Collection")') }) - .locator('.collection-actions') - .click(); - await page.locator('.dropdown-item').getByText('Close').click(); - await page.getByRole('button', { name: 'Close' }).click(); }); }); diff --git a/tests/import/openapi/fixtures/openapi-path-grouping.json b/tests/import/openapi/fixtures/openapi-path-grouping.json new file mode 100644 index 000000000..a64430470 --- /dev/null +++ b/tests/import/openapi/fixtures/openapi-path-grouping.json @@ -0,0 +1,76 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Path Grouping Test API", + "description": "API for testing path-based folder grouping", + "version": "1.0.0" + }, + "servers": [ + { + "url": "https://api.example.com", + "description": "Test server" + } + ], + "paths": { + "/users": { + "get": { + "summary": "List users", + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/users/{id}": { + "get": { + "summary": "Get user by ID", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/products": { + "get": { + "summary": "List products", + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/products/{id}": { + "get": { + "summary": "Get product by ID", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + } + } +} diff --git a/tests/import/openapi/import-openapi-json.spec.ts b/tests/import/openapi/import-openapi-json.spec.ts index 750f438bd..a1360da59 100644 --- a/tests/import/openapi/import-openapi-json.spec.ts +++ b/tests/import/openapi/import-openapi-json.spec.ts @@ -17,6 +17,13 @@ test.describe('Import OpenAPI v3 JSON Collection', () => { // Wait for the loader to disappear await page.locator('#import-collection-loader').waitFor({ state: 'hidden' }); + // verify that the import settings modal appears + const settingsModal = page.getByTestId('import-settings-modal'); + await expect(settingsModal.locator('.bruno-modal-header-title')).toContainText('OpenAPI Import Settings'); + + // click the Import button in the settings modal footer + await settingsModal.getByRole('button', { name: 'Import' }).click(); + // Verify that the Import Collection modal is displayed (for location selection) const locationModal = page.getByRole('dialog'); await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection'); diff --git a/tests/import/openapi/import-openapi-yaml.spec.ts b/tests/import/openapi/import-openapi-yaml.spec.ts index 8d490876c..a60f22ea2 100644 --- a/tests/import/openapi/import-openapi-yaml.spec.ts +++ b/tests/import/openapi/import-openapi-yaml.spec.ts @@ -14,6 +14,13 @@ test.describe('Import OpenAPI v3 YAML Collection', () => { await page.setInputFiles('input[type="file"]', openApiFile); + // verify that the import settings modal appears + const settingsModal = page.getByTestId('import-settings-modal'); + await expect(settingsModal.locator('.bruno-modal-header-title')).toContainText('OpenAPI Import Settings'); + + // click the Import button in the settings modal footer + await settingsModal.getByRole('button', { name: 'Import' }).click(); + // Wait for the loader to disappear await page.locator('#import-collection-loader').waitFor({ state: 'hidden' }); diff --git a/tests/import/openapi/operation-name-with-newlines-fix.spec.ts b/tests/import/openapi/operation-name-with-newlines-fix.spec.ts index 2971ee4c9..95b6cae6e 100644 --- a/tests/import/openapi/operation-name-with-newlines-fix.spec.ts +++ b/tests/import/openapi/operation-name-with-newlines-fix.spec.ts @@ -1,7 +1,13 @@ import { test, expect } from '../../../playwright'; import * as path from 'path'; +import { closeAllCollections } from '../../utils/page'; test.describe('OpenAPI Newline Handling', () => { + test.afterEach(async ({ page }) => { + // cleanup: close all collections + await closeAllCollections(page); + }); + test('should handle operation names with newlines', async ({ page, createTmpDir }) => { const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-newline-in-operation-name.yaml'); @@ -15,6 +21,13 @@ test.describe('OpenAPI Newline Handling', () => { // upload the OpenAPI file with problematic operation names await page.setInputFiles('input[type="file"]', openApiFile); + // verify that the import settings modal appears + const settingsModal = page.getByTestId('import-settings-modal'); + await expect(settingsModal.locator('.bruno-modal-header-title')).toContainText('OpenAPI Import Settings'); + + // click the Import button in the settings modal footer + await settingsModal.getByRole('button', { name: 'Import' }).click(); + // wait for the file processing to complete await page.locator('#import-collection-loader').waitFor({ state: 'hidden' }); @@ -37,14 +50,5 @@ test.describe('OpenAPI Newline Handling', () => { // verify that all requests were imported correctly despite newlines in operation names // the parser should clean up the operation names and create valid request names await expect(page.locator('#collection-newline-test-collection .collection-item-name')).toHaveCount(2); - - // cleanup: close the collection - await page - .locator('.collection-name') - .filter({ has: page.locator('#sidebar-collection-name:has-text("Newline Test Collection")') }) - .locator('.collection-actions') - .click(); - await page.locator('.dropdown-item').getByText('Close').click(); - await page.getByRole('button', { name: 'Close' }).click(); }); }); diff --git a/tests/import/openapi/path-based-grouping.spec.ts b/tests/import/openapi/path-based-grouping.spec.ts new file mode 100644 index 000000000..5738c1264 --- /dev/null +++ b/tests/import/openapi/path-based-grouping.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from '../../../playwright'; +import * as path from 'path'; +import { closeAllCollections } from '../../utils/page'; + +test.describe('OpenAPI Path-Based Grouping', () => { + test.afterEach(async ({ page }) => { + // cleanup: close all collections + await closeAllCollections(page); + }); + + test('should import with path-based folder grouping', async ({ page, createTmpDir }) => { + const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-path-grouping.json'); + + // Start the import process + await page.getByRole('button', { name: 'Import Collection' }).click(); + + // Wait for import collection modal to be ready + const importModal = page.getByTestId('import-collection-modal'); + await importModal.waitFor({ state: 'visible' }); + + // Upload the OpenAPI file + await page.setInputFiles('input[type="file"]', openApiFile); + + // Wait for the loader to disappear + await page.locator('#import-collection-loader').waitFor({ state: 'hidden' }); + + // Verify that the import settings modal appears + const settingsModal = page.getByTestId('import-settings-modal'); + await expect(settingsModal.locator('.bruno-modal-header-title')).toContainText('OpenAPI Import Settings'); + + // Select path-based grouping from the dropdown + await settingsModal.getByTestId('grouping-dropdown').click(); + + // Wait for dropdown options to be visible (they might be rendered outside the modal) + await page.getByTestId('grouping-option-path').waitFor({ state: 'visible' }); + await page.getByTestId('grouping-option-path').click(); + + // Now import the collection with path-based grouping + await settingsModal.getByRole('button', { name: 'Import' }).click(); + + // Verify that the collection location modal appears + const locationModal = page.getByTestId('import-collection-location-modal'); + await expect(locationModal.getByText('Path Grouping Test API')).toBeVisible(); + + // Select a location and import + await page.locator('#collection-location').fill(await createTmpDir('path-grouping-test')); + await page.getByRole('button', { name: 'Import', exact: true }).click(); + + // Verify the collection was imported successfully + await expect(page.locator('#sidebar-collection-name').getByText('Path Grouping Test API')).toBeVisible(); + + // Configure the collection settings + await page.locator('#sidebar-collection-name').getByText('Path Grouping Test API').click(); + await page.getByLabel('Safe Mode').check(); + await page.getByRole('button', { name: 'Save' }).click(); + + // Verify path-based folder structure was created + // Should have 'users' and 'products' folders + await expect(page.locator('.collection-item-name').getByText('users')).toBeVisible(); + await expect(page.locator('.collection-item-name').getByText('products')).toBeVisible(); + + // Expand the products folder to check for nested structure + await page.locator('.collection-item-name').getByText('products').click(); + + // Verify that the products folder contains the {id} subfolder + await expect(page.locator('.collection-item-name').getByText('{id}')).toBeVisible(); + }); +});