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); }); } }); });