Enable encodeUrl setting to control URL encoding in generated snippets (#7187)

* feat(snippet-generator): implement encodeUrl setting to control URL encoding in generated snippets

* refactor(snippet-generator): rename and enhance URL encoding logic for better clarity and functionality

* feat(snippet-generator): enhance raw URL handling to preserve user encoding choices and improve snippet generation

* test(snippet-generator): add tests for URL fragment handling based on encodeUrl setting

* test(snippet-generator): improve comments on URL fragment handling to clarify RFC compliance

* feat(url): enhance interpolateUrlPathParams to support raw URL handling, preserving user encoding choices for snippet generation

* fix(url): ensure URLs are prefixed with http:// if missing in interpolateUrlPathParams function

* refactor(snippet-generator): streamline URL handling logic to improve snippet generation and ensure proper encoding based on settings

* feat(url): add stripOrigin utility to simplify URL processing in snippet generation

* test(snippet-generator): add test for double-encoding of pre-encoded URLs when encodeUrl is true

* feat(encoding): implement URL encoding settings and add tests for encoding behavior

* fix: address PR review comments (#7187)

- Remove unnecessary no-op jest.mock for @usebruno/common/utils
- Add length guard to prevent catastrophic replaceAll('/') on root-path URLs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* empty commit

* fix(tests): update interpolateUrlPathParams tests to use correct parameter structure

* empty commit

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
sanish chirayath
2026-02-27 15:46:24 +05:30
committed by GitHub
parent 5dd684f7a3
commit 8b230043c1
17 changed files with 628 additions and 64 deletions

View File

@@ -116,6 +116,7 @@ const Settings = ({ item, collection }) => {
label="URL Encoding"
description="Automatically encode query parameters in the URL"
size="medium"
data-testid="encode-url-toggle"
/>
</div>

View File

@@ -99,6 +99,10 @@ const GenerateCodeItem = ({ collectionUid, item, onClose, isExample = false, exa
variables
);
// Raw URL: path params resolved via string replacement (no new URL() encoding),
// preserving the user's original encoding choices for snippet generation.
const rawUrl = interpolateUrlPathParams(interpolatedUrl, requestData.params, variables, { raw: true });
// Get the full language object based on current preferences
const selectedLanguage = useMemo(() => {
const fullName = generateCodePrefs.library === 'default'
@@ -120,7 +124,8 @@ const GenerateCodeItem = ({ collectionUid, item, onClose, isExample = false, exa
...requestData.request,
auth: resolvedRequest.auth,
url: finalUrl
}
},
rawUrl
};
// Update modal title based on mode

View File

@@ -4,6 +4,9 @@ import { getAllVariables, getTreePathFromCollectionToItem, mergeHeaders } from '
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';
const addCurlAuthFlags = (curlCommand, auth) => {
if (!auth || !curlCommand) return curlCommand;
@@ -79,6 +82,38 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false
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);
const search = stringify(parsed.query);
const httpSnippetPath = search ? `${parsed.pathname}?${search}` : parsed.pathname;
let desiredPath;
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).
if (language.target === 'http') {
desiredPath = desiredPath.replace(/ /g, '%20');
}
}
if (httpSnippetPath !== desiredPath && httpSnippetPath?.length > 1) {
result = result.replaceAll(httpSnippetPath, desiredPath);
}
return result;
} catch (error) {
console.error('Error generating code snippet:', error);

View File

@@ -906,3 +906,338 @@ describe('generateSnippet digest and NTLM auth curl export', () => {
expect(result).toMatch(/^curl --digest --user 'myuser'/);
});
});
describe('generateSnippet encodeUrl setting', () => {
const language = { target: 'shell', client: 'curl' };
const baseCollection = { root: { request: { auth: { mode: 'none' }, headers: [] } } };
// Replicate HTTPSnippet's internal encoding to get encoded path+query
const getEncodedPath = (url) => {
const { parse } = require('url');
const { stringify } = require('query-string');
const parsed = parse(url, true, true);
if (!parsed.query || Object.keys(parsed.query).length === 0) {
return parsed.pathname;
}
const search = stringify(parsed.query);
return search ? `${parsed.pathname}?${search}` : parsed.pathname;
};
const makeItem = (url, settings, draft) => ({
uid: 'enc-req',
request: {
method: 'GET',
url,
headers: [],
body: { mode: 'none' },
auth: { mode: 'none' }
},
...(settings !== undefined && { settings }),
...(draft !== undefined && { draft })
});
beforeEach(() => {
jest.clearAllMocks();
// Mock HTTPSnippet to simulate encoding (same pipeline as the real library)
require('httpsnippet').HTTPSnippet = jest.fn().mockImplementation((harRequest) => ({
convert: jest.fn((target) => {
const method = harRequest?.method || 'GET';
const url = harRequest?.url || 'http://example.com';
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)
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}'`;
})
}));
});
it('should preserve equals signs in query values when encodeUrl is false', () => {
const rawUrl = 'https://example.com/api?token=abc123==&type=test';
const item = makeItem(rawUrl, { encodeUrl: false });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('token=abc123==');
// %3D = encoded '='
expect(result).not.toContain('%3D');
});
it('should preserve email with plus alias and @ when encodeUrl is false', () => {
const rawUrl = 'https://example.com/invite?email=test+alias@example.com';
const item = makeItem(rawUrl, { encodeUrl: false });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('email=test+alias@example.com');
});
it('should preserve redirect URL with colons and slashes when encodeUrl is false', () => {
const rawUrl = 'https://example.com/auth?redirect=https://other.com/callback&scope=read';
const item = makeItem(rawUrl, { encodeUrl: false });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('redirect=https://other.com/callback');
// %3A = encoded ':'
expect(result).not.toContain('%3A');
// %2F = encoded '/'
expect(result).not.toContain('%2F');
});
it('should preserve comma-separated values when encodeUrl is false', () => {
const rawUrl = 'https://example.com/filter?tags=a,b,c&time=10:30';
const item = makeItem(rawUrl, { encodeUrl: false });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('tags=a,b,c');
expect(result).toContain('time=10:30');
});
it('should encode URL when encodeUrl is true', () => {
const rawUrl = 'https://example.com/api?token=abc123==&type=test';
const item = makeItem(rawUrl, { encodeUrl: true });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
// %3D%3D = encoded '=='
expect(result).toContain('%3D%3D');
});
it('should preserve raw URL when settings are absent (encodeUrl defaults to false)', () => {
const rawUrl = 'https://example.com/auth?redirect=https://other.com/callback';
const item = makeItem(rawUrl);
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('redirect=https://other.com/callback');
// %3A = encoded ':'
expect(result).not.toContain('%3A');
});
it('should be a no-op for URLs without query params and no encoding needed', () => {
const rawUrl = 'https://example.com/api/users';
const item = makeItem(rawUrl, { encodeUrl: false });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toBe(`curl -X GET '${rawUrl}'`);
});
it('should preserve spaces in pathname when encodeUrl is false and rawUrl is provided', () => {
const encodedUrl = 'https://example.com/my%20path/hello%20world?token=abc123==';
const item = {
...makeItem(encodedUrl, { encodeUrl: false }),
rawUrl: 'https://example.com/my path/hello world?token=abc123=='
};
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('/my path/hello world?token=abc123==');
expect(result).not.toContain('%20');
expect(result).not.toContain('%3D');
});
it('should preserve spaces in pathname without query params when encodeUrl is false', () => {
const encodedUrl = 'https://example.com/my%20path/hello%20world';
const item = {
...makeItem(encodedUrl, { encodeUrl: false }),
rawUrl: 'https://example.com/my path/hello world'
};
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('/my path/hello world');
expect(result).not.toContain('%20');
});
it('should preserve spaces in path-only targets (e.g., python) when encodeUrl is false', () => {
const pythonLanguage = { target: 'python', client: 'python3' };
const encodedUrl = 'https://example.com/my%20path/hello%20world?q=test';
const item = {
...makeItem(encodedUrl, { encodeUrl: false }),
rawUrl: 'https://example.com/my path/hello world?q=test'
};
const result = generateSnippet({ language: pythonLanguage, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('/my path/hello world?q=test');
expect(result).not.toContain('%20');
});
it('should preserve spaces in query values when encodeUrl is false and rawUrl is provided', () => {
const encodedUrl = 'https://example.com/api?token=abc%20123==&type=test';
const item = {
...makeItem(encodedUrl, { encodeUrl: false }),
rawUrl: 'https://example.com/api?token=abc 123==&type=test'
};
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('token=abc 123==');
expect(result).not.toContain('%20');
expect(result).not.toContain('%3D');
});
it('should still work when rawUrl is not provided (backward compatibility)', () => {
const rawUrl = 'https://example.com/api?token=abc123==&type=test';
const item = makeItem(rawUrl, { encodeUrl: false });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('token=abc123==');
expect(result).not.toContain('%3D');
});
it('should keep spaces as %20 for http target when encodeUrl is false (HTTP spec compliance)', () => {
const httpLanguage = { target: 'http', client: 'http1.1' };
const encodedUrl = 'https://example.com/api?token=abc%20123==&type=test';
const item = {
...makeItem(encodedUrl, { encodeUrl: false }),
rawUrl: 'https://example.com/api?token=abc 123==&type=test'
};
const result = generateSnippet({ language: httpLanguage, item, collection: baseCollection, shouldInterpolate: false });
// Spaces must remain encoded for valid HTTP request line
expect(result).toContain('%20');
// But other chars like = should still be decoded
expect(result).not.toContain('%3D');
});
it('should preserve user-typed %20 when encodeUrl is false (not decode to space)', () => {
const preEncodedUrl = 'https://example.com/api?token=abc%20123%3D%3D&type=test';
const item = {
...makeItem(preEncodedUrl, { encodeUrl: false }),
rawUrl: preEncodedUrl // rawUrl has %20 intact (no decodeURI applied)
};
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
// %20 should be preserved, not decoded to a literal space
expect(result).toContain('%20');
// %3D should also be preserved
expect(result).toContain('%3D%3D');
// No double-encoding
expect(result).not.toContain('%2520');
expect(result).not.toContain('%253D');
});
it('should double-encode pre-encoded %20 when encodeUrl is true', () => {
const preEncodedUrl = 'https://example.com/api?token=abc%20123%3D%3D&type=test';
const item = {
...makeItem(preEncodedUrl, { encodeUrl: true }),
rawUrl: preEncodedUrl
};
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
// %20 → %2520 because encodeURIComponent encodes the literal '%' in the already-encoded value
expect(result).toContain('%2520');
// %3D → %253D for the same reason
expect(result).toContain('%253D');
});
it('should preserve OData-style paths with parenthesized params when encodeUrl is false', () => {
const rawUrl = 'https://example.com/odata/Products(123)/Categories(456)?$expand=Items&$filter=Price gt 10';
const item = {
...makeItem(rawUrl, { encodeUrl: false }),
rawUrl
};
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('Products(123)/Categories(456)');
expect(result).toContain('$expand=Items');
expect(result).toContain('$filter=Price gt 10');
// $ should not be encoded
expect(result).not.toContain('%24');
});
it('should use draft settings when draft exists', () => {
const rawUrl = 'https://example.com/api?token=abc123==&type=test';
const item = makeItem(rawUrl, { encodeUrl: true }, { settings: { encodeUrl: false } });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('token=abc123==');
// %3D%3D = encoded '=='
expect(result).not.toContain('%3D%3D');
});
it('should replace encoded path for targets that use only path+query (e.g., python http.client)', () => {
const pythonLanguage = { target: 'python', client: 'python3' };
const rawUrl = 'https://example.com/api?token=abc123==&type=test';
const item = makeItem(rawUrl, { encodeUrl: false });
const result = generateSnippet({ language: pythonLanguage, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('/api?token=abc123==&type=test');
// %3D = encoded '='
expect(result).not.toContain('%3D');
});
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.
const rawUrl = 'https://example.com/api?token=abc==#section';
const item = makeItem(rawUrl, { encodeUrl: false });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('#section');
expect(result).toContain('token=abc==');
expect(result).not.toContain('%3D');
});
it('should not include URL fragment (#) in snippet 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
expect(result).not.toContain('#section');
expect(result).toContain('%3D%3D');
});
it('should single-encode spaces and special chars when encodeUrl is true and rawUrl is provided', () => {
// The raw URL (before new URL() encoding) contains literal spaces and @.
// encodeUrl() should encode them once: space → %20, @ → %40.
// Previously this double-encoded because request.url was already encoded by new URL().
const encodedUrl = 'https://example.com/api?name=abc%20os&email=user%40test.com';
const item = {
...makeItem(encodedUrl, { encodeUrl: true }),
rawUrl: 'https://example.com/api?name=abc os&email=user@test.com'
};
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
// space → %20 (single encoding, not %2520)
expect(result).toContain('%20');
expect(result).not.toContain('%2520');
// @ → %40 (single encoding, not %2540)
expect(result).toContain('%40');
expect(result).not.toContain('%2540');
});
it('should encode special chars in query values when encodeUrl is true (e.g., redirect URLs)', () => {
const rawUrl = 'https://example.com/auth?redirect=https://other.com/callback&scope=read';
const item = makeItem(rawUrl, { encodeUrl: true });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
// : → %3A, / → %2F when encodeURIComponent is applied to query values
expect(result).toContain('%3A');
expect(result).toContain('%2F');
});
it('should strip fragment 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
expect(result).not.toContain('#section');
// Query value should be encoded
expect(result).toContain('%3A');
expect(result).toContain('%2F');
});
it('should be a no-op for path-only URLs when encodeUrl is true (no query params to encode)', () => {
const rawUrl = 'https://example.com/api/users';
const item = makeItem(rawUrl, { encodeUrl: true });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toBe(`curl -X GET '${rawUrl}'`);
});
});

