@usebruno/js 0.46.1 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,184 @@
1
+ const ReadOnlyPropertyList = require('./readonly-property-list');
2
+
3
+ /**
4
+ * PropertyList - A mutable collection data structure.
5
+ *
6
+ * Extends ReadOnlyPropertyList with mutation methods that operate on the
7
+ * internal _items array in static mode. In dynamic mode, all mutations
8
+ * throw — subclasses (e.g. CookieList) override with async implementations.
9
+ *
10
+ * Class hierarchy:
11
+ * ReadOnlyPropertyList (read-only, both modes)
12
+ * └── PropertyList (sync mutations in static mode; throws in dynamic mode)
13
+ * └── CookieList (overrides add/upsert/remove/clear/delete with async jar ops)
14
+ */
15
+ class PropertyList extends ReadOnlyPropertyList {
16
+ /**
17
+ * Guard that throws in dynamic mode. Called by all mutation methods.
18
+ * @param {string} method - Name of the calling method (for error message)
19
+ */
20
+ #ensureStaticMode(method) {
21
+ if (this._dynamic) {
22
+ throw new Error(`${method}() is not supported in dynamic mode. Override in subclass.`);
23
+ }
24
+ }
25
+
26
+ // ── Mutation methods ──────────────────────────────────────────────────
27
+
28
+ /**
29
+ * Append an item to the end of the list.
30
+ * @param {object} item
31
+ */
32
+ add(item) {
33
+ this.#ensureStaticMode('add');
34
+ this._items.push(item);
35
+ }
36
+
37
+ /**
38
+ * Alias for add().
39
+ * @param {object} item
40
+ */
41
+ append(item) {
42
+ return this.add(item);
43
+ }
44
+
45
+ /**
46
+ * Insert an item at the beginning of the list.
47
+ * @param {object} item
48
+ */
49
+ prepend(item) {
50
+ this.#ensureStaticMode('prepend');
51
+ this._items.unshift(item);
52
+ }
53
+
54
+ /**
55
+ * Insert an item before a reference item.
56
+ * @param {object} item - The item to insert
57
+ * @param {string|object} before - Key string or item object to insert before
58
+ */
59
+ insert(item, before) {
60
+ this.#ensureStaticMode('insert');
61
+ const idx = this.#findIndex(before);
62
+ if (idx === -1) {
63
+ this._items.push(item);
64
+ } else {
65
+ this._items.splice(idx, 0, item);
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Insert an item after a reference item.
71
+ * @param {object} item - The item to insert
72
+ * @param {string|object} after - Key string or item object to insert after
73
+ */
74
+ insertAfter(item, after) {
75
+ this.#ensureStaticMode('insertAfter');
76
+ const idx = this.#findIndex(after);
77
+ if (idx === -1) {
78
+ this._items.push(item);
79
+ } else {
80
+ this._items.splice(idx + 1, 0, item);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Remove items matching a predicate, key string, or item reference.
86
+ * @param {Function|string|object} predicate
87
+ */
88
+ remove(predicate) {
89
+ this.#ensureStaticMode('remove');
90
+ if (typeof predicate === 'function') {
91
+ this._items = this._items.filter((item) => !predicate(item));
92
+ } else if (typeof predicate === 'string') {
93
+ this._items = this._items.filter((item) => item[this._keyProperty] !== predicate);
94
+ } else if (predicate && typeof predicate === 'object') {
95
+ const idx = this.indexOf(predicate);
96
+ if (idx !== -1) {
97
+ this._items.splice(idx, 1);
98
+ }
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Remove all items from the list.
104
+ */
105
+ clear() {
106
+ this.#ensureStaticMode('clear');
107
+ this._items = [];
108
+ }
109
+
110
+ /**
111
+ * Update an existing item by key, or append if not found.
112
+ * @param {object} item
113
+ */
114
+ upsert(item) {
115
+ this.#ensureStaticMode('upsert');
116
+ const key = item[this._keyProperty];
117
+ const idx = this._items.findIndex((i) => i[this._keyProperty] === key);
118
+ if (idx !== -1) {
119
+ this._items[idx] = item;
120
+ } else {
121
+ this._items.push(item);
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Replace all items with a new array.
127
+ * @param {Array} items
128
+ */
129
+ populate(items) {
130
+ this.#ensureStaticMode('populate');
131
+ this._items = Array.isArray(items) ? [...items] : [];
132
+ }
133
+
134
+ /**
135
+ * Clear and repopulate with new items.
136
+ * @param {Array} items
137
+ */
138
+ repopulate(items) {
139
+ this.#ensureStaticMode('repopulate');
140
+ this.populate(items);
141
+ }
142
+
143
+ /**
144
+ * Merge items from another PropertyList or array.
145
+ * @param {PropertyList|Array} source - Source of items to merge
146
+ * @param {boolean} [prune=false] - If true, clear existing items first
147
+ */
148
+ assimilate(source, prune) {
149
+ this.#ensureStaticMode('assimilate');
150
+ if (prune) {
151
+ this._items = [];
152
+ }
153
+ let items;
154
+ if (ReadOnlyPropertyList.isPropertyList(source)) {
155
+ items = source.all();
156
+ } else if (Array.isArray(source)) {
157
+ items = source;
158
+ } else {
159
+ items = [];
160
+ }
161
+ for (const item of items) {
162
+ this._items.push(item);
163
+ }
164
+ }
165
+
166
+ // ── Internal helpers ──────────────────────────────────────────────────
167
+
168
+ /**
169
+ * Find the index of a reference (key string or item object).
170
+ * @param {string|object} ref
171
+ * @returns {number}
172
+ */
173
+ #findIndex(ref) {
174
+ if (typeof ref === 'string') {
175
+ return this._items.findIndex((i) => i[this._keyProperty] === ref);
176
+ }
177
+ if (ref && typeof ref === 'object') {
178
+ return this.indexOf(ref);
179
+ }
180
+ return -1;
181
+ }
182
+ }
183
+
184
+ module.exports = PropertyList;
@@ -0,0 +1,227 @@
1
+ /**
2
+ * ReadOnlyPropertyList - A read-only collection data structure.
3
+ *
4
+ * Two modes:
5
+ * - Static mode: items stored internally in an array (for headers, query params, etc.)
6
+ * - Dynamic mode: a dataSource function returns fresh items on every read (for cookies)
7
+ *
8
+ * Items are plain objects with a configurable key property (keyProperty) and value property (valueProperty).
9
+ *
10
+ * This base class provides only read/search/iteration/transform methods.
11
+ * See PropertyList for static-mode mutation methods.
12
+ * See CookieList for async cookie-jar write methods.
13
+ *
14
+ * Convention:
15
+ * #field / #method – truly private, inaccessible to subclasses
16
+ * _field / _method – protected, intended for subclass access only
17
+ */
18
+ class ReadOnlyPropertyList {
19
+ // ── Private fields (not accessible by subclasses) ────────────────────
20
+ #valueProperty;
21
+ #dataSource;
22
+
23
+ /**
24
+ * @param {object} options
25
+ * @param {string} [options.keyProperty='key'] - The property name used as the unique key
26
+ * @param {string} [options.valueProperty='value'] - The property name used as the value
27
+ * @param {Function} [options.dataSource] - Dynamic data source function (returns array of items)
28
+ * @param {Array} [options.items] - Initial items for static mode
29
+ */
30
+ // Items are stored in an array (not a Map) to support positional access (idx, indexOf),
31
+ // ordered insertion (insert, insertAfter, prepend in PropertyList), and duplicate keys.
32
+ // At typical list sizes (cookies, headers) the O(n) key lookup is negligible.
33
+ constructor({ keyProperty = 'key', valueProperty = 'value', dataSource, items } = {}) {
34
+ this._keyProperty = keyProperty;
35
+ this.#valueProperty = valueProperty;
36
+ this._dynamic = typeof dataSource === 'function';
37
+ if (this._dynamic) {
38
+ this.#dataSource = dataSource;
39
+ } else {
40
+ this._items = Array.isArray(items) ? [...items] : [];
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Returns the current list of items.
46
+ * In dynamic mode, calls the dataSource function.
47
+ * In static mode, returns the internal array.
48
+ */
49
+ #getItems() {
50
+ return this._dynamic ? this.#dataSource() : this._items;
51
+ }
52
+
53
+ // ── Retrieval ──────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Get the value of an item by its key.
57
+ * @param {string} name
58
+ * @returns {*} The value property of the matching item, or undefined
59
+ */
60
+ get(name) {
61
+ const items = this.#getItems();
62
+ // Use findLast so that duplicate keys resolve to the last entry,
63
+ // consistent with toObject() which also gives last-wins semantics.
64
+ const item = items.findLast((i) => i[this._keyProperty] === name);
65
+ return item ? item[this.#valueProperty] : undefined;
66
+ }
67
+
68
+ /**
69
+ * Get the full item object by its key.
70
+ * @param {string} name
71
+ * @returns {object|undefined}
72
+ */
73
+ one(name) {
74
+ const items = this.#getItems();
75
+ // Use findLast so that duplicate keys resolve to the last entry,
76
+ // consistent with get() and toObject() which also give last-wins semantics.
77
+ return items.findLast((i) => i[this._keyProperty] === name);
78
+ }
79
+
80
+ /**
81
+ * Get a cloned array of all items.
82
+ * @returns {Array}
83
+ */
84
+ all() {
85
+ return [...this.#getItems()];
86
+ }
87
+
88
+ /**
89
+ * Get an item by its positional index.
90
+ * @param {number} index
91
+ * @returns {object|undefined}
92
+ */
93
+ idx(index) {
94
+ return this.#getItems()[index];
95
+ }
96
+
97
+ /**
98
+ * Get the number of items.
99
+ * @returns {number}
100
+ */
101
+ count() {
102
+ return this.#getItems().length;
103
+ }
104
+
105
+ /**
106
+ * Get the index of an item.
107
+ * Uses structural equality (matching by key and value) so it works
108
+ * even when the item is a copy rather than the same reference.
109
+ * @param {object} item
110
+ * @returns {number} -1 if not found
111
+ */
112
+ indexOf(item) {
113
+ if (!item || typeof item !== 'object') return -1;
114
+ const items = this.#getItems();
115
+ const keyProp = this._keyProperty;
116
+ return items.findIndex(
117
+ (i) => i[keyProp] === item[keyProp] && i[this.#valueProperty] === item[this.#valueProperty]
118
+ );
119
+ }
120
+
121
+ // ── Search ─────────────────────────────────────────────────────────────
122
+
123
+ /**
124
+ * Check if an item with the given key exists.
125
+ * If value is provided, also checks that the item's value matches.
126
+ * @param {string} name
127
+ * @param {*} [value]
128
+ * @returns {boolean}
129
+ */
130
+ has(name, value) {
131
+ const items = this.#getItems();
132
+ if (value !== undefined) {
133
+ return items.some((i) => i[this._keyProperty] === name && i[this.#valueProperty] === value);
134
+ }
135
+ return items.some((i) => i[this._keyProperty] === name);
136
+ }
137
+
138
+ /**
139
+ * Find the first item matching a predicate.
140
+ * @param {Function} predicate
141
+ * @returns {object|undefined}
142
+ */
143
+ find(predicate) {
144
+ return this.#getItems().find(predicate);
145
+ }
146
+
147
+ /**
148
+ * Filter items by a predicate.
149
+ * @param {Function} predicate
150
+ * @returns {Array}
151
+ */
152
+ filter(predicate) {
153
+ return this.#getItems().filter(predicate);
154
+ }
155
+
156
+ // ── Iteration ──────────────────────────────────────────────────────────
157
+
158
+ /**
159
+ * Iterate over each item.
160
+ * @param {Function} fn - Called with (item, index)
161
+ */
162
+ each(fn) {
163
+ this.#getItems().forEach(fn);
164
+ }
165
+
166
+ /**
167
+ * Map over items.
168
+ * @param {Function} fn
169
+ * @returns {Array}
170
+ */
171
+ map(fn) {
172
+ return this.#getItems().map(fn);
173
+ }
174
+
175
+ /**
176
+ * Reduce items.
177
+ * @param {Function} fn
178
+ * @param {*} [initialValue] - Optional initial accumulator value
179
+ * @returns {*}
180
+ */
181
+ reduce(fn, ...rest) {
182
+ return rest.length ? this.#getItems().reduce(fn, rest[0]) : this.#getItems().reduce(fn);
183
+ }
184
+
185
+ // ── Transformation ─────────────────────────────────────────────────────
186
+
187
+ /**
188
+ * Convert to a plain object { key: value }.
189
+ * @returns {object}
190
+ */
191
+ toObject() {
192
+ const result = {};
193
+ for (const item of this.#getItems()) {
194
+ result[item[this._keyProperty]] = item[this.#valueProperty];
195
+ }
196
+ return result;
197
+ }
198
+
199
+ /**
200
+ * Convert to a string "key=value; key2=value2".
201
+ * @returns {string}
202
+ */
203
+ toString() {
204
+ return this.#getItems()
205
+ .map((i) => `${i[this._keyProperty]}=${i[this.#valueProperty]}`)
206
+ .join('; ');
207
+ }
208
+
209
+ /**
210
+ * Convert to JSON (returns the same as all()).
211
+ * @returns {Array}
212
+ */
213
+ toJSON() {
214
+ return this.all();
215
+ }
216
+
217
+ /**
218
+ * Check if an object is an instance of ReadOnlyPropertyList.
219
+ * @param {*} obj
220
+ * @returns {boolean}
221
+ */
222
+ static isPropertyList(obj) {
223
+ return obj instanceof ReadOnlyPropertyList;
224
+ }
225
+ }
226
+
227
+ module.exports = ReadOnlyPropertyList;
@@ -7,6 +7,8 @@ const { evaluateJsTemplateLiteral, evaluateJsExpression, createResponseParser, u
7
7
  const { interpolateString } = require('../interpolate-string');
8
8
  const { executeQuickJsVm } = require('../sandbox/quickjs');
9
9
 
10
+ const Ajv = require('ajv');
11
+ const addFormats = require('ajv-formats');
10
12
  const { expect } = chai;
11
13
  chai.use(require('chai-string'));
12
14
  chai.use(function (chai, utils) {
@@ -17,13 +19,55 @@ chai.use(function (chai, utils) {
17
19
  // Objects created inside Node's vm.createContext() have a different Object constructor,
18
20
  // so obj.constructor === Object fails for objects passed via res.setBody() from scripts.
19
21
  // Note: toString check is more permissive than constructor check — custom class instances
20
- const isJson = typeof obj === 'object' && obj !== null && !Array.isArray(obj)
21
- && Object.prototype.toString.call(obj) === '[object Object]';
22
+ const isJson = typeof obj === 'object' && obj !== null
23
+ && (Array.isArray(obj) || Object.prototype.toString.call(obj) === '[object Object]');
22
24
 
23
25
  this.assert(isJson, `expected ${utils.inspect(obj)} to be JSON`, `expected ${utils.inspect(obj)} not to be JSON`);
24
26
  });
25
27
  });
26
28
 
29
+ // Custom assertion for JSON Schema validation
30
+ const defaultAjv = new Ajv({ allErrors: true });
31
+ addFormats(defaultAjv);
32
+
33
+ const SUPPORTED_SCHEMA_VERSIONS = [
34
+ 'http://json-schema.org/draft-07/schema#',
35
+ 'http://json-schema.org/draft-07/schema'
36
+ ];
37
+
38
+ chai.use(function (chai) {
39
+ chai.Assertion.addMethod('jsonSchema', function (schema, ajvOptions) {
40
+ if (schema && schema.$schema && !SUPPORTED_SCHEMA_VERSIONS.includes(schema.$schema)) {
41
+ this.assert(
42
+ false,
43
+ `Unsupported JSON Schema version: "${schema.$schema}". Bruno currently only supports Draft-07 (http://json-schema.org/draft-07/schema#). Please update your schema to be Draft-07 compatible and remove the $schema property.`,
44
+ `Unsupported JSON Schema version: "${schema.$schema}".`
45
+ );
46
+ }
47
+ let ajv;
48
+ if (ajvOptions) {
49
+ ajv = new Ajv({ allErrors: true, ...ajvOptions });
50
+ addFormats(ajv);
51
+ } else {
52
+ ajv = defaultAjv;
53
+ }
54
+ let validate;
55
+ try {
56
+ validate = ajv.compile(schema);
57
+ } catch (e) {
58
+ this.assert(false, 'JSON schema compile error: ' + e.message, 'JSON schema compile error: ' + e.message);
59
+ }
60
+ const data = this._obj;
61
+ const isValid = validate(data);
62
+
63
+ this.assert(
64
+ isValid,
65
+ 'expected #{this} to match JSON schema, validation errors: ' + (validate.errors ? JSON.stringify(validate.errors) : 'none'),
66
+ 'expected #{this} to not match JSON schema'
67
+ );
68
+ });
69
+ });
70
+
27
71
  // Custom assertion for matching regex
28
72
  chai.use(function (chai, utils) {
29
73
  chai.Assertion.addMethod('match', function (regex) {
@@ -43,6 +87,118 @@ chai.use(function (chai, utils) {
43
87
  });
44
88
  });
45
89
 
90
+ // Custom assertion for jsonBody (Postman parity)
91
+ chai.use(function (chai, utils) {
92
+ // Parse a property path into an array of keys.
93
+ // Handles: dot notation (a.b), numeric brackets (a[0]), quoted brackets (a["b.c"], a['key']),
94
+ // and combinations like data[0]["a.b"].name
95
+ //
96
+ // Examples:
97
+ // "a.b.c" -> ["a", "b", "c"]
98
+ // "items[0].name" -> ["items", "0", "name"]
99
+ // 'data["a.b"]' -> ["data", "a.b"]
100
+ // "matrix[0][1]" -> ["matrix", "0", "1"]
101
+ // 'nested["x.y"].z' -> ["nested", "x.y", "z"]
102
+ // '["say \\"hi\\""]' -> ["say \"hi\""]
103
+ function parsePath(path) {
104
+ const keys = [];
105
+ let i = 0;
106
+ while (i < path.length) {
107
+ if (path[i] === '.') {
108
+ // Skip dot separator
109
+ i++;
110
+ } else if (path[i] === '[') {
111
+ i++; // skip '['
112
+ if (i < path.length && (path[i] === '\'' || path[i] === '"')) {
113
+ // Quoted key — collect until matching unescaped quote + ']'
114
+ const quote = path[i];
115
+ i++; // skip opening quote
116
+ let key = '';
117
+ while (i < path.length && path[i] !== quote) {
118
+ if (path[i] === '\\' && i + 1 < path.length && path[i + 1] === quote) {
119
+ key += quote;
120
+ i += 2; // skip backslash + escaped quote
121
+ } else {
122
+ key += path[i];
123
+ i++;
124
+ }
125
+ }
126
+ i++; // skip closing quote
127
+ i++; // skip ']'
128
+ keys.push(key);
129
+ } else {
130
+ // Unquoted (numeric) key — collect until ']'
131
+ let key = '';
132
+ while (i < path.length && path[i] !== ']') {
133
+ key += path[i];
134
+ i++;
135
+ }
136
+ i++; // skip ']'
137
+ keys.push(key);
138
+ }
139
+ } else {
140
+ // Bare key — collect until '.', '[', or end
141
+ let key = '';
142
+ while (i < path.length && path[i] !== '.' && path[i] !== '[') {
143
+ key += path[i];
144
+ i++;
145
+ }
146
+ keys.push(key);
147
+ }
148
+ }
149
+ return keys;
150
+ }
151
+
152
+ function getNestedValue(obj, path) {
153
+ const keys = parsePath(path);
154
+ let current = obj;
155
+ for (const key of keys) {
156
+ if (current === null || current === undefined || !Object.prototype.hasOwnProperty.call(Object(current), key)) {
157
+ return { found: false };
158
+ }
159
+ current = current[key];
160
+ }
161
+ return { found: true, value: current };
162
+ }
163
+
164
+ chai.Assertion.addMethod('jsonBody', function () {
165
+ const obj = this._obj;
166
+ const args = Array.prototype.slice.call(arguments);
167
+
168
+ if (args.length === 0) {
169
+ // No args: check body is valid JSON (object or array)
170
+ this.assert(
171
+ typeof obj === 'object' && obj !== null,
172
+ `expected ${utils.inspect(obj)} to be a JSON body (object or array)`,
173
+ `expected ${utils.inspect(obj)} not to be a JSON body`
174
+ );
175
+ } else if (args.length === 1 && typeof args[0] === 'object' && args[0] !== null) {
176
+ // Object arg: deep equality
177
+ this.assert(
178
+ utils.eql(obj, args[0]),
179
+ `expected body to deeply equal ${utils.inspect(args[0])}`,
180
+ `expected body to not deeply equal ${utils.inspect(args[0])}`
181
+ );
182
+ } else if (args.length === 1) {
183
+ // String path: check nested property exists
184
+ const result = getNestedValue(obj, String(args[0]));
185
+ this.assert(
186
+ result.found,
187
+ `expected body to have nested property '${args[0]}'`,
188
+ `expected body to not have nested property '${args[0]}'`
189
+ );
190
+ } else {
191
+ // Path + value: check nested property equals value
192
+ const result = getNestedValue(obj, String(args[0]));
193
+ this.assert(
194
+ result.found && utils.eql(result.value, args[1]),
195
+ `expected body to have nested property '${args[0]}' equal to ${utils.inspect(args[1])}`,
196
+ `expected body to not have nested property '${args[0]}' equal to ${utils.inspect(args[1])}`
197
+ );
198
+ }
199
+ });
200
+ });
201
+
46
202
  /**
47
203
  * Assertion operators
48
204
  *
@@ -263,14 +419,12 @@ class AssertRuntime {
263
419
  }
264
420
 
265
421
  const certsAndProxyConfig = request?.certsAndProxyConfig;
266
- const bru = new Bru(
267
- this.runtime,
422
+ const bru = new Bru({
423
+ runtime: this.runtime,
268
424
  envVariables,
269
425
  runtimeVariables,
270
426
  processEnvVars,
271
- undefined, // collectionPath,
272
- undefined, // historyLogger,
273
- undefined, // setVisualizations,
427
+ historyLogger,
274
428
  secretVariables,
275
429
  collectionVariables,
276
430
  folderVariables,
@@ -278,10 +432,10 @@ class AssertRuntime {
278
432
  globalEnvironmentVariables,
279
433
  oauth2CredentialVariables,
280
434
  iterationDetails,
281
- undefined,
282
435
  promptVariables,
283
- certsAndProxyConfig
284
- );
436
+ certsAndProxyConfig,
437
+ requestUrl: request?.url
438
+ });
285
439
  const req = new BrunoRequest(request, historyLogger);
286
440
  const res = createResponseParser(response);
287
441