@telnyx/voice-agent-tester 0.4.1 → 0.4.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.3](https://github.com/team-telnyx/voice-agent-tester/compare/v0.4.2...v0.4.3) (2026-03-11)
4
+
5
+ ### Features
6
+
7
+ * add click_with_retry action and fix audio event race conditions ([#19](https://github.com/team-telnyx/voice-agent-tester/issues/19)) ([#19](https://github.com/team-telnyx/voice-agent-tester/issues/19)) ([13e2009](https://github.com/team-telnyx/voice-agent-tester/commit/13e2009a94b4e2f7e05972f01a47c9b31758bf58))
8
+
9
+ ### Chores
10
+
11
+ * release v0.4.2 ([#18](https://github.com/team-telnyx/voice-agent-tester/issues/18)) ([1cf64ef](https://github.com/team-telnyx/voice-agent-tester/commit/1cf64ef563e813c2f06b2b655bfcc414637594cb))
12
+
13
+ ## [0.4.2](https://github.com/team-telnyx/voice-agent-tester/compare/v0.4.1...v0.4.2) (2026-02-23)
14
+
15
+ ### Features
16
+
17
+ * add dashboard hints for Vapi and ElevenLabs comparison mode params ([#16](https://github.com/team-telnyx/voice-agent-tester/issues/16)) ([7fda40b](https://github.com/team-telnyx/voice-agent-tester/commit/7fda40b6971a968dde1fc1c3466662227a3bc77e))
18
+
19
+ ### Chores
20
+
21
+ * improve event logs and comparison mode docs ([#17](https://github.com/team-telnyx/voice-agent-tester/issues/17)) ([24a9683](https://github.com/team-telnyx/voice-agent-tester/commit/24a968337a0b4a6c2d6baddd0aa507d5a87c9488))
22
+
3
23
  ## [0.4.1](https://github.com/team-telnyx/voice-agent-tester/compare/v0.4.0...v0.4.1) (2026-02-18)
4
24
 
5
25
  ### Features
package/README.md CHANGED
@@ -31,6 +31,8 @@ voice-agent-tester -a applications/telnyx.yaml -s scenarios/appointment.yaml --a
31
31
  | `--provider` | | Import from provider (`vapi`, `elevenlabs`, `retell`) |
32
32
  | `--provider-api-key` | | External provider API key (required with `--provider`) |
33
33
  | `--provider-import-id` | | Provider assistant ID to import (required with `--provider`) |
34
+ | `--share-key` | | Vapi share key for comparison mode (prompted if missing) |
35
+ | `--branch-id` | | ElevenLabs branch ID for comparison mode (prompted if missing) |
34
36
  | `--compare` | `true` | Run both provider direct and Telnyx import benchmarks |
35
37
  | `--no-compare` | | Disable comparison (run only Telnyx import) |
36
38
  | `-d, --debug` | `false` | Enable detailed timeout diagnostics |
@@ -190,20 +192,45 @@ When importing from an external provider, the tool automatically runs both bench
190
192
  1. **Provider Direct** - Benchmarks the assistant on the original provider's widget
191
193
  2. **Telnyx Import** - Benchmarks the same assistant after importing to Telnyx
192
194
 
195
+ ### Provider-Specific Keys
196
+
197
+ Comparison mode requires a provider-specific key to load the provider's direct widget. If not passed via CLI, the tool will prompt you with instructions on how to find it.
198
+
199
+ | Provider | Flag | How to find it |
200
+ |----------|------|----------------|
201
+ | Vapi | `--share-key` | In the Vapi Dashboard, select your assistant, then click the link icon (🔗) next to the assistant ID at the top. This copies the demo link containing your share key. |
202
+ | ElevenLabs | `--branch-id` | 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. |
203
+
193
204
  ### Import and Compare (Default)
194
205
 
206
+ **Vapi:**
207
+
195
208
  ```bash
196
209
  npx @telnyx/voice-agent-tester@latest \
197
210
  -a applications/telnyx.yaml \
198
211
  -s scenarios/appointment.yaml \
199
212
  --provider vapi \
213
+ --share-key <VAPI_SHARE_KEY> \
200
214
  --api-key <TELNYX_KEY> \
201
215
  --provider-api-key <VAPI_KEY> \
202
216
  --provider-import-id <VAPI_ASSISTANT_ID>
203
217
  ```
204
218
 
219
+ **ElevenLabs:**
220
+
221
+ ```bash
222
+ npx @telnyx/voice-agent-tester@latest \
223
+ -a applications/telnyx.yaml \
224
+ -s scenarios/appointment.yaml \
225
+ --provider elevenlabs \
226
+ --branch-id <ELEVENLABS_BRANCH_ID> \
227
+ --api-key <TELNYX_KEY> \
228
+ --provider-api-key <ELEVENLABS_KEY> \
229
+ --provider-import-id <ELEVENLABS_AGENT_ID>
230
+ ```
231
+
205
232
  This will:
206
- - Run Phase 1: VAPI direct benchmark
233
+ - Run Phase 1: Provider direct benchmark
207
234
  - Run Phase 2: Telnyx import benchmark
208
235
  - Generate a side-by-side latency comparison report
209
236
 
@@ -4,7 +4,8 @@ steps:
4
4
  selector: "telnyx-ai-agent"
5
5
  - action: sleep
6
6
  time: 3000
7
- - action: click
7
+ - action: click_with_retry
8
8
  selector: "telnyx-ai-agent >>> button"
9
- - action: sleep
10
- time: 4000
9
+ retries: 5
10
+ checkDelay: 4000
11
+ retryDelay: 5000
@@ -13,7 +13,3 @@ steps:
13
13
  time: 2000
14
14
  - action: speak
15
15
  text: "Hello, what can you do?"
16
- - action: wait_for_voice
17
- metrics: elapsed_time
18
- - action: wait_for_silence
19
- metrics: elapsed_time
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telnyx/voice-agent-tester",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
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
@@ -100,12 +100,22 @@ function getCompareRequiredParams(argv) {
100
100
  switch (argv.provider) {
101
101
  case 'vapi':
102
102
  if (!argv.shareKey) {
103
- missing.push({ key: 'shareKey', flag: '--share-key', description: 'Vapi share key' });
103
+ missing.push({
104
+ key: 'shareKey',
105
+ flag: '--share-key',
106
+ description: 'Vapi share key',
107
+ hint: 'In the Vapi Dashboard, select your assistant, then click the link icon (🔗) next to the assistant ID at the top. This copies the demo link containing your share key.'
108
+ });
104
109
  }
105
110
  break;
106
111
  case 'elevenlabs':
107
112
  if (!argv.branchId) {
108
- missing.push({ key: 'branchId', flag: '--branch-id', description: 'ElevenLabs branch ID' });
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
+ });
109
119
  }
110
120
  break;
111
121
  // retell and others: no extra params needed yet
@@ -550,6 +560,9 @@ async function main() {
550
560
  if (missingParams.length > 0) {
551
561
  for (const param of missingParams) {
552
562
  console.log(`\n🔑 ${param.description} is required for comparison mode`);
563
+ if (param.hint) {
564
+ console.log(` ${param.hint}`);
565
+ }
553
566
  const inputVal = await promptUserInput(`Enter ${param.description} (or press Enter to skip comparison): `);
554
567
  if (inputVal) {
555
568
  argv[param.key] = inputVal;
@@ -330,7 +330,8 @@ export class VoiceAgentTester {
330
330
  await this.page.exposeFunction('__publishEvent', (eventType, data) => {
331
331
  const event = { eventType, data, timestamp: Date.now() };
332
332
 
333
- console.log(`\t📢 Event received: ${eventType}`);
333
+ const elementSuffix = data && data.elementId ? ` (audio element: ${data.elementId})` : '';
334
+ console.log(`\t📢 ${eventType}${elementSuffix}`);
334
335
 
335
336
  // Check if there are any pending promises waiting for this event type
336
337
  const pendingPromises = this.pendingPromises.get(eventType);
@@ -534,6 +535,9 @@ export class VoiceAgentTester {
534
535
  case 'screenshot':
535
536
  handlerResult = await this.handleScreenshot(step);
536
537
  break;
538
+ case 'click_with_retry':
539
+ handlerResult = await this.handleClickWithRetry(step);
540
+ break;
537
541
 
538
542
  default:
539
543
  console.log(`Unknown action: ${action}`);
@@ -576,10 +580,173 @@ export class VoiceAgentTester {
576
580
  await this.page.click(selector);
577
581
  }
578
582
 
583
+ async handleClickWithRetry(step) {
584
+ const selector = step.selector;
585
+ if (!selector) {
586
+ throw new Error('No selector specified for click_with_retry action');
587
+ }
588
+
589
+ const maxRetries = step.retries || 2;
590
+ const retryDelay = step.retryDelay || 3000;
591
+ const checkDelay = step.checkDelay || 4000;
592
+
593
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
594
+ let clicked = false;
595
+ try {
596
+ await this.page.waitForSelector(selector, { timeout: attempt === 1 ? 30000 : 5000 });
597
+ await this.page.click(selector);
598
+ clicked = true;
599
+ } catch {
600
+ // Selector not found — will check for widget config errors below
601
+ }
602
+
603
+ if (!clicked) {
604
+ // Check if the widget is showing a configuration error
605
+ const widgetState = await this._getWidgetErrorState(selector);
606
+
607
+ if (widgetState.isConfigError) {
608
+ // Widget is showing "unauthenticated web calls" or similar config error.
609
+ // This means the API config hasn't propagated to the widget yet.
610
+ if (attempt < maxRetries) {
611
+ console.log(`\t⚠️ Click attempt ${attempt}/${maxRetries}: widget not ready — "${widgetState.errorText}"`);
612
+ console.log(`\t⏳ Waiting for configuration to propagate (reloading in ${retryDelay}ms)...`);
613
+ await this.sleep(retryDelay);
614
+ await this.page.reload({ waitUntil: 'networkidle0', timeout: 30000 });
615
+ await this.sleep(2000); // extra time after reload
616
+ continue;
617
+ }
618
+ throw new Error(
619
+ `Widget configuration not ready after ${maxRetries} attempts: "${widgetState.errorText}"\n` +
620
+ `The "Supports Unauthenticated Web Calls" setting may not have propagated yet.\n` +
621
+ `Try running again in a few seconds, or verify the setting in the Telnyx portal.`
622
+ );
623
+ }
624
+
625
+ // Not a config error — genuinely missing selector
626
+ if (attempt < maxRetries) {
627
+ console.log(`\t⚠️ Click attempt ${attempt}/${maxRetries}: selector not found, retrying in ${retryDelay}ms...`);
628
+ await this.sleep(retryDelay);
629
+ continue;
630
+ }
631
+ throw new Error(`Selector "${selector}" not found after ${maxRetries} attempts`);
632
+ }
633
+
634
+ console.log(`\t🖱️ Click attempt ${attempt}/${maxRetries}`);
635
+
636
+ // Wait for connection to establish
637
+ await this.sleep(checkDelay);
638
+
639
+ // Check if audio elements are monitored or WebRTC connections exist
640
+ const status = await this._checkConnectionStatus();
641
+
642
+ if (status.isConnected) {
643
+ console.log(`\t✅ Connection established (monitored: ${status.monitoredElements}, rtc: ${status.rtcConnections})`);
644
+ return;
645
+ }
646
+
647
+ if (attempt < maxRetries) {
648
+ console.log(`\t⚠️ No connection detected (monitored: ${status.monitoredElements}, rtc: ${status.rtcConnections}), retrying in ${retryDelay}ms...`);
649
+ await this.sleep(retryDelay);
650
+ } else {
651
+ console.log(`\t⚠️ No connection detected after ${maxRetries} attempts, proceeding anyway`);
652
+ }
653
+ }
654
+ }
655
+
656
+ /**
657
+ * Check if a widget is showing a configuration error (e.g., "unauthenticated web calls" not enabled).
658
+ * Inspects the shadow DOM for error indicators.
659
+ */
660
+ async _getWidgetErrorState(selector) {
661
+ const parts = selector.split('>>>').map(s => s.trim());
662
+ const hostSelector = parts[0];
663
+
664
+ return await this.page.evaluate((host) => {
665
+ const el = document.querySelector(host);
666
+ if (!el || !el.shadowRoot) return { isConfigError: false };
667
+
668
+ const text = el.shadowRoot.textContent || '';
669
+
670
+ // Check for known configuration error messages
671
+ const configErrors = [
672
+ 'unauthenticated web calls',
673
+ 'support unauthenticated',
674
+ 'not configured',
675
+ 'configuration required'
676
+ ];
677
+
678
+ const lowerText = text.toLowerCase();
679
+ for (const pattern of configErrors) {
680
+ if (lowerText.includes(pattern)) {
681
+ // Extract a readable error message
682
+ const errorText = text.trim().replace(/\s+/g, ' ').substring(0, 200);
683
+ return { isConfigError: true, errorText };
684
+ }
685
+ }
686
+
687
+ return { isConfigError: false };
688
+ }, hostSelector);
689
+ }
690
+
691
+ async _checkConnectionStatus() {
692
+ const status = await this.page.evaluate(() => {
693
+ const info = { monitoredElements: 0, hasActiveConnection: false };
694
+
695
+ if (window.audioMonitor && window.audioMonitor.monitoredElements) {
696
+ info.monitoredElements = window.audioMonitor.monitoredElements.size;
697
+ }
698
+
699
+ document.querySelectorAll('audio').forEach(el => {
700
+ if (el.srcObject) info.hasActiveConnection = true;
701
+ });
702
+
703
+ return info;
704
+ });
705
+
706
+ let rtcConnections = 0;
707
+ try {
708
+ const rtpStats = await this.page.evaluate(async () => {
709
+ if (typeof window.__getRtpStats === 'function') {
710
+ return await window.__getRtpStats();
711
+ }
712
+ return null;
713
+ });
714
+ if (rtpStats) rtcConnections = rtpStats.connectionCount || 0;
715
+ } catch {
716
+ // Ignore RTP stats errors
717
+ }
718
+
719
+ return {
720
+ monitoredElements: status.monitoredElements,
721
+ rtcConnections,
722
+ isConnected: status.monitoredElements > 0 || status.hasActiveConnection || rtcConnections > 0
723
+ };
724
+ }
725
+
579
726
  async handleWaitForVoice() {
580
727
  if (this.debug) {
581
728
  console.log('\t⏳ Waiting for audio to start (AI agent response)...');
582
729
  }
730
+
731
+ // Check if audio is already playing before waiting for a new event.
732
+ // This handles the case where audiostart fired before we started listening
733
+ // (e.g., during click_with_retry or between steps).
734
+ const alreadyPlaying = await this.page.evaluate(() => {
735
+ if (window.audioMonitor && window.audioMonitor.monitoredElements) {
736
+ for (const [, data] of window.audioMonitor.monitoredElements) {
737
+ if (data.isPlaying) return true;
738
+ }
739
+ }
740
+ return false;
741
+ });
742
+
743
+ if (alreadyPlaying) {
744
+ if (this.debug) {
745
+ console.log('\t✅ Audio already playing');
746
+ }
747
+ return;
748
+ }
749
+
583
750
  await this.waitForAudioEvent('audiostart');
584
751
  if (this.debug) {
585
752
  console.log('\t✅ Audio detected');
@@ -590,6 +757,27 @@ export class VoiceAgentTester {
590
757
  if (this.debug) {
591
758
  console.log('\t⏳ Waiting for audio to stop (silence)...');
592
759
  }
760
+
761
+ // Check if all monitored elements are already silent.
762
+ // This handles the case where audiostop fired before we started listening.
763
+ const allSilent = await this.page.evaluate(() => {
764
+ if (window.audioMonitor && window.audioMonitor.monitoredElements) {
765
+ if (window.audioMonitor.monitoredElements.size === 0) return false; // no elements yet
766
+ for (const [, data] of window.audioMonitor.monitoredElements) {
767
+ if (data.isPlaying) return false;
768
+ }
769
+ return true; // all elements exist and are silent
770
+ }
771
+ return false;
772
+ });
773
+
774
+ if (allSilent) {
775
+ if (this.debug) {
776
+ console.log('\t✅ Already silent');
777
+ }
778
+ return;
779
+ }
780
+
593
781
  await this.waitForAudioEvent('audiostop');
594
782
  if (this.debug) {
595
783
  console.log('\t✅ Silence detected');