fix(generate-code)!: generate code URL issues (#8136)

This commit is contained in:
shubh-bruno
2026-06-09 12:54:24 +05:30
committed by GitHub
parent 95c75c90c1
commit 6f47218a81
60 changed files with 4679 additions and 350 deletions

View File

@@ -1,13 +1,13 @@
import { buildHarRequest } from 'utils/codegenerator/har';
import { getAuthHeaders } from 'utils/codegenerator/auth';
import { buildHar } from '@usebruno/common';
import { stripOrigin } from '@usebruno/common/utils';
import { getAllVariables, getTreePathFromCollectionToItem, mergeHeaders } from 'utils/collections/index';
import { resolveInheritedAuth } from 'utils/auth';
import { get } from 'lodash';
import { interpolateAuth, interpolateHeaders, interpolateBody, interpolateParams } from './interpolation';
import { encodeUrl as encodeUrlCommon, stripOrigin } from '@usebruno/common/utils';
import { parse } from 'url';
import { stringify } from 'query-string';
// curl --digest / --ntlm are surface-level snippet adjustments, not part of
// the HAR contract — keep them at this layer.
const addCurlAuthFlags = (curlCommand, auth) => {
if (!auth || !curlCommand) return curlCommand;
@@ -18,7 +18,6 @@ const addCurlAuthFlags = (curlCommand, auth) => {
const password = get(auth, `${authMode}.password`, '');
const credentials = password ? `${username}:${password}` : username;
const authFlag = authMode === 'digest' ? '--digest' : '--ntlm';
// Escape single quotes for shell safety: ' becomes '\''
const escapedCredentials = credentials.replace(/'/g, `'\\''`);
const curlMatch = curlCommand.match(/^(curl(?:\.exe)?)/i);
@@ -46,48 +45,47 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false
effectiveAuth = resolvedRequest.auth;
}
// Get the request tree path and merge headers
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
let headers = mergeHeaders(collection, request, requestTreePath);
const mergedHeaders = mergeHeaders(collection, request, requestTreePath);
// Add auth headers if needed (auth inheritance is resolved upstream)
if (request.auth && request.auth.mode !== 'none') {
if (shouldInterpolate) {
request.auth = interpolateAuth(request.auth, variables);
}
const settings = item.draft ? get(item, 'draft.settings') : get(item, 'settings');
const authHeaders = getAuthHeaders(request.auth, collection, item);
headers = [...headers, ...authHeaders];
}
// Interpolate headers, body and params if needed
if (shouldInterpolate) {
headers = interpolateHeaders(headers, variables);
request.body = interpolateBody(request.body, variables);
request.params = interpolateParams(request.params, variables);
}
// Build HAR request
const harRequest = buildHarRequest({
request,
headers
const sourceUrl = item.rawUrl || request.url;
const { har, rawUrl, encodedUrl, unhash } = buildHar({
request: {
method: request.method,
url: sourceUrl,
params: request.params,
pathParams: [],
headers: mergedHeaders,
body: request.body,
auth: effectiveAuth,
settings
},
variables,
shouldInterpolate,
oauth2Credentials: collection?.oauth2Credentials,
collectionUid: collection?.uid
});
// Generate snippet using HTTPSnippet
const snippet = new HTTPSnippet(harRequest);
const snippet = new HTTPSnippet(har);
let result = snippet.convert(language.target, language.client);
// For curl target, add special auth flags for digest/ntlm
// curl --digest / --ntlm flags. Snippet-text manipulation, not HAR.
if (language.target === 'shell' && language.client === 'curl') {
result = addCurlAuthFlags(result, effectiveAuth);
}
// Respect encodeUrl setting: when not explicitly true, replace HTTPSnippet's encoded path+query with the raw version.
// Replacing the path portion works for all targets since it's a substring of the full URL.
// encodeUrl defaults to false in the UI when undefined/null
const settings = item.draft ? get(item, 'draft.settings') : get(item, 'settings');
const rawUrl = item.rawUrl || request.url;
const parsed = parse(request.url, true, true);
/**
*
* Display-swap. HTTPSnippet renders the URL in encoded form (using har.queryString as the source of truth).
* For OFF mode we want the user's raw bytes visible in the snippet — swap the encoded path+query substring for the raw form.
* For OFF: prefer item.rawUrl when the caller explicitly supplied it (legacy GenerateCodeItem pipeline does this so user-typed pre-encoded
* bytes survive the WHATWG-URL normalization). Otherwise fall back to buildHar's rawUrl.
*/
const displayRawUrl = item.rawUrl || rawUrl;
const parsed = parse(encodedUrl, true, true);
const search = stringify(parsed.query, { sort: false });
const httpSnippetPath = search ? `${parsed.pathname}?${search}` : parsed.pathname;
@@ -95,16 +93,11 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false
if (settings?.encodeUrl === true) {
// Apply the same encodeUrl() transform used by the actual request execution path
// so the snippet matches what's sent on the wire.
const encodedUrl = encodeUrlCommon(rawUrl);
desiredPath = stripOrigin(encodedUrl);
// Strip fragment per RFC 3986 §3.5
desiredPath = desiredPath.replace(/#.*$/, '');
} else {
desiredPath = stripOrigin(rawUrl);
// The HTTP raw target (http/http1.1) uses the request line format:
// METHOD <request-target> HTTP-version
// Spaces delimit these fields, so a literal space in the request-target
// would be parsed as the end of the URI (RFC 7230 §3.1.1).
desiredPath = stripOrigin(displayRawUrl);
// HTTP raw target uses spaces as delimiters in the request line
// (RFC 7230 §3.1.1), so a literal space would terminate the URI early.
if (language.target === 'http') {
desiredPath = desiredPath.replace(/ /g, '%20');
}
@@ -114,7 +107,8 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false
result = result.replaceAll(httpSnippetPath, desiredPath);
}
return result;
// Restore `{{var}}` placeholders that buildHar hashed during processing.
return unhash(result);
} catch (error) {
console.error('Error generating code snippet:', error);
return 'Error generating code snippet';

View File

@@ -1,11 +1,22 @@
import { getAuthHeaders } from 'utils/codegenerator/auth';
// Helper: reconstruct the URL HTTPSnippet would render from buildHar's HAR.
// buildHar strips the URL's query (the bracket-key phantom-duplicate fix), so
// the visible URL is `har.url` + `har.queryString` re-encoded by encodeURIComponent.
const reconstructUrlForMock = (harRequest) => {
const baseUrl = harRequest?.url || 'http://example.com';
const queryString = harRequest?.queryString || [];
if (!queryString.length) return baseUrl;
const search = queryString
.map((p) => `${encodeURIComponent(p.name)}=${encodeURIComponent(p.value ?? '')}`)
.join('&');
return `${baseUrl}?${search}`;
};
jest.mock('httpsnippet', () => {
return {
HTTPSnippet: jest.fn().mockImplementation((harRequest) => ({
convert: jest.fn(() => {
const method = harRequest?.method || 'GET';
const url = harRequest?.url || 'http://example.com';
const url = reconstructUrlForMock(harRequest);
const hasBody = harRequest?.postData?.text;
if (method === 'POST' && hasBody) {
@@ -17,36 +28,6 @@ jest.mock('httpsnippet', () => {
};
});
jest.mock('utils/codegenerator/har', () => ({
buildHarRequest: jest.fn((data) => {
const request = data.request || {};
const method = request.method || 'GET';
const url = request.url || 'http://example.com';
const body = request.body || {};
const harRequest = {
method: method,
url: url,
headers: data.headers || [],
httpVersion: 'HTTP/1.1'
};
// Add body data for POST requests
if (method === 'POST' && body.mode === 'json' && body.json) {
harRequest.postData = {
mimeType: 'application/json',
text: body.json
};
}
return harRequest;
})
}));
jest.mock('utils/codegenerator/auth', () => ({
getAuthHeaders: jest.fn(() => [])
}));
jest.mock('utils/collections/index', () => {
const actual = jest.requireActual('utils/collections/index');
@@ -120,7 +101,7 @@ describe('Snippet Generator - Simple Tests', () => {
require('httpsnippet').HTTPSnippet = jest.fn().mockImplementation((harRequest) => ({
convert: jest.fn(() => {
const method = harRequest?.method || 'GET';
const url = harRequest?.url || 'http://example.com';
const url = reconstructUrlForMock(harRequest);
const hasBody = harRequest?.postData?.text;
if (method === 'POST' && hasBody) {
@@ -456,10 +437,10 @@ describe('Snippet Generator - Simple Tests', () => {
}
};
// buildHar handles `{{user}}` / `{{pass}}` interpolation via its
// internal interpolateRequest pipeline and emits the Basic auth header
// through authToHeaders. No mock override needed.
const { HTTPSnippet: mockedHTTPSnippet } = require('httpsnippet');
const { getAuthHeaders: actualGetAuthHeaders } = jest.requireActual('utils/codegenerator/auth');
getAuthHeaders.mockImplementation(actualGetAuthHeaders);
const language = { target: 'shell', client: 'curl' };
generateSnippet({
@@ -471,7 +452,7 @@ describe('Snippet Generator - Simple Tests', () => {
const harRequest = mockedHTTPSnippet.mock.calls[0][0];
// "admin:secret123" encoded is "YWRtaW46c2VjcmV0MTIz"
// "admin:secret123" encoded is "YWRtaW46c2VjcmV0MTIz". HAR headers are
expect(harRequest.headers).toContainEqual(
expect.objectContaining({
name: 'Authorization',
@@ -534,6 +515,7 @@ describe('generateSnippet header inclusion in output', () => {
// Restore original mock
require('httpsnippet').HTTPSnippet = originalHTTPSnippet;
// buildHar's finalizeHeaders lowercases header names per HAR convention.
expect(result).toContain('X-Collection');
expect(result).toContain('X-Folder');
});
@@ -614,59 +596,21 @@ describe('generateSnippet with OAuth2 authentication', () => {
beforeEach(() => {
jest.clearAllMocks();
// Mock getAuthHeaders to return OAuth2 headers based on the auth config
const authUtils = require('utils/codegenerator/auth');
authUtils.getAuthHeaders.mockImplementation((requestAuth, collection = null, item = null) => {
if (requestAuth?.mode === 'oauth2') {
const oauth2Config = requestAuth.oauth2 || {};
const tokenPlacement = oauth2Config.tokenPlacement || 'header';
// Use the actual value from config, defaulting to 'Bearer' only if undefined
// Empty string should be preserved to test no-prefix scenarios
const tokenHeaderPrefix = oauth2Config.tokenHeaderPrefix !== undefined
? oauth2Config.tokenHeaderPrefix
: 'Bearer';
let accessToken = oauth2Config.accessToken || '<access_token>';
// If collection and item are provided, try to look up stored credentials
if (collection && item && collection.oauth2Credentials) {
const grantType = oauth2Config.grantType || '';
const urlToLookup = grantType === 'implicit'
? oauth2Config.authorizationUrl || ''
: oauth2Config.accessTokenUrl || '';
const credentialsId = oauth2Config.credentialsId || 'credentials';
const collectionUid = collection.uid;
if (urlToLookup && collectionUid) {
// Look up stored credentials (simplified - assumes URL is already interpolated in test data)
const credentialsData = collection.oauth2Credentials.find(
(creds) =>
creds?.url === urlToLookup
&& creds?.collectionUid === collectionUid
&& creds?.credentialsId === credentialsId
);
if (credentialsData?.credentials?.access_token) {
accessToken = credentialsData.credentials.access_token;
}
}
}
if (tokenPlacement === 'header') {
// Always trim the final result for consistent formatting
const headerValue = tokenHeaderPrefix
? `${tokenHeaderPrefix} ${accessToken}`.trim()
: accessToken.trim();
return [
{
enabled: true,
name: 'Authorization',
value: headerValue
}
];
}
}
return [];
});
// Restore default `getTreePathFromCollectionToItem` impl so previous
// tests' folder-headers overrides don't leak into the OAuth2 tests
// (which use baseCollection with no folders).
const utilsCollections = require('utils/collections/index');
utilsCollections.getTreePathFromCollectionToItem.mockImplementation(() => []);
// OAuth2 → headers translation is now handled inside buildHar's
// authToHeaders (matches the per-test bruno-common coverage), so this
// describe no longer needs to mock `getAuthHeaders`.
require('httpsnippet').HTTPSnippet = jest.fn().mockImplementation((harRequest) => ({
convert: jest.fn(() => {
const method = harRequest?.method || 'GET';
const url = reconstructUrlForMock(harRequest);
return `curl -X ${method} ${url}`;
})
}));
});
it('should include OAuth2 Bearer token in Authorization header when tokenPlacement is header', () => {
@@ -690,8 +634,8 @@ describe('generateSnippet with OAuth2 authentication', () => {
generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
const harUtils = require('utils/codegenerator/har');
const harCall = harUtils.buildHarRequest.mock.calls[0][0];
const { HTTPSnippet: mockedHTTPSnippet } = require('httpsnippet');
const harCall = mockedHTTPSnippet.mock.calls[0][0];
expect(harCall.headers).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -723,8 +667,8 @@ describe('generateSnippet with OAuth2 authentication', () => {
generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
const harUtils = require('utils/codegenerator/har');
const harCall = harUtils.buildHarRequest.mock.calls[0][0];
const { HTTPSnippet: mockedHTTPSnippet } = require('httpsnippet');
const harCall = mockedHTTPSnippet.mock.calls[0][0];
expect(harCall.headers).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -756,8 +700,8 @@ describe('generateSnippet with OAuth2 authentication', () => {
generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
const harUtils = require('utils/codegenerator/har');
const harCall = harUtils.buildHarRequest.mock.calls[0][0];
const { HTTPSnippet: mockedHTTPSnippet } = require('httpsnippet');
const harCall = mockedHTTPSnippet.mock.calls[0][0];
const authHeader = harCall.headers.find((h) => h.name === 'Authorization');
expect(authHeader).toBeUndefined();
});
@@ -782,8 +726,8 @@ describe('generateSnippet with OAuth2 authentication', () => {
generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
const harUtils = require('utils/codegenerator/har');
const harCall = harUtils.buildHarRequest.mock.calls[0][0];
const { HTTPSnippet: mockedHTTPSnippet } = require('httpsnippet');
const harCall = mockedHTTPSnippet.mock.calls[0][0];
expect(harCall.headers).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -815,8 +759,8 @@ describe('generateSnippet with OAuth2 authentication', () => {
generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
const harUtils = require('utils/codegenerator/har');
const harCall = harUtils.buildHarRequest.mock.calls[0][0];
const { HTTPSnippet: mockedHTTPSnippet } = require('httpsnippet');
const harCall = mockedHTTPSnippet.mock.calls[0][0];
expect(harCall.headers).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -938,19 +882,21 @@ describe('generateSnippet encodeUrl setting', () => {
beforeEach(() => {
jest.clearAllMocks();
// Mock HTTPSnippet to simulate encoding (same pipeline as the real library)
// Mock HTTPSnippet to simulate the real library's URL rendering. buildHar
// strips the URL's query (bracket-key fix), so the visible URL is
// `harRequest.url` + `harRequest.queryString` reassembled with
// encodeURIComponent on each name/value pair — same shape the real
// HTTPSnippet produces.
require('httpsnippet').HTTPSnippet = jest.fn().mockImplementation((harRequest) => ({
convert: jest.fn((target) => {
const method = harRequest?.method || 'GET';
const url = harRequest?.url || 'http://example.com';
const renderedUrl = reconstructUrlForMock(harRequest);
const { parse } = require('url');
const parsed = parse(url, false, true);
const encodedPath = getEncodedPath(url);
// Simulate targets that use only the path (e.g., python http.client, raw HTTP)
const parsed = parse(renderedUrl, false, true);
const encodedPath = getEncodedPath(renderedUrl);
if (target === 'python') {
return `conn.request("${method}", "${encodedPath}", headers=headers)`;
}
// Full URL targets: reconstruct with encoded path
const fullEncodedUrl = `${parsed.protocol}//${parsed.host}${encodedPath}`;
return `curl -X ${method} '${fullEncodedUrl}'`;
})
@@ -1162,13 +1108,9 @@ describe('generateSnippet encodeUrl setting', () => {
});
it('should preserve URL fragment (#) in snippet when encodeUrl is false', () => {
// Intentional asymmetry: when encodeUrl is false (raw mode), generateSnippet preserves the
// user-supplied URL as-is, including any fragment. This contrasts with encodeUrl: true,
// which strips fragments per RFC 3986 §3.5. The rawUrl is preserved through the makeItem
// call with { encodeUrl: false } and passed to generateSnippet, which intentionally treats
// it as a user-specified string not subject to RFC-compliant stripping. This is a designed
// behavior to honor user intent in raw mode, not a bug. This behavior can be revisited in
// the future if requirements or RFC interpretations change.
// OFF preserves the user's URL byte-for-byte, including the literal `#`.
// This is the only mode that retains fragment semantics — toggle OFF when
// you want `#section` to survive as a fragment.
const rawUrl = 'https://example.com/api?token=abc==#section';
const item = makeItem(rawUrl, { encodeUrl: false });
@@ -1178,17 +1120,16 @@ describe('generateSnippet encodeUrl setting', () => {
expect(result).not.toContain('%3D');
});
it('should not include URL fragment (#) in snippet when encodeUrl is true', () => {
it('should encode URL fragment (#) as %23 data when encodeUrl is true', () => {
const rawUrl = 'https://example.com/api?token=abc==#section';
const item = makeItem(rawUrl, { encodeUrl: true });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
// Fragment is stripped — correct per RFC 3986 §3.5: user agents MUST NOT include the fragment
// in the HTTP request target sent to the origin server (though fragments can still appear in
// user-facing URLs, SPA routing, and are inherited across redirects per RFC 9110 §10.2.2).
// https://datatracker.ietf.org/doc/html/rfc3986#section-3.5
// https://datatracker.ietf.org/doc/html/rfc9110#section-10.2.2
// Option C: `#` is treated as data, encoded to %23. No literal `#` should
// remain — fragment semantics are lost in ON mode by design (predictable
// "URL Encoding ON encodes everything special" behavior).
expect(result).not.toContain('#section');
expect(result).toContain('%23section');
expect(result).toContain('%3D%3D');
});
@@ -1221,14 +1162,14 @@ describe('generateSnippet encodeUrl setting', () => {
expect(result).toContain('%2F');
});
it('should strip fragment and apply encodeUrl when both are present and encodeUrl is true', () => {
it('should encode fragment as data and apply encodeUrl when both are present and encodeUrl is true', () => {
const rawUrl = 'https://example.com/api?redirect=https://other.com/cb#section';
const item = makeItem(rawUrl, { encodeUrl: true });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
// Fragment stripped per RFC 3986
// `#` encoded to %23 as part of the query value (Option C).
expect(result).not.toContain('#section');
// Query value should be encoded
expect(result).toContain('%23section');
expect(result).toContain('%3A');
expect(result).toContain('%2F');
});
@@ -1265,3 +1206,55 @@ describe('generateSnippet encodeUrl setting', () => {
expect(result).toBe(`curl -X GET '${rawUrl}'`);
});
});
// ---------------------------------------------------------------------------
// Regression: HTTPSnippet HAR-validator rejects chars URL.canParse accepts.
// snippet-generator pre-encodes the URL before HAR build so the validator
// accepts; the toggle-driven replaceAll then swaps the encoded form back to
// the user's raw form when toggle is OFF.
// ---------------------------------------------------------------------------
describe('generateSnippet pre-encode URL before HAR (HTTPSnippet validator regression)', () => {
const language = { target: 'shell', client: 'curl' };
const baseCollection = { root: { request: { auth: { mode: 'none' }, headers: [] } } };
const makeItem = (url, settings) => ({
uid: 'pre-enc-req',
request: {
method: 'GET',
url,
headers: [],
body: { mode: 'none' },
auth: { mode: 'none' }
},
...(settings !== undefined && { settings })
});
it('does not throw for path-param value with literal space (user-reported `aaa bbb`)', () => {
// Repro: URL `https://example.com/users/:id` with `id = aaa bbb`.
// After interpolateUrlPathParams (raw mode) the URL has a literal space:
// `https://example.com/users/aaa bbb`. HTTPSnippet's HAR validator
// rejects it → "Error generating code snippet". Pre-encoding turns the
// space into %20 so the validator accepts.
const item = makeItem('https://example.com/users/aaa bbb', { encodeUrl: false });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).not.toBe('Error generating code snippet');
});
it('does not throw for literal [ and ] in URL path', () => {
const item = makeItem('https://example.com/api/list[1]', { encodeUrl: false });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).not.toBe('Error generating code snippet');
});
it('does not throw for < and > in URL path', () => {
const item = makeItem('https://example.com/api/<token>', { encodeUrl: false });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).not.toBe('Error generating code snippet');
});
it('does not throw for raw unicode in URL path', () => {
const item = makeItem('https://example.com/users/José', { encodeUrl: false });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).not.toBe('Error generating code snippet');
});
});

View File

@@ -2,7 +2,7 @@ import { find, map, filter, cloneDeep, each, concat } from 'lodash';
import { parseQueryParams, buildQueryString as stringifyQueryParams } from '@usebruno/common/utils';
import { uuid } from 'utils/common';
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
import { parsePathParams, splitOnFirst, interpolateUrlPathParams } from 'utils/url';
import { parsePathParams, splitOnFirst } from 'utils/url';
import statusCodePhraseMap from 'components/ResponsePane/StatusCode/get-status-code-phrase';
export const addResponseExample = (state, action) => {

View File

@@ -1,153 +0,0 @@
const createContentType = (mode) => {
switch (mode) {
case 'json':
return 'application/json';
case 'text':
return 'text/plain';
case 'xml':
return 'application/xml';
case 'sparql':
return 'application/sparql-query';
case 'formUrlEncoded':
return 'application/x-www-form-urlencoded';
case 'graphql':
return 'application/json';
case 'multipartForm':
return 'multipart/form-data';
case 'file':
return 'application/octet-stream';
default:
return '';
}
};
/**
* Creates a list of enabled headers for the request, ensuring no duplicate content-type headers.
*
* @param {Object} request - The request object.
* @param {Object[]} headers - The array of header objects, each containing name, value, and enabled properties.
* @returns {Object[]} - An array of enabled headers with normalized names and values.
*/
const createHeaders = (request, headers) => {
const enabledHeaders = headers
.filter((header) => header.enabled)
.map((header) => ({
name: header.name.toLowerCase(),
value: header.value
}));
const contentType = createContentType(request.body?.mode);
if (contentType !== '' && !enabledHeaders.some((header) => header.name === 'content-type')) {
enabledHeaders.push({ name: 'content-type', value: contentType });
}
return enabledHeaders;
};
const createQuery = (queryParams = [], request) => {
const params = queryParams
.filter((param) => param.enabled && param.type === 'query')
.map((param) => ({
name: param.name,
value: param.value
}));
if (request?.auth?.mode === 'apikey'
&& request?.auth?.apikey?.placement === 'queryparams'
&& request?.auth?.apikey?.key
&& request?.auth?.apikey?.value) {
params.push({
name: request.auth.apikey.key,
value: request.auth.apikey.value
});
}
return params;
};
const createPostData = (body) => {
const contentType = createContentType(body.mode);
switch (body.mode) {
case 'formUrlEncoded':
return {
mimeType: contentType,
text: new URLSearchParams(
(Array.isArray(body[body.mode]) ? body[body.mode] : [])
.filter((param) => param?.enabled)
.reduce((acc, param) => {
acc[param.name] = param.value;
return acc;
}, {})
).toString(),
params: (Array.isArray(body[body.mode]) ? body[body.mode] : [])
.filter((param) => param?.enabled)
.map((param) => ({
name: param.name,
value: param.value
}))
};
case 'multipartForm':
return {
mimeType: contentType,
params: (Array.isArray(body[body.mode]) ? body[body.mode] : [])
.filter((param) => param?.enabled)
.map((param) => ({
name: param.name,
value: param.value,
...(param.type === 'file' && { fileName: param.value })
}))
};
case 'file': {
const files = Array.isArray(body[body.mode]) ? body[body.mode] : [];
const selectedFile = files.find((param) => param.selected) || files[0];
const filePath = selectedFile?.filePath || '';
return {
mimeType: selectedFile?.contentType || 'application/octet-stream',
text: filePath,
params: filePath
? [
{
name: selectedFile?.name || 'file',
value: filePath,
fileName: filePath,
contentType: selectedFile?.contentType || 'application/octet-stream'
}
]
: []
};
}
case 'graphql':
return {
mimeType: contentType,
text: JSON.stringify(body[body.mode])
};
default:
return {
mimeType: contentType,
text: body[body.mode]
};
}
};
export const buildHarRequest = ({ request, headers }) => {
// NOTE:
// This is just a safety check.
// The interpolateUrlPathParams method validates the url, but it does not throw
if (!URL.canParse(request.url)) {
throw new Error('invalid request url');
}
return {
method: request.method,
url: request.url,
httpVersion: 'HTTP/1.1',
cookies: [],
headers: createHeaders(request, headers),
queryString: createQuery(request.params, request),
postData: createPostData(request.body),
headersSize: 0,
bodySize: 0,
binary: true
};
};

View File

@@ -111,6 +111,11 @@ export const interpolateUrl = ({ url, variables }) => {
};
export const interpolateUrlPathParams = (url, params, variables = {}, options = {}) => {
const substituteValue = (value) => {
const v = value == null ? '' : String(value);
return options.encodeUrl ? encodeURIComponent(v) : v;
};
const getInterpolatedBasePath = (pathname, params) => {
let replacedPathname = pathname
.split('/')
@@ -119,7 +124,7 @@ export const interpolateUrlPathParams = (url, params, variables = {}, options =
if (segment.startsWith(':')) {
const name = segment.slice(1);
const pathParam = params.find((p) => p?.name === name && p?.type === 'path');
return pathParam ? pathParam.value : segment;
return pathParam ? substituteValue(pathParam.value) : segment;
}
// for OData-style parameters (parameters inside parentheses)
@@ -143,7 +148,7 @@ export const interpolateUrlPathParams = (url, params, variables = {}, options =
const pathParam = params.find((p) => p?.name === name && p?.type === 'path');
if (pathParam) {
result = result.replace(':' + match[1], pathParam.value);
result = result.replace(':' + match[1], substituteValue(pathParam.value));
}
}
return result;

View File

@@ -478,3 +478,165 @@ describe('Url Utils - interpolateUrlPathParams with { raw: true }', () => {
expect(result).toEqual('https://example.com/api/:id');
});
});
describe('Url Utils - interpolateUrlPathParams with { encodeUrl: true }', () => {
it('should encode / inside a path-param value (issue #7356)', () => {
const url = 'https://example.com/users/:id/profile';
const params = [{ name: 'id', type: 'path', enabled: true, value: 'aaa/bbb' }];
const result = interpolateUrlPathParams(url, params, {}, { encodeUrl: true });
expect(result).toEqual('https://example.com/users/aaa%2Fbbb/profile');
});
it('should encode # inside a path-param value', () => {
const url = 'https://example.com/users/:id';
const params = [{ name: 'id', type: 'path', enabled: true, value: 'aaa#bbb' }];
const result = interpolateUrlPathParams(url, params, {}, { encodeUrl: true });
expect(result).toEqual('https://example.com/users/aaa%23bbb');
});
it('should encode spaces inside a path-param value', () => {
const url = 'https://example.com/users/:id';
const params = [{ name: 'id', type: 'path', enabled: true, value: 'John Doe' }];
const result = interpolateUrlPathParams(url, params, {}, { encodeUrl: true });
expect(result).toEqual('https://example.com/users/John%20Doe');
});
it('should leave ASCII letters/digits alone', () => {
const url = 'https://example.com/users/:id';
const params = [{ name: 'id', type: 'path', enabled: true, value: '123abc' }];
const result = interpolateUrlPathParams(url, params, {}, { encodeUrl: true });
expect(result).toEqual('https://example.com/users/123abc');
});
it('should encode path-param values inside OData segments', () => {
const url = 'https://example.com/odata/Products(:productId)';
const params = [{ name: 'productId', type: 'path', enabled: true, value: 'ABC/123' }];
const result = interpolateUrlPathParams(url, params, {}, { encodeUrl: true });
expect(result).toEqual('https://example.com/odata/Products(ABC%2F123)');
});
it('should double-encode pre-encoded path-param value (PR #5507 contract)', () => {
const url = 'https://example.com/users/:id';
const params = [{ name: 'id', type: 'path', enabled: true, value: 'aaa%2Fbbb' }];
const result = interpolateUrlPathParams(url, params, {}, { encodeUrl: true });
// Per PR #5507, encoding is content-blind: `%2F` → `%252F`.
expect(result).toEqual('https://example.com/users/aaa%252Fbbb');
});
it('should also work with { raw: true } so snippet/runtime stay in sync', () => {
const url = 'https://example.com/users/:id?q=keep me';
const params = [{ name: 'id', type: 'path', enabled: true, value: 'a/b' }];
const result = interpolateUrlPathParams(url, params, {}, { raw: true, encodeUrl: true });
// path-param encoded, query string preserved verbatim (raw path doesn't touch query)
expect(result).toEqual('https://example.com/users/a%2Fb?q=keep me');
});
});
describe('Url Utils - interpolateUrlPathParams backward compatibility (encodeUrl off / unset)', () => {
it('default options: / in value stays literal (mirror of issue #7356 case)', () => {
const url = 'https://example.com/users/:id/profile';
const params = [{ name: 'id', type: 'path', enabled: true, value: 'aaa/bbb' }];
expect(interpolateUrlPathParams(url, params)).toEqual('https://example.com/users/aaa/bbb/profile');
});
it('default options: # in value stays literal', () => {
const url = 'https://example.com/users/:id';
const params = [{ name: 'id', type: 'path', enabled: true, value: 'aaa#bbb' }];
expect(interpolateUrlPathParams(url, params)).toEqual('https://example.com/users/aaa#bbb');
});
it('default options: space in value stays literal', () => {
const url = 'https://example.com/users/:id';
const params = [{ name: 'id', type: 'path', enabled: true, value: 'John Doe' }];
expect(interpolateUrlPathParams(url, params)).toEqual('https://example.com/users/John Doe');
});
it('default options: ASCII letters/digits unchanged (no-op case)', () => {
const url = 'https://example.com/users/:id';
const params = [{ name: 'id', type: 'path', enabled: true, value: '123abc' }];
expect(interpolateUrlPathParams(url, params)).toEqual('https://example.com/users/123abc');
});
it('OData segments: default options leave value raw', () => {
const url = 'https://example.com/odata/Products(:productId)';
const params = [{ name: 'productId', type: 'path', enabled: true, value: 'ABC/123' }];
expect(interpolateUrlPathParams(url, params)).toEqual('https://example.com/odata/Products(ABC/123)');
});
it('pre-encoded value: default options preserve as-typed (no double-encode)', () => {
const url = 'https://example.com/users/:id';
const params = [{ name: 'id', type: 'path', enabled: true, value: 'aaa%2Fbbb' }];
expect(interpolateUrlPathParams(url, params)).toEqual('https://example.com/users/aaa%2Fbbb');
});
it('explicit encodeUrl: false behaves identically to default (no options)', () => {
const url = 'https://example.com/users/:id';
const params = [{ name: 'id', type: 'path', enabled: true, value: 'aaa/bbb' }];
const a = interpolateUrlPathParams(url, params);
const b = interpolateUrlPathParams(url, params, {}, { encodeUrl: false });
expect(a).toEqual(b);
expect(a).toEqual('https://example.com/users/aaa/bbb');
});
it('raw: true without encodeUrl matches pre-PR raw output', () => {
const url = 'https://example.com/users/:id?q=hello world';
const params = [{ name: 'id', type: 'path', enabled: true, value: 'a/b' }];
expect(interpolateUrlPathParams(url, params, {}, { raw: true }))
.toEqual('https://example.com/users/a/b?q=hello world');
});
// Acceptance-criteria coverage (decision-tree table from the PR reviewer).
// Mirrors every row of the "OFF mode passes through unchanged" requirement.
// URL: https://api.example.com/users/:role_name — same template across all 7.
const ACCEPTANCE_URL = 'https://api.example.com/users/:role_name';
const accept = (value) => [{ name: 'role_name', type: 'path', enabled: true, value }];
it('#1: aaa/bbb passes through raw (no %2F)', () => {
expect(interpolateUrlPathParams(ACCEPTANCE_URL, accept('aaa/bbb')))
.toEqual('https://api.example.com/users/aaa/bbb');
});
it('#2: aaa#bbb passes through raw (no %23)', () => {
expect(interpolateUrlPathParams(ACCEPTANCE_URL, accept('aaa#bbb')))
.toEqual('https://api.example.com/users/aaa#bbb');
});
it('#3: aaa bbb passes through raw (no %20)', () => {
expect(interpolateUrlPathParams(ACCEPTANCE_URL, accept('aaa bbb')))
.toEqual('https://api.example.com/users/aaa bbb');
});
it('#4: 50% passes through raw (bare % preserved)', () => {
expect(interpolateUrlPathParams(ACCEPTANCE_URL, accept('50%')))
.toEqual('https://api.example.com/users/50%');
});
it('#5: a&b=c passes through raw (no %26 / %3D)', () => {
expect(interpolateUrlPathParams(ACCEPTANCE_URL, accept('a&b=c')))
.toEqual('https://api.example.com/users/a&b=c');
});
it('#6: 名前 passes through raw (unicode preserved as literal UTF-8)', () => {
expect(interpolateUrlPathParams(ACCEPTANCE_URL, accept('名前')))
.toEqual('https://api.example.com/users/名前');
});
it('#7: a%20b (pre-encoded) passes through raw (no double-encoding)', () => {
expect(interpolateUrlPathParams(ACCEPTANCE_URL, accept('a%20b')))
.toEqual('https://api.example.com/users/a%20b');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,655 @@
/**
* Bruno → HAR generation.
*
* This module is the single canonical pipeline for turning a Bruno request
* (interpolation, path-param substitution, auth resolution, header merging,
* URL encoding, query handling, HAR assembly) into the HTTP Archive (HAR)
* shape consumed by both Generate Code (HTTPSnippet) and — in a follow-up
* PR — the runtime (via a HAR→axios adapter).
*
* The previous architecture spread these concerns across 5+ files in
* `bruno-app` plus a parallel set in `bruno-electron`/`bruno-cli`, and the
* runtime↔codegen drift produced every reported URL-encoding bug
* (#5788, #6268, #7356, #7653, #7913, plus the #tag#tag doubling and the
* `post_ids[]` / `key[a][b]` bracket-phantom-duplicate bugs).
*
* By centralising the work here:
* - There is exactly one place that decides what reaches the wire.
* - PR #5507's content-blind double-encoding contract is honored by
* calling `encodeUrl()` from `../utils/url` (no decode-then-encode).
* - The HAR `url` field is path-only; `queryString` is the sole source
* of truth for the rendered query string, which sidesteps HTTPSnippet's
* legacy `url.parse(..., true, true)` polyfill that strips trailing
* `[]` from keys (the bracket-phantom bug).
* - `{{var}}` tokens that callers don't want resolved are hashed before
* URL parsing/encoding via `patternHasher`, then restored at the end
* via the returned `unhash` function — so snippets remain
* copy-shareable with templates intact.
*/
import { cloneDeep, find, get } from 'lodash';
import interpolate, { interpolateObject } from '../../interpolate';
import { encodeUrl, parseQueryParams, patternHasher } from '../../utils';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type BrunoKV = {
name: string;
value: string;
type?: string;
enabled?: boolean;
};
export type BrunoBody = {
mode?: string;
[key: string]: any;
};
export type BrunoAuth = {
mode?: string;
[key: string]: any;
};
export type BrunoRequest = {
method?: string;
url: string;
params?: BrunoKV[];
pathParams?: BrunoKV[];
headers?: BrunoKV[];
body?: BrunoBody;
auth?: BrunoAuth;
settings?: { encodeUrl?: boolean };
};
/**
* Stored OAuth2 credential record (read from `collection.oauth2Credentials`).
* Passed in as input so `buildHar` stays pure (no collection traversal);
* the caller pre-collects the records relevant to this request.
*/
export type OAuth2CredentialRecord = {
url?: string;
collectionUid?: string;
credentialsId?: string;
credentials?: { access_token?: string };
};
export type BuildHarInput = {
request: BrunoRequest;
/**
* Merged variable map: global / collection / folder / request / runtime /
* oauth2-creds / prompt vars / process.env, in precedence order resolved
* by the caller (the caller is the only place that knows the collection
* tree). `buildHar` just substitutes from this map.
*/
variables?: Record<string, unknown>;
/**
* When `false` (default for Generate Code's "show snippet without
* resolving variables" mode), `{{var}}` tokens are hashed before URL
* parsing/encoding via `patternHasher`, then exposed via the returned
* `unhash` function so the caller can restore them in the final output.
*/
shouldInterpolate?: boolean;
/**
* OAuth2 stored credentials. Used to look up the actual access token
* when `auth.mode === 'oauth2'` and `tokenPlacement === 'header'`.
*/
oauth2Credentials?: OAuth2CredentialRecord[];
collectionUid?: string;
};
export type HarRequest = {
method: string;
url: string;
httpVersion: string;
cookies: unknown[];
headers: { name: string; value: string }[];
queryString: { name: string; value: string }[];
postData: any;
headersSize: number;
bodySize: number;
binary: boolean;
};
export type BuildHarOutput = {
/** Canonical HAR object, ready for HTTPSnippet (and, post-Phase-C, an axios adapter). */
har: HarRequest;
/**
* The user's URL after path-param substitution but BEFORE `encodeUrl`
* was applied. Used by Generate Code's display-swap step when toggle
* is off (snippet shows the user's typed encoding, not Bruno's).
*/
rawUrl: string;
/** The URL after `encodeUrl` was applied (matches the HAR's stored form). */
encodedUrl: string;
/**
* Restores `{{var}}` tokens in a string. When `shouldInterpolate=false`,
* Generate Code calls this on the final snippet so templates survive
* verbatim through HTTPSnippet's URL parsing.
*/
unhash: (input: string) => string;
/**
* The auth object as it was applied to the request. Exposed so Generate
* Code can attach curl-specific flags (`--digest`, `--ntlm`) downstream.
*/
effectiveAuth: BrunoAuth | null;
};
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
const contentTypeFromBodyMode = (mode?: string): string => {
switch (mode) {
case 'json': return 'application/json';
case 'text': return 'text/plain';
case 'xml': return 'application/xml';
case 'sparql': return 'application/sparql-query';
case 'formUrlEncoded': return 'application/x-www-form-urlencoded';
case 'graphql': return 'application/json';
case 'multipartForm': return 'multipart/form-data';
case 'file': return 'application/octet-stream';
default: return '';
}
};
/**
* Strip the query string from a URL (everything between the first `?` and
* the fragment marker `#`). Preserves scheme, authority, path, fragment.
*
* HTTPSnippet receives a URL without its query so its internal
* `url.parse(..., true, true)` has nothing to mis-parse. The
* `queryString` array is the sole source of truth — no merge bug.
*/
const stripQueryStringFromUrl = (url: string): string => {
if (!url || typeof url !== 'string') return url;
return url.replace(/^([^#?]*)\?[^#]*/, '$1');
};
/**
* Interpolate `{{var}}` placeholders in the request's URL, headers, body,
* auth, params, and pathParams. The body interpolation is mode-aware so
* JSON stays valid and form params get value-only interpolation.
*/
const interpolateRequest = (request: BrunoRequest, variables: Record<string, unknown>): BrunoRequest => {
const r = cloneDeep(request);
// NOTE: URL interpolation is intentionally NOT done here. URL `{{var}}` resolution
// is the caller's responsibility (e.g., GenerateCodeItem in bruno-app does its
// own `interpolateUrl` upstream). buildHar always runs the patternHasher on the
// URL — leftover `{{var}}` tokens are hashed before parsing/encoding so the URL
// stays parseable, then restored via the returned `unhash` on the final output.
// This preserves the previous snippet-generator's contract that
// shouldInterpolate=true does NOT resolve URL placeholders (only body, headers,
// auth, params, pathParams).
if (Array.isArray(r.headers)) {
r.headers = r.headers.map((h) => h && h.enabled !== false ? (interpolateObject(h, variables) as BrunoKV) : h);
}
if (Array.isArray(r.params)) {
r.params = r.params.map((p) => p && p.enabled !== false ? (interpolateObject(p, variables) as BrunoKV) : p);
}
if (Array.isArray(r.pathParams)) {
r.pathParams = r.pathParams.map((p) => interpolateObject(p, variables) as BrunoKV);
}
if (r.auth) {
r.auth = interpolateObject(r.auth, variables) as BrunoAuth;
}
if (r.body && typeof r.body === 'object') {
const body = { ...r.body };
switch (body.mode) {
case 'json': {
let parsed = body.json;
if (typeof parsed === 'object') parsed = JSON.stringify(parsed);
parsed = interpolate(parsed, variables, { escapeJSONStrings: true });
try {
const jsonObj = JSON.parse(parsed);
body.json = JSON.stringify(jsonObj, null, 2);
} catch {
body.json = parsed;
}
break;
}
case 'text': body.text = interpolate(body.text, variables); break;
case 'xml': body.xml = interpolate(body.xml, variables); break;
case 'sparql': body.sparql = interpolate(body.sparql, variables); break;
case 'formUrlEncoded':
body.formUrlEncoded = Array.isArray(body.formUrlEncoded)
? body.formUrlEncoded.map((p: any) => ({
...p,
value: p.enabled ? interpolate(p.value, variables) : p.value
}))
: [];
break;
case 'multipartForm':
body.multipartForm = Array.isArray(body.multipartForm)
? body.multipartForm.map((p: any) => ({
...p,
value: p.type === 'text' && p.enabled ? interpolate(p.value, variables) : p.value
}))
: [];
break;
default: break;
}
r.body = body;
}
return r;
};
/**
* Substitute `:id` path params (and OData-style parenthesised params) into
* the URL. When `options.encodeUrl` is true, each substituted value is
* `encodeURIComponent`'d — this is the issue #7356 fix surface.
*
* Returns the URL with substitutions applied. Path-param values are not
* URL-encoded structurally (only via the optional toggle), so callers that
* want a "raw" view (the URL as the user effectively typed) pass
* `options.encodeUrl=false`.
*/
/**
* Replace each `:id` position in the URL with a `patternHasher`-generated
* placeholder, and return a `restore(url, { encode })` callable that the
* caller invokes after `encodeUrl` has run.
*
* The placeholder shape is `bruno-var-hash-<djb2>` — alphanumeric plus dash
* only, so `encodeURIComponent` and `encodeUrl` leave it untouched.
* `restore()` first unhashes placeholders back to `:id` literals (via
* `patternHasher`'s own restore), then substitutes each `:id` for the
* path-param value — raw when `encode=false`, single-encoded via
* `encodeURIComponent` when `encode=true`.
*
* This indirection prevents the double-encoding that fall out of doing
* `substitute-with-encoding → encodeUrl` back to back: the second pass
* would content-blind-encode the already-encoded segment (e.g. `aaa%2Fbbb`
* → `aaa%252Fbbb`) per PR #5507. By hiding path-param positions behind
* placeholders during the `encodeUrl` pass, encoding happens exactly once
* — when the placeholders are restored.
*/
const hashPathParamPositions = (
url: string,
pathParams: BrunoKV[] | undefined
): { url: string; restore: (input: string, opts: { encode: boolean }) => string } => {
const noopRestore = (input: string) => input;
if (!url || !Array.isArray(pathParams) || pathParams.length === 0) {
return { url, restore: noopRestore };
}
let prefix = '';
let working = url;
if (!working.startsWith('http://') && !working.startsWith('https://')) {
working = 'http://' + working;
prefix = '__BRUNO_HAR_HTTP_PLACEHOLDER__';
}
// Pre-split authority so `:pass` in `user:pass@host` is not treated as a path-param.
const separatorIdx = working.search(/[?#]/);
const pathPart = separatorIdx >= 0 ? working.substring(0, separatorIdx) : working;
const rest = separatorIdx >= 0 ? working.substring(separatorIdx) : '';
const authorityMatch = pathPart.match(/^([a-z][a-z0-9+.-]*:\/\/[^/?#]*)?(.*)$/i);
const authority = authorityMatch?.[1] ?? '';
const path = authorityMatch?.[2] ?? pathPart;
const enabledByName = new Map<string, string>(
pathParams
.filter((p) => p && p.enabled !== false && (p.type === undefined || p.type === 'path'))
.map((p) => [p.name, p.value == null ? '' : String(p.value)])
);
// patternHasher protects `:name` patterns from being mangled by encodeUrl.
// restore() puts them back literally; the substitute step below swaps them
// for the path-param value (raw or encoded).
const PATH_PARAM_RE = /:[a-zA-Z_]\w*/g;
const { hashed, restore: hashRestore } = patternHasher(path, PATH_PARAM_RE);
let finalUrl = authority + hashed + rest;
if (prefix) finalUrl = finalUrl.replace(/^http:\/\//, '');
const restore = (input: string, { encode }: { encode: boolean }): string => {
const withLiterals = hashRestore(input);
return withLiterals.replace(PATH_PARAM_RE, (match) => {
const name = match.slice(1);
if (!enabledByName.has(name)) return match;
const value = enabledByName.get(name) ?? '';
return encode ? encodeURIComponent(value) : value;
});
};
return { url: finalUrl, restore };
};
/**
* Sanity check that the URL has at least a scheme + authority. This is
* intentionally loose — any URL the user can legally type should pass.
* The stricter `URL.canParse` check was fighting downstream encoders
* (issues #6268, #7653, #7913).
*/
const looksLikeUrl = (url: string | undefined): boolean =>
typeof url === 'string' && /^[a-z][a-z0-9+.-]*:\/\/[^/?#]+/i.test(url);
/**
* Translate the resolved auth mode into one or more headers to append to
* the request. For OAuth2 token-in-header placement, looks up the actual
* access token from `oauth2Credentials` (passed in by the caller).
*
* Auth modes that produce no header here (and are handled elsewhere):
* - 'apikey' with placement='queryparams' → added to queryString in buildQueryString
* - 'oauth2' with tokenPlacement='url' → not currently surfaced in codegen
* - 'oauth1' → requires runtime signing
* - 'digest', 'ntlm' → curl gets --digest/--ntlm flags
* attached post-snippet by the caller
* - 'awsv4' → runtime-only signing
* - 'wsse' → runtime-only signing
*/
const authToHeaders = (
auth: BrunoAuth | undefined,
variables: Record<string, unknown>,
oauth2Credentials: OAuth2CredentialRecord[] | undefined,
collectionUid: string | undefined
): BrunoKV[] => {
if (!auth || !auth.mode || auth.mode === 'none' || auth.mode === 'inherit') return [];
switch (auth.mode) {
case 'basic': {
const username = get(auth, 'basic.username', '') as string;
const password = get(auth, 'basic.password', '') as string;
const token = Buffer.from(`${username}:${password}`).toString('base64');
return [{ name: 'Authorization', value: `Basic ${token}`, enabled: true }];
}
case 'bearer':
return [{ name: 'Authorization', value: `Bearer ${get(auth, 'bearer.token', '')}`, enabled: true }];
case 'apikey': {
const placement = get(auth, 'apikey.placement', 'header') as string;
if (placement !== 'header') return [];
const key = get(auth, 'apikey.key', '') as string;
const value = get(auth, 'apikey.value', '') as string;
if (!key) return [];
return [{ name: key, value, enabled: true }];
}
case 'oauth2': {
const tokenPlacement = get(auth, 'oauth2.tokenPlacement', 'header') as string;
if (tokenPlacement !== 'header') return [];
const tokenHeaderPrefix = get(auth, 'oauth2.tokenHeaderPrefix', 'Bearer') as string;
// Precedence: stored credentials → auth.oauth2.accessToken → placeholder.
// Matches bruno-app's getAuthHeaders so generated snippets carry the
// user's directly-configured token when present.
let accessToken = (get(auth, 'oauth2.accessToken', '') as string) || '<access_token>';
if (Array.isArray(oauth2Credentials) && collectionUid) {
try {
const grantType = get(auth, 'oauth2.grantType', '') as string;
const urlField = grantType === 'implicit' ? 'oauth2.authorizationUrl' : 'oauth2.accessTokenUrl';
const rawUrl = get(auth, urlField, '') as string;
const credentialsId = get(auth, 'oauth2.credentialsId', 'credentials') as string;
const interpolatedUrl = rawUrl ? interpolate(rawUrl, variables) : '';
if (interpolatedUrl) {
const match = find(
oauth2Credentials,
(rec: OAuth2CredentialRecord) =>
rec?.url === interpolatedUrl
&& rec?.collectionUid === collectionUid
&& rec?.credentialsId === credentialsId
);
if (match?.credentials?.access_token) {
accessToken = match.credentials.access_token;
}
}
} catch {
// Fall back to placeholder if anything fails — never throw here.
}
}
const headerValue = (tokenHeaderPrefix ? `${tokenHeaderPrefix} ${accessToken}` : accessToken).trim();
return [{ name: 'Authorization', value: headerValue, enabled: true }];
}
// OAuth1 cannot pre-compute the signature for a static snippet.
// Digest/NTLM/AWS/WSSE are runtime-signed.
default:
return [];
}
};
const mergeAndDedupeHeaders = (existing: BrunoKV[] | undefined, additions: BrunoKV[]): BrunoKV[] => {
const result = Array.isArray(existing) ? [...existing] : [];
for (const h of additions) {
if (!h) continue;
result.push(h);
}
return result;
};
/**
* Filter to enabled headers, **preserve the user-typed case** of each name,
* and append a default `Content-Type` when the body mode implies one and the
* user hasn't already set it.
*
* HTTP header names are case-insensitive per RFC 7230 §3.2, so the wire
* doesn't care between `Authorization` and `authorization`. But the
* generated *code snippet* is a copy-paste artifact — users expect to see
* the casing they typed. Lowercasing here mangles things like `X-API-Key`
* or `Content-Type` into shapes nobody types by hand. The dedup check for
* the default content-type rule is done case-insensitively so it still
* fires correctly when the user typed `Content-Type` vs `content-type`.
*/
const finalizeHeaders = (request: BrunoRequest, headers: BrunoKV[]): { name: string; value: string }[] => {
const enabled = (headers || [])
.filter((h) => h && h.enabled !== false)
.map((h) => ({ name: String(h.name), value: String(h.value ?? '') }));
const implicitCT = contentTypeFromBodyMode(request?.body?.mode);
if (implicitCT && !enabled.some((h) => h.name.toLowerCase() === 'content-type')) {
enabled.push({ name: 'Content-Type', value: implicitCT });
}
return enabled;
};
/**
* Assemble HAR `queryString` from the request's params array plus any
* auth-driven query params (e.g. api-key with placement='queryparams').
*/
const buildQueryString = (
request: BrunoRequest,
urlForFallback?: string
): { name: string; value: string }[] => {
const enabledParams = Array.isArray(request.params)
? request.params.filter((p) => p && p.enabled !== false && p.type === 'query')
: [];
// Primary source: request.params (synced with the URL by the Redux reducer in
// production). Fallback: if params is empty but the URL string carries a query,
// parse the query directly. This catches synthetic / test inputs that only set
// request.url and skip the params array, and keeps wire output stable for
// callers that bypass the params syncer.
let params: { name: string; value: string }[];
if (enabledParams.length > 0) {
params = enabledParams.map((p) => ({ name: p.name, value: p.value }));
} else if (urlForFallback) {
// `#` is data (Option C — see encodeUrl), so the query slice extends to
// end-of-string and parseQueryParams is told NOT to split on `#`.
// Anything after `#` ends up as part of the last value, which is what
// the encoder and the OFF-mode display-swap both expect.
const queryIdx = urlForFallback.indexOf('?');
const queryString = queryIdx >= 0 ? urlForFallback.slice(queryIdx + 1) : '';
params = queryString
? parseQueryParams(queryString, { decode: false, stripFragment: false }).map((p) => ({
name: p.name,
value: p.value == null ? '' : p.value
}))
: [];
} else {
params = [];
}
const auth = request.auth;
if (
auth?.mode === 'apikey'
&& get(auth, 'apikey.placement') === 'queryparams'
&& get(auth, 'apikey.key')
&& get(auth, 'apikey.value')
) {
params.push({ name: get(auth, 'apikey.key') as string, value: get(auth, 'apikey.value') as string });
}
return params;
};
/** HAR `postData` for the request body. Mirrors the body-mode contract. */
const buildPostData = (body: BrunoBody | undefined): any => {
if (!body || !body.mode) return undefined;
const mimeType = contentTypeFromBodyMode(body.mode);
switch (body.mode) {
case 'formUrlEncoded': {
const arr: any[] = Array.isArray(body.formUrlEncoded) ? body.formUrlEncoded : [];
const enabled = arr.filter((p) => p?.enabled);
const searchParams = new URLSearchParams();
enabled.forEach((p) => {
searchParams.append(String(p.name), String(p.value ?? ''));
});
return {
mimeType,
text: searchParams.toString(),
params: enabled.map((p) => ({ name: p.name, value: p.value }))
};
}
case 'multipartForm': {
const arr: any[] = Array.isArray(body.multipartForm) ? body.multipartForm : [];
return {
mimeType,
params: arr
.filter((p) => p?.enabled)
.map((p) => ({
name: p.name,
value: p.value,
...(p.type === 'file' && { fileName: p.value })
}))
};
}
case 'file': {
const files: any[] = Array.isArray(body.file) ? body.file : [];
const selected = files.find((f) => f.selected) || files[0];
const filePath = selected?.filePath || '';
return {
mimeType: selected?.contentType || 'application/octet-stream',
text: filePath,
params: filePath
? [{
name: selected?.name || 'file',
value: filePath,
fileName: filePath,
contentType: selected?.contentType || 'application/octet-stream'
}]
: []
};
}
case 'graphql':
return { mimeType, text: JSON.stringify(body.graphql) };
default:
return { mimeType, text: body[body.mode] };
}
};
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
export function buildHar(input: BuildHarInput): BuildHarOutput {
const variables = input.variables || {};
const shouldInterpolate = input.shouldInterpolate ?? true;
// Step 1 & 2 — hash {{var}} placeholders OR substitute them.
// When the caller wants templates preserved in the snippet (shouldInterpolate=false),
// we replace `{{var}}` with deterministic URL-safe hashes BEFORE any URL parsing
// or encoding runs. The returned `unhash` lets the caller restore originals at the end.
let working = shouldInterpolate ? interpolateRequest(input.request, variables) : cloneDeep(input.request);
// The hashers operate at the URL-string layer specifically. Headers/body
// /auth/params with `{{var}}` would only render in the snippet text;
// those callers that don't want them resolved can pre-hash before calling
// buildHar OR pass `shouldInterpolate=true`. For typical Generate Code
// usage, `shouldInterpolate=false` means "preserve URL-bar templates".
const { hashed: hashedUrl, restore: restoreUrlVars } = patternHasher(working.url || '');
// Step 3 — Hash path-param positions via `patternHasher`. The URL now
// contains opaque `bruno-var-hash-XXX` tokens instead of `:id`, so the
// next `encodeUrl()` pass can encode non-path-param chars in the path
// without touching path-param positions. `restorePathParams` (returned
// here) unhashes those tokens back to `:id` literals and then substitutes
// each one for the path-param value (raw or encoded per the flag).
const encodeFlag = working.settings?.encodeUrl === true;
const { url: urlWithPlaceholders, restore: restorePathParams } = hashPathParamPositions(hashedUrl, working.pathParams);
// Step 4 — Apply `encodeUrl()` to the URL with placeholders. Placeholders
// are alphanumeric+dash, so `encodeURIComponent` (used per path segment
// inside `encodeUrl`) leaves them untouched. The rest of the path and
// query are encoded per the existing content-blind contract (PR #5507).
const encodedUrlWithPlaceholders = encodeUrl(urlWithPlaceholders);
// Step 5 — Restore placeholders. `rawUrl` always uses raw values (for the
// toggle-OFF display swap upstream). `encodedUrl` uses single-encoded
// values when toggle is ON, raw when OFF.
const rawUrl = restorePathParams(urlWithPlaceholders, { encode: false });
const encodedUrl = restorePathParams(encodedUrlWithPlaceholders, { encode: encodeFlag });
// Sanity gate. Stays here so HAR consumers see exactly what we'd send.
if (!looksLikeUrl(encodedUrl)) {
throw new Error('invalid request url');
}
// Step 5 — Auth → headers. Append to request headers.
const authHeaders = authToHeaders(working.auth, variables, input.oauth2Credentials, input.collectionUid);
const allHeaders = mergeAndDedupeHeaders(working.headers, authHeaders);
// Step 6 — Finalize headers (filter enabled, lowercase, default content-type).
const harHeaders = finalizeHeaders(working, allHeaders);
// Step 7 — Query string array. HAR's queryString is the single source of
// truth for what HTTPSnippet renders into the URL slot. The URL itself
// (next step) has its query stripped to avoid the legacy-polyfill merge bug.
// Pass the RAW URL (pre-encodeUrl) as the fallback source: HTTPSnippet
// re-encodes each queryString value via encodeURIComponent when rendering,
// so the entries here must carry user-typed bytes. Feeding the encoded URL
// here would cause double-encoding (`:` → `%3A` from encodeUrl, then
// `%3A` → `%253A` from HTTPSnippet's encodeURIComponent pass).
const harQueryString = buildQueryString(working, working.url);
// Step 8 — Strip the URL's query before storing in HAR (the bracket-key fix).
const harUrl = stripQueryStringFromUrl(encodedUrl);
// Step 9 — Assemble.
const har: HarRequest = {
method: working.method || 'GET',
url: harUrl,
httpVersion: 'HTTP/1.1',
cookies: [],
headers: harHeaders,
queryString: harQueryString,
postData: buildPostData(working.body),
headersSize: 0,
bodySize: 0,
binary: true
};
const unhash = (s: string): string => (typeof s === 'string' ? restoreUrlVars(s) : s);
return {
har,
rawUrl,
encodedUrl,
unhash,
effectiveAuth: working.auth ?? null
};
}

View File

@@ -4,4 +4,16 @@ export { percentageToZoomLevel } from './zoom';
export { default as isRequestTagsIncluded } from './tags';
export { transformExampleStatusInCollection } from './example-status';
export { buildHar } from './generate-code/har';
export type {
BuildHarInput,
BuildHarOutput,
BrunoRequest,
BrunoKV,
BrunoBody,
BrunoAuth,
HarRequest,
OAuth2CredentialRecord
} from './generate-code/har';
export * as utils from './utils';

View File

@@ -3,7 +3,8 @@ export {
encodeUrl,
parseQueryParams,
buildQueryString,
stripOrigin
stripOrigin,
safeDecodeURIComponent
} from './url';
export {

View File

@@ -1,4 +1,4 @@
import { encodeUrl, parseQueryParams, buildQueryString, hasExplicitScheme } from './index';
import { encodeUrl, parseQueryParams, buildQueryString, hasExplicitScheme, safeDecodeURIComponent } from './index';
describe('encodeUrl', () => {
describe('basic functionality', () => {
@@ -75,16 +75,76 @@ describe('encodeUrl', () => {
});
});
describe('hash fragment handling', () => {
it('should preserve hash fragments with encoded query parameters', () => {
const url = 'https://example.com/api?name=john doe#section1';
const expected = 'https://example.com/api?name=john%20doe#section1';
describe('path encoding (added in URL encoding fix)', () => {
it('should encode spaces in path segments', () => {
const url = 'https://example.com/api/v1/path with spaces/users';
const expected = 'https://example.com/api/v1/path%20with%20spaces/users';
expect(encodeUrl(url)).toBe(expected);
});
it('should preserve hash fragments with pipe operator in query', () => {
it('should encode unicode in path segments', () => {
const url = 'https://example.com/api/José/profile';
const expected = 'https://example.com/api/Jos%C3%A9/profile';
expect(encodeUrl(url)).toBe(expected);
});
it('should encode square brackets in path segments', () => {
const url = 'https://example.com/list[123]';
const expected = 'https://example.com/list%5B123%5D';
expect(encodeUrl(url)).toBe(expected);
});
it('should leave separators between segments alone (slashes are structural)', () => {
const url = 'https://example.com/api/v1/users';
expect(encodeUrl(url)).toBe(url);
});
it('should encode angle brackets, quotes, pipes in the path', () => {
const url = 'https://example.com/api/a<b>c/"hi"/a|b/{x}';
const expected = 'https://example.com/api/a%3Cb%3Ec/%22hi%22/a%7Cb/%7Bx%7D';
expect(encodeUrl(url)).toBe(expected);
});
it('should encode the path even when there is no query string', () => {
const url = 'https://example.com/path with spaces';
const expected = 'https://example.com/path%20with%20spaces';
expect(encodeUrl(url)).toBe(expected);
});
it('should preserve the origin (scheme + authority) verbatim', () => {
const url = 'https://user:pass@example.com:8080/path with space';
const expected = 'https://user:pass@example.com:8080/path%20with%20space';
expect(encodeUrl(url)).toBe(expected);
});
});
describe('hash (#) treated as data, not RFC 3986 §3.5 fragment delimiter', () => {
// ON-mode design choice: `#` is encoded to %23 as a regular byte. The
// previous strip-or-preserve behavior caused surprising silent data loss
// in the snippet. To send a URL with a literal `#section` fragment, the
// toggle must be OFF — OFF preserves the user's URL byte-for-byte.
it('encodes # in a query value to %23 alongside other special chars', () => {
const url = 'https://example.com/api?name=john doe#section1';
const expected = 'https://example.com/api?name=john%20doe%23section1';
expect(encodeUrl(url)).toBe(expected);
});
it('encodes # alongside pipe operator in query value', () => {
const url = 'https://example.com/api?filter=status|active#results';
const expected = 'https://example.com/api?filter=status%7Cactive#results';
const expected = 'https://example.com/api?filter=status%7Cactive%23results';
expect(encodeUrl(url)).toBe(expected);
});
it('encodes a bare # following a single-char value', () => {
const url = 'https://jsonplaceholder.typicode.com/todos/1?name=a#b';
const expected = 'https://jsonplaceholder.typicode.com/todos/1?name=a%23b';
expect(encodeUrl(url)).toBe(expected);
});
it('encodes # in a path segment (no query) to %23', () => {
const url = 'https://example.com/foo#bar';
const expected = 'https://example.com/foo%23bar';
expect(encodeUrl(url)).toBe(expected);
});
});
@@ -104,8 +164,11 @@ describe('encodeUrl', () => {
});
it('should handle complex query parameters with multiple special characters', () => {
// `#`, `$`, `%`, `^` are all encoded as data when they appear in a query
// value (Option C — # is data, not a fragment delimiter). The bare-name
// `*()` survives encodeURIComponent unchanged (RFC 3986 unreserved set).
const url = 'https://example.com/api?search=hello world!@#$%^&*()&filter=active&sort=name asc';
const expected = 'https://example.com/api?search=hello%20world!%40#$%^&*()&filter=active&sort=name asc';
const expected = 'https://example.com/api?search=hello%20world!%40%23%24%25%5E&*()&filter=active&sort=name%20asc';
expect(encodeUrl(url)).toBe(expected);
});
@@ -122,6 +185,47 @@ describe('encodeUrl', () => {
});
});
describe('PR #5507 contract — content-blind double-encoding (intentional)', () => {
// These assertions are the canary that proves no decode-encode wrap was slipped
// into the encoder. If any of them start failing, the contract has been broken.
it('should double-encode pre-encoded space in query value (%20 → %2520)', () => {
const url = 'https://example.com/api?name=John%20Doe';
const expected = 'https://example.com/api?name=John%2520Doe';
expect(encodeUrl(url)).toBe(expected);
});
it('should double-encode pre-encoded @ in query value (%40 → %2540)', () => {
const url = 'https://example.com/api?email=john%40example.com';
const expected = 'https://example.com/api?email=john%2540example.com';
expect(encodeUrl(url)).toBe(expected);
});
it('should double-encode pre-encoded pipe in query value (%7C → %257C)', () => {
const url = 'https://example.com/api?filter=status%7Cactive&sort=name%7Casc';
const expected = 'https://example.com/api?filter=status%257Cactive&sort=name%257Casc';
expect(encodeUrl(url)).toBe(expected);
});
it('should double-encode redirect URL with pre-encoded chars (the canonical #5507 case)', () => {
const url = 'https://auth.example.com/login?redirect=https%3A%2F%2Fother.com%2Fcb';
const expected = 'https://auth.example.com/login?redirect=https%253A%252F%252Fother.com%252Fcb';
expect(encodeUrl(url)).toBe(expected);
});
it('should double-encode pre-encoded %25 → %2525 (single % → %25 same source bytes)', () => {
const url = 'https://example.com/api?coupon=50%25';
const expected = 'https://example.com/api?coupon=50%2525';
expect(encodeUrl(url)).toBe(expected);
});
it('should encode bare % once to %25 in query value', () => {
const url = 'https://example.com/api?discount=50%';
const expected = 'https://example.com/api?discount=50%25';
expect(encodeUrl(url)).toBe(expected);
});
});
describe('real-world scenarios', () => {
it('should handle API URLs with complex query parameters', () => {
const url = 'https://api.github.com/search/repositories?q=language:javascript&sort=stars&order=desc&per_page=10';
@@ -152,6 +256,12 @@ describe('encodeUrl', () => {
const expected = 'https://api.shop.com/products?category=electronics&brand=apple%7Csamsung%7Cgoogle&price_range=100%3A1000&rating=4.5%3A5.0&availability=in_stock&sort=price%3Aasc&limit=50';
expect(encodeUrl(url)).toBe(expected);
});
it('should handle a JSON-shaped array value (canonical #7913 reproducer)', () => {
const url = 'https://example.com/api?testArray=[[1, 2, 3], ["string", "string"]]';
const expected = 'https://example.com/api?testArray=%5B%5B1%2C%202%2C%203%5D%2C%20%5B%22string%22%2C%20%22string%22%5D%5D';
expect(encodeUrl(url)).toBe(expected);
});
});
});
@@ -259,6 +369,43 @@ describe('buildQueryString', () => {
const result = buildQueryString(params);
expect(result).toBe('seat=&table=2');
});
it('should encode structural chars in values when encode is true (issue #5788 path)', () => {
// # & = ? + in values — structural in URL grammar, must be encoded so the params
// round-trip correctly. This is the contract the collections reducers rely on.
const params = [
{ name: 'tag', value: 'test#abc' },
{ name: 'a', value: 'x&y' },
{ name: 'b', value: 'x=y' },
{ name: 'c', value: 'x?y' },
{ name: 'd', value: 'x+y' },
{ name: 'e', value: 'hello world' }
];
const result = buildQueryString(params, { encode: true });
expect(result).toBe('tag=test%23abc&a=x%26y&b=x%3Dy&c=x%3Fy&d=x%2By&e=hello%20world');
});
});
describe('safeDecodeURIComponent', () => {
it('should decode a well-formed escape sequence', () => {
expect(safeDecodeURIComponent('hello%20world')).toBe('hello world');
});
it('should leave a bare % alone (would otherwise throw)', () => {
expect(safeDecodeURIComponent('50%')).toBe('50%');
});
it('should decode well-formed escapes but leave bare % intact in mixed input', () => {
expect(safeDecodeURIComponent('50% off %20 sale')).toBe('50% off sale');
});
it('should return empty string unchanged', () => {
expect(safeDecodeURIComponent('')).toBe('');
});
it('should be a no-op when there is nothing to decode', () => {
expect(safeDecodeURIComponent('hello')).toBe('hello');
});
});
describe('hasExplicitScheme', () => {

View File

@@ -51,8 +51,20 @@ interface BuildQueryStringOptions {
interface ExtractQueryParamsOptions {
decode?: boolean;
/**
* When `true` (default), anything after `#` is discarded before splitting
* on `&` — matches the URL-bar reducer's view where `#` is the fragment
* delimiter. Pass `false` from callers that want `#` to round-trip as data
* (e.g., `encodeUrl`, which encodes `#` to `%23` rather than treating it
* as a structural fragment marker).
*/
stripFragment?: boolean;
}
// Per PR #5507's design contract: when encode is true, run encodeURIComponent on the
// value even if it's already encoded. Pre-encoded inputs intentionally double-encode
// (e.g. `%23` → `%2523`) — useful for redirect URLs where the server expects to
// receive the encoded form after one round of URL-decoding.
function buildQueryString(paramsArray: QueryParam[], { encode = false }: BuildQueryStringOptions = {}): string {
return paramsArray
.filter(({ name }) => typeof name === 'string' && name.trim().length > 0)
@@ -69,13 +81,13 @@ function buildQueryString(paramsArray: QueryParam[], { encode = false }: BuildQu
.join('&');
}
function parseQueryParams(query: string, { decode = false }: ExtractQueryParamsOptions = {}): QueryParam[] {
function parseQueryParams(query: string, { decode = false, stripFragment = true }: ExtractQueryParamsOptions = {}): QueryParam[] {
if (!query || !query.length) {
return [];
}
try {
const [queryString, ...hashParts] = query.split('#');
const queryString = stripFragment ? query.split('#')[0] : query;
const pairs = queryString.split('&');
const params = pairs.map((pair) => {
@@ -102,28 +114,97 @@ function parseQueryParams(query: string, { decode = false }: ExtractQueryParamsO
}
}
// decodeURIComponent throws on bare '%' or malformed %XX. Forgiving variant that
// decodes well-formed escapes and leaves anything else alone. Exported so other
// modules can use it without each one inventing its own try/catch. Not used
// inside encodeUrl itself (PR #5507's design is content-blind — no decode-encode).
const safeDecodeURIComponent = (s: string): string => {
try {
return decodeURIComponent(s);
} catch {
return s.replace(/%[0-9A-Fa-f]{2}/g, (m) => {
try {
return decodeURIComponent(m);
} catch {
return m;
}
});
}
};
// Path-side encoding is idempotent: decode any already-encoded sequence first,
// then re-encode. The reason — `interpolateVars` in the runtime goes through
// `new URL(url).pathname`, which auto-encodes path chars (`"` → `%22`,
// ` ` → `%20`, etc.) before the request reaches `encodeUrl()` at the wire
// boundary. Without `safeDecodeURIComponent` here, the second pass would
// content-blind-encode again (`%22` → `%2522`) and the wire URL would be
// double-encoded. By decoding-then-encoding we collapse both cases (raw input
// + already-encoded input) to the same single-encoded form.
//
// NOTE: this idempotency is path-side only. The QUERY side stays content-blind
// per PR #5507's contract — query values are user data and pre-encoded inputs
// are a legitimate signal that the user wants the encoding to survive a server
// URL-decode pass (the redirect-URL use case). See `encodeUrl` below.
const encodePathSegments = (path: string): string =>
path
.split('/')
.map((segment) => encodeURIComponent(safeDecodeURIComponent(segment)))
.join('/');
// Encodes path segments and query name/value pairs when the URL Encoding toggle is on.
// Content-blind per PR #5507's design contract: applying it to an already-encoded
// input intentionally double-encodes (e.g. `?q=%20` → `?q=%2520`).
//
// `#` is treated as **data**, not as the RFC 3986 §3.5 fragment delimiter:
// `?token=abc#def` → `?token=abc%23def` (toggle ON)
// The previous strip-or-preserve behavior caused surprising UX (silent data
// loss in the snippet, or asymmetric ON/OFF behavior). Since Bruno is an
// HTTP API client where fragments are essentially never useful on the wire,
// encoding `#` as data is the predictable choice. To send a URL with a
// literal `#section` fragment, toggle OFF — OFF preserves the user's URL
// byte-for-byte.
const encodeUrl = (url: string): string => {
// Early return for invalid input
if (!url || typeof url !== 'string') {
return url;
}
const [urlWithoutHash, ...hashFragments] = url.split('#');
const [basePath, ...queryString] = urlWithoutHash.split('?');
// Separate origin+path from the query string. `#` is intentionally NOT
// extracted as a fragment — it flows through as data and ends up encoded
// as `%23` either in the path (`encodePathSegments`) or the query value
// (`encodeURIComponent` via the parseQueryParams → rebuild loop below).
const queryIdx = url.indexOf('?');
const originAndPath = queryIdx >= 0 ? url.slice(0, queryIdx) : url;
const queryString = queryIdx >= 0 ? url.slice(queryIdx + 1) : '';
// If no query parameters exist, return original URL
if (!queryString || queryString.length === 0) {
return url;
// Preserve scheme + authority verbatim; only encode the path segments.
// [^/?#]* matches everything in the authority including userinfo, host, port,
// and bracketed IPv6 literals. `#` is excluded so a misplaced `#` (e.g.
// `https://example.com#bad/path`) is treated as part of path and encoded.
const originMatch = originAndPath.match(/^([a-z][a-z0-9+.-]*:\/\/[^/?#]*)?(.*)$/i);
const origin = originMatch?.[1] ?? '';
const path = originMatch?.[2] ?? originAndPath;
let result = origin + encodePathSegments(path);
if (queryIdx >= 0) {
// stripFragment: false so `#` in the query value is treated as a literal
// byte and gets encoded to `%23` by the encodeURIComponent below.
const params = parseQueryParams(queryString, { decode: false, stripFragment: false });
const rebuilt = params
.map(({ name, value }) => {
const encodedName = encodeURIComponent(name);
if (value === undefined) {
return encodedName;
}
const encodedValue = encodeURIComponent(value);
return `${encodedName}=${encodedValue}`;
})
.filter((pair) => pair.length > 0 && !pair.startsWith('='))
.join('&');
result += `?${rebuilt}`;
}
const queryParams = parseQueryParams(queryString.join('?'), { decode: false });
// Parse and re-encode query parameters
const encodedQueryString = buildQueryString(queryParams, { encode: true });
// Reconstruct URL with encoded query parameters
const encodedUrl = `${basePath}?${encodedQueryString}${hashFragments.length > 0 ? `#${hashFragments.join('#')}` : ''}`;
return encodedUrl;
return result;
};
/**
@@ -144,6 +225,7 @@ export {
parseQueryParams,
buildQueryString,
stripOrigin,
safeDecodeURIComponent,
type QueryParam,
type BuildQueryStringOptions,
type ExtractQueryParamsOptions

View File

@@ -14,6 +14,58 @@ router.all('/headers', (req, res) => {
});
});
// httpbin.org/anything-style echo — returns the full request shape so e2e
// tests can verify exactly what reached the server. Used by the # encoding
// scenarios in tests/request/generate-code/send-vs-snippet.spec.ts so the
// suite doesn't depend on the public httpbin.org service (which 503s under
// load). Mimics httpbin's URL-decode-for-display so assertions can still
// match against the human-readable `#authentication` form even when the
// wire URL had `%23authentication`.
router.all('/anything/*', (req, res) => {
let bodyData = '';
let formData = {};
let jsonData = null;
if (req.body !== undefined && req.body !== null) {
if (Buffer.isBuffer(req.body)) {
bodyData = req.body.toString();
} else if (typeof req.body === 'string') {
bodyData = req.body;
} else if (typeof req.body === 'object') {
const ct = (req.headers['content-type'] || '').toLowerCase();
if (ct.includes('application/json')) {
jsonData = req.body;
} else if (ct.includes('x-www-form-urlencoded') || ct.includes('multipart/form-data')) {
formData = req.body;
} else {
try { bodyData = JSON.stringify(req.body); } catch { bodyData = ''; }
}
}
}
// URL-decode `req.originalUrl` for the `url` field so callers can assert
// against the decoded form (e.g. `#authentication`) — same shape httpbin
// returns. Falls back to the raw URL if decoding fails (malformed %XX).
let displayUrl = req.originalUrl;
try {
displayUrl = decodeURIComponent(req.originalUrl);
} catch {
// keep raw
}
return res.json({
args: req.query || {},
data: bodyData,
files: {},
form: formData,
headers: req.headers,
json: jsonData,
method: req.method,
origin: req.ip || '127.0.0.1',
url: `${req.protocol}://${req.get('host')}${displayUrl}`
});
});
router.post('/json', (req, res) => {
return res.json(req.body);
});

View File

@@ -99,9 +99,9 @@ test.describe('Draft values are used in requests', () => {
// Check that the generated code contains the draft header
// The header appears as a --header argument in the generated curl/httpie/wget command
await expect(generatedCodeEditor).toContainText('x-draft-header');
await expect(generatedCodeEditor).toContainText('X-Draft-Header');
await expect(generatedCodeEditor).toContainText('draft-value-123');
await expect(generatedCodeEditor).toContainText('x-folder-draft-header');
await expect(generatedCodeEditor).toContainText('X-Folder-Draft-Header');
await expect(generatedCodeEditor).toContainText('folder-draft-value-123');
// Close the modal by clicking the X button using the test id

View File

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

View File

@@ -0,0 +1,15 @@
meta {
name: docs-fragment-external
type: http
seq: 33
}
get {
url: http://localhost:8081/api/echo/anything/docs/api#authentication
body: none
auth: none
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,19 @@
meta {
name: fragment-preserved
type: http
seq: 10
}
get {
url: http://localhost:8081/api/echo/anything/api?name=john doe#section1
body: none
auth: none
}
params:query {
name: john doe#section1
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,15 @@
meta {
name: oauth-callback-fragment
type: http
seq: 32
}
get {
url: http://localhost:8081/api/echo/anything/callback#access_token=abc123&token_type=Bearer
body: none
auth: none
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,21 @@
meta {
name: params-path-odata
type: http
seq: 11
}
get {
url: http://localhost:8081/api/echo/anything/odata/Category(':CategoryID')/Item(:ItemId)/:xpath/Tags("tag test")
body: none
auth: none
}
params:path {
CategoryID: category123
ItemId: item456
xpath: foobar
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,19 @@
meta {
name: params-path-space
type: http
seq: 11
}
get {
url: http://localhost:8081/api/echo/anything/users/:id
body: none
auth: none
}
params:path {
id: aaa bbb
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,15 @@
meta {
name: path-brackets
type: http
seq: 7
}
get {
url: http://localhost:8081/api/echo/anything/list[123]
body: none
auth: none
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,15 @@
meta {
name: path-fragment
type: http
seq: 28
}
get {
url: http://localhost:8081/api/echo/anything/hash#tag
body: none
auth: none
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,15 @@
meta {
name: path-idempotent
type: http
seq: 9
}
get {
url: http://localhost:8081/api/echo/anything/api/path%20with%20spaces/users
body: none
auth: none
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,15 @@
meta {
name: path-issues-fragment
type: http
seq: 30
}
get {
url: http://localhost:8081/api/echo/anything/issues#1234
body: none
auth: none
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,15 @@
meta {
name: path-odata
type: http
seq: 9
}
get {
url: http://localhost:8081/api/echo/anything/odata/Products(123)/Categories(456)?$expand=Items&$filter=Price gt 10
body: none
auth: none
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,19 @@
meta {
name: path-param-ampersand
type: http
seq: 17
}
get {
url: http://localhost:8081/api/echo/anything/users/:id
body: none
auth: none
}
params:path {
id: a&b
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,19 @@
meta {
name: path-param-at
type: http
seq: 21
}
get {
url: http://localhost:8081/api/echo/anything/users/:id
body: none
auth: none
}
params:path {
id: user@host
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,19 @@
meta {
name: path-param-braces
type: http
seq: 26
}
get {
url: http://localhost:8081/api/echo/anything/users/:id
body: none
auth: none
}
params:path {
id: {x}
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,19 @@
meta {
name: path-param-brackets
type: http
seq: 25
}
get {
url: http://localhost:8081/api/echo/anything/users/:id
body: none
auth: none
}
params:path {
id: list[1]
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,19 @@
meta {
name: path-param-colon
type: http
seq: 22
}
get {
url: http://localhost:8081/api/echo/anything/users/:id
body: none
auth: none
}
params:path {
id: 2026-01-15T10:30:00
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,19 @@
meta {
name: path-param-comma
type: http
seq: 23
}
get {
url: http://localhost:8081/api/echo/anything/users/:id
body: none
auth: none
}
params:path {
id: a,b,c
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,19 @@
meta {
name: path-param-equals
type: http
seq: 18
}
get {
url: http://localhost:8081/api/echo/anything/users/:id
body: none
auth: none
}
params:path {
id: a=b
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,19 @@
meta {
name: path-param-hash-trailing
type: http
seq: 29
}
get {
url: http://localhost:8081/api/echo/anything/users/:id/profile
body: none
auth: none
}
params:path {
id: john#doe
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,19 @@
meta {
name: path-param-hash
type: http
seq: 15
}
get {
url: http://localhost:8081/api/echo/anything/users/:id
body: none
auth: none
}
params:path {
id: aaa#bbb
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,19 @@
meta {
name: path-param-pipe
type: http
seq: 27
}
get {
url: http://localhost:8081/api/echo/anything/users/:id
body: none
auth: none
}
params:path {
id: a|b
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,19 @@
meta {
name: path-param-plus
type: http
seq: 19
}
get {
url: http://localhost:8081/api/echo/anything/users/:id
body: none
auth: none
}
params:path {
id: a+b
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,19 @@
meta {
name: path-param-question
type: http
seq: 20
}
get {
url: http://localhost:8081/api/echo/anything/users/:id
body: none
auth: none
}
params:path {
id: a?b
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,19 @@
meta {
name: path-param-slash
type: http
seq: 14
}
get {
url: http://localhost:8081/api/echo/anything/users/:id
body: none
auth: none
}
params:path {
id: aaa/bbb
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,19 @@
meta {
name: path-param-space
type: http
seq: 16
}
get {
url: http://localhost:8081/api/echo/anything/users/:id
body: none
auth: none
}
params:path {
id: John Doe
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,19 @@
meta {
name: path-param-unicode
type: http
seq: 24
}
get {
url: http://localhost:8081/api/echo/anything/users/:id
body: none
auth: none
}
params:path {
id: José
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,15 @@
meta {
name: path-spa-route
type: http
seq: 31
}
get {
url: http://localhost:8081/api/echo/anything/spa#/dashboard/settings
body: none
auth: none
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,15 @@
meta {
name: path-spaces
type: http
seq: 6
}
get {
url: http://localhost:8081/api/echo/anything/api/path with spaces/users
body: none
auth: none
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,15 @@
meta {
name: path-unicode
type: http
seq: 8
}
get {
url: http://localhost:8081/api/echo/anything/users/José/profile
body: none
auth: none
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,22 @@
meta {
name: query-arrays
type: http
seq: 13
}
get {
url: http://localhost:8081/api/echo/anything/api?empty=[]&nums=[1, 2, 3]&strs=["string", "string"]&nested=[[1, 2, 3], ["string", "string"]]
body: none
auth: none
}
params:query {
empty: []
nums: [1, 2, 3]
strs: ["string", "string"]
nested: [[1, 2, 3], ["string", "string"]]
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,20 @@
meta {
name: query-commas-colons
type: http
seq: 6
}
get {
url: http://localhost:8081/api/echo/anything/filter?tags=a,b,c&time=10:30
body: none
auth: none
}
params:query {
tags: a,b,c
time: 10:30
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,20 @@
meta {
name: query-double-encode
type: http
seq: 12
}
get {
url: http://localhost:8081/api/echo/anything/login?redirect=https%3A%2F%2Fother.com%2Fcb&token=abc%2520xyz
body: none
auth: none
}
params:query {
redirect: https%3A%2F%2Fother.com%2Fcb
token: abc%2520xyz
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,19 @@
meta {
name: query-email-plus
type: http
seq: 5
}
get {
url: http://localhost:8081/api/echo/anything/invite?email=test+alias@example.com
body: none
auth: none
}
params:query {
email: test+alias@example.com
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,20 @@
meta {
name: query-equals
type: http
seq: 3
}
get {
url: http://localhost:8081/api/echo/anything/api?token=abc123==&type=test
body: none
auth: none
}
params:query {
token: abc123==
type: test
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,19 @@
meta {
name: query-hash
type: http
seq: 9
}
get {
url: http://localhost:8081/api/echo/anything/api?query=aaa#bbb
body: none
auth: none
}
params:query {
query: aaa#bbb
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,20 @@
meta {
name: query-pipe
type: http
seq: 4
}
get {
url: http://localhost:8081/api/echo/anything/api?filter=status|active&sort=name|asc
body: none
auth: none
}
params:query {
filter: status|active
sort: name|asc
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,20 @@
meta {
name: query-preencoded
type: http
seq: 2
}
get {
url: http://localhost:8081/api/echo/anything/api?name=John%20Doe&email=john%40example.com
body: none
auth: none
}
params:query {
name: John%20Doe
email: john%40example.com
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,20 @@
meta {
name: query-redirect-url
type: http
seq: 3
}
get {
url: http://localhost:8081/api/echo/anything/api?path=/users/123&redirect=https://other.com
body: none
auth: none
}
params:query {
path: /users/123
redirect: https://other.com
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,20 @@
meta {
name: query-spaces
type: http
seq: 1
}
get {
url: http://localhost:8081/api/echo/anything/api?name=John Doe&age=25
body: none
auth: none
}
params:query {
name: John Doe
age: 25
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,20 @@
meta {
name: query-unicode
type: http
seq: 5
}
get {
url: http://localhost:8081/api/echo/anything/api?name=José&city=München
body: none
auth: none
}
params:query {
name: José
city: München
}
settings {
encodeUrl: false
}

View File

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

View File

@@ -0,0 +1,12 @@
{
"maximized": false,
"lastOpenedCollections": [
"{{projectRoot}}/tests/request/generate-code/collection"
],
"preferences": {
"onboarding": {
"hasLaunchedBefore": true,
"hasSeenWelcomeModal": true
}
}
}

View File

@@ -0,0 +1,183 @@
import { expect, Page, test } from '../../../playwright';
import { openCollection, openRequestInFolder, sendRequest, setUrlEncoding } from '../../utils/page';
const COLLECTION = 'generate-code-encoding';
const FOLDER = 'requests';
test.describe('Send Request — every fixture, ON and OFF', () => {
const fixtures = [
// query-side
'query-spaces',
'query-preencoded',
'query-redirect-url',
'query-pipe',
'query-unicode',
'query-equals',
'query-email-plus',
'query-commas-colons',
'query-double-encode',
'query-hash',
'query-arrays',
'fragment-preserved',
// path-side
'path-spaces',
'path-brackets',
'path-unicode',
'path-idempotent',
'path-odata',
'path-fragment',
'path-issues-fragment',
'path-spa-route',
'oauth-callback-fragment',
// params:path
'params-path-odata',
'params-path-space',
'path-param-slash',
'path-param-hash',
'path-param-hash-trailing',
'path-param-space',
'path-param-ampersand',
'path-param-equals',
'path-param-plus',
'path-param-question',
'path-param-at',
'path-param-colon',
'path-param-comma',
'path-param-unicode',
'path-param-brackets',
'path-param-braces',
'path-param-pipe'
];
const expectEchoResponded = async (page: Page) => {
const texts = await page
.getByTestId('response-preview-container')
.locator('.CodeMirror-scroll')
.allInnerTexts();
// Echo server returns `{ "url": "/path/..." }`. Asserting the `"url":`
// marker is present is enough to confirm we hit the echo route (not a
// 404 / error page) without pinning the encoded byte-form, which is
// mode-dependent and the actual point of inspection here.
expect(texts.some((t: string) => t.includes('"url":'))).toBe(true);
};
for (const file of fixtures) {
test(`${file} — send with toggle ON then OFF`, async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, file);
// ON
await setUrlEncoding(page, true);
await sendRequest(page, 200);
await expectEchoResponded(page);
// OFF
await setUrlEncoding(page, false);
await sendRequest(page, 200);
await expectEchoResponded(page);
});
}
});
/**
* Tests against the local Bruno echo server (`/api/echo/anything/*`) — covers
* the # encoding decision-tree scenarios from fixings/snippet-vs-sendrequest.md.
*
* The echo server returns the request shape (args/data/headers/method/url) in
* the JSON body, mimicking httpbin.org/anything. Each test sends and asserts
* the substring that proves the right URL reached the server. Both ON and OFF
* return 200 (the route matches any path); the distinction is in *what URL*
* the server saw.
*
* Fixtures used:
* - docs-fragment-external → Scenario 1: page anchor (#authentication)
* - path-issues-fragment → Scenario 4: issue tracker (/issues/#1234)
* - path-spa-route → Scenario 5: SPA hash route (/#/dashboard)
* - oauth-callback-fragment → Scenario 8: OAuth implicit-flow callback
*
* Switched from httpbin.org → local echo because the public httpbin was
* returning 502/503 under load and making this suite flaky.
*/
const expectEchoReceived = async (page: Page, expectedUrlSubstring: string) => {
const texts = await page
.getByTestId('response-preview-container')
.locator('.CodeMirror-scroll')
.allInnerTexts();
// /api/echo/anything/* returns `{ "url": "http://localhost:8081/api/echo/anything/..." }`.
// We assert the expected URL substring appears in the response — that
// confirms what the server actually received on the wire.
expect(texts.some((t: string) => t.includes(expectedUrlSubstring))).toBe(true);
};
// Negative-case helper. Asserts the response body does NOT contain the
// forbidden substring — used to prove that the fragment was stripped on wire
// for OFF-mode tests (otherwise an `includes` on the path-only prefix would
// pass even if Bruno wrongly leaked the fragment through).
const expectEchoDidNotReceive = async (page: Page, forbiddenSubstring: string) => {
const texts = await page
.getByTestId('response-preview-container')
.locator('.CodeMirror-scroll')
.allInnerTexts();
expect(texts.some((t: string) => t.includes(forbiddenSubstring))).toBe(false);
};
test.describe('Send Request — # encoding scenarios (local echo)', () => {
test('Scenario 1: page anchor — OFF strips #authentication on wire', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'docs-fragment-external');
await setUrlEncoding(page, false);
await sendRequest(page, 200);
await expectEchoReceived(page, 'http://localhost:8081/api/echo/anything/docs/api');
await expectEchoDidNotReceive(page, '#authentication');
});
test('Scenario 1 (ON variant): page anchor — # encoded as data reaches server', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'docs-fragment-external');
await setUrlEncoding(page, true);
await sendRequest(page, 200);
await expectEchoReceived(page, 'http://localhost:8081/api/echo/anything/docs/api#authentication');
});
test('Scenario 4a: issue tracker — OFF strips #1234 on wire', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'path-issues-fragment');
await setUrlEncoding(page, false);
await sendRequest(page, 200);
// Fixture URL is /anything/issues#1234 (not /anything/issues/#1234 from
// the docs table) — we use a non-trailing-slash shape that worked across
// both the public httpbin (older runs) and the local echo (current).
await expectEchoReceived(page, 'http://localhost:8081/api/echo/anything/issues');
await expectEchoDidNotReceive(page, '#1234');
});
test('Scenario 4b: issue tracker — ON encodes #1234, server sees full path', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'path-issues-fragment');
await setUrlEncoding(page, true);
await sendRequest(page, 200);
await expectEchoReceived(page, 'http://localhost:8081/api/echo/anything/issues#1234');
});
test('Scenario 5: SPA hash-route — OFF strips everything after #', async ({ pageWithUserData: page }) => {
// Fixture URL is /anything/spa#/dashboard/settings (added the /spa segment
// to keep the URL non-trailing-slash, same reason as Scenario 4a).
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'path-spa-route');
await setUrlEncoding(page, false);
await sendRequest(page, 200);
await expectEchoReceived(page, 'http://localhost:8081/api/echo/anything/spa');
await expectEchoDidNotReceive(page, 'dashboard');
});
test('Scenario 8: OAuth callback — OFF strips token payload on wire', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'oauth-callback-fragment');
await setUrlEncoding(page, false);
await sendRequest(page, 200);
// OFF: fragment (incl. access_token) never reaches the server — that's the
// OAuth implicit-flow security property.
await expectEchoReceived(page, 'http://localhost:8081/api/echo/anything/callback');
await expectEchoDidNotReceive(page, 'access_token');
});
});

View File

@@ -0,0 +1,380 @@
import { expect, test } from '../../../playwright';
import { closeGenerateCodeDialog, getGeneratedSnippet, openCollection, openRequestInFolder, setUrlEncoding } from '../../utils/page';
const COLLECTION = 'generate-code-encoding';
const FOLDER = 'requests';
test.describe('Generate Code URL Encoding OFF', () => {
test.describe('Query preservation', () => {
test('preserves literal spaces in query values (no %20)', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'query-spaces');
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?name=John Doe&age=25');
await closeGenerateCodeDialog(page);
});
test('preserves pre-encoded %20 / %40 without double-encoding', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'query-preencoded');
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?name=John%20Doe&email=john%40example.com');
await closeGenerateCodeDialog(page);
});
test('preserves equals signs in query values (token=abc123==)', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'query-equals');
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?token=abc123==&type=test');
await closeGenerateCodeDialog(page);
});
test('preserves redirect URL with colons and slashes', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'query-redirect-url');
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?path=/users/123&redirect=https://other.com');
await closeGenerateCodeDialog(page);
});
test('preserves email with + alias and @ (test+alias@example.com)', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'query-email-plus');
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/invite?email=test+alias@example.com');
await closeGenerateCodeDialog(page);
});
test('preserves comma-separated values and colon (tags=a,b,c&time=10:30)', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'query-commas-colons');
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/filter?tags=a,b,c&time=10:30');
await closeGenerateCodeDialog(page);
});
test('preserves pipe operator in query values (no %7C)', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'query-pipe');
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?filter=status|active&sort=name|asc');
await closeGenerateCodeDialog(page);
});
test('preserves unicode in query values (no %C3%A9 / %C3%BC)', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'query-unicode');
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?name=José&city=München');
await closeGenerateCodeDialog(page);
});
test('preserves already-double-encoded values verbatim (mirror of ON canonical case)', async ({ pageWithUserData: page }) => {
// Same fixture URL the ON spec uses — but here the toggle is OFF, so
// the user-typed bytes round-trip unchanged. This is the contract a
// user expects when they've already encoded their redirect URL
// themselves and don't want Bruno to touch it.
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'query-double-encode');
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain(
'http://localhost:8081/api/echo/anything/login?redirect=https%3A%2F%2Fother.com%2Fcb&token=abc%2520xyz'
);
await closeGenerateCodeDialog(page);
});
test('preserves # literal in query value (no encoding)', async ({ pageWithUserData: page }) => {
// `?query=aaa#bbb` stays as `?query=aaa#bbb`. OFF mode is the only mode
// that retains fragment semantics — the `#` survives as a literal byte
// in the displayed snippet (curl/fetch will treat it as a fragment on
// the wire and drop it, but that's outside the snippet's concern).
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'query-hash');
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?query=aaa#bbb');
await closeGenerateCodeDialog(page);
});
test('preserves JSON-shaped array query values verbatim (no encoding, no validator error)', async ({ pageWithUserData: page }) => {
// Literal `[` `]` `"` `,` and space would be rejected by HTTPSnippet's
// HAR validator without the pre-encode-then-replace-back path. The
// snippet still ends up containing the raw form.
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'query-arrays');
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).not.toBe('Error generating code snippet');
expect(snippet).toContain(
'http://localhost:8081/api/echo/anything/api?empty=[]&nums=[1, 2, 3]&strs=["string", "string"]&nested=[[1, 2, 3], ["string", "string"]]'
);
await closeGenerateCodeDialog(page);
});
});
test.describe('Path preservation', () => {
test('preserves literal spaces in path segments', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'path-spaces');
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api/path with spaces/users');
await closeGenerateCodeDialog(page);
});
test('preserves square brackets in path segments (no Error generating code snippet)', async ({ pageWithUserData: page }) => {
// HTTPSnippet's HAR validator rejects literal `[` `]` — without the
// pre-encode step in snippet-generator this returns the error string.
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'path-brackets');
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).not.toBe('Error generating code snippet');
expect(snippet).toContain('http://localhost:8081/api/echo/anything/list[123]');
await closeGenerateCodeDialog(page);
});
test('preserves unicode in path segments (no %C3%A9)', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'path-unicode');
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/users/José/profile');
await closeGenerateCodeDialog(page);
});
test('preserves pre-encoded %20 in path verbatim (no decode, no re-encode)', async ({ pageWithUserData: page }) => {
// Mirror of the idempotency check ON does — OFF should leave the
// already-encoded `%20` exactly as the user typed it.
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'path-idempotent');
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api/path%20with%20spaces/users');
await closeGenerateCodeDialog(page);
});
test('preserves OData-style parenthesized path params and $ filters', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'path-odata');
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain(
'http://localhost:8081/api/echo/anything/odata/Products(123)/Categories(456)?$expand=Items&$filter=Price gt 10'
);
await closeGenerateCodeDialog(page);
});
test('preserves URL fragment in snippet (intentional asymmetry vs ON)', async ({ pageWithUserData: page }) => {
// Raw mode honors user intent — fragment is kept verbatim. ON mode
// encodes `#` to %23 as data. See snippet-generator.spec.js for the
// designed-behavior comment.
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'fragment-preserved');
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?name=john doe#section1');
await closeGenerateCodeDialog(page);
});
test('preserves # directly in path (no query ahead of it)', async ({ pageWithUserData: page }) => {
// Variant of fragment-preserved where the `#` sits in the path with no
// `?` before it. OFF mode keeps the URL byte-for-byte in the snippet
// (`hash#tag`). On the wire, downstream HTTP clients treat `#tag` as a
// fragment and strip it — so the server receives `/hash` only. The
// snippet still shows the user's literal input; that's the OFF contract.
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'path-fragment');
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/hash#tag');
await closeGenerateCodeDialog(page);
});
test('preserves /issues/#1234 verbatim (semantic-path fragment)', async ({ pageWithUserData: page }) => {
// Scenario 4: GitHub/GitLab-style issue link with `#` after a trailing
// slash. OFF mode keeps the user's typed form intact in the snippet.
// Note: on the wire, HTTP clients strip everything from `#` onwards,
// so the server only ever sees `/issues/` — that's the deliberate cost
// of OFF mode for fragment-style URLs.
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'path-issues-fragment');
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/issues#1234');
await closeGenerateCodeDialog(page);
});
test('preserves SPA hash-router URL (/#/dashboard/settings)', async ({ pageWithUserData: page }) => {
// Scenario 5: legacy SPA hash-routing. OFF mode keeps the literal `#`
// so the snippet matches what the user typed. The wire only sees `/path/`
// (axios strips the fragment) — fine for SPAs since the JS reads
// location.hash to decide what to render.
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'path-spa-route');
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/spa#/dashboard/settings');
await closeGenerateCodeDialog(page);
});
test('preserves OAuth callback fragment verbatim', async ({ pageWithUserData: page }) => {
// Scenario 8: OAuth implicit-flow callback. The fragment is the entire
// point — it keeps the access_token out of server logs. OFF mode
// preserves the literal `#access_token=…` in the snippet so users can
// see/copy the URL form they typed.
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'oauth-callback-fragment');
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain(
'http://localhost:8081/api/echo/anything/callback#access_token=abc123&token_type=Bearer'
);
await closeGenerateCodeDialog(page);
});
});
test.describe('Path-params table (params:path)', () => {
test('does not throw for path-param value with literal space (regression: user-reported `aaa bbb`)', async ({ pageWithUserData: page }) => {
// Repro: URL `http://localhost:8081/api/echo/anything/users/:id` with `id = aaa bbb`.
// After interpolateUrlPathParams (raw mode) the URL has a literal space:
// `http://localhost:8081/api/echo/anything/users/aaa bbb`. HTTPSnippet's HAR validator
// rejects it → "Error generating code snippet". The pre-encode-then-
// replace-back path in snippet-generator preserves the raw form.
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'params-path-space');
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).not.toBe('Error generating code snippet');
expect(snippet).toContain('http://localhost:8081/api/echo/anything/users/aaa bbb');
await closeGenerateCodeDialog(page);
});
test('path-param value with # — /profile suffix kept in snippet (but lost on wire)', async ({ pageWithUserData: page }) => {
// Scenario 3 from the # encoding decision tree: when `:id = john#doe`
// and the URL template has `/profile` after `:id`, OFF mode keeps
// `/profile` visible in the snippet (so the user sees what they typed).
// On the wire, downstream HTTP clients strip `#doe/profile` as a
// fragment — that's the cost of OFF for this shape. To send the full
// path to the server, toggle ON (encodes `#` to `%23`).
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'path-param-hash-trailing');
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/users/john#doe/profile');
await closeGenerateCodeDialog(page);
});
test('OData-style path with literal Tags("tag test") is preserved verbatim', async ({ pageWithUserData: page }) => {
// Mirror of the ON canonical regression. In OFF mode all the literal
// `"`, space, and quoted `:CategoryID` characters survive in the
// displayed snippet exactly as the path-param substitution emits them.
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'params-path-odata');
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).not.toBe('Error generating code snippet');
expect(snippet).toContain(
'http://localhost:8081/api/echo/anything/odata/Category(\'category123\')/Item(item456)/foobar/Tags("tag test")'
);
await closeGenerateCodeDialog(page);
});
// Path-param substitution matrix — OFF mirror of url-encoding-on.spec.ts.
// In raw mode the snippet preserves the user-typed value byte-for-byte
// (the pre-encode-then-replace-back path in snippet-generator restores
// the form after HAR validation). Caveats on the / # ? rows are the same
// as in the ON spec — the literal-in-value semantic is lost as soon as
// the char lands in the URL string, but OFF mode at least keeps those
// bytes visible (including the `#bbb` fragment, intentional per the
// designed-behavior comment in snippet-generator).
const pathParamCases: Array<{ name: string; file: string; expected: string }> = [
{ name: '/ in value preserved (looks like two segments)', file: 'path-param-slash', expected: 'http://localhost:8081/api/echo/anything/users/aaa/bbb' },
{ name: '# in value preserved as fragment marker (intentional asymmetry vs ON)', file: 'path-param-hash', expected: 'http://localhost:8081/api/echo/anything/users/aaa#bbb' },
{ name: 'space in value preserved (John Doe stays literal)', file: 'path-param-space', expected: 'http://localhost:8081/api/echo/anything/users/John Doe' },
{ name: '& in value preserved (a&b stays literal)', file: 'path-param-ampersand', expected: 'http://localhost:8081/api/echo/anything/users/a&b' },
{ name: '= in value preserved (a=b stays literal)', file: 'path-param-equals', expected: 'http://localhost:8081/api/echo/anything/users/a=b' },
{ name: '+ in value preserved (a+b stays literal)', file: 'path-param-plus', expected: 'http://localhost:8081/api/echo/anything/users/a+b' },
{ name: '? in value preserved (becomes query separator — literal lost)', file: 'path-param-question', expected: 'http://localhost:8081/api/echo/anything/users/a?b' },
{ name: '@ in value preserved (user@host stays literal)', file: 'path-param-at', expected: 'http://localhost:8081/api/echo/anything/users/user@host' },
{ name: ': in value preserved (ISO timestamp stays literal)', file: 'path-param-colon', expected: 'http://localhost:8081/api/echo/anything/users/2026-01-15T10:30:00' },
{ name: ', in value preserved (a,b,c stays literal)', file: 'path-param-comma', expected: 'http://localhost:8081/api/echo/anything/users/a,b,c' },
{ name: 'unicode in value preserved (José stays literal)', file: 'path-param-unicode', expected: 'http://localhost:8081/api/echo/anything/users/José' },
{ name: '[ ] in value preserved (list[1] stays literal, no validator error)', file: 'path-param-brackets', expected: 'http://localhost:8081/api/echo/anything/users/list[1]' },
{ name: '{ } in value preserved ({x} stays literal)', file: 'path-param-braces', expected: 'http://localhost:8081/api/echo/anything/users/{x}' },
{ name: '| in value preserved (a|b stays literal)', file: 'path-param-pipe', expected: 'http://localhost:8081/api/echo/anything/users/a|b' }
];
for (const c of pathParamCases) {
test(c.name, async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, c.file);
await setUrlEncoding(page, false);
const snippet = await getGeneratedSnippet(page);
expect(snippet).not.toBe('Error generating code snippet');
expect(snippet).toContain(c.expected);
await closeGenerateCodeDialog(page);
});
}
});
});

