@telnyx/voice-agent-tester 0.4.3 → 0.4.5

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.
@@ -526,10 +526,98 @@ class AudioElementMonitor {
526
526
 
527
527
  console.log(`Started monitoring programmatic audio element: ${elementId}`);
528
528
  } catch (error) {
529
- console.error(`Failed to monitor programmatic audio element ${elementId}:`, error);
529
+ console.error(`Failed to monitor programmatic audio element ${elementId} via analyser:`, error.message);
530
+ console.log(`Falling back to event-based monitoring for ${elementId}`);
531
+ this.monitorViaEvents(audioElement, elementId);
530
532
  }
531
533
  }
532
534
 
535
+ /**
536
+ * Fallback monitoring using audio element events (timeupdate/playing/pause).
537
+ * Used when AudioContext-based monitoring fails (e.g., when the audio element
538
+ * is already connected to another AudioContext via MediaStreamDestination).
539
+ */
540
+ monitorViaEvents(audioElement, elementId) {
541
+ const monitorData = {
542
+ element: audioElement,
543
+ source: null,
544
+ analyser: null,
545
+ dataArray: null,
546
+ isPlaying: false,
547
+ lastAudioTime: 0,
548
+ silenceThreshold: 10,
549
+ checkInterval: null,
550
+ isProgrammatic: true,
551
+ eventBased: true
552
+ };
553
+
554
+ this.monitoredElements.set(elementId, monitorData);
555
+
556
+ // Use timeupdate to detect audio activity — fires ~4x/sec during playback
557
+ let lastTimeUpdate = 0;
558
+ let silenceTimeoutId = null;
559
+ const SILENCE_DELAY = 1500; // ms of no timeupdate before declaring silence
560
+
561
+ const resetSilenceTimer = () => {
562
+ if (silenceTimeoutId) clearTimeout(silenceTimeoutId);
563
+ silenceTimeoutId = setTimeout(() => {
564
+ if (monitorData.isPlaying) {
565
+ monitorData.isPlaying = false;
566
+ this.dispatchAudioEvent('audiostop', elementId, audioElement);
567
+ if (typeof window.__publishEvent === 'function') {
568
+ window.__publishEvent('audiostop', { elementId, timestamp: Date.now() });
569
+ }
570
+ console.log(`Audio stopped (event-based): ${elementId}`);
571
+ }
572
+ }, SILENCE_DELAY);
573
+ };
574
+
575
+ audioElement.addEventListener('timeupdate', () => {
576
+ const now = Date.now();
577
+ // timeupdate fires even when seeking; only count if currentTime advances
578
+ if (audioElement.currentTime > 0 && now - lastTimeUpdate > 50) {
579
+ lastTimeUpdate = now;
580
+ monitorData.lastAudioTime = now;
581
+
582
+ if (!monitorData.isPlaying) {
583
+ monitorData.isPlaying = true;
584
+ this.dispatchAudioEvent('audiostart', elementId, audioElement);
585
+ if (typeof window.__publishEvent === 'function') {
586
+ window.__publishEvent('audiostart', { elementId, timestamp: Date.now() });
587
+ }
588
+ console.log(`Audio started (event-based): ${elementId}`);
589
+ }
590
+ resetSilenceTimer();
591
+ }
592
+ });
593
+
594
+ audioElement.addEventListener('pause', () => {
595
+ if (monitorData.isPlaying) {
596
+ monitorData.isPlaying = false;
597
+ if (silenceTimeoutId) clearTimeout(silenceTimeoutId);
598
+ this.dispatchAudioEvent('audiostop', elementId, audioElement);
599
+ if (typeof window.__publishEvent === 'function') {
600
+ window.__publishEvent('audiostop', { elementId, timestamp: Date.now() });
601
+ }
602
+ console.log(`Audio stopped (event-based, pause): ${elementId}`);
603
+ }
604
+ });
605
+
606
+ audioElement.addEventListener('ended', () => {
607
+ if (monitorData.isPlaying) {
608
+ monitorData.isPlaying = false;
609
+ if (silenceTimeoutId) clearTimeout(silenceTimeoutId);
610
+ this.dispatchAudioEvent('audiostop', elementId, audioElement);
611
+ if (typeof window.__publishEvent === 'function') {
612
+ window.__publishEvent('audiostop', { elementId, timestamp: Date.now() });
613
+ }
614
+ console.log(`Audio stopped (event-based, ended): ${elementId}`);
615
+ }
616
+ });
617
+
618
+ console.log(`Started event-based monitoring for programmatic audio element: ${elementId}`);
619
+ }
620
+
533
621
  monitorAudioElement(audioElement, elementId) {
534
622
  if (!this.audioContext) {
535
623
  console.warn("AudioContext not available, cannot monitor audio");
@@ -564,7 +652,9 @@ class AudioElementMonitor {
564
652
 
565
653
  console.log(`Started monitoring audio element: ${elementId}`);
566
654
  } catch (error) {
567
- console.error(`Failed to monitor audio element ${elementId}:`, error);
655
+ console.error(`Failed to monitor audio element ${elementId} via analyser:`, error.message);
656
+ console.log(`Falling back to event-based monitoring for ${elementId}`);
657
+ this.monitorViaEvents(audioElement, elementId);
568
658
  }
569
659
  }
570
660
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telnyx/voice-agent-tester",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "A command-line tool to test voice agents using Puppeteer",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/index.js CHANGED
@@ -109,14 +109,7 @@ function getCompareRequiredParams(argv) {
109
109
  }
110
110
  break;
111
111
  case 'elevenlabs':
112
- if (!argv.branchId) {
113
- missing.push({
114
- key: 'branchId',
115
- flag: '--branch-id',
116
- description: 'ElevenLabs branch ID',
117
- hint: 'In the ElevenLabs Dashboard, go to Agents, select your target agent, then click the dropdown next to Publish and select "Copy shareable link". This copies the demo link containing your branch ID.'
118
- });
119
- }
112
+ // branchId is optional — the talk-to URL works with just agent_id
120
113
  break;
121
114
  // retell and others: no extra params needed yet
122
115
  }
