feat: add redirect and timeout in request settings (#5672)

* feat: add redirect and timeout in request settings
This commit is contained in:
Pooja
2025-10-08 20:00:37 +05:30
committed by GitHub
parent ce40949564
commit 0c30357b01
29 changed files with 898 additions and 56 deletions

View File

@@ -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 (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<label className="text-xs font-medium text-gray-900 dark:text-gray-100" htmlFor={id}>
{label}
</label>
{description && (
<p className="text-xs text-gray-700 dark:text-gray-400">
{description}
</p>
)}
</div>
<div className="flex items-center justify-end">
{isInherited ? (
<Dropdown
icon={(
<button
type="button"
className="px-2 py-1 text-xs rounded-sm outline-none transition-colors duration-100 w-24 h-8 flex items-center justify-between"
style={{
backgroundColor: theme.modal.input.bg,
border: `1px solid ${theme.modal.input.border}`,
color: theme.modal.input.text
}}
>
<span>Inherit</span>
<IconChevronDown size={12} />
</button>
)}
>
<div className="dropdown-item" onClick={() => onDropdownSelect('inherit')}>
Inherit
</div>
<div className="dropdown-item" onClick={() => onDropdownSelect('custom')}>
Custom
</div>
</Dropdown>
) : (
<div className="relative">
<input
id={id}
type="text"
className="block px-2 py-1 pr-6 rounded-sm outline-none transition-colors duration-100 w-24 h-8"
style={{
backgroundColor: theme.modal.input.bg,
border: `1px solid ${theme.modal.input.border}`,
color: theme.modal.input.text
}}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={value}
onChange={onValueChange}
onKeyDown={onKeyDown}
/>
<button
type="button"
onClick={onCustomValueReset}
className="absolute right-1 top-1/2 transform -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
title="Reset to inherit"
>
<IconX size={14} />
</button>
</div>
)}
</div>
</div>
);
};
export default InheritableSettingsInput;

View File

@@ -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 (
<div className="flex items-center gap-3">
<div className="flex items-center justify-between">
<div className="flex flex-col">
<label className="text-xs font-medium text-gray-900 dark:text-gray-100">
{label}
</label>
{description && (
<p className="text-xs text-gray-700 dark:text-gray-400">
{description}
</p>
)}
</div>
<button
type="button"
onClick={onChange}
disabled={disabled}
data-testid={dataTestId}
className={`
relative inline-flex ${currentSize.container} mx-1 items-center rounded-full transition-colors
relative inline-flex ${currentSize.container} flex-shrink-0 items-center rounded-full transition-colors
focus:outline-none focus:ring-1 focus:ring-offset-1
${disabled
? 'opacity-50 cursor-not-allowed'
@@ -57,16 +69,6 @@ const ToggleSelector = ({
`}
/>
</button>
<div className="flex flex-col">
<label className="text-xs font-medium text-gray-900 dark:text-gray-100">
{label}
</label>
{description && (
<p className="text-xs text-gray-700 dark:text-gray-400">
{description}
</p>
)}
</div>
</div>
);
};

View File

@@ -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 (
<div className="w-full h-full flex flex-col gap-10">
<div className='flex flex-col gap-2 max-w-[400px]'>
<h3 className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1">
<IconTag size={16} />
Tags
</h3>
<div label="Tags">
<div className="h-full w-full">
<div className="text-xs mb-4 text-muted">Configure request settings for this item.</div>
<div className="bruno-form">
<div className="mb-6">
<h3 className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1 mb-4">
<IconTag size={16} />
Tags
</h3>
<Tags item={item} collection={collection} />
</div>
</div>
<div className='flex flex-col gap-4'>
<ToggleSelector
checked={encodeUrl}
onChange={onToggleUrlEncoding}
label="URL Encoding"
description="Automatically encode query parameters in the URL"
size="medium"
/>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4">
<ToggleSelector
checked={encodeUrl}
onChange={onToggleUrlEncoding}
label="URL Encoding"
description="Automatically encode query parameters in the URL"
size="medium"
/>
</div>
<div className="flex flex-col gap-4">
<ToggleSelector
checked={followRedirects}
onChange={onToggleFollowRedirects}
label="Automatically Follow Redirects"
description="Follow HTTP redirects automatically"
size="medium"
data-testid="follow-redirects-toggle"
/>
</div>
<SettingsInput
id="maxRedirects"
label="Max Redirects"
value={maxRedirects}
onChange={onMaxRedirectsChange}
description="Set a limit for the number of redirects to follow"
onKeyDown={handleKeyDown}
/>
<InheritableSettingsInput
id="timeout"
label="Timeout (ms)"
value={timeout}
description="Set maximum time to wait before aborting the request"
onKeyDown={handleKeyDown}
isInherited={isTimeoutInherited}
onDropdownSelect={handleTimeoutDropdownSelect}
onValueChange={(e) => !isTimeoutInherited && onTimeoutChange(e)}
onCustomValueReset={() => onTimeoutChange({ target: { value: 'inherit' } })}
/>
</div>
</div>
</div>
);

View File

@@ -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 (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<label className="text-xs font-medium text-gray-900 dark:text-gray-100" htmlFor={id}>
{label}
</label>
{description && (
<p className="text-xs text-gray-700 dark:text-gray-400">
{description}
</p>
)}
</div>
<input
id={id}
type="text"
className={`block px-2 py-1 rounded-sm outline-none transition-colors duration-100 w-24 h-8 ${className}`}
style={{
backgroundColor: theme.modal.input.bg,
border: `1px solid ${theme.modal.input.border}`
}}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
/>
</div>
);
};
export default SettingsInput;

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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);

View File

@@ -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': {

View File

@@ -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({

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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);
});
});
});

View File

@@ -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()

View File

@@ -0,0 +1,8 @@
meta {
name: request-setting
seq: 14
}
auth {
mode: inherit
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,9 @@
{
"version": "1",
"name": "settings-test",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,10 @@
{
"collections": [
{
"path": "{{projectRoot}}/tests/request/settings/collection",
"securityConfig": {
"jsSandboxMode": "safe"
}
}
]
}

View File

@@ -0,0 +1,6 @@
{
"maximized": true,
"lastOpenedCollections": [
"{{projectRoot}}/tests/request/settings/collection"
]
}

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});