@outputai/core 0.10.1-next.b7b2fbe.0 → 0.10.1-next.bf95a6f.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.
Files changed (99) hide show
  1. package/package.json +13 -7
  2. package/src/bus.js +58 -20
  3. package/src/bus.spec.js +135 -77
  4. package/src/consts.js +7 -3
  5. package/src/errors.d.ts +12 -2
  6. package/src/errors.js +17 -3
  7. package/src/errors.spec.js +19 -0
  8. package/src/helpers/activity_options.js +21 -0
  9. package/src/helpers/activity_options.spec.js +55 -0
  10. package/src/helpers/error_serializer.js +236 -0
  11. package/src/helpers/error_serializer.spec.js +413 -0
  12. package/src/helpers/errors.js +23 -14
  13. package/src/helpers/errors.spec.js +31 -75
  14. package/src/helpers/fetch.js +1 -38
  15. package/src/helpers/fetch.spec.js +1 -39
  16. package/src/helpers/function.js +13 -0
  17. package/src/helpers/function.spec.js +23 -1
  18. package/src/helpers/object.js +48 -35
  19. package/src/helpers/object.spec.js +123 -75
  20. package/src/helpers/redact.js +51 -0
  21. package/src/helpers/redact.spec.js +66 -0
  22. package/src/helpers/string.js +10 -0
  23. package/src/helpers/string.spec.js +36 -1
  24. package/src/hooks/index.d.ts +76 -78
  25. package/src/hooks/index.js +57 -49
  26. package/src/hooks/index.spec.js +104 -105
  27. package/src/hooks/pending_hooks.js +24 -0
  28. package/src/hooks/pending_hooks.spec.js +62 -0
  29. package/src/interface/evaluator.d.ts +10 -9
  30. package/src/interface/evaluator.js +1 -6
  31. package/src/interface/evaluator.spec.js +10 -10
  32. package/src/interface/index.js +2 -1
  33. package/src/interface/step.d.ts +16 -15
  34. package/src/interface/step.js +1 -6
  35. package/src/interface/step.spec.js +10 -10
  36. package/src/interface/validations/index.js +33 -53
  37. package/src/interface/validations/index.spec.js +25 -17
  38. package/src/interface/webhook.js +1 -2
  39. package/src/interface/webhook.spec.js +25 -19
  40. package/src/interface/workflow.d.ts +25 -18
  41. package/src/interface/workflow.js +64 -85
  42. package/src/interface/workflow.spec.js +220 -226
  43. package/src/interface/workflow_activity_options.js +2 -5
  44. package/src/interface/workflow_utils.d.ts +15 -0
  45. package/src/interface/workflow_utils.js +19 -0
  46. package/src/interface/workflow_utils.spec.js +82 -1
  47. package/src/sdk/helpers/objects.d.ts +24 -19
  48. package/src/sdk/runtime/events.d.ts +4 -6
  49. package/src/sdk/runtime/events.js +2 -15
  50. package/src/sdk/runtime/events.spec.js +14 -92
  51. package/src/temporal/client.js +47 -0
  52. package/src/temporal/client.spec.js +124 -0
  53. package/src/temporal/index.d.ts +32 -0
  54. package/src/temporal/index.js +1 -0
  55. package/src/tracing/trace_engine.js +9 -11
  56. package/src/tracing/trace_engine.spec.js +67 -28
  57. package/src/worker/catalog_workflow/catalog_job.js +3 -3
  58. package/src/worker/catalog_workflow/catalog_job.spec.js +1 -1
  59. package/src/worker/configs.js +0 -3
  60. package/src/worker/global_functions.js +2 -2
  61. package/src/worker/global_functions.spec.js +3 -3
  62. package/src/worker/index.js +24 -11
  63. package/src/worker/index.spec.js +36 -16
  64. package/src/worker/interceptors/activity.js +34 -21
  65. package/src/worker/interceptors/activity.spec.js +77 -172
  66. package/src/worker/interceptors/workflow.js +58 -16
  67. package/src/worker/interceptors/workflow.spec.js +183 -56
  68. package/src/worker/loader/activities.js +56 -40
  69. package/src/worker/loader/activities.spec.js +21 -22
  70. package/src/worker/loader/workflows.js +16 -3
  71. package/src/worker/loader/workflows.spec.js +36 -5
  72. package/src/worker/log_hooks.js +22 -19
  73. package/src/worker/log_hooks.spec.js +14 -6
  74. package/src/worker/sinks.js +8 -8
  75. package/src/worker/sinks.spec.js +9 -9
  76. package/src/worker/telemetry.js +2 -1
  77. package/src/worker/temporal_logger.js +33 -0
  78. package/src/worker/temporal_logger.spec.js +90 -0
  79. package/src/worker/webpack_loaders/consts.js +6 -0
  80. package/src/worker/webpack_loaders/tools.js +1 -146
  81. package/src/worker/webpack_loaders/tools.spec.js +0 -23
  82. package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.js +24 -32
  83. package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.spec.js +82 -279
  84. package/src/worker/webpack_loaders/workflow_rewriter/index.mjs +6 -7
  85. package/src/worker/webpack_loaders/workflow_rewriter/index.spec.js +57 -119
  86. package/src/worker/webpack_loaders/workflow_rewriter/rewrite_activity_calls.js +88 -0
  87. package/src/worker/webpack_loaders/workflow_rewriter/rewrite_activity_calls.spec.js +165 -0
  88. package/src/worker/webpack_loaders/workflow_validator/index.mjs +96 -29
  89. package/src/worker/webpack_loaders/workflow_validator/index.spec.js +110 -583
  90. package/src/worker/webpack_loaders/{npm_workflow_export_resolve.js → workflow_validator/npm_workflow_export_resolve.js} +1 -1
  91. package/src/worker/webpack_loaders/{npm_workflow_export_resolve.spec.js → workflow_validator/npm_workflow_export_resolve.spec.js} +1 -1
  92. package/src/helpers/aggregations.js +0 -44
  93. package/src/helpers/aggregations.spec.js +0 -92
  94. package/src/tracing/tools/utils.js +0 -42
  95. package/src/tracing/tools/utils.spec.js +0 -83
  96. package/src/worker/proxy.js +0 -20
  97. package/src/worker/proxy.spec.js +0 -51
  98. package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.js +0 -193
  99. package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.spec.js +0 -123
