@usebruno/js 0.47.0 → 0.49.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usebruno/js",
3
- "version": "0.47.0",
3
+ "version": "0.49.0",
4
4
  "license": "MIT",
5
5
  "main": "src/index.js",
6
6
  "files": [
@@ -9,16 +9,17 @@
9
9
  ],
10
10
  "scripts": {
11
11
  "test": "node --experimental-vm-modules $(npx which jest) --testPathIgnorePatterns test.js",
12
+ "test:ci": "node --experimental-vm-modules ../../node_modules/jest/bin/jest.js --testPathIgnorePatterns test.js",
12
13
  "sandbox:bundle-libraries": "node ./src/sandbox/bundle-libraries.js",
13
14
  "prepack": "npm run test"
14
15
  },
15
16
  "dependencies": {
16
- "@usebruno/common": "0.21.0",
17
+ "@usebruno/common": "0.23.0",
17
18
  "@usebruno/query": "0.2.2",
18
19
  "ajv": "^8.12.0",
19
20
  "ajv-formats": "^2.1.1",
20
21
  "atob": "^2.1.2",
21
- "axios": "1.13.6",
22
+ "axios": "1.16.0",
22
23
  "btoa": "^1.2.1",
23
24
  "chai": "^4.3.7",
24
25
  "chai-string": "^1.5.0",
@@ -32,7 +33,7 @@
32
33
  "nanoid": "3.3.8",
33
34
  "node-fetch": "^2.7.0",
34
35
  "path": "^0.12.7",
35
- "quickjs-emscripten": "^0.29.2",
36
+ "quickjs-emscripten": "^0.32.0",
36
37
  "tv4": "^1.3.0",
37
38
  "uuid": "^10.0.0",
38
39
  "xml-formatter": "^3.5.0",
@@ -41,6 +42,7 @@
41
42
  },
