mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-15 11:51:30 +00:00
386 lines
20 KiB
TypeScript
386 lines
20 KiB
TypeScript
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);
|
||
});
|
||
}
|
||
});
|
||
});
|