mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
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:
@@ -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)',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
272
packages/bruno-js/src/cookie-list.js
Normal file
272
packages/bruno-js/src/cookie-list.js
Normal 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;
|
||||
184
packages/bruno-js/src/property-list.js
Normal file
184
packages/bruno-js/src/property-list.js
Normal 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;
|
||||
227
packages/bruno-js/src/readonly-property-list.js
Normal file
227
packages/bruno-js/src/readonly-property-list.js
Normal 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;
|
||||
@@ -272,7 +272,8 @@ class AssertRuntime {
|
||||
requestVariables,
|
||||
globalEnvironmentVariables,
|
||||
promptVariables,
|
||||
certsAndProxyConfig
|
||||
certsAndProxyConfig,
|
||||
requestUrl: request?.url
|
||||
});
|
||||
const req = new BrunoRequest(request);
|
||||
const res = createResponseParser(response);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -49,7 +49,8 @@ class TestRuntime {
|
||||
oauth2CredentialVariables,
|
||||
collectionName,
|
||||
promptVariables,
|
||||
certsAndProxyConfig
|
||||
certsAndProxyConfig,
|
||||
requestUrl: request?.url
|
||||
});
|
||||
const req = new BrunoRequest(request);
|
||||
const res = new BrunoResponse(response);
|
||||
|
||||
@@ -48,7 +48,8 @@ class VarsRuntime {
|
||||
globalEnvironmentVariables,
|
||||
oauth2CredentialVariables,
|
||||
promptVariables,
|
||||
certsAndProxyConfig
|
||||
certsAndProxyConfig,
|
||||
requestUrl: request?.url
|
||||
});
|
||||
const req = new BrunoRequest(request);
|
||||
const res = createResponseParser(response);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
345
packages/bruno-js/tests/cookie-list.spec.js
Normal file
345
packages/bruno-js/tests/cookie-list.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
237
packages/bruno-js/tests/property-list.spec.js
Normal file
237
packages/bruno-js/tests/property-list.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
225
packages/bruno-js/tests/readonly-property-list.spec.js
Normal file
225
packages/bruno-js/tests/readonly-property-list.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,4 +20,3 @@ body:json {
|
||||
assert {
|
||||
res.body: eq hello
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ meta {
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{host}}/ping
|
||||
url: {{localhost}}/ping
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -5,7 +5,7 @@ meta {
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{host}}/ping
|
||||
url: {{localhost}}/ping
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ meta {
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{host}}/ping
|
||||
url: {{localhost}}/ping
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -5,7 +5,7 @@ meta {
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{host}}/ping
|
||||
url: {{localhost}}/ping
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ meta {
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{host}}/ping
|
||||
url: {{localhost}}/ping
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ meta {
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{host}}/ping
|
||||
url: {{localhost}}/ping
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ meta {
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{host}}/ping
|
||||
url: {{localhost}}/ping
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
26
tests/scripting/bru-api/cookies/cookies.spec.ts
Normal file
26
tests/scripting/bru-api/cookies/cookies.spec.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"collections": [
|
||||
{
|
||||
"path": "{{projectRoot}}/packages/bruno-tests/collection",
|
||||
"securityConfig": {
|
||||
"jsSandboxMode": "developer"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"maximized": false,
|
||||
"lastOpenedCollections": [
|
||||
"{{projectRoot}}/packages/bruno-tests/collection"
|
||||
],
|
||||
"preferences": {
|
||||
"onboarding": {
|
||||
"hasLaunchedBefore": true,
|
||||
"hasSeenWelcomeModal": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user