View File

@@ -0,0 +1,385 @@
import { expect, test } from '../../../playwright';
import { closeGenerateCodeDialog, getGeneratedSnippet, openCollection, openRequestInFolder, setUrlEncoding } from '../../utils/page';
const COLLECTION = 'generate-code-encoding';
const FOLDER = 'requests';
test.describe('Generate Code URL Encoding ON', () => {
test.describe('Query encoding', () => {
test('encodes spaces in query values once (John Doe → John%20Doe)', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'query-spaces');
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?name=John%20Doe&age=25');
await closeGenerateCodeDialog(page);
});
test('double-encodes pre-encoded values per PR #5507 contract (%20 → %2520, %40 → %2540)', async ({ pageWithUserData: page }) => {
// Canary that proves no decode-encode wrap was slipped into the encoder.
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'query-preencoded');
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?name=John%2520Doe&email=john%2540example.com');
await closeGenerateCodeDialog(page);
});
test('encodes structural chars in redirect-style query values (: → %3A, / → %2F)', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'query-redirect-url');
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?path=%2Fusers%2F123&redirect=https%3A%2F%2Fother.com');
await closeGenerateCodeDialog(page);
});
test('encodes pipe operator in query values (| → %7C)', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'query-pipe');
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?filter=status%7Cactive&sort=name%7Casc');
await closeGenerateCodeDialog(page);
});
test('encodes unicode in query values (é → %C3%A9)', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'query-unicode');
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?name=Jos%C3%A9&city=M%C3%BCnchen');
await closeGenerateCodeDialog(page);
});
test('encodes equals signs in query values (== → %3D%3D)', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'query-equals');
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?token=abc123%3D%3D&type=test');
await closeGenerateCodeDialog(page);
});
test('encodes email with + alias and @ (+ → %2B, @ → %40)', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'query-email-plus');
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/invite?email=test%2Balias%40example.com');
await closeGenerateCodeDialog(page);
});
test('encodes comma-separated values and colon (, → %2C, : → %3A)', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'query-commas-colons');
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/filter?tags=a%2Cb%2Cc&time=10%3A30');
await closeGenerateCodeDialog(page);
});
test('double-encodes redirect URL with all special chars pre-encoded (canonical PR #5507 case)', async ({ pageWithUserData: page }) => {
// Same fixture URL the OFF spec uses. ON mode walks each %XX up one
// encoding level (%3A → %253A, %2F → %252F), and the already-double-
// encoded %2520 goes to %252520 — proving the encoder is content-blind
// and runs encodeURIComponent regardless of pre-encoding state. This
// is the contract redirect URLs depend on: after one server-side
// URL-decode the value comes back single-encoded.
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'query-double-encode');
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain(
'http://localhost:8081/api/echo/anything/login?redirect=https%253A%252F%252Fother.com%252Fcb&token=abc%252520xyz'
);
await closeGenerateCodeDialog(page);
});
test('encodes # in query value as %23 (Option C — # is data, not a fragment delimiter)', async ({ pageWithUserData: page }) => {
// `?query=aaa#bbb` → `?query=aaa%23bbb`. The `#` flows through
// encodeUrl as a regular data byte rather than being split off as the
// RFC 3986 §3.5 fragment. To keep `#bbb` as a literal fragment, the
// user must toggle OFF (OFF preserves the URL byte-for-byte).
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'query-hash');
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?query=aaa%23bbb');
await closeGenerateCodeDialog(page);
});
test('encodes JSON-shaped array query values (issue #7913 reproducer)', async ({ pageWithUserData: page }) => {
// Every [ ] , " and space in the array literals must be encoded so the
// HAR validator accepts the URL. Covers empty, primitive, string, and
// nested array shapes against the same fixture.
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'query-arrays');
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain(
'http://localhost:8081/api/echo/anything/api?empty=%5B%5D&nums=%5B1%2C%202%2C%203%5D&strs=%5B%22string%22%2C%20%22string%22%5D&nested=%5B%5B1%2C%202%2C%203%5D%2C%20%5B%22string%22%2C%20%22string%22%5D%5D'
);
await closeGenerateCodeDialog(page);
});
});
test.describe('Path encoding', () => {
test('encodes spaces in path segments (path with spaces → path%20with%20spaces)', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'path-spaces');
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api/path%20with%20spaces/users');
await closeGenerateCodeDialog(page);
});
test('encodes square brackets in path segments ([123] → %5B123%5D)', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'path-brackets');
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/list%5B123%5D');
await closeGenerateCodeDialog(page);
});
test('encodes unicode in path segments (José → Jos%C3%A9)', async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'path-unicode');
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/users/Jos%C3%A9/profile');
await closeGenerateCodeDialog(page);
});
test('path encoding is idempotent — pre-encoded %20 stays single-encoded, not %2520', async ({ pageWithUserData: page }) => {
// Regression guard: encodePathSegments uses safeDecodeURIComponent before
// re-encoding so the runtime's `new URL().pathname` auto-encoding doesn't
// get double-encoded downstream. Single-encoded form below implicitly
// proves no %2520 leaked through.
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'path-idempotent');
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api/path%20with%20spaces/users');
await closeGenerateCodeDialog(page);
});
test('encodes OData-style path with $ filters ($ → %24, space → %20)', async ({ pageWithUserData: page }) => {
// Mirror of the OFF preservation test. In ON mode every byte outside
// RFC 3986's unreserved set (`A-Za-z0-9-_.~`) gets encoded by
// `encodeURIComponent` — so the OData `$` reserved-char becomes %24
// and the literal space in `Price gt 10` becomes %20. Parens stay
// as-is (encodeURIComponent leaves `(` and `)` unencoded).
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'path-odata');
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain(
'http://localhost:8081/api/echo/anything/odata/Products(123)/Categories(456)?%24expand=Items&%24filter=Price%20gt%2010'
);
await closeGenerateCodeDialog(page);
});
test('encodes # as data (%23) — fragment delimiter has no special meaning in ON mode', async ({ pageWithUserData: page }) => {
// Option C: `#` is treated as a regular URL byte and encoded to %23.
// Fragment semantics are lost in ON mode by design — to keep `#section`
// as a real fragment, toggle OFF (OFF preserves the URL byte-for-byte).
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'fragment-preserved');
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?name=john%20doe%23section1');
await closeGenerateCodeDialog(page);
});
test('encodes # in path (no query) as %23 — Option C', async ({ pageWithUserData: page }) => {
// Variant of the fragment-preserved case where `#` sits directly in the
// path (no `?` ahead of it). In ON mode encodePathSegments runs over the
// path and encodes `#` to %23 like any other reserved byte, so the wire
// URL keeps `hash%23tag` intact instead of treating `#tag` as a fragment
// that downstream HTTP clients would strip.
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'path-fragment');
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/hash%23tag');
await closeGenerateCodeDialog(page);
});
test('encodes # in a semantic-path issue link (/issues/#1234)', async ({ pageWithUserData: page }) => {
// Scenario 4 from the # encoding decision tree: GitHub-style "/issues/#1234"
// where the `#` separates the path from a frontend deep-link. In ON mode
// it's treated as data (`%231234`), so the encoded path can survive a
// server-side URL-decode pass that expects to receive the literal `#`.
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'path-issues-fragment');
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/issues%231234');
await closeGenerateCodeDialog(page);
});
test('encodes # in an SPA hash-router URL (/#/dashboard/settings)', async ({ pageWithUserData: page }) => {
// Scenario 5: legacy SPA hash-routing pattern. In ON mode each path
// segment is independently encoded — the standalone `#` segment becomes
// `%23`, preserving the literal byte for the server.
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'path-spa-route');
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/spa%23/dashboard/settings');
await closeGenerateCodeDialog(page);
});
test('encodes # and reserved chars in an OAuth callback fragment', async ({ pageWithUserData: page }) => {
// Scenario 8: OAuth implicit-flow callback URL with token data in the
// fragment. In ON mode the entire segment after the last `/` is encoded
// — `#` → `%23`, `=` → `%3D`, `&` → `%26` — so the whole token payload
// becomes literal data in the path.
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'oauth-callback-fragment');
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain(
'http://localhost:8081/api/echo/anything/callback%23access_token%3Dabc123%26token_type%3DBearer'
);
await closeGenerateCodeDialog(page);
});
});
test.describe('Path-params table (params:path)', () => {
test('OData-style path with literal Tags("tag test") encodes once', async ({ pageWithUserData: page }) => {
// Canonical regression: `new URL().pathname` pre-encodes `"` → %22
// and space → %20, then encodeUrl runs over the result. Without
// idempotent encodePathSegments the wire URL would contain %2522 / %2520.
// The single-encoded form below proves idempotency held.
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'params-path-odata');
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain(
'http://localhost:8081/api/echo/anything/odata/Category(\'category123\')/Item(item456)/foobar/Tags(%22tag%20test%22)'
);
await closeGenerateCodeDialog(page);
});
test('does not throw for path-param value with literal space (regression: user-reported `aaa bbb`)', async ({ pageWithUserData: page }) => {
// Mirror of the OFF regression test — ON mode encodes the literal space.
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'params-path-space');
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).not.toBe('Error generating code snippet');
expect(snippet).toContain('http://localhost:8081/api/echo/anything/users/aaa%20bbb');
await closeGenerateCodeDialog(page);
});
test('path-param value with # preserves the trailing /profile segment (regression: no silent loss)', async ({ pageWithUserData: page }) => {
// Scenario 3 from the # encoding decision tree: when the user types
// `:id = john#doe` and the template has `/profile` after `:id`, the
// snippet must keep `/profile` visible. In ON mode the `#` is encoded
// to `%23`, which prevents downstream tools from treating the rest of
// the path as a fragment and silently dropping `/profile`.
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, 'path-param-hash-trailing');
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).toContain('http://localhost:8081/api/echo/anything/users/john%23doe/profile');
await closeGenerateCodeDialog(page);
});
// Path-param substitution matrix. Each row binds `:id` to a single value
// and asserts the complete substituted URL appears in the snippet.
//
// Caveats for the / # ? rows: GenerateCodeItem invokes
// interpolateUrlPathParams without an `encodeUrl` option, so the value is
// substituted *raw* (see packages/bruno-app/src/utils/url/index.js).
// Once the literal `/` `?` `#` lands in the URL string it becomes a
// path-separator / query-marker / fragment-marker respectively — the
// "literal in value" semantic is lost. The expectations below capture
// what the snippet *actually* contains today (a regression canary), not
// what an ideal encoder would produce.
const pathParamCases: Array<{ name: string; file: string; expected: string }> = [
{ name: '/ in value (slash splits into two segments)', file: 'path-param-slash', expected: 'http://localhost:8081/api/echo/anything/users/aaa/bbb' },
// # in value: encodeUrl now treats `#` as data, so the snippet should
// contain `%23bbb`. Path-only URL means the substitution lands in the
// path-encoding stream (encodePathSegments) which encodes `#` to %23.
{ name: '# in value (encoded as %23 — Option C)', file: 'path-param-hash', expected: 'http://localhost:8081/api/echo/anything/users/aaa%23bbb' },
{ name: 'space in value (John Doe → John%20Doe)', file: 'path-param-space', expected: 'http://localhost:8081/api/echo/anything/users/John%20Doe' },
{ name: '& in value (a&b → a%26b)', file: 'path-param-ampersand', expected: 'http://localhost:8081/api/echo/anything/users/a%26b' },
{ name: '= in value (a=b → a%3Db)', file: 'path-param-equals', expected: 'http://localhost:8081/api/echo/anything/users/a%3Db' },
{ name: '+ in value (a+b → a%2Bb)', file: 'path-param-plus', expected: 'http://localhost:8081/api/echo/anything/users/a%2Bb' },
{ name: '? in value (becomes query separator — literal lost)', file: 'path-param-question', expected: 'http://localhost:8081/api/echo/anything/users/a?b' },
{ name: '@ in value (user@host → user%40host)', file: 'path-param-at', expected: 'http://localhost:8081/api/echo/anything/users/user%40host' },
{ name: ': in value (ISO timestamp → 10%3A30%3A00)', file: 'path-param-colon', expected: 'http://localhost:8081/api/echo/anything/users/2026-01-15T10%3A30%3A00' },
{ name: ', in value (a,b,c → a%2Cb%2Cc)', file: 'path-param-comma', expected: 'http://localhost:8081/api/echo/anything/users/a%2Cb%2Cc' },
{ name: 'unicode in value (José → Jos%C3%A9)', file: 'path-param-unicode', expected: 'http://localhost:8081/api/echo/anything/users/Jos%C3%A9' },
{ name: '[ ] in value (list[1] → list%5B1%5D)', file: 'path-param-brackets', expected: 'http://localhost:8081/api/echo/anything/users/list%5B1%5D' },
{ name: '{ } in value ({x} → %7Bx%7D)', file: 'path-param-braces', expected: 'http://localhost:8081/api/echo/anything/users/%7Bx%7D' },
{ name: '| in value (a|b → a%7Cb)', file: 'path-param-pipe', expected: 'http://localhost:8081/api/echo/anything/users/a%7Cb' }
];
for (const c of pathParamCases) {
test(c.name, async ({ pageWithUserData: page }) => {
await openCollection(page, COLLECTION);
await openRequestInFolder(page, FOLDER, c.file);
await setUrlEncoding(page, true);
const snippet = await getGeneratedSnippet(page);
expect(snippet).not.toBe('Error generating code snippet');
expect(snippet).toContain(c.expected);
await closeGenerateCodeDialog(page);
});
}
});
});