@@ -134,8 +127,22 @@ function getCompareTemplateParams(argv) {
134
127
  switch (argv.provider) {
135
128
  case 'vapi':
136
129
  return { shareKey: argv.shareKey };
130
+ default:
131
+ return {};
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Get provider-specific extra query parameters to append to the comparison URL.
137
+ * Unlike template params, these are appended as-is (not substituted into {{...}} placeholders).
138
+ *
139
+ * @param {Object} argv - Parsed CLI arguments
140
+ * @returns {Object} Key-value pairs to append as query parameters
141
+ */
142
+ function getCompareExtraQueryParams(argv) {
143
+ switch (argv.provider) {
137
144
  case 'elevenlabs':
138
- return { branchId: argv.branchId };
145
+ return argv.branchId ? { branch_id: argv.branchId } : {};
139
146
  default:
140
147
  return {};
141
148
  }
@@ -295,7 +302,7 @@ const argv = yargs(hideBin(process.argv))
295
302
  })
296
303
  .option('branch-id', {
297
304
  type: 'string',
298
- description: 'ElevenLabs branch ID for direct widget testing (required for comparison mode with --provider elevenlabs)'
305
+ description: 'ElevenLabs branch ID for direct widget testing (optional, appended to demo URL when provided)'
299
306
  })
300
307
  .option('assistant-id', {
301
308
  type: 'string',
@@ -327,6 +334,11 @@ const argv = yargs(hideBin(process.argv))
327
334
  description: 'Volume level for audio input (0.0 to 1.0)',
328
335
  default: 1.0
329
336
  })
337
+ .option('retries', {
338
+ type: 'number',
339
+ description: 'Number of retries for failed test runs (0 = no retries)',
340
+ default: 0
341
+ })
330
342
  .help()
331
343
  .argv;
332
344
 
@@ -409,22 +421,49 @@ async function runBenchmark({ applications, scenarios, repeat, concurrency, argv
409
421
  audioVolume: argv.audioVolume
410
422
  });
411
423
 
412
- try {
413
- await tester.runScenario(targetUrl, app.steps, scenario.steps, app.name, scenario.name, repetition);
414
- console.log(`✅ Completed successfully (Run ${runNumber}/${totalRuns})`);
415
- return { success: true };
416
- } catch (error) {
417
- // Store only the first line for summary, but print full message here (with diagnostics)
418
- const shortMessage = error.message.split('\n')[0];
419
- const errorInfo = {
420
- app: app.name,
421
- scenario: scenario.name,
422
- repetition,
423
- error: shortMessage
424
- };
425
- // Print full diagnostics here (only place they appear)
426
- console.error(`❌ Error (Run ${runNumber}/${totalRuns}):\n${error.message}`);
427
- return { success: false, error: errorInfo };
424
+ const maxAttempts = (argv.retries || 0) + 1;
425
+
426
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
427
+ // Create a fresh tester for each attempt (after first, original tester is closed)
428
+ const currentTester = attempt === 1 ? tester : new VoiceAgentTester({
429
+ verbose: argv.verbose,
430
+ headless: argv.headless,
431
+ assetsServerUrl: argv.assetsServer,
432
+ reportGenerator: reportGenerator,
433
+ record: argv.record,
434
+ debug: argv.debug,
435
+ audioUrl: argv.audioUrl,
436
+ audioVolume: argv.audioVolume
437
+ });
438
+
439
+ try {
440
+ await currentTester.runScenario(targetUrl, app.steps, scenario.steps, app.name, scenario.name, repetition);
441
+ console.log(`✅ Completed successfully (Run ${runNumber}/${totalRuns})`);
442
+ return { success: true };
443
+ } catch (error) {
444
+ const shortMessage = error.message.split('\n')[0];
445
+
446
+ if (attempt < maxAttempts) {
447
+ console.warn(`\n⚠️ Attempt ${attempt}/${maxAttempts} failed: ${shortMessage}`);
448
+ console.warn(`🔄 Retrying in 3s... (${maxAttempts - attempt} retries left)\n`);
449
+ await new Promise(r => setTimeout(r, 3000));
450
+ continue;
451
+ }
452
+
453
+ // Final attempt failed
454
+ const errorInfo = {
455
+ app: app.name,
456
+ scenario: scenario.name,
457
+ repetition,
458
+ error: shortMessage
459
+ };
460
+ // Print full diagnostics here (only place they appear)
461
+ console.error(`❌ Error (Run ${runNumber}/${totalRuns}):\n${error.message}`);
462
+ if (maxAttempts > 1) {
463
+ console.error(` Failed after ${maxAttempts} attempts`);
464
+ }
465
+ return { success: false, error: errorInfo };
466
+ }
428
467
  }
429
468
  }