View File

@@ -98,7 +98,7 @@ export const interpolateUrl = ({ url, variables }) => {
return interpolate(url, variables);
};
export const interpolateUrlPathParams = (url, params, variables = {}) => {
export const interpolateUrlPathParams = (url, params, variables = {}, options = {}) => {
const getInterpolatedBasePath = (pathname, params) => {
let replacedPathname = pathname
.split('/')
@@ -141,12 +141,28 @@ export const interpolateUrlPathParams = (url, params, variables = {}) => {
return interpolate(replacedPathname, variables);
};
let uri;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = `http://${url}`;
}
// When raw is true, resolve :params via pure string manipulation without
// passing through new URL(), which would percent-encode characters like spaces.
// This preserves the user's original encoding choices for snippet generation.
if (options.raw) {
const enabledPathParams = (params || []).filter((p) => p.enabled !== false && p.type === 'path');
if (enabledPathParams.length === 0) return url;
const separatorIdx = url.search(/[?#]/);
const pathPart = separatorIdx >= 0 ? url.substring(0, separatorIdx) : url;
const rest = separatorIdx >= 0 ? url.substring(separatorIdx) : '';
// resolvedPath includes the origin (scheme + host) since pathPart is the full URL before ?/#
const resolvedPath = getInterpolatedBasePath(pathPart, enabledPathParams);
return `${resolvedPath}${rest}`;
}
let uri;
try {
uri = new URL(url);
} catch (error) {

View File

@@ -372,3 +372,68 @@ describe('Url Utils - interpolateUrl, interpolateUrlPathParams', () => {
expect(result).toEqual(expectedUrl);
});
});
describe('Url Utils - interpolateUrlPathParams with { raw: true }', () => {
it('should resolve :params without encoding (spaces stay as spaces)', () => {
const url = 'https://example.com/api/:id/path';
const params = [{ name: 'id', type: 'path', enabled: true, value: 'hello world' }];
const result = interpolateUrlPathParams(url, params, {}, { raw: true });
expect(result).toEqual('https://example.com/api/hello world/path');
});
it('should preserve query string and fragment as-is', () => {
const url = 'https://example.com/api/:id?foo=bar&baz=qux#section';
const params = [{ name: 'id', type: 'path', enabled: true, value: '123' }];
const result = interpolateUrlPathParams(url, params, {}, { raw: true });
expect(result).toEqual('https://example.com/api/123?foo=bar&baz=qux#section');
});
it('should return URL unchanged when no path params match', () => {
const url = 'https://example.com/api/path?q=1';
const params = [{ name: 'id', type: 'path', enabled: true, value: '123' }];
const result = interpolateUrlPathParams(url, params, {}, { raw: true });
expect(result).toEqual('https://example.com/api/path?q=1');
});
it('should return URL unchanged when params array is empty', () => {
const url = 'https://example.com/api/:id';
const params = [];
const result = interpolateUrlPathParams(url, params, {}, { raw: true });
expect(result).toEqual('https://example.com/api/:id');
});
it('should handle OData-style params', () => {
const url = 'https://example.com/odata/Products(\':productId\')';
const params = [{ name: 'productId', type: 'path', enabled: true, value: 'ABC 123' }];
const result = interpolateUrlPathParams(url, params, {}, { raw: true });
expect(result).toEqual('https://example.com/odata/Products(\'ABC 123\')');
});
it('should preserve existing percent-encoding', () => {
const url = 'https://example.com/api/:id/already%20encoded';
const params = [{ name: 'id', type: 'path', enabled: true, value: '456' }];
const result = interpolateUrlPathParams(url, params, {}, { raw: true });
expect(result).toEqual('https://example.com/api/456/already%20encoded');
});
it('should skip disabled params', () => {
const url = 'https://example.com/api/:id';
const params = [{ name: 'id', type: 'path', enabled: false, value: '123' }];
const result = interpolateUrlPathParams(url, params, {}, { raw: true });
expect(result).toEqual('https://example.com/api/:id');
});
});

View File

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

View File

@@ -76,10 +76,23 @@ const encodeUrl = (url: string): string => {
return encodedUrl;
};
/**
* Strip the origin (scheme + authority) from a URL, returning the path, query, and fragment.
* Returns '/' if the URL has no path component.
*
* @example
* stripOrigin('https://example.com/api/users?name=foo') // '/api/users?name=foo'
* stripOrigin('http://localhost:3000') // '/'
*/
const stripOrigin = (url: string): string => {
return url.replace(/^https?:\/\/[^/?#]*/, '') || '/';
};
export {
encodeUrl,
parseQueryParams,
buildQueryString,
stripOrigin,
type QueryParam,
type BuildQueryStringOptions,
type ExtractQueryParamsOptions

View File

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

View File

@@ -0,0 +1,15 @@
meta {
name: encode-url-preencoded
type: http
seq: 2
}
get {
url: http://base.source?name=John%20Doe
body: none
auth: inherit
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,15 @@
meta {
name: encode-url-unencoded
type: http
seq: 1
}
get {
url: http://base.source?name=John Doe
body: none
auth: inherit
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,15 @@
meta {
name: raw-url-preencoded
type: http
seq: 4
}
get {
url: http://base.source?name=John%20Doe
body: none
auth: inherit
}
settings {
encodeUrl: false
}

View File

@@ -0,0 +1,15 @@
meta {
name: raw-url-unencoded
type: http
seq: 3
}
get {
url: http://base.source?name=John Doe
body: none
auth: inherit
}
settings {
encodeUrl: false
}

View File

@@ -1,78 +1,85 @@
import { test, expect } from '../../../playwright';
import { closeAllCollections, createCollection, createRequest } from '../../utils/page';
import { openCollection } from '../../utils/page';
import { buildCommonLocators } from '../../utils/page/locators';
test.describe('Code Generation URL Encoding', () => {
test.afterEach(async ({ page }) => {
try {
const modalCloseButton = page.getByTestId('modal-close-button');
if (await modalCloseButton.isVisible()) {
await modalCloseButton.click();
await modalCloseButton.waitFor({ state: 'hidden' });
}
} catch (e) {}
test.describe('when encodeUrl is true', () => {
test('should encode unencoded URL (spaces to %20)', async ({ pageWithUserData: page }) => {
const { sidebar, request, modal } = buildCommonLocators(page);
await closeAllCollections(page);
await openCollection(page, 'encoding-test');
await sidebar.request('encode-url-unencoded').click();
await request.generateCodeButton().click();
await expect(page.getByRole('dialog')).toBeVisible();
const codeEditor = page.locator('.editor-content .CodeMirror').first();
await expect(codeEditor).toBeVisible();
const generatedCode = await codeEditor.textContent();
expect(generatedCode).toContain('http://base.source?name=John%20Doe');
await modal.closeButton().click();
await modal.closeButton().waitFor({ state: 'hidden' });
});
test('should double-encode pre-encoded URL (%20 to %2520)', async ({ pageWithUserData: page }) => {
const { sidebar, request, modal } = buildCommonLocators(page);
await openCollection(page, 'encoding-test');
await sidebar.request('encode-url-preencoded').click();
await request.generateCodeButton().click();
await expect(page.getByRole('dialog')).toBeVisible();
const codeEditor = page.locator('.editor-content .CodeMirror').first();
await expect(codeEditor).toBeVisible();
const generatedCode = await codeEditor.textContent();
expect(generatedCode).toContain('http://base.source?name=John%2520Doe');
await modal.closeButton().click();
await modal.closeButton().waitFor({ state: 'hidden' });
});
});
test('Should generate code with proper URL encoding for unencoded input', async ({
page,
createTmpDir
}) => {
const collectionName = 'unencoded-test-collection';
const requestName = 'curl-encoding-unencoded';
test.describe('when encodeUrl is false', () => {
test('should preserve unencoded URL as-is (spaces kept)', async ({ pageWithUserData: page }) => {
const { sidebar, request, modal } = buildCommonLocators(page);
// Create collection and request
await createCollection(page, collectionName, await createTmpDir(collectionName));
await createRequest(page, requestName, collectionName, { url: 'http://base.source?name=John Doe' });
await openCollection(page, 'encoding-test');
await sidebar.request('raw-url-unencoded').click();
// Click the request in the sidebar
await page.locator('.collection-item-name').filter({ hasText: requestName }).first().click();
await request.generateCodeButton().click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.locator('#send-request .infotip').first().click();
const codeEditor = page.locator('.editor-content .CodeMirror').first();
await expect(codeEditor).toBeVisible();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('dialog').locator('.bruno-modal-header-title')).toContainText('Generate Code');
const generatedCode = await codeEditor.textContent();
expect(generatedCode).toContain('http://base.source?name=John Doe');
const codeEditor = page.locator('.editor-content .CodeMirror').first();
await expect(codeEditor).toBeVisible();
await modal.closeButton().click();
await modal.closeButton().waitFor({ state: 'hidden' });
});
const generatedCode = await codeEditor.textContent();
test('should preserve pre-encoded URL as-is', async ({ pageWithUserData: page }) => {
const { sidebar, request, modal } = buildCommonLocators(page);
expect(generatedCode).toContain('http://base.source/?name=John%20Doe');
await openCollection(page, 'encoding-test');
await sidebar.request('raw-url-preencoded').click();
await page.getByTestId('modal-close-button').click();
await request.generateCodeButton().click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByTestId('modal-close-button').waitFor({ state: 'hidden' });
});
const codeEditor = page.locator('.editor-content .CodeMirror').first();
await expect(codeEditor).toBeVisible();
test('Should generate code with proper URL encoding for encoded input', async ({
page,
createTmpDir
}) => {
const collectionName = 'encoded-test-collection';
const requestName = 'curl-encoding-encoded';
const generatedCode = await codeEditor.textContent();
expect(generatedCode).toContain('http://base.source?name=John%20Doe');
// Create collection and request
await createCollection(page, collectionName, await createTmpDir(collectionName));
await createRequest(page, requestName, collectionName, { url: 'http://base.source?name=John%20Doe' });
// Click the request in the sidebar
await page.locator('.collection-item-name').filter({ hasText: requestName }).first().click();
await page.locator('#send-request .infotip').first().click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('dialog').locator('.bruno-modal-header-title')).toContainText('Generate Code');
const codeEditor = page.locator('.editor-content .CodeMirror').first();
await expect(codeEditor).toBeVisible();
const generatedCode = await codeEditor.textContent();
expect(generatedCode).toContain('http://base.source/?name=John%20Doe');
await page.getByTestId('modal-close-button').click();
await page.getByTestId('modal-close-button').waitFor({ state: 'hidden' });
await modal.closeButton().click();
await modal.closeButton().waitFor({ state: 'hidden' });
});
});
});

View File

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

View File

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

View File

@@ -75,6 +75,7 @@ export const buildCommonLocators = (page: Page) => ({
newRequestUrl: () => page.locator('#new-request-url .CodeMirror'),
requestNameInput: () => page.getByPlaceholder('Request Name'),
requestTestId: () => page.getByTestId('request-name'),
generateCodeButton: () => page.locator('#send-request .infotip').first(),
bodyModeSelector: () => page.getByTestId('request-body-mode-selector'),
bodyEditor: () => page.getByTestId('request-body-editor')
},