@miradorlabs/parallax-web 2.0.1 → 2.2.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.
@@ -1,15 +1,18 @@
1
1
  // ParallaxClient and ParallaxTrace Unit Tests
2
2
  import { ParallaxClient, ParallaxTrace } from '../src/parallax';
3
3
  import { ParallaxGatewayServiceClient } from 'mirador-gateway-parallax-web/proto/gateway/parallax/v1/Parallax_gatewayServiceClientPb';
4
- import { CreateTraceRequest, Chain } from 'mirador-gateway-parallax-web/proto/gateway/parallax/v1/parallax_gateway_pb';
4
+ import {
5
+ CreateTraceRequest,
6
+ UpdateTraceRequest,
7
+ Chain,
8
+ } from 'mirador-gateway-parallax-web/proto/gateway/parallax/v1/parallax_gateway_pb';
9
+ import { ResponseStatus } from 'mirador-gateway-parallax-web/proto/common/v1/status_pb';
5
10
 
6
11
  // Mock the gRPC-Web client
7
12
  jest.mock('mirador-gateway-parallax-web/proto/gateway/parallax/v1/Parallax_gatewayServiceClientPb');
8
13
 
9
14
  // Mock console to avoid cluttering test output
10
15
  const mockConsoleError = jest.spyOn(console, 'error').mockImplementation();
11
- const mockConsoleDebug = jest.spyOn(console, 'debug').mockImplementation();
12
- const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation();
13
16
 
14
17
  // Mock fetch for IP lookup
15
18
  global.fetch = jest.fn();
@@ -46,33 +49,43 @@ beforeAll(() => {
46
49
  configurable: true,
47
50
  });
48
51
 
49
- // Mock Intl.DateTimeFormat
52
+ });
53
+
54
+ // Helper to mock Intl.DateTimeFormat (needs to be called after jest.useFakeTimers)
55
+ function mockIntlDateTimeFormat() {
50
56
  const mockDateTimeFormat = {
51
57
  resolvedOptions: () => ({ timeZone: 'America/New_York' }),
52
58
  };
53
59
  jest.spyOn(Intl, 'DateTimeFormat').mockImplementation(() => mockDateTimeFormat as unknown as Intl.DateTimeFormat);
54
- });
60
+ }
55
61
 
56
62
  afterAll(() => {
57
63
  mockConsoleError.mockRestore();
58
- mockConsoleDebug.mockRestore();
59
- mockConsoleLog.mockRestore();
60
64
  });
61
65
 
