diff --git a/packages/bruno-app/src/utils/codemirror/autocomplete.js b/packages/bruno-app/src/utils/codemirror/autocomplete.js index 311020e5d..9cd283dc7 100644 --- a/packages/bruno-app/src/utils/codemirror/autocomplete.js +++ b/packages/bruno-app/src/utils/codemirror/autocomplete.js @@ -108,6 +108,26 @@ const STATIC_API_HINTS = { 'bru.runner.stopExecution()', 'bru.interpolate(str)', 'bru.cookies', + 'bru.cookies.get(name)', + 'bru.cookies.has(name)', + 'bru.cookies.has(name, value)', + 'bru.cookies.one(name)', + 'bru.cookies.all()', + 'bru.cookies.count()', + 'bru.cookies.idx(index)', + 'bru.cookies.indexOf(item)', + 'bru.cookies.find(fn)', + 'bru.cookies.filter(fn)', + 'bru.cookies.each(fn)', + 'bru.cookies.map(fn)', + 'bru.cookies.reduce(fn, initialValue)', + 'bru.cookies.toObject()', + 'bru.cookies.toString()', + 'bru.cookies.add(cookieObj)', + 'bru.cookies.upsert(cookieObj)', + 'bru.cookies.remove(name)', + 'bru.cookies.delete(name)', + 'bru.cookies.clear()', 'bru.cookies.jar()', 'bru.cookies.jar().getCookie(url, name, callback)', 'bru.cookies.jar().getCookies(url, callback)', diff --git a/packages/bruno-converters/src/postman/postman-translations.js b/packages/bruno-converters/src/postman/postman-translations.js index 6cd13fc56..214f9058a 100644 --- a/packages/bruno-converters/src/postman/postman-translations.js +++ b/packages/bruno-converters/src/postman/postman-translations.js @@ -80,17 +80,41 @@ const replacements = { 'pm\\.execution\\.skipRequest': 'bru.runner.skipRequest', 'pm\\.execution\\.setNextRequest\\(null\\)': 'bru.runner.stopExecution()', 'pm\\.execution\\.setNextRequest\\(\'null\'\\)': 'bru.runner.stopExecution()', - // Direct cookie access translations (pm.cookies.has/get/toObject) - 'pm\\.cookies\\.has\\(([^)]+)\\)': 'await bru.cookies.jar().hasCookie(req.getUrl(), $1)', - 'pm\\.cookies\\.get\\(([^)]+)\\)': '(await bru.cookies.jar().getCookie(req.getUrl(), $1))?.value', - 'pm\\.cookies\\.toObject\\(\\)': '(await bru.cookies.jar().getCookies(req.getUrl())).reduce((obj, c) => ({...obj, [c.key]: c.value}), {})', - // Cookie jar translations - 'pm\\.cookies\\.jar\\(\\)': 'bru.cookies.jar()', + // Cookie jar translations — order matters: + // 1. Specific jar method patterns must come before the general jar() pattern, + // otherwise jar() consumes the prefix and the method patterns never match. + // 2. All jar patterns must precede the simpler pm.cookies.* patterns below, + // since replacements are applied in insertion order. 'pm\\.cookies\\.jar\\(\\)\\.get\\(': 'bru.cookies.jar().getCookie(', 'pm\\.cookies\\.jar\\(\\)\\.set\\(': 'bru.cookies.jar().setCookie(', 'pm\\.cookies\\.jar\\(\\)\\.unset\\(': 'bru.cookies.jar().deleteCookie(', 'pm\\.cookies\\.jar\\(\\)\\.clear\\(': 'bru.cookies.jar().deleteCookies(', - 'pm\\.cookies\\.jar\\(\\)\\.getAll\\(': 'bru.cookies.jar().getCookies(' + 'pm\\.cookies\\.jar\\(\\)\\.getAll\\(': 'bru.cookies.jar().getCookies(', + 'pm\\.cookies\\.jar\\(\\)': 'bru.cookies.jar()', + // Direct cookie access + 'pm\\.cookies\\.get\\(': 'bru.cookies.get(', + 'pm\\.cookies\\.has\\(': 'bru.cookies.has(', + 'pm\\.cookies\\.toObject\\(': 'bru.cookies.toObject(', + 'pm\\.cookies\\.toString\\(': 'bru.cookies.toString(', + 'pm\\.cookies\\.clear\\(': 'bru.cookies.clear(', + 'pm\\.cookies\\.remove\\(': 'bru.cookies.delete(', + // PropertyList cookie methods + 'pm\\.cookies\\.one\\(': 'bru.cookies.one(', + 'pm\\.cookies\\.all\\(': 'bru.cookies.all(', + 'pm\\.cookies\\.idx\\(': 'bru.cookies.idx(', + 'pm\\.cookies\\.count\\(': 'bru.cookies.count(', + 'pm\\.cookies\\.indexOf\\(': 'bru.cookies.indexOf(', + 'pm\\.cookies\\.find\\(': 'bru.cookies.find(', + 'pm\\.cookies\\.filter\\(': 'bru.cookies.filter(', + 'pm\\.cookies\\.each\\(': 'bru.cookies.each(', + 'pm\\.cookies\\.map\\(': 'bru.cookies.map(', + 'pm\\.cookies\\.reduce\\(': 'bru.cookies.reduce(', + 'pm\\.cookies\\.add\\(': 'bru.cookies.add(', + 'pm\\.cookies\\.upsert\\(': 'bru.cookies.upsert(', + // Lossy: position-aware inserts map to add (position irrelevant for cookies) + 'pm\\.cookies\\.prepend\\(': 'bru.cookies.add(', + 'pm\\.cookies\\.insert\\(': 'bru.cookies.add(', + 'pm\\.cookies\\.insertAfter\\(': 'bru.cookies.add(' }; const extendedReplacements = Object.keys(replacements).reduce((acc, key) => { diff --git a/packages/bruno-converters/src/utils/bruno-to-postman-translator.js b/packages/bruno-converters/src/utils/bruno-to-postman-translator.js index c4d15ae99..f62c8a01e 100644 --- a/packages/bruno-converters/src/utils/bruno-to-postman-translator.js +++ b/packages/bruno-converters/src/utils/bruno-to-postman-translator.js @@ -97,6 +97,29 @@ const simpleTranslations = { // Cookies jar 'bru.cookies.jar': 'pm.cookies.jar', + // Direct cookie access + 'bru.cookies.get': 'pm.cookies.get', + 'bru.cookies.has': 'pm.cookies.has', + 'bru.cookies.toObject': 'pm.cookies.toObject', + 'bru.cookies.toString': 'pm.cookies.toString', + 'bru.cookies.clear': 'pm.cookies.clear', + 'bru.cookies.delete': 'pm.cookies.remove', + + // PropertyList cookie methods (1:1 mappings) + 'bru.cookies.one': 'pm.cookies.one', + 'bru.cookies.all': 'pm.cookies.all', + 'bru.cookies.idx': 'pm.cookies.idx', + 'bru.cookies.count': 'pm.cookies.count', + 'bru.cookies.indexOf': 'pm.cookies.indexOf', + 'bru.cookies.find': 'pm.cookies.find', + 'bru.cookies.filter': 'pm.cookies.filter', + 'bru.cookies.each': 'pm.cookies.each', + 'bru.cookies.map': 'pm.cookies.map', + 'bru.cookies.reduce': 'pm.cookies.reduce', + 'bru.cookies.add': 'pm.cookies.add', + 'bru.cookies.upsert': 'pm.cookies.upsert', + 'bru.cookies.remove': 'pm.cookies.remove', + // Testing 'expect.fail': 'pm.expect.fail' }; diff --git a/packages/bruno-converters/src/utils/postman-to-bruno-translator.js b/packages/bruno-converters/src/utils/postman-to-bruno-translator.js index 958a2c40c..a36f9a196 100644 --- a/packages/bruno-converters/src/utils/postman-to-bruno-translator.js +++ b/packages/bruno-converters/src/utils/postman-to-bruno-translator.js @@ -91,6 +91,32 @@ const simpleTranslations = { 'pm.cookies.jar().unset': 'bru.cookies.jar().deleteCookie', 'pm.cookies.jar().clear': 'bru.cookies.jar().deleteCookies', + // Direct cookie access (pm.cookies.get/has/toObject) + 'pm.cookies.get': 'bru.cookies.get', + 'pm.cookies.has': 'bru.cookies.has', + 'pm.cookies.toObject': 'bru.cookies.toObject', + 'pm.cookies.toString': 'bru.cookies.toString', + 'pm.cookies.clear': 'bru.cookies.clear', + 'pm.cookies.remove': 'bru.cookies.delete', + + // PropertyList cookie methods (1:1 mappings) + 'pm.cookies.one': 'bru.cookies.one', + 'pm.cookies.all': 'bru.cookies.all', + 'pm.cookies.idx': 'bru.cookies.idx', + 'pm.cookies.count': 'bru.cookies.count', + 'pm.cookies.indexOf': 'bru.cookies.indexOf', + 'pm.cookies.find': 'bru.cookies.find', + 'pm.cookies.filter': 'bru.cookies.filter', + 'pm.cookies.each': 'bru.cookies.each', + 'pm.cookies.map': 'bru.cookies.map', + 'pm.cookies.reduce': 'bru.cookies.reduce', + 'pm.cookies.add': 'bru.cookies.add', + 'pm.cookies.upsert': 'bru.cookies.upsert', + // Lossy: position-aware inserts map to add (position irrelevant for cookies) + 'pm.cookies.prepend': 'bru.cookies.add', + 'pm.cookies.insert': 'bru.cookies.add', + 'pm.cookies.insertAfter': 'bru.cookies.add', + // Execution control 'pm.execution.skipRequest': 'bru.runner.skipRequest', @@ -263,94 +289,6 @@ const complexTransformations = [ } }, - // pm.cookies.has(name) → await bru.cookies.jar().hasCookie(req.getUrl(), name) - { - pattern: 'pm.cookies.has', - transform: (path, j) => { - const callExpr = path.parent.value; - const args = callExpr.arguments; - - const hasCookieCall = j.callExpression( - j.identifier('bru.cookies.jar().hasCookie'), - [j.identifier('req.getUrl()'), ...args] - ); - - return j.awaitExpression(hasCookieCall); - } - }, - - // pm.cookies.get(name) → (await bru.cookies.jar().getCookie(req.getUrl(), name))?.value - { - pattern: 'pm.cookies.get', - transform: (path, j) => { - const callExpr = path.parent.value; - const args = callExpr.arguments; - - const getCookieCall = j.callExpression( - j.identifier('bru.cookies.jar().getCookie'), - [j.identifier('req.getUrl()'), ...args] - ); - - const awaitExpr = j.awaitExpression(getCookieCall); - const parenAwait = j.parenthesizedExpression - ? j.parenthesizedExpression(awaitExpr) - : awaitExpr; - - return j.optionalMemberExpression( - parenAwait, - j.identifier('value'), - false, - true - ); - } - }, - - // pm.cookies.toObject() → (await bru.cookies.jar().getCookies(req.getUrl())).reduce((obj, c) => ({...obj, [c.key]: c.value}), {}) - { - pattern: 'pm.cookies.toObject', - transform: (path, j) => { - const getCookiesCall = j.callExpression( - j.identifier('bru.cookies.jar().getCookies'), - [j.identifier('req.getUrl()')] - ); - - const awaitExpr = j.awaitExpression(getCookiesCall); - - // Build the reduce callback: (obj, c) => ({...obj, [c.key]: c.value}) - const objParam = j.identifier('obj'); - const cParam = j.identifier('c'); - - const spreadElement = j.spreadElement(objParam); - const computedProp = j.property( - 'init', - j.memberExpression(cParam, j.identifier('key')), - j.memberExpression(cParam, j.identifier('value')) - ); - computedProp.computed = true; - - const objectExpr = j.objectExpression([spreadElement, computedProp]); - - const arrowBody = j.parenthesizedExpression - ? j.parenthesizedExpression(objectExpr) - : objectExpr; - - const reduceFn = j.arrowFunctionExpression( - [objParam, cParam], - arrowBody - ); - reduceFn.expression = true; - - // Build: (await ...).reduce(fn, {}) - return j.callExpression( - j.memberExpression( - awaitExpr, - j.identifier('reduce') - ), - [reduceFn, j.objectExpression([])] - ); - } - }, - // pm.globals.has requires special handling { pattern: 'pm.globals.has', diff --git a/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/cookies.test.js b/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/cookies.test.js index 717965afd..56f6491d4 100644 --- a/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/cookies.test.js +++ b/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/cookies.test.js @@ -144,4 +144,60 @@ if (bru.getVar("clearAll") === "true") { expect(translatedCode).toContain('if (pm.variables.get("clearAll") === "true") {'); expect(translatedCode).toContain('jar.clear(domain);'); }); + + // Direct cookie access API tests (bru.cookies.get/has/toObject) + it('should translate bru.cookies.get to pm.cookies.get', () => { + const code = 'const token = bru.cookies.get("authToken");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const token = pm.cookies.get("authToken");'); + }); + + it('should translate bru.cookies.has to pm.cookies.has', () => { + const code = 'const exists = bru.cookies.has("sessionId");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const exists = pm.cookies.has("sessionId");'); + }); + + it('should translate bru.cookies.toString to pm.cookies.toString', () => { + const code = 'const str = bru.cookies.toString();'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const str = pm.cookies.toString();'); + }); + + it('should pass through jar().hasCookie() (no Postman equivalent)', () => { + const code = 'const exists = bru.cookies.jar().hasCookie("https://example.com", "session");'; + const translatedCode = translateBruToPostman(code); + // hasCookie has no Postman equivalent, so the jar() part translates but hasCookie stays + expect(translatedCode).toContain('pm.cookies.jar().hasCookie'); + }); + + it('should translate bru.cookies.toObject to pm.cookies.toObject', () => { + const code = 'const allCookies = bru.cookies.toObject();'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const allCookies = pm.cookies.toObject();'); + }); + + it('should translate bru.cookies.has in conditional', () => { + const code = `if (bru.cookies.has("auth")) { + console.log(bru.cookies.get("auth")); +}`; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toContain('if (pm.cookies.has("auth"))'); + expect(translatedCode).toContain('console.log(pm.cookies.get("auth"))'); + }); + + it('should handle mixed direct cookie access and jar methods', () => { + const code = ` +const token = bru.cookies.get("authToken"); +const jar = bru.cookies.jar(); +jar.setCookie("https://example.com", "newCookie", "value"); +const allCookies = bru.cookies.toObject(); +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('const token = pm.cookies.get("authToken");'); + expect(translatedCode).toContain('const jar = pm.cookies.jar();'); + expect(translatedCode).toContain('jar.set("https://example.com", "newCookie", "value");'); + expect(translatedCode).toContain('const allCookies = pm.cookies.toObject();'); + }); }); diff --git a/packages/bruno-converters/tests/postman/postman-translations/postman-cookie-conversions.spec.js b/packages/bruno-converters/tests/postman/postman-translations/postman-cookie-conversions.spec.js index d17680567..d55528562 100644 --- a/packages/bruno-converters/tests/postman/postman-translations/postman-cookie-conversions.spec.js +++ b/packages/bruno-converters/tests/postman/postman-translations/postman-cookie-conversions.spec.js @@ -295,9 +295,9 @@ describe('postmanTranslations - cookie API conversions', () => { const inputScript = ` const jar = pm.cookies.jar(); jar.get('https://api.com', 'session'); - + pm.cookies.jar().set('https://other.com', 'temp', 'value'); - + jar.getAll('https://api.com', (err, cookies) => { console.log(cookies); }); @@ -306,9 +306,9 @@ describe('postmanTranslations - cookie API conversions', () => { const expectedOutput = ` const jar = bru.cookies.jar(); jar.getCookie('https://api.com', 'session'); - + bru.cookies.jar().setCookie('https://other.com', 'temp', 'value'); - + jar.getCookies('https://api.com', (err, cookies) => { console.log(cookies); }); @@ -319,54 +319,129 @@ describe('postmanTranslations - cookie API conversions', () => { // Tests for pm.cookies direct access methods (has, get, toObject) - test('should convert pm.cookies.has(name) to await hasCookie', () => { + test('should convert pm.cookies.has(name) to bru.cookies.has', () => { const inputScript = `pm.cookies.has('token')`; - const expectedOutput = `await bru.cookies.jar().hasCookie(req.getUrl(), 'token')`; + const expectedOutput = `bru.cookies.has('token')`; expect(postmanTranslation(inputScript)).toBe(expectedOutput); }); - test('should convert pm.cookies.get(name) to await getCookie?.value', () => { + test('should convert pm.cookies.get(name) to bru.cookies.get', () => { const inputScript = `pm.cookies.get('token')`; - const expectedOutput = `(await bru.cookies.jar().getCookie(req.getUrl(), 'token'))?.value`; + const expectedOutput = `bru.cookies.get('token')`; expect(postmanTranslation(inputScript)).toBe(expectedOutput); }); - test('should convert pm.cookies.toObject() to getCookies reduce', () => { + test('should convert pm.cookies.toObject() to bru.cookies.toObject', () => { const inputScript = `pm.cookies.toObject()`; - const expectedOutput = `(await bru.cookies.jar().getCookies(req.getUrl())).reduce((obj, c) => ({ - ...obj, - [c.key]: c.value -}), {})`; + const expectedOutput = `bru.cookies.toObject()`; expect(postmanTranslation(inputScript)).toBe(expectedOutput); }); test('should convert pm.cookies.has inside an if conditional', () => { const inputScript = `if (pm.cookies.has('auth')) { console.log('found'); }`; - const expectedOutput = `if (await bru.cookies.jar().hasCookie(req.getUrl(), 'auth')) { console.log('found'); }`; + const expectedOutput = `if (bru.cookies.has('auth')) { console.log('found'); }`; expect(postmanTranslation(inputScript)).toBe(expectedOutput); }); test('should convert pm.cookies.get with a variable argument', () => { const inputScript = `const val = pm.cookies.get(cookieName)`; - const expectedOutput = `const val = (await bru.cookies.jar().getCookie(req.getUrl(), cookieName))?.value`; + const expectedOutput = `const val = bru.cookies.get(cookieName)`; + expect(postmanTranslation(inputScript)).toBe(expectedOutput); + }); + + test('should convert pm.cookies.toString() to bru.cookies.toString', () => { + const inputScript = `const str = pm.cookies.toString()`; + const expectedOutput = `const str = bru.cookies.toString()`; expect(postmanTranslation(inputScript)).toBe(expectedOutput); }); test('should handle mixed pm.cookies.get and pm.cookies.jar().set without conflict', () => { const inputScript = `const v = pm.cookies.get('token'); pm.cookies.jar().set('https://example.com', 'a', 'b');`; - const expectedOutput = `const v = (await bru.cookies.jar().getCookie(req.getUrl(), 'token'))?.value; bru.cookies.jar().setCookie('https://example.com', 'a', 'b');`; + const expectedOutput = `const v = bru.cookies.get('token'); bru.cookies.jar().setCookie('https://example.com', 'a', 'b');`; expect(postmanTranslation(inputScript)).toBe(expectedOutput); }); test('should handle combined has + get in same script', () => { const inputScript = `if (pm.cookies.has('auth')) { const token = pm.cookies.get('auth'); }`; - const expectedOutput = `if (await bru.cookies.jar().hasCookie(req.getUrl(), 'auth')) { const token = (await bru.cookies.jar().getCookie(req.getUrl(), 'auth'))?.value; }`; + const expectedOutput = `if (bru.cookies.has('auth')) { const token = bru.cookies.get('auth'); }`; expect(postmanTranslation(inputScript)).toBe(expectedOutput); }); test('should handle aliased access: const cookies = pm.cookies', () => { const inputScript = `const cookies = pm.cookies; cookies.get('token');`; - const expectedOutput = `(await bru.cookies.jar().getCookie(req.getUrl(), 'token'))?.value;`; + const expectedOutput = `bru.cookies.get('token');`; + expect(postmanTranslation(inputScript)).toBe(expectedOutput); + }); + + // Direct cookie access API tests (pm.cookies.get/has/toObject) + test('should convert pm.cookies.get to bru.cookies.get', () => { + const inputScript = `const token = pm.cookies.get('authToken');`; + const expectedOutput = `const token = bru.cookies.get('authToken');`; + expect(postmanTranslation(inputScript)).toBe(expectedOutput); + }); + + test('should convert pm.cookies.has to bru.cookies.has', () => { + const inputScript = `const exists = pm.cookies.has('sessionId');`; + const expectedOutput = `const exists = bru.cookies.has('sessionId');`; + expect(postmanTranslation(inputScript)).toBe(expectedOutput); + }); + + test('should convert pm.cookies.toObject to bru.cookies.toObject', () => { + const inputScript = `const allCookies = pm.cookies.toObject();`; + const expectedOutput = `const allCookies = bru.cookies.toObject();`; + expect(postmanTranslation(inputScript)).toBe(expectedOutput); + }); + + test('should convert pm.cookies.has in conditional', () => { + const inputScript = `if (pm.cookies.has('auth')) { + console.log(pm.cookies.get('auth')); +}`; + const expectedOutput = `if (bru.cookies.has('auth')) { + console.log(bru.cookies.get('auth')); +}`; + expect(postmanTranslation(inputScript)).toBe(expectedOutput); + }); + + // Regression guards: jar patterns must be matched before simpler pm.cookies.* patterns + // to prevent pm.cookies.jar().get being caught by pm.cookies.get, etc. + + test('regression: pm.cookies.jar().get must not be caught by pm.cookies.get pattern', () => { + const inputScript = `pm.cookies.jar().get('https://example.com', 'token', (err, cookie) => { console.log(cookie); });`; + const result = postmanTranslation(inputScript); + expect(result).toContain('bru.cookies.jar().getCookie('); + expect(result).not.toContain('bru.cookies.get('); + }); + + test('regression: pm.cookies.jar().set must not be caught by pm.cookies.add pattern', () => { + const inputScript = `pm.cookies.jar().set('https://example.com', 'session', 'abc', (err) => {});`; + const result = postmanTranslation(inputScript); + expect(result).toContain('bru.cookies.jar().setCookie('); + expect(result).not.toContain('bru.cookies.add('); + }); + + test('regression: mixed jar and direct patterns produce correct output for each', () => { + const inputScript = `const v = pm.cookies.get('token');\npm.cookies.jar().set('https://example.com', 'a', 'b');\npm.cookies.jar().get('https://example.com', 'a', cb);`; + const result = postmanTranslation(inputScript); + expect(result).toContain('bru.cookies.get('); + expect(result).toContain('bru.cookies.jar().setCookie('); + expect(result).toContain('bru.cookies.jar().getCookie('); + }); + + test('should handle mixed direct cookie access and jar methods', () => { + const inputScript = ` + const token = pm.cookies.get('authToken'); + const jar = pm.cookies.jar(); + jar.set('https://example.com', 'newCookie', 'value'); + const allCookies = pm.cookies.toObject(); + `; + + const expectedOutput = ` + const token = bru.cookies.get('authToken'); + const jar = bru.cookies.jar(); + jar.setCookie('https://example.com', 'newCookie', 'value'); + const allCookies = bru.cookies.toObject(); + `; + expect(postmanTranslation(inputScript)).toBe(expectedOutput); }); }); diff --git a/packages/bruno-js/src/bru.js b/packages/bruno-js/src/bru.js index ba630c0c8..f728f15c0 100644 --- a/packages/bruno-js/src/bru.js +++ b/packages/bruno-js/src/bru.js @@ -2,7 +2,8 @@ const { cloneDeep } = require('lodash'); const xmlFormat = require('xml-formatter'); const { interpolate: _interpolate } = require('@usebruno/common'); const { sendRequest, createSendRequest } = require('@usebruno/requests').scripting; -const { jar: createCookieJar } = require('@usebruno/requests').cookies; +const { jar: createCookieJar, getCookiesForUrl } = require('@usebruno/requests').cookies; +const CookieList = require('./cookie-list'); const variableNameRegex = /^[\w-.]*$/; @@ -27,6 +28,7 @@ class Bru { * @property {object} [options.certsAndProxyConfig.clientCertificates] - Client certificate configuration * @property {object} [options.certsAndProxyConfig.collectionLevelProxy] - Collection-level proxy settings * @property {object} [options.certsAndProxyConfig.systemProxyConfig] - System proxy configuration + * @property {string} [options.requestUrl] - The URL of the current request (used for cookie access) */ constructor({ runtime, @@ -41,7 +43,8 @@ class Bru { oauth2CredentialVariables, collectionName, promptVariables, - certsAndProxyConfig + certsAndProxyConfig, + requestUrl }) { this.envVariables = envVariables || {}; this.runtimeVariables = runtimeVariables || {}; @@ -57,54 +60,13 @@ class Bru { // Use createSendRequest with config if provided, otherwise use default sendRequest this.sendRequest = certsAndProxyConfig ? createSendRequest(certsAndProxyConfig) : sendRequest; this.runtime = runtime; - this.cookies = { - jar: () => { - const cookieJar = createCookieJar(); - - return { - getCookie: (url, cookieName, callback) => { - const interpolatedUrl = this.interpolate(url); - return cookieJar.getCookie(interpolatedUrl, cookieName, callback); - }, - - getCookies: (url, callback) => { - const interpolatedUrl = this.interpolate(url); - return cookieJar.getCookies(interpolatedUrl, callback); - }, - - setCookie: (url, nameOrCookieObj, valueOrCallback, maybeCallback) => { - const interpolatedUrl = this.interpolate(url); - return cookieJar.setCookie(interpolatedUrl, nameOrCookieObj, valueOrCallback, maybeCallback); - }, - - setCookies: (url, cookiesArray, callback) => { - const interpolatedUrl = this.interpolate(url); - return cookieJar.setCookies(interpolatedUrl, cookiesArray, callback); - }, - - // Clear entire cookie jar - clear: (callback) => { - return cookieJar.clear(callback); - }, - - // Delete cookies for a specific URL/domain - deleteCookies: (url, callback) => { - const interpolatedUrl = this.interpolate(url); - return cookieJar.deleteCookies(interpolatedUrl, callback); - }, - - deleteCookie: (url, cookieName, callback) => { - const interpolatedUrl = this.interpolate(url); - return cookieJar.deleteCookie(interpolatedUrl, cookieName, callback); - }, - - hasCookie: (url, cookieName, callback) => { - const interpolatedUrl = this.interpolate(url); - return cookieJar.hasCookie(interpolatedUrl, cookieName, callback); - } - }; - } - }; + this.requestUrl = requestUrl; + this.cookies = new CookieList({ + getUrl: () => this.interpolate(this.requestUrl), + interpolate: (str) => this.interpolate(str), + createCookieJar, + getCookiesForUrl + }); // Holds variables that are marked as persistent by scripts this.persistentEnvVariables = {}; // Holds credential IDs to be reset after script execution diff --git a/packages/bruno-js/src/cookie-list.js b/packages/bruno-js/src/cookie-list.js new file mode 100644 index 000000000..ee7c44d24 --- /dev/null +++ b/packages/bruno-js/src/cookie-list.js @@ -0,0 +1,272 @@ +const PropertyList = require('./property-list'); + +/** + * CookieList — the `bru.cookies` API for reading and writing cookies in scripts. + * + * Extends PropertyList in dynamic mode: the cookie list is freshly read from the + * cookie jar on every access, and write operations delegate to the jar rather + * than mutating an in-memory array. + * + * --- + * + * ## Cookie object shape + * + * Every cookie surfaced by this list is a plain object: + * + * ```js + * { key, value, domain, path, secure, httpOnly, expires } + * ``` + * + * --- + * + * ## Read methods (inherited from ReadOnlyPropertyList) + * + * | Method | Description | Example return value | + * |--------------------|--------------------------------------------------|---------------------------------------| + * | `get(name)` | Value of the first cookie with `key === name` | `'abc123'` | + * | `one(name)` | Full cookie object for `key === name` | `{ key: 'sid', value: 'abc123', … }` | + * | `all()` | Cloned array of all cookie objects | `[{ key: 'sid', … }, …]` | + * | `idx(index)` | Cookie at positional index | `{ key: 'sid', … }` | + * | `count()` | Number of cookies for the current request URL | `3` | + * + * ## Search methods (inherited) + * + * | Method | Description | Example return value | + * |--------------------|--------------------------------------------------|----------------------| + * | `has(name)` | `true` if a cookie with that key exists | `true` | + * | `has(name, value)` | `true` if key exists **and** value matches | `false` | + * | `find(predicate)` | First cookie matching the predicate function | `{ key: 'sid', … }` | + * | `filter(predicate)`| Array of cookies matching the predicate | `[{ key: … }, …]` | + * | `indexOf(item)` | Index of a structurally-equal cookie, or `-1` | `0` | + * + * ## Iteration methods (inherited) + * + * | Method | Description | + * |-------------------------|----------------------------------------------| + * | `each(fn)` | Calls `fn(cookie, index)` for every cookie | + * | `map(fn)` | Returns a new array of mapped values | + * | `reduce(fn, initial?)` | Reduces cookies to a single value | + * + * ## Transform methods (inherited) + * + * | Method | Description | Example return value | + * |---------------|-----------------------------------------------------|-------------------------------------| + * | `toObject()` | `{ key: value }` map of all cookies | `{ sid: 'abc123', lang: 'en' }` | + * | `toString()` | Semicolon-separated `key=value` string | `'sid=abc123; lang=en'` | + * | `toJSON()` | Same as `all()` — suitable for `JSON.stringify()` | `[{ key: 'sid', … }]` | + * + * ## Write methods (CookieList overrides) + * + * | Method | Description | + * |--------------------------|------------------------------------------------------| + * | `add(cookieObj, cb?)` | Alias for `upsert()` — sets a cookie in the jar | + * | `upsert(cookieObj, cb?)` | Sets (or replaces) a cookie in the jar | + * | `remove(name, cb?)` | Deletes a single cookie by name (no-op if missing) | + * | `delete(name, cb?)` | Alias for `remove()` | + * | `clear(cb?)` | Removes **all** cookies for the current request URL | + * + * ## Jar access + * + * | Method | Description | + * |---------|--------------------------------------------------------------------------| + * | `jar()` | Returns a jar handle with URL interpolation for cross-URL cookie access | + * + * The jar handle exposes: `getCookie`, `getCookies`, `setCookie`, `setCookies`, + * `deleteCookie`, `deleteCookies`, `hasCookie`, and `clear`. + */ +class CookieList extends PropertyList { + /** + * @param {object} options + * @param {Function} options.getUrl - Returns the interpolated request URL (or falsy if unavailable) + * @param {Function} options.interpolate - Interpolates variables in a string + * @param {Function} options.createCookieJar - Factory that returns a cookie jar instance + * @param {Function} options.getCookiesForUrl - Returns cookies array for a given URL + */ + constructor({ getUrl, interpolate, createCookieJar, getCookiesForUrl }) { + super({ + keyProperty: 'key', + dataSource: () => { + const url = getUrl(); + if (!url) return []; + // Normalize tough-cookie Cookie instances to plain objects to avoid + // circular references and exposing internal library structures. + return getCookiesForUrl(url).map(({ key, value, domain, path, secure, httpOnly, expires }) => + ({ key, value, domain, path, secure, httpOnly, expires }) + ); + } + }); + this._getUrl = getUrl; + this._interpolateFn = interpolate; + // Factory function — returns a wrapper around the module-level cookie jar singleton + this._createCookieJar = createCookieJar; + } + + // ── Write methods (cookie jar delegation) ───────────────────────────── + + /** + * Add a cookie to the jar (alias for {@link CookieList#upsert}). + * + * @param {object} cookieObj - Cookie object with at least `key` and `value`. + * @param {Function} [callback] - Optional `(error) => void` callback. If omitted, returns a Promise. + * @returns {Promise|void} A Promise when no callback is given. + * @example + * // Promise usage + * await bru.cookies.add({ key: 'lang', value: 'en' }); + * + * // Callback usage + * bru.cookies.add({ key: 'lang', value: 'en' }, (err) => { if (err) throw err; }); + */ + add(cookieObj, callback) { + return this.upsert(cookieObj, callback); + } + + /** + * Set (or replace) a cookie in the jar for the current request URL. + * + * If a cookie with the same key already exists for this URL, it is overwritten. + * Rejects with an error if `cookieObj` is not a non-null object. + * + * @param {object} cookieObj - Cookie object with at least `key` and `value`. + * @param {Function} [callback] - Optional `(error) => void` callback. If omitted, returns a Promise. + * @returns {Promise|void} A Promise when no callback is given. + * @example + * await bru.cookies.upsert({ key: 'sid', value: 'abc123', secure: true }); + */ + upsert(cookieObj, callback) { + if (!cookieObj || typeof cookieObj !== 'object') { + const error = new Error('cookieObj must be a non-null object'); + if (callback) return callback(error); + return Promise.reject(error); + } + const url = this._getUrl(); + if (!url) { + if (callback) return callback(undefined); + return Promise.resolve(); + } + const jar = this._createCookieJar(); + return jar.setCookie(url, cookieObj, callback); + } + + /** + * Remove a single cookie by name from the current request URL. + * + * A no-op if `name` is falsy or if no cookie with that name exists + * (analogous to `Map.prototype.delete`). + * + * @param {string} name - The cookie key to remove. + * @param {Function} [callback] - Optional `(error) => void` callback. If omitted, returns a Promise. + * @returns {Promise|void} A Promise when no callback is given. + * @example + * await bru.cookies.remove('sid'); + */ + remove(name, callback) { + const url = this._getUrl(); + if (!url || !name) { + if (callback) return callback(undefined); + return Promise.resolve(); + } + const jar = this._createCookieJar(); + return jar.deleteCookie(url, name, callback); + } + + /** + * Remove cookies scoped to the current request URL only. + * Unlike jar().clear() which removes ALL cookies globally, this only + * removes cookies matching the current request's domain and path. + */ + clear(callback) { + const url = this._getUrl(); + if (!url) { + if (callback) return callback(undefined); + return Promise.resolve(); + } + const jar = this._createCookieJar(); + return jar.deleteCookies(url, callback); + } + + /** + * Delete a cookie by name (alias for {@link CookieList#remove}). + * + * @param {string} name - The cookie key to delete. + * @param {Function} [callback] - Optional `(error) => void` callback. If omitted, returns a Promise. + * @returns {Promise|void} A Promise when no callback is given. + * @example + * await bru.cookies.delete('sid'); + */ + delete(name, callback) { + return this.remove(name, callback); + } + + // ── Cookie-specific method ──────────────────────────────────────────── + + /** + * Returns a jar handle for cross-URL cookie operations. + * + * Unlike the CookieList methods (which are scoped to the current request URL), + * the jar handle lets you read/write cookies for **any** URL. All URL arguments + * are automatically interpolated with environment/collection variables. + * + * @returns {{ getCookie, getCookies, setCookie, setCookies, deleteCookie, deleteCookies, hasCookie, clear }} + * @example + * const jar = bru.cookies.jar(); + * + * // Read a cookie from a different URL + * const token = await jar.getCookie('{{authBaseUrl}}/login', 'access_token'); + * + * // Set a cookie on a specific URL + * await jar.setCookie('{{apiBaseUrl}}', { key: 'sid', value: 'abc' }); + * await jar.setCookie('{{apiBaseUrl}}', 'theme', 'dark'); + * + * // Check if a cookie exists + * const exists = await jar.hasCookie('{{apiBaseUrl}}', 'sid'); + * + * // Clear ALL cookies globally + * await jar.clear(); + */ + jar() { + const cookieJar = this._createCookieJar(); + + return { + getCookie: (url, cookieName, callback) => { + const interpolatedUrl = this._interpolateFn(url); + return cookieJar.getCookie(interpolatedUrl, cookieName, callback); + }, + + getCookies: (url, callback) => { + const interpolatedUrl = this._interpolateFn(url); + return cookieJar.getCookies(interpolatedUrl, callback); + }, + + setCookie: (url, nameOrCookieObj, valueOrCallback, maybeCallback) => { + const interpolatedUrl = this._interpolateFn(url); + return cookieJar.setCookie(interpolatedUrl, nameOrCookieObj, valueOrCallback, maybeCallback); + }, + + setCookies: (url, cookiesArray, callback) => { + const interpolatedUrl = this._interpolateFn(url); + return cookieJar.setCookies(interpolatedUrl, cookiesArray, callback); + }, + + clear: (callback) => { + return cookieJar.clear(callback); + }, + + deleteCookies: (url, callback) => { + const interpolatedUrl = this._interpolateFn(url); + return cookieJar.deleteCookies(interpolatedUrl, callback); + }, + + deleteCookie: (url, cookieName, callback) => { + const interpolatedUrl = this._interpolateFn(url); + return cookieJar.deleteCookie(interpolatedUrl, cookieName, callback); + }, + + hasCookie: (url, cookieName, callback) => { + const interpolatedUrl = this._interpolateFn(url); + return cookieJar.hasCookie(interpolatedUrl, cookieName, callback); + } + }; + } +} + +module.exports = CookieList; diff --git a/packages/bruno-js/src/property-list.js b/packages/bruno-js/src/property-list.js new file mode 100644 index 000000000..3e20bb2e4 --- /dev/null +++ b/packages/bruno-js/src/property-list.js @@ -0,0 +1,184 @@ +const ReadOnlyPropertyList = require('./readonly-property-list'); + +/** + * PropertyList - A mutable collection data structure. + * + * Extends ReadOnlyPropertyList with mutation methods that operate on the + * internal _items array in static mode. In dynamic mode, all mutations + * throw — subclasses (e.g. CookieList) override with async implementations. + * + * Class hierarchy: + * ReadOnlyPropertyList (read-only, both modes) + * └── PropertyList (sync mutations in static mode; throws in dynamic mode) + * └── CookieList (overrides add/upsert/remove/clear/delete with async jar ops) + */ +class PropertyList extends ReadOnlyPropertyList { + /** + * Guard that throws in dynamic mode. Called by all mutation methods. + * @param {string} method - Name of the calling method (for error message) + */ + #ensureStaticMode(method) { + if (this._dynamic) { + throw new Error(`${method}() is not supported in dynamic mode. Override in subclass.`); + } + } + + // ── Mutation methods ────────────────────────────────────────────────── + + /** + * Append an item to the end of the list. + * @param {object} item + */ + add(item) { + this.#ensureStaticMode('add'); + this._items.push(item); + } + + /** + * Alias for add(). + * @param {object} item + */ + append(item) { + return this.add(item); + } + + /** + * Insert an item at the beginning of the list. + * @param {object} item + */ + prepend(item) { + this.#ensureStaticMode('prepend'); + this._items.unshift(item); + } + + /** + * Insert an item before a reference item. + * @param {object} item - The item to insert + * @param {string|object} before - Key string or item object to insert before + */ + insert(item, before) { + this.#ensureStaticMode('insert'); + const idx = this.#findIndex(before); + if (idx === -1) { + this._items.push(item); + } else { + this._items.splice(idx, 0, item); + } + } + + /** + * Insert an item after a reference item. + * @param {object} item - The item to insert + * @param {string|object} after - Key string or item object to insert after + */ + insertAfter(item, after) { + this.#ensureStaticMode('insertAfter'); + const idx = this.#findIndex(after); + if (idx === -1) { + this._items.push(item); + } else { + this._items.splice(idx + 1, 0, item); + } + } + + /** + * Remove items matching a predicate, key string, or item reference. + * @param {Function|string|object} predicate + */ + remove(predicate) { + this.#ensureStaticMode('remove'); + if (typeof predicate === 'function') { + this._items = this._items.filter((item) => !predicate(item)); + } else if (typeof predicate === 'string') { + this._items = this._items.filter((item) => item[this._keyProperty] !== predicate); + } else if (predicate && typeof predicate === 'object') { + const idx = this.indexOf(predicate); + if (idx !== -1) { + this._items.splice(idx, 1); + } + } + } + + /** + * Remove all items from the list. + */ + clear() { + this.#ensureStaticMode('clear'); + this._items = []; + } + + /** + * Update an existing item by key, or append if not found. + * @param {object} item + */ + upsert(item) { + this.#ensureStaticMode('upsert'); + const key = item[this._keyProperty]; + const idx = this._items.findIndex((i) => i[this._keyProperty] === key); + if (idx !== -1) { + this._items[idx] = item; + } else { + this._items.push(item); + } + } + + /** + * Replace all items with a new array. + * @param {Array} items + */ + populate(items) { + this.#ensureStaticMode('populate'); + this._items = Array.isArray(items) ? [...items] : []; + } + + /** + * Clear and repopulate with new items. + * @param {Array} items + */ + repopulate(items) { + this.#ensureStaticMode('repopulate'); + this.populate(items); + } + + /** + * Merge items from another PropertyList or array. + * @param {PropertyList|Array} source - Source of items to merge + * @param {boolean} [prune=false] - If true, clear existing items first + */ + assimilate(source, prune) { + this.#ensureStaticMode('assimilate'); + if (prune) { + this._items = []; + } + let items; + if (ReadOnlyPropertyList.isPropertyList(source)) { + items = source.all(); + } else if (Array.isArray(source)) { + items = source; + } else { + items = []; + } + for (const item of items) { + this._items.push(item); + } + } + + // ── Internal helpers ────────────────────────────────────────────────── + + /** + * Find the index of a reference (key string or item object). + * @param {string|object} ref + * @returns {number} + */ + #findIndex(ref) { + if (typeof ref === 'string') { + return this._items.findIndex((i) => i[this._keyProperty] === ref); + } + if (ref && typeof ref === 'object') { + return this.indexOf(ref); + } + return -1; + } +} + +module.exports = PropertyList; diff --git a/packages/bruno-js/src/readonly-property-list.js b/packages/bruno-js/src/readonly-property-list.js new file mode 100644 index 000000000..9665f14f7 --- /dev/null +++ b/packages/bruno-js/src/readonly-property-list.js @@ -0,0 +1,227 @@ +/** + * ReadOnlyPropertyList - A read-only collection data structure. + * + * Two modes: + * - Static mode: items stored internally in an array (for headers, query params, etc.) + * - Dynamic mode: a dataSource function returns fresh items on every read (for cookies) + * + * Items are plain objects with a configurable key property (keyProperty) and value property (valueProperty). + * + * This base class provides only read/search/iteration/transform methods. + * See PropertyList for static-mode mutation methods. + * See CookieList for async cookie-jar write methods. + * + * Convention: + * #field / #method – truly private, inaccessible to subclasses + * _field / _method – protected, intended for subclass access only + */ +class ReadOnlyPropertyList { + // ── Private fields (not accessible by subclasses) ──────────────────── + #valueProperty; + #dataSource; + + /** + * @param {object} options + * @param {string} [options.keyProperty='key'] - The property name used as the unique key + * @param {string} [options.valueProperty='value'] - The property name used as the value + * @param {Function} [options.dataSource] - Dynamic data source function (returns array of items) + * @param {Array} [options.items] - Initial items for static mode + */ + // Items are stored in an array (not a Map) to support positional access (idx, indexOf), + // ordered insertion (insert, insertAfter, prepend in PropertyList), and duplicate keys. + // At typical list sizes (cookies, headers) the O(n) key lookup is negligible. + constructor({ keyProperty = 'key', valueProperty = 'value', dataSource, items } = {}) { + this._keyProperty = keyProperty; + this.#valueProperty = valueProperty; + this._dynamic = typeof dataSource === 'function'; + if (this._dynamic) { + this.#dataSource = dataSource; + } else { + this._items = Array.isArray(items) ? [...items] : []; + } + } + + /** + * Returns the current list of items. + * In dynamic mode, calls the dataSource function. + * In static mode, returns the internal array. + */ + #getItems() { + return this._dynamic ? this.#dataSource() : this._items; + } + + // ── Retrieval ────────────────────────────────────────────────────────── + + /** + * Get the value of an item by its key. + * @param {string} name + * @returns {*} The value property of the matching item, or undefined + */ + get(name) { + const items = this.#getItems(); + // Use findLast so that duplicate keys resolve to the last entry, + // consistent with toObject() which also gives last-wins semantics. + const item = items.findLast((i) => i[this._keyProperty] === name); + return item ? item[this.#valueProperty] : undefined; + } + + /** + * Get the full item object by its key. + * @param {string} name + * @returns {object|undefined} + */ + one(name) { + const items = this.#getItems(); + // Use findLast so that duplicate keys resolve to the last entry, + // consistent with get() and toObject() which also give last-wins semantics. + return items.findLast((i) => i[this._keyProperty] === name); + } + + /** + * Get a cloned array of all items. + * @returns {Array} + */ + all() { + return [...this.#getItems()]; + } + + /** + * Get an item by its positional index. + * @param {number} index + * @returns {object|undefined} + */ + idx(index) { + return this.#getItems()[index]; + } + + /** + * Get the number of items. + * @returns {number} + */ + count() { + return this.#getItems().length; + } + + /** + * Get the index of an item. + * Uses structural equality (matching by key and value) so it works + * even when the item is a copy rather than the same reference. + * @param {object} item + * @returns {number} -1 if not found + */ + indexOf(item) { + if (!item || typeof item !== 'object') return -1; + const items = this.#getItems(); + const keyProp = this._keyProperty; + return items.findIndex( + (i) => i[keyProp] === item[keyProp] && i[this.#valueProperty] === item[this.#valueProperty] + ); + } + + // ── Search ───────────────────────────────────────────────────────────── + + /** + * Check if an item with the given key exists. + * If value is provided, also checks that the item's value matches. + * @param {string} name + * @param {*} [value] + * @returns {boolean} + */ + has(name, value) { + const items = this.#getItems(); + if (value !== undefined) { + return items.some((i) => i[this._keyProperty] === name && i[this.#valueProperty] === value); + } + return items.some((i) => i[this._keyProperty] === name); + } + + /** + * Find the first item matching a predicate. + * @param {Function} predicate + * @returns {object|undefined} + */ + find(predicate) { + return this.#getItems().find(predicate); + } + + /** + * Filter items by a predicate. + * @param {Function} predicate + * @returns {Array} + */ + filter(predicate) { + return this.#getItems().filter(predicate); + } + + // ── Iteration ────────────────────────────────────────────────────────── + + /** + * Iterate over each item. + * @param {Function} fn - Called with (item, index) + */ + each(fn) { + this.#getItems().forEach(fn); + } + + /** + * Map over items. + * @param {Function} fn + * @returns {Array} + */ + map(fn) { + return this.#getItems().map(fn); + } + + /** + * Reduce items. + * @param {Function} fn + * @param {*} [initialValue] - Optional initial accumulator value + * @returns {*} + */ + reduce(fn, ...rest) { + return rest.length ? this.#getItems().reduce(fn, rest[0]) : this.#getItems().reduce(fn); + } + + // ── Transformation ───────────────────────────────────────────────────── + + /** + * Convert to a plain object { key: value }. + * @returns {object} + */ + toObject() { + const result = {}; + for (const item of this.#getItems()) { + result[item[this._keyProperty]] = item[this.#valueProperty]; + } + return result; + } + + /** + * Convert to a string "key=value; key2=value2". + * @returns {string} + */ + toString() { + return this.#getItems() + .map((i) => `${i[this._keyProperty]}=${i[this.#valueProperty]}`) + .join('; '); + } + + /** + * Convert to JSON (returns the same as all()). + * @returns {Array} + */ + toJSON() { + return this.all(); + } + + /** + * Check if an object is an instance of ReadOnlyPropertyList. + * @param {*} obj + * @returns {boolean} + */ + static isPropertyList(obj) { + return obj instanceof ReadOnlyPropertyList; + } +} + +module.exports = ReadOnlyPropertyList; diff --git a/packages/bruno-js/src/runtime/assert-runtime.js b/packages/bruno-js/src/runtime/assert-runtime.js index ff4566a85..c30f1eb65 100644 --- a/packages/bruno-js/src/runtime/assert-runtime.js +++ b/packages/bruno-js/src/runtime/assert-runtime.js @@ -272,7 +272,8 @@ class AssertRuntime { requestVariables, globalEnvironmentVariables, promptVariables, - certsAndProxyConfig + certsAndProxyConfig, + requestUrl: request?.url }); const req = new BrunoRequest(request); const res = createResponseParser(response); diff --git a/packages/bruno-js/src/runtime/script-runtime.js b/packages/bruno-js/src/runtime/script-runtime.js index 2eb43c3ee..4d29e9e6e 100644 --- a/packages/bruno-js/src/runtime/script-runtime.js +++ b/packages/bruno-js/src/runtime/script-runtime.js @@ -49,7 +49,8 @@ class ScriptRuntime { oauth2CredentialVariables, collectionName, promptVariables, - certsAndProxyConfig + certsAndProxyConfig, + requestUrl: request?.url }); const req = new BrunoRequest(request); @@ -182,7 +183,8 @@ class ScriptRuntime { oauth2CredentialVariables, collectionName, promptVariables, - certsAndProxyConfig + certsAndProxyConfig, + requestUrl: request?.url }); const req = new BrunoRequest(request); const res = new BrunoResponse(response); diff --git a/packages/bruno-js/src/runtime/test-runtime.js b/packages/bruno-js/src/runtime/test-runtime.js index 91b15ffd1..3daa0dfde 100644 --- a/packages/bruno-js/src/runtime/test-runtime.js +++ b/packages/bruno-js/src/runtime/test-runtime.js @@ -49,7 +49,8 @@ class TestRuntime { oauth2CredentialVariables, collectionName, promptVariables, - certsAndProxyConfig + certsAndProxyConfig, + requestUrl: request?.url }); const req = new BrunoRequest(request); const res = new BrunoResponse(response); diff --git a/packages/bruno-js/src/runtime/vars-runtime.js b/packages/bruno-js/src/runtime/vars-runtime.js index f46b6b188..483501db0 100644 --- a/packages/bruno-js/src/runtime/vars-runtime.js +++ b/packages/bruno-js/src/runtime/vars-runtime.js @@ -48,7 +48,8 @@ class VarsRuntime { globalEnvironmentVariables, oauth2CredentialVariables, promptVariables, - certsAndProxyConfig + certsAndProxyConfig, + requestUrl: request?.url }); const req = new BrunoRequest(request); const res = createResponseParser(response); diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/bru.js b/packages/bruno-js/src/sandbox/quickjs/shims/bru.js index 821b7ef05..708ef755e 100644 --- a/packages/bruno-js/src/sandbox/quickjs/shims/bru.js +++ b/packages/bruno-js/src/sandbox/quickjs/shims/bru.js @@ -1,5 +1,6 @@ const { cleanJson, cleanCircularJson } = require('../../../utils'); const { marshallToVm } = require('../utils'); +const { createPropertyListBridge } = require('../utils/property-list-bridge'); const addBruShimToContext = (vm, bru) => { const bruObject = vm.newObject(); @@ -349,6 +350,13 @@ const addBruShimToContext = (vm, bru) => { sleep.consume((handle) => vm.setProp(bruObject, 'sleep', handle)); let bruCookiesObject = vm.newObject(); + const { evalCode: cookiesEvalCode } = createPropertyListBridge(vm, bru.cookies, bruCookiesObject, { + globalPath: 'globalThis.bru.cookies', + syncReadMethods: ['get', 'has', 'count', 'indexOf', 'toObject', 'toString'], + syncReadObjectMethods: ['one', 'all', 'idx', 'toJSON'], + asyncWriteMethods: ['add', 'upsert', 'remove', 'clear', 'delete'], + withIterators: true + }); const _jarFn = vm.newFunction('_jar', () => { const nativeJar = bru.cookies.jar(); @@ -524,6 +532,10 @@ const addBruShimToContext = (vm, bru) => { } }; + { + ${cookiesEvalCode} + } + globalThis.bru.cookies.jar = () => { const _jar = globalThis.bru.cookies._jar(); diff --git a/packages/bruno-js/src/sandbox/quickjs/utils/property-list-bridge.js b/packages/bruno-js/src/sandbox/quickjs/utils/property-list-bridge.js new file mode 100644 index 000000000..ed6bf725e --- /dev/null +++ b/packages/bruno-js/src/sandbox/quickjs/utils/property-list-bridge.js @@ -0,0 +1,165 @@ +const { cleanJson, cleanCircularJson } = require('../../../utils'); +const { marshallToVm } = require('../utils'); + +/** + * Creates an async bridge that resolves with `undefined` (write-only). + * Do NOT reuse this for read methods that need to return values — + * those require resolving with the callback's result argument instead. + */ +const createAsyncBridge = (vm, targetObj, propName, nativeMethod) => { + const fn = vm.newFunction(propName, (...vmArgs) => { + const promise = vm.newPromise(); + const args = vmArgs.map((a) => vm.dump(a)); + nativeMethod(...args, (err) => { + if (err) { + promise.reject(marshallToVm(cleanJson(err), vm)); + } else { + promise.resolve(vm.undefined); + } + }); + promise.settled.then(vm.runtime.executePendingJobs); + return promise.handle; + }); + fn.consume((handle) => vm.setProp(targetObj, propName, handle)); +}; + +/** + * Factory that auto-wires PropertyList methods onto a QuickJS VM object. + * + * Generates: + * - Sync read methods: `vm.newFunction` → `marshallToVm(nativeList.method(...args), vm)` + * - Sync read object methods: same but wrapped with `cleanCircularJson()` + * - Async write methods: `_prefix` bridge pattern (native callback → QuickJS promise) + * - Returns `{ evalCode }` string containing `callWithCallback` helper + async wrappers + iterators + * + * @example + * In shims/bru.js, wiring up bru.cookies takes a single call: + * + * const { evalCode: cookiesEvalCode } = createPropertyListBridge(vm, bru.cookies, bruCookiesObject, { + * globalPath: 'globalThis.bru.cookies', + * syncReadMethods: ['get', 'has', 'count', 'indexOf', 'toObject', 'toString'], + * syncReadObjectMethods: ['one', 'all', 'idx', 'toJSON'], + * asyncWriteMethods: ['add', 'upsert', 'remove', 'clear', 'delete'], + * withIterators: true + * }); + * + * Without this factory, each method would require manual boilerplate like the + * hand-written jar() bridge in bru.js (~100 lines), where every method needs: + * + * const _fn = vm.newFunction('_method', (...vmArgs) => { + * const promise = vm.newPromise(); + * nativeObj.method(vm.dump(vmArgs[0]), (err, result) => { + * if (err) { + * promise.reject(marshallToVm(cleanJson(err), vm)); + * } else { + * promise.resolve(marshallToVm(cleanCircularJson(result), vm)); + * } + * }); + * promise.settled.then(vm.runtime.executePendingJobs); + * return promise.handle; + * }); + * _fn.consume((handle) => vm.setProp(obj, '_method', handle)); + * + * …repeated for every method, plus separate evalCode for async wrappers. + * + * To wire up a new PropertyList-backed object, add one createPropertyListBridge + * call instead of duplicating all that boilerplate. + * + * @param {Object} vm - QuickJS VM instance + * @param {Object} nativeList - Native PropertyList instance + * @param {Object} targetObj - QuickJS object handle to attach methods to + * @param {Object} options + * @param {string} options.globalPath - Global path in QuickJS (e.g. 'globalThis.bru.cookies') + * @param {string[]} [options.syncReadMethods] - Methods that return primitive values + * @param {string[]} [options.syncReadObjectMethods] - Methods that return objects (need cleanCircularJson) + * @param {string[]} [options.asyncWriteMethods] - Async write methods (use _prefix bridge) + * @param {boolean} [options.withIterators] - Whether to add each/find/filter/map/reduce + * @returns {{ evalCode: string }} - JavaScript code to eval in the VM for async wrappers and iterators + */ +const createPropertyListBridge = (vm, nativeList, targetObj, options) => { + const { + globalPath, + syncReadMethods = [], + syncReadObjectMethods = [], + asyncWriteMethods = [], + withIterators = false + } = options; + + // Sync read methods — return primitive values + for (const methodName of syncReadMethods) { + const fn = vm.newFunction(methodName, (...vmArgs) => { + const args = vmArgs.map((a) => vm.dump(a)); + return marshallToVm(nativeList[methodName](...args), vm); + }); + fn.consume((handle) => vm.setProp(targetObj, methodName, handle)); + } + + // Sync read object methods — need cleanCircularJson + for (const methodName of syncReadObjectMethods) { + const fn = vm.newFunction(methodName, (...vmArgs) => { + const args = vmArgs.map((a) => vm.dump(a)); + return marshallToVm(cleanCircularJson(nativeList[methodName](...args)), vm); + }); + fn.consume((handle) => vm.setProp(targetObj, methodName, handle)); + } + + // Async write methods — two-phase setup: + // Phase 1 (native): Register `_prefixed` bridge functions (e.g. `_add`, `_remove`) via + // createAsyncBridge. These are QuickJS promise-based wrappers that call the native method's + // callback API and resolve with `undefined` (write-only). + // Phase 2 (evalCode): Generates JS code eval'd in the VM that: + // 1. Defines a `callWithCallback` helper supporting both `await method(args)` and + // `method(args, callback)` calling styles. + // 2. Captures `_prefixed` direct references, then overwrites the public method name with + // a wrapper that auto-detects whether the last argument is a callback. + for (const methodName of asyncWriteMethods) { + createAsyncBridge(vm, targetObj, `_${methodName}`, (...a) => nativeList[methodName](...a)); + } + + let evalCode = ''; + + if (asyncWriteMethods.length > 0) { + evalCode += `const callWithCallback = async (promiseFn, callback) => { + if (!callback) return await promiseFn(); + try { + const result = await promiseFn(); + try { await callback(null, result); } catch(cbErr) { return Promise.reject(cbErr); } + } catch(err) { + try { await callback(err, null); } catch(cbErr) { return Promise.reject(cbErr); } + } + };\n`; + + // Capture _prefixed direct references before overwriting + for (const methodName of asyncWriteMethods) { + evalCode += `const _${methodName}Direct = ${globalPath}._${methodName};\n`; + } + + // Generate wrapper functions: method(...args, cb?) => callWithCallback(() => _direct(...args), cb) + for (const methodName of asyncWriteMethods) { + evalCode += `${globalPath}.${methodName} = (...args) => { + const cb = typeof args[args.length - 1] === 'function' ? args.pop() : undefined; + return callWithCallback(() => _${methodName}Direct(...args), cb); + };\n`; + } + } + + // Iterators — these can't be bridged as syncReadObjectMethods because they take a callback + // function as an argument, and functions can't cross the native↔VM boundary (vm.dump() can't + // serialize them). Instead, we pull the data into the VM via `all()`, then run the array + // operation inside the VM where the callback lives. Requires `all` in `syncReadObjectMethods`. + if (withIterators) { + evalCode += `const _allNative = ${globalPath}.all; + ${globalPath}.each = (fn) => { _allNative().forEach(fn); }; + ${globalPath}.filter = (fn) => _allNative().filter(fn); + ${globalPath}.find = (fn) => _allNative().find(fn); + ${globalPath}.map = (fn) => _allNative().map(fn); + ${globalPath}.reduce = (fn, ...rest) => rest.length ? _allNative().reduce(fn, rest[0]) : _allNative().reduce(fn);\n`; + } + + return { evalCode }; +}; + +module.exports = { + createPropertyListBridge, + createAsyncBridge +}; diff --git a/packages/bruno-js/tests/cookie-list.spec.js b/packages/bruno-js/tests/cookie-list.spec.js new file mode 100644 index 000000000..dc60d6e3f --- /dev/null +++ b/packages/bruno-js/tests/cookie-list.spec.js @@ -0,0 +1,345 @@ +const CookieList = require('../src/cookie-list'); +const PropertyList = require('../src/property-list'); +const ReadOnlyPropertyList = require('../src/readonly-property-list'); + +describe('CookieList', () => { + const mockCookies = [ + { key: 'session', value: 'abc123' }, + { key: 'token', value: 'xyz789' }, + { key: 'theme', value: 'dark' } + ]; + + function createCookieList(overrides = {}) { + return new CookieList({ + getUrl: overrides.getUrl || (() => 'https://example.com'), + interpolate: overrides.interpolate || ((str) => str), + createCookieJar: overrides.createCookieJar || (() => ({})), + getCookiesForUrl: overrides.getCookiesForUrl || (() => mockCookies) + }); + } + + // ── Inheritance ──────────────────────────────────────────────────────── + + test('extends PropertyList and ReadOnlyPropertyList', () => { + const list = createCookieList(); + expect(list).toBeInstanceOf(ReadOnlyPropertyList); + expect(list).toBeInstanceOf(PropertyList); + expect(list).toBeInstanceOf(CookieList); + }); + + test('inherits read methods from ReadOnlyPropertyList', () => { + const list = createCookieList(); + expect(list.get('session')).toBe('abc123'); + expect(list.all()).toHaveLength(3); + expect(list.count()).toBe(3); + }); + + test('normalizes tough-cookie objects to plain objects', () => { + const toughCookies = [ + { key: 'a', value: '1', domain: 'example.com', path: '/', secure: true, httpOnly: false, expires: null, _internal: 'hidden', creation: new Date(), store: { circular: true } } + ]; + const list = createCookieList({ + getCookiesForUrl: () => toughCookies + }); + const items = list.all(); + expect(items).toHaveLength(1); + expect(items[0]).toEqual({ key: 'a', value: '1', domain: 'example.com', path: '/', secure: true, httpOnly: false, expires: null }); + expect(items[0]).not.toHaveProperty('_internal'); + expect(items[0]).not.toHaveProperty('creation'); + expect(items[0]).not.toHaveProperty('store'); + }); + + test('toJSON() returns cloned array of normalized cookies', () => { + const list = createCookieList(); + const json = list.toJSON(); + expect(json).toHaveLength(3); + expect(json[0]).toEqual({ key: 'session', value: 'abc123' }); + }); + + // ── Read methods with no URL ─────────────────────────────────────────── + + describe('read methods when URL is falsy', () => { + let list; + + beforeEach(() => { + list = createCookieList({ getUrl: () => null }); + }); + + test('all() returns empty array', () => { + expect(list.all()).toEqual([]); + }); + + test('count() returns 0', () => { + expect(list.count()).toBe(0); + }); + + test('get() returns undefined', () => { + expect(list.get('session')).toBeUndefined(); + }); + + test('has() returns false', () => { + expect(list.has('session')).toBe(false); + }); + }); + + // ── Write methods (cookie jar delegation) ────────────────────────────── + + describe('add()', () => { + test('delegates to jar.setCookie', () => { + const setCookie = jest.fn().mockResolvedValue('ok'); + const list = createCookieList({ + createCookieJar: () => ({ setCookie }) + }); + + list.add({ name: 'foo', value: 'bar' }); + expect(setCookie).toHaveBeenCalledWith('https://example.com', { name: 'foo', value: 'bar' }, undefined); + }); + + test('passes callback to jar.setCookie', () => { + const setCookie = jest.fn(); + const cb = jest.fn(); + const list = createCookieList({ + createCookieJar: () => ({ setCookie }) + }); + + list.add({ name: 'foo', value: 'bar' }, cb); + expect(setCookie).toHaveBeenCalledWith('https://example.com', { name: 'foo', value: 'bar' }, cb); + }); + + test('returns Promise.resolve() when no URL', async () => { + const list = createCookieList({ getUrl: () => null }); + const result = list.add({ name: 'foo', value: 'bar' }); + await expect(result).resolves.toBeUndefined(); + }); + + test('calls callback with undefined when no URL', () => { + const cb = jest.fn(); + const list = createCookieList({ getUrl: () => '' }); + list.add({ name: 'foo', value: 'bar' }, cb); + expect(cb).toHaveBeenCalledWith(undefined); + }); + }); + + describe('upsert()', () => { + test('delegates to jar.setCookie', () => { + const setCookie = jest.fn().mockResolvedValue('ok'); + const list = createCookieList({ + createCookieJar: () => ({ setCookie }) + }); + + list.upsert({ name: 'foo', value: 'bar' }); + expect(setCookie).toHaveBeenCalledWith('https://example.com', { name: 'foo', value: 'bar' }, undefined); + }); + + test('returns Promise.resolve() when no URL', async () => { + const list = createCookieList({ getUrl: () => null }); + const result = list.upsert({ name: 'foo', value: 'bar' }); + await expect(result).resolves.toBeUndefined(); + }); + + test('rejects with error when cookieObj is null', async () => { + const list = createCookieList(); + await expect(list.upsert(null)).rejects.toThrow('cookieObj must be a non-null object'); + }); + + test('rejects with error when cookieObj is a string', async () => { + const list = createCookieList(); + await expect(list.upsert('not-an-object')).rejects.toThrow('cookieObj must be a non-null object'); + }); + + test('calls callback with error when cookieObj is null', () => { + const cb = jest.fn(); + const list = createCookieList(); + list.upsert(null, cb); + expect(cb).toHaveBeenCalledWith(expect.any(Error)); + expect(cb.mock.calls[0][0].message).toBe('cookieObj must be a non-null object'); + }); + }); + + describe('remove()', () => { + test('delegates to jar.deleteCookie', () => { + const deleteCookie = jest.fn().mockResolvedValue('ok'); + const list = createCookieList({ + createCookieJar: () => ({ deleteCookie }) + }); + + list.remove('session'); + expect(deleteCookie).toHaveBeenCalledWith('https://example.com', 'session', undefined); + }); + + test('passes callback', () => { + const deleteCookie = jest.fn(); + const cb = jest.fn(); + const list = createCookieList({ + createCookieJar: () => ({ deleteCookie }) + }); + + list.remove('session', cb); + expect(deleteCookie).toHaveBeenCalledWith('https://example.com', 'session', cb); + }); + + test('returns Promise.resolve() when no URL', async () => { + const list = createCookieList({ getUrl: () => null }); + const result = list.remove('session'); + await expect(result).resolves.toBeUndefined(); + }); + + test('returns Promise.resolve() when no name', async () => { + const list = createCookieList(); + const result = list.remove(null); + await expect(result).resolves.toBeUndefined(); + }); + + test('calls callback when no name', () => { + const cb = jest.fn(); + const list = createCookieList(); + list.remove('', cb); + expect(cb).toHaveBeenCalledWith(undefined); + }); + }); + + describe('clear()', () => { + test('delegates to jar.deleteCookies', () => { + const deleteCookies = jest.fn().mockResolvedValue('ok'); + const list = createCookieList({ + createCookieJar: () => ({ deleteCookies }) + }); + + list.clear(); + expect(deleteCookies).toHaveBeenCalledWith('https://example.com', undefined); + }); + + test('passes callback', () => { + const deleteCookies = jest.fn(); + const cb = jest.fn(); + const list = createCookieList({ + createCookieJar: () => ({ deleteCookies }) + }); + + list.clear(cb); + expect(deleteCookies).toHaveBeenCalledWith('https://example.com', cb); + }); + + test('returns Promise.resolve() when no URL', async () => { + const list = createCookieList({ getUrl: () => null }); + const result = list.clear(); + await expect(result).resolves.toBeUndefined(); + }); + }); + + describe('delete()', () => { + test('delegates to jar.deleteCookie', () => { + const deleteCookie = jest.fn().mockResolvedValue('ok'); + const list = createCookieList({ + createCookieJar: () => ({ deleteCookie }) + }); + + list.delete('session'); + expect(deleteCookie).toHaveBeenCalledWith('https://example.com', 'session', undefined); + }); + + test('returns Promise.resolve() when no URL', async () => { + const list = createCookieList({ getUrl: () => null }); + const result = list.delete('session'); + await expect(result).resolves.toBeUndefined(); + }); + + test('returns Promise.resolve() when no name', async () => { + const list = createCookieList(); + const result = list.delete(null); + await expect(result).resolves.toBeUndefined(); + }); + }); + + // ── jar() ────────────────────────────────────────────────────────────── + + describe('jar()', () => { + let mockJar; + let list; + + beforeEach(() => { + mockJar = { + getCookie: jest.fn().mockResolvedValue({ key: 'session', value: 'abc' }), + getCookies: jest.fn().mockResolvedValue([]), + setCookie: jest.fn().mockResolvedValue('ok'), + setCookies: jest.fn().mockResolvedValue('ok'), + clear: jest.fn().mockResolvedValue('ok'), + deleteCookies: jest.fn().mockResolvedValue('ok'), + deleteCookie: jest.fn().mockResolvedValue('ok'), + hasCookie: jest.fn().mockResolvedValue(true) + }; + list = createCookieList({ + interpolate: (str) => str.replace('{{host}}', 'example.com'), + createCookieJar: () => mockJar + }); + }); + + test('returns an object with all jar methods', () => { + const jar = list.jar(); + expect(jar).toHaveProperty('getCookie'); + expect(jar).toHaveProperty('getCookies'); + expect(jar).toHaveProperty('setCookie'); + expect(jar).toHaveProperty('setCookies'); + expect(jar).toHaveProperty('clear'); + expect(jar).toHaveProperty('deleteCookies'); + expect(jar).toHaveProperty('deleteCookie'); + expect(jar).toHaveProperty('hasCookie'); + }); + + test('getCookie interpolates URL and delegates', () => { + const jar = list.jar(); + const cb = jest.fn(); + jar.getCookie('https://{{host}}/path', 'session', cb); + expect(mockJar.getCookie).toHaveBeenCalledWith('https://example.com/path', 'session', cb); + }); + + test('getCookies interpolates URL and delegates', () => { + const jar = list.jar(); + const cb = jest.fn(); + jar.getCookies('https://{{host}}/path', cb); + expect(mockJar.getCookies).toHaveBeenCalledWith('https://example.com/path', cb); + }); + + test('setCookie interpolates URL and delegates', () => { + const jar = list.jar(); + const cb = jest.fn(); + jar.setCookie('https://{{host}}/path', 'name', 'value', cb); + expect(mockJar.setCookie).toHaveBeenCalledWith('https://example.com/path', 'name', 'value', cb); + }); + + test('setCookies interpolates URL and delegates', () => { + const jar = list.jar(); + const cb = jest.fn(); + jar.setCookies('https://{{host}}/path', [{ name: 'a' }], cb); + expect(mockJar.setCookies).toHaveBeenCalledWith('https://example.com/path', [{ name: 'a' }], cb); + }); + + test('clear delegates directly', () => { + const jar = list.jar(); + const cb = jest.fn(); + jar.clear(cb); + expect(mockJar.clear).toHaveBeenCalledWith(cb); + }); + + test('deleteCookies interpolates URL and delegates', () => { + const jar = list.jar(); + const cb = jest.fn(); + jar.deleteCookies('https://{{host}}/path', cb); + expect(mockJar.deleteCookies).toHaveBeenCalledWith('https://example.com/path', cb); + }); + + test('deleteCookie interpolates URL and delegates', () => { + const jar = list.jar(); + const cb = jest.fn(); + jar.deleteCookie('https://{{host}}/path', 'session', cb); + expect(mockJar.deleteCookie).toHaveBeenCalledWith('https://example.com/path', 'session', cb); + }); + + test('hasCookie interpolates URL and delegates', () => { + const jar = list.jar(); + const cb = jest.fn(); + jar.hasCookie('https://{{host}}/path', 'session', cb); + expect(mockJar.hasCookie).toHaveBeenCalledWith('https://example.com/path', 'session', cb); + }); + }); +}); diff --git a/packages/bruno-js/tests/property-list.spec.js b/packages/bruno-js/tests/property-list.spec.js new file mode 100644 index 000000000..2efa5f9c0 --- /dev/null +++ b/packages/bruno-js/tests/property-list.spec.js @@ -0,0 +1,237 @@ +const PropertyList = require('../src/property-list'); +const ReadOnlyPropertyList = require('../src/readonly-property-list'); + +describe('PropertyList', () => { + // ── Inheritance ─────────────────────────────────────────────────────── + + test('instanceof ReadOnlyPropertyList', () => { + const list = new PropertyList({ items: [] }); + expect(list).toBeInstanceOf(ReadOnlyPropertyList); + expect(list).toBeInstanceOf(PropertyList); + }); + + test('isPropertyList() returns true', () => { + const list = new PropertyList({ items: [] }); + expect(ReadOnlyPropertyList.isPropertyList(list)).toBe(true); + }); + + // ── Static Mode Mutations ───────────────────────────────────────────── + + describe('static mode', () => { + let list; + + beforeEach(() => { + list = new PropertyList({ + items: [ + { key: 'a', value: '1' }, + { key: 'b', value: '2' }, + { key: 'c', value: '3' } + ] + }); + }); + + // ── add / append ── + + test('add() appends item to end', () => { + list.add({ key: 'd', value: '4' }); + expect(list.count()).toBe(4); + expect(list.idx(3)).toEqual({ key: 'd', value: '4' }); + }); + + test('append() is alias for add()', () => { + list.append({ key: 'd', value: '4' }); + expect(list.count()).toBe(4); + expect(list.idx(3)).toEqual({ key: 'd', value: '4' }); + }); + + // ── prepend ── + + test('prepend() inserts item at beginning', () => { + list.prepend({ key: 'z', value: '0' }); + expect(list.count()).toBe(4); + expect(list.idx(0)).toEqual({ key: 'z', value: '0' }); + expect(list.idx(1)).toEqual({ key: 'a', value: '1' }); + }); + + // ── insert ── + + test('insert() before key string', () => { + list.insert({ key: 'x', value: '9' }, 'b'); + expect(list.count()).toBe(4); + expect(list.idx(1)).toEqual({ key: 'x', value: '9' }); + expect(list.idx(2)).toEqual({ key: 'b', value: '2' }); + }); + + test('insert() before item object', () => { + list.insert({ key: 'x', value: '9' }, { key: 'c', value: '3' }); + expect(list.count()).toBe(4); + expect(list.idx(2)).toEqual({ key: 'x', value: '9' }); + expect(list.idx(3)).toEqual({ key: 'c', value: '3' }); + }); + + test('insert() appends when reference not found', () => { + list.insert({ key: 'x', value: '9' }, 'missing'); + expect(list.count()).toBe(4); + expect(list.idx(3)).toEqual({ key: 'x', value: '9' }); + }); + + // ── insertAfter ── + + test('insertAfter() after key string', () => { + list.insertAfter({ key: 'x', value: '9' }, 'a'); + expect(list.count()).toBe(4); + expect(list.idx(0)).toEqual({ key: 'a', value: '1' }); + expect(list.idx(1)).toEqual({ key: 'x', value: '9' }); + expect(list.idx(2)).toEqual({ key: 'b', value: '2' }); + }); + + test('insertAfter() after item object', () => { + list.insertAfter({ key: 'x', value: '9' }, { key: 'b', value: '2' }); + expect(list.count()).toBe(4); + expect(list.idx(1)).toEqual({ key: 'b', value: '2' }); + expect(list.idx(2)).toEqual({ key: 'x', value: '9' }); + }); + + test('insertAfter() appends when reference not found', () => { + list.insertAfter({ key: 'x', value: '9' }, 'missing'); + expect(list.count()).toBe(4); + expect(list.idx(3)).toEqual({ key: 'x', value: '9' }); + }); + + // ── remove ── + + test('remove() by predicate', () => { + list.remove((item) => item.value === '2'); + expect(list.count()).toBe(2); + expect(list.has('b')).toBe(false); + }); + + test('remove() by key string', () => { + list.remove('a'); + expect(list.count()).toBe(2); + expect(list.has('a')).toBe(false); + }); + + test('remove() by item object', () => { + list.remove({ key: 'c', value: '3' }); + expect(list.count()).toBe(2); + expect(list.has('c')).toBe(false); + }); + + test('remove() by item object that does not exist is no-op', () => { + list.remove({ key: 'missing', value: '0' }); + expect(list.count()).toBe(3); + }); + + // ── clear ── + + test('clear() empties the list', () => { + list.clear(); + expect(list.count()).toBe(0); + expect(list.all()).toEqual([]); + }); + + // ── upsert ── + + test('upsert() updates existing item by key', () => { + list.upsert({ key: 'b', value: 'updated' }); + expect(list.count()).toBe(3); + expect(list.get('b')).toBe('updated'); + }); + + test('upsert() appends new item when key not found', () => { + list.upsert({ key: 'd', value: '4' }); + expect(list.count()).toBe(4); + expect(list.get('d')).toBe('4'); + }); + + // ── populate ── + + test('populate() replaces all items', () => { + list.populate([{ key: 'x', value: '10' }]); + expect(list.count()).toBe(1); + expect(list.get('x')).toBe('10'); + }); + + test('populate() with non-array sets empty list', () => { + list.populate(null); + expect(list.count()).toBe(0); + }); + + // ── repopulate ── + + test('repopulate() clears and replaces', () => { + list.repopulate([{ key: 'y', value: '20' }]); + expect(list.count()).toBe(1); + expect(list.get('y')).toBe('20'); + }); + + test('repopulate() with non-array sets empty list', () => { + list.repopulate(undefined); + expect(list.count()).toBe(0); + }); + + // ── assimilate ── + + test('assimilate() merges from array', () => { + list.assimilate([{ key: 'd', value: '4' }]); + expect(list.count()).toBe(4); + expect(list.get('d')).toBe('4'); + }); + + test('assimilate() merges from PropertyList', () => { + const source = new PropertyList({ + items: [{ key: 'd', value: '4' }] + }); + list.assimilate(source); + expect(list.count()).toBe(4); + expect(list.get('d')).toBe('4'); + }); + + test('assimilate() with prune clears first', () => { + list.assimilate([{ key: 'x', value: '10' }], true); + expect(list.count()).toBe(1); + expect(list.get('x')).toBe('10'); + }); + + test('assimilate() with invalid source is no-op', () => { + list.assimilate('not-a-list'); + expect(list.count()).toBe(3); + }); + }); + + // ── Dynamic Mode ────────────────────────────────────────────────────── + + describe('dynamic mode', () => { + let list; + + beforeEach(() => { + list = new PropertyList({ + dataSource: () => [{ key: 'x', value: '10' }] + }); + }); + + test.each([ + ['add', 'add'], + ['append', 'add'], + ['prepend', 'prepend'], + ['insert', 'insert'], + ['insertAfter', 'insertAfter'], + ['remove', 'remove'], + ['clear', 'clear'], + ['upsert', 'upsert'], + ['populate', 'populate'], + ['repopulate', 'repopulate'], + ['assimilate', 'assimilate'] + ])('%s() throws in dynamic mode', (method, errorMethod) => { + expect(() => list[method]({ key: 'a', value: '1' })).toThrow( + `${errorMethod}() is not supported in dynamic mode. Override in subclass.` + ); + }); + + test('read methods still work', () => { + expect(list.get('x')).toBe('10'); + expect(list.count()).toBe(1); + }); + }); +}); diff --git a/packages/bruno-js/tests/readonly-property-list.spec.js b/packages/bruno-js/tests/readonly-property-list.spec.js new file mode 100644 index 000000000..730f3238a --- /dev/null +++ b/packages/bruno-js/tests/readonly-property-list.spec.js @@ -0,0 +1,225 @@ +const ReadOnlyPropertyList = require('../src/readonly-property-list'); + +describe('ReadOnlyPropertyList', () => { + // ── Static Mode ────────────────────────────────────────────────────── + + describe('static mode', () => { + let list; + + beforeEach(() => { + list = new ReadOnlyPropertyList({ + keyProperty: 'key', + items: [ + { key: 'a', value: '1' }, + { key: 'b', value: '2' }, + { key: 'c', value: '3' } + ] + }); + }); + + test('get() returns value by key', () => { + expect(list.get('a')).toBe('1'); + expect(list.get('missing')).toBeUndefined(); + }); + + test('one() returns full item by key', () => { + expect(list.one('b')).toEqual({ key: 'b', value: '2' }); + }); + + test('all() returns cloned array', () => { + const items = list.all(); + expect(items).toHaveLength(3); + items.push({ key: 'd', value: '4' }); + expect(list.count()).toBe(3); + }); + + test('idx() returns item by position', () => { + expect(list.idx(0)).toEqual({ key: 'a', value: '1' }); + expect(list.idx(10)).toBeUndefined(); + }); + + test('count() returns number of items', () => { + expect(list.count()).toBe(3); + }); + + test('has() checks existence', () => { + expect(list.has('a')).toBe(true); + expect(list.has('a', '1')).toBe(true); + expect(list.has('a', 'wrong')).toBe(false); + expect(list.has('missing')).toBe(false); + }); + + test('toObject() converts to key-value object', () => { + expect(list.toObject()).toEqual({ a: '1', b: '2', c: '3' }); + }); + + test('toString() converts to string', () => { + expect(list.toString()).toBe('a=1; b=2; c=3'); + }); + + test('toJSON() returns cloned array of all items', () => { + const json = list.toJSON(); + expect(json).toEqual([ + { key: 'a', value: '1' }, + { key: 'b', value: '2' }, + { key: 'c', value: '3' } + ]); + json.push({ key: 'd', value: '4' }); + expect(list.count()).toBe(3); + }); + + test('indexOf() finds item by structural equality', () => { + expect(list.indexOf({ key: 'b', value: '2' })).toBe(1); + expect(list.indexOf({ key: 'missing', value: '0' })).toBe(-1); + }); + + test('indexOf() returns -1 for non-object input', () => { + expect(list.indexOf(null)).toBe(-1); + expect(list.indexOf('string')).toBe(-1); + }); + + test('find() returns first matching item', () => { + const item = list.find((i) => i.value === '2'); + expect(item).toEqual({ key: 'b', value: '2' }); + }); + + test('find() returns undefined when no match', () => { + expect(list.find((i) => i.value === 'missing')).toBeUndefined(); + }); + + test('filter() returns matching items', () => { + const items = list.filter((i) => i.value !== '2'); + expect(items).toHaveLength(2); + expect(items[0].key).toBe('a'); + expect(items[1].key).toBe('c'); + }); + + test('each() iterates over all items', () => { + const keys = []; + list.each((item) => keys.push(item.key)); + expect(keys).toEqual(['a', 'b', 'c']); + }); + + test('map() transforms items', () => { + const values = list.map((item) => item.value); + expect(values).toEqual(['1', '2', '3']); + }); + + test('reduce() accumulates values', () => { + const sum = list.reduce((acc, item) => acc + item.value, ''); + expect(sum).toBe('123'); + }); + + test('reduce() without initial value uses first element as accumulator', () => { + const numList = new ReadOnlyPropertyList({ + items: [ + { key: 'a', value: 1 }, + { key: 'b', value: 2 }, + { key: 'c', value: 3 } + ] + }); + const result = numList.reduce((acc, item) => acc + item.value); + // First element becomes accumulator: { key: 'a', value: 1 } + 2 + 3 + expect(result).toEqual({ key: 'a', value: 1 } + 2 + 3); + }); + + test('reduce() without initial value on single-element list returns that element', () => { + const singleList = new ReadOnlyPropertyList({ + items: [{ key: 'only', value: 42 }] + }); + const result = singleList.reduce((acc, item) => acc + item.value); + expect(result).toEqual({ key: 'only', value: 42 }); + }); + + test('reduce() without initial value on empty list throws TypeError', () => { + const emptyList = new ReadOnlyPropertyList({ items: [] }); + expect(() => emptyList.reduce((acc, item) => acc + item.value)).toThrow(TypeError); + }); + + test('get() returns last value when duplicate keys exist, consistent with toObject()', () => { + const dupList = new ReadOnlyPropertyList({ + items: [ + { key: 'x', value: 'first' }, + { key: 'x', value: 'second' } + ] + }); + expect(dupList.get('x')).toBe('second'); + expect(dupList.toObject().x).toBe('second'); + }); + }); + + // ── Dynamic Mode ───────────────────────────────────────────────────── + + describe('dynamic mode', () => { + test('reads from dataSource on every call', () => { + let callCount = 0; + const list = new ReadOnlyPropertyList({ + dataSource: () => { + callCount++; + return [ + { key: 'x', value: '10' }, + { key: 'y', value: '20' } + ]; + } + }); + + expect(list.get('x')).toBe('10'); + expect(list.count()).toBe(2); + expect(list.all()).toHaveLength(2); + expect(callCount).toBe(3); + }); + }); + + // ── No Mutation Methods ────────────────────────────────────────────── + + describe('does not have mutation methods', () => { + const list = new ReadOnlyPropertyList({ items: [] }); + + test.each([ + 'add', 'prepend', 'insert', 'insertAfter', + 'upsert', 'remove', 'clear', + 'populate', 'repopulate', 'assimilate' + ])('%s is not defined', (method) => { + expect(list[method]).toBeUndefined(); + }); + }); + + // ── Edge Cases ─────────────────────────────────────────────────────── + + describe('edge cases', () => { + test('empty list', () => { + const list = new ReadOnlyPropertyList({ items: [] }); + expect(list.count()).toBe(0); + expect(list.toObject()).toEqual({}); + }); + + test('default constructor', () => { + const list = new ReadOnlyPropertyList(); + expect(list.count()).toBe(0); + }); + + test('custom keyProperty', () => { + const list = new ReadOnlyPropertyList({ + keyProperty: 'name', + items: [{ name: 'Content-Type', value: 'application/json' }] + }); + expect(list.get('Content-Type')).toBe('application/json'); + }); + }); + + // ── Static Methods ────────────────────────────────────────────────── + + describe('static methods', () => { + test('isPropertyList() returns true for ReadOnlyPropertyList', () => { + const list = new ReadOnlyPropertyList({ items: [] }); + expect(ReadOnlyPropertyList.isPropertyList(list)).toBe(true); + }); + + test('isPropertyList() returns false for plain objects', () => { + expect(ReadOnlyPropertyList.isPropertyList({})).toBe(false); + expect(ReadOnlyPropertyList.isPropertyList(null)).toBe(false); + expect(ReadOnlyPropertyList.isPropertyList([])).toBe(false); + expect(ReadOnlyPropertyList.isPropertyList('string')).toBe(false); + }); + }); +}); diff --git a/packages/bruno-tests/collection/response-parsing/test plain text response.bru b/packages/bruno-tests/collection/response-parsing/test plain text response.bru index fbb884483..7fcc725f2 100644 --- a/packages/bruno-tests/collection/response-parsing/test plain text response.bru +++ b/packages/bruno-tests/collection/response-parsing/test plain text response.bru @@ -20,4 +20,3 @@ body:json { assert { res.body: eq hello } - diff --git a/packages/bruno-tests/collection/scripting/api/bru/cookies/clear.bru b/packages/bruno-tests/collection/scripting/api/bru/cookies/clear.bru index 2f0000b3d..9e74deeb9 100644 --- a/packages/bruno-tests/collection/scripting/api/bru/cookies/clear.bru +++ b/packages/bruno-tests/collection/scripting/api/bru/cookies/clear.bru @@ -5,7 +5,7 @@ meta { } get { - url: {{host}}/ping + url: {{localhost}}/ping body: none auth: inherit } diff --git a/packages/bruno-tests/collection/scripting/api/bru/cookies/clearScope.bru b/packages/bruno-tests/collection/scripting/api/bru/cookies/clearScope.bru new file mode 100644 index 000000000..7f5ab4a22 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/api/bru/cookies/clearScope.bru @@ -0,0 +1,77 @@ +meta { + name: clearScope + type: http + seq: 14 +} + +get { + url: {{localhost}}/ping + body: none + auth: inherit +} + +script:pre-request { + const jar = bru.cookies.jar() + await jar.clear(); + + // Set cookies on two different domains: + // - localhost matches the request URL, so bru.cookies.clear() targets it + // - other.example.com should be left untouched by bru.cookies.clear() + const host = bru.getEnvVar('localhost'); + await jar.setCookies(host, [ + { key: "a", value: "1", path: "/" }, + { key: "b", value: "2", path: "/" } + ]); + + await jar.setCookie("https://other.example.com", { + key: "c", + value: "3", + path: "/", + secure: true + }); +} + +tests { + const jar = bru.cookies.jar() + const host = bru.getEnvVar('localhost'); + + // bru.cookies sees only the current request URL's cookies + await test("bru.cookies.clear() only clears cookies for current URL", async function() { + // Verify cookies exist before clear + const before = await jar.getCookies(host); + expect(before.length).to.be.at.least(1); + + // bru.cookies.clear() clears only current request URL cookies + await bru.cookies.clear(); + + // Current URL's cookies should be gone + const after = await jar.getCookies(host); + expect(after.length).to.equal(0); + + // Other domain cookies should still exist + const otherDomain = await jar.getCookies("https://other.example.com"); + expect(otherDomain.length).to.be.at.least(1); + expect(otherDomain[0].key).to.equal("c"); + }); + + await test("jar.clear() clears ALL cookies globally", async function() { + // Re-add a cookie on main domain + await jar.setCookie(host, { + key: "d", + value: "4", + path: "/" + }); + + // jar.clear() clears everything + await jar.clear(); + + const main = await jar.getCookies(host); + const other = await jar.getCookies("https://other.example.com"); + expect(main.length).to.equal(0); + expect(other.length).to.equal(0); + }); +} + +settings { + encodeUrl: true +} diff --git a/packages/bruno-tests/collection/scripting/api/bru/cookies/crossDomainIsolation.bru b/packages/bruno-tests/collection/scripting/api/bru/cookies/crossDomainIsolation.bru new file mode 100644 index 000000000..97328df09 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/api/bru/cookies/crossDomainIsolation.bru @@ -0,0 +1,69 @@ +meta { + name: crossDomainIsolation + type: http + seq: 15 +} + +get { + url: {{localhost}}/ping + body: none + auth: inherit +} + +script:pre-request { + const jar = bru.cookies.jar() + await jar.clear(); + + await jar.setCookie("https://testbench-sanity.usebruno.com", { + key: "domainA", + value: "valueA", + path: "/", + secure: true + }); + + await jar.setCookie("https://other.example.com", { + key: "domainB", + value: "valueB", + path: "/", + secure: true + }); +} + +tests { + const jar = bru.cookies.jar() + + await test("cookies on domain A are not visible on domain B", async function() { + const cookiesA = await jar.getCookies("https://testbench-sanity.usebruno.com"); + const cookiesB = await jar.getCookies("https://other.example.com"); + + const keysA = cookiesA.map(function(c) { return c.key; }); + const keysB = cookiesB.map(function(c) { return c.key; }); + + expect(keysA).to.include("domainA"); + expect(keysA).to.not.include("domainB"); + + expect(keysB).to.include("domainB"); + expect(keysB).to.not.include("domainA"); + }); + + await test("bru.cookies.get() only returns cookies for the current request URL", function() { + // bru.cookies reads from the current request's URL + // domainB cookie should not be accessible via bru.cookies + expect(bru.cookies.has("domainB")).to.be.false; + }); + + await test("jar.getCookie() respects domain isolation", async function() { + const cookieA = await jar.getCookie("https://testbench-sanity.usebruno.com", "domainA"); + const cookieBOnA = await jar.getCookie("https://testbench-sanity.usebruno.com", "domainB"); + + expect(cookieA).to.not.be.null; + expect(cookieA.value).to.equal("valueA"); + expect(cookieBOnA).to.be.null; + }); + + await jar.clear() +} + +settings { + encodeUrl: true +} diff --git a/packages/bruno-tests/collection/scripting/api/bru/cookies/deleteCookie.bru b/packages/bruno-tests/collection/scripting/api/bru/cookies/deleteCookie.bru index d1d1da1c2..222753cab 100644 --- a/packages/bruno-tests/collection/scripting/api/bru/cookies/deleteCookie.bru +++ b/packages/bruno-tests/collection/scripting/api/bru/cookies/deleteCookie.bru @@ -5,7 +5,7 @@ meta { } get { - url: {{host}}/ping + url: {{localhost}}/ping body: none auth: inherit } diff --git a/packages/bruno-tests/collection/scripting/api/bru/cookies/deleteCookies.bru b/packages/bruno-tests/collection/scripting/api/bru/cookies/deleteCookies.bru index 03e604e8c..3ca762086 100644 --- a/packages/bruno-tests/collection/scripting/api/bru/cookies/deleteCookies.bru +++ b/packages/bruno-tests/collection/scripting/api/bru/cookies/deleteCookies.bru @@ -5,7 +5,7 @@ meta { } get { - url: {{host}}/ping + url: {{localhost}}/ping body: none auth: inherit } diff --git a/packages/bruno-tests/collection/scripting/api/bru/cookies/directGet.bru b/packages/bruno-tests/collection/scripting/api/bru/cookies/directGet.bru new file mode 100644 index 000000000..cedc957b1 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/api/bru/cookies/directGet.bru @@ -0,0 +1,55 @@ +meta { + name: directGet + type: http + seq: 10 +} + +get { + url: {{localhost}}/ping + body: none + auth: inherit +} + +script:pre-request { + const jar = bru.cookies.jar() + + const host = bru.getEnvVar('localhost'); + await jar.setCookie(host, { + key: 'session', + value: 'abc123', + path: '/' + }); +} + +tests { + test("get() should return value for existing cookie", function() { + expect(bru.cookies.get('session')).to.equal('abc123'); + }); + + test("get() should return undefined for nonexistent cookie", function() { + expect(bru.cookies.get('nonexistent')).to.be.undefined; + }); + + test("has() should return true for existing cookie", function() { + expect(bru.cookies.has('session')).to.be.true; + }); + + test("has() should return false for nonexistent cookie", function() { + expect(bru.cookies.has('nonexistent')).to.be.false; + }); + + test("has() with value should return true when value matches", function() { + expect(bru.cookies.has('session', 'abc123')).to.be.true; + }); + + test("has() with value should return false when value does not match", function() { + expect(bru.cookies.has('session', 'wrong')).to.be.false; + }); + + const jar = bru.cookies.jar() + await jar.clear() +} + +settings { + encodeUrl: true +} diff --git a/packages/bruno-tests/collection/scripting/api/bru/cookies/directIteration.bru b/packages/bruno-tests/collection/scripting/api/bru/cookies/directIteration.bru new file mode 100644 index 000000000..b45da36b9 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/api/bru/cookies/directIteration.bru @@ -0,0 +1,94 @@ +meta { + name: directIteration + type: http + seq: 12 +} + +get { + url: {{localhost}}/ping + body: none + auth: inherit +} + +script:pre-request { + const jar = bru.cookies.jar() + + const host = bru.getEnvVar('localhost'); + await jar.setCookies(host, [ + { + key: 'x', + value: '10', + path: '/' + }, + { + key: 'y', + value: '20', + path: '/' + } + ]); +} + +tests { + test("each() should iterate over all cookies", function() { + const keys = []; + bru.cookies.each(function(cookie) { + keys.push(cookie.key); + }); + expect(keys).to.include('x'); + expect(keys).to.include('y'); + }); + + test("find() should return matching cookie", function() { + const cookie = bru.cookies.find(function(c) { return c.key === 'x'; }); + expect(cookie).to.have.property('value', '10'); + }); + + test("find() should return undefined when no match", function() { + const cookie = bru.cookies.find(function(c) { return c.key === 'zzz'; }); + expect(cookie).to.be.undefined; + }); + + test("filter() should return array of matching cookies", function() { + const result = bru.cookies.filter(function(c) { return c.key === 'y'; }); + expect(result).to.be.an('array'); + expect(result.length).to.equal(1); + expect(result[0]).to.have.property('value', '20'); + }); + + test("map() should transform cookies", function() { + const keys = bru.cookies.map(function(c) { return c.key; }); + expect(keys).to.be.an('array'); + expect(keys).to.include('x'); + expect(keys).to.include('y'); + }); + + test("reduce() should accumulate over cookies", function() { + const result = bru.cookies.reduce(function(acc, c) { return acc + c.key + ','; }, ''); + expect(result).to.be.a('string'); + expect(result).to.contain('x,'); + expect(result).to.contain('y,'); + }); + + test("indexOf() should find a cookie by structural equality", function() { + const cookie = bru.cookies.one('x'); + const idx = bru.cookies.indexOf(cookie); + expect(idx).to.be.at.least(0); + }); + + test("indexOf() should return -1 for non-existent cookie", function() { + const idx = bru.cookies.indexOf({ key: 'nonexistent', value: 'nope' }); + expect(idx).to.equal(-1); + }); + + test("idx() out-of-bounds should return undefined", function() { + const result = bru.cookies.idx(999); + expect(result).to.be.undefined; + }); + + const jar = bru.cookies.jar() + await jar.clear() +} + +settings { + encodeUrl: true +} diff --git a/packages/bruno-tests/collection/scripting/api/bru/cookies/directReadMethods.bru b/packages/bruno-tests/collection/scripting/api/bru/cookies/directReadMethods.bru new file mode 100644 index 000000000..f195eed4c --- /dev/null +++ b/packages/bruno-tests/collection/scripting/api/bru/cookies/directReadMethods.bru @@ -0,0 +1,85 @@ +meta { + name: directReadMethods + type: http + seq: 11 +} + +get { + url: {{localhost}}/ping + body: none + auth: inherit +} + +script:pre-request { + const jar = bru.cookies.jar() + + const host = bru.getEnvVar('localhost'); + await jar.setCookies(host, [ + { + key: 'alpha', + value: 'one', + path: '/' + }, + { + key: 'beta', + value: 'two', + path: '/' + } + ]); +} + +tests { + test("one() should return full cookie object by name", function() { + const cookie = bru.cookies.one('alpha'); + expect(cookie).to.have.property('key', 'alpha'); + expect(cookie).to.have.property('value', 'one'); + }); + + test("one() should return undefined for nonexistent cookie", function() { + expect(bru.cookies.one('nonexistent')).to.be.undefined; + }); + + test("all() should return an array with at least 2 items", function() { + const cookies = bru.cookies.all(); + expect(cookies).to.be.an('array'); + expect(cookies.length).to.be.at.least(2); + }); + + test("count() should be at least 2", function() { + expect(bru.cookies.count()).to.be.at.least(2); + }); + + test("idx() should return a cookie object at index 0", function() { + const cookie = bru.cookies.idx(0); + expect(cookie).to.have.property('key'); + expect(cookie).to.have.property('value'); + }); + + test("toObject() should return object with cookie key-value pairs", function() { + const obj = bru.cookies.toObject(); + expect(obj).to.be.an('object'); + expect(obj).to.have.property('alpha', 'one'); + expect(obj).to.have.property('beta', 'two'); + }); + + test("toString() should return a string containing cookie pairs", function() { + const str = bru.cookies.toString(); + expect(str).to.be.a('string'); + expect(str).to.contain('alpha=one'); + }); + + test("toJSON() should return cloned array of all cookies", function() { + const json = bru.cookies.toJSON(); + expect(json).to.be.an('array'); + expect(json.length).to.be.at.least(2); + expect(json[0]).to.have.property('key'); + expect(json[0]).to.have.property('value'); + }); + + const jar = bru.cookies.jar() + await jar.clear() +} + +settings { + encodeUrl: true +} diff --git a/packages/bruno-tests/collection/scripting/api/bru/cookies/directWrite.bru b/packages/bruno-tests/collection/scripting/api/bru/cookies/directWrite.bru new file mode 100644 index 000000000..fab6f287b --- /dev/null +++ b/packages/bruno-tests/collection/scripting/api/bru/cookies/directWrite.bru @@ -0,0 +1,63 @@ +meta { + name: directWrite + type: http + seq: 13 +} + +get { + url: {{localhost}}/ping + body: none + auth: inherit +} + +script:pre-request { + const jar = bru.cookies.jar() + await jar.clear(); +} + +tests { + const jar = bru.cookies.jar() + + await bru.cookies.add({ key: 'a', value: '1' }); + test("add() should add a new cookie", function() { + expect(bru.cookies.get('a')).to.equal('1'); + }); + + await bru.cookies.upsert({ key: 'a', value: '2' }); + test("upsert() should update an existing cookie", function() { + expect(bru.cookies.get('a')).to.equal('2'); + }); + + await bru.cookies.upsert({ key: 'newCookie', value: 'inserted' }); + test("upsert() should insert a cookie that does not exist", function() { + expect(bru.cookies.has('newCookie')).to.be.true; + expect(bru.cookies.get('newCookie')).to.equal('inserted'); + }); + await bru.cookies.remove('newCookie'); + + await bru.cookies.remove('a'); + test("remove() should remove a cookie", function() { + expect(bru.cookies.has('a')).to.be.false; + }); + + await bru.cookies.add({ key: 'b', value: '3' }); + await bru.cookies.delete('b'); + test("delete() should delete a cookie", function() { + expect(bru.cookies.has('b')).to.be.false; + }); + + await bru.cookies.add({ key: 'c', value: '4' }); + test("clear() should remove all cookies after add", function() { + expect(bru.cookies.count()).to.be.at.least(1); + }); + await bru.cookies.clear(); + test("clear() should leave zero cookies", function() { + expect(bru.cookies.count()).to.equal(0); + }); + + await jar.clear() +} + +settings { + encodeUrl: true +} diff --git a/packages/bruno-tests/collection/scripting/api/bru/cookies/getCookie.bru b/packages/bruno-tests/collection/scripting/api/bru/cookies/getCookie.bru index ed5dd963f..d4e4071cb 100644 --- a/packages/bruno-tests/collection/scripting/api/bru/cookies/getCookie.bru +++ b/packages/bruno-tests/collection/scripting/api/bru/cookies/getCookie.bru @@ -5,7 +5,7 @@ meta { } get { - url: {{host}}/ping + url: {{localhost}}/ping body: none auth: inherit } diff --git a/packages/bruno-tests/collection/scripting/api/bru/cookies/getCookies.bru b/packages/bruno-tests/collection/scripting/api/bru/cookies/getCookies.bru index 7c09371c7..c942dc8fe 100644 --- a/packages/bruno-tests/collection/scripting/api/bru/cookies/getCookies.bru +++ b/packages/bruno-tests/collection/scripting/api/bru/cookies/getCookies.bru @@ -5,11 +5,21 @@ meta { } get { - url: {{host}}/ping + url: {{localhost}}/ping body: none auth: inherit } +script:pre-request { + const jar = bru.cookies.jar() + await jar.clear(); + + await jar.setCookies("https://testbench-sanity.usebruno.com", [ + { key: "session", value: "abc123", path: "/", secure: true }, + { key: "token", value: "xyz789", path: "/", secure: true } + ]); +} + tests { const jar = bru.cookies.jar() diff --git a/packages/bruno-tests/collection/scripting/api/bru/cookies/hasCookie.bru b/packages/bruno-tests/collection/scripting/api/bru/cookies/hasCookie.bru index a2b17fc13..be7c47417 100644 --- a/packages/bruno-tests/collection/scripting/api/bru/cookies/hasCookie.bru +++ b/packages/bruno-tests/collection/scripting/api/bru/cookies/hasCookie.bru @@ -5,7 +5,7 @@ meta { } get { - url: {{host}}/ping + url: {{localhost}}/ping body: none auth: inherit } diff --git a/packages/bruno-tests/collection/scripting/api/bru/cookies/setCookie.bru b/packages/bruno-tests/collection/scripting/api/bru/cookies/setCookie.bru index 5449a248a..004fb355d 100644 --- a/packages/bruno-tests/collection/scripting/api/bru/cookies/setCookie.bru +++ b/packages/bruno-tests/collection/scripting/api/bru/cookies/setCookie.bru @@ -5,7 +5,7 @@ meta { } get { - url: {{host}}/ping + url: {{localhost}}/ping body: none auth: inherit } diff --git a/packages/bruno-tests/collection/scripting/api/bru/cookies/setCookies.bru b/packages/bruno-tests/collection/scripting/api/bru/cookies/setCookies.bru index 87cefde76..18b5c4a6d 100644 --- a/packages/bruno-tests/collection/scripting/api/bru/cookies/setCookies.bru +++ b/packages/bruno-tests/collection/scripting/api/bru/cookies/setCookies.bru @@ -5,7 +5,7 @@ meta { } get { - url: {{host}}/ping + url: {{localhost}}/ping body: none auth: inherit } diff --git a/tests/scripting/bru-api/cookies/cookies.spec.ts b/tests/scripting/bru-api/cookies/cookies.spec.ts new file mode 100644 index 000000000..097d1976e --- /dev/null +++ b/tests/scripting/bru-api/cookies/cookies.spec.ts @@ -0,0 +1,26 @@ +import { test } from '../../../../playwright'; +import { setSandboxMode, runFolder, selectEnvironment, validateRunnerResults } from '../../../utils/page'; + +test.describe.serial('bru.cookies PropertyList API', () => { + test('all cookie tests pass in developer mode', async ({ pageWithUserData: page }) => { + await setSandboxMode(page, 'bruno-testbench', 'developer'); + await selectEnvironment(page, 'Local'); + await runFolder(page, 'bruno-testbench', ['scripting', 'api', 'bru', 'cookies']); + await validateRunnerResults(page, { + totalRequests: 16, + passed: 16, + failed: 0 + }); + }); + + test('all cookie tests pass in safe mode', async ({ pageWithUserData: page }) => { + await setSandboxMode(page, 'bruno-testbench', 'safe'); + await selectEnvironment(page, 'Local'); + await runFolder(page, 'bruno-testbench', ['scripting', 'api', 'bru', 'cookies']); + await validateRunnerResults(page, { + totalRequests: 16, + passed: 16, + failed: 0 + }); + }); +}); diff --git a/tests/scripting/bru-api/cookies/init-user-data/collection-security.json b/tests/scripting/bru-api/cookies/init-user-data/collection-security.json new file mode 100644 index 000000000..bf37743a7 --- /dev/null +++ b/tests/scripting/bru-api/cookies/init-user-data/collection-security.json @@ -0,0 +1,10 @@ +{ + "collections": [ + { + "path": "{{projectRoot}}/packages/bruno-tests/collection", + "securityConfig": { + "jsSandboxMode": "developer" + } + } + ] +} diff --git a/tests/scripting/bru-api/cookies/init-user-data/preferences.json b/tests/scripting/bru-api/cookies/init-user-data/preferences.json new file mode 100644 index 000000000..d6762015c --- /dev/null +++ b/tests/scripting/bru-api/cookies/init-user-data/preferences.json @@ -0,0 +1,12 @@ +{ + "maximized": false, + "lastOpenedCollections": [ + "{{projectRoot}}/packages/bruno-tests/collection" + ], + "preferences": { + "onboarding": { + "hasLaunchedBefore": true, + "hasSeenWelcomeModal": true + } + } +} diff --git a/tests/utils/page/runner.ts b/tests/utils/page/runner.ts index edeb76210..7e69504aa 100644 --- a/tests/utils/page/runner.ts +++ b/tests/utils/page/runner.ts @@ -114,6 +114,66 @@ export const runCollection = async (page: Page, collectionName: string) => { }); }; +/** + * Runs a specific folder within a collection by navigating to it in the sidebar, + * opening its context menu, and clicking "Run" followed by "Recursive Run". + * @param page - The Playwright page object + * @param collectionName - The name of the collection containing the folder + * @param folderPath - Array of folder names forming the path (e.g. ['scripting', 'api', 'bru', 'cookies']) + */ +export const runFolder = async (page: Page, collectionName: string, folderPath: string[]) => { + await test.step(`Run folder "${folderPath.join('/')}" in "${collectionName}"`, async () => { + // Scope to the specific collection by its DOM id (collection-) + const collectionId = `collection-${collectionName.replace(/\s+/g, '-').toLowerCase()}`; + const collectionContainer = page.locator(`#${collectionId}`); + await collectionContainer.waitFor({ state: 'visible', timeout: 5000 }); + + // Walk down the folder path, scoping each step to the previous folder's container. + // Each CollectionItem renders as a StyledWrapper div containing: + // - div.collection-item-name (the row with chevron, name, menu) + // - div (children container when expanded) + // We scope to the parent wrapper so the next folder lookup is unambiguous. + let scope = collectionContainer; + for (const folderName of folderPath) { + const row = scope.locator('.collection-item-name').filter({ hasText: folderName }).first(); + await row.waitFor({ state: 'visible', timeout: 5000 }); + + // Click the chevron to expand (skip if already expanded) + const chevron = row.getByTestId('folder-chevron'); + const isExpanded = await chevron.evaluate((el: HTMLElement) => el.classList.contains('rotate-90')); + if (!isExpanded) { + await chevron.click(); + } + + // Scope to this folder's wrapper (parent of the row) for the next iteration + scope = row.locator('..'); + } + + // The target folder row is the last one we found — hover to reveal menu + const targetRow = scope.locator('.collection-item-name').filter({ hasText: folderPath[folderPath.length - 1] }).first(); + await targetRow.hover(); + + // Click the menu icon + const menuIcon = targetRow.locator('.menu-icon'); + await menuIcon.waitFor({ state: 'visible', timeout: 5000 }); + await menuIcon.click(); + + // Click "Run" in the dropdown + const runMenuItem = page.locator('.dropdown-item').filter({ hasText: 'Run' }); + await runMenuItem.waitFor({ state: 'visible' }); + await runMenuItem.click(); + + // In the RunCollectionItem modal, click "Recursive Run" + const recursiveRunButton = page.getByRole('button', { name: 'Recursive Run' }); + await recursiveRunButton.waitFor({ state: 'visible', timeout: 5000 }); + await recursiveRunButton.click(); + + // Wait for the run to complete + const runnerLocators = buildRunnerLocators(page); + await runnerLocators.runAgainButton().waitFor({ timeout: 2 * 60 * 1000 }); + }); +}; + /** * Sets up the JavaScript sandbox mode for a collection * @param page - The Playwright page object