@outputai/core 0.10.1-next.6deba0f.0 → 0.10.1-next.74e6a3d.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 +6 -1
- package/src/consts.js +5 -2
- package/src/errors.d.ts +12 -2
- package/src/errors.js +17 -3
- package/src/errors.spec.js +19 -0
- package/src/helpers/activity_options.js +21 -0
- package/src/helpers/activity_options.spec.js +55 -0
- package/src/helpers/error_serializer.js +236 -0
- package/src/helpers/error_serializer.spec.js +413 -0
- package/src/helpers/errors.js +23 -14
- package/src/helpers/errors.spec.js +31 -75
- package/src/helpers/fetch.js +1 -38
- package/src/helpers/fetch.spec.js +1 -39
- package/src/helpers/function.js +13 -0
- package/src/helpers/function.spec.js +23 -1
- package/src/helpers/redact.js +51 -0
- package/src/helpers/redact.spec.js +66 -0
- package/src/helpers/string.js +10 -0
- package/src/helpers/string.spec.js +36 -1
- package/src/hooks/index.d.ts +36 -41
- package/src/hooks/index.js +2 -1
- package/src/hooks/index.spec.js +15 -7
- package/src/interface/evaluator.d.ts +10 -9
- package/src/interface/evaluator.js +1 -6
- package/src/interface/evaluator.spec.js +10 -10
- package/src/interface/step.d.ts +16 -15
- package/src/interface/step.js +1 -6
- package/src/interface/step.spec.js +10 -10
- package/src/interface/validations/index.js +33 -53
- package/src/interface/validations/index.spec.js +25 -17
- package/src/interface/webhook.js +1 -2
- package/src/interface/webhook.spec.js +25 -19
- package/src/interface/workflow.d.ts +25 -18
- package/src/interface/workflow.js +24 -52
- package/src/interface/workflow.spec.js +109 -108
- package/src/interface/workflow_activity_options.js +2 -5
- package/src/temporal/client.js +47 -0
- package/src/temporal/client.spec.js +124 -0
- package/src/temporal/index.d.ts +32 -0
- package/src/temporal/index.js +1 -0
- package/src/tracing/trace_engine.js +9 -11
- package/src/tracing/trace_engine.spec.js +67 -28
- package/src/worker/catalog_workflow/catalog_job.js +3 -3
- package/src/worker/catalog_workflow/catalog_job.spec.js +1 -1
- package/src/worker/index.js +10 -1
- package/src/worker/index.spec.js +23 -8
- package/src/worker/interceptors/activity.js +32 -19
- package/src/worker/interceptors/activity.spec.js +71 -166
- package/src/worker/interceptors/workflow.js +53 -11
- package/src/worker/interceptors/workflow.spec.js +176 -49
- package/src/worker/loader/workflows.js +16 -3
- package/src/worker/loader/workflows.spec.js +37 -5
- package/src/worker/log_hooks.js +13 -10
- package/src/worker/log_hooks.spec.js +12 -4
- package/src/worker/telemetry.js +2 -1
- package/src/worker/temporal_logger.js +33 -0
- package/src/worker/temporal_logger.spec.js +90 -0
- package/src/helpers/aggregations.js +0 -44
- package/src/helpers/aggregations.spec.js +0 -92
- package/src/tracing/tools/utils.js +0 -42
- package/src/tracing/tools/utils.spec.js +0 -83
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
flattenPrototypeChain,
|
|
4
|
+
resolveErrorName,
|
|
5
|
+
serializeError,
|
|
6
|
+
truncateArray,
|
|
7
|
+
truncateString
|
|
8
|
+
} from './error_serializer.js';
|
|
9
|
+
|
|
10
|
+
const redactUrlMock = vi.hoisted( () => vi.fn().mockReturnValue( '[Redacted URL]' ) );
|
|
11
|
+
const isUrlMock = vi.hoisted( () => vi.fn().mockReturnValue( false ) );
|
|
12
|
+
|
|
13
|
+
vi.mock( './redact.js', () => ( { redactUrl: redactUrlMock } ) );
|
|
14
|
+
vi.mock( './string.js', () => ( { isUrl: isUrlMock } ) );
|
|
15
|
+
|
|
16
|
+
describe( 'error serializer helpers', () => {
|
|
17
|
+
describe( 'truncateString', () => {
|
|
18
|
+
it( 'preserves values within the limit and marks omitted characters', () => {
|
|
19
|
+
expect( truncateString( 'exact', 5 ) ).toBe( 'exact' );
|
|
20
|
+
expect( truncateString( 'longer', 5 ) ).toBe( 'longe... [1 more characters omitted]' );
|
|
21
|
+
} );
|
|
22
|
+
} );
|
|
23
|
+
|
|
24
|
+
describe( 'truncateArray', () => {
|
|
25
|
+
it( 'preserves values within the limit and marks omitted items without mutating the source', () => {
|
|
26
|
+
const exact = [ 1, 2 ];
|
|
27
|
+
const long = [ 1, 2, 3 ];
|
|
28
|
+
|
|
29
|
+
expect( truncateArray( exact, 2 ) ).toBe( exact );
|
|
30
|
+
expect( truncateArray( long, 2 ) ).toEqual( [ 1, 2, '... [1 more items omitted]' ] );
|
|
31
|
+
expect( long ).toEqual( [ 1, 2, 3 ] );
|
|
32
|
+
} );
|
|
33
|
+
} );
|
|
34
|
+
|
|
35
|
+
describe( 'flattenPrototypeChain', () => {
|
|
36
|
+
it( 'returns child-first prototypes and honors the depth limit', () => {
|
|
37
|
+
const grandparent = { grandparent: true };
|
|
38
|
+
const parent = Object.create( grandparent );
|
|
39
|
+
const child = Object.create( parent );
|
|
40
|
+
|
|
41
|
+
expect( flattenPrototypeChain( child, 0, [], 2 ) ).toEqual( [ child, parent ] );
|
|
42
|
+
expect( flattenPrototypeChain( child, 0, [], 3 ) ).toEqual( [ child, parent, grandparent ] );
|
|
43
|
+
} );
|
|
44
|
+
} );
|
|
45
|
+
|
|
46
|
+
describe( 'resolveErrorName', () => {
|
|
47
|
+
it( 'uses explicit, inherited, and constructor names in precedence order', () => {
|
|
48
|
+
class ConstructorNamedError extends Error {}
|
|
49
|
+
class InheritedNamedError extends Error {}
|
|
50
|
+
InheritedNamedError.prototype.name = 'InheritedName';
|
|
51
|
+
class ExplicitlyNamedError extends Error {
|
|
52
|
+
name = 'ExplicitName';
|
|
53
|
+
}
|
|
54
|
+
class ExplicitlyGenericError extends Error {
|
|
55
|
+
name = 'Error';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
expect( resolveErrorName( new ConstructorNamedError() ) ).toBe( 'ConstructorNamedError' );
|
|
59
|
+
expect( resolveErrorName( new InheritedNamedError() ) ).toBe( 'InheritedName' );
|
|
60
|
+
expect( resolveErrorName( new ExplicitlyNamedError() ) ).toBe( 'ExplicitName' );
|
|
61
|
+
expect( resolveErrorName( new ExplicitlyGenericError() ) ).toBe( 'Error' );
|
|
62
|
+
expect( resolveErrorName( new Error() ) ).toBe( 'Error' );
|
|
63
|
+
expect( resolveErrorName( { name: 'ObjectName' } ) ).toBeUndefined();
|
|
64
|
+
} );
|
|
65
|
+
|
|
66
|
+
it( 'returns undefined when an explicit name getter throws', () => {
|
|
67
|
+
const error = new Error();
|
|
68
|
+
Object.defineProperty( error, 'name', {
|
|
69
|
+
get: () => {
|
|
70
|
+
throw new Error( 'name unavailable' );
|
|
71
|
+
}
|
|
72
|
+
} );
|
|
73
|
+
|
|
74
|
+
expect( resolveErrorName( error ) ).toBeUndefined();
|
|
75
|
+
} );
|
|
76
|
+
} );
|
|
77
|
+
} );
|
|
78
|
+
|
|
79
|
+
describe( 'serializeError', () => {
|
|
80
|
+
it( 'serializes descriptors and skips unusable properties', () => {
|
|
81
|
+
const error = new Error( 'step failed' );
|
|
82
|
+
Object.defineProperties( error, {
|
|
83
|
+
code: { value: 'ESTEP' },
|
|
84
|
+
hidden: { value: 'available for debugging' },
|
|
85
|
+
computed: { get: () => `computed from ${error.message}` },
|
|
86
|
+
broken: {
|
|
87
|
+
get: () => {
|
|
88
|
+
throw new Error( 'getter failed' );
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
callback: { value: () => 'not serializable' },
|
|
92
|
+
skipped: { value: undefined },
|
|
93
|
+
symbol: { value: Symbol( 'debug' ) },
|
|
94
|
+
__proto__: { value: { unsafe: true } }
|
|
95
|
+
} );
|
|
96
|
+
error.nullable = null;
|
|
97
|
+
|
|
98
|
+
const serialized = serializeError( error, { dropKeys: [ 'stack' ] } );
|
|
99
|
+
|
|
100
|
+
expect( serialized ).toEqual( {
|
|
101
|
+
name: 'Error',
|
|
102
|
+
message: 'step failed',
|
|
103
|
+
code: 'ESTEP',
|
|
104
|
+
hidden: 'available for debugging',
|
|
105
|
+
computed: 'computed from step failed',
|
|
106
|
+
nullable: null,
|
|
107
|
+
symbol: 'Symbol(debug)'
|
|
108
|
+
} );
|
|
109
|
+
expect( Object.hasOwn( serialized, '__proto__' ) ).toBe( false );
|
|
110
|
+
} );
|
|
111
|
+
|
|
112
|
+
it( 'applies custom dropped keys recursively and across prototypes', () => {
|
|
113
|
+
class DebugError extends Error {}
|
|
114
|
+
DebugError.prototype.internal = 'prototype value';
|
|
115
|
+
|
|
116
|
+
const cause = new DebugError( 'cause failed' );
|
|
117
|
+
cause.context = { internal: 'nested cause value', visible: 'cause context' };
|
|
118
|
+
const error = new DebugError( 'request failed', { cause } );
|
|
119
|
+
error.internal = 'instance value';
|
|
120
|
+
error.context = { internal: 'nested root value', visible: 'root context' };
|
|
121
|
+
|
|
122
|
+
const serialized = serializeError( error, { dropKeys: [ 'internal', 'stack' ] } );
|
|
123
|
+
|
|
124
|
+
expect( serialized ).not.toHaveProperty( 'internal' );
|
|
125
|
+
expect( serialized.context ).toEqual( { visible: 'root context' } );
|
|
126
|
+
expect( serialized.cause ).not.toHaveProperty( 'internal' );
|
|
127
|
+
expect( serialized.cause.context ).toEqual( { visible: 'cause context' } );
|
|
128
|
+
} );
|
|
129
|
+
|
|
130
|
+
it( 'merges prototype properties child-first without invoking overridden getters', () => {
|
|
131
|
+
const overriddenGetter = vi.fn( () => 'base' );
|
|
132
|
+
class BaseFailure extends Error {}
|
|
133
|
+
Object.defineProperties( BaseFailure.prototype, {
|
|
134
|
+
baseProperty: { value: 'base' },
|
|
135
|
+
overridden: { get: overriddenGetter }
|
|
136
|
+
} );
|
|
137
|
+
class RequestFailure extends BaseFailure {}
|
|
138
|
+
RequestFailure.prototype.requestProperty = 'request';
|
|
139
|
+
|
|
140
|
+
const error = new RequestFailure( 'request failed' );
|
|
141
|
+
Object.defineProperty( error, 'overridden', { value: 'instance' } );
|
|
142
|
+
|
|
143
|
+
expect( serializeError( error, { dropKeys: [ 'stack' ] } ) ).toEqual( {
|
|
144
|
+
name: 'RequestFailure',
|
|
145
|
+
message: 'request failed',
|
|
146
|
+
overridden: 'instance',
|
|
147
|
+
requestProperty: 'request',
|
|
148
|
+
baseProperty: 'base'
|
|
149
|
+
} );
|
|
150
|
+
expect( overriddenGetter ).not.toHaveBeenCalled();
|
|
151
|
+
} );
|
|
152
|
+
|
|
153
|
+
it( 'recursively serializes causes, nested objects, and arrays', () => {
|
|
154
|
+
const databaseError = new Error( 'database unavailable' );
|
|
155
|
+
databaseError.code = 'ECONNREFUSED';
|
|
156
|
+
const serviceError = new Error( 'service failed', { cause: databaseError } );
|
|
157
|
+
serviceError.context = {
|
|
158
|
+
attempts: [
|
|
159
|
+
{ number: 1, metadata: { retryable: true } },
|
|
160
|
+
{ number: 2, metadata: null }
|
|
161
|
+
]
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
expect( serializeError( serviceError, { dropKeys: [ 'stack' ] } ) ).toEqual( {
|
|
165
|
+
name: 'Error',
|
|
166
|
+
message: 'service failed',
|
|
167
|
+
cause: {
|
|
168
|
+
name: 'Error',
|
|
169
|
+
message: 'database unavailable',
|
|
170
|
+
code: 'ECONNREFUSED'
|
|
171
|
+
},
|
|
172
|
+
context: {
|
|
173
|
+
attempts: [
|
|
174
|
+
{ number: 1, metadata: { retryable: true } },
|
|
175
|
+
{ number: 2, metadata: null }
|
|
176
|
+
]
|
|
177
|
+
}
|
|
178
|
+
} );
|
|
179
|
+
} );
|
|
180
|
+
|
|
181
|
+
it( 'returns primitives as-is', () => {
|
|
182
|
+
expect( serializeError( undefined ) ).toBeUndefined();
|
|
183
|
+
expect( serializeError( null ) ).toBeNull();
|
|
184
|
+
expect( serializeError( 42 ) ).toBe( 42 );
|
|
185
|
+
expect( serializeError( true ) ).toBe( true );
|
|
186
|
+
} );
|
|
187
|
+
|
|
188
|
+
it( 'serializes Date values as ISO strings and marks invalid dates', () => {
|
|
189
|
+
expect( serializeError( new Date( '2026-07-22T16:30:00.000Z' ) ) ).toBe( '2026-07-22T16:30:00.000Z' );
|
|
190
|
+
expect( serializeError( new Date( 'foo' ) ) ).toBe( '[Invalid Date]' );
|
|
191
|
+
} );
|
|
192
|
+
|
|
193
|
+
it( 'serializes RegExp values with toString', () => {
|
|
194
|
+
expect( serializeError( /request-\d+/gi ) ).toBe( '/request-\\d+/gi' );
|
|
195
|
+
} );
|
|
196
|
+
|
|
197
|
+
it( 'serializes bigint values with an n suffix', () => {
|
|
198
|
+
expect( serializeError( 42n ) ).toBe( '42n' );
|
|
199
|
+
expect( serializeError( -42n ) ).toBe( '-42n' );
|
|
200
|
+
} );
|
|
201
|
+
|
|
202
|
+
it( 'marks bigints that would exceed the string length budget', () => {
|
|
203
|
+
const maxDigits = 16_383; // MAX_STRING_LEN - 1
|
|
204
|
+
const tooLarge = 10n ** BigInt( maxDigits );
|
|
205
|
+
|
|
206
|
+
expect( serializeError( tooLarge - 1n ) ).toBe( `${tooLarge - 1n}n` );
|
|
207
|
+
expect( serializeError( tooLarge ) ).toBe( '[BigInt too large]' );
|
|
208
|
+
expect( serializeError( -tooLarge ) ).toBe( '[BigInt too large]' );
|
|
209
|
+
expect( serializeError( 10n ** 20_000n ) ).toBe( '[BigInt too large]' );
|
|
210
|
+
} );
|
|
211
|
+
|
|
212
|
+
it( 'serializes symbol values', () => {
|
|
213
|
+
expect( serializeError( Symbol( 'request' ) ) ).toBe( 'Symbol(request)' );
|
|
214
|
+
} );
|
|
215
|
+
|
|
216
|
+
it( 'serializes functions by name', () => {
|
|
217
|
+
function namedFunction() {}
|
|
218
|
+
expect( serializeError( namedFunction ) ).toBe( '[Function: namedFunction]' );
|
|
219
|
+
expect( serializeError( () => undefined ) ).toBe( '[Function: (anonymous)]' );
|
|
220
|
+
} );
|
|
221
|
+
|
|
222
|
+
it( 'serializes ArrayBuffers and views as type and size without dumping contents', () => {
|
|
223
|
+
expect( serializeError( new Uint16Array( [ 1, 2 ] ) ) ).toBe( 'Uint16Array(2)' );
|
|
224
|
+
expect( serializeError( Buffer.from( [ 1, 2 ] ) ) ).toBe( 'Buffer(2)' );
|
|
225
|
+
expect( serializeError( new DataView( new ArrayBuffer( 8 ) ) ) ).toBe( 'DataView(8)' );
|
|
226
|
+
expect( serializeError( new ArrayBuffer( 16 ) ) ).toBe( 'ArrayBuffer(16)' );
|
|
227
|
+
} );
|
|
228
|
+
|
|
229
|
+
it( 'redacts URL strings and URL instances with redactUrl', () => {
|
|
230
|
+
const stringUrl = 'https://user:password@example.com/models?model=gpt-5&token=secret#debug';
|
|
231
|
+
const url = new URL( 'ftp://user:password@example.com/file.txt?download=true#details' );
|
|
232
|
+
redactUrlMock.mockClear();
|
|
233
|
+
isUrlMock.mockClear();
|
|
234
|
+
isUrlMock.mockReturnValueOnce( true );
|
|
235
|
+
|
|
236
|
+
expect( serializeError( {
|
|
237
|
+
stringUrl,
|
|
238
|
+
url
|
|
239
|
+
} ) ).toEqual( {
|
|
240
|
+
stringUrl: '[Redacted URL]',
|
|
241
|
+
url: '[Redacted URL]'
|
|
242
|
+
} );
|
|
243
|
+
expect( isUrlMock ).toHaveBeenCalledOnce();
|
|
244
|
+
expect( isUrlMock ).toHaveBeenCalledWith( stringUrl );
|
|
245
|
+
expect( redactUrlMock ).toHaveBeenCalledTimes( 2 );
|
|
246
|
+
expect( redactUrlMock ).toHaveBeenNthCalledWith( 1, stringUrl );
|
|
247
|
+
expect( redactUrlMock ).toHaveBeenNthCalledWith( 2, url );
|
|
248
|
+
} );
|
|
249
|
+
|
|
250
|
+
it( 'truncates string values and property names', () => {
|
|
251
|
+
const longValue = 'v'.repeat( 16_385 );
|
|
252
|
+
const sharedKeyPrefix = 'k'.repeat( 256 );
|
|
253
|
+
|
|
254
|
+
expect( serializeError( {
|
|
255
|
+
longValue,
|
|
256
|
+
[`${sharedKeyPrefix}a`]: 'first',
|
|
257
|
+
[`${sharedKeyPrefix}b`]: 'second'
|
|
258
|
+
} ) ).toEqual( {
|
|
259
|
+
longValue: `${'v'.repeat( 16_384 )}... [1 more characters omitted]`,
|
|
260
|
+
[`${sharedKeyPrefix}... [1 more characters omitted]`]: 'first'
|
|
261
|
+
} );
|
|
262
|
+
} );
|
|
263
|
+
|
|
264
|
+
it( 'limits arrays, maps, and sets while recursively serializing their values', () => {
|
|
265
|
+
const exactArray = Array.from( { length: 50 }, ( _, index ) => index );
|
|
266
|
+
const longArray = Array.from( { length: 52 }, ( _, index ) => index );
|
|
267
|
+
const longMap = new Map( Array.from( { length: 51 }, ( _, index ) => [ `key-${index}`, BigInt( index ) ] ) );
|
|
268
|
+
const longSet = new Set( Array.from( { length: 52 }, ( _, index ) => BigInt( index ) ) );
|
|
269
|
+
|
|
270
|
+
expect( serializeError( exactArray ) ).toEqual( exactArray );
|
|
271
|
+
expect( serializeError( longArray ) ).toEqual( [ ...exactArray, '... [2 more items omitted]' ] );
|
|
272
|
+
|
|
273
|
+
const serializedMap = serializeError( longMap );
|
|
274
|
+
expect( serializedMap ).toHaveLength( 51 );
|
|
275
|
+
expect( serializedMap[0] ).toEqual( [ 'key-0', '0n' ] );
|
|
276
|
+
expect( serializedMap.at( -1 ) ).toBe( '... [1 more items omitted]' );
|
|
277
|
+
|
|
278
|
+
const serializedSet = serializeError( longSet );
|
|
279
|
+
expect( serializedSet ).toHaveLength( 51 );
|
|
280
|
+
expect( serializedSet[0] ).toBe( '0n' );
|
|
281
|
+
expect( serializedSet.at( -1 ) ).toBe( '... [2 more items omitted]' );
|
|
282
|
+
} );
|
|
283
|
+
|
|
284
|
+
it( 'globally limits object properties while prioritizing own properties', () => {
|
|
285
|
+
const exactProperties = Object.fromEntries( Array.from( { length: 50 }, ( _, index ) => [ `property-${index}`, index ] ) );
|
|
286
|
+
expect( serializeError( exactProperties ) ).toEqual( exactProperties );
|
|
287
|
+
|
|
288
|
+
const prototype = { inherited: 'omitted' };
|
|
289
|
+
const instance = Object.assign(
|
|
290
|
+
Object.create( prototype ),
|
|
291
|
+
Object.fromEntries( Array.from( { length: 50 }, ( _, index ) => [ `own-${index}`, index ] ) )
|
|
292
|
+
);
|
|
293
|
+
const serialized = serializeError( instance );
|
|
294
|
+
|
|
295
|
+
expect( Object.keys( serialized ) ).toHaveLength( 51 );
|
|
296
|
+
expect( serialized['own-49'] ).toBe( 49 );
|
|
297
|
+
expect( serialized ).not.toHaveProperty( 'inherited' );
|
|
298
|
+
expect( serialized['...'] ).toBe( '[Maximum Object Properties Reached]' );
|
|
299
|
+
} );
|
|
300
|
+
|
|
301
|
+
it( 'retains error identity when properties reach the object limit', () => {
|
|
302
|
+
class DetailedError extends Error {}
|
|
303
|
+
const error = new DetailedError( 'failed' );
|
|
304
|
+
Object.assign( error, Object.fromEntries( Array.from( { length: 50 }, ( _, index ) => [ `field-${index}`, index ] ) ) );
|
|
305
|
+
|
|
306
|
+
const serialized = serializeError( error, { dropKeys: [ 'stack' ] } );
|
|
307
|
+
|
|
308
|
+
expect( serialized.name ).toBe( 'DetailedError' );
|
|
309
|
+
expect( serialized.message ).toBe( 'failed' );
|
|
310
|
+
expect( serialized['...'] ).toBe( '[Maximum Object Properties Reached]' );
|
|
311
|
+
} );
|
|
312
|
+
|
|
313
|
+
it( 'recursively excludes sensitive and internal properties while retaining similarly named diagnostics', () => {
|
|
314
|
+
const value = {
|
|
315
|
+
failure: { internal: 'Temporal protobuf' },
|
|
316
|
+
originalFailure: { details: 'Nexus failure' },
|
|
317
|
+
$type: { parent: 'protobuf reflection graph' },
|
|
318
|
+
request: { body: 'private request' },
|
|
319
|
+
response: { body: 'private response' },
|
|
320
|
+
headers: { Authorization: 'Bearer secret' },
|
|
321
|
+
Authorization: 'Bearer secret',
|
|
322
|
+
RequestBodyValues: { prompt: 'private prompt' },
|
|
323
|
+
RESPONSE_HEADERS: { Cookie: 'private cookie' },
|
|
324
|
+
APIKey: 'private key',
|
|
325
|
+
AccessToken: 'private token',
|
|
326
|
+
PASSWORD: 'private password',
|
|
327
|
+
Credential: 'private credential',
|
|
328
|
+
keyboard: 'preserved',
|
|
329
|
+
tokenCount: 42,
|
|
330
|
+
passwordPolicy: 'preserved',
|
|
331
|
+
secretCount: 1,
|
|
332
|
+
nested: {
|
|
333
|
+
Cookie: 'private cookie',
|
|
334
|
+
code: 'EFAILED'
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
expect( serializeError( value ) ).toEqual( {
|
|
339
|
+
keyboard: 'preserved',
|
|
340
|
+
tokenCount: 42,
|
|
341
|
+
passwordPolicy: 'preserved',
|
|
342
|
+
secretCount: 1,
|
|
343
|
+
nested: { code: 'EFAILED' }
|
|
344
|
+
} );
|
|
345
|
+
} );
|
|
346
|
+
|
|
347
|
+
it( 'includes stacks by default and supports dropping them recursively', () => {
|
|
348
|
+
const cause = new Error( 'database unavailable' );
|
|
349
|
+
const error = new Error( 'service failed', { cause } );
|
|
350
|
+
|
|
351
|
+
const withStacks = serializeError( error );
|
|
352
|
+
const withoutStacks = serializeError( error, { dropKeys: [ 'stack' ] } );
|
|
353
|
+
|
|
354
|
+
expect( withStacks.stack ).toContain( 'Error: service failed' );
|
|
355
|
+
expect( withStacks.cause.stack ).toContain( 'Error: database unavailable' );
|
|
356
|
+
expect( withoutStacks ).not.toHaveProperty( 'stack' );
|
|
357
|
+
expect( withoutStacks.cause ).not.toHaveProperty( 'stack' );
|
|
358
|
+
} );
|
|
359
|
+
|
|
360
|
+
it( 'marks circular references but serializes shared sibling values independently', () => {
|
|
361
|
+
const shared = { code: 'ESHARED' };
|
|
362
|
+
const objectCycle = {};
|
|
363
|
+
objectCycle.self = objectCycle;
|
|
364
|
+
const arrayCycle = [];
|
|
365
|
+
arrayCycle.push( arrayCycle );
|
|
366
|
+
const mapCycle = new Map();
|
|
367
|
+
mapCycle.set( 'self', mapCycle );
|
|
368
|
+
const setCycle = new Set();
|
|
369
|
+
setCycle.add( setCycle );
|
|
370
|
+
|
|
371
|
+
expect( serializeError( {
|
|
372
|
+
objectCycle,
|
|
373
|
+
arrayCycle,
|
|
374
|
+
mapCycle,
|
|
375
|
+
setCycle,
|
|
376
|
+
first: shared,
|
|
377
|
+
second: shared
|
|
378
|
+
} ) ).toEqual( {
|
|
379
|
+
objectCycle: { self: '[Circular Reference]' },
|
|
380
|
+
arrayCycle: [ '[Circular Reference]' ],
|
|
381
|
+
mapCycle: [ [ 'self', '[Circular Reference]' ] ],
|
|
382
|
+
setCycle: [ '[Circular Reference]' ],
|
|
383
|
+
first: { code: 'ESHARED' },
|
|
384
|
+
second: { code: 'ESHARED' }
|
|
385
|
+
} );
|
|
386
|
+
} );
|
|
387
|
+
|
|
388
|
+
it( 'marks values beyond the maximum recursive depth', () => {
|
|
389
|
+
const createNestedValue = depth => depth === 0 ? {} : { child: createNestedValue( depth - 1 ) };
|
|
390
|
+
const root = createNestedValue( 10 );
|
|
391
|
+
|
|
392
|
+
expect(
|
|
393
|
+
Array.from( { length: 9 } ).reduce( value => value.child, serializeError( root ) ).child
|
|
394
|
+
).toBe( '[Maximum Depth Reached]' );
|
|
395
|
+
} );
|
|
396
|
+
|
|
397
|
+
it( 'marks values after the total serialization limit is reached', () => {
|
|
398
|
+
const value = Array.from(
|
|
399
|
+
{ length: 50 },
|
|
400
|
+
() => Array.from( { length: 50 }, ( _, index ) => index )
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
expect( JSON.stringify( serializeError( value ) ) ).toContain( '[Serialization Limit Reached]' );
|
|
404
|
+
} );
|
|
405
|
+
|
|
406
|
+
it( 'returns an unserializable marker for exotic values that throw during reflection', () => {
|
|
407
|
+
const { proxy, revoke } = Proxy.revocable( {}, {} );
|
|
408
|
+
revoke();
|
|
409
|
+
|
|
410
|
+
expect( serializeError( proxy ) ).toBe( '[Unserializable]' );
|
|
411
|
+
expect( serializeError( { nested: proxy } ) ).toEqual( { nested: '[Unserializable]' } );
|
|
412
|
+
} );
|
|
413
|
+
} );
|
package/src/helpers/errors.js
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
|
-
import { ApplicationFailure } from '@temporalio/common';
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
|
-
*
|
|
5
|
-
* @param {
|
|
6
|
-
* @param {
|
|
7
|
-
* @returns {
|
|
2
|
+
* Checks whether a value inherits from a constructor matching any supplied class name.
|
|
3
|
+
* @param {*} obj - Value whose prototype chain to inspect
|
|
4
|
+
* @param {string[]} classNames - Constructor names to match
|
|
5
|
+
* @returns {boolean} Whether a matching constructor exists
|
|
8
6
|
*/
|
|
9
|
-
export const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
7
|
+
export const inheritsFromAnyNamedType = ( obj, classNames ) => {
|
|
8
|
+
if ( classNames.length === 0 ) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
if ( obj === null || ( typeof obj !== 'object' && typeof obj !== 'function' ) ) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const prototype = Object.getPrototypeOf( obj );
|
|
16
|
+
if ( !prototype ) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const constructor = Object.getOwnPropertyDescriptor( prototype, 'constructor' )?.value;
|
|
21
|
+
if ( constructor?.name && classNames.includes( constructor.name ) ) {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
return inheritsFromAnyNamedType( prototype, classNames );
|
|
25
|
+
};
|
|
@@ -1,89 +1,45 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
import { buildApplicationFailureWithDetails } from './errors.js';
|
|
2
|
+
import { inheritsFromAnyNamedType } from './errors.js';
|
|
4
3
|
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
expect(
|
|
15
|
-
expect(
|
|
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( '
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
55
|
-
const error = new
|
|
56
|
-
|
|
57
|
-
|
|
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(
|
|
31
|
+
expect( inheritsFromAnyNamedType( error, [ 'SpoofedFailure' ] ) ).toBe( false );
|
|
32
|
+
expect( inheritsFromAnyNamedType( error, [ 'RequestFailure' ] ) ).toBe( true );
|
|
62
33
|
} );
|
|
63
34
|
|
|
64
|
-
it( '
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
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(
|
|
76
|
-
expect(
|
|
77
|
-
expect(
|
|
78
|
-
expect( 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
|
} );
|
package/src/helpers/fetch.js
CHANGED
|
@@ -1,42 +1,5 @@
|
|
|
1
1
|
import { FatalError } from '#errors';
|
|
2
|
-
|
|
3
|
-
/** Matches red in "hot-red-pie", but not in "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,
|
|
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( () => {
|