42
43
  "devDependencies": {
43
44
  "@rollup/plugin-commonjs": "^23.0.2",
45
+ "@rollup/plugin-json": "^6.0.0",
44
46
  "@rollup/plugin-node-resolve": "^15.0.1",
45
47
  "@rollup/plugin-terser": "^1.0.0",
46
48
  "rollup": "3.30.0"
package/src/bru.js CHANGED
@@ -2,7 +2,7 @@ const { cloneDeep } = require('lodash');
2
2
  const { uuid } = require('./utils');
3
3
  const xmlFormat = require('xml-formatter');
4
4
  const { interpolate: _interpolate } = require('@usebruno/common');
5
- const { sendRequest, createSendRequest } = require('@usebruno/requests').scripting;
5
+ const { createSendRequest } = require('@usebruno/requests').scripting;
6
6
  const { jar: createCookieJar, getCookiesForUrl } = require('@usebruno/requests').cookies;
7
7
  const CookieList = require('./cookie-list');
8
8
  const Handlebars = require('handlebars');
@@ -102,8 +102,17 @@ class Bru {
102
102
  this.setVisualizations = setVisualizations;
103
103
  this.onConsoleLog = onConsoleLog || null;
104
104
  this.collectionName = collectionName;
105
- // Use createSendRequest with config if provided, otherwise use default sendRequest
106
- this.sendRequest = certsAndProxyConfig ? createSendRequest(certsAndProxyConfig) : sendRequest;
105
+ // Set by the host-side __bruSetScope global at the top of each segment's IIFE.
106
+ this._currentScope = null;
107
+ this.scriptedRequestEntries = [];
108
+ this.sendRequest = (...args) => {
109
+ const scopeSnapshot = this._currentScope ? { ...this._currentScope } : null;
110
+ const send = createSendRequest(certsAndProxyConfig, {
111
+ onComplete: (entry) =>
112
+ this._recordScriptedRequest({ source: 'sendRequest', scope: scopeSnapshot, ...entry })
113
+ });
114
+ return send(...args);
115
+ };
107
116
  this.runtime = runtime;
108
117
  this.requestUrl = requestUrl;
109
118
  this.cookies = new CookieList({
@@ -205,6 +214,16 @@ class Bru {
205
214
  return this.collectionPath;
206
215
  }
207
216
 
217
+ _recordScriptedRequest(entry) {
218
+ // Prefer scope passed in by the caller (snapshot at call time). Fall back to
219
+ // _currentScope for callers that don't supply one (e.g. bru.runRequest).
220
+ const { scope: providedScope, ...rest } = entry;
221
+ const scope = providedScope !== undefined
222
+ ? providedScope
223
+ : (this._currentScope ? { ...this._currentScope } : null);
224
+ this.scriptedRequestEntries.push({ ...rest, scope });
225
+ }
226
+
208
227
  getEnvName() {
209
228
  return this.envVariables.__name__;
210
229
  }
@@ -270,6 +289,10 @@ class Bru {
270
289
  }
271
290
  }
272
291
 
292
+ hasGlobalEnvVar(key) {
293
+ return Object.hasOwn(this.globalEnvironmentVariables, key);
294
+ }
295
+
273
296
  getGlobalEnvVar(key) {
274
297
  return this.interpolate(this.globalEnvironmentVariables[key]);
275
298
  }
@@ -1,9 +1,12 @@
1
+ const HeaderList = require('./header-list');
2
+
1
3
  class BrunoRequest {
2
4
  /**
3
5
  * The following properties are available as shorthand:
4
6
  * - req.url
5
7
  * - req.method
6
- * - req.headers
8
+ * - req.headers (raw headers object)
9
+ * - req.headerList (PropertyList API for headers)
7
10
  * - req.timeout
8
11
  * - req.body
9
12
  *
@@ -22,6 +25,7 @@ class BrunoRequest {
22
25
  this.name = req.name;
23
26
  this.pathParams = req.pathParams;
24
27
  this.tags = req.tags || [];
28
+ this.headerList = new HeaderList(this.req);
25
29
  /**
26
30
  * We automatically parse the JSON body if the content type is JSON
27
31
  * This is to make it easier for the user to access the body directly
@@ -96,19 +100,24 @@ class BrunoRequest {
96
100
  }
97
101
 
98
102
  getAuthMode() {
103
+ const headers = this.req.headers;
99
104
  if (this.req?.oauth2) {
100
105
  return 'oauth2';
101
106
  } else if (this.req?.oauth1config) {
102
107
  return 'oauth1';
103
- } else if (this.headers?.['Authorization']?.startsWith('Bearer')) {
108
+ } else if (headers?.['Authorization']?.startsWith('Bearer')) {
104
109
  return 'bearer';
105
- } else if (this.headers?.['Authorization']?.startsWith('Basic') || this.req?.auth?.username) {
110
+ } else if (headers?.['Authorization']?.startsWith('Basic') || this.req?.auth?.username) {
106
111
  return 'basic';
112
+ } else if (this.req?.apiKeyAuthValueForQueryParams) {
113
+ return 'apikey';
114
+ } else if (this.req?.apiKeyHeaderName && this.headers?.[this.req.apiKeyHeaderName] !== undefined) {
115
+ return 'apikey';
107
116
  } else if (this.req?.awsv4) {
108
117
  return 'awsv4';
109
118
  } else if (this.req?.digestConfig) {
110
119
  return 'digest';
111
- } else if (this.headers?.['X-WSSE'] || this.req?.auth?.username) {
120
+ } else if (headers?.['X-WSSE'] || this.req?.auth?.username) {
112
121
  return 'wsse';
113
122
  } else {
114
123
  return 'none';
@@ -125,7 +134,6 @@ class BrunoRequest {
125
134
  }
126
135
 
127
136
  setHeaders(headers) {
128
- this.headers = headers;
129
137
  this.req.headers = headers;
130
138
  }
131
139
 
@@ -138,12 +146,10 @@ class BrunoRequest {
138
146
  }
139
147
 
140
148
  setHeader(name, value) {
141
- this.headers[name] = value;
142
149
  this.req.headers[name] = value;
143
150
  }
144
151
 
145
152
  deleteHeader(name) {
146
- delete this.headers[name];
147
153
  delete this.req.headers[name];
148
154
 
149
155
  /**
@@ -1,5 +1,6 @@
1
1
  const { get } = require('@usebruno/query');
2
2
  const _ = require('lodash');
3
+ const HeaderList = require('./header-list');
3
4
 
4
5
  class BrunoResponse {
5
6
  constructor(res) {
@@ -11,6 +12,9 @@ class BrunoResponse {
11
12
  this.responseTime = res ? res.responseTime : null;
12
13
  this.url = res?.request ? res.request.protocol + '//' + res.request.host + res.request.path : null;
13
14
 
15
+ // HeaderList in static read-only mode — write methods throw
16
+ this.headerList = new HeaderList(res, { writable: false });
17
+
14
18
  // Make the instance callable
15
19
  const callable = (...args) => get(this.body, ...args);
16
20
  Object.setPrototypeOf(callable, this.constructor.prototype);
@@ -0,0 +1,497 @@
1
+ const ReadOnlyPropertyList = require('./readonly-property-list');
2
+
3
+ /**
4
+ * HeaderList — the `req.headerList` / `res.headerList` API in scripts.
5
+ *
6
+ * Extends PropertyList in dynamic mode: the header list is freshly read from the
7
+ * request's headers object on every access, and write operations manipulate the
8
+ * request config directly (preserving `__headersToDelete` tracking).
9
+ *
10
+ * Key differences from the base PropertyList:
11
+ * - **Case-insensitive** key lookups (HTTP headers are case-insensitive)
12
+ * - **Disabled headers** surfaced with `disabled: true`
13
+ * - **Read-only mode** for response headers (write methods throw)
14
+ * - Write operations manipulate the request config directly (preserving `__headersToDelete`)
15
+ *
16
+ * Accepts the raw request config object (`req`) directly — no dependency on BrunoRequest.
17
+ * Access: `req.headerList` (PropertyList API) vs `req.headers` (raw headers object).
18
+ *
19
+ * ---
20
+ *
21
+ * ## Header object shape
22
+ *
23
+ * Every header surfaced by this list is a plain object:
24
+ *
25
+ * ```js
26
+ * { key, value } // enabled header
27
+ * { key, value, disabled: true } // disabled header
28
+ * ```
29
+ *
30
+ * ---
31
+ *
32
+ * ## Read methods (case-insensitive key matching)
33
+ *
34
+ * | Method | Description | Example return value |
35
+ * |--------------------|----------------------------------------------------|-------------------------------------------------|
36
+ * | `get(name)` | Value of the header with matching key | `'application/json'` |
37
+ * | `one(name)` | Full header object for matching key | `{ key: 'Content-Type', value: 'application/json' }` |
38
+ * | `all()` | Cloned array of all header objects | `[{ key: 'Content-Type', … }, …]` |
39
+ * | `count()` | Number of headers | `3` |
40
+ *
41
+ * ## Search methods (case-insensitive key matching)
42
+ *
43
+ * | Method | Description | Example return value |
44
+ * |--------------------|----------------------------------------------------|----------------------|
45
+ * | `has(name)` | `true` if a header with that key exists | `true` |
46
+ * | `has(name, value)` | `true` if key exists **and** value matches | `false` |
47
+ * | `has(object)` | `true` if a header with `object.key` exists | `true` |
48
+ * | `find(fn, context?)` | First header matching the predicate function | `{ key: … }` |
49
+ * | `filter(fn, context?)` | Array of headers matching the predicate | `[{ key: … }, …]` |
50
+ * | `indexOf(item)` | Index of a header by string key or object, or `-1` | `0` |
51
+ *
52
+ * ## Iteration methods (optional `context` binds `this` in callbacks)
53
+ *
54
+ * | Method | Description |
55
+ * |------------------------------|----------------------------------------------|
56
+ * | `each(fn, context?)` | Calls `fn(header, index)` for every header |
57
+ * | `map(fn, context?)` | Returns a new array of mapped values |
58
+ * | `reduce(fn, initial?, context?)` | Reduces headers to a single value |
59
+ *
60
+ * ## Transform methods
61
+ *
62
+ * | Method | Description |
63
+ * |---------------------------------------------------------------|-------------------------------------------------------|
64
+ * | `toObject(excludeDisabled?, caseSensitive?, multiValue?, sanitizeKeys?)` | `{ key: value }` map of all headers |
65
+ * | `toString()` | HTTP wire format `Key: Value\n...`, skips disabled |
66
+ * | `toJSON()` | Same as `all()` — suitable for `JSON.stringify()` |
67
+ *
68
+ * ## Write methods (HeaderList overrides — synchronous, case-insensitive)
69
+ *
70
+ * | Method | Description |
71
+ * |-----------------------------------|----------------------------------------------------------|
72
+ * | `add(headerObj\|name, value?)` | Sets a header; accepts `{key,value}`, `"Key: Value"`, or `(name, value)` |
73
+ * | `upsert(headerObj\|name, value?)` | Sets (or replaces) a header; returns true/false/null |
74
+ * | `remove(predicate, context?)` | Deletes header(s) by name, predicate, or object |
75
+ * | `clear()` | Removes **all** headers (enabled and disabled) |
76
+ * | `populate(items\|string)` | Adds items, skipping keys that already exist |
77
+ * | `repopulate(items)` | Clears all, then populates with new items |
78
+ * | `assimilate(source, prune?)` | Merges headers; prune removes items not in source |
79
+ */
80
+ class HeaderList extends ReadOnlyPropertyList {
81
+ #req;
82
+ #writable;
83
+
84
+ /**
85
+ * @param {object} source - Request config (dynamic mode) or response object
86
+ * (static mode). Both must have a `headers` property.
87
+ * @param {object} [options]
88
+ * @param {boolean} [options.writable=true] - When false, write methods throw.
89
+ */
90
+ constructor(source, { writable = true } = {}) {
91
+ if (writable) {
92
+ // Dynamic mode — reads always reflect current req.headers
93
+ super({
94
+ keyProperty: 'key',
95
+ valueProperty: 'value',
96
+ dataSource: () => {
97
+ const headers = source.headers || {};
98
+ const enabled = Object.entries(headers).map(([key, value]) => ({ key, value }));
99
+ const disabled = (source.disabledHeaders || []).map((h) => ({
100
+ key: h.name,
101
+ value: h.value,
102
+ disabled: true
103
+ }));
104
+ return [...disabled, ...enabled];
105
+ }
106
+ });
107
+ this.#req = source;
108
+ } else {
109
+ // Static read-only mode — snapshot of response headers
110
+ const rawHeaders = (source && source.headers) || {};
111
+ super({
112
+ keyProperty: 'key',
113
+ valueProperty: 'value',
114
+ items: Object.entries(rawHeaders).map(([key, value]) => ({ key, value }))
115
+ });
116
+ this.#req = null;
117
+ }
118
+ this.#writable = writable;
119
+ }
120
+
121
+ #assertWritable() {
122
+ if (!this.#writable) {
123
+ throw new Error('HeaderList is read-only (response headers cannot be modified)');
124
+ }
125
+ }
126
+
127
+ // ── Case-insensitive key helpers ──────────────────────────────────────
128
+
129
+ /**
130
+ * Case-insensitive string comparison.
131
+ * @param {string} a
132
+ * @param {string} b
133
+ * @returns {boolean}
134
+ */
135
+ static #ciEquals(a, b) {
136
+ return typeof a === 'string' && typeof b === 'string'
137
+ ? a.toLowerCase() === b.toLowerCase()
138
+ : a === b;
139
+ }
140
+
141
+ /**
142
+ * Parse a "Key: Value" string into a { key, value } object.
143
+ * @param {string} str
144
+ * @returns {object|null}
145
+ */
146
+ static #parseHeaderString(str) {
147
+ if (typeof str !== 'string') return null;
148
+ const idx = str.indexOf(':');
149
+ if (idx === -1) return null;
150
+ return { key: str.substring(0, idx).trim(), value: str.substring(idx + 1).trim() };
151
+ }
152
+
153
+ // ── Blocked inherited methods ─────────────────────────────────────────
154
+ // idx is inherited from ReadOnlyPropertyList but not part of the
155
+ // HeaderList API. Set to undefined so it is not callable.
156
+ idx = undefined;
157
+
158
+ // ── Read method overrides (case-insensitive) ──────────────────────────
159
+
160
+ /**
161
+ * Get the value of a header by key (case-insensitive).
162
+ * @param {string} name
163
+ * @returns {*}
164
+ */
165
+ get(name) {
166
+ const item = this.all().findLast((i) => HeaderList.#ciEquals(i.key, name));
167
+ return item ? item.value : undefined;
168
+ }
169
+
170
+ /**
171
+ * Get the full header object by key (case-insensitive).
172
+ * @param {string} name
173
+ * @returns {object|undefined}
174
+ */
175
+ one(name) {
176
+ return this.all().findLast((i) => HeaderList.#ciEquals(i.key, name));
177
+ }
178
+
179
+ /**
180
+ * Check if a header exists (case-insensitive).
181
+ * Accepts a string key, a string key + value, or an object with `key`.
182
+ * @param {string|object} name - Header key string or object with `key` property
183
+ * @param {*} [value]
184
+ * @returns {boolean}
185
+ */
186
+ has(name, value) {
187
+ if (name && typeof name === 'object' && name.key) {
188
+ return this.all().some((i) => HeaderList.#ciEquals(i.key, name.key));
189
+ }
190
+ const items = this.all();
191
+ if (value !== undefined) {
192
+ return items.some((i) => HeaderList.#ciEquals(i.key, name) && i.value === value);
193
+ }
194
+ return items.some((i) => HeaderList.#ciEquals(i.key, name));
195
+ }
196
+
197
+ /**
198
+ * Get the index of an item (case-insensitive key matching).
199
+ * Accepts a string key or an object with { key, value }.
200
+ * @param {string|object} item
201
+ * @returns {number} -1 if not found
202
+ */
203
+ indexOf(item) {
204
+ const items = this.all();
205
+ if (typeof item === 'string') {
206
+ return items.findIndex((i) => HeaderList.#ciEquals(i.key, item));
207
+ }
208
+ if (!item || typeof item !== 'object') return -1;
209
+ return items.findIndex(
210
+ (i) => HeaderList.#ciEquals(i.key, item.key) && i.value === item.value
211
+ );
212
+ }
213
+
214
+ // ── Iteration overrides (optional context binding) ─────────────────
215
+
216
+ /** @param {Function} fn @param {*} [context] */
217
+ each(fn, context) {
218
+ super.each(context !== undefined ? fn.bind(context) : fn);
219
+ }
220
+
221
+ /** @param {Function} fn @param {*} [context] @returns {Array} */
222
+ filter(fn, context) {
223
+ return super.filter(context !== undefined ? fn.bind(context) : fn);
224
+ }
225
+
226
+ /** @param {Function} fn @param {*} [context] @returns {object|undefined} */
227
+ find(fn, context) {
228
+ return super.find(context !== undefined ? fn.bind(context) : fn);
229
+ }
230
+
231
+ /** @param {Function} fn @param {*} [context] @returns {Array} */
232
+ map(fn, context) {
233
+ return super.map(context !== undefined ? fn.bind(context) : fn);
234
+ }
235
+
236
+ /** @param {Function} fn @param {*} [accumulator] @param {*} [context] @returns {*} */
237
+ reduce(fn, ...args) {
238
+ const hasAccumulator = args.length > 0;
239
+ const hasContext = args.length > 1;
240
+ const bound = hasContext ? fn.bind(args[1]) : fn;
241
+ return hasAccumulator ? super.reduce(bound, args[0]) : super.reduce(bound);
242
+ }
243
+
244
+ // ── Write methods (direct request config manipulation) ────────────────
245
+
246
+ /**
247
+ * Add a header. Accepts a { key, value } object, a "Key: Value" string,
248
+ * or two arguments (name, value). Delegates to upsert().
249
+ *
250
+ * @param {object|string} itemOrName - Header object, "Key: Value" string, or header name
251
+ * @param {string} [value] - Header value (when using two-arg form)
252
+ */
253
+ add(itemOrName, value) {
254
+ if (typeof itemOrName === 'string' && value !== undefined) {
255
+ this.upsert({ key: itemOrName, value });
256
+ return;
257
+ }
258
+ if (typeof itemOrName === 'string') {
259
+ itemOrName = HeaderList.#parseHeaderString(itemOrName);
260
+ }
261
+ this.upsert(itemOrName);
262
+ }
263
+
264
+ /**
265
+ * Set (or replace) a header on the request (case-insensitive key match).
266
+ * Accepts a { key, value } object or two arguments (name, value).
267
+ * @param {object|string} itemOrName - Header object with `key` and `value`, or header name
268
+ * @param {string} [value] - Header value (when using two-arg form)
269
+ * @returns {boolean|null} `true` if added, `false` if updated, `null` if input was nil
270
+ */
271
+ upsert(itemOrName, value) {
272
+ this.#assertWritable();
273
+ let item = itemOrName;
274
+ if (typeof itemOrName === 'string') {
275
+ item = { key: itemOrName, value };
276
+ }
277
+ if (!item || typeof item !== 'object' || !item.key) return null;
278
+ const headers = this.#req.headers || {};
279
+ const existingKey = Object.keys(headers).find(
280
+ (k) => HeaderList.#ciEquals(k, item.key)
281
+ );
282
+ const existed = existingKey !== undefined;
283
+ // Remove old-cased key if casing differs, tracking it for the axios interceptor
284
+ if (existed && existingKey !== item.key) {
285
+ this.#deleteHeader(existingKey);
286
+ }
287
+ headers[item.key] = item.value;
288
+ // Remove from __headersToDelete since we just (re-)added this header
289
+ const toDelete = this.#req.__headersToDelete;
290
+ if (toDelete) {
291
+ const idx = toDelete.findIndex((k) => HeaderList.#ciEquals(k, item.key));
292
+ if (idx !== -1) toDelete.splice(idx, 1);
293
+ }
294
+ return !existed;
295
+ }
296
+
297
+ /**
298
+ * Remove header(s) matching a predicate, key string, or item reference.
299
+ * String and object removal are case-insensitive.
300
+ * @param {Function|string|object} predicate
301
+ * @param {*} [context] - Bind `this` for function predicates
302
+ */
303
+ remove(predicate, context) {
304
+ this.#assertWritable();
305
+ if (typeof predicate === 'function') {
306
+ const bound = context !== undefined ? predicate.bind(context) : predicate;
307
+ const headers = this.all();
308
+ for (const header of headers) {
309
+ if (bound(header)) {
310
+ if (header.disabled) {
311
+ this.#removeDisabledHeader(header.key);
312
+ } else {
313
+ this.#deleteHeaderCI(header.key);
314
+ }
315
+ }
316
+ }
317
+ } else if (typeof predicate === 'string') {
318
+ this.#deleteHeaderCI(predicate);
319
+ this.#removeDisabledHeader(predicate);
320
+ } else if (predicate && typeof predicate === 'object' && predicate.key) {
321
+ this.#deleteHeaderCI(predicate.key);
322
+ this.#removeDisabledHeader(predicate.key);
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Delete a header by exact key and track it in `__headersToDelete`
328
+ * so the axios interceptor can suppress default headers added later.
329
+ * @param {string} name
330
+ */
331
+ #deleteHeader(name) {
332
+ delete this.#req.headers[name];
333
+ if (!this.#req.__headersToDelete) {
334
+ this.#req.__headersToDelete = [];
335
+ }
336
+ if (!this.#req.__headersToDelete.includes(name)) {
337
+ this.#req.__headersToDelete.push(name);
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Delete an enabled header by key (case-insensitive).
343
+ * @param {string} key
344
+ */
345
+ #deleteHeaderCI(key) {
346
+ const headers = this.#req.headers || {};
347
+ const matchingKey = Object.keys(headers).find(
348
+ (k) => HeaderList.#ciEquals(k, key)
349
+ );
350
+ if (matchingKey) {
351
+ this.#deleteHeader(matchingKey);
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Remove all disabled headers matching a key (case-insensitive).
357
+ * @param {string} key
358
+ */
359
+ #removeDisabledHeader(key) {
360
+ const arr = this.#req.disabledHeaders;
361
+ if (!arr) return;
362
+ this.#req.disabledHeaders = arr.filter(
363
+ (h) => !HeaderList.#ciEquals(h.name, key)
364
+ );
365
+ }
366
+
367
+ /**
368
+ * Remove all headers (enabled and disabled) from the request.
369
+ */
370
+ clear() {
371
+ this.#assertWritable();
372
+ const headers = this.all();
373
+ for (const header of headers) {
374
+ if (!header.disabled) {
375
+ this.#deleteHeader(header.key);
376
+ }
377
+ }
378
+ if (this.#req.disabledHeaders) {
379
+ this.#req.disabledHeaders = [];
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Load one or more headers into the list (without clearing existing ones).
385
+ * Accepts an array of { key, value } objects or a multi-line "Key: Value" string.
386
+ *
387
+ * Headers whose key already exists are skipped (case-insensitive).
388
+ * Note: Postman's populate adds duplicate keys because Postman supports
389
+ * multiple headers with the same name. Bruno does not, so we skip
390
+ * existing keys to preserve the current value.
391
+ *
392
+ * @param {Array|string} items
393
+ */
394
+ populate(items) {
395
+ this.#assertWritable();
396
+ if (typeof items === 'string') {
397
+ const lines = items.split(/\r?\n/).filter((l) => l.trim());
398
+ for (const line of lines) {
399
+ const parsed = HeaderList.#parseHeaderString(line);
400
+ if (parsed && !this.has(parsed.key)) {
401
+ this.add(parsed);
402
+ }
403
+ }
404
+ return;
405
+ }
406
+ const list = Array.isArray(items) ? items : [];
407
+ for (const item of list) {
408
+ if (item && item.key && !this.has(item.key)) {
409
+ this.add(item);
410
+ }
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Clear all headers and repopulate with new items.
416
+ * @param {Array|string} items
417
+ */
418
+ repopulate(items) {
419
+ this.clear();
420
+ this.populate(items);
421
+ }
422
+
423
+ // ── Transform overrides ───────────────────────────────────────────────
424
+
425
+ /**
426
+ * Convert to a plain object. Matches Postman's PropertyList.toObject() signature.
427
+ * @param {boolean} [excludeDisabled=false] - If true, skip disabled headers
428
+ * @param {boolean} [caseSensitive=true] - If false, lowercase all keys
429
+ * @param {boolean} [multiValue=false] - If true, only the first value of a duplicate key is kept
430
+ * @param {boolean} [sanitizeKeys=false] - If true, skip headers with falsy keys
431
+ * @returns {object}
432
+ */
433
+ toObject(excludeDisabled, caseSensitive, multiValue, sanitizeKeys) {
434
+ const result = {};
435
+ for (const item of this.all()) {
436
+ if (excludeDisabled && item.disabled) continue;
437
+ const key = caseSensitive === false ? item.key.toLowerCase() : item.key;
438
+ if (sanitizeKeys && !key) continue;
439
+ if (multiValue) {
440
+ if (!(key in result)) {
441
+ result[key] = item.value;
442
+ }
443
+ } else {
444
+ result[key] = item.value;
445
+ }
446
+ }
447
+ return result;
448
+ }
449
+
450
+ /**
451
+ * Convert to HTTP wire-format string, skipping disabled headers.
452
+ * Matches Postman's Header.unparse() behavior: `Key: Value\n...`
453
+ * @returns {string}
454
+ */
455
+ toString() {
456
+ const headers = this.all().filter((h) => !h.disabled);
457
+ if (headers.length === 0) return '';
458
+ return headers.map((h) => `${h.key}: ${h.value}`).join('\n') + '\n';
459
+ }
460
+
461
+ /**
462
+ * Merge items from another PropertyList or array.
463
+ * @param {PropertyList|Array} source - Source of items to merge
464
+ * @param {boolean} [prune=false] - If true, remove items not present in source after merging
465
+ */
466
+ assimilate(source, prune) {
467
+ this.#assertWritable();
468
+ let items;
469
+ if (ReadOnlyPropertyList.isPropertyList(source)) {
470
+ items = source.all();
471
+ } else if (Array.isArray(source)) {
472
+ items = source;
473
+ } else {
474
+ items = [];
475
+ }
476
+ // Merge source items into this list
477
+ for (const item of items) {
478
+ this.add(item);
479
+ }
480
+ // Prune: remove items from this list that are not in source
481
+ if (prune && items.length > 0) {
482
+ const sourceKeys = new Set(items.map((i) => (i.key || '').toLowerCase()));
483
+ const toRemove = this.all().filter(
484
+ (h) => !sourceKeys.has(h.key.toLowerCase())
485
+ );
486
+ for (const header of toRemove) {
487
+ if (header.disabled) {
488
+ this.#removeDisabledHeader(header.key);
489
+ } else {
490
+ this.#deleteHeader(header.key);
491
+ }
492
+ }
493
+ }
494
+ }
495
+ }
496
+
497
+ module.exports = HeaderList;