@@ -1,89 +1,45 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { ApplicationFailure } from '@temporalio/common';
3
- import { buildApplicationFailureWithDetails } from './errors.js';
2
+ import { inheritsFromAnyNamedType } from './errors.js';
4
3
 
5
- class CustomFailure extends Error {}
4
+ describe( 'inheritsFromAnyNamedType', () => {
5
+ class BaseFailure extends Error {}
6
+ class DomainFailure extends BaseFailure {}
7
+ class RequestFailure extends DomainFailure {}
8
+ class UnrelatedFailure extends Error {}
6
9
 
7
- describe( 'buildApplicationFailureWithDetails', () => {
8
- it( 'wraps a regular error in an ApplicationFailure with appended details', () => {
9
- const error = new Error( 'step failed' );
10
- const info = { aggregations: { cost: { total: 1 } } };
10
+ it( 'matches exact, parent, and grandparent constructor names', () => {
11
+ const error = new RequestFailure( 'request failed' );
11
12
 
12
- const failure = buildApplicationFailureWithDetails( error, info );
13
-
14
- expect( failure ).toBeInstanceOf( ApplicationFailure );
15
- expect( failure ).toMatchObject( {
16
- message: 'step failed',
17
- type: 'Error',
18
- nonRetryable: false,
19
- details: [ info ],
20
- cause: error
21
- } );
22
- } );
23
-
24
- it( 'uses the original constructor name for custom errors', () => {
25
- const error = new CustomFailure( 'custom failed' );
26
- const info = { trace: { destinations: { local: '/tmp/trace' } } };
27
-
28
- const failure = buildApplicationFailureWithDetails( error, info );
29
-
30
- expect( failure ).toMatchObject( {
31
- message: 'custom failed',
32
- type: 'CustomFailure',
33
- details: [ info ],
34
- cause: error
35
- } );
13
+ expect( inheritsFromAnyNamedType( error, [ 'RequestFailure' ] ) ).toBe( true );
14
+ expect( inheritsFromAnyNamedType( error, [ 'DomainFailure' ] ) ).toBe( true );
15
+ expect( inheritsFromAnyNamedType( error, [ 'BaseFailure' ] ) ).toBe( true );
16
+ expect( inheritsFromAnyNamedType( error, [ 'OtherFailure', 'BaseFailure' ] ) ).toBe( true );
36
17
  } );
37
18
 
38
- it( 'preserves existing details and appends new info without mutating the original error', () => {
39
- const existingDetails = [ { domain: { reason: 'bad-input' } } ];
40
- const error = new Error( 'step failed' );
41
- error.details = existingDetails;
42
- const info = { aggregations: { httpRequests: { total: 1 } } };
43
-
44
- const failure = buildApplicationFailureWithDetails( error, info );
45
-
46
- expect( failure.details ).toEqual( [
47
- { domain: { reason: 'bad-input' } },
48
- info
49
- ] );
50
- expect( error.details ).toBe( existingDetails );
51
- expect( error.details ).toEqual( [ { domain: { reason: 'bad-input' } } ] );
19
+ it( 'does not match unrelated or descendant constructor names', () => {
20
+ expect( inheritsFromAnyNamedType( new RequestFailure(), [ 'UnrelatedFailure' ] ) ).toBe( false );
21
+ expect( inheritsFromAnyNamedType( new BaseFailure(), [ 'RequestFailure' ] ) ).toBe( false );
22
+ expect( inheritsFromAnyNamedType( new UnrelatedFailure(), [ 'BaseFailure' ] ) ).toBe( false );
52
23
  } );
53
24
 
54
- it( 'ignores non-array details on the original error', () => {
55
- const error = new Error( 'step failed' );
56
- error.details = { domain: { reason: 'bad-input' } };
57
- const info = { aggregations: { tokens: { total: 3 } } };
58
-
59
- const failure = buildApplicationFailureWithDetails( error, info );
25
+ it( 'ignores a spoofed own constructor property', () => {
26
+ const error = new RequestFailure();
27
+ Object.defineProperty( error, 'constructor', {
28
+ value: { name: 'SpoofedFailure' }
29
+ } );
60
30
 
61
- expect( failure.details ).toEqual( [ info ] );
31
+ expect( inheritsFromAnyNamedType( error, [ 'SpoofedFailure' ] ) ).toBe( false );
32
+ expect( inheritsFromAnyNamedType( error, [ 'RequestFailure' ] ) ).toBe( true );
62
33
  } );
63
34
 
64
- it( 'preserves ApplicationFailure type, nonRetryable flag, and details while avoiding self-cause', () => {
65
- const original = ApplicationFailure.create( {
66
- message: 'application failed',
67
- type: 'DomainFailure',
68
- nonRetryable: true,
69
- details: [ { domain: { reason: 'bad-input' } } ]
70
- } );
71
- const info = { aggregations: { cost: { total: 2 } } };
72
-
73
- const failure = buildApplicationFailureWithDetails( original, info );
35
+ it( 'rejects empty names and invalid values without false positives', () => {
36
+ const unnamedConstructor = function NamedConstructor() {};
37
+ Object.defineProperty( unnamedConstructor, 'name', { value: '' } );
38
+ const value = Object.create( { constructor: unnamedConstructor } );
74
39
 
75
- expect( failure ).toBeInstanceOf( ApplicationFailure );
76
- expect( failure ).not.toBe( original );
77
- expect( failure.cause ).toBe( original );
78
- expect( failure.cause ).not.toBe( failure );
79
- expect( failure ).toMatchObject( {
80
- message: 'application failed',
81
- type: 'DomainFailure',
82
- nonRetryable: true,
83
- details: [
84
- { domain: { reason: 'bad-input' } },
85
- info
86
- ]
87
- } );
40
+ expect( inheritsFromAnyNamedType( value, [ '', undefined ] ) ).toBe( false );
41
+ expect( inheritsFromAnyNamedType( value, [] ) ).toBe( false );
42
+ expect( inheritsFromAnyNamedType( null, [ 'BaseFailure' ] ) ).toBe( false );
43
+ expect( inheritsFromAnyNamedType( 'failure', [ 'String' ] ) ).toBe( false );
88
44
  } );
89
45
  } );
@@ -1,42 +1,5 @@
1
1
  import { FatalError } from '#errors';
2
-
3
- /** Matches red int "hot-red-pie", but not int "redact" */
4
- const wordMatcher = term => new RegExp( `(?<![a-z\\d])${term}(?![a-z\\d])`, 'i' );
5
-
6
- /** Matches red in "acquired", but not in "redact" */
7
- const wordEndMatcher = term => new RegExp( `${term}(?![a-z\\d])`, 'i' );
8
-
9
- /**
10
- * Redacts sensitive headers
11
- * @param {object} headers
12
- * @returns {object} The redacted headers
13
- */
14
- export const redactHeaders = headers => {
15
- /** Header names that look sensitive by substring rules but are not secret material. */
16
- const ignoreHeaders = new Set( [
17
- 'x-csrf-token',
18
- 'public-key-pins'
19
- ] );
20
-
21
- /** * Sensitive header patterns for redaction (case-insensitive). */
22
- const sensitiveHeadersPatterns = [
23
- // matches headers that contain these exact words
24
- wordMatcher( 'authorization' ),
25
- wordMatcher( 'token' ),
26
- wordMatcher( 'secret' ),
27
- wordMatcher( 'password' ),
28
- wordMatcher( 'pwd' ),
29
- wordMatcher( 'cookie' ),
30
- // matches header that contain words ending with these sequences
31
- wordEndMatcher( 'key' )
32
- ];
33
-
34
- return Object.entries( headers ).reduce( ( redacted, [ key, value ] ) => {
35
- const lowKey = key.toLowerCase();
36
- const isSensitive = !ignoreHeaders.has( lowKey ) && sensitiveHeadersPatterns.some( rx => rx.test( lowKey ) );
37
- return Object.assign( redacted, { [key]: isSensitive ? '[REDACTED]' : value } );
38
- }, {} );
39
- };
2
+ import { redactHeaders } from './redact.js';
40
3
 
41
4
  /**
42
5
  * Consume the body of a Response according it its content-type and returns it
@@ -1,44 +1,6 @@
1
1
  import { afterEach, describe, it, expect } from 'vitest';
2
2
  import { Readable } from 'node:stream';
3
- import { hydrateHeaders, redactHeaders, serializeBodyAndInferContentType, serializeResponse } from './fetch.js';
4
-
5
- describe( 'redactHeaders', () => {
6
- it( 'redacts sensitive header names', () => {
7
- const result = redactHeaders( {
8
- Authorization: 'Bearer token',
9
- 'X-Api-Key': 'api-key',
10
- Cookie: 'session=id',
11
- 'x-client-secret': 'secret'
12
- } );
13
-
14
- expect( result ).toEqual( {
15
- Authorization: '[REDACTED]',
16
- 'X-Api-Key': '[REDACTED]',
17
- Cookie: '[REDACTED]',
18
- 'x-client-secret': '[REDACTED]'
19
- } );
20
- } );
21
-
22
- it( 'preserves non-sensitive header names and ignored false positives', () => {
23
- const result = redactHeaders( {
24
- Accept: 'application/json',
25
- 'Content-Type': 'application/json',
26
- 'x-csrf-token': 'csrf-token',
27
- 'public-key-pins': 'pin'
28
- } );
29
-
30
- expect( result ).toEqual( {
31
- Accept: 'application/json',
32
- 'Content-Type': 'application/json',
33
- 'x-csrf-token': 'csrf-token',
34
- 'public-key-pins': 'pin'
35
- } );
36
- } );
37
-
38
- it( 'handles empty headers', () => {
39
- expect( redactHeaders( {} ) ).toEqual( {} );
40
- } );
41
- } );
3
+ import { hydrateHeaders, serializeBodyAndInferContentType, serializeResponse } from './fetch.js';
42
4
 
43
5
  describe( 'hydrateHeaders', () => {
44
6
  afterEach( () => {
@@ -13,3 +13,16 @@ export const runOnce = fn => {
13
13
  return state.result;
14
14
  };
15
15
  };
16
+
17
+ /**
18
+ * Calls a given function and in case it throws errors, returns `undefined`
19
+ * @param {Function} fn
20
+ * @returns {unknown|undefined}
21
+ */
22
+ export const tryOrUndefined = fn => {
23
+ try {
24
+ return fn();
25
+ } catch {
26
+ return undefined; // eslint-disable-line consistent-return
27
+ }
28
+ };
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
- import { runOnce } from './function.js';
2
+ import { runOnce, tryOrUndefined } from './function.js';
3
3
 
4
4
  describe( 'runOnce', () => {
5
5
  it( 'calls the wrapped function only once', () => {
@@ -46,3 +46,25 @@ describe( 'runOnce', () => {
46
46
  expect( fn ).toHaveBeenCalledOnce();
47
47
  } );
48
48
  } );
49
+
50
+ describe( 'tryOrUndefined', () => {
51
+ it( 'returns the invoked function result', () => {
52
+ const fn = vi.fn( () => 'result' );
53
+
54
+ expect( tryOrUndefined( fn ) ).toBe( 'result' );
55
+ expect( fn ).toHaveBeenCalledOnce();
56
+ } );
57
+
58
+ it( 'returns undefined when the invoked function throws', () => {
59
+ const fn = vi.fn( () => {
60
+ throw new Error( 'unavailable' );
61
+ } );
62
+
63
+ expect( tryOrUndefined( fn ) ).toBeUndefined();
64
+ expect( fn ).toHaveBeenCalledOnce();
65
+ } );
66
+
67
+ it( 'preserves an explicit undefined result', () => {
68
+ expect( tryOrUndefined( () => undefined ) ).toBeUndefined();
69
+ } );
70
+ } );
@@ -24,58 +24,71 @@ export const clone = v => {
24
24
  };
25
25
 
26
26
  /**
27
- * Creates a new object merging object "b" onto object "a", using a resolver function to define the value to keep.
28
- * - Object "b" fields that also exists on "a" will have their value defined by the "resolver" function
29
- * - Object "b" fields that don't exist on object "a" will be added;
30
- * - Object "a" fields that don't exist on object "b" will be preserved;
27
+ * Creates a new object recursively merging the rightmost object over the previous one using a resolver function.
28
+ * Give two objects, (L)eft and (R)right
29
+ * - Object "R" will overwrite fields on object "L" based on the result of the resolver function;
30
+ * - Object "R" fields that don't exist on object "L" will be added;
31
+ * - Object "L" fields that don't exist on object "R" will be preserved;
31
32
  *
32
- * If "b" isn't an object, a new object equal to "a" is returned
33
+ * If "R" isn't an object, a new object equal to "L" is returned.
33
34
  *
34
- * @param {object} a - The base object
35
- * @param {object} b - The target object
36
- * @param {function} resolver - A function that return the value to be kept. First argument is value a, second is value b
35
+ * The resolver function will define the final value of the merge, it receives two args value "L" and "R".
36
+ *
37
+ *
38
+ * @param {object} base - The base object
39
+ * @param {...(object|function)} args - Target objects followed by the resolver function
37
40
  * @returns {object} A new object
38
41
  */
39
- export const deepMergeWithResolver = ( a, b, resolver ) => {
40
- if ( !isPlainObject( a ) ) {
41
- throw new Error( 'Parameter "a" is not an object.' );
42
+ export const deepMergeWithResolver = ( base, ...args ) => {
43
+ const objects = args.slice( 0, -1 );
44
+ const resolver = args.at( -1 );
45
+
46
+ if ( !isPlainObject( base ) ) {
47
+ throw new Error( 'First argument (base object) is not an object.' );
42
48
  }
43
- if ( !isPlainObject( b ) ) {
44
- return clone( a );
49
+
50
+ if ( typeof resolver !== 'function' ) {
51
+ throw new Error( 'Last argument (resolver) is not a function.' );
45
52
  }
46
- return Object.entries( b ).reduce( ( obj, [ k, v ] ) =>
47
- Object.assign( obj, {
48
- [k]: ( () => {
49
- if ( isPlainObject( v ) && isPlainObject( a[k] ) ) {
50
- return deepMergeWithResolver( a[k], v, resolver );
51
- }
52
- if ( Object.hasOwn( a, k ) ) {
53
- return resolver( a[k], v );
54
- }
55
- return v;
56
- } )()
57
- } )
58
- , clone( a ) );
53
+
54
+ return objects.reduce( ( merged, object ) => {
55
+ if ( !isPlainObject( object ) ) {
56
+ return merged;
57
+ }
58
+
59
+ for ( const [ k, v ] of Object.entries( object ) ) {
60
+ if ( isPlainObject( v ) && isPlainObject( merged[k] ) ) {
61
+ merged[k] = deepMergeWithResolver( merged[k], v, resolver );
62
+ } else if ( Object.hasOwn( merged, k ) ) {
63
+ merged[k] = resolver( merged[k], v );
64
+ } else {
65
+ merged[k] = v;
66
+ }
67
+ }
68
+ return merged;
69
+ }, clone( base ) );
59
70
  };
60
71
 
61
72
  /**
62
- * Creates a new object merging object "b" onto object "a" biased to "b":
63
- * - Object "b" will overwrite fields on object "a";
64
- * - Object "b" fields that don't exist on object "a" will be added;
65
- * - Object "a" fields that don't exist on object "b" will be preserved;
73
+ * Creates a new object recursively merging the rightmost object over the previous one.
74
+ * Give two objects, (L)eft and (R)right
75
+ * - Object "R" will overwrite fields on object "L";
76
+ * - Object "R" fields that don't exist on object "L" will be added;
77
+ * - Object "L" fields that don't exist on object "R" will be preserved;
66
78
  *
67
- * If "b" isn't an object, a new object equal to "a" is returned
79
+ * If "R" isn't an object, a new object equal to "L" is returned.
68
80
  *
69
- * @param {object} a - The base object
70
- * @param {object} b - The target object
81
+ * @param {object} base - The base object
82
+ * @param {...object} rest - The target objects
71
83
  * @returns {object} A new object
72
84
  */
73
- export const deepMerge = ( a, b ) => deepMergeWithResolver( a, b, ( _, b ) => b );
85
+ export const deepMerge = ( base, ...rest ) =>
86
+ deepMergeWithResolver( base, ...rest, ( _, b ) => b );
74
87
 
75
88
  /**
76
89
  * Adds an non-writable, non-configurable and non-enumerable property to an object
77
90
  * @param {object} obj
78
- * @param {string} key
91
+ * @param {string|Symbol} key
79
92
  * @param {any} value
80
93
  * @returns
81
94
  */
@@ -95,98 +95,86 @@ describe( 'clone', () => {
95
95
  } );
96
96
 
97
97
  describe( 'deepMerge', () => {
98
- it( 'Overwrites properties in object "a"', () => {
98
+ it( 'returns a clone when only the base object is provided', () => {
99
99
  const a = {
100
- a: 1,
101
- b: {
102
- c: 2
103
- }
100
+ nested: { value: 1 }
104
101
  };
105
- const b = {
106
- a: false,
107
- b: {
108
- c: true
109
- }
110
- };
111
- expect( deepMerge( a, b ) ).toEqual( {
112
- a: false,
113
- b: {
114
- c: true
115
- }
116
- } );
117
- } );
102
+ const result = deepMerge( a );
118
103
 
119
- it( 'Adds properties existing in "b" but absent in "a"', () => {
120
- const a = {
121
- a: 1
122
- };
123
- const b = {
124
- a: false,
125
- b: true
126
- };
127
- expect( deepMerge( a, b ) ).toEqual( {
128
- a: false,
129
- b: true
104
+ a.nested.value = 2;
105
+
106
+ expect( result ).toEqual( {
107
+ nested: { value: 1 }
130
108
  } );
131
109
  } );
132
110
 
133
- it( 'Keep extra properties in "a"', () => {
134
- const a = {
135
- a: 1
136
- };
137
- const b = {
138
- b: true
139
- };
140
- expect( deepMerge( a, b ) ).toEqual( {
141
- a: 1,
142
- b: true
111
+ it( 'Throws when first argument is not a plain object', () => {
112
+ expect( () => deepMerge( null ) ).toThrow( Error );
113
+ } );
114
+
115
+ it( 'merges multiple objects from left to right using rightmost values', () => {
116
+ expect( deepMerge(
117
+ {
118
+ a: 1,
119
+ nested: {
120
+ a: 'base',
121
+ kept: true
122
+ }
123
+ },
124
+ {
125
+ b: 2,
126
+ nested: {
127
+ a: 'first',
128
+ b: 'first'
129
+ }
130
+ },
131
+ {
132
+ a: 3,
133
+ nested: {
134
+ b: 'second',
135
+ c: 'second'
136
+ }
137
+ }
138
+ ) ).toEqual( {
139
+ a: 3,
140
+ b: 2,
141
+ nested: {
142
+ a: 'first',
143
+ b: 'second',
144
+ c: 'second',
145
+ kept: true
146
+ }
143
147
  } );
144
148
  } );
145
149
 
146
- it( 'Merge object is a clone', () => {
147
- const a = {
148
- a: 1
149
- };
150
- const b = {
151
- b: 1
152
- };
153
- const result = deepMerge( a, b );
154
- a.a = 2;
155
- b.b = 2;
156
- expect( result.a ).toEqual( 1 );
150
+ it( 'ignores non-object values among multiple overlays', () => {
151
+ expect( deepMerge(
152
+ { a: 1 },
153
+ null,
154
+ { b: 2 },
155
+ undefined,
156
+ { a: 3 }
157
+ ) ).toEqual( {
158
+ a: 3,
159
+ b: 2
160
+ } );
157
161
  } );
162
+ } );
158
163
 
159
- it( 'Returns copy of "a" if "b" is not an object', () => {
164
+ describe( 'deepMergeWithResolver', () => {
165
+ it( 'returns a clone when only the base object and resolver are provided', () => {
160
166
  const a = {
161
- a: 1
167
+ nested: { value: 1 }
162
168
  };
163
- expect( deepMerge( a, null ) ).toEqual( { a: 1 } );
164
- expect( deepMerge( a, undefined ) ).toEqual( { a: 1 } );
165
- } );
169
+ const result = deepMergeWithResolver( a, ( x, y ) => x + y );
166
170
 
167
- it( 'Copy of object "a" is a clone', () => {
168
- const a = {
169
- a: 1
170
- };
171
- const result = deepMerge( a, null );
172
- a.a = 2;
173
- expect( result.a ).toEqual( 1 );
174
- } );
171
+ a.nested.value = 2;
175
172
 
176
- it( 'Throws when first argument is not a plain object', () => {
177
- expect( () => deepMerge( Function ) ).toThrow( Error );
178
- expect( () => deepMerge( () => {} ) ).toThrow( Error );
179
- expect( () => deepMerge( 'a' ) ).toThrow( Error );
180
- expect( () => deepMerge( true ) ).toThrow( Error );
181
- expect( () => deepMerge( /a/ ) ).toThrow( Error );
182
- expect( () => deepMerge( [] ) ).toThrow( Error );
183
- expect( () => deepMerge( class Foo {}, class Foo {} ) ).toThrow( Error );
184
- expect( () => deepMerge( Number.constructor, Number.constructor ) ).toThrow( Error );
185
- expect( () => deepMerge( Number.constructor.prototype, Number.constructor.prototype ) ).toThrow( Error );
173
+ expect( result ).toEqual( {
174
+ nested: { value: 1 }
175
+ } );
186
176
  } );
187
- } );
188
177
 
189
- describe( 'deepMergeWithResolver', () => {
190
178
  it( 'uses resolver for existing leaf values, including nested leaves', () => {
191
179
  const a = {
192
180
  cost: { total: 1 },
@@ -235,6 +223,66 @@ describe( 'deepMergeWithResolver', () => {
235
223
  expect( () => deepMergeWithResolver( [], {}, ( x, y ) => x + y ) ).toThrow( Error );
236
224
  expect( () => deepMergeWithResolver( 'a', {}, ( x, y ) => x + y ) ).toThrow( Error );
237
225
  } );
226
+
227
+ it( 'throws when last argument is not a resolver function', () => {
228
+ expect( () => deepMergeWithResolver( { a: 1 }, { a: 2 } ) )
229
+ .toThrow( 'Last argument (resolver) is not a function.' );
230
+ } );
231
+
232
+ it( 'merges multiple objects using the resolver from left to right', () => {
233
+ const resolver = vi.fn( ( x, y ) => `${ x }:${ y }` );
234
+
235
+ expect( deepMergeWithResolver(
236
+ {
237
+ a: 'base',
238
+ nested: {
239
+ count: 1,
240
+ kept: 'yes'
241
+ }
242
+ },
243
+ {
244
+ a: 'first',
245
+ nested: {
246
+ count: 2,
247
+ firstOnly: true
248
+ }
249
+ },
250
+ {
251
+ a: 'second',
252
+ nested: {
253
+ count: 3,
254
+ secondOnly: true
255
+ }
256
+ },
257
+ resolver
258
+ ) ).toEqual( {
259
+ a: 'base:first:second',
260
+ nested: {
261
+ count: '1:2:3',
262
+ firstOnly: true,
263
+ kept: 'yes',
264
+ secondOnly: true
265
+ }
266
+ } );
267
+ expect( resolver ).toHaveBeenCalledTimes( 4 );
268
+ } );
269
+
270
+ it( 'ignores non-object values among multiple resolver overlays', () => {
271
+ const resolver = vi.fn( ( x, y ) => x + y );
272
+
273
+ expect( deepMergeWithResolver(
274
+ { a: 1 },
275
+ null,
276
+ { a: 2 },
277
+ undefined,
278
+ { b: 3 },
279
+ resolver
280
+ ) ).toEqual( {
281
+ a: 3,
282
+ b: 3
283
+ } );
284
+ expect( resolver ).toHaveBeenCalledOnce();
285
+ } );
238
286
  } );
239
287
 
240
288
  describe( 'isPlainObject', () => {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Redacts a given URL object by setting each search param, hash, username and password to '***'
3
+ * @param {URL|string} target
4
+ * @returns {string} Redacted URL as a string
5
+ */
6
+ export const redactUrl = target => {
7
+ const url = new URL( target );
8
+ url.search = new URLSearchParams( url.searchParams.entries().map( ( [ p ] ) => [ p, '***' ] ) ).toString();
9
+ url.username = url.username.length > 0 ? '***' : '';
10
+ url.password = url.password.length > 0 ? '***' : '';
11
+ url.hash = url.hash ? '#***' : '';
12
+ return url.toString();
13
+ };
14
+
15
+ /** Matches red in "hot-red-pie", but not in "redact" */
16
+ const wordMatcher = term => new RegExp( `(?<![a-z\\d])${term}(?![a-z\\d])`, 'i' );
17
+
18
+ /** Matches red in "acquired", but not in "redact" */
19
+ const wordEndMatcher = term => new RegExp( `${term}(?![a-z\\d])`, 'i' );
20
+
21
+ /**
22
+ * Redacts sensitive headers
23
+ * @param {object} headers
24
+ * @returns {object} The redacted headers
25
+ */
26
+ export const redactHeaders = headers => {
27
+ /** Header names that look sensitive by substring rules but are not secret material. */
28
+ const ignoreHeaders = new Set( [
29
+ 'x-csrf-token',
30
+ 'public-key-pins'
31
+ ] );
32
+
33
+ /** * Sensitive header patterns for redaction (case-insensitive). */
34
+ const sensitiveHeadersPatterns = [
35
+ // matches headers that contain these exact words
36
+ wordMatcher( 'authorization' ),
37
+ wordMatcher( 'token' ),
38
+ wordMatcher( 'secret' ),
39
+ wordMatcher( 'password' ),
40
+ wordMatcher( 'pwd' ),
41
+ wordMatcher( 'cookie' ),
42
+ // matches header that contain words ending with these sequences
43
+ wordEndMatcher( 'key' )
44
+ ];
45
+
46
+ return Object.entries( headers ).reduce( ( redacted, [ key, value ] ) => {
47
+ const lowKey = key.toLowerCase();
48
+ const isSensitive = !ignoreHeaders.has( lowKey ) && sensitiveHeadersPatterns.some( rx => rx.test( lowKey ) );
49
+ return Object.assign( redacted, { [key]: isSensitive ? '[REDACTED]' : value } );
50
+ }, {} );
51
+ };