@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/.github/CODEOWNERS +4 -0
- package/.github/workflows/ci.yml +29 -0
- package/.github/workflows/draft-release.yml +72 -0
- package/.github/workflows/publish-release.yml +39 -0
- package/.release-it.json +31 -0
- package/CHANGELOG.md +30 -0
- package/CLAUDE.md +72 -0
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/assets/appointment_data.mp3 +0 -0
- package/assets/confirmation.mp3 +0 -0
- package/assets/greet_me_angry.mp3 +0 -0
- package/assets/hello_make_an_appointment.mp3 +0 -0
- package/assets/name_lebron_james.mp3 +0 -0
- package/assets/recording-processor.js +86 -0
- package/assets/tell_me_joke_laugh.mp3 +0 -0
- package/assets/tell_me_something_funny.mp3 +0 -0
- package/assets/tell_me_something_sad.mp3 +0 -0
- package/benchmarks/applications/elevenlabs.yaml +10 -0
- package/benchmarks/applications/telnyx.yaml +10 -0
- package/benchmarks/applications/vapi.yaml +10 -0
- package/benchmarks/scenarios/appointment.yaml +16 -0
- package/javascript/audio_input_hooks.js +291 -0
- package/javascript/audio_output_hooks.js +876 -0
- package/package.json +61 -0
- package/src/index.js +560 -0
- package/src/provider-import.js +315 -0
- package/src/report.js +228 -0
- package/src/server.js +31 -0
- package/src/transcription.js +138 -0
- package/src/voice-agent-tester.js +1033 -0
- package/tests/integration.test.js +138 -0
- package/tests/voice-agent-tester.test.js +190 -0
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
|
+
}
|