feat(cookies): add direct cookie access methods and update translations (#7073)

* feat(cookies): add direct cookie access methods and update translations

- Introduced new methods for direct cookie access: `bru.cookies.get`, `bru.cookies.has`, and `bru.cookies.toObject`.
- Updated translation mappings in `bruno-to-postman-translator` and `postman-to-bruno-translator` to support these new methods.
- Enhanced tests to verify correct translation between `bru` and `pm` cookie methods, including mixed usage scenarios.
- Updated `Bru` class to handle cookie access based on the current request URL.

* feat(cookies): enhance cookie management with new methods and refactor

- Added new cookie methods: `toString`, `clear`, `delete`, `one`, `all`, `idx`, `count`, `indexOf`, `find`, `filter`, `each`, `map`, and `reduce` to `bru.cookies`.
- Refactored `Bru` class to utilize a new `CookieList` for cookie management, improving structure and readability.
- Updated translation mappings in `bruno-to-postman-translator` and `postman-to-bruno-translator` to include new cookie methods.
- Introduced `PropertyList` and `ReadOnlyPropertyList` classes for better data structure management.
- Enhanced tests for comprehensive coverage of new cookie functionalities and their interactions.

* docs(readonly-property-list): clarify array usage in constructor comments

* feat(cookies): add direct cookie manipulation tests and methods

* feat(cookies): add hasCookie method for checking cookie existence

* fix

* refactor(cookies): simplify cookie method translations

* feat(cookies): expand cookie API with new methods and tests

- Added new cookie methods: `get`, `has`, `toString`, `clear`, `upsert`, `remove`, `idx`, and `indexOf` to enhance cookie management.
- Updated translation mappings for `bru.cookies` to include new methods in `bruno-to-postman-translator` and `postman-to-bruno-translator`.
- Introduced tests for new methods and their interactions, ensuring comprehensive coverage of cookie functionalities.
- Enhanced existing tests to validate correct behavior of cookie methods across different scenarios.

* refactor(cookies): update CookieList to extend PropertyList and improve error handling

* test(cookies): add regression tests for jar and direct cookie patterns

- Introduced regression tests to ensure that jar patterns are correctly prioritized over direct cookie access patterns in translations.
- Updated `CookieList` to extend `ReadOnlyPropertyList` instead of `PropertyList`, clarifying its functionality.
- Refactored cookie method handling in the `bru` shim to utilize a new asynchronous bridge for improved error handling and consistency.

* refactor(cookies): update translations and remove PropertyList

- Enhanced comments in `postman-translations.js` to clarify the order of cookie jar translations.
- Updated `cookie-list.js` comments to better describe the factory function for the cookie jar.
- Removed the `PropertyList` class and its associated tests, streamlining the codebase and focusing on `ReadOnlyPropertyList` and `CookieList` for cookie management.

* fix(cookies): normalize tough-cookie objects and improve remove method comments

- Updated `CookieList` to normalize tough-cookie instances to plain objects, preventing circular references and exposing internal structures.
- Enhanced comments in the `remove` method to clarify behavior when removing non-existent or empty-named cookies.

* test(cookies): update tests to use async/await for consistency

* test(cookies): use async/await in cookie tests for consistency

* refactor(readonly-property-list): update get and reduce methods for improved behavior

* fix(cookies): update cookie method signature in autocomplete hints and enhance translation comments

- Modified the autocomplete hint for `bru.cookies.has` to include the new signature with an optional value parameter.
- Improved comments in `postman-translations.js` to clarify the order of cookie jar translation patterns for better understanding.

* refactor(cookies): introduce PropertyList for enhanced cookie management

* refactor(property-list): simplify repopulate method and enhance item handling logic

* feat(cookies): implement PropertyList bridge for enhanced cookie management

- Introduced a new `createPropertyListBridge` utility to streamline the integration of cookie methods into the QuickJS VM.
- Replaced the previous async cookie bridge with a more flexible approach, allowing for both synchronous and asynchronous cookie operations.
- Added comprehensive tests to validate the functionality of the new cookie methods in both developer and safe modes.
- Updated existing cookie tests to ensure compatibility with the new PropertyList structure.

* fix(tests): correct expected passed requests in cookie tests

- Updated the expected number of passed requests in the cookie tests from 34 to 6 to reflect the correct validation results.
- Ensured consistency in test assertions across multiple test cases for the PropertyList API.

* fix(cookies): update cookie URLs to use localhost for testing

- Changed all cookie-related test scripts to use `{{localhost}}` instead of `{{host}}` for the ping URL, ensuring consistency in local testing environments.
- Updated the cookie test suite to reflect the new URL structure, enhancing the reliability of the tests.
- Removed outdated cookie test files to streamline the test suite.

* refactor(cookies): standardize cookie handling with localhost variable

- Updated cookie test scripts to utilize the `{{localhost}}` variable for setting and retrieving cookies, ensuring consistency across tests.
- Enhanced clarity in comments regarding cookie behavior for different domains.
- Improved test assertions to validate cookie management functionality more effectively.

* refactor(property-list, readonly-property-list): update methods to use private class fields

* feat(cookies): enhance CookieList API with detailed documentation and method improvements

- Updated the `CookieList` class to provide comprehensive documentation on cookie management methods, including `add`, `upsert`, `remove`, and `delete`.
- Improved method signatures to support both callback and Promise-based usage for asynchronous operations.
- Added detailed descriptions for read and write methods, including examples and expected behavior.
- Enhanced the integration of the `CookieList` with the QuickJS VM by updating the property list bridge to include `toJSON` in sync read object methods.

* feat(cookies): add detailed examples and improve async bridge documentation

- Enhanced the `createPropertyListBridge` function documentation with comprehensive examples for setting up cookie methods in QuickJS.
- Clarified the two-phase setup process for async write methods, detailing the registration of bridge functions and the generation of JavaScript code for method wrappers.
- Added a new test case for the `toJSON()` method to ensure it returns a cloned array of all cookies, validating the expected structure and properties.

* fix(assert-runtime): correct syntax error in response parser assignment

- Added a semicolon at the end of the response parser assignment to ensure proper syntax in the AssertRuntime class.
This commit is contained in:
sanish chirayath
2026-03-27 19:42:23 +05:30
committed by GitHub
parent c2de480091
commit bef4b6bbee
38 changed files with 2507 additions and 177 deletions

View File

@@ -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)',