62
66
  describe('ParallaxClient', () => {
63
67
  let mockCreateTrace: jest.Mock;
68
+ let mockUpdateTrace: jest.Mock;
64
69
 
65
70
  beforeEach(() => {
66
71
  jest.clearAllMocks();
72
+ jest.useFakeTimers();
73
+ mockIntlDateTimeFormat();
67
74
 
68
75
  // Setup mock for createTrace
69
76
  mockCreateTrace = jest.fn().mockResolvedValue({
70
77
  getTraceId: () => 'trace-123',
71
- getStatus: () => ({ getCode: () => 1 }),
78
+ getStatus: () => ({ getCode: () => ResponseStatus.StatusCode.STATUS_CODE_SUCCESS }),
79
+ });
80
+
81
+ // Setup mock for updateTrace
82
+ mockUpdateTrace = jest.fn().mockResolvedValue({
83
+ getStatus: () => ({ getCode: () => ResponseStatus.StatusCode.STATUS_CODE_SUCCESS }),
72
84
  });
73
85
 
74
86
  (ParallaxGatewayServiceClient as jest.Mock).mockImplementation(() => ({
75
87
  createTrace: mockCreateTrace,
88
+ updateTrace: mockUpdateTrace,
76
89
  }));
77
90
 
78
91
  // Mock successful IP fetch
@@ -83,9 +96,8 @@ describe('ParallaxClient', () => {
83
96
  });
84
97
 
85
98
  afterEach(() => {
99
+ jest.useRealTimers();
86
100
  mockConsoleError.mockClear();
87
- mockConsoleDebug.mockClear();
88
- mockConsoleLog.mockClear();
89
101
  });
90
102
 
91
103
  describe('constructor', () => {
@@ -97,12 +109,12 @@ describe('ParallaxClient', () => {
97
109
 
98
110
  it('should use default gateway URL when not provided', () => {
99
111
  const client = new ParallaxClient('my-api-key');
100
- expect(client.apiUrl).toBe('https://parallax-gateway.dev.mirador.org:443');
112
+ expect(client.apiUrl).toBe('https://parallax-gateway-dev.mirador.org:443');
101
113
  });
102
114
 
103
115
  it('should use custom gateway URL when provided', () => {
104
116
  const customUrl = 'https://custom-gateway.example.com:443';
105
- const client = new ParallaxClient('my-api-key', customUrl);
117
+ const client = new ParallaxClient('my-api-key', { apiUrl: customUrl });
106
118
  expect(client.apiUrl).toBe(customUrl);
107
119
  });
108
120
 
@@ -111,18 +123,7 @@ describe('ParallaxClient', () => {
111
123
  new ParallaxClient(apiKey);
112
124
 
113
125
  expect(ParallaxGatewayServiceClient).toHaveBeenCalledWith(
114
- 'https://parallax-gateway.dev.mirador.org:443',
115
- { 'x-parallax-api-key': apiKey }
116
- );
117
- });
118
-
119
- it('should initialize gRPC client with custom URL and credentials', () => {
120
- const apiKey = 'test-key';
121
- const customUrl = 'https://custom.example.com:443';
122
- new ParallaxClient(apiKey, customUrl);
123
-
124
- expect(ParallaxGatewayServiceClient).toHaveBeenCalledWith(
125
- customUrl,
126
+ 'https://parallax-gateway-dev.mirador.org:443',
126
127
  { 'x-parallax-api-key': apiKey }
127
128
  );
128
129
  });
@@ -131,22 +132,26 @@ describe('ParallaxClient', () => {
131
132
  describe('trace()', () => {
132
133
  it('should return a ParallaxTrace instance', () => {
133
134
  const client = new ParallaxClient('test-key');
134
- const trace = client.trace('TestTrace');
135
+ const trace = client.trace({ name: 'TestTrace' });
135
136
 
136
137
  expect(trace).toBeInstanceOf(ParallaxTrace);
137
138
  });
138
139
 
139
- it('should pass name to ParallaxTrace', () => {
140
+ it('should work without options', () => {
140
141
  const client = new ParallaxClient('test-key');
141
- const trace = client.trace('MyTraceName');
142
+ const trace = client.trace();
142
143
 
143
- // The name is stored internally, we can verify by submitting
144
144
  expect(trace).toBeInstanceOf(ParallaxTrace);
145
145
  });
146
146
 
147
- it('should pass includeClientMeta flag to ParallaxTrace', () => {
147
+ it('should accept all trace options', () => {
148
148
  const client = new ParallaxClient('test-key');
149
- const trace = client.trace('TestTrace', true);
149
+ const trace = client.trace({
150
+ name: 'TestTrace',
151
+ autoFlush: false,
152
+ flushPeriodMs: 100,
153
+ includeClientMeta: false,
154
+ });
150
155
 
151
156
  expect(trace).toBeInstanceOf(ParallaxTrace);
152
157
  });
@@ -156,17 +161,25 @@ describe('ParallaxClient', () => {
156
161
  describe('ParallaxTrace', () => {
157
162
  let client: ParallaxClient;
158
163
  let mockCreateTrace: jest.Mock;
164
+ let mockUpdateTrace: jest.Mock;
159
165
 
160
166
  beforeEach(() => {
161
167
  jest.clearAllMocks();
168
+ jest.useFakeTimers();
169
+ mockIntlDateTimeFormat();
162
170
 
163
171
  mockCreateTrace = jest.fn().mockResolvedValue({
164
172
  getTraceId: () => 'trace-456',
165
- getStatus: () => ({ getCode: () => 1 }),
173
+ getStatus: () => ({ getCode: () => ResponseStatus.StatusCode.STATUS_CODE_SUCCESS }),
174
+ });
175
+
176
+ mockUpdateTrace = jest.fn().mockResolvedValue({
177
+ getStatus: () => ({ getCode: () => ResponseStatus.StatusCode.STATUS_CODE_SUCCESS }),
166
178
  });
167
179
 
168
180
  (ParallaxGatewayServiceClient as jest.Mock).mockImplementation(() => ({
169
181
  createTrace: mockCreateTrace,
182
+ updateTrace: mockUpdateTrace,
170
183
  }));
171
184
 
172
185
  (global.fetch as jest.Mock).mockResolvedValue({
@@ -177,97 +190,69 @@ describe('ParallaxTrace', () => {
177
190
  client = new ParallaxClient('test-api-key');
178
191
  });
179
192
 
193
+ afterEach(() => {
194
+ jest.useRealTimers();
195
+ });
196
+
180
197
  describe('builder methods', () => {
181
198
  it('addAttribute() should return this for chaining', () => {
182
- const trace = client.trace('TestTrace');
199
+ const trace = client.trace({ name: 'TestTrace', autoFlush: false });
183
200
  const result = trace.addAttribute('key', 'value');
184
201
 
185
202
  expect(result).toBe(trace);
186
203
  });
187
204
 
188
205
  it('addAttributes() should return this for chaining', () => {
189
- const trace = client.trace('TestTrace');
206
+ const trace = client.trace({ name: 'TestTrace', autoFlush: false });
190
207
  const result = trace.addAttributes({ key1: 'value1', key2: 'value2' });
191
208
 
192
209
  expect(result).toBe(trace);
193
210
  });
194
211
 
195
212
  it('addTag() should return this for chaining', () => {
196
- const trace = client.trace('TestTrace');
213
+ const trace = client.trace({ name: 'TestTrace', autoFlush: false });
197
214
  const result = trace.addTag('tag1');
198
215
 
199
216
  expect(result).toBe(trace);
200
217
  });
201
218
 
202
219
  it('addTags() should return this for chaining', () => {
203
- const trace = client.trace('TestTrace');
220
+ const trace = client.trace({ name: 'TestTrace', autoFlush: false });
204
221
  const result = trace.addTags(['tag1', 'tag2']);
205
222
 
206
223
  expect(result).toBe(trace);
207
224
  });
208
225
 
209
226
  it('addEvent() should return this for chaining', () => {
210
- const trace = client.trace('TestTrace');
227
+ const trace = client.trace({ name: 'TestTrace', autoFlush: false });
211
228
  const result = trace.addEvent('event_name');
212
229
 
213
230
  expect(result).toBe(trace);
214
231
  });
215
232
 
216
233
  it('addEvent() should accept object details', () => {
217
- const trace = client.trace('TestTrace');
234
+ const trace = client.trace({ name: 'TestTrace', autoFlush: false });
218
235
  const result = trace.addEvent('event_name', { key: 'value' });
219
236
 
220
237
  expect(result).toBe(trace);
221
238
  });
222
239
 
223
- it('setTxHint() should return this for chaining', () => {
224
- const trace = client.trace('TestTrace');
225
- const result = trace.setTxHint('0x123', 'ethereum');
240
+ it('addTxHint() should return this for chaining', () => {
241
+ const trace = client.trace({ name: 'TestTrace', autoFlush: false });
242
+ const result = trace.addTxHint('0x123', 'ethereum');
226
243
 
227
244
  expect(result).toBe(trace);
228
245
  });
229
246
 
230
247
  it('addAttribute() should accept and stringify objects', () => {
231
- const trace = client.trace('TestTrace');
248
+ const trace = client.trace({ name: 'TestTrace', autoFlush: false });
232
249
  const result = trace.addAttribute('data', { nested: 'value', count: 42 });
233
250
 
234
251
  expect(result).toBe(trace);
235
252
  });
236
253
 
237
- it('addAttributes() should accept and stringify objects', () => {
238
- const trace = client.trace('TestTrace');
239
- const result = trace.addAttributes({
240
- simple: 'string',
241
- complex: { nested: true },
242
- });
243
-
244
- expect(result).toBe(trace);
245
- });
246
- });
247
-
248
- describe('optional name', () => {
249
- it('should allow trace without name', async () => {
250
- await client.trace()
251
- .addAttribute('key', 'value')
252
- .create();
253
-
254
- const request = mockCreateTrace.mock.calls[0][0] as CreateTraceRequest;
255
- expect(request.getName()).toBe('');
256
- });
257
-
258
- it('should allow empty string as name', async () => {
259
- await client.trace('')
260
- .addAttribute('key', 'value')
261
- .create();
262
-
263
- const request = mockCreateTrace.mock.calls[0][0] as CreateTraceRequest;
264
- expect(request.getName()).toBe('');
265
- });
266
- });
267
-
268
- describe('method chaining', () => {
269
254
  it('should support fluent API pattern', () => {
270
- const trace = client.trace('TestTrace')
255
+ const trace = client.trace({ name: 'TestTrace', autoFlush: false })
271
256
  .addAttribute('from', '0xabc')
272
257
  .addAttribute('to', '0xdef')
273
258
  .addAttributes({ value: '100', gas: '21000' })
@@ -275,188 +260,563 @@ describe('ParallaxTrace', () => {
275
260
  .addTags(['ethereum', 'send'])
276
261
  .addEvent('started')
277
262
  .addEvent('completed', { success: true })
278
- .setTxHint('0x123', 'ethereum');
263
+ .addTxHint('0x123', 'ethereum');
279
264
 
280
265
  expect(trace).toBeInstanceOf(ParallaxTrace);
281
266
  });
282
267
  });
283
268
 
284
- describe('create()', () => {
285
- it('should create CreateTraceRequest with attributes', async () => {
286
- await client.trace('TestTrace')
287
- .addAttribute('key1', 'value1')
288
- .addAttribute('key2', 'value2')
289
- .create();
269
+ describe('flush() - manual mode', () => {
270
+ it('should call CreateTrace on first flush', async () => {
271
+ const trace = client.trace({ name: 'TestTrace', autoFlush: false })
272
+ .addAttribute('key', 'value');
290
273
 
291
- expect(mockCreateTrace).toHaveBeenCalled();
292
- const request = mockCreateTrace.mock.calls[0][0] as CreateTraceRequest;
293
- expect(request.getName()).toBe('TestTrace');
274
+ trace.flush();
275
+ await jest.runAllTimersAsync();
294
276
 
295
- const attrsMap = request.getAttributesMap();
296
- expect(attrsMap.get('key1')).toBe('value1');
297
- expect(attrsMap.get('key2')).toBe('value2');
277
+ expect(mockCreateTrace).toHaveBeenCalledTimes(1);
278
+ expect(mockUpdateTrace).not.toHaveBeenCalled();
298
279
  });
299
280
 
300
- it('should stringify object attributes in request', async () => {
301
- await client.trace('TestTrace', false)
302
- .addAttribute('config', { timeout: 5000, retries: 3 })
303
- .addAttributes({ data: { nested: 'value' }, count: 42 })
304
- .create();
281
+ it('should call UpdateTrace on subsequent flushes', async () => {
282
+ const trace = client.trace({ name: 'TestTrace', autoFlush: false })
283
+ .addAttribute('key', 'value');
284
+
285
+ trace.flush();
286
+ await jest.runAllTimersAsync();
287
+
288
+ trace.addEvent('new_event');
289
+ trace.flush();
290
+ await jest.runAllTimersAsync();
291
+
292
+ expect(mockCreateTrace).toHaveBeenCalledTimes(1);
293
+ expect(mockUpdateTrace).toHaveBeenCalledTimes(1);
294
+ });
295
+
296
+ it('should set trace name in CreateTraceRequest', async () => {
297
+ const trace = client.trace({ name: 'MyTraceName', autoFlush: false })
298
+ .addAttribute('key', 'value');
299
+
300
+ trace.flush();
301
+ await jest.runAllTimersAsync();
305
302
 
306
303
  const request = mockCreateTrace.mock.calls[0][0] as CreateTraceRequest;
307
- const attrsMap = request.getAttributesMap();
304
+ expect(request.getName()).toBe('MyTraceName');
305
+ });
306
+
307
+ it('should include TraceData with attributes', async () => {
308
+ const trace = client.trace({ name: 'TestTrace', autoFlush: false, includeClientMeta: false })
309
+ .addAttribute('key1', 'value1')
310
+ .addAttribute('key2', 'value2');
311
+
312
+ trace.flush();
313
+ await jest.runAllTimersAsync();
314
+
315
+ const request = mockCreateTrace.mock.calls[0][0] as CreateTraceRequest;
316
+ const data = request.getData();
317
+ expect(data).toBeDefined();
308
318
 
309
- expect(attrsMap.get('config')).toBe('{"timeout":5000,"retries":3}');
310
- expect(attrsMap.get('data')).toBe('{"nested":"value"}');
311
- expect(attrsMap.get('count')).toBe('42');
319
+ const attrsList = data!.getAttributesList();
320
+ expect(attrsList.length).toBe(1);
321
+
322
+ const attrsMap = attrsList[0].getAttributesMap();
323
+ expect(attrsMap.get('key1')).toBe('value1');
324
+ expect(attrsMap.get('key2')).toBe('value2');
312
325
  });
313
326
 
314
- it('should include tags in request', async () => {
315
- await client.trace('TestTrace')
316
- .addTags(['tag1', 'tag2', 'tag3'])
317
- .create();
327
+ it('should include tags in TraceData', async () => {
328
+ const trace = client.trace({ name: 'TestTrace', autoFlush: false, includeClientMeta: false })
329
+ .addTags(['tag1', 'tag2', 'tag3']);
330
+
331
+ trace.flush();
332
+ await jest.runAllTimersAsync();
318
333
 
319
334
  const request = mockCreateTrace.mock.calls[0][0] as CreateTraceRequest;
320
- expect(request.getTagsList()).toEqual(['tag1', 'tag2', 'tag3']);
335
+ const data = request.getData();
336
+ const tagsList = data!.getTagsList();
337
+ expect(tagsList.length).toBe(1);
338
+ expect(tagsList[0].getTagsList()).toEqual(['tag1', 'tag2', 'tag3']);
321
339
  });
322
340
 
323
- it('should include events in request', async () => {
324
- await client.trace('TestTrace')
341
+ it('should include events in TraceData', async () => {
342
+ const trace = client.trace({ name: 'TestTrace', autoFlush: false, includeClientMeta: false })
325
343
  .addEvent('event1', 'details1')
326
- .addEvent('event2', { key: 'value' })
327
- .create();
344
+ .addEvent('event2', { key: 'value' });
345
+
346
+ trace.flush();
347
+ await jest.runAllTimersAsync();
328
348
 
329
349
  const request = mockCreateTrace.mock.calls[0][0] as CreateTraceRequest;
330
- const events = request.getEventsList();
350
+ const data = request.getData();
351
+ const events = data!.getEventsList();
331
352
  expect(events.length).toBe(2);
332
353
  expect(events[0].getName()).toBe('event1');
333
354
  expect(events[1].getName()).toBe('event2');
334
355
  });
335
356
 
336
- it('should include txHashHint when set via setTxHint()', async () => {
337
- await client.trace('TestTrace')
338
- .setTxHint('0xabc123', 'ethereum', 'transaction details')
339
- .create();
357
+ it('should include multiple txHashHints in TraceData', async () => {
358
+ const trace = client.trace({ name: 'TestTrace', autoFlush: false, includeClientMeta: false })
359
+ .addTxHint('0xabc123', 'ethereum', 'first tx')
360
+ .addTxHint('0xdef456', 'polygon', 'second tx');
340
361
 
341
- const request = mockCreateTrace.mock.calls[0][0] as CreateTraceRequest;
342
- const txHint = request.getTxHashHint();
343
- expect(txHint).toBeDefined();
344
- expect(txHint?.getTxHash()).toBe('0xabc123');
345
- expect(txHint?.getChain()).toBe(Chain.CHAIN_ETHEREUM);
346
- });
347
-
348
- it('should support different chain names', async () => {
349
- await client.trace('TestTrace')
350
- .setTxHint('0xabc123', 'polygon')
351
- .create();
362
+ trace.flush();
363
+ await jest.runAllTimersAsync();
352
364
 
353
365
  const request = mockCreateTrace.mock.calls[0][0] as CreateTraceRequest;
354
- const txHint = request.getTxHashHint();
355
- expect(txHint?.getChain()).toBe(Chain.CHAIN_POLYGON);
366
+ const data = request.getData();
367
+ const hints = data!.getTxHashHintsList();
368
+ expect(hints.length).toBe(2);
369
+ expect(hints[0].getTxHash()).toBe('0xabc123');
370
+ expect(hints[0].getChain()).toBe(Chain.CHAIN_ETHEREUM);
371
+ expect(hints[1].getTxHash()).toBe('0xdef456');
372
+ expect(hints[1].getChain()).toBe(Chain.CHAIN_POLYGON);
356
373
  });
357
374
 
358
375
  it('should include client metadata by default', async () => {
359
- await client.trace('TestTrace') // includeClientMeta defaults to true
360
- .addAttribute('custom', 'value')
361
- .create();
376
+ const trace = client.trace({ name: 'TestTrace', autoFlush: false })
377
+ .addAttribute('custom', 'value');
378
+
379
+ trace.flush();
380
+ await jest.runAllTimersAsync();
362
381
 
363
382
  const request = mockCreateTrace.mock.calls[0][0] as CreateTraceRequest;
364
- const attrsMap = request.getAttributesMap();
383
+ const data = request.getData();
384
+ const attrsList = data!.getAttributesList();
385
+ expect(attrsList.length).toBe(1);
365
386
 
366
- // Custom attribute should be present
387
+ const attrsMap = attrsList[0].getAttributesMap();
367
388
  expect(attrsMap.get('custom')).toBe('value');
368
-
369
- // Client metadata should be prefixed with 'client.'
370
389
  expect(attrsMap.get('client.browser')).toBe('Chrome');
371
390
  expect(attrsMap.get('client.os')).toBe('macOS');
372
391
  });
373
392
 
374
- it('should not include client metadata when explicitly disabled', async () => {
375
- await client.trace('TestTrace', false)
376
- .addAttribute('custom', 'value')
377
- .create();
393
+ it('should not include client metadata when disabled', async () => {
394
+ const trace = client.trace({ name: 'TestTrace', autoFlush: false, includeClientMeta: false })
395
+ .addAttribute('custom', 'value');
396
+
397
+ trace.flush();
398
+ await jest.runAllTimersAsync();
378
399
 
379
400
  const request = mockCreateTrace.mock.calls[0][0] as CreateTraceRequest;
380
- const attrsMap = request.getAttributesMap();
401
+ const data = request.getData();
402
+ const attrsList = data!.getAttributesList();
403
+ const attrsMap = attrsList[0].getAttributesMap();
381
404
 
382
405
  expect(attrsMap.get('custom')).toBe('value');
383
406
  expect(attrsMap.get('client.browser')).toBeUndefined();
384
407
  });
385
408
 
386
- it('should return traceId from create()', async () => {
387
- const traceId = await client.trace('TestTrace').create();
409
+ it('should set traceId after successful CreateTrace', async () => {
410
+ const trace = client.trace({ name: 'TestTrace', autoFlush: false })
411
+ .addAttribute('key', 'value');
412
+
413
+ expect(trace.getTraceId()).toBeNull();
414
+
415
+ trace.flush();
416
+ await jest.runAllTimersAsync();
417
+
418
+ expect(trace.getTraceId()).toBe('trace-456');
419
+ });
420
+
421
+ it('should maintain strict ordering of flushes', async () => {
422
+ const trace = client.trace({ name: 'TestTrace', autoFlush: false })
423
+ .addAttribute('first', 'value');
424
+
425
+ trace.flush();
426
+ trace.addEvent('second');
427
+ trace.flush();
428
+ trace.addEvent('third');
429
+ trace.flush();
430
+
431
+ await jest.runAllTimersAsync();
432
+
433
+ expect(mockCreateTrace).toHaveBeenCalledTimes(1);
434
+ expect(mockUpdateTrace).toHaveBeenCalledTimes(2);
435
+ });
436
+ });
437
+
438
+ describe('auto-flush mode', () => {
439
+ it('should auto-flush after flushPeriodMs of inactivity', async () => {
440
+ const trace = client.trace({ name: 'TestTrace', autoFlush: true, flushPeriodMs: 50, includeClientMeta: false })
441
+ .addAttribute('key', 'value');
442
+
443
+ expect(mockCreateTrace).not.toHaveBeenCalled();
444
+
445
+ // Advance time past flushPeriodMs
446
+ jest.advanceTimersByTime(50);
447
+ await jest.runAllTimersAsync();
448
+
449
+ expect(mockCreateTrace).toHaveBeenCalledTimes(1);
450
+ expect(trace.getTraceId()).toBe('trace-456');
451
+ });
452
+
453
+ it('should reset timer on each SDK call', async () => {
454
+ const trace = client.trace({ name: 'TestTrace', autoFlush: true, flushPeriodMs: 50, includeClientMeta: false });
455
+
456
+ trace.addAttribute('key1', 'value1');
457
+ jest.advanceTimersByTime(30);
458
+
459
+ trace.addAttribute('key2', 'value2');
460
+ jest.advanceTimersByTime(30);
461
+
462
+ // Should not have flushed yet (timer reset)
463
+ expect(mockCreateTrace).not.toHaveBeenCalled();
464
+
465
+ jest.advanceTimersByTime(50);
466
+ await jest.runAllTimersAsync();
467
+
468
+ // Now should have flushed with both attributes
469
+ expect(mockCreateTrace).toHaveBeenCalledTimes(1);
470
+ });
471
+
472
+ it('should allow explicit flush() even when autoFlush is true', async () => {
473
+ const trace = client.trace({ name: 'TestTrace', autoFlush: true, flushPeriodMs: 50, includeClientMeta: false })
474
+ .addAttribute('key', 'value');
475
+
476
+ // Explicit flush before timer expires
477
+ trace.flush();
478
+ await jest.runAllTimersAsync();
479
+
480
+ expect(mockCreateTrace).toHaveBeenCalledTimes(1);
481
+
482
+ // Timer should be cancelled, no duplicate flush
483
+ jest.advanceTimersByTime(100);
484
+ await jest.runAllTimersAsync();
485
+
486
+ expect(mockCreateTrace).toHaveBeenCalledTimes(1);
487
+ });
488
+
489
+ it('should call UpdateTrace on subsequent auto-flushes', async () => {
490
+ const trace = client.trace({ name: 'TestTrace', autoFlush: true, flushPeriodMs: 50, includeClientMeta: false })
491
+ .addAttribute('key', 'value');
492
+
493
+ jest.advanceTimersByTime(50);
494
+ await jest.runAllTimersAsync();
495
+
496
+ expect(mockCreateTrace).toHaveBeenCalledTimes(1);
497
+
498
+ trace.addEvent('new_event');
499
+ jest.advanceTimersByTime(50);
500
+ await jest.runAllTimersAsync();
388
501
 
389
- expect(traceId).toBe('trace-456');
502
+ expect(mockUpdateTrace).toHaveBeenCalledTimes(1);
390
503
  });
504
+ });
391
505
 
392
- it('should return undefined and log on connection error', async () => {
506
+ describe('immediate flush mode (flushPeriodMs: 0)', () => {
507
+ it('should flush immediately on every SDK call', async () => {
508
+ const trace = client.trace({ name: 'TestTrace', flushPeriodMs: 0, includeClientMeta: false });
509
+
510
+ trace.addAttribute('key1', 'value1');
511
+ await jest.runAllTimersAsync();
512
+ expect(mockCreateTrace).toHaveBeenCalledTimes(1);
513
+
514
+ trace.addAttribute('key2', 'value2');
515
+ await jest.runAllTimersAsync();
516
+ expect(mockUpdateTrace).toHaveBeenCalledTimes(1);
517
+
518
+ trace.addEvent('event1');
519
+ await jest.runAllTimersAsync();
520
+ expect(mockUpdateTrace).toHaveBeenCalledTimes(2);
521
+ });
522
+
523
+ it('should send each SDK call as a separate request', async () => {
524
+ const trace = client.trace({ name: 'TestTrace', flushPeriodMs: 0, includeClientMeta: false });
525
+
526
+ trace.addAttribute('first', 'value');
527
+ await jest.runAllTimersAsync();
528
+
529
+ trace.addTxHint('0xabc', 'ethereum');
530
+ await jest.runAllTimersAsync();
531
+
532
+ trace.addEvent('done');
533
+ await jest.runAllTimersAsync();
534
+
535
+ expect(mockCreateTrace).toHaveBeenCalledTimes(1);
536
+ expect(mockUpdateTrace).toHaveBeenCalledTimes(2);
537
+ });
538
+ });
539
+
540
+ describe('error handling', () => {
541
+ it('should log error on CreateTrace failure after retries', async () => {
393
542
  mockCreateTrace.mockRejectedValue(new Error('Connection failed'));
394
543
 
395
- const traceId = await client.trace('TestTrace').create();
544
+ // Disable retries for faster test
545
+ const trace = client.trace({ name: 'TestTrace', autoFlush: false, maxRetries: 0 })
546
+ .addAttribute('key', 'value');
547
+
548
+ trace.flush();
549
+ await jest.runAllTimersAsync();
550
+
551
+ expect(mockConsoleError).toHaveBeenCalledWith(
552
+ '[ParallaxTrace] CreateTrace error after retries:',
553
+ expect.any(Error)
554
+ );
555
+ });
556
+
557
+ it('should log error on UpdateTrace failure after retries', async () => {
558
+ mockUpdateTrace.mockRejectedValue(new Error('Connection failed'));
559
+
560
+ // Disable retries for faster test
561
+ const trace = client.trace({ name: 'TestTrace', autoFlush: false, maxRetries: 0 })
562
+ .addAttribute('key', 'value');
563
+
564
+ trace.flush();
565
+ await jest.runAllTimersAsync();
396
566
 
397
- expect(traceId).toBeUndefined();
398
- expect(mockConsoleLog).toHaveBeenCalledWith(
399
- '[ParallaxTrace] Error creating trace:',
567
+ trace.addEvent('event');
568
+ trace.flush();
569
+ await jest.runAllTimersAsync();
570
+
571
+ expect(mockConsoleError).toHaveBeenCalledWith(
572
+ '[ParallaxTrace] UpdateTrace error after retries:',
400
573
  expect.any(Error)
401
574
  );
402
575
  });
403
576
 
404
- it('should return undefined and log on status error', async () => {
577
+ it('should log error on status failure', async () => {
405
578
  mockCreateTrace.mockResolvedValue({
406
579
  getTraceId: () => '',
407
580
  getStatus: () => ({
408
- getCode: () => 4, // INTERNAL_ERROR
409
- getErrorMessage: () => 'Internal server error',
581
+ getCode: () => ResponseStatus.StatusCode.STATUS_CODE_VALIDATION_ERROR,
582
+ getErrorMessage: () => 'Validation error',
410
583
  }),
411
584
  });
412
585
 
413
- const traceId = await client.trace('TestTrace').create();
586
+ const trace = client.trace({ name: 'TestTrace', autoFlush: false })
587
+ .addAttribute('key', 'value');
588
+
589
+ trace.flush();
590
+ await jest.runAllTimersAsync();
414
591
 
415
- expect(traceId).toBeUndefined();
416
- expect(mockConsoleLog).toHaveBeenCalledWith(
417
- '[ParallaxTrace] Error:',
418
- 'Internal server error'
592
+ expect(mockConsoleError).toHaveBeenCalledWith(
593
+ '[ParallaxTrace] CreateTrace failed:',
594
+ 'Validation error'
419
595
  );
420
596
  });
597
+ });
421
598
 
422
- it('should work without txHashHint', async () => {
423
- await client.trace('TestTrace')
424
- .addAttribute('key', 'value')
425
- .create();
599
+ describe('retry behavior', () => {
600
+ let mockConsoleWarn: jest.SpyInstance;
426
601
 
427
- const request = mockCreateTrace.mock.calls[0][0] as CreateTraceRequest;
428
- expect(request.getTxHashHint()).toBeUndefined();
602
+ beforeEach(() => {
603
+ mockConsoleWarn = jest.spyOn(console, 'warn').mockImplementation();
604
+ });
605
+
606
+ afterEach(() => {
607
+ mockConsoleWarn.mockRestore();
608
+ });
609
+
610
+ it('should retry on CreateTrace failure with exponential backoff', async () => {
611
+ // Fail twice, then succeed
612
+ mockCreateTrace
613
+ .mockRejectedValueOnce(new Error('Network error'))
614
+ .mockRejectedValueOnce(new Error('Network error'))
615
+ .mockResolvedValueOnce({
616
+ getTraceId: () => 'trace-retry-success',
617
+ getStatus: () => ({ getCode: () => ResponseStatus.StatusCode.STATUS_CODE_SUCCESS }),
618
+ });
619
+
620
+ const trace = client.trace({
621
+ name: 'TestTrace',
622
+ autoFlush: false,
623
+ includeClientMeta: false,
624
+ maxRetries: 3,
625
+ retryBackoff: 100,
626
+ }).addAttribute('key', 'value');
627
+
628
+ trace.flush();
629
+
630
+ // First attempt happens immediately
631
+ await jest.advanceTimersByTimeAsync(0);
632
+ expect(mockCreateTrace).toHaveBeenCalledTimes(1);
633
+
634
+ // Before first retry delay (100ms * 2^0 = 100ms), no second call
635
+ await jest.advanceTimersByTimeAsync(99);
636
+ expect(mockCreateTrace).toHaveBeenCalledTimes(1);
637
+
638
+ // After first retry delay, second call happens
639
+ await jest.advanceTimersByTimeAsync(1);
640
+ expect(mockCreateTrace).toHaveBeenCalledTimes(2);
641
+
642
+ // Before second retry delay (100ms * 2^1 = 200ms), no third call
643
+ await jest.advanceTimersByTimeAsync(199);
644
+ expect(mockCreateTrace).toHaveBeenCalledTimes(2);
645
+
646
+ // After second retry delay, third call happens
647
+ await jest.advanceTimersByTimeAsync(1);
648
+ expect(mockCreateTrace).toHaveBeenCalledTimes(3);
649
+
650
+ await jest.runAllTimersAsync();
651
+
652
+ expect(trace.getTraceId()).toBe('trace-retry-success');
653
+ expect(mockConsoleWarn).toHaveBeenCalledTimes(2);
654
+ });
655
+
656
+ it('should retry on UpdateTrace failure', async () => {
657
+ // First CreateTrace succeeds
658
+ mockCreateTrace.mockResolvedValue({
659
+ getTraceId: () => 'trace-456',
660
+ getStatus: () => ({ getCode: () => ResponseStatus.StatusCode.STATUS_CODE_SUCCESS }),
661
+ });
662
+
663
+ // UpdateTrace fails once, then succeeds
664
+ mockUpdateTrace
665
+ .mockRejectedValueOnce(new Error('Network error'))
666
+ .mockResolvedValueOnce({
667
+ getStatus: () => ({ getCode: () => ResponseStatus.StatusCode.STATUS_CODE_SUCCESS }),
668
+ });
669
+
670
+ const trace = client.trace({
671
+ name: 'TestTrace',
672
+ autoFlush: false,
673
+ includeClientMeta: false,
674
+ maxRetries: 2,
675
+ retryBackoff: 50,
676
+ }).addAttribute('key', 'value');
677
+
678
+ trace.flush();
679
+ await jest.runAllTimersAsync();
680
+
681
+ trace.addEvent('event');
682
+ trace.flush();
683
+
684
+ // Wait for retry
685
+ await jest.advanceTimersByTimeAsync(50);
686
+ await jest.runAllTimersAsync();
687
+
688
+ expect(mockUpdateTrace).toHaveBeenCalledTimes(2);
689
+ expect(mockConsoleWarn).toHaveBeenCalledTimes(1);
690
+ });
691
+
692
+ it('should respect maxRetries option', async () => {
693
+ mockCreateTrace.mockRejectedValue(new Error('Always fails'));
694
+
695
+ const trace = client.trace({
696
+ name: 'TestTrace',
697
+ autoFlush: false,
698
+ includeClientMeta: false,
699
+ maxRetries: 2,
700
+ retryBackoff: 10,
701
+ }).addAttribute('key', 'value');
702
+
703
+ trace.flush();
704
+
705
+ // Allow all retries to complete
706
+ await jest.advanceTimersByTimeAsync(10); // First retry
707
+ await jest.advanceTimersByTimeAsync(20); // Second retry
708
+ await jest.runAllTimersAsync();
709
+
710
+ // Initial attempt + 2 retries = 3 total calls
711
+ expect(mockCreateTrace).toHaveBeenCalledTimes(3);
712
+ expect(mockConsoleError).toHaveBeenCalledWith(
713
+ '[ParallaxTrace] CreateTrace error after retries:',
714
+ expect.any(Error)
715
+ );
716
+ });
717
+
718
+ it('should use default retry options', async () => {
719
+ mockCreateTrace
720
+ .mockRejectedValueOnce(new Error('Network error'))
721
+ .mockResolvedValueOnce({
722
+ getTraceId: () => 'trace-default',
723
+ getStatus: () => ({ getCode: () => ResponseStatus.StatusCode.STATUS_CODE_SUCCESS }),
724
+ });
725
+
726
+ // Use default options (maxRetries: 3, retryBackoff: 1000)
727
+ const trace = client.trace({
728
+ name: 'TestTrace',
729
+ autoFlush: false,
730
+ includeClientMeta: false,
731
+ }).addAttribute('key', 'value');
732
+
733
+ trace.flush();
734
+
735
+ // First attempt fails
736
+ await jest.advanceTimersByTimeAsync(0);
737
+
738
+ // Wait for first retry (1000ms default backoff)
739
+ await jest.advanceTimersByTimeAsync(1000);
740
+ await jest.runAllTimersAsync();
741
+
742
+ expect(mockCreateTrace).toHaveBeenCalledTimes(2);
743
+ expect(trace.getTraceId()).toBe('trace-default');
744
+ });
745
+
746
+ it('should disable retries when maxRetries is 0', async () => {
747
+ mockCreateTrace.mockRejectedValue(new Error('Network error'));
748
+
749
+ const trace = client.trace({
750
+ name: 'TestTrace',
751
+ autoFlush: false,
752
+ includeClientMeta: false,
753
+ maxRetries: 0,
754
+ }).addAttribute('key', 'value');
755
+
756
+ trace.flush();
757
+ await jest.runAllTimersAsync();
758
+
759
+ // Only 1 attempt, no retries
760
+ expect(mockCreateTrace).toHaveBeenCalledTimes(1);
761
+ expect(mockConsoleWarn).not.toHaveBeenCalled();
429
762
  });
430
763
  });
431
764
 
432
765
  describe('integration test', () => {
433
- it('should work with real usage pattern', async () => {
434
- const traceId = await client.trace('SendTransaction') // client metadata included by default
766
+ it('should work with real usage pattern - manual flush', async () => {
767
+ const trace = client.trace({ name: 'SendTransaction', autoFlush: false })
435
768
  .addAttribute('from', '0xabc123')
436
769
  .addAttribute('to', '0xdef456')
437
770
  .addAttribute('value', '1.5')
438
- .addAttribute('network', 'ethereum')
439
771
  .addTags(['transaction', 'send', 'ethereum'])
440
772
  .addEvent('wallet_connected', { wallet: 'MetaMask' })
441
- .addEvent('transaction_initiated')
442
- .addEvent('transaction_sent', { blockNumber: 12345 })
443
- .setTxHint('0xtxhash123', 'ethereum')
444
- .create();
773
+ .addEvent('transaction_initiated');
774
+
775
+ trace.flush();
776
+ await jest.runAllTimersAsync();
445
777
 
446
- expect(traceId).toBe('trace-456');
447
778
  expect(mockCreateTrace).toHaveBeenCalledTimes(1);
779
+ expect(trace.getTraceId()).toBe('trace-456');
448
780
 
449
781
  const request = mockCreateTrace.mock.calls[0][0] as CreateTraceRequest;
450
782
  expect(request.getName()).toBe('SendTransaction');
451
- expect(request.getTagsList()).toEqual(['transaction', 'send', 'ethereum']);
452
- expect(request.getEventsList().length).toBe(3);
453
- expect(request.getTxHashHint()?.getTxHash()).toBe('0xtxhash123');
454
- expect(request.getTxHashHint()?.getChain()).toBe(Chain.CHAIN_ETHEREUM);
455
-
456
- const attrsMap = request.getAttributesMap();
457
- expect(attrsMap.get('from')).toBe('0xabc123');
458
- expect(attrsMap.get('to')).toBe('0xdef456');
459
- expect(attrsMap.get('client.browser')).toBe('Chrome');
783
+
784
+ // Add more data and flush again
785
+ trace.addEvent('transaction_sent', { blockNumber: 12345 })
786
+ .addTxHint('0xtxhash123', 'ethereum');
787
+
788
+ trace.flush();
789
+ await jest.runAllTimersAsync();
790
+
791
+ expect(mockUpdateTrace).toHaveBeenCalledTimes(1);
792
+
793
+ const updateRequest = mockUpdateTrace.mock.calls[0][0] as UpdateTraceRequest;
794
+ expect(updateRequest.getTraceId()).toBe('trace-456');
795
+ });
796
+
797
+ it('should work with real usage pattern - auto flush', async () => {
798
+ const trace = client.trace({ name: 'SendTransaction', flushPeriodMs: 50 })
799
+ .addAttribute('from', '0xabc123')
800
+ .addAttribute('to', '0xdef456')
801
+ .addTags(['transaction', 'ethereum'])
802
+ .addEvent('wallet_connected');
803
+
804
+ // Let auto-flush trigger
805
+ jest.advanceTimersByTime(50);
806
+ await jest.runAllTimersAsync();
807
+
808
+ expect(mockCreateTrace).toHaveBeenCalledTimes(1);
809
+ expect(trace.getTraceId()).toBe('trace-456');
810
+
811
+ // Add more data
812
+ trace.addEvent('transaction_sent')
813
+ .addTxHint('0xtxhash', 'ethereum');
814
+
815
+ // Let auto-flush trigger again
816
+ jest.advanceTimersByTime(50);
817
+ await jest.runAllTimersAsync();
818
+
819
+ expect(mockUpdateTrace).toHaveBeenCalledTimes(1);
460
820
  });
461
821
  });
462
822
  });