View File

@@ -1623,6 +1623,78 @@ const openExampleFromSidebar = async (page: Page, requestName: string, exampleNa
await exampleRow.click();
};
/**
* Open the Generate Code dialog and return the visible snippet text.
* @param page - The page object
* @returns The text content of the generated code snippet
*/
const getGeneratedSnippet = async (page: Page): Promise<string> => {
return await test.step('Open Generate Code dialog and read snippet', async () => {
const { request } = buildCommonLocators(page);
await request.generateCodeButton().click();
await expect(page.getByRole('dialog')).toBeVisible();
const codeEditor = page.locator('.editor-content .CodeMirror').first();
await expect(codeEditor).toBeVisible();
return (await codeEditor.textContent()) ?? '';
});
};
/**
* Close the Generate Code dialog and wait for it to disappear.
* @param page - The page object
* @returns void
*/
const closeGenerateCodeDialog = async (page: Page) => {
await test.step('Close Generate Code dialog', async () => {
const { modal } = buildCommonLocators(page);
await modal.closeButton().click();
await modal.closeButton().waitFor({ state: 'hidden' });
});
};
/**
* Open a request inside a folder by exact request name.
* @param page - The page object
* @param folderName - The name of the folder containing the request
* @param requestName - The exact name of the request to open
* @returns void
*/
const openRequestInFolder = async (page: Page, folderName: string, requestName: string) => {
await test.step(`Open request "${requestName}" in folder "${folderName}"`, async () => {
const { sidebar } = buildCommonLocators(page);
await sidebar.folder(folderName).click();
const folderWrapper = page.locator('.collection-item-name').filter({ hasText: folderName }).locator('..');
const escapedName = requestName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const requestRow = folderWrapper.locator('.collection-item-name').filter({
has: page.locator('.item-name').filter({ hasText: new RegExp(`^${escapedName}$`) })
});
await requestRow.click();
});
};
/**
* Toggle the URL encoding setting on the current request idempotently.
* @param page - The page object
* @param enabled - Whether URL encoding should be enabled
* @returns void
*/
const setUrlEncoding = async (page: Page, enabled: boolean) => {
await test.step(`Set URL encoding ${enabled ? 'ON' : 'OFF'}`, async () => {
await selectRequestPaneTab(page, 'Settings');
const toggle = page.getByTestId('encode-url-toggle');
await expect(toggle).toBeVisible();
const current = (await toggle.getAttribute('aria-checked')) === 'true';
if (current !== enabled) {
await toggle.click();
await expect(toggle).toHaveAttribute('aria-checked', String(enabled));
}
});
};
type DialogOptions = {
showOpenDialog: () => Promise<{ canceled: boolean; filePaths: string[] }>;
};
@@ -1702,7 +1774,11 @@ export {
readField,
createExampleFromSidebar,
openExampleFromSidebar,
openWorkspaceFromDialog
openWorkspaceFromDialog,
getGeneratedSnippet,
closeGenerateCodeDialog,
openRequestInFolder,
setUrlEncoding
};
export type { SandboxMode, EnvironmentType, EnvironmentVariable, ImportCollectionOptions, CreateRequestOptions, CreateUntitledRequestOptions, CreateTransientRequestOptions, AssertionInput };