View File

@@ -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) => {

View File

@@ -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'
};

View File

@@ -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',

View File

@@ -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();');
});
});

View File

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

View File

@@ -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

View File

@@ -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>|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>|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>|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>|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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -272,7 +272,8 @@ class AssertRuntime {
requestVariables,
globalEnvironmentVariables,
promptVariables,
certsAndProxyConfig
certsAndProxyConfig,
requestUrl: request?.url
});
const req = new BrunoRequest(request);
const res = createResponseParser(response);

View File

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

View File

@@ -49,7 +49,8 @@ class TestRuntime {
oauth2CredentialVariables,
collectionName,
promptVariables,
certsAndProxyConfig
certsAndProxyConfig,
requestUrl: request?.url
});
const req = new BrunoRequest(request);
const res = new BrunoResponse(response);

View File

@@ -48,7 +48,8 @@ class VarsRuntime {
globalEnvironmentVariables,
oauth2CredentialVariables,
promptVariables,
certsAndProxyConfig
certsAndProxyConfig,
requestUrl: request?.url
});
const req = new BrunoRequest(request);
const res = createResponseParser(response);

View File

@@ -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();

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,4 +20,3 @@ body:json {
assert {
res.body: eq hello
}

View File

@@ -5,7 +5,7 @@ meta {
}
get {
url: {{host}}/ping
url: {{localhost}}/ping
body: none
auth: inherit
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -5,7 +5,7 @@ meta {
}
get {
url: {{host}}/ping
url: {{localhost}}/ping
body: none
auth: inherit
}

View File

@@ -5,7 +5,7 @@ meta {
}
get {
url: {{host}}/ping
url: {{localhost}}/ping
body: none
auth: inherit
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -5,7 +5,7 @@ meta {
}
get {
url: {{host}}/ping
url: {{localhost}}/ping
body: none
auth: inherit
}

View File

@@ -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()

View File

@@ -5,7 +5,7 @@ meta {
}
get {
url: {{host}}/ping
url: {{localhost}}/ping
body: none
auth: inherit
}

View File

@@ -5,7 +5,7 @@ meta {
}
get {
url: {{host}}/ping
url: {{localhost}}/ping
body: none
auth: inherit
}

View File

@@ -5,7 +5,7 @@ meta {
}
get {
url: {{host}}/ping
url: {{localhost}}/ping
body: none
auth: inherit
}

View File

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

View File

@@ -0,0 +1,10 @@
{
"collections": [
{
"path": "{{projectRoot}}/packages/bruno-tests/collection",
"securityConfig": {
"jsSandboxMode": "developer"
}
}
]
}

View File

@@ -0,0 +1,12 @@
{
"maximized": false,
"lastOpenedCollections": [
"{{projectRoot}}/packages/bruno-tests/collection"
],
"preferences": {
"onboarding": {
"hasLaunchedBefore": true,
"hasSeenWelcomeModal": true
}
}
}

View File

@@ -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-<name-kebab>)
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