diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js index 2feaab2fa..0712b1c53 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js @@ -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 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'; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js index 0a2822451..cc08aad55 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js @@ -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 || ''; - - // 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/', { 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'); + }); +}); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/exampleReducers.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/exampleReducers.js index 7d3d216e5..55f38e1a2 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/exampleReducers.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/exampleReducers.js @@ -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) => { diff --git a/packages/bruno-app/src/utils/codegenerator/har.js b/packages/bruno-app/src/utils/codegenerator/har.js deleted file mode 100644 index 11d5b66d2..000000000 --- a/packages/bruno-app/src/utils/codegenerator/har.js +++ /dev/null @@ -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 - }; -}; diff --git a/packages/bruno-app/src/utils/url/index.js b/packages/bruno-app/src/utils/url/index.js index 710186127..db6ca7b62 100644 --- a/packages/bruno-app/src/utils/url/index.js +++ b/packages/bruno-app/src/utils/url/index.js @@ -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; diff --git a/packages/bruno-app/src/utils/url/index.spec.js b/packages/bruno-app/src/utils/url/index.spec.js index 88c215f71..c4cefb3ff 100644 --- a/packages/bruno-app/src/utils/url/index.spec.js +++ b/packages/bruno-app/src/utils/url/index.spec.js @@ -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'); + }); +}); diff --git a/packages/bruno-common/src/generate-code/har/index.spec.ts b/packages/bruno-common/src/generate-code/har/index.spec.ts new file mode 100644 index 000000000..da12cdc70 --- /dev/null +++ b/packages/bruno-common/src/generate-code/har/index.spec.ts @@ -0,0 +1,1610 @@ +import { buildHar } from './index'; + +const baseRequest = (overrides: any = {}) => ({ + method: 'GET', + url: 'https://example.com/api', + params: [], + headers: [], + body: { mode: 'none' }, + auth: { mode: 'none' }, + ...overrides +}); + +describe('buildHar — basic HAR shape', () => { + it('returns a HAR-shaped object for a minimal GET', () => { + const { har } = buildHar({ request: baseRequest(), shouldInterpolate: false }); + expect(har).toEqual( + expect.objectContaining({ + method: 'GET', + url: 'https://example.com/api', + httpVersion: 'HTTP/1.1', + cookies: [], + queryString: [], + headersSize: 0, + bodySize: 0, + binary: true + }) + ); + expect(Array.isArray(har.headers)).toBe(true); + }); + + it('throws "invalid request url" for inputs that are not URLs', () => { + expect(() => buildHar({ request: baseRequest({ url: '' }), shouldInterpolate: false })).toThrow('invalid request url'); + expect(() => buildHar({ request: baseRequest({ url: 'not a url' }), shouldInterpolate: false })).toThrow('invalid request url'); + expect(() => buildHar({ request: baseRequest({ url: 'http://' }), shouldInterpolate: false })).toThrow('invalid request url'); + }); + + it('accepts URLs with %XX sequences, brackets, OData parens (encoding handled internally)', () => { + expect(() => buildHar({ request: baseRequest({ url: 'https://example.com/list%5B1%5D' }), shouldInterpolate: false })).not.toThrow(); + expect(() => buildHar({ request: baseRequest({ url: 'https://example.com/odata/Products(123)/Categories(456)' }), shouldInterpolate: false })).not.toThrow(); + expect(() => buildHar({ request: baseRequest({ url: 'https://example.com/path%20with%20spaces' }), shouldInterpolate: false })).not.toThrow(); + }); +}); + +describe('buildHar — encodeUrl toggle (PR #5507 content-blind contract)', () => { + it('OFF: rawUrl matches user-typed URL (path-param substitution happens upstream for Generate Code)', () => { + const { rawUrl } = buildHar({ + request: baseRequest({ + url: 'https://example.com/api?name=John Doe', + params: [{ name: 'name', value: 'John Doe', type: 'query', enabled: true }], + settings: { encodeUrl: false } + }), + shouldInterpolate: false + }); + expect(rawUrl).toBe('https://example.com/api?name=John Doe'); + }); + + it('ON: encodedUrl applies encodeUrl() (path encoded, query encoded)', () => { + const { encodedUrl } = buildHar({ + request: baseRequest({ + url: 'https://example.com/api?name=John Doe', + params: [{ name: 'name', value: 'John Doe', type: 'query', enabled: true }], + settings: { encodeUrl: true } + }), + shouldInterpolate: false + }); + expect(encodedUrl).toBe('https://example.com/api?name=John%20Doe'); + }); + + it('ON: pre-encoded inputs INTENTIONALLY double-encode (PR #5507)', () => { + const { encodedUrl } = buildHar({ + request: baseRequest({ + url: 'https://example.com/api?name=John%20Doe', + params: [{ name: 'name', value: 'John%20Doe', type: 'query', enabled: true }], + settings: { encodeUrl: true } + }), + shouldInterpolate: false + }); + // %20 → %2520 — content-blind encoding, exactly what redirect-URL flows require. + expect(encodedUrl).toContain('John%2520Doe'); + }); + + it('ON: # in URL is encoded as %23 (Option C — # is data, not a fragment delimiter)', () => { + // encodeUrl treats `#` as a regular byte and encodes it via + // encodeURIComponent in the query-value pipeline. To keep `#section` as a + // literal fragment, toggle OFF (OFF preserves the URL byte-for-byte). + const { encodedUrl } = buildHar({ + request: baseRequest({ + url: 'https://example.com/api?tag=test#section', + params: [{ name: 'tag', value: 'test#section', type: 'query', enabled: true }], + settings: { encodeUrl: true } + }), + shouldInterpolate: false + }); + expect(encodedUrl).toBe('https://example.com/api?tag=test%23section'); + }); +}); + +describe('buildHar — auth → headers translation', () => { + it('basic auth → Authorization: Basic ', () => { + const { har } = buildHar({ + request: baseRequest({ auth: { mode: 'basic', basic: { username: 'alice', password: 'pw' } } }), + shouldInterpolate: false + }); + const auth = har.headers.find((h) => h.name === 'Authorization'); + expect(auth).toBeDefined(); + expect(auth?.value).toBe(`Basic ${Buffer.from('alice:pw').toString('base64')}`); + }); + + it('bearer auth → Authorization: Bearer ', () => { + const { har } = buildHar({ + request: baseRequest({ auth: { mode: 'bearer', bearer: { token: 'tk-123' } } }), + shouldInterpolate: false + }); + expect(har.headers).toContainEqual({ name: 'Authorization', value: 'Bearer tk-123' }); + }); + + it('apikey in header → custom header name + value', () => { + const { har } = buildHar({ + request: baseRequest({ + auth: { mode: 'apikey', apikey: { key: 'X-API-Key', value: 'secret', placement: 'header' } } + }), + shouldInterpolate: false + }); + expect(har.headers).toContainEqual({ name: 'X-API-Key', value: 'secret' }); + }); + + it('apikey with placement=queryparams → goes into queryString, NOT headers', () => { + const { har } = buildHar({ + request: baseRequest({ + auth: { mode: 'apikey', apikey: { key: 'api_key', value: 'secret', placement: 'queryparams' } } + }), + shouldInterpolate: false + }); + expect(har.queryString).toContainEqual({ name: 'api_key', value: 'secret' }); + expect(har.headers.find((h) => h.name === 'api_key')).toBeUndefined(); + }); + + it('oauth2 header placement with no stored credentials falls back to ', () => { + const { har } = buildHar({ + request: baseRequest({ + auth: { mode: 'oauth2', oauth2: { tokenPlacement: 'header', accessTokenUrl: 'https://x/token', credentialsId: 'creds' } } + }), + shouldInterpolate: false + }); + expect(har.headers).toContainEqual({ name: 'Authorization', value: 'Bearer ' }); + }); + + it('oauth2 header placement looks up actual access token from oauth2Credentials', () => { + const { har } = buildHar({ + request: baseRequest({ + auth: { + mode: 'oauth2', + oauth2: { tokenPlacement: 'header', accessTokenUrl: 'https://x/token', credentialsId: 'creds' } + } + }), + oauth2Credentials: [ + { url: 'https://x/token', collectionUid: 'col-1', credentialsId: 'creds', credentials: { access_token: 'tk-real' } } + ], + collectionUid: 'col-1', + shouldInterpolate: false + }); + expect(har.headers).toContainEqual({ name: 'Authorization', value: 'Bearer tk-real' }); + }); + + it.each(['oauth1', 'digest', 'ntlm', 'awsv4', 'wsse', 'none', 'inherit'])( + 'auth mode "%s" → no Authorization header (runtime-only or curl-flag-only)', + (mode) => { + const { har } = buildHar({ request: baseRequest({ auth: { mode } }), shouldInterpolate: false }); + expect(har.headers.find((h) => h.name === 'Authorization')).toBeUndefined(); + } + ); + + // ---- Phase B: oauth2 detail + apikey edge cases + inherit contract ----- + + it('oauth2 with custom tokenHeaderPrefix=OAuth → "OAuth "', () => { + const { har } = buildHar({ + request: baseRequest({ + auth: { + mode: 'oauth2', + oauth2: { tokenPlacement: 'header', tokenHeaderPrefix: 'OAuth', accessTokenUrl: 'https://x/token', credentialsId: 'creds' } + } + }), + oauth2Credentials: [ + { url: 'https://x/token', collectionUid: 'col-1', credentialsId: 'creds', credentials: { access_token: 'tk-real' } } + ], + collectionUid: 'col-1', + shouldInterpolate: false + }); + expect(har.headers).toContainEqual({ name: 'Authorization', value: 'OAuth tk-real' }); + }); + + it('oauth2 with empty tokenHeaderPrefix="" → bare token, no leading space', () => { + const { har } = buildHar({ + request: baseRequest({ + auth: { + mode: 'oauth2', + oauth2: { tokenPlacement: 'header', tokenHeaderPrefix: '', accessTokenUrl: 'https://x/token', credentialsId: 'creds' } + } + }), + oauth2Credentials: [ + { url: 'https://x/token', collectionUid: 'col-1', credentialsId: 'creds', credentials: { access_token: 'bare-tk' } } + ], + collectionUid: 'col-1', + shouldInterpolate: false + }); + expect(har.headers).toContainEqual({ name: 'Authorization', value: 'bare-tk' }); + }); + + it('oauth2 with tokenPlacement=url → no Authorization header (URL placement is the runtime/snippet caller\'s responsibility)', () => { + const { har } = buildHar({ + request: baseRequest({ + auth: { + mode: 'oauth2', + oauth2: { tokenPlacement: 'url', tokenQueryKey: 'access_token', accessTokenUrl: 'https://x/token' } + } + }), + shouldInterpolate: false + }); + expect(har.headers.find((h) => h.name === 'Authorization')).toBeUndefined(); + }); + + it('oauth2 implicit grant uses authorizationUrl (not accessTokenUrl) for credential lookup', () => { + const { har } = buildHar({ + request: baseRequest({ + auth: { + mode: 'oauth2', + oauth2: { + tokenPlacement: 'header', + grantType: 'implicit', + authorizationUrl: 'https://x/authorize', + credentialsId: 'creds' + } + } + }), + oauth2Credentials: [ + { url: 'https://x/authorize', collectionUid: 'col-1', credentialsId: 'creds', credentials: { access_token: 'implicit-tk' } } + ], + collectionUid: 'col-1', + shouldInterpolate: false + }); + expect(har.headers).toContainEqual({ name: 'Authorization', value: 'Bearer implicit-tk' }); + }); + + it('oauth2 credentialsId defaults to "credentials" when not specified', () => { + const { har } = buildHar({ + request: baseRequest({ + auth: { + mode: 'oauth2', + oauth2: { tokenPlacement: 'header', accessTokenUrl: 'https://x/token' } + } + }), + oauth2Credentials: [ + { url: 'https://x/token', collectionUid: 'col-1', credentialsId: 'credentials', credentials: { access_token: 'default-id-tk' } } + ], + collectionUid: 'col-1', + shouldInterpolate: false + }); + expect(har.headers).toContainEqual({ name: 'Authorization', value: 'Bearer default-id-tk' }); + }); + + it('oauth2 missing oauth2Credentials array → falls back to placeholder', () => { + const { har } = buildHar({ + request: baseRequest({ + auth: { + mode: 'oauth2', + oauth2: { tokenPlacement: 'header', accessTokenUrl: 'https://x/token' } + } + }), + shouldInterpolate: false + }); + expect(har.headers).toContainEqual({ name: 'Authorization', value: 'Bearer ' }); + }); + + it('apikey with placement=queryparams AND empty key → not added to queryString', () => { + const { har } = buildHar({ + request: baseRequest({ + auth: { mode: 'apikey', apikey: { key: '', value: 'secret', placement: 'queryparams' } } + }), + shouldInterpolate: false + }); + expect(har.queryString).toEqual([]); + }); + + it('apikey with placement=queryparams AND empty value → not added to queryString', () => { + const { har } = buildHar({ + request: baseRequest({ + auth: { mode: 'apikey', apikey: { key: 'api_key', value: '', placement: 'queryparams' } } + }), + shouldInterpolate: false + }); + expect(har.queryString).toEqual([]); + }); + + it('apikey with placement=header AND empty key → no header added', () => { + const { har } = buildHar({ + request: baseRequest({ + auth: { mode: 'apikey', apikey: { key: '', value: 'secret', placement: 'header' } } + }), + shouldInterpolate: false + }); + expect(har.headers).toEqual(expect.not.arrayContaining([expect.objectContaining({ name: '' })])); + }); + + it('basic auth with empty username/password → still emits Authorization with base64 of ":"', () => { + const { har } = buildHar({ + request: baseRequest({ auth: { mode: 'basic', basic: { username: '', password: '' } } }), + shouldInterpolate: false + }); + const auth = har.headers.find((h) => h.name === 'Authorization'); + expect(auth?.value).toBe(`Basic ${Buffer.from(':').toString('base64')}`); + }); + + it('bearer with missing token → "Bearer " with empty string', () => { + const { har } = buildHar({ + request: baseRequest({ auth: { mode: 'bearer' } }), + shouldInterpolate: false + }); + expect(har.headers).toContainEqual({ name: 'Authorization', value: 'Bearer ' }); + }); +}); + +describe('buildHar — header ordering when request + auth produce same name', () => { + // buildHar appends auth-generated headers AFTER the caller-passed request.headers. + // Documents the contract — caller decides whether to dedupe pre-buildHar (e.g., + // bruno-app's mergeHeaders walks collection/folder/request tree). buildHar itself + // is intentionally not opinionated about dedup since the layering ambiguity + // (which copy wins?) is collection-tree-specific. + + it('request.headers Authorization survives alongside auth-generated Authorization', () => { + const { har } = buildHar({ + request: baseRequest({ + headers: [{ name: 'Authorization', value: 'Custom token', enabled: true }], + auth: { mode: 'bearer', bearer: { token: 'tk-from-auth' } } + }), + shouldInterpolate: false + }); + const auths = har.headers.filter((h) => h.name === 'Authorization'); + expect(auths).toHaveLength(2); + expect(auths[0].value).toBe('Custom token'); + expect(auths[1].value).toBe('Bearer tk-from-auth'); + }); +}); + +describe('buildHar — headers finalization (default content-type, lowercase, enabled-only)', () => { + it('lowercases header names and filters disabled', () => { + const { har } = buildHar({ + request: baseRequest({ + headers: [ + { name: 'X-Custom', value: 'v1', enabled: true }, + { name: 'X-Disabled', value: 'v2', enabled: false } + ] + }), + shouldInterpolate: false + }); + expect(har.headers).toContainEqual({ name: 'X-Custom', value: 'v1' }); + expect(har.headers.find((h) => h.name === 'x-disabled')).toBeUndefined(); + }); + + it('appends default content-type for body mode if none is set', () => { + const cases: Array<[string, string]> = [ + ['json', 'application/json'], + ['xml', 'application/xml'], + ['text', 'text/plain'], + ['multipartForm', 'multipart/form-data'], + ['formUrlEncoded', 'application/x-www-form-urlencoded'] + ]; + for (const [mode, contentType] of cases) { + const { har } = buildHar({ request: baseRequest({ body: { mode } }), shouldInterpolate: false }); + expect(har.headers).toContainEqual({ name: 'Content-Type', value: contentType }); + } + }); + + it('does NOT override an explicit content-type header', () => { + const { har } = buildHar({ + request: baseRequest({ + body: { mode: 'json' }, + headers: [{ name: 'Content-Type', value: 'application/vnd.api+json', enabled: true }] + }), + shouldInterpolate: false + }); + const cts = har.headers.filter((h) => h.name === 'Content-Type'); + expect(cts).toHaveLength(1); + expect(cts[0].value).toBe('application/vnd.api+json'); + }); +}); + +describe('buildHar — query string assembly', () => { + it('includes only enabled query-type params', () => { + const { har } = buildHar({ + request: baseRequest({ + params: [ + { name: 'a', value: '1', type: 'query', enabled: true }, + { name: 'b', value: '2', type: 'query', enabled: false }, + { name: 'c', value: '3', type: 'query', enabled: true }, + { name: 'd', value: '4', type: 'path', enabled: true } // path param, not query + ] + }), + shouldInterpolate: false + }); + expect(har.queryString).toEqual([ + { name: 'a', value: '1' }, + { name: 'c', value: '3' } + ]); + }); + + it('preserves insertion order (no alphabetical sort)', () => { + const { har } = buildHar({ + request: baseRequest({ + params: [ + { name: 'z', value: 'last', type: 'query', enabled: true }, + { name: 'a', value: 'first', type: 'query', enabled: true }, + { name: 'm', value: 'middle', type: 'query', enabled: true } + ] + }), + shouldInterpolate: false + }); + expect(har.queryString.map((p) => p.name)).toEqual(['z', 'a', 'm']); + }); +}); + +describe('buildHar — body / postData', () => { + it('formUrlEncoded → mimeType + text (URL-encoded form) + params array', () => { + const { har } = buildHar({ + request: baseRequest({ + body: { + mode: 'formUrlEncoded', + formUrlEncoded: [ + { name: 'name', value: 'alice', enabled: true }, + { name: 'role', value: 'admin', enabled: true } + ] + } + }), + shouldInterpolate: false + }); + expect(har.postData.mimeType).toBe('application/x-www-form-urlencoded'); + expect(har.postData.text).toBe('name=alice&role=admin'); + expect(har.postData.params).toEqual([ + { name: 'name', value: 'alice' }, + { name: 'role', value: 'admin' } + ]); + }); + + it('formUrlEncoded with duplicate names → preserves both entries in text and params', () => { + // Regression canary: serializing to a plain object would collapse duplicates + // (`{tag: '1', tag: '2'}` → only the last survives). Real APIs use repeated + // field names for array-shaped form values; both occurrences must round-trip. + const { har } = buildHar({ + request: baseRequest({ + body: { + mode: 'formUrlEncoded', + formUrlEncoded: [ + { name: 'tag', value: '1', enabled: true }, + { name: 'tag', value: '2', enabled: true }, + { name: 'tag', value: '3', enabled: true } + ] + } + }), + shouldInterpolate: false + }); + expect(har.postData.text).toBe('tag=1&tag=2&tag=3'); + expect(har.postData.params).toEqual([ + { name: 'tag', value: '1' }, + { name: 'tag', value: '2' }, + { name: 'tag', value: '3' } + ]); + }); + + it('json → mimeType + text (JSON string)', () => { + const { har } = buildHar({ + request: baseRequest({ body: { mode: 'json', json: '{"hello":"world"}' } }), + shouldInterpolate: false + }); + expect(har.postData.mimeType).toBe('application/json'); + expect(har.postData.text).toBe('{"hello":"world"}'); + }); + + it('multipartForm → params with optional fileName for type=file', () => { + const { har } = buildHar({ + request: baseRequest({ + body: { + mode: 'multipartForm', + multipartForm: [ + { name: 'caption', value: 'hi', type: 'text', enabled: true }, + { name: 'upload', value: '/tmp/a.txt', type: 'file', enabled: true } + ] + } + }), + shouldInterpolate: false + }); + expect(har.postData.mimeType).toBe('multipart/form-data'); + expect(har.postData.params).toContainEqual({ name: 'caption', value: 'hi' }); + expect(har.postData.params).toContainEqual({ name: 'upload', value: '/tmp/a.txt', fileName: '/tmp/a.txt' }); + }); + + // ---- Phase A: body-mode parity with bruno-app's buildHarRequest -------- + // Mirrors createPostData in packages/bruno-app/src/utils/codegenerator/har.js. + // Each mode is tested for (a) correct mimeType, (b) correct postData shape, + // (c) reasonable behavior on empty / missing / disabled inputs. + + it('text → mimeType=text/plain, raw body in postData.text', () => { + const { har } = buildHar({ + request: baseRequest({ body: { mode: 'text', text: 'hello world' } }), + shouldInterpolate: false + }); + expect(har.postData.mimeType).toBe('text/plain'); + expect(har.postData.text).toBe('hello world'); + }); + + it('xml → mimeType=application/xml, raw body in postData.text', () => { + const xmlBody = 'hi'; + const { har } = buildHar({ + request: baseRequest({ body: { mode: 'xml', xml: xmlBody } }), + shouldInterpolate: false + }); + expect(har.postData.mimeType).toBe('application/xml'); + expect(har.postData.text).toBe(xmlBody); + }); + + it('sparql → mimeType=application/sparql-query, raw body in postData.text', () => { + const sparqlBody = 'SELECT ?s WHERE { ?s ?p ?o } LIMIT 10'; + const { har } = buildHar({ + request: baseRequest({ body: { mode: 'sparql', sparql: sparqlBody } }), + shouldInterpolate: false + }); + expect(har.postData.mimeType).toBe('application/sparql-query'); + expect(har.postData.text).toBe(sparqlBody); + }); + + it('graphql → mimeType=application/json, postData.text is JSON.stringify of body.graphql', () => { + const graphql = { query: 'query Q { me { id } }', variables: { foo: 'bar' } }; + const { har } = buildHar({ + request: baseRequest({ body: { mode: 'graphql', graphql } }), + shouldInterpolate: false + }); + expect(har.postData.mimeType).toBe('application/json'); + expect(har.postData.text).toBe(JSON.stringify(graphql)); + }); + + it('file → mimeType from selected file, text=filePath, params has fileName + contentType', () => { + const { har } = buildHar({ + request: baseRequest({ + body: { + mode: 'file', + file: [ + { name: 'upload', filePath: '/tmp/a.png', contentType: 'image/png', selected: true }, + { name: 'other', filePath: '/tmp/b.txt', contentType: 'text/plain', selected: false } + ] + } + }), + shouldInterpolate: false + }); + expect(har.postData.mimeType).toBe('image/png'); + expect(har.postData.text).toBe('/tmp/a.png'); + expect(har.postData.params).toEqual([ + { name: 'upload', value: '/tmp/a.png', fileName: '/tmp/a.png', contentType: 'image/png' } + ]); + }); + + it('file with no `selected` flag → falls back to first entry', () => { + const { har } = buildHar({ + request: baseRequest({ + body: { + mode: 'file', + file: [ + { name: 'a', filePath: '/tmp/first.txt', contentType: 'text/plain' }, + { name: 'b', filePath: '/tmp/second.txt', contentType: 'text/plain' } + ] + } + }), + shouldInterpolate: false + }); + expect(har.postData.text).toBe('/tmp/first.txt'); + }); + + it('file with empty file[] → octet-stream fallback, empty text, empty params', () => { + const { har } = buildHar({ + request: baseRequest({ body: { mode: 'file', file: [] } }), + shouldInterpolate: false + }); + expect(har.postData.mimeType).toBe('application/octet-stream'); + expect(har.postData.text).toBe(''); + expect(har.postData.params).toEqual([]); + }); + + it('formUrlEncoded with empty [] → empty text + empty params, no error', () => { + const { har } = buildHar({ + request: baseRequest({ body: { mode: 'formUrlEncoded', formUrlEncoded: [] } }), + shouldInterpolate: false + }); + expect(har.postData.mimeType).toBe('application/x-www-form-urlencoded'); + expect(har.postData.text).toBe(''); + expect(har.postData.params).toEqual([]); + }); + + it('formUrlEncoded with missing array → treated as empty', () => { + const { har } = buildHar({ + request: baseRequest({ body: { mode: 'formUrlEncoded' } }), + shouldInterpolate: false + }); + expect(har.postData.params).toEqual([]); + }); + + it('formUrlEncoded with disabled entries → filtered from text + params', () => { + const { har } = buildHar({ + request: baseRequest({ + body: { + mode: 'formUrlEncoded', + formUrlEncoded: [ + { name: 'kept', value: 'on', enabled: true }, + { name: 'dropped', value: 'off', enabled: false }, + { name: 'kept2', value: 'on2', enabled: true } + ] + } + }), + shouldInterpolate: false + }); + expect(har.postData.text).toBe('kept=on&kept2=on2'); + expect(har.postData.params).toEqual([ + { name: 'kept', value: 'on' }, + { name: 'kept2', value: 'on2' } + ]); + }); + + it('multipartForm with empty [] → empty params, no error', () => { + const { har } = buildHar({ + request: baseRequest({ body: { mode: 'multipartForm', multipartForm: [] } }), + shouldInterpolate: false + }); + expect(har.postData.mimeType).toBe('multipart/form-data'); + expect(har.postData.params).toEqual([]); + }); + + it('multipartForm with missing array → treated as empty', () => { + const { har } = buildHar({ + request: baseRequest({ body: { mode: 'multipartForm' } }), + shouldInterpolate: false + }); + expect(har.postData.params).toEqual([]); + }); + + it('multipartForm with disabled entries → filtered out', () => { + const { har } = buildHar({ + request: baseRequest({ + body: { + mode: 'multipartForm', + multipartForm: [ + { name: 'kept', value: 'a', type: 'text', enabled: true }, + { name: 'dropped', value: 'b', type: 'text', enabled: false } + ] + } + }), + shouldInterpolate: false + }); + expect(har.postData.params).toEqual([{ name: 'kept', value: 'a' }]); + }); + + it('multipartForm type=file → fileName populated only on file entries', () => { + const { har } = buildHar({ + request: baseRequest({ + body: { + mode: 'multipartForm', + multipartForm: [ + { name: 'caption', value: 'hello', type: 'text', enabled: true }, + { name: 'upload', value: '/tmp/x', type: 'file', enabled: true } + ] + } + }), + shouldInterpolate: false + }); + expect(har.postData.params[0]).toEqual({ name: 'caption', value: 'hello' }); + expect(har.postData.params[0]).not.toHaveProperty('fileName'); + expect(har.postData.params[1]).toEqual({ name: 'upload', value: '/tmp/x', fileName: '/tmp/x' }); + }); +}); + +describe('buildHar — interpolation', () => { + it('URL {{var}} tokens are hashed (not substituted) — caller owns URL interpolation', () => { + // URL interpolation is intentionally NOT done inside buildHar. The caller + // (e.g. GenerateCodeItem in bruno-app, or the runtime adapter) is the only + // place that knows whether the user wants templates resolved in the URL. + // buildHar's job is to keep the URL parseable through encoding via + // patternHasher hashing — the returned `unhash` lets the caller restore + // the original `{{var}}` text at the end of their pipeline. + const { har, unhash } = buildHar({ + request: baseRequest({ url: 'https://{{host}}/api/{{path}}' }), + variables: { host: 'example.com', path: 'users' }, + shouldInterpolate: true + }); + // No `{{` leaks into the HAR (so HTTPSnippet's URL parsing doesn't choke). + expect(har.url).not.toContain('{{'); + expect(har.url).not.toContain('}}'); + // unhash restores the templates exactly as the user typed them. + expect(unhash(har.url)).toContain('{{host}}'); + expect(unhash(har.url)).toContain('{{path}}'); + }); + + it('shouldInterpolate=true substitutes {{var}} in headers and body (json)', () => { + const { har } = buildHar({ + request: baseRequest({ + headers: [{ name: 'X-Token', value: '{{token}}', enabled: true }], + body: { mode: 'json', json: '{"user":"{{user}}"}' } + }), + variables: { token: 'secret', user: 'alice' }, + shouldInterpolate: true + }); + expect(har.headers).toContainEqual({ name: 'X-Token', value: 'secret' }); + expect(har.postData.text).toContain('"user": "alice"'); + }); + + it('shouldInterpolate=false hashes {{var}} so the URL survives parsing, exposes unhash()', () => { + const { har, unhash } = buildHar({ + request: baseRequest({ url: 'https://{{host}}/api?key={{secret}}', params: [{ name: 'key', value: '{{secret}}', type: 'query', enabled: true }] }), + shouldInterpolate: false + }); + // Hashed in the HAR url (no { or } leaks) + expect(har.url).not.toContain('{{'); + expect(har.url).not.toContain('}}'); + // unhash restores the templates verbatim + const restored = unhash(har.url); + expect(restored).toContain('{{host}}'); + }); +}); + +describe('buildHar — regression: known issues map to single fixes', () => { + it('path with %20 (issue #6268) — no throw, URL passes the soft gate', () => { + expect(() => buildHar({ + request: baseRequest({ url: 'https://example.com/api/v1/roles/test%20-%20test' }), + shouldInterpolate: false + })).not.toThrow(); + }); + + it('square brackets in path (issue #7653) — no throw, URL passes the soft gate', () => { + expect(() => buildHar({ + request: baseRequest({ url: 'https://example.com/list[1]' }), + shouldInterpolate: false + })).not.toThrow(); + }); + + it('JSON-shaped array values (issue #7913) — no throw, URL passes the soft gate', () => { + expect(() => buildHar({ + request: baseRequest({ + url: 'https://example.com/api?testArray=[[1, 2, 3], ["a", "b"]]', + params: [{ name: 'testArray', value: '[[1, 2, 3], ["a", "b"]]', type: 'query', enabled: true }] + }), + shouldInterpolate: false + })).not.toThrow(); + }); + + it('path-param substitution (toggle ON) — built-in capability for runtime (issue #7356)', () => { + // For Generate Code today, GenerateCodeItem pre-substitutes path params; buildHar + // sees no `:id` to act on. But the capability is here for the future runtime + // adoption (see Architectural pivot — Phase C in fixings/url-encoding-fix.md). + const { rawUrl, encodedUrl } = buildHar({ + request: baseRequest({ + url: 'https://example.com/users/:id/profile', + pathParams: [{ name: 'id', value: 'aaa/bbb', type: 'path', enabled: true }], + settings: { encodeUrl: true } + }), + shouldInterpolate: false + }); + expect(rawUrl).toBe('https://example.com/users/aaa/bbb/profile'); + // Toggle ON — path-param value is SINGLE-encoded (`/` → `%2F`). The + // placeholder-hash flow prevents `encodeUrl()` from double-encoding the + // segment: path-param positions are replaced with URL-safe placeholders + // before `encodeUrl()` runs, then restored with `encodeURIComponent(value)` + // afterwards. So `aaa/bbb` → `aaa%2Fbbb` (single-encoded), not + // `aaa%252Fbbb` (which would be the content-blind double-encoded form). + expect(encodedUrl).toContain('aaa%2Fbbb'); + expect(encodedUrl).not.toContain('aaa%252Fbbb'); + }); + + it('path-param substitution (toggle OFF) — value passed raw', () => { + const { rawUrl, encodedUrl, har } = buildHar({ + request: baseRequest({ + url: 'https://example.com/users/:id/profile', + pathParams: [{ name: 'id', value: 'aaa/bbb', type: 'path', enabled: true }], + settings: { encodeUrl: false } + }), + shouldInterpolate: false + }); + expect(rawUrl).toBe('https://example.com/users/aaa/bbb/profile'); + expect(encodedUrl).toBe('https://example.com/users/aaa/bbb/profile'); + expect(har.url).toBe('https://example.com/users/aaa/bbb/profile'); + }); + + it('OData-style path param (Foo(:productId)) substitutes correctly', () => { + const { rawUrl } = buildHar({ + request: baseRequest({ + url: 'https://example.com/odata/Products(:productId)', + pathParams: [{ name: 'productId', value: 'ABC123', type: 'path', enabled: true }], + settings: { encodeUrl: false } + }), + shouldInterpolate: false + }); + expect(rawUrl).toBe('https://example.com/odata/Products(ABC123)'); + }); +}); + +describe('buildHar — does not mutate caller inputs', () => { + it('caller\'s request object is not mutated by buildHar', () => { + const request = baseRequest({ + url: 'https://example.com/api?q=1', + params: [{ name: 'q', value: '1', type: 'query', enabled: true }], + headers: [{ name: 'X-A', value: 'a', enabled: true }] + }); + const before = JSON.stringify(request); + buildHar({ request, shouldInterpolate: true, variables: { foo: 'bar' } }); + expect(JSON.stringify(request)).toBe(before); + }); +}); + +// --------------------------------------------------------------------------- +// Path-param encoding validation matrix (issue #7356) +// --------------------------------------------------------------------------- +// +// For URL `https://example.com/users/:id` with `pathParams.id = ` and +// `settings.encodeUrl = true`, validate that the substituted segment is +// percent-encoded per `encodeURIComponent` semantics. The "Expected segment" +// column is the value that should appear in place of `:id` in the final URL. +// +// These tests exercise buildHar's internal `substitutePathParams` (with +// `encodeUrl: true`). Tests inspect `rawUrl` because it's the +// substitutePathParams output before the second `encodeUrl()` pass — the +// single-pass form the user would expect to see for "encode my path param". +describe('buildHar — path-param encoding matrix (toggle ON, issue #7356)', () => { + const cases: Array<{ name: string; input: string; expectedSegment: string }> = [ + { name: 'forward slash', input: 'aaa/bbb', expectedSegment: 'aaa%2Fbbb' }, + { name: 'hash', input: 'aaa#bbb', expectedSegment: 'aaa%23bbb' }, + { name: 'literal space', input: 'John Doe', expectedSegment: 'John%20Doe' }, + { name: 'ampersand', input: 'a&b', expectedSegment: 'a%26b' }, + { name: 'equals', input: 'a=b', expectedSegment: 'a%3Db' }, + { name: 'plus', input: 'a+b', expectedSegment: 'a%2Bb' }, + { name: 'question mark', input: 'a?b', expectedSegment: 'a%3Fb' }, + { name: 'at sign', input: 'user@host', expectedSegment: 'user%40host' }, + { name: 'colon (single)', input: 'key:value', expectedSegment: 'key%3Avalue' }, + { name: 'ISO 8601 timestamp (multi-colon)', input: '2026-01-15T10:30:00', expectedSegment: '2026-01-15T10%3A30%3A00' }, + { name: 'comma (CSV-like)', input: 'a,b,c', expectedSegment: 'a%2Cb%2Cc' }, + { name: 'semicolon', input: 'a;b', expectedSegment: 'a%3Bb' }, + { name: 'Latin-1 unicode (é in José)', input: 'José', expectedSegment: 'Jos%C3%A9' }, + { name: 'square brackets', input: 'list[1]', expectedSegment: 'list%5B1%5D' }, + { name: 'curly braces', input: '{x}', expectedSegment: '%7Bx%7D' }, + { name: 'caret', input: 'a^b', expectedSegment: 'a%5Eb' }, + { name: 'pipe', input: 'a|b', expectedSegment: 'a%7Cb' }, + { name: 'bare percent', input: '100%', expectedSegment: '100%25' }, + { name: 'unreserved chars only (no encoding)', input: 'a-b_c.d~e', expectedSegment: 'a-b_c.d~e' } + ]; + + it.each(cases)('encodes path-param value $name: "$input" → "$expectedSegment"', ({ input, expectedSegment }) => { + // har.url is what reaches the wire. For toggle ON the user expects the + // path-param value to be percent-encoded once (matching encodeURIComponent + // semantics). + const { har } = buildHar({ + request: baseRequest({ + url: 'https://example.com/users/:id', + pathParams: [{ name: 'id', value: input, type: 'path', enabled: true }], + settings: { encodeUrl: true } + }), + shouldInterpolate: false + }); + + expect(har.url).toBe(`https://example.com/users/${expectedSegment}`); + }); +}); + +// --------------------------------------------------------------------------- +// Path-param substitution matrix (toggle OFF) — companion to the ON matrix +// --------------------------------------------------------------------------- +// +// For URL `https://example.com/users/:id` with `pathParams.id = ` and +// `settings.encodeUrl = false` (the default), the user expects the path-param +// value to be substituted RAW — no encoding. Tests inspect `rawUrl` (the +// buildHar output that holds the user-typed form after path-param +// substitution, before any encodeUrl pass). +// +// Note: when toggle is OFF, characters with structural meaning in URL grammar +// (`/`, `#`, `?`) inside a path-param value will be interpreted by URL +// parsers as path separators / fragments / query starts respectively. That's +// the user's responsibility to handle — flipping the toggle ON is the way to +// preserve them as data. +describe('buildHar — path-param substitution matrix (toggle OFF)', () => { + const cases: Array<{ name: string; input: string }> = [ + { name: 'forward slash', input: 'aaa/bbb' }, + { name: 'hash', input: 'aaa#bbb' }, + { name: 'literal space', input: 'John Doe' }, + { name: 'ampersand', input: 'a&b' }, + { name: 'equals', input: 'a=b' }, + { name: 'plus', input: 'a+b' }, + { name: 'question mark', input: 'a?b' }, + { name: 'at sign', input: 'user@host' }, + { name: 'colon (single)', input: 'key:value' }, + { name: 'ISO 8601 timestamp (multi-colon)', input: '2026-01-15T10:30:00' }, + { name: 'comma (CSV-like)', input: 'a,b,c' }, + { name: 'semicolon', input: 'a;b' }, + { name: 'Latin-1 unicode (é in José)', input: 'José' }, + { name: 'square brackets', input: 'list[1]' }, + { name: 'curly braces', input: '{x}' }, + { name: 'caret', input: 'a^b' }, + { name: 'pipe', input: 'a|b' }, + { name: 'bare percent', input: '100%' }, + { name: 'unreserved chars only', input: 'a-b_c.d~e' } + ]; + + it.each(cases)('passes path-param value through raw $name: "$input"', ({ input }) => { + const { rawUrl } = buildHar({ + request: baseRequest({ + url: 'https://example.com/users/:id', + pathParams: [{ name: 'id', value: input, type: 'path', enabled: true }], + settings: { encodeUrl: false } + }), + shouldInterpolate: false + }); + + expect(rawUrl).toBe(`https://example.com/users/${input}`); + }); +}); + +// ---- Phase C.1: query encoding matrix (mirrors e2e fixtures) ------------ +// Each scenario asserts both rawUrl (OFF behavior — bytes preserved) and +// encodedUrl (ON behavior — encoded per Option C: `#` is data, content-blind +// per PR #5507). The pairs cover every distinct query-encoding scenario in +// `tests/request/generate-code/collection/requests/`. + +describe('buildHar — query encoding matrix (mirror e2e fixtures)', () => { + type QueryCase = { + name: string; + url: string; + params: Array<{ name: string; value: string }>; + encoded: string; + raw: string; + }; + + const cases: QueryCase[] = [ + { + name: 'spaces', + url: 'https://example.com/api?name=John Doe&age=25', + params: [{ name: 'name', value: 'John Doe' }, { name: 'age', value: '25' }], + encoded: 'https://example.com/api?name=John%20Doe&age=25', + raw: 'https://example.com/api?name=John Doe&age=25' + }, + { + name: 'pre-encoded (PR #5507 content-blind double-encode)', + url: 'https://example.com/api?name=John%20Doe&email=john%40example.com', + params: [{ name: 'name', value: 'John%20Doe' }, { name: 'email', value: 'john%40example.com' }], + encoded: 'https://example.com/api?name=John%2520Doe&email=john%2540example.com', + raw: 'https://example.com/api?name=John%20Doe&email=john%40example.com' + }, + { + name: 'redirect-style structural chars (:, /)', + url: 'https://example.com/api?path=/users/123&redirect=https://other.com', + params: [{ name: 'path', value: '/users/123' }, { name: 'redirect', value: 'https://other.com' }], + encoded: 'https://example.com/api?path=%2Fusers%2F123&redirect=https%3A%2F%2Fother.com', + raw: 'https://example.com/api?path=/users/123&redirect=https://other.com' + }, + { + name: 'pipe operator', + url: 'https://example.com/api?filter=status|active&sort=name|asc', + params: [{ name: 'filter', value: 'status|active' }, { name: 'sort', value: 'name|asc' }], + encoded: 'https://example.com/api?filter=status%7Cactive&sort=name%7Casc', + raw: 'https://example.com/api?filter=status|active&sort=name|asc' + }, + { + name: 'unicode (é, ü)', + url: 'https://example.com/api?name=José&city=München', + params: [{ name: 'name', value: 'José' }, { name: 'city', value: 'München' }], + encoded: 'https://example.com/api?name=Jos%C3%A9&city=M%C3%BCnchen', + raw: 'https://example.com/api?name=José&city=München' + }, + { + name: 'equals signs in value (token=abc123==)', + url: 'https://example.com/api?token=abc123==&type=test', + params: [{ name: 'token', value: 'abc123==' }, { name: 'type', value: 'test' }], + encoded: 'https://example.com/api?token=abc123%3D%3D&type=test', + raw: 'https://example.com/api?token=abc123==&type=test' + }, + { + name: 'email with + alias and @', + url: 'https://example.com/invite?email=test+alias@example.com', + params: [{ name: 'email', value: 'test+alias@example.com' }], + encoded: 'https://example.com/invite?email=test%2Balias%40example.com', + raw: 'https://example.com/invite?email=test+alias@example.com' + }, + { + name: 'commas + colon (CSV / ISO time)', + url: 'https://example.com/filter?tags=a,b,c&time=10:30', + params: [{ name: 'tags', value: 'a,b,c' }, { name: 'time', value: '10:30' }], + encoded: 'https://example.com/filter?tags=a%2Cb%2Cc&time=10%3A30', + raw: 'https://example.com/filter?tags=a,b,c&time=10:30' + }, + { + name: 'canonical PR #5507 redirect with pre-encoded chars', + url: 'https://auth.example.com/login?redirect=https%3A%2F%2Fother.com%2Fcb&token=abc%2520xyz', + params: [ + { name: 'redirect', value: 'https%3A%2F%2Fother.com%2Fcb' }, + { name: 'token', value: 'abc%2520xyz' } + ], + encoded: 'https://auth.example.com/login?redirect=https%253A%252F%252Fother.com%252Fcb&token=abc%252520xyz', + raw: 'https://auth.example.com/login?redirect=https%3A%2F%2Fother.com%2Fcb&token=abc%2520xyz' + }, + { + name: '# in query value (Option C: data, not fragment)', + url: 'https://example.com/api?query=aaa#bbb', + params: [{ name: 'query', value: 'aaa#bbb' }], + encoded: 'https://example.com/api?query=aaa%23bbb', + raw: 'https://example.com/api?query=aaa#bbb' + }, + { + name: 'JSON-shaped array values (issue #7913)', + url: 'https://example.com/api?empty=[]&nums=[1, 2, 3]&strs=["string", "string"]&nested=[[1, 2, 3], ["string", "string"]]', + params: [ + { name: 'empty', value: '[]' }, + { name: 'nums', value: '[1, 2, 3]' }, + { name: 'strs', value: '["string", "string"]' }, + { name: 'nested', value: '[[1, 2, 3], ["string", "string"]]' } + ], + encoded: 'https://example.com/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', + raw: 'https://example.com/api?empty=[]&nums=[1, 2, 3]&strs=["string", "string"]&nested=[[1, 2, 3], ["string", "string"]]' + } + ]; + + describe.each(cases)('$name', ({ url, params, encoded, raw }) => { + const requestWithEnabled = (encodeUrl: boolean) => ({ + ...baseRequest({ url, settings: { encodeUrl } }), + params: params.map((p) => ({ ...p, type: 'query', enabled: true })) + }); + + it('ON: encodedUrl runs encodeUrl() (encoded form)', () => { + const { encodedUrl } = buildHar({ request: requestWithEnabled(true), shouldInterpolate: false }); + expect(encodedUrl).toBe(encoded); + }); + + it('OFF: rawUrl preserves user-typed URL byte-for-byte', () => { + const { rawUrl } = buildHar({ request: requestWithEnabled(false), shouldInterpolate: false }); + expect(rawUrl).toBe(raw); + }); + }); +}); + +// ---- Phase C.2: path encoding matrix (mirrors e2e fixtures) ------------ +// Each scenario asserts both rawUrl (OFF) and encodedUrl (ON). Path-side +// encoding is idempotent: pre-encoded inputs survive as single-encoded (no +// %20 → %2520) thanks to encodePathSegments using safeDecodeURIComponent. + +describe('buildHar — path encoding matrix (mirror e2e fixtures)', () => { + type PathCase = { + name: string; + url: string; + encoded: string; + raw: string; + }; + + const cases: PathCase[] = [ + { + name: 'spaces in path', + url: 'https://example.com/api/path with spaces/users', + encoded: 'https://example.com/api/path%20with%20spaces/users', + raw: 'https://example.com/api/path with spaces/users' + }, + { + name: 'square brackets', + url: 'https://example.com/list[123]', + encoded: 'https://example.com/list%5B123%5D', + raw: 'https://example.com/list[123]' + }, + { + name: 'unicode', + url: 'https://example.com/users/José/profile', + encoded: 'https://example.com/users/Jos%C3%A9/profile', + raw: 'https://example.com/users/José/profile' + }, + { + name: 'pre-encoded path is idempotent (single-encoded form preserved)', + url: 'https://example.com/api/path%20with%20spaces/users', + // ON: decode-then-encode collapses to single-encoded form (NOT %2520) + encoded: 'https://example.com/api/path%20with%20spaces/users', + // OFF: byte-for-byte preserved + raw: 'https://example.com/api/path%20with%20spaces/users' + }, + { + name: 'OData path with $ filters + space (Products(123)/Categories(456))', + url: 'https://example.com/odata/Products(123)/Categories(456)?$expand=Items&$filter=Price gt 10', + // ON: parens stay unreserved; $ → %24 in query keys; space → %20 + encoded: 'https://example.com/odata/Products(123)/Categories(456)?%24expand=Items&%24filter=Price%20gt%2010', + raw: 'https://example.com/odata/Products(123)/Categories(456)?$expand=Items&$filter=Price gt 10' + } + ]; + + describe.each(cases)('$name', ({ url, encoded, raw }) => { + it('ON: encodedUrl applies encodeUrl()', () => { + const { encodedUrl } = buildHar({ + request: baseRequest({ url, settings: { encodeUrl: true } }), + shouldInterpolate: false + }); + expect(encodedUrl).toBe(encoded); + }); + + it('OFF: rawUrl preserves user-typed URL byte-for-byte', () => { + const { rawUrl } = buildHar({ + request: baseRequest({ url, settings: { encodeUrl: false } }), + shouldInterpolate: false + }); + expect(rawUrl).toBe(raw); + }); + }); +}); + +// ---- Phase C.3: bracket-key phantom-duplicate regression ---------------- +// `stripQueryStringFromUrl` removes the URL's query before storing in HAR +// so HTTPSnippet's `url.parse(..., true, true)` can't strip `[]` from +// bracketed keys and emit a phantom second copy. Sole source of truth for +// the rendered query string is `har.queryString`. + +describe('buildHar — bracket-key query regression (post_ids[], key[a][b])', () => { + it('post_ids[]=8647 — har.queryString length 1, har.url has no `?`', () => { + const { har } = buildHar({ + request: baseRequest({ + url: 'https://meta.discourse.org/t/173975/posts.json?post_ids%5B%5D=8647', + params: [{ name: 'post_ids[]', value: '8647', type: 'query', enabled: true }] + }), + shouldInterpolate: false + }); + expect(har.url).toBe('https://meta.discourse.org/t/173975/posts.json'); + expect(har.url).not.toContain('?'); + expect(har.queryString).toHaveLength(1); + expect(har.queryString[0]).toEqual({ name: 'post_ids[]', value: '8647' }); + }); + + it('key[a][b]=nested — Rails-style nested brackets, no phantom duplicate', () => { + const { har } = buildHar({ + request: baseRequest({ + url: 'https://example.com/api?key[a][b]=nested', + params: [{ name: 'key[a][b]', value: 'nested', type: 'query', enabled: true }] + }), + shouldInterpolate: false + }); + expect(har.url).toBe('https://example.com/api'); + expect(har.queryString).toHaveLength(1); + expect(har.queryString[0]).toEqual({ name: 'key[a][b]', value: 'nested' }); + }); + + it('repeated array keys ids[]=1&ids[]=2&ids[]=3 → 3 distinct entries preserved in order', () => { + const { har } = buildHar({ + request: baseRequest({ + url: 'https://example.com/api?ids[]=1&ids[]=2&ids[]=3', + params: [ + { name: 'ids[]', value: '1', type: 'query', enabled: true }, + { name: 'ids[]', value: '2', type: 'query', enabled: true }, + { name: 'ids[]', value: '3', type: 'query', enabled: true } + ] + }), + shouldInterpolate: false + }); + expect(har.queryString).toHaveLength(3); + expect(har.queryString).toEqual([ + { name: 'ids[]', value: '1' }, + { name: 'ids[]', value: '2' }, + { name: 'ids[]', value: '3' } + ]); + }); + + it('# in URL with a query: encodeUrl folds # into the query value as %23, then strip removes the whole query', () => { + // Option C contract: encodeUrl no longer splits on `#`. The `#section` + // chunk is parsed as part of the query value, encoded to `%23section`, + // and then stripped along with the rest of `?q=1%23section` by + // stripQueryStringFromUrl. Net result: har.url is just origin + path. + const { har } = buildHar({ + request: baseRequest({ + url: 'https://example.com/api?q=1#section', + params: [{ name: 'q', value: '1#section', type: 'query', enabled: true }] + }), + shouldInterpolate: false + }); + expect(har.url).toBe('https://example.com/api'); + }); + + it('# in URL with NO query: encodeUrl encodes # to %23 in the path; strip is a no-op (no `?` to strip)', () => { + // Option C: `#` lands inside the path encoding pipeline because encodeUrl + // no longer splits on `#`. encodePathSegments encodes it to `%23` via + // encodeURIComponent. stripQueryStringFromUrl does nothing (no `?`). + const { har } = buildHar({ + request: baseRequest({ url: 'https://example.com/api#section' }), + shouldInterpolate: false + }); + expect(har.url).toBe('https://example.com/api%23section'); + }); + + it('strips queries the same way regardless of bracket presence (multi-param sanity check)', () => { + const { har } = buildHar({ + request: baseRequest({ + url: 'https://example.com/api?a=1&b=2&c=3', + params: [ + { name: 'a', value: '1', type: 'query', enabled: true }, + { name: 'b', value: '2', type: 'query', enabled: true }, + { name: 'c', value: '3', type: 'query', enabled: true } + ] + }), + shouldInterpolate: false + }); + expect(har.url).toBe('https://example.com/api'); + expect(har.queryString).toHaveLength(3); + }); +}); + +// ---- Phase C.4: rawUrl / encodedUrl invariants ------------------------- +// The two URL fields returned by buildHar are the contract snippet-generator +// uses for its display-swap (OFF mode shows rawUrl, ON mode shows encodedUrl). +// These tests pin the invariants so a future refactor can't silently swap +// which URL each field carries. + +describe('buildHar — rawUrl vs encodedUrl invariants', () => { + it('OFF: rawUrl equals user-typed URL byte-for-byte', () => { + const url = 'https://example.com/api?name=John Doe&path=/x/y'; + const { rawUrl } = buildHar({ + request: baseRequest({ + url, + params: [ + { name: 'name', value: 'John Doe', type: 'query', enabled: true }, + { name: 'path', value: '/x/y', type: 'query', enabled: true } + ], + settings: { encodeUrl: false } + }), + shouldInterpolate: false + }); + expect(rawUrl).toBe(url); + }); + + it('ON: encodedUrl differs from rawUrl when input has non-unreserved chars', () => { + const url = 'https://example.com/api?name=John Doe'; + const { rawUrl, encodedUrl } = buildHar({ + request: baseRequest({ + url, + params: [{ name: 'name', value: 'John Doe', type: 'query', enabled: true }], + settings: { encodeUrl: true } + }), + shouldInterpolate: false + }); + expect(encodedUrl).not.toBe(rawUrl); + expect(encodedUrl).toContain('John%20Doe'); + }); + + it('ON: encodedUrl equals rawUrl when input is already unreserved-only (encoding is a no-op)', () => { + const url = 'https://example.com/api/foo/bar?a=1&b=2'; + const { rawUrl, encodedUrl } = buildHar({ + request: baseRequest({ + url, + params: [ + { name: 'a', value: '1', type: 'query', enabled: true }, + { name: 'b', value: '2', type: 'query', enabled: true } + ], + settings: { encodeUrl: true } + }), + shouldInterpolate: false + }); + expect(encodedUrl).toBe(rawUrl); + }); + + it('encodedUrl applies Option C — # becomes %23', () => { + const { encodedUrl } = buildHar({ + request: baseRequest({ + url: 'https://example.com/api?q=aaa#bbb', + params: [{ name: 'q', value: 'aaa#bbb', type: 'query', enabled: true }], + settings: { encodeUrl: true } + }), + shouldInterpolate: false + }); + expect(encodedUrl).toContain('q=aaa%23bbb'); + expect(encodedUrl).not.toContain('#bbb'); + }); + + it('rawUrl includes literal {{var}} when shouldInterpolate=false (hashed internally, restored by unhash)', () => { + // While processing, `{{baseUrl}}` is replaced by a URL-safe hash so + // parsing/encoding work. buildHar exposes `unhash` which, when applied + // to the final string output, restores the literal `{{baseUrl}}` form. + const { rawUrl, unhash } = buildHar({ + request: baseRequest({ + url: 'https://{{baseUrl}}/api?q=1', + params: [{ name: 'q', value: '1', type: 'query', enabled: true }] + }), + shouldInterpolate: false + }); + // rawUrl carries the internal hashed form; unhash restores the template + const restored = unhash(rawUrl); + expect(restored).toContain('{{baseUrl}}'); + }); + + it('rawUrl and encodedUrl both reflect path-param substitution from pathParams[]', () => { + const { rawUrl, encodedUrl } = buildHar({ + request: baseRequest({ + url: 'https://example.com/users/:id', + pathParams: [{ name: 'id', value: 'aaa bbb', type: 'path', enabled: true }], + settings: { encodeUrl: true } + }), + shouldInterpolate: false + }); + // ON: encoded form + expect(encodedUrl).toBe('https://example.com/users/aaa%20bbb'); + // raw form always preserves user input + expect(rawUrl).toBe('https://example.com/users/aaa bbb'); + }); +}); + +// ---- Phase D: interpolation edge cases + defensive inputs -------------- + +describe('buildHar — interpolation edge cases', () => { + it('{{?prompt}} variables in URL survive verbatim through unhash (not substituted at HAR-build time)', () => { + const { unhash, rawUrl } = buildHar({ + request: baseRequest({ url: 'https://example.com/api?q={{?Prompt Var}}' }), + shouldInterpolate: false + }); + // The `?` makes it a prompt placeholder. Bruno's runtime/snippet caller is + // responsible for asking the user — buildHar must leave the token intact. + expect(unhash(rawUrl)).toContain('{{?Prompt Var}}'); + }); + + it('missing variable {{undefined_var}} passes through as literal placeholder when shouldInterpolate=true', () => { + const { har } = buildHar({ + request: baseRequest({ + headers: [{ name: 'X-Foo', value: '{{undefined_var}}', enabled: true }] + }), + variables: { other: 'value' }, + shouldInterpolate: true + }); + const header = har.headers.find((h) => h.name === 'X-Foo'); + expect(header?.value).toContain('{{undefined_var}}'); + }); + + it('shouldInterpolate=true resolves headers with {{var}}', () => { + const { har } = buildHar({ + request: baseRequest({ + headers: [{ name: 'X-Token', value: '{{token}}', enabled: true }] + }), + variables: { token: 'tk-resolved' }, + shouldInterpolate: true + }); + expect(har.headers).toContainEqual({ name: 'X-Token', value: 'tk-resolved' }); + }); + + it('shouldInterpolate=true resolves body text with {{var}} (text mode)', () => { + const { har } = buildHar({ + request: baseRequest({ body: { mode: 'text', text: 'hello {{name}}' } }), + variables: { name: 'world' }, + shouldInterpolate: true + }); + expect(har.postData.text).toBe('hello world'); + }); + + it('shouldInterpolate=true resolves body xml with {{var}}', () => { + const { har } = buildHar({ + request: baseRequest({ body: { mode: 'xml', xml: '{{name}}' } }), + variables: { name: 'alice' }, + shouldInterpolate: true + }); + expect(har.postData.text).toBe('alice'); + }); + + it('shouldInterpolate=true resolves body sparql with {{var}}', () => { + const { har } = buildHar({ + request: baseRequest({ body: { mode: 'sparql', sparql: 'SELECT ?{{var}} WHERE { ?s ?p ?o }' } }), + variables: { var: 'x' }, + shouldInterpolate: true + }); + expect(har.postData.text).toBe('SELECT ?x WHERE { ?s ?p ?o }'); + }); + + it('shouldInterpolate=true resolves formUrlEncoded values with {{var}}', () => { + const { har } = buildHar({ + request: baseRequest({ + body: { + mode: 'formUrlEncoded', + formUrlEncoded: [{ name: 'role', value: '{{role}}', enabled: true }] + } + }), + variables: { role: 'admin' }, + shouldInterpolate: true + }); + expect(har.postData.text).toBe('role=admin'); + }); + + it('shouldInterpolate=true resolves multipartForm values with {{var}}', () => { + const { har } = buildHar({ + request: baseRequest({ + body: { + mode: 'multipartForm', + multipartForm: [{ name: 'caption', value: '{{label}}', type: 'text', enabled: true }] + } + }), + variables: { label: 'hello' }, + shouldInterpolate: true + }); + expect(har.postData.params).toContainEqual({ name: 'caption', value: 'hello' }); + }); + + it('shouldInterpolate=true resolves auth fields (basic password)', () => { + const { har } = buildHar({ + request: baseRequest({ + auth: { mode: 'basic', basic: { username: 'u', password: '{{pw}}' } } + }), + variables: { pw: 'sekret' }, + shouldInterpolate: true + }); + const auth = har.headers.find((h) => h.name === 'Authorization'); + expect(auth?.value).toBe(`Basic ${Buffer.from('u:sekret').toString('base64')}`); + }); +}); + +describe('buildHar — defensive / robustness', () => { + it('empty headers array → still adds default content-type for body mode', () => { + const { har } = buildHar({ + request: baseRequest({ headers: [], body: { mode: 'json', json: '{}' } }), + shouldInterpolate: false + }); + expect(har.headers).toContainEqual({ name: 'Content-Type', value: 'application/json' }); + }); + + it('malformed auth (missing .basic.username) → empty string substituted, no throw', () => { + const { har } = buildHar({ + request: baseRequest({ auth: { mode: 'basic', basic: {} } }), + shouldInterpolate: false + }); + expect(har.headers.find((h) => h.name === 'Authorization')).toBeDefined(); + }); + + it('encoded-only-once for unreserved-chars-only path (no-op encoding)', () => { + const url = 'https://example.com/api/users/abc-123_.~/profile'; + const { encodedUrl } = buildHar({ + request: baseRequest({ url, settings: { encodeUrl: true } }), + shouldInterpolate: false + }); + expect(encodedUrl).toBe(url); + }); + + it('missing body → har.postData is undefined / empty (no throw)', () => { + const { har } = buildHar({ + request: { method: 'GET', url: 'https://example.com/api', params: [], headers: [], auth: { mode: 'none' } } as any, + shouldInterpolate: false + }); + // Body-less request still produces a HAR + expect(har.method).toBe('GET'); + }); + + it('disabled query params filtered before stripping/encoding (do not leak into queryString)', () => { + const { har } = buildHar({ + request: baseRequest({ + url: 'https://example.com/api?keep=yes&drop=no', + params: [ + { name: 'keep', value: 'yes', type: 'query', enabled: true }, + { name: 'drop', value: 'no', type: 'query', enabled: false } + ] + }), + shouldInterpolate: false + }); + const names = har.queryString.map((p) => p.name); + expect(names).toContain('keep'); + expect(names).not.toContain('drop'); + }); + + it('headers with disabled flag false → not present in har.headers', () => { + const { har } = buildHar({ + request: baseRequest({ + headers: [ + { name: 'X-Keep', value: 'on', enabled: true }, + { name: 'X-Drop', value: 'off', enabled: false } + ] + }), + shouldInterpolate: false + }); + const names = har.headers.map((h) => h.name); + expect(names).toContain('X-Keep'); + expect(names).not.toContain('X-Drop'); + }); +}); + +/** + * `#` encoding decision-tree matrix — covers every scenario from the + * fixings/snippet-vs-sendrequest.md docs. Each scenario asserts both the + * rawUrl (OFF: byte-for-byte) and the encodedUrl (ON: per the path/query + * encoding contracts). `b` variants test pre-encoded URL-bar input. + */ +describe('buildHar — # encoding scenarios (decision-tree coverage)', () => { + it('Scenario 1: # at fragment position (/docs/api#authentication) — anchor for SPA/static docs', () => { + const url = 'https://example.com/docs/api#authentication'; + const off = buildHar({ request: baseRequest({ url, settings: { encodeUrl: false } }), shouldInterpolate: false }); + const on = buildHar({ request: baseRequest({ url, settings: { encodeUrl: true } }), shouldInterpolate: false }); + + // OFF: snippet shows the literal `#authentication` (user intent preserved) + expect(off.rawUrl).toBe('https://example.com/docs/api#authentication'); + // ON: `#` becomes `%23` so the byte survives downstream URL parsing + expect(on.encodedUrl).toBe('https://example.com/docs/api%23authentication'); + }); + + it('Scenario 2: # inside a query value (?query=aaa#bbb)', () => { + const url = 'http://localhost:6000/request-echo?query=aaa#bbb'; + const off = buildHar({ request: baseRequest({ url, settings: { encodeUrl: false } }), shouldInterpolate: false }); + const on = buildHar({ + request: baseRequest({ + url, + params: [{ name: 'query', value: 'aaa#bbb', type: 'query', enabled: true }], + settings: { encodeUrl: true } + }), + shouldInterpolate: false + }); + + expect(off.rawUrl).toBe('http://localhost:6000/request-echo?query=aaa#bbb'); + expect(on.encodedUrl).toBe('http://localhost:6000/request-echo?query=aaa%23bbb'); + }); + + it('Scenario 2b: pre-encoded query (?query=aaa%23bbb) — ON double-encodes %23 → %2523', () => { + // Pre-encoded URL bar — `%23` is in the typed input. Per PR #5507's + // content-blind contract, ON mode re-encodes content-blindly so `%23` + // becomes `%2523` (one more encoding pass). OFF preserves byte-for-byte. + const url = 'https://example.com/api?query=aaa%23bbb'; + const off = buildHar({ + request: baseRequest({ + url, + params: [{ name: 'query', value: 'aaa%23bbb', type: 'query', enabled: true }], + settings: { encodeUrl: false } + }), + shouldInterpolate: false + }); + const on = buildHar({ + request: baseRequest({ + url, + params: [{ name: 'query', value: 'aaa%23bbb', type: 'query', enabled: true }], + settings: { encodeUrl: true } + }), + shouldInterpolate: false + }); + + expect(off.rawUrl).toBe('https://example.com/api?query=aaa%23bbb'); + expect(on.encodedUrl).toBe('https://example.com/api?query=aaa%2523bbb'); + }); + + it('Scenario 3: # in path-param value with trailing path (:id=john#doe, /users/:id/profile)', () => { + // The trailing /profile demonstrates the "silent data loss" case in OFF mode: + // axios on the wire treats `#doe/profile` as a fragment and strips it, so the + // server only sees `/users/john`. ON mode encodes `#` to `%23`, preserving + // both the value AND the /profile suffix on the wire. + const url = 'https://example.com/users/:id/profile'; + const pathParams = [{ name: 'id', value: 'john#doe', type: 'path', enabled: true }]; + const off = buildHar({ request: baseRequest({ url, pathParams, settings: { encodeUrl: false } }), shouldInterpolate: false }); + const on = buildHar({ request: baseRequest({ url, pathParams, settings: { encodeUrl: true } }), shouldInterpolate: false }); + + expect(off.rawUrl).toBe('https://example.com/users/john#doe/profile'); + expect(on.encodedUrl).toBe('https://example.com/users/john%23doe/profile'); + }); + + it('Scenario 4: # in semantic path with trailing data (/issues/#1234)', () => { + const url = 'https://example.com/issues/#1234'; + const off = buildHar({ request: baseRequest({ url, settings: { encodeUrl: false } }), shouldInterpolate: false }); + const on = buildHar({ request: baseRequest({ url, settings: { encodeUrl: true } }), shouldInterpolate: false }); + + expect(off.rawUrl).toBe('https://example.com/issues/#1234'); + expect(on.encodedUrl).toBe('https://example.com/issues/%231234'); + }); + + it('Scenario 4b: pre-encoded issue tracker path (/issues/%231234) — idempotent path encoding', () => { + // Pre-encoded URL bar with `%23` in the path. Path-side encoding is + // idempotent (decode-then-encode via safeDecodeURIComponent), so ON mode + // keeps `%231234` exactly — it does NOT double-encode like the query side. + // This is the key asymmetry between path and query encoding contracts. + const url = 'https://example.com/issues/%231234'; + const off = buildHar({ request: baseRequest({ url, settings: { encodeUrl: false } }), shouldInterpolate: false }); + const on = buildHar({ request: baseRequest({ url, settings: { encodeUrl: true } }), shouldInterpolate: false }); + + expect(off.rawUrl).toBe('https://example.com/issues/%231234'); + expect(on.encodedUrl).toBe('https://example.com/issues/%231234'); + }); + + it('Scenario 5: SPA hash-router URL (/#/dashboard/settings)', () => { + const url = 'https://yourapp.com/#/dashboard/settings'; + const off = buildHar({ request: baseRequest({ url, settings: { encodeUrl: false } }), shouldInterpolate: false }); + const on = buildHar({ request: baseRequest({ url, settings: { encodeUrl: true } }), shouldInterpolate: false }); + + expect(off.rawUrl).toBe('https://yourapp.com/#/dashboard/settings'); + // Each path segment is independently encoded; the standalone `#` becomes `%23` + expect(on.encodedUrl).toBe('https://yourapp.com/%23/dashboard/settings'); + }); + + it('Scenario 7: # directly in path (browser-address-bar style)', () => { + // Same shape as Scenario 1 but with the # following a path segment with no + // trailing slash. Behaves identically — # is data in ON, fragment in OFF. + const url = 'http://localhost:6000/request-echo/hash#tag'; + const off = buildHar({ request: baseRequest({ url, settings: { encodeUrl: false } }), shouldInterpolate: false }); + const on = buildHar({ request: baseRequest({ url, settings: { encodeUrl: true } }), shouldInterpolate: false }); + + expect(off.rawUrl).toBe('http://localhost:6000/request-echo/hash#tag'); + expect(on.encodedUrl).toBe('http://localhost:6000/request-echo/hash%23tag'); + }); + + it('Scenario 7b: pre-encoded direct path (/path/hash%23tag) — idempotent path encoding', () => { + // Mirror of 4b for the typed-in-address-bar case. Same idempotent + // path-side encoding contract: `%23` survives ON mode without becoming `%2523`. + const url = 'http://localhost:6000/request-echo/hash%23tag'; + const off = buildHar({ request: baseRequest({ url, settings: { encodeUrl: false } }), shouldInterpolate: false }); + const on = buildHar({ request: baseRequest({ url, settings: { encodeUrl: true } }), shouldInterpolate: false }); + + expect(off.rawUrl).toBe('http://localhost:6000/request-echo/hash%23tag'); + expect(on.encodedUrl).toBe('http://localhost:6000/request-echo/hash%23tag'); + }); + + it('Scenario 8: OAuth-style fragment (/callback#access_token=...&token_type=Bearer)', () => { + // OAuth implicit-flow callback. The entire token payload after `#` is treated + // as one path segment by encodeUrl, so `#`, `=`, and `&` all get encoded. + const url = 'https://myapp.com/callback#access_token=abc123&token_type=Bearer'; + const off = buildHar({ request: baseRequest({ url, settings: { encodeUrl: false } }), shouldInterpolate: false }); + const on = buildHar({ request: baseRequest({ url, settings: { encodeUrl: true } }), shouldInterpolate: false }); + + expect(off.rawUrl).toBe('https://myapp.com/callback#access_token=abc123&token_type=Bearer'); + expect(on.encodedUrl).toBe('https://myapp.com/callback%23access_token%3Dabc123%26token_type%3DBearer'); + }); +}); diff --git a/packages/bruno-common/src/generate-code/har/index.ts b/packages/bruno-common/src/generate-code/har/index.ts new file mode 100644 index 000000000..23afc678b --- /dev/null +++ b/packages/bruno-common/src/generate-code/har/index.ts @@ -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; + /** + * 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): 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-` — 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( + 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, + 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) || ''; + + 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 + }; +} diff --git a/packages/bruno-common/src/index.ts b/packages/bruno-common/src/index.ts index bd30944bc..66c7ccaf6 100644 --- a/packages/bruno-common/src/index.ts +++ b/packages/bruno-common/src/index.ts @@ -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'; diff --git a/packages/bruno-common/src/utils/index.ts b/packages/bruno-common/src/utils/index.ts index 7e481a6aa..5ca82b232 100644 --- a/packages/bruno-common/src/utils/index.ts +++ b/packages/bruno-common/src/utils/index.ts @@ -3,7 +3,8 @@ export { encodeUrl, parseQueryParams, buildQueryString, - stripOrigin + stripOrigin, + safeDecodeURIComponent } from './url'; export { diff --git a/packages/bruno-common/src/utils/url/index.spec.ts b/packages/bruno-common/src/utils/url/index.spec.ts index 69e0b80d1..27a6f11cc 100644 --- a/packages/bruno-common/src/utils/url/index.spec.ts +++ b/packages/bruno-common/src/utils/url/index.spec.ts @@ -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/ac/"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', () => { diff --git a/packages/bruno-common/src/utils/url/index.ts b/packages/bruno-common/src/utils/url/index.ts index dae5e65cb..e0d358885 100644 --- a/packages/bruno-common/src/utils/url/index.ts +++ b/packages/bruno-common/src/utils/url/index.ts @@ -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 diff --git a/packages/bruno-tests/src/echo/index.js b/packages/bruno-tests/src/echo/index.js index f17197b0a..f13ac1e01 100644 --- a/packages/bruno-tests/src/echo/index.js +++ b/packages/bruno-tests/src/echo/index.js @@ -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); }); diff --git a/tests/collection/draft/draft-values-in-requests.spec.ts b/tests/collection/draft/draft-values-in-requests.spec.ts index 5f4e85a35..5b3e1b959 100644 --- a/tests/collection/draft/draft-values-in-requests.spec.ts +++ b/tests/collection/draft/draft-values-in-requests.spec.ts @@ -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 diff --git a/tests/request/generate-code/collection/bruno.json b/tests/request/generate-code/collection/bruno.json new file mode 100644 index 000000000..6777d583e --- /dev/null +++ b/tests/request/generate-code/collection/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "generate-code-encoding", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} diff --git a/tests/request/generate-code/collection/requests/docs-fragment-external.bru b/tests/request/generate-code/collection/requests/docs-fragment-external.bru new file mode 100644 index 000000000..856f43b86 --- /dev/null +++ b/tests/request/generate-code/collection/requests/docs-fragment-external.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/fragment-preserved.bru b/tests/request/generate-code/collection/requests/fragment-preserved.bru new file mode 100644 index 000000000..ce36cdc32 --- /dev/null +++ b/tests/request/generate-code/collection/requests/fragment-preserved.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/oauth-callback-fragment.bru b/tests/request/generate-code/collection/requests/oauth-callback-fragment.bru new file mode 100644 index 000000000..05102fee7 --- /dev/null +++ b/tests/request/generate-code/collection/requests/oauth-callback-fragment.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/params-path-odata.bru b/tests/request/generate-code/collection/requests/params-path-odata.bru new file mode 100644 index 000000000..dd234c9b1 --- /dev/null +++ b/tests/request/generate-code/collection/requests/params-path-odata.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/params-path-space.bru b/tests/request/generate-code/collection/requests/params-path-space.bru new file mode 100644 index 000000000..e9ac0591a --- /dev/null +++ b/tests/request/generate-code/collection/requests/params-path-space.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/path-brackets.bru b/tests/request/generate-code/collection/requests/path-brackets.bru new file mode 100644 index 000000000..3884b4ebb --- /dev/null +++ b/tests/request/generate-code/collection/requests/path-brackets.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/path-fragment.bru b/tests/request/generate-code/collection/requests/path-fragment.bru new file mode 100644 index 000000000..df777a40a --- /dev/null +++ b/tests/request/generate-code/collection/requests/path-fragment.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/path-idempotent.bru b/tests/request/generate-code/collection/requests/path-idempotent.bru new file mode 100644 index 000000000..01fcff617 --- /dev/null +++ b/tests/request/generate-code/collection/requests/path-idempotent.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/path-issues-fragment.bru b/tests/request/generate-code/collection/requests/path-issues-fragment.bru new file mode 100644 index 000000000..4c8cee6bd --- /dev/null +++ b/tests/request/generate-code/collection/requests/path-issues-fragment.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/path-odata.bru b/tests/request/generate-code/collection/requests/path-odata.bru new file mode 100644 index 000000000..4be65c724 --- /dev/null +++ b/tests/request/generate-code/collection/requests/path-odata.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/path-param-ampersand.bru b/tests/request/generate-code/collection/requests/path-param-ampersand.bru new file mode 100644 index 000000000..713dbc481 --- /dev/null +++ b/tests/request/generate-code/collection/requests/path-param-ampersand.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/path-param-at.bru b/tests/request/generate-code/collection/requests/path-param-at.bru new file mode 100644 index 000000000..2a3d579b2 --- /dev/null +++ b/tests/request/generate-code/collection/requests/path-param-at.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/path-param-braces.bru b/tests/request/generate-code/collection/requests/path-param-braces.bru new file mode 100644 index 000000000..9e607b25a --- /dev/null +++ b/tests/request/generate-code/collection/requests/path-param-braces.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/path-param-brackets.bru b/tests/request/generate-code/collection/requests/path-param-brackets.bru new file mode 100644 index 000000000..e18e91359 --- /dev/null +++ b/tests/request/generate-code/collection/requests/path-param-brackets.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/path-param-colon.bru b/tests/request/generate-code/collection/requests/path-param-colon.bru new file mode 100644 index 000000000..846c88297 --- /dev/null +++ b/tests/request/generate-code/collection/requests/path-param-colon.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/path-param-comma.bru b/tests/request/generate-code/collection/requests/path-param-comma.bru new file mode 100644 index 000000000..6781d45d0 --- /dev/null +++ b/tests/request/generate-code/collection/requests/path-param-comma.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/path-param-equals.bru b/tests/request/generate-code/collection/requests/path-param-equals.bru new file mode 100644 index 000000000..f89f43d46 --- /dev/null +++ b/tests/request/generate-code/collection/requests/path-param-equals.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/path-param-hash-trailing.bru b/tests/request/generate-code/collection/requests/path-param-hash-trailing.bru new file mode 100644 index 000000000..4a24d9639 --- /dev/null +++ b/tests/request/generate-code/collection/requests/path-param-hash-trailing.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/path-param-hash.bru b/tests/request/generate-code/collection/requests/path-param-hash.bru new file mode 100644 index 000000000..a2690be90 --- /dev/null +++ b/tests/request/generate-code/collection/requests/path-param-hash.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/path-param-pipe.bru b/tests/request/generate-code/collection/requests/path-param-pipe.bru new file mode 100644 index 000000000..f7c1c5e02 --- /dev/null +++ b/tests/request/generate-code/collection/requests/path-param-pipe.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/path-param-plus.bru b/tests/request/generate-code/collection/requests/path-param-plus.bru new file mode 100644 index 000000000..8e3337ec9 --- /dev/null +++ b/tests/request/generate-code/collection/requests/path-param-plus.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/path-param-question.bru b/tests/request/generate-code/collection/requests/path-param-question.bru new file mode 100644 index 000000000..276feba2a --- /dev/null +++ b/tests/request/generate-code/collection/requests/path-param-question.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/path-param-slash.bru b/tests/request/generate-code/collection/requests/path-param-slash.bru new file mode 100644 index 000000000..a154f7d20 --- /dev/null +++ b/tests/request/generate-code/collection/requests/path-param-slash.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/path-param-space.bru b/tests/request/generate-code/collection/requests/path-param-space.bru new file mode 100644 index 000000000..05e317d1b --- /dev/null +++ b/tests/request/generate-code/collection/requests/path-param-space.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/path-param-unicode.bru b/tests/request/generate-code/collection/requests/path-param-unicode.bru new file mode 100644 index 000000000..a24b1eff4 --- /dev/null +++ b/tests/request/generate-code/collection/requests/path-param-unicode.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/path-spa-route.bru b/tests/request/generate-code/collection/requests/path-spa-route.bru new file mode 100644 index 000000000..e3ff6fe86 --- /dev/null +++ b/tests/request/generate-code/collection/requests/path-spa-route.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/path-spaces.bru b/tests/request/generate-code/collection/requests/path-spaces.bru new file mode 100644 index 000000000..5c563d45e --- /dev/null +++ b/tests/request/generate-code/collection/requests/path-spaces.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/path-unicode.bru b/tests/request/generate-code/collection/requests/path-unicode.bru new file mode 100644 index 000000000..7179e5d17 --- /dev/null +++ b/tests/request/generate-code/collection/requests/path-unicode.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/query-arrays.bru b/tests/request/generate-code/collection/requests/query-arrays.bru new file mode 100644 index 000000000..1b38f74c7 --- /dev/null +++ b/tests/request/generate-code/collection/requests/query-arrays.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/query-commas-colons.bru b/tests/request/generate-code/collection/requests/query-commas-colons.bru new file mode 100644 index 000000000..c944badba --- /dev/null +++ b/tests/request/generate-code/collection/requests/query-commas-colons.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/query-double-encode.bru b/tests/request/generate-code/collection/requests/query-double-encode.bru new file mode 100644 index 000000000..afc80421b --- /dev/null +++ b/tests/request/generate-code/collection/requests/query-double-encode.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/query-email-plus.bru b/tests/request/generate-code/collection/requests/query-email-plus.bru new file mode 100644 index 000000000..e90c83beb --- /dev/null +++ b/tests/request/generate-code/collection/requests/query-email-plus.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/query-equals.bru b/tests/request/generate-code/collection/requests/query-equals.bru new file mode 100644 index 000000000..049d19a3a --- /dev/null +++ b/tests/request/generate-code/collection/requests/query-equals.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/query-hash.bru b/tests/request/generate-code/collection/requests/query-hash.bru new file mode 100644 index 000000000..90af57c10 --- /dev/null +++ b/tests/request/generate-code/collection/requests/query-hash.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/query-pipe.bru b/tests/request/generate-code/collection/requests/query-pipe.bru new file mode 100644 index 000000000..40467f8a2 --- /dev/null +++ b/tests/request/generate-code/collection/requests/query-pipe.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/query-preencoded.bru b/tests/request/generate-code/collection/requests/query-preencoded.bru new file mode 100644 index 000000000..677aa41b7 --- /dev/null +++ b/tests/request/generate-code/collection/requests/query-preencoded.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/query-redirect-url.bru b/tests/request/generate-code/collection/requests/query-redirect-url.bru new file mode 100644 index 000000000..9c505e78a --- /dev/null +++ b/tests/request/generate-code/collection/requests/query-redirect-url.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/query-spaces.bru b/tests/request/generate-code/collection/requests/query-spaces.bru new file mode 100644 index 000000000..d0752a3cb --- /dev/null +++ b/tests/request/generate-code/collection/requests/query-spaces.bru @@ -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 +} diff --git a/tests/request/generate-code/collection/requests/query-unicode.bru b/tests/request/generate-code/collection/requests/query-unicode.bru new file mode 100644 index 000000000..c74ae558f --- /dev/null +++ b/tests/request/generate-code/collection/requests/query-unicode.bru @@ -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 +} diff --git a/tests/request/generate-code/init-user-data/collection-security.json b/tests/request/generate-code/init-user-data/collection-security.json new file mode 100644 index 000000000..6f5072ff6 --- /dev/null +++ b/tests/request/generate-code/init-user-data/collection-security.json @@ -0,0 +1,10 @@ +{ + "collections": [ + { + "path": "{{projectRoot}}/tests/request/generate-code/collection", + "securityConfig": { + "jsSandboxMode": "safe" + } + } + ] +} diff --git a/tests/request/generate-code/init-user-data/preferences.json b/tests/request/generate-code/init-user-data/preferences.json new file mode 100644 index 000000000..8785e4e91 --- /dev/null +++ b/tests/request/generate-code/init-user-data/preferences.json @@ -0,0 +1,12 @@ +{ + "maximized": false, + "lastOpenedCollections": [ + "{{projectRoot}}/tests/request/generate-code/collection" + ], + "preferences": { + "onboarding": { + "hasLaunchedBefore": true, + "hasSeenWelcomeModal": true + } + } +} diff --git a/tests/request/generate-code/send-vs-snippet.spec.ts b/tests/request/generate-code/send-vs-snippet.spec.ts new file mode 100644 index 000000000..1919444b2 --- /dev/null +++ b/tests/request/generate-code/send-vs-snippet.spec.ts @@ -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'); + }); +}); diff --git a/tests/request/generate-code/url-encoding-off.spec.ts b/tests/request/generate-code/url-encoding-off.spec.ts new file mode 100644 index 000000000..e3678c1c6 --- /dev/null +++ b/tests/request/generate-code/url-encoding-off.spec.ts @@ -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); + }); + } + }); +}); diff --git a/tests/request/generate-code/url-encoding-on.spec.ts b/tests/request/generate-code/url-encoding-on.spec.ts new file mode 100644 index 000000000..8e9ec25b5 --- /dev/null +++ b/tests/request/generate-code/url-encoding-on.spec.ts @@ -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); + }); + } + }); +}); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index f9874f88d..bc8bfe451 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -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 => { + 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 };