@telnyx/voice-agent-tester 0.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.
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@telnyx/voice-agent-tester",
3
+ "version": "0.2.0",
4
+ "description": "A command-line tool to test voice agents using Puppeteer",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "start": "node src/index.js",
9
+ "server": "node src/server.js",
10
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
11
+ "test:watch": "jest --watch",
12
+ "release": "release-it"
13
+ },
14
+ "bin": {
15
+ "voice-agent-tester": "./src/index.js"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git://github.com/team-telnyx/voice-agent-tester.git"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public",
23
+ "@telnyx:registry": "https://registry.npmjs.org"
24
+ },
25
+ "dependencies": {
26
+ "@puppeteer/browsers": "^2.4.0",
27
+ "express": "^5.1.0",
28
+ "glob": "^11.1.0",
29
+ "openai": "^4.104.0",
30
+ "puppeteer": "^24.3.0",
31
+ "puppeteer-stream": "^3.0.8",
32
+ "yaml": "^2.3.0",
33
+ "yargs": "^17.7.0"
34
+ },
35
+ "devDependencies": {
36
+ "@jest/globals": "^29.0.0",
37
+ "@release-it/conventional-changelog": "^10.0.4",
38
+ "jest": "^29.0.0",
39
+ "release-it": "^19.2.3"
40
+ },
41
+ "jest": {
42
+ "testEnvironment": "node",
43
+ "moduleNameMapper": {
44
+ "^(\\.{1,2}/.*)\\.js$": "$1"
45
+ },
46
+ "transform": {},
47
+ "testMatch": [
48
+ "**/tests/**/*.test.js"
49
+ ]
50
+ },
51
+ "keywords": [
52
+ "voice",
53
+ "agent",
54
+ "testing",
55
+ "puppeteer",
56
+ "automation"
57
+ ],
58
+ "author": "Voice Agent Tester",
59
+ "license": "MIT",
60
+ "packageManager": "yarn@4.11.0+sha512.4e54aeace9141df2f0177c266b05ec50dc044638157dae128c471ba65994ac802122d7ab35bcd9e81641228b7dcf24867d28e750e0bcae8a05277d600008ad54"
61
+ }
package/src/index.js ADDED
@@ -0,0 +1,560 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import readline from 'readline';
7
+ import yargs from 'yargs';
8
+ import { hideBin } from 'yargs/helpers';
9
+ import YAML from 'yaml';
10
+ import { VoiceAgentTester } from './voice-agent-tester.js';
11
+ import { ReportGenerator } from './report.js';
12
+ import { createServer } from './server.js';
13
+ import { importAssistantsFromProvider, getAssistant, enableWebCalls, SUPPORTED_PROVIDERS } from './provider-import.js';
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = path.dirname(__filename);
17
+
18
+ // Helper function to resolve file paths from comma-separated input or folder
19
+ function resolveConfigPaths(input) {
20
+ const paths = [];
21
+ const items = input.split(',').map(s => s.trim());
22
+
23
+ for (const item of items) {
24
+ const resolvedPath = path.resolve(item);
25
+
26
+ if (fs.existsSync(resolvedPath)) {
27
+ const stat = fs.statSync(resolvedPath);
28
+
29
+ if (stat.isDirectory()) {
30
+ // If it's a directory, find all .yaml files
31
+ const files = fs.readdirSync(resolvedPath)
32
+ .filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))
33
+ .map(f => path.join(resolvedPath, f));
34
+ paths.push(...files);
35
+ } else if (stat.isFile()) {
36
+ paths.push(resolvedPath);
37
+ }
38
+ } else {
39
+ throw new Error(`Path not found: ${resolvedPath}`);
40
+ }
41
+ }
42
+
43
+ return paths;
44
+ }
45
+
46
+ // Helper function to parse params string into an object
47
+ function parseParams(paramsString) {
48
+ if (!paramsString) {
49
+ return {};
50
+ }
51
+
52
+ const params = {};
53
+ const pairs = paramsString.split(',');
54
+
55
+ for (const pair of pairs) {
56
+ const [key, ...valueParts] = pair.split('=');
57
+ if (key && valueParts.length > 0) {
58
+ params[key.trim()] = valueParts.join('=').trim();
59
+ }
60
+ }
61
+
62
+ return params;
63
+ }
64
+
65
+ // Helper function to substitute template variables in URL
66
+ function substituteUrlParams(url, params) {
67
+ if (!url) return url;
68
+
69
+ let result = url;
70
+ for (const [key, value] of Object.entries(params)) {
71
+ // Replace {{key}} with value
72
+ const templatePattern = new RegExp(`\\{\\{${key}\\}\\}`, 'g');
73
+ result = result.replace(templatePattern, value);
74
+ }
75
+
76
+ return result;
77
+ }
78
+
79
+ // Helper function to load and validate application config
80
+ function loadApplicationConfig(configPath, params = {}) {
81
+ const configFile = fs.readFileSync(configPath, 'utf8');
82
+ const config = YAML.parse(configFile);
83
+
84
+ if (!config.url && !config.html) {
85
+ throw new Error(`Application config must contain "url" or "html" field: ${configPath}`);
86
+ }
87
+
88
+ // Substitute URL template params
89
+ const url = substituteUrlParams(config.url, params);
90
+
91
+ return {
92
+ name: path.basename(configPath, path.extname(configPath)),
93
+ path: configPath,
94
+ url: url,
95
+ html: config.html,
96
+ steps: config.steps || [],
97
+ tags: config.tags || []
98
+ };
99
+ }
100
+
101
+ // Helper function to load scenario config
102
+ function loadScenarioConfig(configPath) {
103
+ const configFile = fs.readFileSync(configPath, 'utf8');
104
+ const config = YAML.parse(configFile);
105
+
106
+ return {
107
+ name: path.basename(configPath, path.extname(configPath)),
108
+ path: configPath,
109
+ steps: config.steps || [],
110
+ background: config.background || null,
111
+ tags: config.tags || []
112
+ };
113
+ }
114
+
115
+ // Helper function to prompt user for y/n response
116
+ function promptUser(question) {
117
+ return new Promise((resolve) => {
118
+ const rl = readline.createInterface({
119
+ input: process.stdin,
120
+ output: process.stdout
121
+ });
122
+ rl.question(question, (answer) => {
123
+ rl.close();
124
+ resolve(answer.toLowerCase().trim() === 'y' || answer.toLowerCase().trim() === 'yes');
125
+ });
126
+ });
127
+ }
128
+
129
+ // Parse command-line arguments
130
+ const argv = yargs(hideBin(process.argv))
131
+ .option('applications', {
132
+ alias: 'a',
133
+ type: 'string',
134
+ description: 'Comma-separated application paths or folder path',
135
+ demandOption: true
136
+ })
137
+ .option('scenarios', {
138
+ alias: 's',
139
+ type: 'string',
140
+ description: 'Comma-separated scenario paths or folder path',
141
+ demandOption: true
142
+ })
143
+ .option('verbose', {
144
+ alias: 'v',
145
+ type: 'boolean',
146
+ description: 'Show browser console logs',
147
+ default: false
148
+ })
149
+ .option('assets-server', {
150
+ type: 'string',
151
+ description: 'Assets server URL',
152
+ default: `http://localhost:${process.env.HTTP_PORT || process.env.PORT || 3333}`
153
+ })
154
+ .option('report', {
155
+ alias: 'r',
156
+ type: 'string',
157
+ description: 'Generate CSV report with step elapsed times to specified file',
158
+ default: null
159
+ })
160
+ .option('repeat', {
161
+ type: 'number',
162
+ description: 'Number of repetitions to run each app+scenario combination (closes and recreates browser for each)',
163
+ default: 1
164
+ })
165
+ .option('headless', {
166
+ type: 'boolean',
167
+ description: 'Run browser in headless mode',
168
+ default: true
169
+ })
170
+ .option('application-tags', {
171
+ type: 'string',
172
+ description: 'Comma-separated list of application tags to filter by',
173
+ default: null
174
+ })
175
+ .option('scenario-tags', {
176
+ type: 'string',
177
+ description: 'Comma-separated list of scenario tags to filter by',
178
+ default: null
179
+ })
180
+ .option('concurrency', {
181
+ alias: 'c',
182
+ type: 'number',
183
+ description: 'Number of tests to run in parallel',
184
+ default: 1
185
+ })
186
+ .option('record', {
187
+ type: 'boolean',
188
+ description: 'Record video and audio of the test in webm format',
189
+ default: false
190
+ })
191
+ .option('params', {
192
+ alias: 'p',
193
+ type: 'string',
194
+ description: 'Comma-separated key=value pairs for URL template substitution (e.g., --params key=value)',
195
+ default: null
196
+ })
197
+ .option('provider', {
198
+ type: 'string',
199
+ description: `Import from external provider (${SUPPORTED_PROVIDERS.join(', ')}) - requires --api-key, --provider-api-key, --provider-import-id`,
200
+ choices: SUPPORTED_PROVIDERS
201
+ })
202
+ .option('api-key', {
203
+ type: 'string',
204
+ description: 'Telnyx API key for authentication and import operations'
205
+ })
206
+ .option('provider-api-key', {
207
+ type: 'string',
208
+ description: 'External provider API key (required with --provider for import)'
209
+ })
210
+ .option('provider-import-id', {
211
+ type: 'string',
212
+ description: 'Provider assistant/agent ID to import (required with --provider)'
213
+ })
214
+ .option('assistant-id', {
215
+ type: 'string',
216
+ description: 'Assistant/agent ID for direct benchmarking (works with all providers)'
217
+ })
218
+ .option('debug', {
219
+ alias: 'd',
220
+ type: 'boolean',
221
+ description: 'Enable detailed timeout diagnostics for audio events',
222
+ default: false
223
+ })
224
+ .help()
225
+ .argv;
226
+
227
+ async function main() {
228
+ let server;
229
+ let exitCode = 0;
230
+ const tempHtmlPaths = [];
231
+
232
+ try {
233
+ // Start the assets server
234
+ server = createServer();
235
+
236
+ // Resolve application and scenario paths
237
+ const applicationPaths = resolveConfigPaths(argv.applications);
238
+ const scenarioPaths = resolveConfigPaths(argv.scenarios);
239
+
240
+ if (applicationPaths.length === 0) {
241
+ throw new Error('No application config files found');
242
+ }
243
+
244
+ if (scenarioPaths.length === 0) {
245
+ throw new Error('No scenario config files found');
246
+ }
247
+
248
+ // Parse URL parameters for template substitution
249
+ const params = parseParams(argv.params);
250
+
251
+ // Handle provider import if requested
252
+ if (argv.provider) {
253
+ // Validate required options for provider import
254
+ if (!argv.apiKey) {
255
+ throw new Error('--api-key (Telnyx) is required when using --provider');
256
+ }
257
+ if (!argv.providerApiKey) {
258
+ throw new Error('--provider-api-key is required when using --provider');
259
+ }
260
+ if (!argv.providerImportId) {
261
+ throw new Error('--provider-import-id is required when using --provider');
262
+ }
263
+
264
+ const importResult = await importAssistantsFromProvider({
265
+ provider: argv.provider,
266
+ providerApiKey: argv.providerApiKey,
267
+ telnyxApiKey: argv.apiKey,
268
+ assistantId: argv.providerImportId
269
+ });
270
+
271
+ // Use the imported assistant's Telnyx ID
272
+ const selectedAssistant = importResult.assistants[0];
273
+
274
+ // Inject the imported assistant ID into params (overrides CLI assistant-id with Telnyx ID)
275
+ if (selectedAssistant) {
276
+ params.assistantId = selectedAssistant.id;
277
+ console.log(`šŸ“ Injected Telnyx assistantId from ${argv.provider} import: ${selectedAssistant.id}`);
278
+ }
279
+ } else if (!argv.assistantId) {
280
+ throw new Error('--assistant-id is required');
281
+ } else {
282
+ // Inject assistant-id into params for URL template substitution
283
+ params.assistantId = argv.assistantId;
284
+ // Direct Telnyx use case - optionally check web calls support if api-key provided
285
+ if (argv.apiKey) {
286
+ console.log(`\nšŸ” Checking assistant configuration...`);
287
+ try {
288
+ const assistant = await getAssistant({
289
+ assistantId: argv.assistantId,
290
+ telnyxApiKey: argv.apiKey
291
+ });
292
+
293
+ const supportsWebCalls = assistant.telephony_settings?.supports_unauthenticated_web_calls;
294
+
295
+ if (!supportsWebCalls) {
296
+ console.log(`āŒ Unauthenticated web calls: disabled`);
297
+ console.warn(`\nāš ļø Warning: Assistant "${assistant.name}" does not support unauthenticated web calls.`);
298
+ console.warn(` The benchmark may not work correctly without this setting enabled.\n`);
299
+
300
+ const shouldEnable = await promptUser('Would you like to enable unauthenticated web calls? (y/n): ');
301
+
302
+ if (shouldEnable) {
303
+ await enableWebCalls({
304
+ assistantId: argv.assistantId,
305
+ telnyxApiKey: argv.apiKey,
306
+ assistant
307
+ });
308
+ } else {
309
+ console.log(' Proceeding without enabling web calls...\n');
310
+ }
311
+ } else {
312
+ console.log(`āœ… Unauthenticated web calls: enabled`);
313
+ }
314
+ } catch (error) {
315
+ console.log(`āš ļø Could not check assistant: ${error.message}`);
316
+ }
317
+ }
318
+ }
319
+
320
+ if (Object.keys(params).length > 0) {
321
+ console.log(`šŸ“ URL parameters: ${JSON.stringify(params)}`);
322
+ }
323
+
324
+ // Load all application and scenario configs
325
+ let applications = applicationPaths.map(p => loadApplicationConfig(p, params));
326
+ let scenarios = scenarioPaths.map(loadScenarioConfig);
327
+
328
+ // Filter applications by tags if specified
329
+ if (argv.applicationTags) {
330
+ const filterTags = argv.applicationTags.split(',').map(t => t.trim());
331
+ applications = applications.filter(app =>
332
+ app.tags.some(tag => filterTags.includes(tag))
333
+ );
334
+ if (applications.length === 0) {
335
+ throw new Error(`No applications found with tags: ${filterTags.join(', ')}`);
336
+ }
337
+ }
338
+
339
+ // Filter scenarios by tags if specified
340
+ if (argv.scenarioTags) {
341
+ const filterTags = argv.scenarioTags.split(',').map(t => t.trim());
342
+ scenarios = scenarios.filter(scenario =>
343
+ scenario.tags.some(tag => filterTags.includes(tag))
344
+ );
345
+ if (scenarios.length === 0) {
346
+ throw new Error(`No scenarios found with tags: ${filterTags.join(', ')}`);
347
+ }
348
+ }
349
+
350
+ console.log(`\nšŸ“‹ Loaded ${applications.length} application(s) and ${scenarios.length} scenario(s)`);
351
+ console.log(`Applications: ${applications.map(a => a.name).join(', ')}`);
352
+ console.log(`Scenarios: ${scenarios.map(s => s.name).join(', ')}`);
353
+
354
+ // Create matrix of all combinations
355
+ const combinations = [];
356
+ for (const app of applications) {
357
+ for (const scenario of scenarios) {
358
+ combinations.push({ app, scenario });
359
+ }
360
+ }
361
+
362
+ const totalRuns = combinations.length * argv.repeat;
363
+ console.log(`\nšŸŽÆ Running ${combinations.length} combination(s) Ɨ ${argv.repeat} repetition(s) = ${totalRuns} total run(s)\n`);
364
+
365
+ // Create a single report generator for metrics tracking
366
+ const reportGenerator = new ReportGenerator(argv.report || 'temp_metrics.csv');
367
+
368
+ // Helper function to execute a single test run
369
+ async function executeRun({ app, scenario, repetition, runNumber }) {
370
+ console.log(`\n${'='.repeat(80)}`);
371
+ console.log(`šŸ“± Application: ${app.name}`);
372
+ console.log(`šŸ“ Scenario: ${scenario.name}`);
373
+ if (argv.repeat > 1) {
374
+ console.log(`šŸ” Repetition: ${repetition}`);
375
+ }
376
+ console.log(`šŸƒ Run: ${runNumber}/${totalRuns}`);
377
+ console.log(`${'='.repeat(80)}`);
378
+
379
+ // Handle HTML content vs URL
380
+ let targetUrl;
381
+ let tempHtmlPath = null;
382
+
383
+ if (app.html) {
384
+ // Create temporary HTML file and serve it
385
+ const assetsDir = path.join(__dirname, '..', 'assets');
386
+ if (!fs.existsSync(assetsDir)) {
387
+ fs.mkdirSync(assetsDir, { recursive: true });
388
+ }
389
+ tempHtmlPath = path.join(assetsDir, `temp_${app.name}_${Date.now()}.html`);
390
+ fs.writeFileSync(tempHtmlPath, app.html, 'utf8');
391
+ tempHtmlPaths.push(tempHtmlPath);
392
+ targetUrl = `${argv.assetsServer}/assets/${path.basename(tempHtmlPath)}`;
393
+ console.log(`HTML content served at: ${targetUrl}`);
394
+ } else {
395
+ targetUrl = app.url;
396
+ console.log(`URL: ${targetUrl}`);
397
+ }
398
+
399
+ // Application and scenario steps are executed separately
400
+ console.log(`Total steps: ${app.steps.length + scenario.steps.length} (${app.steps.length} from app + ${scenario.steps.length} from suite)\n`);
401
+
402
+ const tester = new VoiceAgentTester({
403
+ verbose: argv.verbose,
404
+ headless: argv.headless,
405
+ assetsServerUrl: argv.assetsServer,
406
+ reportGenerator: reportGenerator,
407
+ record: argv.record,
408
+ debug: argv.debug
409
+ });
410
+
411
+ try {
412
+ await tester.runScenario(targetUrl, app.steps, scenario.steps, app.name, scenario.name, repetition, scenario.background);
413
+ console.log(`āœ… Completed successfully (Run ${runNumber}/${totalRuns})`);
414
+ return { success: true };
415
+ } catch (error) {
416
+ // Store only the first line for summary, but print full message here (with diagnostics)
417
+ const shortMessage = error.message.split('\n')[0];
418
+ const errorInfo = {
419
+ app: app.name,
420
+ scenario: scenario.name,
421
+ repetition,
422
+ error: shortMessage
423
+ };
424
+ // Print full diagnostics here (only place they appear)
425
+ console.error(`āŒ Error (Run ${runNumber}/${totalRuns}):\n${error.message}`);
426
+ return { success: false, error: errorInfo };
427
+ }
428
+ }
429
+
430
+ // Build all test runs (combination x repetitions)
431
+ const allRuns = [];
432
+ let runNumber = 0;
433
+
434
+ for (const { app, scenario } of combinations) {
435
+ const repetitions = argv.repeat || 1;
436
+ for (let i = 0; i < repetitions; i++) {
437
+ runNumber++;
438
+ allRuns.push({
439
+ app,
440
+ scenario,
441
+ repetition: i,
442
+ runNumber
443
+ });
444
+ }
445
+ }
446
+
447
+ // Execute runs with concurrency limit using a worker pool
448
+ const concurrency = Math.min(argv.concurrency || 1, allRuns.length);
449
+ console.log(`⚔ Concurrency level: ${concurrency}`);
450
+
451
+ // Worker pool implementation - start new tests as soon as one finishes
452
+ const allResults = [];
453
+ let nextRunIndex = 0;
454
+
455
+ // Create a pool of worker promises
456
+ const workers = [];
457
+ for (let i = 0; i < concurrency; i++) {
458
+ workers.push(runWorker(i + 1));
459
+ }
460
+
461
+ // Worker function that processes runs from the queue
462
+ async function runWorker(workerId) {
463
+ const workerResults = [];
464
+
465
+ while (nextRunIndex < allRuns.length) {
466
+ const runIndex = nextRunIndex++;
467
+ const run = allRuns[runIndex];
468
+
469
+ if (concurrency > 1) {
470
+ console.log(`\nšŸ‘· Worker ${workerId}: Starting run ${run.runNumber}/${totalRuns}`);
471
+ }
472
+
473
+ const result = await executeRun(run);
474
+ workerResults.push(result);
475
+ }
476
+
477
+ return workerResults;
478
+ }
479
+
480
+ // Wait for all workers to complete
481
+ const workerResultArrays = await Promise.all(workers);
482
+
483
+ // Flatten all worker results into a single array
484
+ workerResultArrays.forEach(workerResults => {
485
+ allResults.push(...workerResults);
486
+ });
487
+
488
+ // Aggregate results
489
+ const results = {
490
+ successful: allResults.filter(r => r.success).length,
491
+ failed: allResults.filter(r => !r.success).length,
492
+ errors: allResults.filter(r => !r.success).map(r => r.error)
493
+ };
494
+
495
+ // Generate the final report if requested, and always show metrics summary
496
+ if (argv.report) {
497
+ reportGenerator.generateCSV();
498
+ }
499
+ reportGenerator.generateMetricsSummary();
500
+
501
+ // Print final summary
502
+ console.log(`\n${'='.repeat(80)}`);
503
+ console.log(`šŸ“Š FINAL SUMMARY`);
504
+ console.log(`${'='.repeat(80)}`);
505
+ console.log(`āœ… Successful runs: ${results.successful}/${totalRuns}`);
506
+
507
+ if (results.failed > 0) {
508
+ console.log(`\nšŸ” Failure Details:`);
509
+ results.errors.forEach(({ app, scenario, repetition, error }) => {
510
+ console.log(` ${app} + ${scenario} (rep ${repetition}): ${error}`);
511
+ });
512
+ }
513
+
514
+ if (results.failed === 0) {
515
+ console.log(`\nšŸŽ‰ All runs completed successfully!`);
516
+ } else {
517
+ console.log(`\nāš ļø Completed with ${results.failed} failure(s).`);
518
+
519
+ // Show helpful hint for direct Telnyx usage (when not using --provider)
520
+ if (!argv.provider && argv.assistantId) {
521
+ const editUrl = `https://portal.telnyx.com/#/login/sign-in?redirectTo=/ai/assistants/edit/${argv.assistantId}`;
522
+ console.log(`\nšŸ’” Tip: Make sure that the "Supports Unauthenticated Web Calls" option is enabled in your Telnyx assistant settings.`);
523
+ console.log(` Edit assistant: ${editUrl}`);
524
+ console.log(` Or provide --api-key to enable this setting automatically via CLI.`);
525
+ }
526
+ }
527
+
528
+ // Set exit code based on results
529
+ if (results.failed > 0) {
530
+ exitCode = 1;
531
+ }
532
+ } catch (error) {
533
+ console.error('Error running scenarios:', error.message);
534
+ exitCode = 1;
535
+ } finally {
536
+ // Clean up temporary HTML files if created
537
+ for (const tempHtmlPath of tempHtmlPaths) {
538
+ if (fs.existsSync(tempHtmlPath)) {
539
+ fs.unlinkSync(tempHtmlPath);
540
+ }
541
+ }
542
+ if (tempHtmlPaths.length > 0) {
543
+ console.log('Temporary HTML files cleaned up');
544
+ }
545
+
546
+ // Close the server to allow process to exit
547
+ if (server) {
548
+ server.close(() => {
549
+ console.log('Server closed');
550
+ process.exit(exitCode);
551
+ });
552
+ } else {
553
+ process.exit(exitCode);
554
+ }
555
+ }
556
+ }
557
+
558
+ if (import.meta.url === `file://${process.argv[1]}`) {
559
+ main();
560
+ }