430
469
 
@@ -678,7 +717,19 @@ async function main() {
678
717
  throw new Error(`Provider application config not found: ${providerAppPath}\nPlease create applications/${argv.provider}.yaml for direct provider benchmarking.`);
679
718
  }
680
719
 
681
- const providerApplications = [loadApplicationConfig(providerAppPath, providerParams)];
720
+ const providerApp = loadApplicationConfig(providerAppPath, providerParams);
721
+
722
+ // Append optional extra query parameters (e.g. branch_id for ElevenLabs)
723
+ const extraQueryParams = getCompareExtraQueryParams(argv);
724
+ if (providerApp.url && Object.keys(extraQueryParams).length > 0) {
725
+ const url = new URL(providerApp.url);
726
+ for (const [key, value] of Object.entries(extraQueryParams)) {
727
+ url.searchParams.set(key, value);
728
+ }
729
+ providerApp.url = url.toString();
730
+ }
731
+
732
+ const providerApplications = [providerApp];
682
733
 
683
734
  const providerResults = await runBenchmark({
684
735
  applications: providerApplications,
@@ -730,7 +781,7 @@ async function main() {
730
781
  telnyxReportGenerator.generateMetricsSummary();
731
782
 
732
783
  // Generate comparison report
733
- ReportGenerator.generateComparisonSummary(providerReportGenerator, telnyxReportGenerator, argv.provider);
784
+ ReportGenerator.generateComparisonSummary(providerReportGenerator, telnyxReportGenerator, argv.provider, { debug: argv.debug });
734
785
 
735
786
  // Generate CSV reports if requested
736
787
  if (argv.report) {
package/src/report.js CHANGED
@@ -28,7 +28,7 @@ export class ReportGenerator {
28
28
  });
29
29
  }
30
30
 
31
- recordStepMetric(appName, scenarioName, repetition, stepIndex, action, name, value) {
31
+ recordStepMetric(appName, scenarioName, repetition, stepIndex, action, name, value, scenarioStepIndex = null) {
32
32
  const key = this._getRunKey(appName, scenarioName, repetition);
33
33
  const run = this.runs.get(key);
34
34
 
@@ -52,6 +52,26 @@ export class ReportGenerator {
52
52
  if (!this.stepColumns.get(stepIndex).has(name)) {
53
53
  this.stepColumns.get(stepIndex).set(name, `step_${stepIndex + 1}_${action}_${name}`);
54
54
  }
55
+
56
+ // Track scenario step index for cross-provider comparison alignment
57
+ if (scenarioStepIndex !== null) {
58
+ if (!this.scenarioStepMap) {
59
+ this.scenarioStepMap = new Map();
60
+ }
61
+ // Map absolute stepIndex -> scenarioStepIndex (1-based)
62
+ this.scenarioStepMap.set(stepIndex, scenarioStepIndex);
63
+
64
+ // Track scenario-based column names for comparison display
65
+ if (!this.scenarioStepColumns) {
66
+ this.scenarioStepColumns = new Map();
67
+ }
68
+ if (!this.scenarioStepColumns.has(scenarioStepIndex)) {
69
+ this.scenarioStepColumns.set(scenarioStepIndex, new Map());
70
+ }
71
+ if (!this.scenarioStepColumns.get(scenarioStepIndex).has(name)) {
72
+ this.scenarioStepColumns.get(scenarioStepIndex).set(name, `scenario_step_${scenarioStepIndex}_${action}_${name}`);
73
+ }
74
+ }
55
75
  }
56
76
 
57
77
  endRun(appName, scenarioName, repetition, success = true) {
@@ -285,119 +305,178 @@ export class ReportGenerator {
285
305
  return result;
286
306
  }
287
307
 
308
+ /**
309
+ * Get aggregated metrics keyed by scenario step index for cross-provider comparison.
310
+ * Returns a Map of scenarioStepIndex -> { metricName -> { avg, min, max, p50, columnName } }
311
+ */
312
+ getAggregatedMetricsByScenarioStep() {
313
+ const result = new Map();
314
+
315
+ // Build reverse map: absolute stepIndex -> scenarioStepIndex
316
+ const scenarioStepMap = this.scenarioStepMap || new Map();
317
+
318
+ // Collect values grouped by scenarioStepIndex
319
+ const grouped = new Map(); // scenarioStepIndex -> metricName -> values[]
320
+
321
+ this.allRunsData.forEach(run => {
322
+ run.stepMetrics.forEach((metrics, stepIndex) => {
323
+ const scenarioIdx = scenarioStepMap.get(stepIndex);
324
+ if (scenarioIdx == null) return; // skip steps without scenario mapping
325
+
326
+ if (!grouped.has(scenarioIdx)) {
327
+ grouped.set(scenarioIdx, new Map());
328
+ }
329
+
330
+ metrics.forEach((value, metricName) => {
331
+ if (!grouped.get(scenarioIdx).has(metricName)) {
332
+ grouped.get(scenarioIdx).set(metricName, []);
333
+ }
334
+ grouped.get(scenarioIdx).get(metricName).push(value);
335
+ });
336
+ });
337
+ });
338
+
339
+ grouped.forEach((metricMap, scenarioIdx) => {
340
+ const stepResult = new Map();
341
+
342
+ metricMap.forEach((values, metricName) => {
343
+ if (values.length > 0) {
344
+ const sum = values.reduce((a, b) => a + b, 0);
345
+ const avg = sum / values.length;
346
+ const min = Math.min(...values);
347
+ const max = Math.max(...values);
348
+
349
+ const sortedValues = [...values].sort((a, b) => a - b);
350
+ let p50;
351
+ if (sortedValues.length % 2 === 0) {
352
+ p50 = (sortedValues[sortedValues.length / 2 - 1] + sortedValues[sortedValues.length / 2]) / 2;
353
+ } else {
354
+ p50 = sortedValues[Math.floor(sortedValues.length / 2)];
355
+ }
356
+
357
+ const columnName = this.scenarioStepColumns?.get(scenarioIdx)?.get(metricName) ||
358
+ `scenario_step_${scenarioIdx}_${metricName}`;
359
+
360
+ stepResult.set(metricName, { avg, min, max, p50, columnName });
361
+ }
362
+ });
363
+
364
+ result.set(scenarioIdx, stepResult);
365
+ });
366
+
367
+ return result;
368
+ }
369
+
288
370
  /**
289
371
  * Generate a comparison summary between two providers.
372
+ * Aligns metrics by scenario step index so that identical scenario steps
373
+ * are compared regardless of different application setup steps.
290
374
  * @param {ReportGenerator} providerReport - Report from the provider benchmark
291
375
  * @param {ReportGenerator} telnyxReport - Report from the Telnyx benchmark
292
376
  * @param {string} providerName - Name of the external provider
293
377
  */
294
- static generateComparisonSummary(providerReport, telnyxReport, providerName) {
378
+ static generateComparisonSummary(providerReport, telnyxReport, providerName, { debug = false } = {}) {
295
379
  console.log('\n' + '='.repeat(80));
296
- console.log('📊 COMPARISON SUMMARY: ' + providerName.toUpperCase() + ' vs TELNYX');
380
+ console.log('📊 COMPARISON: ' + providerName.toUpperCase() + ' vs TELNYX');
297
381
  console.log('='.repeat(80));
298
382
 
299
- const providerMetrics = providerReport.getAggregatedMetrics();
300
- const telnyxMetrics = telnyxReport.getAggregatedMetrics();
383
+ // Use scenario-step-aligned metrics for comparison
384
+ const providerMetrics = providerReport.getAggregatedMetricsByScenarioStep();
385
+ const telnyxMetrics = telnyxReport.getAggregatedMetricsByScenarioStep();
301
386
 
302
- // Find all unique step indices from both reports
303
- const allStepIndices = new Set([
387
+ // Find matched scenario steps (present in both providers)
388
+ const allScenarioSteps = new Set([
304
389
  ...providerMetrics.keys(),
305
390
  ...telnyxMetrics.keys()
306
391
  ]);
307
- const sortedIndices = Array.from(allStepIndices).sort((a, b) => a - b);
392
+ const sortedIndices = Array.from(allScenarioSteps).sort((a, b) => a - b);
393
+
394
+ // Collect matched latencies
395
+ const providerLatencies = [];
396
+ const telnyxLatencies = [];
397
+ const perResponse = []; // for debug output
308
398
 
309
- if (sortedIndices.length === 0) {
310
- console.log('No metrics available for comparison.');
399
+ sortedIndices.forEach(scenarioStep => {
400
+ const providerElapsed = providerMetrics.get(scenarioStep)?.get('elapsed_time');
401
+ const telnyxElapsed = telnyxMetrics.get(scenarioStep)?.get('elapsed_time');
402
+
403
+ if (providerElapsed && telnyxElapsed) {
404
+ providerLatencies.push(providerElapsed.avg);
405
+ telnyxLatencies.push(telnyxElapsed.avg);
406
+ perResponse.push({
407
+ providerAvg: providerElapsed.avg,
408
+ telnyxAvg: telnyxElapsed.avg,
409
+ columnName: providerElapsed.columnName || telnyxElapsed.columnName
410
+ });
411
+ }
412
+ });
413
+
414
+ if (providerLatencies.length === 0) {
415
+ console.log('\n⚠️ No comparable metrics found between providers.');
416
+ console.log('='.repeat(80));
311
417
  return;
312
418
  }
313
419
 
314
- // Compare elapsed_time metrics (primary latency indicator)
315
- console.log('\n📈 Latency Comparison (elapsed_time):');
316
- console.log('-'.repeat(80));
317
- console.log(
318
- 'Step'.padEnd(40) +
319
- providerName.padEnd(12) +
320
- 'Telnyx'.padEnd(12) +
321
- 'Delta'.padEnd(12) +
322
- 'Winner'
323
- );
324
- console.log('-'.repeat(80));
325
-
326
- sortedIndices.forEach(stepIndex => {
327
- const providerStep = providerMetrics.get(stepIndex);
328
- const telnyxStep = telnyxMetrics.get(stepIndex);
329
-
330
- const providerElapsed = providerStep?.get('elapsed_time');
331
- const telnyxElapsed = telnyxStep?.get('elapsed_time');
332
-
333
- if (providerElapsed || telnyxElapsed) {
334
- const columnName = providerElapsed?.columnName || telnyxElapsed?.columnName || `step_${stepIndex + 1}`;
335
- const shortName = columnName.length > 38 ? columnName.substring(0, 35) + '...' : columnName;
336
-
337
- const providerAvg = providerElapsed ? Math.round(providerElapsed.avg) : '-';
338
- const telnyxAvg = telnyxElapsed ? Math.round(telnyxElapsed.avg) : '-';
339
-
340
- let delta = '-';
341
- let winner = '-';
342
-
343
- if (providerElapsed && telnyxElapsed) {
344
- const diff = telnyxElapsed.avg - providerElapsed.avg;
345
- const pct = ((diff / providerElapsed.avg) * 100).toFixed(1);
346
- delta = diff > 0 ? `+${Math.round(diff)}ms` : `${Math.round(diff)}ms`;
347
-
348
- if (Math.abs(diff) < 50) {
349
- winner = '≈ Tie';
350
- } else if (diff < 0) {
351
- winner = '🏆 Telnyx';
352
- } else {
353
- winner = `🏆 ${providerName}`;
354
- }
355
- delta += ` (${pct}%)`;
420
+ // Debug: show per-response breakdown
421
+ if (debug && perResponse.length > 0) {
422
+ console.log('\n📈 Per-response breakdown:');
423
+ console.log('-'.repeat(80));
424
+ console.log(
425
+ 'Response'.padEnd(40) +
426
+ providerName.padEnd(12) +
427
+ 'Telnyx'.padEnd(12) +
428
+ 'Delta'.padEnd(16) +
429
+ 'Winner'
430
+ );
431
+ console.log('-'.repeat(80));
432
+
433
+ perResponse.forEach((r, i) => {
434
+ const action = (r.columnName || '').replace(/^scenario_step_\d+_/, '');
435
+ const label = `#${i + 1} (${action})`;
436
+ const shortLabel = label.length > 38 ? label.substring(0, 35) + '...' : label;
437
+
438
+ const diff = r.telnyxAvg - r.providerAvg;
439
+ const pct = ((diff / r.providerAvg) * 100).toFixed(1);
440
+ const delta = `${diff > 0 ? '+' : ''}${Math.round(diff)}ms (${pct}%)`;
441
+
442
+ let winner;
443
+ if (Math.abs(diff) < 50) {
444
+ winner = '≈ Tie';
445
+ } else if (diff < 0) {
446
+ winner = '🏆 Telnyx';
447
+ } else {
448
+ winner = `🏆 ${providerName}`;
356
449
  }
357
450
 
358
451
  console.log(
359
- shortName.padEnd(40) +
360
- `${providerAvg}ms`.padEnd(12) +
361
- `${telnyxAvg}ms`.padEnd(12) +
362
- delta.padEnd(12) +
452
+ shortLabel.padEnd(40) +
453
+ `${Math.round(r.providerAvg)}ms`.padEnd(12) +
454
+ `${Math.round(r.telnyxAvg)}ms`.padEnd(12) +
455
+ delta.padEnd(16) +
363
456
  winner
364
457
  );
365
- }
366
- });
367
-
368
- console.log('-'.repeat(80));
458
+ });
369
459
 
370
- // Summary
371
- let providerTotal = 0, telnyxTotal = 0, comparableSteps = 0;
372
- sortedIndices.forEach(stepIndex => {
373
- const providerStep = providerMetrics.get(stepIndex);
374
- const telnyxStep = telnyxMetrics.get(stepIndex);
375
- const providerElapsed = providerStep?.get('elapsed_time');
376
- const telnyxElapsed = telnyxStep?.get('elapsed_time');
377
-
378
- if (providerElapsed && telnyxElapsed) {
379
- providerTotal += providerElapsed.avg;
380
- telnyxTotal += telnyxElapsed.avg;
381
- comparableSteps++;
382
- }
383
- });
460
+ console.log('-'.repeat(80));
461
+ }
384
462
 
385
- if (comparableSteps > 0) {
386
- const totalDiff = telnyxTotal - providerTotal;
387
- const totalPct = ((totalDiff / providerTotal) * 100).toFixed(1);
388
-
389
- console.log('\n📊 Overall Summary:');
390
- console.log(` ${providerName} total latency: ${Math.round(providerTotal)}ms`);
391
- console.log(` Telnyx total latency: ${Math.round(telnyxTotal)}ms`);
392
- console.log(` Difference: ${totalDiff > 0 ? '+' : ''}${Math.round(totalDiff)}ms (${totalPct}%)`);
393
-
394
- if (Math.abs(totalDiff) < 100) {
395
- console.log('\n 🤝 Result: Both providers perform similarly');
396
- } else if (totalDiff < 0) {
397
- console.log('\n 🏆 Result: Telnyx is faster overall');
398
- } else {
399
- console.log(`\n 🏆 Result: ${providerName} is faster overall`);
400
- }
463
+ // One headline number: average response latency
464
+ const providerAvg = providerLatencies.reduce((a, b) => a + b, 0) / providerLatencies.length;
465
+ const telnyxAvg = telnyxLatencies.reduce((a, b) => a + b, 0) / telnyxLatencies.length;
466
+ const diff = telnyxAvg - providerAvg;
467
+ const pct = ((diff / providerAvg) * 100).toFixed(1);
468
+
469
+ console.log(`\n Average response latency (${providerLatencies.length} matched responses):\n`);
470
+ console.log(` ${providerName.padEnd(16)} ${Math.round(providerAvg)}ms`);
471
+ console.log(` ${'Telnyx'.padEnd(16)} ${Math.round(telnyxAvg)}ms`);
472
+ console.log(` ${'Difference'.padEnd(16)} ${diff > 0 ? '+' : ''}${Math.round(diff)}ms (${pct}%)`);
473
+
474
+ if (Math.abs(diff) < 50) {
475
+ console.log('\n 🤝 Result: Both providers perform similarly');
476
+ } else if (diff < 0) {
477
+ console.log(`\n 🏆 Telnyx is ${Math.abs(pct)}% faster`);
478
+ } else {
479
+ console.log(`\n 🏆 ${providerName} is ${Math.abs(pct)}% faster`);
401
480
  }
402
481
 
403
482
  console.log('\n' + '='.repeat(80));