mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
fix(generate-code)!: generate code URL issues (#8136)
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
import { buildHarRequest } from 'utils/codegenerator/har';
|
||||
import { getAuthHeaders } from 'utils/codegenerator/auth';
|
||||
import { buildHar } from '@usebruno/common';
|
||||
import { stripOrigin } from '@usebruno/common/utils';
|
||||
import { getAllVariables, getTreePathFromCollectionToItem, mergeHeaders } from 'utils/collections/index';
|
||||
import { resolveInheritedAuth } from 'utils/auth';
|
||||
import { get } from 'lodash';
|
||||
import { interpolateAuth, interpolateHeaders, interpolateBody, interpolateParams } from './interpolation';
|
||||
import { encodeUrl as encodeUrlCommon, stripOrigin } from '@usebruno/common/utils';
|
||||
import { parse } from 'url';
|
||||
import { stringify } from 'query-string';
|
||||
|
||||
// curl --digest / --ntlm are surface-level snippet adjustments, not part of
|
||||
// the HAR contract — keep them at this layer.
|
||||
const addCurlAuthFlags = (curlCommand, auth) => {
|
||||
if (!auth || !curlCommand) return curlCommand;
|
||||
|
||||
@@ -18,7 +18,6 @@ const addCurlAuthFlags = (curlCommand, auth) => {
|
||||
const password = get(auth, `${authMode}.password`, '');
|
||||
const credentials = password ? `${username}:${password}` : username;
|
||||
const authFlag = authMode === 'digest' ? '--digest' : '--ntlm';
|
||||
// Escape single quotes for shell safety: ' becomes '\''
|
||||
const escapedCredentials = credentials.replace(/'/g, `'\\''`);
|
||||
|
||||
const curlMatch = curlCommand.match(/^(curl(?:\.exe)?)/i);
|
||||
@@ -46,48 +45,47 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false
|
||||
effectiveAuth = resolvedRequest.auth;
|
||||
}
|
||||
|
||||
// Get the request tree path and merge headers
|
||||
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
|
||||
let headers = mergeHeaders(collection, request, requestTreePath);
|
||||
const mergedHeaders = mergeHeaders(collection, request, requestTreePath);
|
||||
|
||||
// Add auth headers if needed (auth inheritance is resolved upstream)
|
||||
if (request.auth && request.auth.mode !== 'none') {
|
||||
if (shouldInterpolate) {
|
||||
request.auth = interpolateAuth(request.auth, variables);
|
||||
}
|
||||
const settings = item.draft ? get(item, 'draft.settings') : get(item, 'settings');
|
||||
|
||||
const authHeaders = getAuthHeaders(request.auth, collection, item);
|
||||
headers = [...headers, ...authHeaders];
|
||||
}
|
||||
|
||||
// Interpolate headers, body and params if needed
|
||||
if (shouldInterpolate) {
|
||||
headers = interpolateHeaders(headers, variables);
|
||||
request.body = interpolateBody(request.body, variables);
|
||||
request.params = interpolateParams(request.params, variables);
|
||||
}
|
||||
|
||||
// Build HAR request
|
||||
const harRequest = buildHarRequest({
|
||||
request,
|
||||
headers
|
||||
const sourceUrl = item.rawUrl || request.url;
|
||||
const { har, rawUrl, encodedUrl, unhash } = buildHar({
|
||||
request: {
|
||||
method: request.method,
|
||||
url: sourceUrl,
|
||||
params: request.params,
|
||||
pathParams: [],
|
||||
headers: mergedHeaders,
|
||||
body: request.body,
|
||||
auth: effectiveAuth,
|
||||
settings
|
||||
},
|
||||
variables,
|
||||
shouldInterpolate,
|
||||
oauth2Credentials: collection?.oauth2Credentials,
|
||||
collectionUid: collection?.uid
|
||||
});
|
||||
|
||||
// Generate snippet using HTTPSnippet
|
||||
const snippet = new HTTPSnippet(harRequest);
|
||||
const snippet = new HTTPSnippet(har);
|
||||
let result = snippet.convert(language.target, language.client);
|
||||
|
||||
// For curl target, add special auth flags for digest/ntlm
|
||||
// curl --digest / --ntlm flags. Snippet-text manipulation, not HAR.
|
||||
if (language.target === 'shell' && language.client === 'curl') {
|
||||
result = addCurlAuthFlags(result, effectiveAuth);
|
||||
}
|
||||
|
||||
// Respect encodeUrl setting: when not explicitly true, replace HTTPSnippet's encoded path+query with the raw version.
|
||||
// Replacing the path portion works for all targets since it's a substring of the full URL.
|
||||
// encodeUrl defaults to false in the UI when undefined/null
|
||||
const settings = item.draft ? get(item, 'draft.settings') : get(item, 'settings');
|
||||
const rawUrl = item.rawUrl || request.url;
|
||||
const parsed = parse(request.url, true, true);
|
||||
/**
|
||||
*
|
||||
* Display-swap. HTTPSnippet renders the URL in encoded form (using har.queryString as the source of truth).
|
||||
* For OFF mode we want the user's raw bytes visible in the snippet — swap the encoded path+query substring for the raw form.
|
||||
* For OFF: prefer item.rawUrl when the caller explicitly supplied it (legacy GenerateCodeItem pipeline does this so user-typed pre-encoded
|
||||
* bytes survive the WHATWG-URL normalization). Otherwise fall back to buildHar's rawUrl.
|
||||
*/
|
||||
const displayRawUrl = item.rawUrl || rawUrl;
|
||||
const parsed = parse(encodedUrl, true, true);
|
||||
const search = stringify(parsed.query, { sort: false });
|
||||
const httpSnippetPath = search ? `${parsed.pathname}?${search}` : parsed.pathname;
|
||||
|
||||
@@ -95,16 +93,11 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false
|
||||
if (settings?.encodeUrl === true) {
|
||||
// Apply the same encodeUrl() transform used by the actual request execution path
|
||||
// so the snippet matches what's sent on the wire.
|
||||
const encodedUrl = encodeUrlCommon(rawUrl);
|
||||
desiredPath = stripOrigin(encodedUrl);
|
||||
// Strip fragment per RFC 3986 §3.5
|
||||
desiredPath = desiredPath.replace(/#.*$/, '');
|
||||
} else {
|
||||
desiredPath = stripOrigin(rawUrl);
|
||||
// The HTTP raw target (http/http1.1) uses the request line format:
|
||||
// METHOD <request-target> HTTP-version
|
||||
// Spaces delimit these fields, so a literal space in the request-target
|
||||
// would be parsed as the end of the URI (RFC 7230 §3.1.1).
|
||||
desiredPath = stripOrigin(displayRawUrl);
|
||||
// HTTP raw target uses spaces as delimiters in the request line
|
||||
// (RFC 7230 §3.1.1), so a literal space would terminate the URI early.
|
||||
if (language.target === 'http') {
|
||||
desiredPath = desiredPath.replace(/ /g, '%20');
|
||||
}
|
||||
@@ -114,7 +107,8 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false
|
||||
result = result.replaceAll(httpSnippetPath, desiredPath);
|
||||
}
|
||||
|
||||
return result;
|
||||
// Restore `{{var}}` placeholders that buildHar hashed during processing.
|
||||
return unhash(result);
|
||||
} catch (error) {
|
||||
console.error('Error generating code snippet:', error);
|
||||
return 'Error generating code snippet';
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { getAuthHeaders } from 'utils/codegenerator/auth';
|
||||
// Helper: reconstruct the URL HTTPSnippet would render from buildHar's HAR.
|
||||
// buildHar strips the URL's query (the bracket-key phantom-duplicate fix), so
|
||||
// the visible URL is `har.url` + `har.queryString` re-encoded by encodeURIComponent.
|
||||
const reconstructUrlForMock = (harRequest) => {
|
||||
const baseUrl = harRequest?.url || 'http://example.com';
|
||||
const queryString = harRequest?.queryString || [];
|
||||
if (!queryString.length) return baseUrl;
|
||||
const search = queryString
|
||||
.map((p) => `${encodeURIComponent(p.name)}=${encodeURIComponent(p.value ?? '')}`)
|
||||
.join('&');
|
||||
return `${baseUrl}?${search}`;
|
||||
};
|
||||
|
||||
jest.mock('httpsnippet', () => {
|
||||
return {
|
||||
HTTPSnippet: jest.fn().mockImplementation((harRequest) => ({
|
||||
convert: jest.fn(() => {
|
||||
const method = harRequest?.method || 'GET';
|
||||
const url = harRequest?.url || 'http://example.com';
|
||||
const url = reconstructUrlForMock(harRequest);
|
||||
const hasBody = harRequest?.postData?.text;
|
||||
|
||||
if (method === 'POST' && hasBody) {
|
||||
@@ -17,36 +28,6 @@ jest.mock('httpsnippet', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('utils/codegenerator/har', () => ({
|
||||
buildHarRequest: jest.fn((data) => {
|
||||
const request = data.request || {};
|
||||
const method = request.method || 'GET';
|
||||
const url = request.url || 'http://example.com';
|
||||
const body = request.body || {};
|
||||
|
||||
const harRequest = {
|
||||
method: method,
|
||||
url: url,
|
||||
headers: data.headers || [],
|
||||
httpVersion: 'HTTP/1.1'
|
||||
};
|
||||
|
||||
// Add body data for POST requests
|
||||
if (method === 'POST' && body.mode === 'json' && body.json) {
|
||||
harRequest.postData = {
|
||||
mimeType: 'application/json',
|
||||
text: body.json
|
||||
};
|
||||
}
|
||||
|
||||
return harRequest;
|
||||
})
|
||||
}));
|
||||
|
||||
jest.mock('utils/codegenerator/auth', () => ({
|
||||
getAuthHeaders: jest.fn(() => [])
|
||||
}));
|
||||
|
||||
jest.mock('utils/collections/index', () => {
|
||||
const actual = jest.requireActual('utils/collections/index');
|
||||
|
||||
@@ -120,7 +101,7 @@ describe('Snippet Generator - Simple Tests', () => {
|
||||
require('httpsnippet').HTTPSnippet = jest.fn().mockImplementation((harRequest) => ({
|
||||
convert: jest.fn(() => {
|
||||
const method = harRequest?.method || 'GET';
|
||||
const url = harRequest?.url || 'http://example.com';
|
||||
const url = reconstructUrlForMock(harRequest);
|
||||
const hasBody = harRequest?.postData?.text;
|
||||
|
||||
if (method === 'POST' && hasBody) {
|
||||
@@ -456,10 +437,10 @@ describe('Snippet Generator - Simple Tests', () => {
|
||||
}
|
||||
};
|
||||
|
||||
// buildHar handles `{{user}}` / `{{pass}}` interpolation via its
|
||||
// internal interpolateRequest pipeline and emits the Basic auth header
|
||||
// through authToHeaders. No mock override needed.
|
||||
const { HTTPSnippet: mockedHTTPSnippet } = require('httpsnippet');
|
||||
const { getAuthHeaders: actualGetAuthHeaders } = jest.requireActual('utils/codegenerator/auth');
|
||||
getAuthHeaders.mockImplementation(actualGetAuthHeaders);
|
||||
|
||||
const language = { target: 'shell', client: 'curl' };
|
||||
|
||||
generateSnippet({
|
||||
@@ -471,7 +452,7 @@ describe('Snippet Generator - Simple Tests', () => {
|
||||
|
||||
const harRequest = mockedHTTPSnippet.mock.calls[0][0];
|
||||
|
||||
// "admin:secret123" encoded is "YWRtaW46c2VjcmV0MTIz"
|
||||
// "admin:secret123" encoded is "YWRtaW46c2VjcmV0MTIz". HAR headers are
|
||||
expect(harRequest.headers).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'Authorization',
|
||||
@@ -534,6 +515,7 @@ describe('generateSnippet – header inclusion in output', () => {
|
||||
// Restore original mock
|
||||
require('httpsnippet').HTTPSnippet = originalHTTPSnippet;
|
||||
|
||||
// buildHar's finalizeHeaders lowercases header names per HAR convention.
|
||||
expect(result).toContain('X-Collection');
|
||||
expect(result).toContain('X-Folder');
|
||||
});
|
||||
@@ -614,59 +596,21 @@ describe('generateSnippet with OAuth2 authentication', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Mock getAuthHeaders to return OAuth2 headers based on the auth config
|
||||
const authUtils = require('utils/codegenerator/auth');
|
||||
authUtils.getAuthHeaders.mockImplementation((requestAuth, collection = null, item = null) => {
|
||||
if (requestAuth?.mode === 'oauth2') {
|
||||
const oauth2Config = requestAuth.oauth2 || {};
|
||||
const tokenPlacement = oauth2Config.tokenPlacement || 'header';
|
||||
// Use the actual value from config, defaulting to 'Bearer' only if undefined
|
||||
// Empty string should be preserved to test no-prefix scenarios
|
||||
const tokenHeaderPrefix = oauth2Config.tokenHeaderPrefix !== undefined
|
||||
? oauth2Config.tokenHeaderPrefix
|
||||
: 'Bearer';
|
||||
let accessToken = oauth2Config.accessToken || '<access_token>';
|
||||
|
||||
// If collection and item are provided, try to look up stored credentials
|
||||
if (collection && item && collection.oauth2Credentials) {
|
||||
const grantType = oauth2Config.grantType || '';
|
||||
const urlToLookup = grantType === 'implicit'
|
||||
? oauth2Config.authorizationUrl || ''
|
||||
: oauth2Config.accessTokenUrl || '';
|
||||
const credentialsId = oauth2Config.credentialsId || 'credentials';
|
||||
const collectionUid = collection.uid;
|
||||
|
||||
if (urlToLookup && collectionUid) {
|
||||
// Look up stored credentials (simplified - assumes URL is already interpolated in test data)
|
||||
const credentialsData = collection.oauth2Credentials.find(
|
||||
(creds) =>
|
||||
creds?.url === urlToLookup
|
||||
&& creds?.collectionUid === collectionUid
|
||||
&& creds?.credentialsId === credentialsId
|
||||
);
|
||||
|
||||
if (credentialsData?.credentials?.access_token) {
|
||||
accessToken = credentialsData.credentials.access_token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenPlacement === 'header') {
|
||||
// Always trim the final result for consistent formatting
|
||||
const headerValue = tokenHeaderPrefix
|
||||
? `${tokenHeaderPrefix} ${accessToken}`.trim()
|
||||
: accessToken.trim();
|
||||
return [
|
||||
{
|
||||
enabled: true,
|
||||
name: 'Authorization',
|
||||
value: headerValue
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
});
|
||||
// Restore default `getTreePathFromCollectionToItem` impl so previous
|
||||
// tests' folder-headers overrides don't leak into the OAuth2 tests
|
||||
// (which use baseCollection with no folders).
|
||||
const utilsCollections = require('utils/collections/index');
|
||||
utilsCollections.getTreePathFromCollectionToItem.mockImplementation(() => []);
|
||||
// OAuth2 → headers translation is now handled inside buildHar's
|
||||
// authToHeaders (matches the per-test bruno-common coverage), so this
|
||||
// describe no longer needs to mock `getAuthHeaders`.
|
||||
require('httpsnippet').HTTPSnippet = jest.fn().mockImplementation((harRequest) => ({
|
||||
convert: jest.fn(() => {
|
||||
const method = harRequest?.method || 'GET';
|
||||
const url = reconstructUrlForMock(harRequest);
|
||||
return `curl -X ${method} ${url}`;
|
||||
})
|
||||
}));
|
||||
});
|
||||
|
||||
it('should include OAuth2 Bearer token in Authorization header when tokenPlacement is header', () => {
|
||||
@@ -690,8 +634,8 @@ describe('generateSnippet with OAuth2 authentication', () => {
|
||||
|
||||
generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
|
||||
const harUtils = require('utils/codegenerator/har');
|
||||
const harCall = harUtils.buildHarRequest.mock.calls[0][0];
|
||||
const { HTTPSnippet: mockedHTTPSnippet } = require('httpsnippet');
|
||||
const harCall = mockedHTTPSnippet.mock.calls[0][0];
|
||||
expect(harCall.headers).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
@@ -723,8 +667,8 @@ describe('generateSnippet with OAuth2 authentication', () => {
|
||||
|
||||
generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
|
||||
const harUtils = require('utils/codegenerator/har');
|
||||
const harCall = harUtils.buildHarRequest.mock.calls[0][0];
|
||||
const { HTTPSnippet: mockedHTTPSnippet } = require('httpsnippet');
|
||||
const harCall = mockedHTTPSnippet.mock.calls[0][0];
|
||||
expect(harCall.headers).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
@@ -756,8 +700,8 @@ describe('generateSnippet with OAuth2 authentication', () => {
|
||||
|
||||
generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
|
||||
const harUtils = require('utils/codegenerator/har');
|
||||
const harCall = harUtils.buildHarRequest.mock.calls[0][0];
|
||||
const { HTTPSnippet: mockedHTTPSnippet } = require('httpsnippet');
|
||||
const harCall = mockedHTTPSnippet.mock.calls[0][0];
|
||||
const authHeader = harCall.headers.find((h) => h.name === 'Authorization');
|
||||
expect(authHeader).toBeUndefined();
|
||||
});
|
||||
@@ -782,8 +726,8 @@ describe('generateSnippet with OAuth2 authentication', () => {
|
||||
|
||||
generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
|
||||
const harUtils = require('utils/codegenerator/har');
|
||||
const harCall = harUtils.buildHarRequest.mock.calls[0][0];
|
||||
const { HTTPSnippet: mockedHTTPSnippet } = require('httpsnippet');
|
||||
const harCall = mockedHTTPSnippet.mock.calls[0][0];
|
||||
expect(harCall.headers).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
@@ -815,8 +759,8 @@ describe('generateSnippet with OAuth2 authentication', () => {
|
||||
|
||||
generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
|
||||
const harUtils = require('utils/codegenerator/har');
|
||||
const harCall = harUtils.buildHarRequest.mock.calls[0][0];
|
||||
const { HTTPSnippet: mockedHTTPSnippet } = require('httpsnippet');
|
||||
const harCall = mockedHTTPSnippet.mock.calls[0][0];
|
||||
expect(harCall.headers).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
@@ -938,19 +882,21 @@ describe('generateSnippet – encodeUrl setting', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Mock HTTPSnippet to simulate encoding (same pipeline as the real library)
|
||||
// Mock HTTPSnippet to simulate the real library's URL rendering. buildHar
|
||||
// strips the URL's query (bracket-key fix), so the visible URL is
|
||||
// `harRequest.url` + `harRequest.queryString` reassembled with
|
||||
// encodeURIComponent on each name/value pair — same shape the real
|
||||
// HTTPSnippet produces.
|
||||
require('httpsnippet').HTTPSnippet = jest.fn().mockImplementation((harRequest) => ({
|
||||
convert: jest.fn((target) => {
|
||||
const method = harRequest?.method || 'GET';
|
||||
const url = harRequest?.url || 'http://example.com';
|
||||
const renderedUrl = reconstructUrlForMock(harRequest);
|
||||
const { parse } = require('url');
|
||||
const parsed = parse(url, false, true);
|
||||
const encodedPath = getEncodedPath(url);
|
||||
// Simulate targets that use only the path (e.g., python http.client, raw HTTP)
|
||||
const parsed = parse(renderedUrl, false, true);
|
||||
const encodedPath = getEncodedPath(renderedUrl);
|
||||
if (target === 'python') {
|
||||
return `conn.request("${method}", "${encodedPath}", headers=headers)`;
|
||||
}
|
||||
// Full URL targets: reconstruct with encoded path
|
||||
const fullEncodedUrl = `${parsed.protocol}//${parsed.host}${encodedPath}`;
|
||||
return `curl -X ${method} '${fullEncodedUrl}'`;
|
||||
})
|
||||
@@ -1162,13 +1108,9 @@ describe('generateSnippet – encodeUrl setting', () => {
|
||||
});
|
||||
|
||||
it('should preserve URL fragment (#) in snippet when encodeUrl is false', () => {
|
||||
// Intentional asymmetry: when encodeUrl is false (raw mode), generateSnippet preserves the
|
||||
// user-supplied URL as-is, including any fragment. This contrasts with encodeUrl: true,
|
||||
// which strips fragments per RFC 3986 §3.5. The rawUrl is preserved through the makeItem
|
||||
// call with { encodeUrl: false } and passed to generateSnippet, which intentionally treats
|
||||
// it as a user-specified string not subject to RFC-compliant stripping. This is a designed
|
||||
// behavior to honor user intent in raw mode, not a bug. This behavior can be revisited in
|
||||
// the future if requirements or RFC interpretations change.
|
||||
// OFF preserves the user's URL byte-for-byte, including the literal `#`.
|
||||
// This is the only mode that retains fragment semantics — toggle OFF when
|
||||
// you want `#section` to survive as a fragment.
|
||||
const rawUrl = 'https://example.com/api?token=abc==#section';
|
||||
const item = makeItem(rawUrl, { encodeUrl: false });
|
||||
|
||||
@@ -1178,17 +1120,16 @@ describe('generateSnippet – encodeUrl setting', () => {
|
||||
expect(result).not.toContain('%3D');
|
||||
});
|
||||
|
||||
it('should not include URL fragment (#) in snippet when encodeUrl is true', () => {
|
||||
it('should encode URL fragment (#) as %23 data when encodeUrl is true', () => {
|
||||
const rawUrl = 'https://example.com/api?token=abc==#section';
|
||||
const item = makeItem(rawUrl, { encodeUrl: true });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
// Fragment is stripped — correct per RFC 3986 §3.5: user agents MUST NOT include the fragment
|
||||
// in the HTTP request target sent to the origin server (though fragments can still appear in
|
||||
// user-facing URLs, SPA routing, and are inherited across redirects per RFC 9110 §10.2.2).
|
||||
// https://datatracker.ietf.org/doc/html/rfc3986#section-3.5
|
||||
// https://datatracker.ietf.org/doc/html/rfc9110#section-10.2.2
|
||||
// Option C: `#` is treated as data, encoded to %23. No literal `#` should
|
||||
// remain — fragment semantics are lost in ON mode by design (predictable
|
||||
// "URL Encoding ON encodes everything special" behavior).
|
||||
expect(result).not.toContain('#section');
|
||||
expect(result).toContain('%23section');
|
||||
expect(result).toContain('%3D%3D');
|
||||
});
|
||||
|
||||
@@ -1221,14 +1162,14 @@ describe('generateSnippet – encodeUrl setting', () => {
|
||||
expect(result).toContain('%2F');
|
||||
});
|
||||
|
||||
it('should strip fragment and apply encodeUrl when both are present and encodeUrl is true', () => {
|
||||
it('should encode fragment as data and apply encodeUrl when both are present and encodeUrl is true', () => {
|
||||
const rawUrl = 'https://example.com/api?redirect=https://other.com/cb#section';
|
||||
const item = makeItem(rawUrl, { encodeUrl: true });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
// Fragment stripped per RFC 3986
|
||||
// `#` encoded to %23 as part of the query value (Option C).
|
||||
expect(result).not.toContain('#section');
|
||||
// Query value should be encoded
|
||||
expect(result).toContain('%23section');
|
||||
expect(result).toContain('%3A');
|
||||
expect(result).toContain('%2F');
|
||||
});
|
||||
@@ -1265,3 +1206,55 @@ describe('generateSnippet – encodeUrl setting', () => {
|
||||
expect(result).toBe(`curl -X GET '${rawUrl}'`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regression: HTTPSnippet HAR-validator rejects chars URL.canParse accepts.
|
||||
// snippet-generator pre-encodes the URL before HAR build so the validator
|
||||
// accepts; the toggle-driven replaceAll then swaps the encoded form back to
|
||||
// the user's raw form when toggle is OFF.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('generateSnippet – pre-encode URL before HAR (HTTPSnippet validator regression)', () => {
|
||||
const language = { target: 'shell', client: 'curl' };
|
||||
const baseCollection = { root: { request: { auth: { mode: 'none' }, headers: [] } } };
|
||||
|
||||
const makeItem = (url, settings) => ({
|
||||
uid: 'pre-enc-req',
|
||||
request: {
|
||||
method: 'GET',
|
||||
url,
|
||||
headers: [],
|
||||
body: { mode: 'none' },
|
||||
auth: { mode: 'none' }
|
||||
},
|
||||
...(settings !== undefined && { settings })
|
||||
});
|
||||
|
||||
it('does not throw for path-param value with literal space (user-reported `aaa bbb`)', () => {
|
||||
// Repro: URL `https://example.com/users/:id` with `id = aaa bbb`.
|
||||
// After interpolateUrlPathParams (raw mode) the URL has a literal space:
|
||||
// `https://example.com/users/aaa bbb`. HTTPSnippet's HAR validator
|
||||
// rejects it → "Error generating code snippet". Pre-encoding turns the
|
||||
// space into %20 so the validator accepts.
|
||||
const item = makeItem('https://example.com/users/aaa bbb', { encodeUrl: false });
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).not.toBe('Error generating code snippet');
|
||||
});
|
||||
|
||||
it('does not throw for literal [ and ] in URL path', () => {
|
||||
const item = makeItem('https://example.com/api/list[1]', { encodeUrl: false });
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).not.toBe('Error generating code snippet');
|
||||
});
|
||||
|
||||
it('does not throw for < and > in URL path', () => {
|
||||
const item = makeItem('https://example.com/api/<token>', { encodeUrl: false });
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).not.toBe('Error generating code snippet');
|
||||
});
|
||||
|
||||
it('does not throw for raw unicode in URL path', () => {
|
||||
const item = makeItem('https://example.com/users/José', { encodeUrl: false });
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).not.toBe('Error generating code snippet');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
1610
packages/bruno-common/src/generate-code/har/index.spec.ts
Normal file
1610
packages/bruno-common/src/generate-code/har/index.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
655
packages/bruno-common/src/generate-code/har/index.ts
Normal file
655
packages/bruno-common/src/generate-code/har/index.ts
Normal file
@@ -0,0 +1,655 @@
|
||||
/**
|
||||
* Bruno → HAR generation.
|
||||
*
|
||||
* This module is the single canonical pipeline for turning a Bruno request
|
||||
* (interpolation, path-param substitution, auth resolution, header merging,
|
||||
* URL encoding, query handling, HAR assembly) into the HTTP Archive (HAR)
|
||||
* shape consumed by both Generate Code (HTTPSnippet) and — in a follow-up
|
||||
* PR — the runtime (via a HAR→axios adapter).
|
||||
*
|
||||
* The previous architecture spread these concerns across 5+ files in
|
||||
* `bruno-app` plus a parallel set in `bruno-electron`/`bruno-cli`, and the
|
||||
* runtime↔codegen drift produced every reported URL-encoding bug
|
||||
* (#5788, #6268, #7356, #7653, #7913, plus the #tag#tag doubling and the
|
||||
* `post_ids[]` / `key[a][b]` bracket-phantom-duplicate bugs).
|
||||
*
|
||||
* By centralising the work here:
|
||||
* - There is exactly one place that decides what reaches the wire.
|
||||
* - PR #5507's content-blind double-encoding contract is honored by
|
||||
* calling `encodeUrl()` from `../utils/url` (no decode-then-encode).
|
||||
* - The HAR `url` field is path-only; `queryString` is the sole source
|
||||
* of truth for the rendered query string, which sidesteps HTTPSnippet's
|
||||
* legacy `url.parse(..., true, true)` polyfill that strips trailing
|
||||
* `[]` from keys (the bracket-phantom bug).
|
||||
* - `{{var}}` tokens that callers don't want resolved are hashed before
|
||||
* URL parsing/encoding via `patternHasher`, then restored at the end
|
||||
* via the returned `unhash` function — so snippets remain
|
||||
* copy-shareable with templates intact.
|
||||
*/
|
||||
|
||||
import { cloneDeep, find, get } from 'lodash';
|
||||
import interpolate, { interpolateObject } from '../../interpolate';
|
||||
import { encodeUrl, parseQueryParams, patternHasher } from '../../utils';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type BrunoKV = {
|
||||
name: string;
|
||||
value: string;
|
||||
type?: string;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type BrunoBody = {
|
||||
mode?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type BrunoAuth = {
|
||||
mode?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type BrunoRequest = {
|
||||
method?: string;
|
||||
url: string;
|
||||
params?: BrunoKV[];
|
||||
pathParams?: BrunoKV[];
|
||||
headers?: BrunoKV[];
|
||||
body?: BrunoBody;
|
||||
auth?: BrunoAuth;
|
||||
settings?: { encodeUrl?: boolean };
|
||||
};
|
||||
|
||||
/**
|
||||
* Stored OAuth2 credential record (read from `collection.oauth2Credentials`).
|
||||
* Passed in as input so `buildHar` stays pure (no collection traversal);
|
||||
* the caller pre-collects the records relevant to this request.
|
||||
*/
|
||||
export type OAuth2CredentialRecord = {
|
||||
url?: string;
|
||||
collectionUid?: string;
|
||||
credentialsId?: string;
|
||||
credentials?: { access_token?: string };
|
||||
};
|
||||
|
||||
export type BuildHarInput = {
|
||||
request: BrunoRequest;
|
||||
/**
|
||||
* Merged variable map: global / collection / folder / request / runtime /
|
||||
* oauth2-creds / prompt vars / process.env, in precedence order resolved
|
||||
* by the caller (the caller is the only place that knows the collection
|
||||
* tree). `buildHar` just substitutes from this map.
|
||||
*/
|
||||
variables?: Record<string, unknown>;
|
||||
/**
|
||||
* When `false` (default for Generate Code's "show snippet without
|
||||
* resolving variables" mode), `{{var}}` tokens are hashed before URL
|
||||
* parsing/encoding via `patternHasher`, then exposed via the returned
|
||||
* `unhash` function so the caller can restore them in the final output.
|
||||
*/
|
||||
shouldInterpolate?: boolean;
|
||||
/**
|
||||
* OAuth2 stored credentials. Used to look up the actual access token
|
||||
* when `auth.mode === 'oauth2'` and `tokenPlacement === 'header'`.
|
||||
*/
|
||||
oauth2Credentials?: OAuth2CredentialRecord[];
|
||||
collectionUid?: string;
|
||||
};
|
||||
|
||||
export type HarRequest = {
|
||||
method: string;
|
||||
url: string;
|
||||
httpVersion: string;
|
||||
cookies: unknown[];
|
||||
headers: { name: string; value: string }[];
|
||||
queryString: { name: string; value: string }[];
|
||||
postData: any;
|
||||
headersSize: number;
|
||||
bodySize: number;
|
||||
binary: boolean;
|
||||
};
|
||||
|
||||
export type BuildHarOutput = {
|
||||
/** Canonical HAR object, ready for HTTPSnippet (and, post-Phase-C, an axios adapter). */
|
||||
har: HarRequest;
|
||||
/**
|
||||
* The user's URL after path-param substitution but BEFORE `encodeUrl`
|
||||
* was applied. Used by Generate Code's display-swap step when toggle
|
||||
* is off (snippet shows the user's typed encoding, not Bruno's).
|
||||
*/
|
||||
rawUrl: string;
|
||||
/** The URL after `encodeUrl` was applied (matches the HAR's stored form). */
|
||||
encodedUrl: string;
|
||||
/**
|
||||
* Restores `{{var}}` tokens in a string. When `shouldInterpolate=false`,
|
||||
* Generate Code calls this on the final snippet so templates survive
|
||||
* verbatim through HTTPSnippet's URL parsing.
|
||||
*/
|
||||
unhash: (input: string) => string;
|
||||
/**
|
||||
* The auth object as it was applied to the request. Exposed so Generate
|
||||
* Code can attach curl-specific flags (`--digest`, `--ntlm`) downstream.
|
||||
*/
|
||||
effectiveAuth: BrunoAuth | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const contentTypeFromBodyMode = (mode?: string): string => {
|
||||
switch (mode) {
|
||||
case 'json': return 'application/json';
|
||||
case 'text': return 'text/plain';
|
||||
case 'xml': return 'application/xml';
|
||||
case 'sparql': return 'application/sparql-query';
|
||||
case 'formUrlEncoded': return 'application/x-www-form-urlencoded';
|
||||
case 'graphql': return 'application/json';
|
||||
case 'multipartForm': return 'multipart/form-data';
|
||||
case 'file': return 'application/octet-stream';
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip the query string from a URL (everything between the first `?` and
|
||||
* the fragment marker `#`). Preserves scheme, authority, path, fragment.
|
||||
*
|
||||
* HTTPSnippet receives a URL without its query so its internal
|
||||
* `url.parse(..., true, true)` has nothing to mis-parse. The
|
||||
* `queryString` array is the sole source of truth — no merge bug.
|
||||
*/
|
||||
const stripQueryStringFromUrl = (url: string): string => {
|
||||
if (!url || typeof url !== 'string') return url;
|
||||
return url.replace(/^([^#?]*)\?[^#]*/, '$1');
|
||||
};
|
||||
|
||||
/**
|
||||
* Interpolate `{{var}}` placeholders in the request's URL, headers, body,
|
||||
* auth, params, and pathParams. The body interpolation is mode-aware so
|
||||
* JSON stays valid and form params get value-only interpolation.
|
||||
*/
|
||||
const interpolateRequest = (request: BrunoRequest, variables: Record<string, unknown>): BrunoRequest => {
|
||||
const r = cloneDeep(request);
|
||||
|
||||
// NOTE: URL interpolation is intentionally NOT done here. URL `{{var}}` resolution
|
||||
// is the caller's responsibility (e.g., GenerateCodeItem in bruno-app does its
|
||||
// own `interpolateUrl` upstream). buildHar always runs the patternHasher on the
|
||||
// URL — leftover `{{var}}` tokens are hashed before parsing/encoding so the URL
|
||||
// stays parseable, then restored via the returned `unhash` on the final output.
|
||||
// This preserves the previous snippet-generator's contract that
|
||||
// shouldInterpolate=true does NOT resolve URL placeholders (only body, headers,
|
||||
// auth, params, pathParams).
|
||||
|
||||
if (Array.isArray(r.headers)) {
|
||||
r.headers = r.headers.map((h) => h && h.enabled !== false ? (interpolateObject(h, variables) as BrunoKV) : h);
|
||||
}
|
||||
|
||||
if (Array.isArray(r.params)) {
|
||||
r.params = r.params.map((p) => p && p.enabled !== false ? (interpolateObject(p, variables) as BrunoKV) : p);
|
||||
}
|
||||
|
||||
if (Array.isArray(r.pathParams)) {
|
||||
r.pathParams = r.pathParams.map((p) => interpolateObject(p, variables) as BrunoKV);
|
||||
}
|
||||
|
||||
if (r.auth) {
|
||||
r.auth = interpolateObject(r.auth, variables) as BrunoAuth;
|
||||
}
|
||||
|
||||
if (r.body && typeof r.body === 'object') {
|
||||
const body = { ...r.body };
|
||||
switch (body.mode) {
|
||||
case 'json': {
|
||||
let parsed = body.json;
|
||||
if (typeof parsed === 'object') parsed = JSON.stringify(parsed);
|
||||
parsed = interpolate(parsed, variables, { escapeJSONStrings: true });
|
||||
try {
|
||||
const jsonObj = JSON.parse(parsed);
|
||||
body.json = JSON.stringify(jsonObj, null, 2);
|
||||
} catch {
|
||||
body.json = parsed;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'text': body.text = interpolate(body.text, variables); break;
|
||||
case 'xml': body.xml = interpolate(body.xml, variables); break;
|
||||
case 'sparql': body.sparql = interpolate(body.sparql, variables); break;
|
||||
case 'formUrlEncoded':
|
||||
body.formUrlEncoded = Array.isArray(body.formUrlEncoded)
|
||||
? body.formUrlEncoded.map((p: any) => ({
|
||||
...p,
|
||||
value: p.enabled ? interpolate(p.value, variables) : p.value
|
||||
}))
|
||||
: [];
|
||||
break;
|
||||
case 'multipartForm':
|
||||
body.multipartForm = Array.isArray(body.multipartForm)
|
||||
? body.multipartForm.map((p: any) => ({
|
||||
...p,
|
||||
value: p.type === 'text' && p.enabled ? interpolate(p.value, variables) : p.value
|
||||
}))
|
||||
: [];
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
r.body = body;
|
||||
}
|
||||
|
||||
return r;
|
||||
};
|
||||
|
||||
/**
|
||||
* Substitute `:id` path params (and OData-style parenthesised params) into
|
||||
* the URL. When `options.encodeUrl` is true, each substituted value is
|
||||
* `encodeURIComponent`'d — this is the issue #7356 fix surface.
|
||||
*
|
||||
* Returns the URL with substitutions applied. Path-param values are not
|
||||
* URL-encoded structurally (only via the optional toggle), so callers that
|
||||
* want a "raw" view (the URL as the user effectively typed) pass
|
||||
* `options.encodeUrl=false`.
|
||||
*/
|
||||
/**
|
||||
* Replace each `:id` position in the URL with a `patternHasher`-generated
|
||||
* placeholder, and return a `restore(url, { encode })` callable that the
|
||||
* caller invokes after `encodeUrl` has run.
|
||||
*
|
||||
* The placeholder shape is `bruno-var-hash-<djb2>` — alphanumeric plus dash
|
||||
* only, so `encodeURIComponent` and `encodeUrl` leave it untouched.
|
||||
* `restore()` first unhashes placeholders back to `:id` literals (via
|
||||
* `patternHasher`'s own restore), then substitutes each `:id` for the
|
||||
* path-param value — raw when `encode=false`, single-encoded via
|
||||
* `encodeURIComponent` when `encode=true`.
|
||||
*
|
||||
* This indirection prevents the double-encoding that fall out of doing
|
||||
* `substitute-with-encoding → encodeUrl` back to back: the second pass
|
||||
* would content-blind-encode the already-encoded segment (e.g. `aaa%2Fbbb`
|
||||
* → `aaa%252Fbbb`) per PR #5507. By hiding path-param positions behind
|
||||
* placeholders during the `encodeUrl` pass, encoding happens exactly once
|
||||
* — when the placeholders are restored.
|
||||
*/
|
||||
const hashPathParamPositions = (
|
||||
url: string,
|
||||
pathParams: BrunoKV[] | undefined
|
||||
): { url: string; restore: (input: string, opts: { encode: boolean }) => string } => {
|
||||
const noopRestore = (input: string) => input;
|
||||
if (!url || !Array.isArray(pathParams) || pathParams.length === 0) {
|
||||
return { url, restore: noopRestore };
|
||||
}
|
||||
|
||||
let prefix = '';
|
||||
let working = url;
|
||||
if (!working.startsWith('http://') && !working.startsWith('https://')) {
|
||||
working = 'http://' + working;
|
||||
prefix = '__BRUNO_HAR_HTTP_PLACEHOLDER__';
|
||||
}
|
||||
|
||||
// Pre-split authority so `:pass` in `user:pass@host` is not treated as a path-param.
|
||||
const separatorIdx = working.search(/[?#]/);
|
||||
const pathPart = separatorIdx >= 0 ? working.substring(0, separatorIdx) : working;
|
||||
const rest = separatorIdx >= 0 ? working.substring(separatorIdx) : '';
|
||||
|
||||
const authorityMatch = pathPart.match(/^([a-z][a-z0-9+.-]*:\/\/[^/?#]*)?(.*)$/i);
|
||||
const authority = authorityMatch?.[1] ?? '';
|
||||
const path = authorityMatch?.[2] ?? pathPart;
|
||||
|
||||
const enabledByName = new Map<string, string>(
|
||||
pathParams
|
||||
.filter((p) => p && p.enabled !== false && (p.type === undefined || p.type === 'path'))
|
||||
.map((p) => [p.name, p.value == null ? '' : String(p.value)])
|
||||
);
|
||||
|
||||
// patternHasher protects `:name` patterns from being mangled by encodeUrl.
|
||||
// restore() puts them back literally; the substitute step below swaps them
|
||||
// for the path-param value (raw or encoded).
|
||||
const PATH_PARAM_RE = /:[a-zA-Z_]\w*/g;
|
||||
const { hashed, restore: hashRestore } = patternHasher(path, PATH_PARAM_RE);
|
||||
|
||||
let finalUrl = authority + hashed + rest;
|
||||
if (prefix) finalUrl = finalUrl.replace(/^http:\/\//, '');
|
||||
|
||||
const restore = (input: string, { encode }: { encode: boolean }): string => {
|
||||
const withLiterals = hashRestore(input);
|
||||
return withLiterals.replace(PATH_PARAM_RE, (match) => {
|
||||
const name = match.slice(1);
|
||||
if (!enabledByName.has(name)) return match;
|
||||
const value = enabledByName.get(name) ?? '';
|
||||
return encode ? encodeURIComponent(value) : value;
|
||||
});
|
||||
};
|
||||
|
||||
return { url: finalUrl, restore };
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanity check that the URL has at least a scheme + authority. This is
|
||||
* intentionally loose — any URL the user can legally type should pass.
|
||||
* The stricter `URL.canParse` check was fighting downstream encoders
|
||||
* (issues #6268, #7653, #7913).
|
||||
*/
|
||||
const looksLikeUrl = (url: string | undefined): boolean =>
|
||||
typeof url === 'string' && /^[a-z][a-z0-9+.-]*:\/\/[^/?#]+/i.test(url);
|
||||
|
||||
/**
|
||||
* Translate the resolved auth mode into one or more headers to append to
|
||||
* the request. For OAuth2 token-in-header placement, looks up the actual
|
||||
* access token from `oauth2Credentials` (passed in by the caller).
|
||||
*
|
||||
* Auth modes that produce no header here (and are handled elsewhere):
|
||||
* - 'apikey' with placement='queryparams' → added to queryString in buildQueryString
|
||||
* - 'oauth2' with tokenPlacement='url' → not currently surfaced in codegen
|
||||
* - 'oauth1' → requires runtime signing
|
||||
* - 'digest', 'ntlm' → curl gets --digest/--ntlm flags
|
||||
* attached post-snippet by the caller
|
||||
* - 'awsv4' → runtime-only signing
|
||||
* - 'wsse' → runtime-only signing
|
||||
*/
|
||||
|
||||
const authToHeaders = (
|
||||
auth: BrunoAuth | undefined,
|
||||
variables: Record<string, unknown>,
|
||||
oauth2Credentials: OAuth2CredentialRecord[] | undefined,
|
||||
collectionUid: string | undefined
|
||||
): BrunoKV[] => {
|
||||
if (!auth || !auth.mode || auth.mode === 'none' || auth.mode === 'inherit') return [];
|
||||
|
||||
switch (auth.mode) {
|
||||
case 'basic': {
|
||||
const username = get(auth, 'basic.username', '') as string;
|
||||
const password = get(auth, 'basic.password', '') as string;
|
||||
const token = Buffer.from(`${username}:${password}`).toString('base64');
|
||||
return [{ name: 'Authorization', value: `Basic ${token}`, enabled: true }];
|
||||
}
|
||||
|
||||
case 'bearer':
|
||||
return [{ name: 'Authorization', value: `Bearer ${get(auth, 'bearer.token', '')}`, enabled: true }];
|
||||
|
||||
case 'apikey': {
|
||||
const placement = get(auth, 'apikey.placement', 'header') as string;
|
||||
if (placement !== 'header') return [];
|
||||
const key = get(auth, 'apikey.key', '') as string;
|
||||
const value = get(auth, 'apikey.value', '') as string;
|
||||
if (!key) return [];
|
||||
return [{ name: key, value, enabled: true }];
|
||||
}
|
||||
|
||||
case 'oauth2': {
|
||||
const tokenPlacement = get(auth, 'oauth2.tokenPlacement', 'header') as string;
|
||||
if (tokenPlacement !== 'header') return [];
|
||||
|
||||
const tokenHeaderPrefix = get(auth, 'oauth2.tokenHeaderPrefix', 'Bearer') as string;
|
||||
// Precedence: stored credentials → auth.oauth2.accessToken → placeholder.
|
||||
// Matches bruno-app's getAuthHeaders so generated snippets carry the
|
||||
// user's directly-configured token when present.
|
||||
let accessToken = (get(auth, 'oauth2.accessToken', '') as string) || '<access_token>';
|
||||
|
||||
if (Array.isArray(oauth2Credentials) && collectionUid) {
|
||||
try {
|
||||
const grantType = get(auth, 'oauth2.grantType', '') as string;
|
||||
const urlField = grantType === 'implicit' ? 'oauth2.authorizationUrl' : 'oauth2.accessTokenUrl';
|
||||
const rawUrl = get(auth, urlField, '') as string;
|
||||
const credentialsId = get(auth, 'oauth2.credentialsId', 'credentials') as string;
|
||||
const interpolatedUrl = rawUrl ? interpolate(rawUrl, variables) : '';
|
||||
if (interpolatedUrl) {
|
||||
const match = find(
|
||||
oauth2Credentials,
|
||||
(rec: OAuth2CredentialRecord) =>
|
||||
rec?.url === interpolatedUrl
|
||||
&& rec?.collectionUid === collectionUid
|
||||
&& rec?.credentialsId === credentialsId
|
||||
);
|
||||
if (match?.credentials?.access_token) {
|
||||
accessToken = match.credentials.access_token;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to placeholder if anything fails — never throw here.
|
||||
}
|
||||
}
|
||||
|
||||
const headerValue = (tokenHeaderPrefix ? `${tokenHeaderPrefix} ${accessToken}` : accessToken).trim();
|
||||
return [{ name: 'Authorization', value: headerValue, enabled: true }];
|
||||
}
|
||||
|
||||
// OAuth1 cannot pre-compute the signature for a static snippet.
|
||||
// Digest/NTLM/AWS/WSSE are runtime-signed.
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const mergeAndDedupeHeaders = (existing: BrunoKV[] | undefined, additions: BrunoKV[]): BrunoKV[] => {
|
||||
const result = Array.isArray(existing) ? [...existing] : [];
|
||||
for (const h of additions) {
|
||||
if (!h) continue;
|
||||
result.push(h);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter to enabled headers, **preserve the user-typed case** of each name,
|
||||
* and append a default `Content-Type` when the body mode implies one and the
|
||||
* user hasn't already set it.
|
||||
*
|
||||
* HTTP header names are case-insensitive per RFC 7230 §3.2, so the wire
|
||||
* doesn't care between `Authorization` and `authorization`. But the
|
||||
* generated *code snippet* is a copy-paste artifact — users expect to see
|
||||
* the casing they typed. Lowercasing here mangles things like `X-API-Key`
|
||||
* or `Content-Type` into shapes nobody types by hand. The dedup check for
|
||||
* the default content-type rule is done case-insensitively so it still
|
||||
* fires correctly when the user typed `Content-Type` vs `content-type`.
|
||||
*/
|
||||
const finalizeHeaders = (request: BrunoRequest, headers: BrunoKV[]): { name: string; value: string }[] => {
|
||||
const enabled = (headers || [])
|
||||
.filter((h) => h && h.enabled !== false)
|
||||
.map((h) => ({ name: String(h.name), value: String(h.value ?? '') }));
|
||||
|
||||
const implicitCT = contentTypeFromBodyMode(request?.body?.mode);
|
||||
if (implicitCT && !enabled.some((h) => h.name.toLowerCase() === 'content-type')) {
|
||||
enabled.push({ name: 'Content-Type', value: implicitCT });
|
||||
}
|
||||
|
||||
return enabled;
|
||||
};
|
||||
|
||||
/**
|
||||
* Assemble HAR `queryString` from the request's params array plus any
|
||||
* auth-driven query params (e.g. api-key with placement='queryparams').
|
||||
*/
|
||||
const buildQueryString = (
|
||||
request: BrunoRequest,
|
||||
urlForFallback?: string
|
||||
): { name: string; value: string }[] => {
|
||||
const enabledParams = Array.isArray(request.params)
|
||||
? request.params.filter((p) => p && p.enabled !== false && p.type === 'query')
|
||||
: [];
|
||||
|
||||
// Primary source: request.params (synced with the URL by the Redux reducer in
|
||||
// production). Fallback: if params is empty but the URL string carries a query,
|
||||
// parse the query directly. This catches synthetic / test inputs that only set
|
||||
// request.url and skip the params array, and keeps wire output stable for
|
||||
// callers that bypass the params syncer.
|
||||
let params: { name: string; value: string }[];
|
||||
if (enabledParams.length > 0) {
|
||||
params = enabledParams.map((p) => ({ name: p.name, value: p.value }));
|
||||
} else if (urlForFallback) {
|
||||
// `#` is data (Option C — see encodeUrl), so the query slice extends to
|
||||
// end-of-string and parseQueryParams is told NOT to split on `#`.
|
||||
// Anything after `#` ends up as part of the last value, which is what
|
||||
// the encoder and the OFF-mode display-swap both expect.
|
||||
const queryIdx = urlForFallback.indexOf('?');
|
||||
const queryString = queryIdx >= 0 ? urlForFallback.slice(queryIdx + 1) : '';
|
||||
params = queryString
|
||||
? parseQueryParams(queryString, { decode: false, stripFragment: false }).map((p) => ({
|
||||
name: p.name,
|
||||
value: p.value == null ? '' : p.value
|
||||
}))
|
||||
: [];
|
||||
} else {
|
||||
params = [];
|
||||
}
|
||||
|
||||
const auth = request.auth;
|
||||
if (
|
||||
auth?.mode === 'apikey'
|
||||
&& get(auth, 'apikey.placement') === 'queryparams'
|
||||
&& get(auth, 'apikey.key')
|
||||
&& get(auth, 'apikey.value')
|
||||
) {
|
||||
params.push({ name: get(auth, 'apikey.key') as string, value: get(auth, 'apikey.value') as string });
|
||||
}
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
/** HAR `postData` for the request body. Mirrors the body-mode contract. */
|
||||
const buildPostData = (body: BrunoBody | undefined): any => {
|
||||
if (!body || !body.mode) return undefined;
|
||||
const mimeType = contentTypeFromBodyMode(body.mode);
|
||||
|
||||
switch (body.mode) {
|
||||
case 'formUrlEncoded': {
|
||||
const arr: any[] = Array.isArray(body.formUrlEncoded) ? body.formUrlEncoded : [];
|
||||
const enabled = arr.filter((p) => p?.enabled);
|
||||
const searchParams = new URLSearchParams();
|
||||
enabled.forEach((p) => {
|
||||
searchParams.append(String(p.name), String(p.value ?? ''));
|
||||
});
|
||||
return {
|
||||
mimeType,
|
||||
text: searchParams.toString(),
|
||||
params: enabled.map((p) => ({ name: p.name, value: p.value }))
|
||||
};
|
||||
}
|
||||
case 'multipartForm': {
|
||||
const arr: any[] = Array.isArray(body.multipartForm) ? body.multipartForm : [];
|
||||
return {
|
||||
mimeType,
|
||||
params: arr
|
||||
.filter((p) => p?.enabled)
|
||||
.map((p) => ({
|
||||
name: p.name,
|
||||
value: p.value,
|
||||
...(p.type === 'file' && { fileName: p.value })
|
||||
}))
|
||||
};
|
||||
}
|
||||
case 'file': {
|
||||
const files: any[] = Array.isArray(body.file) ? body.file : [];
|
||||
const selected = files.find((f) => f.selected) || files[0];
|
||||
const filePath = selected?.filePath || '';
|
||||
return {
|
||||
mimeType: selected?.contentType || 'application/octet-stream',
|
||||
text: filePath,
|
||||
params: filePath
|
||||
? [{
|
||||
name: selected?.name || 'file',
|
||||
value: filePath,
|
||||
fileName: filePath,
|
||||
contentType: selected?.contentType || 'application/octet-stream'
|
||||
}]
|
||||
: []
|
||||
};
|
||||
}
|
||||
case 'graphql':
|
||||
return { mimeType, text: JSON.stringify(body.graphql) };
|
||||
default:
|
||||
return { mimeType, text: body[body.mode] };
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildHar(input: BuildHarInput): BuildHarOutput {
|
||||
const variables = input.variables || {};
|
||||
const shouldInterpolate = input.shouldInterpolate ?? true;
|
||||
|
||||
// Step 1 & 2 — hash {{var}} placeholders OR substitute them.
|
||||
// When the caller wants templates preserved in the snippet (shouldInterpolate=false),
|
||||
// we replace `{{var}}` with deterministic URL-safe hashes BEFORE any URL parsing
|
||||
// or encoding runs. The returned `unhash` lets the caller restore originals at the end.
|
||||
let working = shouldInterpolate ? interpolateRequest(input.request, variables) : cloneDeep(input.request);
|
||||
|
||||
// The hashers operate at the URL-string layer specifically. Headers/body
|
||||
// /auth/params with `{{var}}` would only render in the snippet text;
|
||||
// those callers that don't want them resolved can pre-hash before calling
|
||||
// buildHar OR pass `shouldInterpolate=true`. For typical Generate Code
|
||||
// usage, `shouldInterpolate=false` means "preserve URL-bar templates".
|
||||
const { hashed: hashedUrl, restore: restoreUrlVars } = patternHasher(working.url || '');
|
||||
|
||||
// Step 3 — Hash path-param positions via `patternHasher`. The URL now
|
||||
// contains opaque `bruno-var-hash-XXX` tokens instead of `:id`, so the
|
||||
// next `encodeUrl()` pass can encode non-path-param chars in the path
|
||||
// without touching path-param positions. `restorePathParams` (returned
|
||||
// here) unhashes those tokens back to `:id` literals and then substitutes
|
||||
// each one for the path-param value (raw or encoded per the flag).
|
||||
const encodeFlag = working.settings?.encodeUrl === true;
|
||||
const { url: urlWithPlaceholders, restore: restorePathParams } = hashPathParamPositions(hashedUrl, working.pathParams);
|
||||
|
||||
// Step 4 — Apply `encodeUrl()` to the URL with placeholders. Placeholders
|
||||
// are alphanumeric+dash, so `encodeURIComponent` (used per path segment
|
||||
// inside `encodeUrl`) leaves them untouched. The rest of the path and
|
||||
// query are encoded per the existing content-blind contract (PR #5507).
|
||||
const encodedUrlWithPlaceholders = encodeUrl(urlWithPlaceholders);
|
||||
|
||||
// Step 5 — Restore placeholders. `rawUrl` always uses raw values (for the
|
||||
// toggle-OFF display swap upstream). `encodedUrl` uses single-encoded
|
||||
// values when toggle is ON, raw when OFF.
|
||||
const rawUrl = restorePathParams(urlWithPlaceholders, { encode: false });
|
||||
const encodedUrl = restorePathParams(encodedUrlWithPlaceholders, { encode: encodeFlag });
|
||||
|
||||
// Sanity gate. Stays here so HAR consumers see exactly what we'd send.
|
||||
if (!looksLikeUrl(encodedUrl)) {
|
||||
throw new Error('invalid request url');
|
||||
}
|
||||
|
||||
// Step 5 — Auth → headers. Append to request headers.
|
||||
const authHeaders = authToHeaders(working.auth, variables, input.oauth2Credentials, input.collectionUid);
|
||||
const allHeaders = mergeAndDedupeHeaders(working.headers, authHeaders);
|
||||
|
||||
// Step 6 — Finalize headers (filter enabled, lowercase, default content-type).
|
||||
const harHeaders = finalizeHeaders(working, allHeaders);
|
||||
|
||||
// Step 7 — Query string array. HAR's queryString is the single source of
|
||||
// truth for what HTTPSnippet renders into the URL slot. The URL itself
|
||||
// (next step) has its query stripped to avoid the legacy-polyfill merge bug.
|
||||
// Pass the RAW URL (pre-encodeUrl) as the fallback source: HTTPSnippet
|
||||
// re-encodes each queryString value via encodeURIComponent when rendering,
|
||||
// so the entries here must carry user-typed bytes. Feeding the encoded URL
|
||||
// here would cause double-encoding (`:` → `%3A` from encodeUrl, then
|
||||
// `%3A` → `%253A` from HTTPSnippet's encodeURIComponent pass).
|
||||
const harQueryString = buildQueryString(working, working.url);
|
||||
|
||||
// Step 8 — Strip the URL's query before storing in HAR (the bracket-key fix).
|
||||
const harUrl = stripQueryStringFromUrl(encodedUrl);
|
||||
|
||||
// Step 9 — Assemble.
|
||||
const har: HarRequest = {
|
||||
method: working.method || 'GET',
|
||||
url: harUrl,
|
||||
httpVersion: 'HTTP/1.1',
|
||||
cookies: [],
|
||||
headers: harHeaders,
|
||||
queryString: harQueryString,
|
||||
postData: buildPostData(working.body),
|
||||
headersSize: 0,
|
||||
bodySize: 0,
|
||||
binary: true
|
||||
};
|
||||
|
||||
const unhash = (s: string): string => (typeof s === 'string' ? restoreUrlVars(s) : s);
|
||||
|
||||
return {
|
||||
har,
|
||||
rawUrl,
|
||||
encodedUrl,
|
||||
unhash,
|
||||
effectiveAuth: working.auth ?? null
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -3,7 +3,8 @@ export {
|
||||
encodeUrl,
|
||||
parseQueryParams,
|
||||
buildQueryString,
|
||||
stripOrigin
|
||||
stripOrigin,
|
||||
safeDecodeURIComponent
|
||||
} from './url';
|
||||
|
||||
export {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { encodeUrl, parseQueryParams, buildQueryString, hasExplicitScheme } from './index';
|
||||
import { encodeUrl, parseQueryParams, buildQueryString, hasExplicitScheme, safeDecodeURIComponent } from './index';
|
||||
|
||||
describe('encodeUrl', () => {
|
||||
describe('basic functionality', () => {
|
||||
@@ -75,16 +75,76 @@ describe('encodeUrl', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('hash fragment handling', () => {
|
||||
it('should preserve hash fragments with encoded query parameters', () => {
|
||||
const url = 'https://example.com/api?name=john doe#section1';
|
||||
const expected = 'https://example.com/api?name=john%20doe#section1';
|
||||
describe('path encoding (added in URL encoding fix)', () => {
|
||||
it('should encode spaces in path segments', () => {
|
||||
const url = 'https://example.com/api/v1/path with spaces/users';
|
||||
const expected = 'https://example.com/api/v1/path%20with%20spaces/users';
|
||||
expect(encodeUrl(url)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should preserve hash fragments with pipe operator in query', () => {
|
||||
it('should encode unicode in path segments', () => {
|
||||
const url = 'https://example.com/api/José/profile';
|
||||
const expected = 'https://example.com/api/Jos%C3%A9/profile';
|
||||
expect(encodeUrl(url)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should encode square brackets in path segments', () => {
|
||||
const url = 'https://example.com/list[123]';
|
||||
const expected = 'https://example.com/list%5B123%5D';
|
||||
expect(encodeUrl(url)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should leave separators between segments alone (slashes are structural)', () => {
|
||||
const url = 'https://example.com/api/v1/users';
|
||||
expect(encodeUrl(url)).toBe(url);
|
||||
});
|
||||
|
||||
it('should encode angle brackets, quotes, pipes in the path', () => {
|
||||
const url = 'https://example.com/api/a<b>c/"hi"/a|b/{x}';
|
||||
const expected = 'https://example.com/api/a%3Cb%3Ec/%22hi%22/a%7Cb/%7Bx%7D';
|
||||
expect(encodeUrl(url)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should encode the path even when there is no query string', () => {
|
||||
const url = 'https://example.com/path with spaces';
|
||||
const expected = 'https://example.com/path%20with%20spaces';
|
||||
expect(encodeUrl(url)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should preserve the origin (scheme + authority) verbatim', () => {
|
||||
const url = 'https://user:pass@example.com:8080/path with space';
|
||||
const expected = 'https://user:pass@example.com:8080/path%20with%20space';
|
||||
expect(encodeUrl(url)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hash (#) treated as data, not RFC 3986 §3.5 fragment delimiter', () => {
|
||||
// ON-mode design choice: `#` is encoded to %23 as a regular byte. The
|
||||
// previous strip-or-preserve behavior caused surprising silent data loss
|
||||
// in the snippet. To send a URL with a literal `#section` fragment, the
|
||||
// toggle must be OFF — OFF preserves the user's URL byte-for-byte.
|
||||
|
||||
it('encodes # in a query value to %23 alongside other special chars', () => {
|
||||
const url = 'https://example.com/api?name=john doe#section1';
|
||||
const expected = 'https://example.com/api?name=john%20doe%23section1';
|
||||
expect(encodeUrl(url)).toBe(expected);
|
||||
});
|
||||
|
||||
it('encodes # alongside pipe operator in query value', () => {
|
||||
const url = 'https://example.com/api?filter=status|active#results';
|
||||
const expected = 'https://example.com/api?filter=status%7Cactive#results';
|
||||
const expected = 'https://example.com/api?filter=status%7Cactive%23results';
|
||||
expect(encodeUrl(url)).toBe(expected);
|
||||
});
|
||||
|
||||
it('encodes a bare # following a single-char value', () => {
|
||||
const url = 'https://jsonplaceholder.typicode.com/todos/1?name=a#b';
|
||||
const expected = 'https://jsonplaceholder.typicode.com/todos/1?name=a%23b';
|
||||
expect(encodeUrl(url)).toBe(expected);
|
||||
});
|
||||
|
||||
it('encodes # in a path segment (no query) to %23', () => {
|
||||
const url = 'https://example.com/foo#bar';
|
||||
const expected = 'https://example.com/foo%23bar';
|
||||
expect(encodeUrl(url)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -104,8 +164,11 @@ describe('encodeUrl', () => {
|
||||
});
|
||||
|
||||
it('should handle complex query parameters with multiple special characters', () => {
|
||||
// `#`, `$`, `%`, `^` are all encoded as data when they appear in a query
|
||||
// value (Option C — # is data, not a fragment delimiter). The bare-name
|
||||
// `*()` survives encodeURIComponent unchanged (RFC 3986 unreserved set).
|
||||
const url = 'https://example.com/api?search=hello world!@#$%^&*()&filter=active&sort=name asc';
|
||||
const expected = 'https://example.com/api?search=hello%20world!%40#$%^&*()&filter=active&sort=name asc';
|
||||
const expected = 'https://example.com/api?search=hello%20world!%40%23%24%25%5E&*()&filter=active&sort=name%20asc';
|
||||
expect(encodeUrl(url)).toBe(expected);
|
||||
});
|
||||
|
||||
@@ -122,6 +185,47 @@ describe('encodeUrl', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PR #5507 contract — content-blind double-encoding (intentional)', () => {
|
||||
// These assertions are the canary that proves no decode-encode wrap was slipped
|
||||
// into the encoder. If any of them start failing, the contract has been broken.
|
||||
|
||||
it('should double-encode pre-encoded space in query value (%20 → %2520)', () => {
|
||||
const url = 'https://example.com/api?name=John%20Doe';
|
||||
const expected = 'https://example.com/api?name=John%2520Doe';
|
||||
expect(encodeUrl(url)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should double-encode pre-encoded @ in query value (%40 → %2540)', () => {
|
||||
const url = 'https://example.com/api?email=john%40example.com';
|
||||
const expected = 'https://example.com/api?email=john%2540example.com';
|
||||
expect(encodeUrl(url)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should double-encode pre-encoded pipe in query value (%7C → %257C)', () => {
|
||||
const url = 'https://example.com/api?filter=status%7Cactive&sort=name%7Casc';
|
||||
const expected = 'https://example.com/api?filter=status%257Cactive&sort=name%257Casc';
|
||||
expect(encodeUrl(url)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should double-encode redirect URL with pre-encoded chars (the canonical #5507 case)', () => {
|
||||
const url = 'https://auth.example.com/login?redirect=https%3A%2F%2Fother.com%2Fcb';
|
||||
const expected = 'https://auth.example.com/login?redirect=https%253A%252F%252Fother.com%252Fcb';
|
||||
expect(encodeUrl(url)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should double-encode pre-encoded %25 → %2525 (single % → %25 same source bytes)', () => {
|
||||
const url = 'https://example.com/api?coupon=50%25';
|
||||
const expected = 'https://example.com/api?coupon=50%2525';
|
||||
expect(encodeUrl(url)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should encode bare % once to %25 in query value', () => {
|
||||
const url = 'https://example.com/api?discount=50%';
|
||||
const expected = 'https://example.com/api?discount=50%25';
|
||||
expect(encodeUrl(url)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world scenarios', () => {
|
||||
it('should handle API URLs with complex query parameters', () => {
|
||||
const url = 'https://api.github.com/search/repositories?q=language:javascript&sort=stars&order=desc&per_page=10';
|
||||
@@ -152,6 +256,12 @@ describe('encodeUrl', () => {
|
||||
const expected = 'https://api.shop.com/products?category=electronics&brand=apple%7Csamsung%7Cgoogle&price_range=100%3A1000&rating=4.5%3A5.0&availability=in_stock&sort=price%3Aasc&limit=50';
|
||||
expect(encodeUrl(url)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle a JSON-shaped array value (canonical #7913 reproducer)', () => {
|
||||
const url = 'https://example.com/api?testArray=[[1, 2, 3], ["string", "string"]]';
|
||||
const expected = 'https://example.com/api?testArray=%5B%5B1%2C%202%2C%203%5D%2C%20%5B%22string%22%2C%20%22string%22%5D%5D';
|
||||
expect(encodeUrl(url)).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -259,6 +369,43 @@ describe('buildQueryString', () => {
|
||||
const result = buildQueryString(params);
|
||||
expect(result).toBe('seat=&table=2');
|
||||
});
|
||||
|
||||
it('should encode structural chars in values when encode is true (issue #5788 path)', () => {
|
||||
// # & = ? + in values — structural in URL grammar, must be encoded so the params
|
||||
// round-trip correctly. This is the contract the collections reducers rely on.
|
||||
const params = [
|
||||
{ name: 'tag', value: 'test#abc' },
|
||||
{ name: 'a', value: 'x&y' },
|
||||
{ name: 'b', value: 'x=y' },
|
||||
{ name: 'c', value: 'x?y' },
|
||||
{ name: 'd', value: 'x+y' },
|
||||
{ name: 'e', value: 'hello world' }
|
||||
];
|
||||
const result = buildQueryString(params, { encode: true });
|
||||
expect(result).toBe('tag=test%23abc&a=x%26y&b=x%3Dy&c=x%3Fy&d=x%2By&e=hello%20world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeDecodeURIComponent', () => {
|
||||
it('should decode a well-formed escape sequence', () => {
|
||||
expect(safeDecodeURIComponent('hello%20world')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('should leave a bare % alone (would otherwise throw)', () => {
|
||||
expect(safeDecodeURIComponent('50%')).toBe('50%');
|
||||
});
|
||||
|
||||
it('should decode well-formed escapes but leave bare % intact in mixed input', () => {
|
||||
expect(safeDecodeURIComponent('50% off %20 sale')).toBe('50% off sale');
|
||||
});
|
||||
|
||||
it('should return empty string unchanged', () => {
|
||||
expect(safeDecodeURIComponent('')).toBe('');
|
||||
});
|
||||
|
||||
it('should be a no-op when there is nothing to decode', () => {
|
||||
expect(safeDecodeURIComponent('hello')).toBe('hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasExplicitScheme', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
9
tests/request/generate-code/collection/bruno.json
Normal file
9
tests/request/generate-code/collection/bruno.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "generate-code-encoding",
|
||||
"type": "collection",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"collections": [
|
||||
{
|
||||
"path": "{{projectRoot}}/tests/request/generate-code/collection",
|
||||
"securityConfig": {
|
||||
"jsSandboxMode": "safe"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
12
tests/request/generate-code/init-user-data/preferences.json
Normal file
12
tests/request/generate-code/init-user-data/preferences.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"maximized": false,
|
||||
"lastOpenedCollections": [
|
||||
"{{projectRoot}}/tests/request/generate-code/collection"
|
||||
],
|
||||
"preferences": {
|
||||
"onboarding": {
|
||||
"hasLaunchedBefore": true,
|
||||
"hasSeenWelcomeModal": true
|
||||
}
|
||||
}
|
||||
}
|
||||
183
tests/request/generate-code/send-vs-snippet.spec.ts
Normal file
183
tests/request/generate-code/send-vs-snippet.spec.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { expect, Page, test } from '../../../playwright';
|
||||
import { openCollection, openRequestInFolder, sendRequest, setUrlEncoding } from '../../utils/page';
|
||||
|
||||
const COLLECTION = 'generate-code-encoding';
|
||||
const FOLDER = 'requests';
|
||||
|
||||
test.describe('Send Request — every fixture, ON and OFF', () => {
|
||||
const fixtures = [
|
||||
// query-side
|
||||
'query-spaces',
|
||||
'query-preencoded',
|
||||
'query-redirect-url',
|
||||
'query-pipe',
|
||||
'query-unicode',
|
||||
'query-equals',
|
||||
'query-email-plus',
|
||||
'query-commas-colons',
|
||||
'query-double-encode',
|
||||
'query-hash',
|
||||
'query-arrays',
|
||||
'fragment-preserved',
|
||||
// path-side
|
||||
'path-spaces',
|
||||
'path-brackets',
|
||||
'path-unicode',
|
||||
'path-idempotent',
|
||||
'path-odata',
|
||||
'path-fragment',
|
||||
'path-issues-fragment',
|
||||
'path-spa-route',
|
||||
'oauth-callback-fragment',
|
||||
// params:path
|
||||
'params-path-odata',
|
||||
'params-path-space',
|
||||
'path-param-slash',
|
||||
'path-param-hash',
|
||||
'path-param-hash-trailing',
|
||||
'path-param-space',
|
||||
'path-param-ampersand',
|
||||
'path-param-equals',
|
||||
'path-param-plus',
|
||||
'path-param-question',
|
||||
'path-param-at',
|
||||
'path-param-colon',
|
||||
'path-param-comma',
|
||||
'path-param-unicode',
|
||||
'path-param-brackets',
|
||||
'path-param-braces',
|
||||
'path-param-pipe'
|
||||
];
|
||||
|
||||
const expectEchoResponded = async (page: Page) => {
|
||||
const texts = await page
|
||||
.getByTestId('response-preview-container')
|
||||
.locator('.CodeMirror-scroll')
|
||||
.allInnerTexts();
|
||||
// Echo server returns `{ "url": "/path/..." }`. Asserting the `"url":`
|
||||
// marker is present is enough to confirm we hit the echo route (not a
|
||||
// 404 / error page) without pinning the encoded byte-form, which is
|
||||
// mode-dependent and the actual point of inspection here.
|
||||
expect(texts.some((t: string) => t.includes('"url":'))).toBe(true);
|
||||
};
|
||||
|
||||
for (const file of fixtures) {
|
||||
test(`${file} — send with toggle ON then OFF`, async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, file);
|
||||
|
||||
// ON
|
||||
await setUrlEncoding(page, true);
|
||||
await sendRequest(page, 200);
|
||||
await expectEchoResponded(page);
|
||||
|
||||
// OFF
|
||||
await setUrlEncoding(page, false);
|
||||
await sendRequest(page, 200);
|
||||
await expectEchoResponded(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests against the local Bruno echo server (`/api/echo/anything/*`) — covers
|
||||
* the # encoding decision-tree scenarios from fixings/snippet-vs-sendrequest.md.
|
||||
*
|
||||
* The echo server returns the request shape (args/data/headers/method/url) in
|
||||
* the JSON body, mimicking httpbin.org/anything. Each test sends and asserts
|
||||
* the substring that proves the right URL reached the server. Both ON and OFF
|
||||
* return 200 (the route matches any path); the distinction is in *what URL*
|
||||
* the server saw.
|
||||
*
|
||||
* Fixtures used:
|
||||
* - docs-fragment-external → Scenario 1: page anchor (#authentication)
|
||||
* - path-issues-fragment → Scenario 4: issue tracker (/issues/#1234)
|
||||
* - path-spa-route → Scenario 5: SPA hash route (/#/dashboard)
|
||||
* - oauth-callback-fragment → Scenario 8: OAuth implicit-flow callback
|
||||
*
|
||||
* Switched from httpbin.org → local echo because the public httpbin was
|
||||
* returning 502/503 under load and making this suite flaky.
|
||||
*/
|
||||
const expectEchoReceived = async (page: Page, expectedUrlSubstring: string) => {
|
||||
const texts = await page
|
||||
.getByTestId('response-preview-container')
|
||||
.locator('.CodeMirror-scroll')
|
||||
.allInnerTexts();
|
||||
// /api/echo/anything/* returns `{ "url": "http://localhost:8081/api/echo/anything/..." }`.
|
||||
// We assert the expected URL substring appears in the response — that
|
||||
// confirms what the server actually received on the wire.
|
||||
expect(texts.some((t: string) => t.includes(expectedUrlSubstring))).toBe(true);
|
||||
};
|
||||
|
||||
// Negative-case helper. Asserts the response body does NOT contain the
|
||||
// forbidden substring — used to prove that the fragment was stripped on wire
|
||||
// for OFF-mode tests (otherwise an `includes` on the path-only prefix would
|
||||
// pass even if Bruno wrongly leaked the fragment through).
|
||||
const expectEchoDidNotReceive = async (page: Page, forbiddenSubstring: string) => {
|
||||
const texts = await page
|
||||
.getByTestId('response-preview-container')
|
||||
.locator('.CodeMirror-scroll')
|
||||
.allInnerTexts();
|
||||
expect(texts.some((t: string) => t.includes(forbiddenSubstring))).toBe(false);
|
||||
};
|
||||
|
||||
test.describe('Send Request — # encoding scenarios (local echo)', () => {
|
||||
test('Scenario 1: page anchor — OFF strips #authentication on wire', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'docs-fragment-external');
|
||||
await setUrlEncoding(page, false);
|
||||
await sendRequest(page, 200);
|
||||
await expectEchoReceived(page, 'http://localhost:8081/api/echo/anything/docs/api');
|
||||
await expectEchoDidNotReceive(page, '#authentication');
|
||||
});
|
||||
|
||||
test('Scenario 1 (ON variant): page anchor — # encoded as data reaches server', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'docs-fragment-external');
|
||||
await setUrlEncoding(page, true);
|
||||
await sendRequest(page, 200);
|
||||
await expectEchoReceived(page, 'http://localhost:8081/api/echo/anything/docs/api#authentication');
|
||||
});
|
||||
|
||||
test('Scenario 4a: issue tracker — OFF strips #1234 on wire', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'path-issues-fragment');
|
||||
await setUrlEncoding(page, false);
|
||||
await sendRequest(page, 200);
|
||||
// Fixture URL is /anything/issues#1234 (not /anything/issues/#1234 from
|
||||
// the docs table) — we use a non-trailing-slash shape that worked across
|
||||
// both the public httpbin (older runs) and the local echo (current).
|
||||
await expectEchoReceived(page, 'http://localhost:8081/api/echo/anything/issues');
|
||||
await expectEchoDidNotReceive(page, '#1234');
|
||||
});
|
||||
|
||||
test('Scenario 4b: issue tracker — ON encodes #1234, server sees full path', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'path-issues-fragment');
|
||||
await setUrlEncoding(page, true);
|
||||
await sendRequest(page, 200);
|
||||
await expectEchoReceived(page, 'http://localhost:8081/api/echo/anything/issues#1234');
|
||||
});
|
||||
|
||||
test('Scenario 5: SPA hash-route — OFF strips everything after #', async ({ pageWithUserData: page }) => {
|
||||
// Fixture URL is /anything/spa#/dashboard/settings (added the /spa segment
|
||||
// to keep the URL non-trailing-slash, same reason as Scenario 4a).
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'path-spa-route');
|
||||
await setUrlEncoding(page, false);
|
||||
await sendRequest(page, 200);
|
||||
await expectEchoReceived(page, 'http://localhost:8081/api/echo/anything/spa');
|
||||
await expectEchoDidNotReceive(page, 'dashboard');
|
||||
});
|
||||
|
||||
test('Scenario 8: OAuth callback — OFF strips token payload on wire', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'oauth-callback-fragment');
|
||||
await setUrlEncoding(page, false);
|
||||
await sendRequest(page, 200);
|
||||
// OFF: fragment (incl. access_token) never reaches the server — that's the
|
||||
// OAuth implicit-flow security property.
|
||||
await expectEchoReceived(page, 'http://localhost:8081/api/echo/anything/callback');
|
||||
await expectEchoDidNotReceive(page, 'access_token');
|
||||
});
|
||||
});
|
||||
380
tests/request/generate-code/url-encoding-off.spec.ts
Normal file
380
tests/request/generate-code/url-encoding-off.spec.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import { expect, test } from '../../../playwright';
|
||||
import { closeGenerateCodeDialog, getGeneratedSnippet, openCollection, openRequestInFolder, setUrlEncoding } from '../../utils/page';
|
||||
|
||||
const COLLECTION = 'generate-code-encoding';
|
||||
const FOLDER = 'requests';
|
||||
|
||||
test.describe('Generate Code – URL Encoding OFF', () => {
|
||||
test.describe('Query preservation', () => {
|
||||
test('preserves literal spaces in query values (no %20)', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'query-spaces');
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?name=John Doe&age=25');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('preserves pre-encoded %20 / %40 without double-encoding', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'query-preencoded');
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?name=John%20Doe&email=john%40example.com');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('preserves equals signs in query values (token=abc123==)', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'query-equals');
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?token=abc123==&type=test');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('preserves redirect URL with colons and slashes', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'query-redirect-url');
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?path=/users/123&redirect=https://other.com');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('preserves email with + alias and @ (test+alias@example.com)', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'query-email-plus');
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/invite?email=test+alias@example.com');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('preserves comma-separated values and colon (tags=a,b,c&time=10:30)', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'query-commas-colons');
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/filter?tags=a,b,c&time=10:30');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('preserves pipe operator in query values (no %7C)', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'query-pipe');
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?filter=status|active&sort=name|asc');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('preserves unicode in query values (no %C3%A9 / %C3%BC)', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'query-unicode');
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?name=José&city=München');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('preserves already-double-encoded values verbatim (mirror of ON canonical case)', async ({ pageWithUserData: page }) => {
|
||||
// Same fixture URL the ON spec uses — but here the toggle is OFF, so
|
||||
// the user-typed bytes round-trip unchanged. This is the contract a
|
||||
// user expects when they've already encoded their redirect URL
|
||||
// themselves and don't want Bruno to touch it.
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'query-double-encode');
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain(
|
||||
'http://localhost:8081/api/echo/anything/login?redirect=https%3A%2F%2Fother.com%2Fcb&token=abc%2520xyz'
|
||||
);
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('preserves # literal in query value (no encoding)', async ({ pageWithUserData: page }) => {
|
||||
// `?query=aaa#bbb` stays as `?query=aaa#bbb`. OFF mode is the only mode
|
||||
// that retains fragment semantics — the `#` survives as a literal byte
|
||||
// in the displayed snippet (curl/fetch will treat it as a fragment on
|
||||
// the wire and drop it, but that's outside the snippet's concern).
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'query-hash');
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?query=aaa#bbb');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('preserves JSON-shaped array query values verbatim (no encoding, no validator error)', async ({ pageWithUserData: page }) => {
|
||||
// Literal `[` `]` `"` `,` and space would be rejected by HTTPSnippet's
|
||||
// HAR validator without the pre-encode-then-replace-back path. The
|
||||
// snippet still ends up containing the raw form.
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'query-arrays');
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).not.toBe('Error generating code snippet');
|
||||
expect(snippet).toContain(
|
||||
'http://localhost:8081/api/echo/anything/api?empty=[]&nums=[1, 2, 3]&strs=["string", "string"]&nested=[[1, 2, 3], ["string", "string"]]'
|
||||
);
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Path preservation', () => {
|
||||
test('preserves literal spaces in path segments', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'path-spaces');
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api/path with spaces/users');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('preserves square brackets in path segments (no Error generating code snippet)', async ({ pageWithUserData: page }) => {
|
||||
// HTTPSnippet's HAR validator rejects literal `[` `]` — without the
|
||||
// pre-encode step in snippet-generator this returns the error string.
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'path-brackets');
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).not.toBe('Error generating code snippet');
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/list[123]');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('preserves unicode in path segments (no %C3%A9)', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'path-unicode');
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/users/José/profile');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('preserves pre-encoded %20 in path verbatim (no decode, no re-encode)', async ({ pageWithUserData: page }) => {
|
||||
// Mirror of the idempotency check ON does — OFF should leave the
|
||||
// already-encoded `%20` exactly as the user typed it.
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'path-idempotent');
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api/path%20with%20spaces/users');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('preserves OData-style parenthesized path params and $ filters', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'path-odata');
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain(
|
||||
'http://localhost:8081/api/echo/anything/odata/Products(123)/Categories(456)?$expand=Items&$filter=Price gt 10'
|
||||
);
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('preserves URL fragment in snippet (intentional asymmetry vs ON)', async ({ pageWithUserData: page }) => {
|
||||
// Raw mode honors user intent — fragment is kept verbatim. ON mode
|
||||
// encodes `#` to %23 as data. See snippet-generator.spec.js for the
|
||||
// designed-behavior comment.
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'fragment-preserved');
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?name=john doe#section1');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('preserves # directly in path (no query ahead of it)', async ({ pageWithUserData: page }) => {
|
||||
// Variant of fragment-preserved where the `#` sits in the path with no
|
||||
// `?` before it. OFF mode keeps the URL byte-for-byte in the snippet
|
||||
// (`hash#tag`). On the wire, downstream HTTP clients treat `#tag` as a
|
||||
// fragment and strip it — so the server receives `/hash` only. The
|
||||
// snippet still shows the user's literal input; that's the OFF contract.
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'path-fragment');
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/hash#tag');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('preserves /issues/#1234 verbatim (semantic-path fragment)', async ({ pageWithUserData: page }) => {
|
||||
// Scenario 4: GitHub/GitLab-style issue link with `#` after a trailing
|
||||
// slash. OFF mode keeps the user's typed form intact in the snippet.
|
||||
// Note: on the wire, HTTP clients strip everything from `#` onwards,
|
||||
// so the server only ever sees `/issues/` — that's the deliberate cost
|
||||
// of OFF mode for fragment-style URLs.
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'path-issues-fragment');
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/issues#1234');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('preserves SPA hash-router URL (/#/dashboard/settings)', async ({ pageWithUserData: page }) => {
|
||||
// Scenario 5: legacy SPA hash-routing. OFF mode keeps the literal `#`
|
||||
// so the snippet matches what the user typed. The wire only sees `/path/`
|
||||
// (axios strips the fragment) — fine for SPAs since the JS reads
|
||||
// location.hash to decide what to render.
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'path-spa-route');
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/spa#/dashboard/settings');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('preserves OAuth callback fragment verbatim', async ({ pageWithUserData: page }) => {
|
||||
// Scenario 8: OAuth implicit-flow callback. The fragment is the entire
|
||||
// point — it keeps the access_token out of server logs. OFF mode
|
||||
// preserves the literal `#access_token=…` in the snippet so users can
|
||||
// see/copy the URL form they typed.
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'oauth-callback-fragment');
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain(
|
||||
'http://localhost:8081/api/echo/anything/callback#access_token=abc123&token_type=Bearer'
|
||||
);
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Path-params table (params:path)', () => {
|
||||
test('does not throw for path-param value with literal space (regression: user-reported `aaa bbb`)', async ({ pageWithUserData: page }) => {
|
||||
// Repro: URL `http://localhost:8081/api/echo/anything/users/:id` with `id = aaa bbb`.
|
||||
// After interpolateUrlPathParams (raw mode) the URL has a literal space:
|
||||
// `http://localhost:8081/api/echo/anything/users/aaa bbb`. HTTPSnippet's HAR validator
|
||||
// rejects it → "Error generating code snippet". The pre-encode-then-
|
||||
// replace-back path in snippet-generator preserves the raw form.
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'params-path-space');
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).not.toBe('Error generating code snippet');
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/users/aaa bbb');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('path-param value with # — /profile suffix kept in snippet (but lost on wire)', async ({ pageWithUserData: page }) => {
|
||||
// Scenario 3 from the # encoding decision tree: when `:id = john#doe`
|
||||
// and the URL template has `/profile` after `:id`, OFF mode keeps
|
||||
// `/profile` visible in the snippet (so the user sees what they typed).
|
||||
// On the wire, downstream HTTP clients strip `#doe/profile` as a
|
||||
// fragment — that's the cost of OFF for this shape. To send the full
|
||||
// path to the server, toggle ON (encodes `#` to `%23`).
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'path-param-hash-trailing');
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/users/john#doe/profile');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('OData-style path with literal Tags("tag test") is preserved verbatim', async ({ pageWithUserData: page }) => {
|
||||
// Mirror of the ON canonical regression. In OFF mode all the literal
|
||||
// `"`, space, and quoted `:CategoryID` characters survive in the
|
||||
// displayed snippet exactly as the path-param substitution emits them.
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'params-path-odata');
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).not.toBe('Error generating code snippet');
|
||||
expect(snippet).toContain(
|
||||
'http://localhost:8081/api/echo/anything/odata/Category(\'category123\')/Item(item456)/foobar/Tags("tag test")'
|
||||
);
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
// Path-param substitution matrix — OFF mirror of url-encoding-on.spec.ts.
|
||||
// In raw mode the snippet preserves the user-typed value byte-for-byte
|
||||
// (the pre-encode-then-replace-back path in snippet-generator restores
|
||||
// the form after HAR validation). Caveats on the / # ? rows are the same
|
||||
// as in the ON spec — the literal-in-value semantic is lost as soon as
|
||||
// the char lands in the URL string, but OFF mode at least keeps those
|
||||
// bytes visible (including the `#bbb` fragment, intentional per the
|
||||
// designed-behavior comment in snippet-generator).
|
||||
const pathParamCases: Array<{ name: string; file: string; expected: string }> = [
|
||||
{ name: '/ in value preserved (looks like two segments)', file: 'path-param-slash', expected: 'http://localhost:8081/api/echo/anything/users/aaa/bbb' },
|
||||
{ name: '# in value preserved as fragment marker (intentional asymmetry vs ON)', file: 'path-param-hash', expected: 'http://localhost:8081/api/echo/anything/users/aaa#bbb' },
|
||||
{ name: 'space in value preserved (John Doe stays literal)', file: 'path-param-space', expected: 'http://localhost:8081/api/echo/anything/users/John Doe' },
|
||||
{ name: '& in value preserved (a&b stays literal)', file: 'path-param-ampersand', expected: 'http://localhost:8081/api/echo/anything/users/a&b' },
|
||||
{ name: '= in value preserved (a=b stays literal)', file: 'path-param-equals', expected: 'http://localhost:8081/api/echo/anything/users/a=b' },
|
||||
{ name: '+ in value preserved (a+b stays literal)', file: 'path-param-plus', expected: 'http://localhost:8081/api/echo/anything/users/a+b' },
|
||||
{ name: '? in value preserved (becomes query separator — literal lost)', file: 'path-param-question', expected: 'http://localhost:8081/api/echo/anything/users/a?b' },
|
||||
{ name: '@ in value preserved (user@host stays literal)', file: 'path-param-at', expected: 'http://localhost:8081/api/echo/anything/users/user@host' },
|
||||
{ name: ': in value preserved (ISO timestamp stays literal)', file: 'path-param-colon', expected: 'http://localhost:8081/api/echo/anything/users/2026-01-15T10:30:00' },
|
||||
{ name: ', in value preserved (a,b,c stays literal)', file: 'path-param-comma', expected: 'http://localhost:8081/api/echo/anything/users/a,b,c' },
|
||||
{ name: 'unicode in value preserved (José stays literal)', file: 'path-param-unicode', expected: 'http://localhost:8081/api/echo/anything/users/José' },
|
||||
{ name: '[ ] in value preserved (list[1] stays literal, no validator error)', file: 'path-param-brackets', expected: 'http://localhost:8081/api/echo/anything/users/list[1]' },
|
||||
{ name: '{ } in value preserved ({x} stays literal)', file: 'path-param-braces', expected: 'http://localhost:8081/api/echo/anything/users/{x}' },
|
||||
{ name: '| in value preserved (a|b stays literal)', file: 'path-param-pipe', expected: 'http://localhost:8081/api/echo/anything/users/a|b' }
|
||||
];
|
||||
|
||||
for (const c of pathParamCases) {
|
||||
test(c.name, async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, c.file);
|
||||
await setUrlEncoding(page, false);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).not.toBe('Error generating code snippet');
|
||||
expect(snippet).toContain(c.expected);
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
385
tests/request/generate-code/url-encoding-on.spec.ts
Normal file
385
tests/request/generate-code/url-encoding-on.spec.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import { expect, test } from '../../../playwright';
|
||||
import { closeGenerateCodeDialog, getGeneratedSnippet, openCollection, openRequestInFolder, setUrlEncoding } from '../../utils/page';
|
||||
|
||||
const COLLECTION = 'generate-code-encoding';
|
||||
const FOLDER = 'requests';
|
||||
|
||||
test.describe('Generate Code – URL Encoding ON', () => {
|
||||
test.describe('Query encoding', () => {
|
||||
test('encodes spaces in query values once (John Doe → John%20Doe)', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'query-spaces');
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?name=John%20Doe&age=25');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('double-encodes pre-encoded values per PR #5507 contract (%20 → %2520, %40 → %2540)', async ({ pageWithUserData: page }) => {
|
||||
// Canary that proves no decode-encode wrap was slipped into the encoder.
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'query-preencoded');
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?name=John%2520Doe&email=john%2540example.com');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('encodes structural chars in redirect-style query values (: → %3A, / → %2F)', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'query-redirect-url');
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?path=%2Fusers%2F123&redirect=https%3A%2F%2Fother.com');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('encodes pipe operator in query values (| → %7C)', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'query-pipe');
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?filter=status%7Cactive&sort=name%7Casc');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('encodes unicode in query values (é → %C3%A9)', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'query-unicode');
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?name=Jos%C3%A9&city=M%C3%BCnchen');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('encodes equals signs in query values (== → %3D%3D)', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'query-equals');
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?token=abc123%3D%3D&type=test');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('encodes email with + alias and @ (+ → %2B, @ → %40)', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'query-email-plus');
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/invite?email=test%2Balias%40example.com');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('encodes comma-separated values and colon (, → %2C, : → %3A)', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'query-commas-colons');
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/filter?tags=a%2Cb%2Cc&time=10%3A30');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('double-encodes redirect URL with all special chars pre-encoded (canonical PR #5507 case)', async ({ pageWithUserData: page }) => {
|
||||
// Same fixture URL the OFF spec uses. ON mode walks each %XX up one
|
||||
// encoding level (%3A → %253A, %2F → %252F), and the already-double-
|
||||
// encoded %2520 goes to %252520 — proving the encoder is content-blind
|
||||
// and runs encodeURIComponent regardless of pre-encoding state. This
|
||||
// is the contract redirect URLs depend on: after one server-side
|
||||
// URL-decode the value comes back single-encoded.
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'query-double-encode');
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain(
|
||||
'http://localhost:8081/api/echo/anything/login?redirect=https%253A%252F%252Fother.com%252Fcb&token=abc%252520xyz'
|
||||
);
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('encodes # in query value as %23 (Option C — # is data, not a fragment delimiter)', async ({ pageWithUserData: page }) => {
|
||||
// `?query=aaa#bbb` → `?query=aaa%23bbb`. The `#` flows through
|
||||
// encodeUrl as a regular data byte rather than being split off as the
|
||||
// RFC 3986 §3.5 fragment. To keep `#bbb` as a literal fragment, the
|
||||
// user must toggle OFF (OFF preserves the URL byte-for-byte).
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'query-hash');
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?query=aaa%23bbb');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('encodes JSON-shaped array query values (issue #7913 reproducer)', async ({ pageWithUserData: page }) => {
|
||||
// Every [ ] , " and space in the array literals must be encoded so the
|
||||
// HAR validator accepts the URL. Covers empty, primitive, string, and
|
||||
// nested array shapes against the same fixture.
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'query-arrays');
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain(
|
||||
'http://localhost:8081/api/echo/anything/api?empty=%5B%5D&nums=%5B1%2C%202%2C%203%5D&strs=%5B%22string%22%2C%20%22string%22%5D&nested=%5B%5B1%2C%202%2C%203%5D%2C%20%5B%22string%22%2C%20%22string%22%5D%5D'
|
||||
);
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Path encoding', () => {
|
||||
test('encodes spaces in path segments (path with spaces → path%20with%20spaces)', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'path-spaces');
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api/path%20with%20spaces/users');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('encodes square brackets in path segments ([123] → %5B123%5D)', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'path-brackets');
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/list%5B123%5D');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('encodes unicode in path segments (José → Jos%C3%A9)', async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'path-unicode');
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/users/Jos%C3%A9/profile');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('path encoding is idempotent — pre-encoded %20 stays single-encoded, not %2520', async ({ pageWithUserData: page }) => {
|
||||
// Regression guard: encodePathSegments uses safeDecodeURIComponent before
|
||||
// re-encoding so the runtime's `new URL().pathname` auto-encoding doesn't
|
||||
// get double-encoded downstream. Single-encoded form below implicitly
|
||||
// proves no %2520 leaked through.
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'path-idempotent');
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api/path%20with%20spaces/users');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('encodes OData-style path with $ filters ($ → %24, space → %20)', async ({ pageWithUserData: page }) => {
|
||||
// Mirror of the OFF preservation test. In ON mode every byte outside
|
||||
// RFC 3986's unreserved set (`A-Za-z0-9-_.~`) gets encoded by
|
||||
// `encodeURIComponent` — so the OData `$` reserved-char becomes %24
|
||||
// and the literal space in `Price gt 10` becomes %20. Parens stay
|
||||
// as-is (encodeURIComponent leaves `(` and `)` unencoded).
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'path-odata');
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain(
|
||||
'http://localhost:8081/api/echo/anything/odata/Products(123)/Categories(456)?%24expand=Items&%24filter=Price%20gt%2010'
|
||||
);
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('encodes # as data (%23) — fragment delimiter has no special meaning in ON mode', async ({ pageWithUserData: page }) => {
|
||||
// Option C: `#` is treated as a regular URL byte and encoded to %23.
|
||||
// Fragment semantics are lost in ON mode by design — to keep `#section`
|
||||
// as a real fragment, toggle OFF (OFF preserves the URL byte-for-byte).
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'fragment-preserved');
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/api?name=john%20doe%23section1');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('encodes # in path (no query) as %23 — Option C', async ({ pageWithUserData: page }) => {
|
||||
// Variant of the fragment-preserved case where `#` sits directly in the
|
||||
// path (no `?` ahead of it). In ON mode encodePathSegments runs over the
|
||||
// path and encodes `#` to %23 like any other reserved byte, so the wire
|
||||
// URL keeps `hash%23tag` intact instead of treating `#tag` as a fragment
|
||||
// that downstream HTTP clients would strip.
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'path-fragment');
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/hash%23tag');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('encodes # in a semantic-path issue link (/issues/#1234)', async ({ pageWithUserData: page }) => {
|
||||
// Scenario 4 from the # encoding decision tree: GitHub-style "/issues/#1234"
|
||||
// where the `#` separates the path from a frontend deep-link. In ON mode
|
||||
// it's treated as data (`%231234`), so the encoded path can survive a
|
||||
// server-side URL-decode pass that expects to receive the literal `#`.
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'path-issues-fragment');
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/issues%231234');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('encodes # in an SPA hash-router URL (/#/dashboard/settings)', async ({ pageWithUserData: page }) => {
|
||||
// Scenario 5: legacy SPA hash-routing pattern. In ON mode each path
|
||||
// segment is independently encoded — the standalone `#` segment becomes
|
||||
// `%23`, preserving the literal byte for the server.
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'path-spa-route');
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/spa%23/dashboard/settings');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('encodes # and reserved chars in an OAuth callback fragment', async ({ pageWithUserData: page }) => {
|
||||
// Scenario 8: OAuth implicit-flow callback URL with token data in the
|
||||
// fragment. In ON mode the entire segment after the last `/` is encoded
|
||||
// — `#` → `%23`, `=` → `%3D`, `&` → `%26` — so the whole token payload
|
||||
// becomes literal data in the path.
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'oauth-callback-fragment');
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain(
|
||||
'http://localhost:8081/api/echo/anything/callback%23access_token%3Dabc123%26token_type%3DBearer'
|
||||
);
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Path-params table (params:path)', () => {
|
||||
test('OData-style path with literal Tags("tag test") encodes once', async ({ pageWithUserData: page }) => {
|
||||
// Canonical regression: `new URL().pathname` pre-encodes `"` → %22
|
||||
// and space → %20, then encodeUrl runs over the result. Without
|
||||
// idempotent encodePathSegments the wire URL would contain %2522 / %2520.
|
||||
// The single-encoded form below proves idempotency held.
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'params-path-odata');
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain(
|
||||
'http://localhost:8081/api/echo/anything/odata/Category(\'category123\')/Item(item456)/foobar/Tags(%22tag%20test%22)'
|
||||
);
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('does not throw for path-param value with literal space (regression: user-reported `aaa bbb`)', async ({ pageWithUserData: page }) => {
|
||||
// Mirror of the OFF regression test — ON mode encodes the literal space.
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'params-path-space');
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).not.toBe('Error generating code snippet');
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/users/aaa%20bbb');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
test('path-param value with # preserves the trailing /profile segment (regression: no silent loss)', async ({ pageWithUserData: page }) => {
|
||||
// Scenario 3 from the # encoding decision tree: when the user types
|
||||
// `:id = john#doe` and the template has `/profile` after `:id`, the
|
||||
// snippet must keep `/profile` visible. In ON mode the `#` is encoded
|
||||
// to `%23`, which prevents downstream tools from treating the rest of
|
||||
// the path as a fragment and silently dropping `/profile`.
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, 'path-param-hash-trailing');
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).toContain('http://localhost:8081/api/echo/anything/users/john%23doe/profile');
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
|
||||
// Path-param substitution matrix. Each row binds `:id` to a single value
|
||||
// and asserts the complete substituted URL appears in the snippet.
|
||||
//
|
||||
// Caveats for the / # ? rows: GenerateCodeItem invokes
|
||||
// interpolateUrlPathParams without an `encodeUrl` option, so the value is
|
||||
// substituted *raw* (see packages/bruno-app/src/utils/url/index.js).
|
||||
// Once the literal `/` `?` `#` lands in the URL string it becomes a
|
||||
// path-separator / query-marker / fragment-marker respectively — the
|
||||
// "literal in value" semantic is lost. The expectations below capture
|
||||
// what the snippet *actually* contains today (a regression canary), not
|
||||
// what an ideal encoder would produce.
|
||||
const pathParamCases: Array<{ name: string; file: string; expected: string }> = [
|
||||
{ name: '/ in value (slash splits into two segments)', file: 'path-param-slash', expected: 'http://localhost:8081/api/echo/anything/users/aaa/bbb' },
|
||||
// # in value: encodeUrl now treats `#` as data, so the snippet should
|
||||
// contain `%23bbb`. Path-only URL means the substitution lands in the
|
||||
// path-encoding stream (encodePathSegments) which encodes `#` to %23.
|
||||
{ name: '# in value (encoded as %23 — Option C)', file: 'path-param-hash', expected: 'http://localhost:8081/api/echo/anything/users/aaa%23bbb' },
|
||||
{ name: 'space in value (John Doe → John%20Doe)', file: 'path-param-space', expected: 'http://localhost:8081/api/echo/anything/users/John%20Doe' },
|
||||
{ name: '& in value (a&b → a%26b)', file: 'path-param-ampersand', expected: 'http://localhost:8081/api/echo/anything/users/a%26b' },
|
||||
{ name: '= in value (a=b → a%3Db)', file: 'path-param-equals', expected: 'http://localhost:8081/api/echo/anything/users/a%3Db' },
|
||||
{ name: '+ in value (a+b → a%2Bb)', file: 'path-param-plus', expected: 'http://localhost:8081/api/echo/anything/users/a%2Bb' },
|
||||
{ name: '? in value (becomes query separator — literal lost)', file: 'path-param-question', expected: 'http://localhost:8081/api/echo/anything/users/a?b' },
|
||||
{ name: '@ in value (user@host → user%40host)', file: 'path-param-at', expected: 'http://localhost:8081/api/echo/anything/users/user%40host' },
|
||||
{ name: ': in value (ISO timestamp → 10%3A30%3A00)', file: 'path-param-colon', expected: 'http://localhost:8081/api/echo/anything/users/2026-01-15T10%3A30%3A00' },
|
||||
{ name: ', in value (a,b,c → a%2Cb%2Cc)', file: 'path-param-comma', expected: 'http://localhost:8081/api/echo/anything/users/a%2Cb%2Cc' },
|
||||
{ name: 'unicode in value (José → Jos%C3%A9)', file: 'path-param-unicode', expected: 'http://localhost:8081/api/echo/anything/users/Jos%C3%A9' },
|
||||
{ name: '[ ] in value (list[1] → list%5B1%5D)', file: 'path-param-brackets', expected: 'http://localhost:8081/api/echo/anything/users/list%5B1%5D' },
|
||||
{ name: '{ } in value ({x} → %7Bx%7D)', file: 'path-param-braces', expected: 'http://localhost:8081/api/echo/anything/users/%7Bx%7D' },
|
||||
{ name: '| in value (a|b → a%7Cb)', file: 'path-param-pipe', expected: 'http://localhost:8081/api/echo/anything/users/a%7Cb' }
|
||||
];
|
||||
|
||||
for (const c of pathParamCases) {
|
||||
test(c.name, async ({ pageWithUserData: page }) => {
|
||||
await openCollection(page, COLLECTION);
|
||||
await openRequestInFolder(page, FOLDER, c.file);
|
||||
await setUrlEncoding(page, true);
|
||||
|
||||
const snippet = await getGeneratedSnippet(page);
|
||||
expect(snippet).not.toBe('Error generating code snippet');
|
||||
expect(snippet).toContain(c.expected);
|
||||
|
||||
await closeGenerateCodeDialog(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1623,6 +1623,78 @@ const openExampleFromSidebar = async (page: Page, requestName: string, exampleNa
|
||||
await exampleRow.click();
|
||||
};
|
||||
|
||||
/**
|
||||
* Open the Generate Code dialog and return the visible snippet text.
|
||||
* @param page - The page object
|
||||
* @returns The text content of the generated code snippet
|
||||
*/
|
||||
const getGeneratedSnippet = async (page: Page): Promise<string> => {
|
||||
return await test.step('Open Generate Code dialog and read snippet', async () => {
|
||||
const { request } = buildCommonLocators(page);
|
||||
|
||||
await request.generateCodeButton().click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
const codeEditor = page.locator('.editor-content .CodeMirror').first();
|
||||
await expect(codeEditor).toBeVisible();
|
||||
|
||||
return (await codeEditor.textContent()) ?? '';
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Close the Generate Code dialog and wait for it to disappear.
|
||||
* @param page - The page object
|
||||
* @returns void
|
||||
*/
|
||||
const closeGenerateCodeDialog = async (page: Page) => {
|
||||
await test.step('Close Generate Code dialog', async () => {
|
||||
const { modal } = buildCommonLocators(page);
|
||||
await modal.closeButton().click();
|
||||
await modal.closeButton().waitFor({ state: 'hidden' });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Open a request inside a folder by exact request name.
|
||||
* @param page - The page object
|
||||
* @param folderName - The name of the folder containing the request
|
||||
* @param requestName - The exact name of the request to open
|
||||
* @returns void
|
||||
*/
|
||||
const openRequestInFolder = async (page: Page, folderName: string, requestName: string) => {
|
||||
await test.step(`Open request "${requestName}" in folder "${folderName}"`, async () => {
|
||||
const { sidebar } = buildCommonLocators(page);
|
||||
await sidebar.folder(folderName).click();
|
||||
|
||||
const folderWrapper = page.locator('.collection-item-name').filter({ hasText: folderName }).locator('..');
|
||||
const escapedName = requestName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const requestRow = folderWrapper.locator('.collection-item-name').filter({
|
||||
has: page.locator('.item-name').filter({ hasText: new RegExp(`^${escapedName}$`) })
|
||||
});
|
||||
await requestRow.click();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle the URL encoding setting on the current request idempotently.
|
||||
* @param page - The page object
|
||||
* @param enabled - Whether URL encoding should be enabled
|
||||
* @returns void
|
||||
*/
|
||||
const setUrlEncoding = async (page: Page, enabled: boolean) => {
|
||||
await test.step(`Set URL encoding ${enabled ? 'ON' : 'OFF'}`, async () => {
|
||||
await selectRequestPaneTab(page, 'Settings');
|
||||
const toggle = page.getByTestId('encode-url-toggle');
|
||||
await expect(toggle).toBeVisible();
|
||||
const current = (await toggle.getAttribute('aria-checked')) === 'true';
|
||||
if (current !== enabled) {
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute('aria-checked', String(enabled));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
type DialogOptions = {
|
||||
showOpenDialog: () => Promise<{ canceled: boolean; filePaths: string[] }>;
|
||||
};
|
||||
@@ -1702,7 +1774,11 @@ export {
|
||||
readField,
|
||||
createExampleFromSidebar,
|
||||
openExampleFromSidebar,
|
||||
openWorkspaceFromDialog
|
||||
openWorkspaceFromDialog,
|
||||
getGeneratedSnippet,
|
||||
closeGenerateCodeDialog,
|
||||
openRequestInFolder,
|
||||
setUrlEncoding
|
||||
};
|
||||
|
||||
export type { SandboxMode, EnvironmentType, EnvironmentVariable, ImportCollectionOptions, CreateRequestOptions, CreateUntitledRequestOptions, CreateTransientRequestOptions, AssertionInput };
|
||||
|
||||
Reference in New Issue
Block a user