From 0c30357b0123403b4e0d111f8d638f776cda4fb9 Mon Sep 17 00:00:00 2001 From: Pooja Date: Wed, 8 Oct 2025 20:00:37 +0530 Subject: [PATCH] feat: add redirect and timeout in request settings (#5672) * feat: add redirect and timeout in request settings --- .../InheritableSettingsInput/index.js | 90 +++++++++++ .../Settings/ToggleSelector/index.js | 28 ++-- .../components/RequestPane/Settings/index.js | 153 +++++++++++++++--- .../src/components/SettingsInput/index.js | 47 ++++++ .../ReduxStore/slices/collections/index.js | 3 + .../src/runner/run-single-request.js | 34 +++- .../src/postman/postman-to-bruno.js | 10 ++ .../postman-to-bruno/postman-to-bruno.spec.js | 82 ++++++++++ .../bruno-electron/src/ipc/network/index.js | 27 +++- .../bruno-electron/src/utils/collection.js | 30 +++- packages/bruno-lang/v2/src/bruToJson.js | 40 ++++- .../fixtures/settings-all-options.bru | 26 +++ .../fixtures/settings-all-options.json | 27 ++++ .../settings/fixtures/settings-minimal.bru | 14 ++ .../settings/fixtures/settings-minimal.json | 15 ++ .../v2/tests/settings/settings.spec.js | 58 +++++++ .../bruno-schema/src/collections/index.js | 5 +- .../collection/request-setting/folder.bru | 8 + .../request-setting/follow-redirect.bru | 23 +++ .../request-setting/max-redirect.bru | 24 +++ tests/request/settings/collection/bruno.json | 9 ++ .../settings/collection/max-redirects.bru | 17 ++ .../settings/collection/no-redirects.bru | 17 ++ tests/request/settings/collection/timeout.bru | 17 ++ .../init-user-data/collection-security.json | 10 ++ .../settings/init-user-data/preferences.json | 6 + tests/request/settings/max-redirects.spec.ts | 46 ++++++ tests/request/settings/no-redirects.spec.ts | 50 ++++++ tests/request/settings/timeout.spec.ts | 38 +++++ 29 files changed, 898 insertions(+), 56 deletions(-) create mode 100644 packages/bruno-app/src/components/InheritableSettingsInput/index.js create mode 100644 packages/bruno-app/src/components/SettingsInput/index.js create mode 100644 packages/bruno-lang/v2/tests/settings/fixtures/settings-all-options.bru create mode 100644 packages/bruno-lang/v2/tests/settings/fixtures/settings-all-options.json create mode 100644 packages/bruno-lang/v2/tests/settings/fixtures/settings-minimal.bru create mode 100644 packages/bruno-lang/v2/tests/settings/fixtures/settings-minimal.json create mode 100644 packages/bruno-lang/v2/tests/settings/settings.spec.js create mode 100644 packages/bruno-tests/collection/request-setting/folder.bru create mode 100644 packages/bruno-tests/collection/request-setting/follow-redirect.bru create mode 100644 packages/bruno-tests/collection/request-setting/max-redirect.bru create mode 100644 tests/request/settings/collection/bruno.json create mode 100644 tests/request/settings/collection/max-redirects.bru create mode 100644 tests/request/settings/collection/no-redirects.bru create mode 100644 tests/request/settings/collection/timeout.bru create mode 100644 tests/request/settings/init-user-data/collection-security.json create mode 100644 tests/request/settings/init-user-data/preferences.json create mode 100644 tests/request/settings/max-redirects.spec.ts create mode 100644 tests/request/settings/no-redirects.spec.ts create mode 100644 tests/request/settings/timeout.spec.ts diff --git a/packages/bruno-app/src/components/InheritableSettingsInput/index.js b/packages/bruno-app/src/components/InheritableSettingsInput/index.js new file mode 100644 index 000000000..0d88bbf20 --- /dev/null +++ b/packages/bruno-app/src/components/InheritableSettingsInput/index.js @@ -0,0 +1,90 @@ +import React from 'react'; +import { IconChevronDown, IconX } from '@tabler/icons'; +import { useTheme } from 'providers/Theme'; +import Dropdown from 'components/Dropdown'; + +const InheritableSettingsInput = ({ + id, + label, + value, + description, + onKeyDown, + isInherited, + onDropdownSelect, + onValueChange, + onCustomValueReset +}) => { + const { theme } = useTheme(); + + return ( +
+
+ + {description && ( +

+ {description} +

+ )} +
+
+ {isInherited ? ( + + Inherit + + + )} + > +
onDropdownSelect('inherit')}> + Inherit +
+
onDropdownSelect('custom')}> + Custom +
+
+ ) : ( +
+ + +
+ )} +
+
+ ); +}; + +export default InheritableSettingsInput; diff --git a/packages/bruno-app/src/components/RequestPane/Settings/ToggleSelector/index.js b/packages/bruno-app/src/components/RequestPane/Settings/ToggleSelector/index.js index f0294aee9..05ad36f4f 100644 --- a/packages/bruno-app/src/components/RequestPane/Settings/ToggleSelector/index.js +++ b/packages/bruno-app/src/components/RequestPane/Settings/ToggleSelector/index.js @@ -6,7 +6,8 @@ const ToggleSelector = ({ label, description, disabled = false, - size = 'small' // 'small', 'medium', 'large' + size = 'small', // 'small', 'medium', 'large' + 'data-testid': dataTestId }) => { const sizeClasses = { small: { @@ -29,13 +30,24 @@ const ToggleSelector = ({ const currentSize = sizeClasses[size]; return ( -
+
+
+ + {description && ( +

+ {description} +

+ )} +
-
- - {description && ( -

- {description} -

- )} -
); }; diff --git a/packages/bruno-app/src/components/RequestPane/Settings/index.js b/packages/bruno-app/src/components/RequestPane/Settings/index.js index df570085d..2f5c7306d 100644 --- a/packages/bruno-app/src/components/RequestPane/Settings/index.js +++ b/packages/bruno-app/src/components/RequestPane/Settings/index.js @@ -1,47 +1,156 @@ import React, { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import get from 'lodash/get'; import { IconTag } from '@tabler/icons'; import ToggleSelector from 'components/RequestPane/Settings/ToggleSelector'; +import SettingsInput from 'components/SettingsInput'; +import InheritableSettingsInput from 'components/InheritableSettingsInput'; import { updateItemSettings } from 'providers/ReduxStore/slices/collections'; +import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions'; import Tags from './Tags/index'; +// Default settings configuration +const DEFAULT_SETTINGS = { + encodeUrl: false, + followRedirects: true, + maxRedirects: 5, + timeout: 'inherit' +}; + const Settings = ({ item, collection }) => { const dispatch = useDispatch(); - // get the length of active params, headers, asserts and vars as well as the contents of the body, tests and script + // Get current settings with defaults applied const getPropertyFromDraftOrRequest = (propertyKey) => item.draft ? get(item, `draft.${propertyKey}`, {}) : get(item, propertyKey, {}); - const { encodeUrl } = getPropertyFromDraftOrRequest('settings'); + const rawSettings = getPropertyFromDraftOrRequest('settings'); + const settings = { ...DEFAULT_SETTINGS, ...rawSettings }; + const { encodeUrl, followRedirects, maxRedirects, timeout } = settings; - const onToggleUrlEncoding = useCallback(() => { + // Reusable function to update settings + const updateSetting = useCallback((settingUpdate) => { + const updatedSettings = { ...settings, ...settingUpdate }; dispatch(updateItemSettings({ collectionUid: collection.uid, itemUid: item.uid, - settings: { encodeUrl: !encodeUrl } + settings: updatedSettings })); - }, [encodeUrl, dispatch, collection.uid, item.uid]); + }, [dispatch, collection.uid, item.uid, settings]); + + // Setting change handlers + const onToggleUrlEncoding = useCallback(() => + updateSetting({ encodeUrl: !encodeUrl }), [encodeUrl, updateSetting]); + + const onToggleFollowRedirects = useCallback(() => + updateSetting({ followRedirects: !followRedirects }), [followRedirects, updateSetting]); + + const onMaxRedirectsChange = useCallback((e) => { + const value = e.target.value; + // Only allow empty string or digits + if (value === '' || /^\d+$/.test(value)) { + const numericValue = value === '' ? 0 : parseInt(value, 10); + updateSetting({ maxRedirects: numericValue }); + } + }, [updateSetting]); + + const onTimeoutChange = useCallback((e) => { + const value = e.target.value; + // Only allow empty string or digits + if (value === '' || /^\d+$/.test(value)) { + const numericValue = value === '' ? 0 : parseInt(value, 10); + updateSetting({ timeout: numericValue }); + } + }, [updateSetting]); + + // Check if timeout is inherited + const isTimeoutInherited = timeout === 'inherit' || timeout === undefined || timeout === null; + + const handleTimeoutDropdownSelect = useCallback((option) => { + if (option === 'inherit') { + onTimeoutChange({ target: { value: 'inherit' } }); + } else if (option === 'custom') { + // Switch to custom value - start with 0 + onTimeoutChange({ target: { value: 0 } }); + } + }, [onTimeoutChange]); + + // Keyboard shortcut handlers + const onSave = useCallback(() => { + dispatch(saveRequest(item.uid, collection.uid)); + }, [dispatch, item.uid, collection.uid]); + + const onRun = useCallback(() => { + dispatch(sendRequest(item, collection.uid)); + }, [dispatch, item, collection.uid]); + + // Keyboard shortcut handler for input fields + const handleKeyDown = useCallback((e) => { + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + onSave(); + } else if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault(); + onRun(); + } + }, [onSave, onRun]); return ( -
-
-

- - Tags -

-
+
+
Configure request settings for this item.
+
+
+

+ + Tags +

-
-
- + +
+ +
+ +
+ +
+ +
+ + + + !isTimeoutInherited && onTimeoutChange(e)} + onCustomValueReset={() => onTimeoutChange({ target: { value: 'inherit' } })} + /> +
); diff --git a/packages/bruno-app/src/components/SettingsInput/index.js b/packages/bruno-app/src/components/SettingsInput/index.js new file mode 100644 index 000000000..70f980652 --- /dev/null +++ b/packages/bruno-app/src/components/SettingsInput/index.js @@ -0,0 +1,47 @@ +import React from 'react'; +import { useTheme } from 'providers/Theme'; + +const SettingsInput = ({ + id, + label, + value, + onChange, + className = '', + description = '', + onKeyDown +}) => { + const { theme } = useTheme(); + + return ( +
+
+ + {description && ( +

+ {description} +

+ )} +
+ +
+ ); +}; + +export default SettingsInput; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 14d63a555..cdaa7929a 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -617,6 +617,9 @@ export const collectionsSlice = createSlice({ if (item && item.draft) { item.request = item.draft.request; + if (item.draft.settings) { + item.settings = item.draft.settings; + } item.draft = null; } } diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index 8b4c77a02..95f4787ab 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -346,14 +346,24 @@ const runSingleRequest = async function ( } } - let requestMaxRedirects = request.maxRedirects - request.maxRedirects = 0 - - // Set default value for requestMaxRedirects if not explicitly set - if (requestMaxRedirects === undefined) { + // Get followRedirects setting, default to true for backward compatibility + const followRedirects = request.settings?.followRedirects ?? true; + + // Get maxRedirects from request settings, fallback to request.maxRedirects, then default to 5 + let requestMaxRedirects = request.settings?.maxRedirects ?? request.maxRedirects ?? 5; + + // Ensure it's a valid number + if (typeof requestMaxRedirects !== 'number' || requestMaxRedirects < 0) { requestMaxRedirects = 5; // Default to 5 redirects } + // If followRedirects is disabled, set maxRedirects to 0 to disable all redirects + if (!followRedirects) { + requestMaxRedirects = 0; + } + + request.maxRedirects = 0; + // Handle OAuth2 authentication if (request.oauth2) { try { @@ -384,12 +394,22 @@ const runSingleRequest = async function ( let response, responseTime; try { - let axiosInstance = makeAxiosInstance({ requestMaxRedirects: requestMaxRedirects, disableCookies: options.disableCookies }); + // Set timeout from request settings, default to 0 (no timeout) + const requestTimeout = request.settings?.timeout || 0; + if (requestTimeout > 0) { + request.timeout = requestTimeout; + } + + let axiosInstance = makeAxiosInstance({ + requestMaxRedirects: requestMaxRedirects, + disableCookies: options.disableCookies + }); + if (request.ntlmConfig) { axiosInstance=NtlmClient(request.ntlmConfig,axiosInstance.defaults) delete request.ntlmConfig; } - + if (request.awsv4config) { // todo: make this happen in prepare-request.js diff --git a/packages/bruno-converters/src/postman/postman-to-bruno.js b/packages/bruno-converters/src/postman/postman-to-bruno.js index b40315854..e9c4f421f 100644 --- a/packages/bruno-converters/src/postman/postman-to-bruno.js +++ b/packages/bruno-converters/src/postman/postman-to-bruno.js @@ -387,6 +387,16 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false } encodeUrl: i.protocolProfileBehavior?.disableUrlEncoding !== true } + // Handle followRedirects setting + if (i.protocolProfileBehavior?.followRedirects !== undefined) { + settings.followRedirects = i.protocolProfileBehavior.followRedirects; + } + + // Handle maxRedirects setting + if (i.protocolProfileBehavior?.maxRedirects !== undefined) { + settings.maxRedirects = i.protocolProfileBehavior.maxRedirects; + } + brunoRequestItem.settings = settings; brunoParent.items.push(brunoRequestItem); diff --git a/packages/bruno-converters/tests/postman/postman-to-bruno/postman-to-bruno.spec.js b/packages/bruno-converters/tests/postman/postman-to-bruno/postman-to-bruno.spec.js index 4e1084ec1..faef24465 100644 --- a/packages/bruno-converters/tests/postman/postman-to-bruno/postman-to-bruno.spec.js +++ b/packages/bruno-converters/tests/postman/postman-to-bruno/postman-to-bruno.spec.js @@ -74,6 +74,88 @@ describe('postman-collection', () => { expect(brunoCollection.root.request.vars.req).toEqual([]); }); + it('should correctly import protocolProfileBehavior settings from Postman requests', async () => { + const collectionWithSettings = { + info: { + _postman_id: 'test-settings-id', + name: 'Collection with Settings', + schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json' + }, + item: [ + { + name: 'Request with all settings', + protocolProfileBehavior: { + maxRedirects: 10, + followRedirects: false, + disableUrlEncoding: true + }, + request: { + method: 'GET', + header: [], + url: { + raw: 'https://httpbin.org/get', + protocol: 'https', + host: ['httpbin', 'org'], + path: ['get'] + } + } + }, + { + name: 'Request with partial settings', + protocolProfileBehavior: { + followRedirects: true + }, + request: { + method: 'POST', + header: [], + url: { + raw: 'https://httpbin.org/post', + protocol: 'https', + host: ['httpbin', 'org'], + path: ['post'] + } + } + }, + { + name: 'Request without settings', + request: { + method: 'PUT', + header: [], + url: { + raw: 'https://httpbin.org/put', + protocol: 'https', + host: ['httpbin', 'org'], + path: ['put'] + } + } + } + ] + }; + + const brunoCollection = await postmanToBruno(collectionWithSettings); + + // Test request with all settings + const requestWithAllSettings = brunoCollection.items[0]; + expect(requestWithAllSettings.settings).toEqual({ + encodeUrl: false, + followRedirects: false, + maxRedirects: 10 + }); + + // Test request with partial settings + const requestWithPartialSettings = brunoCollection.items[1]; + expect(requestWithPartialSettings.settings).toEqual({ + encodeUrl: true, + followRedirects: true + }); + + // Test request without settings + const requestWithoutSettings = brunoCollection.items[2]; + expect(requestWithoutSettings.settings).toEqual({ + encodeUrl: true + }); + }); + it('should handle collection with auth object having undefined type', async () => { const collectionWithUndefinedAuthType = { 'info': { diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 8c090baf1..f40ed7dc2 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -18,6 +18,7 @@ const prepareGqlIntrospectionRequest = require('./prepare-gql-introspection-requ const { prepareRequest } = require('./prepare-request'); const interpolateVars = require('./interpolate-vars'); const { makeAxiosInstance } = require('./axios-instance'); +const { resolveInheritedSettings } = require('../../utils/collection'); const { cancelTokens, saveCancelToken, deleteCancelToken } = require('../../utils/cancel-token'); const { uuid, safeStringifyJSON, safeParseJSON, parseDataFromResponse, parseDataFromRequest } = require('../../utils/common'); const { chooseFileToSave, writeBinaryFile, writeFile } = require('../../utils/filesystem'); @@ -89,14 +90,24 @@ const configureRequest = async ( collectionPath }); - let requestMaxRedirects = request.maxRedirects - request.maxRedirects = 0 + // Get followRedirects setting, default to true for backward compatibility + const followRedirects = request.settings?.followRedirects ?? true; - // Set default value for requestMaxRedirects if not explicitly set - if (requestMaxRedirects === undefined) { + // Get maxRedirects from request settings, fallback to request.maxRedirects, then default to 5 + let requestMaxRedirects = request.settings?.maxRedirects ?? request.maxRedirects ?? 5; + + // Ensure it's a valid number + if (typeof requestMaxRedirects !== 'number' || requestMaxRedirects < 0) { requestMaxRedirects = 5; // Default to 5 redirects } + // If followRedirects is disabled, set maxRedirects to 0 to disable all redirects + if (!followRedirects) { + requestMaxRedirects = 0; + } + + request.maxRedirects = 0; + let { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig; let axiosInstance = makeAxiosInstance({ proxyMode, @@ -193,7 +204,9 @@ const configureRequest = async ( addDigestInterceptor(axiosInstance, request); } - request.timeout = preferencesUtil.getRequestTimeout(); + // Get timeout from request settings, fallback to global preference + const resolvedSettings = resolveInheritedSettings(request.settings || {}); + request.timeout = resolvedSettings.timeout; // add cookies to request if (preferencesUtil.shouldSendCookies()) { @@ -276,7 +289,9 @@ const fetchGqlSchemaHandler = async (event, endpoint, environment, _request, col const collectionRoot = get(collection, 'root', {}); const request = prepareGqlIntrospectionRequest(endpoint, resolvedVars, _request, collectionRoot); - request.timeout = preferencesUtil.getRequestTimeout(); + // Get timeout from request settings, resolve inheritance if needed + const resolvedSettings = resolveInheritedSettings(request.settings || {}); + request.timeout = resolvedSettings.timeout; if (!preferencesUtil.shouldVerifyTls()) { request.httpsAgent = new https.Agent({ diff --git a/packages/bruno-electron/src/utils/collection.js b/packages/bruno-electron/src/utils/collection.js index 4ae8e307f..4d0fb5fac 100644 --- a/packages/bruno-electron/src/utils/collection.js +++ b/packages/bruno-electron/src/utils/collection.js @@ -3,6 +3,7 @@ const fs = require('fs'); const { getRequestUid } = require('../cache/requestUids'); const { uuid } = require('./common'); const os = require('os'); +const { preferencesUtil } = require('../store/preferences'); const mergeHeaders = (collection, request, requestTreePath) => { let headers = new Map(); @@ -523,6 +524,32 @@ const mergeAuth = (collection, request, requestTreePath) => { } }; +const resolveInheritedSettings = (settings) => { + const resolvedSettings = {}; + + // Resolve each setting individually + Object.keys(settings).forEach((settingKey) => { + const currentValue = settings[settingKey]; + + // If setting is inherited, fallback to preferences only for timeout setting + if (currentValue === 'inherit' || currentValue === undefined || currentValue === null) { + if (settingKey === 'timeout') { + resolvedSettings[settingKey] = preferencesUtil.getRequestTimeout(); + } + } else { + // Use the current value as-is + resolvedSettings[settingKey] = currentValue; + } + }); + + // Handle missing timeout setting - if timeout is not in settings, treat it as inherited + if (!settings.hasOwnProperty('timeout')) { + resolvedSettings.timeout = preferencesUtil.getRequestTimeout(); + } + + return resolvedSettings; +}; + const sortByNameThenSequence = items => { const isSeqValid = seq => Number.isFinite(seq) && Number.isInteger(seq) && seq > 0; @@ -585,5 +612,6 @@ module.exports = { getAllRequestsInFolderRecursively, getEnvVars, getFormattedCollectionOauth2Credentials, - sortByNameThenSequence + sortByNameThenSequence, + resolveInheritedSettings }; \ No newline at end of file diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/v2/src/bruToJson.js index 7652f003d..e80daa170 100644 --- a/packages/bruno-lang/v2/src/bruToJson.js +++ b/packages/bruno-lang/v2/src/bruToJson.js @@ -427,20 +427,48 @@ const sem = grammar.createSemantics().addAttribute('ast', { const keepAliveInterval = getNumFromRecord('keepAliveInterval'); - const timeout = getNumFromRecord('timeout'); + const parsedSettings = {}; + if (settings.followRedirects !== undefined) { + parsedSettings.followRedirects = typeof settings.followRedirects === 'boolean' ? settings.followRedirects : settings.followRedirects === 'true'; + } + + // Parse maxRedirects as number + if (settings.maxRedirects !== undefined) { + const maxRedirects = parseInt(settings.maxRedirects, 10); + if (!isNaN(maxRedirects)) { + parsedSettings.maxRedirects = maxRedirects; + } + } + + // Parse timeout as number or inherit + if (settings.timeout !== undefined) { + if (settings.timeout === 'inherit') { + parsedSettings.timeout = 'inherit'; + } else { + const timeout = parseInt(settings.timeout, 10); + if (!isNaN(timeout)) { + parsedSettings.timeout = timeout; + } + } + } const _settings = { - encodeUrl: typeof settings.encodeUrl === 'boolean' ? settings.encodeUrl : settings.encodeUrl === 'true' + encodeUrl: typeof settings.encodeUrl === 'boolean' ? settings.encodeUrl : settings.encodeUrl === 'true', + timeout: parsedSettings.timeout !== undefined ? parsedSettings.timeout : 0 }; + if (parsedSettings.followRedirects !== undefined) { + _settings.followRedirects = parsedSettings.followRedirects; + } + + if (parsedSettings.maxRedirects !== undefined) { + _settings.maxRedirects = parsedSettings.maxRedirects; + } + if (keepAliveInterval) { _settings.keepAliveInterval = keepAliveInterval; } - if (timeout) { - _settings.timeout = timeout; - } - return { settings: _settings }; diff --git a/packages/bruno-lang/v2/tests/settings/fixtures/settings-all-options.bru b/packages/bruno-lang/v2/tests/settings/fixtures/settings-all-options.bru new file mode 100644 index 000000000..df01c1f47 --- /dev/null +++ b/packages/bruno-lang/v2/tests/settings/fixtures/settings-all-options.bru @@ -0,0 +1,26 @@ +meta { + name: Settings All Options Test + type: http + seq: 3 +} + +put { + url: https://api.example.com/all-options +} + +headers { + content-type: application/json +} + +body:json { + { + "test": "data" + } +} + +settings { + encodeUrl: true + followRedirects: false + maxRedirects: 0 + timeout: 60000 +} diff --git a/packages/bruno-lang/v2/tests/settings/fixtures/settings-all-options.json b/packages/bruno-lang/v2/tests/settings/fixtures/settings-all-options.json new file mode 100644 index 000000000..cd81b4c21 --- /dev/null +++ b/packages/bruno-lang/v2/tests/settings/fixtures/settings-all-options.json @@ -0,0 +1,27 @@ +{ + "meta": { + "name": "Settings All Options Test", + "type": "http", + "seq": "3" + }, + "http": { + "method": "put", + "url": "https://api.example.com/all-options" + }, + "headers": [ + { + "name": "content-type", + "value": "application/json", + "enabled": true + } + ], + "body": { + "json": "{\n \"test\": \"data\"\n}" + }, + "settings": { + "encodeUrl": true, + "followRedirects": false, + "maxRedirects": 0, + "timeout": 60000 + } +} diff --git a/packages/bruno-lang/v2/tests/settings/fixtures/settings-minimal.bru b/packages/bruno-lang/v2/tests/settings/fixtures/settings-minimal.bru new file mode 100644 index 000000000..c9daf67dd --- /dev/null +++ b/packages/bruno-lang/v2/tests/settings/fixtures/settings-minimal.bru @@ -0,0 +1,14 @@ +meta { + name: Settings Minimal Test + type: http + seq: 2 +} + +post { + url: https://api.example.com/minimal +} + +settings { + encodeUrl: false + timeout: 5000 +} diff --git a/packages/bruno-lang/v2/tests/settings/fixtures/settings-minimal.json b/packages/bruno-lang/v2/tests/settings/fixtures/settings-minimal.json new file mode 100644 index 000000000..7a88e1be4 --- /dev/null +++ b/packages/bruno-lang/v2/tests/settings/fixtures/settings-minimal.json @@ -0,0 +1,15 @@ +{ + "meta": { + "name": "Settings Minimal Test", + "type": "http", + "seq": "2" + }, + "http": { + "method": "post", + "url": "https://api.example.com/minimal" + }, + "settings": { + "encodeUrl": false, + "timeout": 5000 + } +} diff --git a/packages/bruno-lang/v2/tests/settings/settings.spec.js b/packages/bruno-lang/v2/tests/settings/settings.spec.js new file mode 100644 index 000000000..5a24471b7 --- /dev/null +++ b/packages/bruno-lang/v2/tests/settings/settings.spec.js @@ -0,0 +1,58 @@ +const fs = require('fs'); +const path = require('path'); +const bruToJson = require('../../src/bruToJson'); +const jsonToBru = require('../../src/jsonToBru'); + +describe('Settings Conversion Tests', () => { + const fixturesDir = path.join(__dirname, 'fixtures'); + + describe('parse (BRU to JSON)', () => { + it('should parse minimal settings from BRU to JSON', () => { + const input = fs.readFileSync(path.join(fixturesDir, 'settings-minimal.bru'), 'utf8'); + const expected = require(path.join(fixturesDir, 'settings-minimal.json')); + const output = bruToJson(input); + + expect(output).toEqual(expected); + }); + + it('should parse all settings options from BRU to JSON', () => { + const input = fs.readFileSync(path.join(fixturesDir, 'settings-all-options.bru'), 'utf8'); + const expected = require(path.join(fixturesDir, 'settings-all-options.json')); + const output = bruToJson(input); + + expect(output).toEqual(expected); + }); + }); + + describe('stringify (JSON to BRU)', () => { + it('should stringify minimal settings from JSON to BRU (with defaults)', () => { + const input = require(path.join(fixturesDir, 'settings-minimal.json')); + const expected = fs.readFileSync(path.join(fixturesDir, 'settings-minimal.bru'), 'utf8'); + const output = jsonToBru(input); + + expect(output).toEqual(expected); + }); + + it('should stringify all settings options from JSON to BRU', () => { + const input = require(path.join(fixturesDir, 'settings-all-options.json')); + const expected = fs.readFileSync(path.join(fixturesDir, 'settings-all-options.bru'), 'utf8'); + const output = jsonToBru(input); + + expect(output).toEqual(expected); + }); + }); + + describe('round-trip conversion', () => { + it('should maintain data integrity through JSON -> BRU -> JSON conversion', () => { + const originalJson = require(path.join(fixturesDir, 'settings-all-options.json')); + + // Convert JSON to BRU + const bru = jsonToBru(originalJson); + + // Convert BRU back to JSON + const convertedJson = bruToJson(bru); + + expect(convertedJson).toEqual(originalJson); + }); + }); +}); diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js index 1b0b82fd0..10852a3da 100644 --- a/packages/bruno-schema/src/collections/index.js +++ b/packages/bruno-schema/src/collections/index.js @@ -490,7 +490,10 @@ const itemSchema = Yup.object({ is: (type) => type === 'ws-request', then: wsSettingsSchema, otherwise: Yup.object({ - encodeUrl: Yup.boolean().nullable() + encodeUrl: Yup.boolean().nullable(), + followRedirects: Yup.boolean().nullable(), + maxRedirects: Yup.number().min(0).max(50).nullable(), + timeout: Yup.mixed().nullable(), }).noUnknown(true) .strict() .nullable() diff --git a/packages/bruno-tests/collection/request-setting/folder.bru b/packages/bruno-tests/collection/request-setting/folder.bru new file mode 100644 index 000000000..5a6cf4d55 --- /dev/null +++ b/packages/bruno-tests/collection/request-setting/folder.bru @@ -0,0 +1,8 @@ +meta { + name: request-setting + seq: 14 +} + +auth { + mode: inherit +} diff --git a/packages/bruno-tests/collection/request-setting/follow-redirect.bru b/packages/bruno-tests/collection/request-setting/follow-redirect.bru new file mode 100644 index 000000000..065120554 --- /dev/null +++ b/packages/bruno-tests/collection/request-setting/follow-redirect.bru @@ -0,0 +1,23 @@ +meta { + name: follow-redirect + type: http + seq: 1 +} + +get { + url: https://httpbun.com/redirect/3 + body: none + auth: inherit +} + +script:post-response { + test("body should include redirecting", function() { + const data = res.getBody(); + expect(data).to.include("Redirecting..."); + }); +} + +settings { + encodeUrl: true + followRedirects: false +} diff --git a/packages/bruno-tests/collection/request-setting/max-redirect.bru b/packages/bruno-tests/collection/request-setting/max-redirect.bru new file mode 100644 index 000000000..20bcb5678 --- /dev/null +++ b/packages/bruno-tests/collection/request-setting/max-redirect.bru @@ -0,0 +1,24 @@ +meta { + name: max-redirect + type: http + seq: 2 +} + +get { + url: https://httpbun.com/redirect/3 + body: none + auth: inherit +} + +script:post-response { + test("body should include redirecting", function() { + const data = res.status; + expect(data).to.be.equal(200) + }); +} + +settings { + encodeUrl: true + followRedirects: true + maxRedirects: 5 +} diff --git a/tests/request/settings/collection/bruno.json b/tests/request/settings/collection/bruno.json new file mode 100644 index 000000000..abe6c3cbf --- /dev/null +++ b/tests/request/settings/collection/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "settings-test", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/tests/request/settings/collection/max-redirects.bru b/tests/request/settings/collection/max-redirects.bru new file mode 100644 index 000000000..a041ff519 --- /dev/null +++ b/tests/request/settings/collection/max-redirects.bru @@ -0,0 +1,17 @@ +meta { + name: max-redirects-test + type: http + seq: 1 +} + +get { + url: https://httpbun.com/redirect/2 + body: none + auth: inherit +} + +settings { + followRedirects: true + maxRedirects: 1 + timeout: 0 +} diff --git a/tests/request/settings/collection/no-redirects.bru b/tests/request/settings/collection/no-redirects.bru new file mode 100644 index 000000000..b5c824d43 --- /dev/null +++ b/tests/request/settings/collection/no-redirects.bru @@ -0,0 +1,17 @@ +meta { + name: no-redirects-test + type: http + seq: 2 +} + +get { + url: https://httpbun.com/redirect/2 + body: none + auth: inherit +} + +settings { + followRedirects: false + maxRedirects: 5 + timeout: 0 +} diff --git a/tests/request/settings/collection/timeout.bru b/tests/request/settings/collection/timeout.bru new file mode 100644 index 000000000..ee40d4cae --- /dev/null +++ b/tests/request/settings/collection/timeout.bru @@ -0,0 +1,17 @@ +meta { + name: timeout-test + type: http + seq: 3 +} + +get { + url: https://httpbun.com/redirect/2 + body: none + auth: inherit +} + +settings { + followRedirects: false + maxRedirects: 0 + timeout: 5 +} diff --git a/tests/request/settings/init-user-data/collection-security.json b/tests/request/settings/init-user-data/collection-security.json new file mode 100644 index 000000000..f60c658a5 --- /dev/null +++ b/tests/request/settings/init-user-data/collection-security.json @@ -0,0 +1,10 @@ +{ + "collections": [ + { + "path": "{{projectRoot}}/tests/request/settings/collection", + "securityConfig": { + "jsSandboxMode": "safe" + } + } + ] +} \ No newline at end of file diff --git a/tests/request/settings/init-user-data/preferences.json b/tests/request/settings/init-user-data/preferences.json new file mode 100644 index 000000000..f59ee0971 --- /dev/null +++ b/tests/request/settings/init-user-data/preferences.json @@ -0,0 +1,6 @@ +{ + "maximized": true, + "lastOpenedCollections": [ + "{{projectRoot}}/tests/request/settings/collection" + ] +} \ No newline at end of file diff --git a/tests/request/settings/max-redirects.spec.ts b/tests/request/settings/max-redirects.spec.ts new file mode 100644 index 000000000..22de00ad4 --- /dev/null +++ b/tests/request/settings/max-redirects.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from '../../../playwright'; + +test.describe('Max Redirects Settings Tests', () => { + test('should configure and test max redirects settings', async ({ + pageWithUserData: page + }) => { + // Navigate to the test collection and request + await expect(page.locator('#sidebar-collection-name').getByText('settings-test')).toBeVisible(); + + await page.locator('#sidebar-collection-name').getByText('settings-test').click(); + + // Navigate to the max-redirects request + await page.getByRole('complementary').getByText('max-redirects').click(); + + // Go to Settings tab + await page.getByRole('tab', { name: 'Settings' }).click(); + + // Test Max Redirects Settings + const maxRedirectsInput = page.locator('input[id="maxRedirects"]'); + await expect(maxRedirectsInput).toBeVisible(); + + // Verify default value from .bru file (1) + await expect(maxRedirectsInput).toHaveValue('1'); + + // Test Follow Redirects toggle + const followRedirectsToggle = page.getByTestId('follow-redirects-toggle'); + await expect(followRedirectsToggle).toBeVisible(); + await expect(followRedirectsToggle).toBeChecked(); + + // Send the request + await page.getByTestId('send-arrow-icon').click(); + + await expect(page.getByTestId('response-status-code')).toContainText('302', { timeout: 15000 }); + + // change the max redirects to 2 + await maxRedirectsInput.fill('2'); + await page.getByTestId('send-arrow-icon').click(); + await expect(page.getByTestId('response-status-code')).toContainText('200', { timeout: 15000 }); + }); + + test.afterEach(async ({ pageWithUserData: page }) => { + // Close the single open tab + await page.locator('.close-icon-container').click(); + await page.locator('button:has-text("Don\'t Save")').first().click(); + }); +}); diff --git a/tests/request/settings/no-redirects.spec.ts b/tests/request/settings/no-redirects.spec.ts new file mode 100644 index 000000000..c9e6d828f --- /dev/null +++ b/tests/request/settings/no-redirects.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from '../../../playwright'; + +test.describe('No Redirects Settings Tests', () => { + test('should configure and test no redirects settings', async ({ + pageWithUserData: page + }) => { + // Navigate to the test collection and request + await expect(page.locator('#sidebar-collection-name').getByText('settings-test')).toBeVisible(); + + await page.locator('#sidebar-collection-name').getByText('settings-test').click(); + + // Navigate to the no-redirects request + await page.getByRole('complementary').getByText('no-redirects').click(); + + // Go to Settings tab + await page.getByRole('tab', { name: 'Settings' }).click(); + + // Test No Redirects Settings + const maxRedirectsInput = page.locator('input[id="maxRedirects"]'); + await expect(maxRedirectsInput).toBeVisible(); + + // Verify default value from .bru file (5) + await expect(maxRedirectsInput).toHaveValue('5'); + + // Test Follow Redirects toggle - should be unchecked + const followRedirectsToggle = page.getByTestId('follow-redirects-toggle'); + await expect(followRedirectsToggle).toBeVisible(); + await expect(followRedirectsToggle).not.toBeChecked(); + + // Send the request - should stop at first redirect (302) without following + await page.getByTestId('send-arrow-icon').click(); + + // Should get 302 because redirects are disabled, regardless of maxRedirects value + await expect(page.getByTestId('response-status-code')).toContainText('302', { timeout: 15000 }); + + // Toggle follow redirects to true + await followRedirectsToggle.click(); + await expect(followRedirectsToggle).toBeChecked(); + + // Send request again - now should follow redirects and get 200 + await page.getByTestId('send-arrow-icon').click(); + await expect(page.getByTestId('response-status-code')).toContainText('200', { timeout: 15000 }); + }); + + test.afterEach(async ({ pageWithUserData: page }) => { + // Close the single open tab + await page.locator('.close-icon-container').click(); + await page.locator('button:has-text("Don\'t Save")').first().click(); + }); +}); diff --git a/tests/request/settings/timeout.spec.ts b/tests/request/settings/timeout.spec.ts new file mode 100644 index 000000000..8c54d2d2d --- /dev/null +++ b/tests/request/settings/timeout.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from '../../../playwright'; +import { closeAllCollections } from '../../utils/page'; + +test.describe('Timeout Settings Tests', () => { + test('should configure and test timeout settings', async ({ + pageWithUserData: page + }) => { + // Navigate to the test collection and request + await expect(page.locator('#sidebar-collection-name').getByText('settings-test')).toBeVisible(); + + await page.locator('#sidebar-collection-name').getByText('settings-test').click(); + // Navigate to thetimeout request + await page.getByRole('complementary').getByText('timeout-test').click(); + + // Go to Settings tab + await page.getByRole('tab', { name: 'Settings' }).click(); + + // Test Timeout Settings + const timeoutInput = page.locator('input[id="timeout"]'); + await expect(timeoutInput).toBeVisible(); + + // Verify default value from .bru file (5) + await expect(timeoutInput).toHaveValue('5'); + + await page.getByTestId('send-arrow-icon').click(); + + const responsePane = page.locator('.response-pane'); + await expect(responsePane).toContainText('timeout of 5ms exceeded'); + + // go to welcome page + await page.locator('.bruno-logo').click(); + }); + + test.afterEach(async ({ pageWithUserData: page }) => { + // cleanup: close all collections + await closeAllCollections(page); + }); +});