@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
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External Provider to Telnyx Assistant Import Module
|
|
3
|
+
*
|
|
4
|
+
* Imports assistants from external providers (vapi, elevenlabs, retell)
|
|
5
|
+
* into Telnyx using the Telnyx AI Assistants Import API.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Auto-creates integration secrets from provider API keys
|
|
9
|
+
* - Enables unauthenticated web calls for imported assistants
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const TELNYX_BASE_URL = 'https://api.telnyx.com/v2';
|
|
13
|
+
const TELNYX_SECRETS_ENDPOINT = `${TELNYX_BASE_URL}/integration_secrets`;
|
|
14
|
+
const TELNYX_IMPORT_ENDPOINT = `${TELNYX_BASE_URL}/ai/assistants/import`;
|
|
15
|
+
const TELNYX_ASSISTANTS_ENDPOINT = `${TELNYX_BASE_URL}/ai/assistants`;
|
|
16
|
+
|
|
17
|
+
// Supported providers
|
|
18
|
+
const SUPPORTED_PROVIDERS = ['vapi', 'elevenlabs', 'retell'];
|
|
19
|
+
|
|
20
|
+
// Default widget settings for benchmarking
|
|
21
|
+
const DEFAULT_WIDGET_SETTINGS = {
|
|
22
|
+
theme: 'dark',
|
|
23
|
+
audio_visualizer_config: {
|
|
24
|
+
color: 'verdant',
|
|
25
|
+
preset: 'roundBars'
|
|
26
|
+
},
|
|
27
|
+
start_call_text: '',
|
|
28
|
+
default_state: 'expanded',
|
|
29
|
+
position: 'fixed',
|
|
30
|
+
view_history_url: null,
|
|
31
|
+
report_issue_url: null,
|
|
32
|
+
give_feedback_url: null,
|
|
33
|
+
agent_thinking_text: '',
|
|
34
|
+
speak_to_interrupt_text: '',
|
|
35
|
+
logo_icon_url: null
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create an integration secret in Telnyx from a provider's API key.
|
|
40
|
+
*
|
|
41
|
+
* @param {Object} options
|
|
42
|
+
* @param {string} options.identifier - Unique identifier for the secret
|
|
43
|
+
* @param {string} options.token - The API key/token to store
|
|
44
|
+
* @param {string} options.telnyxApiKey - Telnyx API key for authentication
|
|
45
|
+
* @returns {Promise<{id: string, identifier: string}>}
|
|
46
|
+
*/
|
|
47
|
+
async function createIntegrationSecret({ identifier, token, telnyxApiKey }) {
|
|
48
|
+
console.log(`🔐 Creating integration secret: ${identifier}`);
|
|
49
|
+
|
|
50
|
+
const response = await fetch(TELNYX_SECRETS_ENDPOINT, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
'Authorization': `Bearer ${telnyxApiKey}`,
|
|
54
|
+
'Content-Type': 'application/json'
|
|
55
|
+
},
|
|
56
|
+
body: JSON.stringify({
|
|
57
|
+
identifier: identifier,
|
|
58
|
+
type: 'bearer',
|
|
59
|
+
token: token
|
|
60
|
+
})
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
const errorText = await response.text();
|
|
65
|
+
throw new Error(`Failed to create integration secret: ${response.status} - ${errorText}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const data = await response.json();
|
|
69
|
+
console.log(`✅ Integration secret created: ${data.data.identifier}`);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
id: data.data.id,
|
|
73
|
+
identifier: data.data.identifier
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get assistant details from Telnyx API.
|
|
79
|
+
*
|
|
80
|
+
* @param {Object} options
|
|
81
|
+
* @param {string} options.assistantId - The Telnyx assistant ID
|
|
82
|
+
* @param {string} options.telnyxApiKey - Telnyx API key
|
|
83
|
+
* @returns {Promise<Object>} - Assistant details
|
|
84
|
+
*/
|
|
85
|
+
export async function getAssistant({ assistantId, telnyxApiKey }) {
|
|
86
|
+
const response = await fetch(`${TELNYX_ASSISTANTS_ENDPOINT}/${assistantId}`, {
|
|
87
|
+
method: 'GET',
|
|
88
|
+
headers: {
|
|
89
|
+
'Authorization': `Bearer ${telnyxApiKey}`,
|
|
90
|
+
'Content-Type': 'application/json'
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
const errorText = await response.text();
|
|
96
|
+
throw new Error(`Failed to get assistant: ${response.status} ${errorText}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const data = await response.json();
|
|
100
|
+
|
|
101
|
+
// API returns data at root level for single assistant GET
|
|
102
|
+
if (!data.id) {
|
|
103
|
+
throw new Error(`Assistant not found: ${assistantId}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return data;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Enable unauthenticated web calls for a Telnyx assistant.
|
|
111
|
+
*
|
|
112
|
+
* @param {Object} options
|
|
113
|
+
* @param {string} options.assistantId - The Telnyx assistant ID
|
|
114
|
+
* @param {string} options.telnyxApiKey - Telnyx API key
|
|
115
|
+
* @param {Object} options.assistant - Optional existing assistant data to preserve settings
|
|
116
|
+
* @returns {Promise<boolean>} - true if successful
|
|
117
|
+
*/
|
|
118
|
+
export async function enableWebCalls({ assistantId, telnyxApiKey, assistant }) {
|
|
119
|
+
console.log(`🔧 Enabling unauthenticated web calls for assistant ${assistantId}...`);
|
|
120
|
+
|
|
121
|
+
// Preserve existing telephony_settings and just enable web calls
|
|
122
|
+
const telephonySettings = {
|
|
123
|
+
...(assistant?.telephony_settings || {}),
|
|
124
|
+
supports_unauthenticated_web_calls: true
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Build request body preserving widget_settings if they exist
|
|
128
|
+
const requestBody = {
|
|
129
|
+
telephony_settings: telephonySettings
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// Use existing widget_settings or set defaults
|
|
133
|
+
if (assistant?.widget_settings && Object.keys(assistant.widget_settings).length > 0) {
|
|
134
|
+
requestBody.widget_settings = assistant.widget_settings;
|
|
135
|
+
} else {
|
|
136
|
+
requestBody.widget_settings = DEFAULT_WIDGET_SETTINGS;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const response = await fetch(`${TELNYX_ASSISTANTS_ENDPOINT}/${assistantId}`, {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
headers: {
|
|
142
|
+
'Authorization': `Bearer ${telnyxApiKey}`,
|
|
143
|
+
'Content-Type': 'application/json'
|
|
144
|
+
},
|
|
145
|
+
body: JSON.stringify(requestBody)
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (!response.ok) {
|
|
149
|
+
const errorText = await response.text();
|
|
150
|
+
throw new Error(`Failed to enable web calls: ${response.status} ${errorText}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
console.log(`✅ Unauthenticated web calls enabled`);
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Configure an imported assistant with web calls enabled, widget settings, and timestamped name.
|
|
159
|
+
* Returns true if successful, false if failed (with warning).
|
|
160
|
+
*
|
|
161
|
+
* @param {Object} options
|
|
162
|
+
* @param {string} options.assistantId - The assistant ID
|
|
163
|
+
* @param {string} options.assistantName - The original assistant name
|
|
164
|
+
* @param {string} options.telnyxApiKey - Telnyx API key for authentication
|
|
165
|
+
* @returns {Promise<boolean>}
|
|
166
|
+
*/
|
|
167
|
+
async function configureImportedAssistant({ assistantId, assistantName, telnyxApiKey, provider }) {
|
|
168
|
+
// Generate UTC timestamp suffix
|
|
169
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
170
|
+
const providerLabel = provider ? `_${provider}` : '';
|
|
171
|
+
const newName = `${assistantName || 'Imported'}${providerLabel}_${timestamp}`;
|
|
172
|
+
|
|
173
|
+
console.log(`🔧 Configuring assistant: ${assistantId}`);
|
|
174
|
+
console.log(` 📝 Renaming to: ${newName}`);
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const response = await fetch(`${TELNYX_ASSISTANTS_ENDPOINT}/${assistantId}`, {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
headers: {
|
|
180
|
+
'Authorization': `Bearer ${telnyxApiKey}`,
|
|
181
|
+
'Content-Type': 'application/json'
|
|
182
|
+
},
|
|
183
|
+
body: JSON.stringify({
|
|
184
|
+
name: newName,
|
|
185
|
+
model: 'Qwen/Qwen3-235B-A22',
|
|
186
|
+
telephony_settings: {
|
|
187
|
+
supports_unauthenticated_web_calls: true
|
|
188
|
+
},
|
|
189
|
+
widget_settings: DEFAULT_WIDGET_SETTINGS
|
|
190
|
+
})
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
if (!response.ok) {
|
|
194
|
+
const errorText = await response.text();
|
|
195
|
+
console.warn(`⚠️ Could not configure assistant ${assistantId}: ${response.status}`);
|
|
196
|
+
console.warn(` This may require manual configuration in the Telnyx portal.`);
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
console.log(`✅ Assistant configured: ${newName}`);
|
|
201
|
+
return true;
|
|
202
|
+
} catch (error) {
|
|
203
|
+
console.warn(`⚠️ Error configuring assistant ${assistantId}: ${error.message}`);
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Import assistants from an external provider into Telnyx.
|
|
210
|
+
*
|
|
211
|
+
* This function:
|
|
212
|
+
* 1. Creates an integration secret from the provider's private API key
|
|
213
|
+
* 2. Imports assistants from the provider (optionally filtered by ID)
|
|
214
|
+
* 3. Enables unauthenticated web calls for each imported assistant
|
|
215
|
+
*
|
|
216
|
+
* @param {Object} options - Import options
|
|
217
|
+
* @param {string} options.provider - External provider name (vapi, elevenlabs, retell)
|
|
218
|
+
* @param {string} options.providerApiKey - The provider's private API key
|
|
219
|
+
* @param {string} options.telnyxApiKey - Telnyx API key for authentication
|
|
220
|
+
* @param {string} [options.assistantId] - Optional: specific assistant ID to import
|
|
221
|
+
* @returns {Promise<{assistants: Array<{id: string, name: string}>, assistantId: string}>}
|
|
222
|
+
*/
|
|
223
|
+
export async function importAssistantsFromProvider({ provider, providerApiKey, telnyxApiKey, assistantId }) {
|
|
224
|
+
// Validate provider
|
|
225
|
+
if (!SUPPORTED_PROVIDERS.includes(provider)) {
|
|
226
|
+
throw new Error(`Unsupported provider: ${provider}. Supported providers: ${SUPPORTED_PROVIDERS.join(', ')}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
console.log(`\n🔄 Importing assistants from ${provider} into Telnyx...`);
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
// Step 1: Create integration secret from provider API key
|
|
233
|
+
const secretIdentifier = `${provider}_import_${Date.now()}`;
|
|
234
|
+
const secret = await createIntegrationSecret({
|
|
235
|
+
identifier: secretIdentifier,
|
|
236
|
+
token: providerApiKey,
|
|
237
|
+
telnyxApiKey
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Step 2: Import assistant using the secret reference
|
|
241
|
+
console.log(`📥 Importing assistant ${assistantId} using secret: ${secret.identifier}`);
|
|
242
|
+
|
|
243
|
+
// Build import request body with specific assistant ID
|
|
244
|
+
const importBody = {
|
|
245
|
+
provider: provider,
|
|
246
|
+
api_key_ref: secret.identifier,
|
|
247
|
+
import_ids: [assistantId]
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const importResponse = await fetch(TELNYX_IMPORT_ENDPOINT, {
|
|
251
|
+
method: 'POST',
|
|
252
|
+
headers: {
|
|
253
|
+
'Authorization': `Bearer ${telnyxApiKey}`,
|
|
254
|
+
'Content-Type': 'application/json'
|
|
255
|
+
},
|
|
256
|
+
body: JSON.stringify(importBody)
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
if (!importResponse.ok) {
|
|
260
|
+
const errorText = await importResponse.text();
|
|
261
|
+
throw new Error(`Telnyx import API failed with status ${importResponse.status}: ${errorText}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const importData = await importResponse.json();
|
|
265
|
+
const assistants = importData.data || [];
|
|
266
|
+
|
|
267
|
+
if (assistants.length === 0) {
|
|
268
|
+
throw new Error(`No assistant was imported for ID "${assistantId}"`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Validate that we got the correct assistant
|
|
272
|
+
const importedAssistant = assistants[0];
|
|
273
|
+
const importId = importedAssistant.import_metadata?.import_id;
|
|
274
|
+
|
|
275
|
+
if (importId !== assistantId) {
|
|
276
|
+
throw new Error(`Import mismatch: requested "${assistantId}" but got "${importId}"`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Check if this is a previously imported assistant (re-using existing Telnyx assistant)
|
|
280
|
+
const importedAt = importedAssistant.import_metadata?.imported_at;
|
|
281
|
+
const isReused = importedAt && (Date.now() - new Date(importedAt).getTime() > 60000); // More than 1 minute ago
|
|
282
|
+
|
|
283
|
+
if (isReused) {
|
|
284
|
+
console.log(`♻️ Re-using previously imported assistant: ${importedAssistant.name} (${importedAssistant.id})`);
|
|
285
|
+
console.log(` Originally imported at: ${importedAt}`);
|
|
286
|
+
} else {
|
|
287
|
+
console.log(`✅ Successfully imported: ${importedAssistant.name} (${importedAssistant.id})`);
|
|
288
|
+
|
|
289
|
+
// Only configure newly imported assistants (rename with timestamp, enable web calls, set widget settings)
|
|
290
|
+
console.log(`\n🔧 Configuring imported assistant...`);
|
|
291
|
+
|
|
292
|
+
await configureImportedAssistant({
|
|
293
|
+
assistantId: importedAssistant.id,
|
|
294
|
+
assistantName: importedAssistant.name,
|
|
295
|
+
telnyxApiKey,
|
|
296
|
+
provider
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
assistants: [{
|
|
302
|
+
id: importedAssistant.id,
|
|
303
|
+
name: importedAssistant.name,
|
|
304
|
+
import_id: importId
|
|
305
|
+
}],
|
|
306
|
+
assistantId: importedAssistant.id
|
|
307
|
+
};
|
|
308
|
+
} catch (error) {
|
|
309
|
+
console.error(`❌ Failed to import assistants from ${provider}:`, error.message);
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Export supported providers for CLI validation
|
|
315
|
+
export { SUPPORTED_PROVIDERS };
|
package/src/report.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export class ReportGenerator {
|
|
5
|
+
constructor(filePath) {
|
|
6
|
+
this.filePath = path.resolve(filePath);
|
|
7
|
+
this.allRunsData = [];
|
|
8
|
+
// Map: "app|scenario|repetition" -> { metadata, stepMetrics, startTime }
|
|
9
|
+
this.runs = new Map();
|
|
10
|
+
this.stepColumns = new Map(); // Map of stepIndex -> Map of metricName -> column name
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Create a unique key for each run
|
|
14
|
+
_getRunKey(appName, scenarioName, repetition) {
|
|
15
|
+
return `${appName}|${scenarioName}|${repetition}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
beginRun(appName, scenarioName, repetition) {
|
|
19
|
+
const key = this._getRunKey(appName, scenarioName, repetition);
|
|
20
|
+
this.runs.set(key, {
|
|
21
|
+
metadata: {
|
|
22
|
+
app: appName,
|
|
23
|
+
scenario: scenarioName,
|
|
24
|
+
repetition: repetition,
|
|
25
|
+
startTime: Date.now()
|
|
26
|
+
},
|
|
27
|
+
stepMetrics: new Map() // Map of stepIndex -> Map of metricName -> value
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
recordStepMetric(appName, scenarioName, repetition, stepIndex, action, name, value) {
|
|
32
|
+
const key = this._getRunKey(appName, scenarioName, repetition);
|
|
33
|
+
const run = this.runs.get(key);
|
|
34
|
+
|
|
35
|
+
if (!run) {
|
|
36
|
+
console.warn(`Warning: Attempting to record metric for non-existent run: ${key}`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Initialize step metrics map if it doesn't exist
|
|
41
|
+
if (!run.stepMetrics.has(stepIndex)) {
|
|
42
|
+
run.stepMetrics.set(stepIndex, new Map());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Record the metric value
|
|
46
|
+
run.stepMetrics.get(stepIndex).set(name, value);
|
|
47
|
+
|
|
48
|
+
// Track column names based on step index, action, and metric name
|
|
49
|
+
if (!this.stepColumns.has(stepIndex)) {
|
|
50
|
+
this.stepColumns.set(stepIndex, new Map());
|
|
51
|
+
}
|
|
52
|
+
if (!this.stepColumns.get(stepIndex).has(name)) {
|
|
53
|
+
this.stepColumns.get(stepIndex).set(name, `step_${stepIndex + 1}_${action}_${name}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
endRun(appName, scenarioName, repetition, success = true) {
|
|
58
|
+
const key = this._getRunKey(appName, scenarioName, repetition);
|
|
59
|
+
const run = this.runs.get(key);
|
|
60
|
+
|
|
61
|
+
if (!run) {
|
|
62
|
+
console.warn(`Warning: Attempting to end non-existent run: ${key}`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Calculate duration
|
|
67
|
+
const duration = Date.now() - run.metadata.startTime;
|
|
68
|
+
|
|
69
|
+
// Deep copy the nested Map structure
|
|
70
|
+
const runCopy = new Map();
|
|
71
|
+
run.stepMetrics.forEach((metricsMap, stepIndex) => {
|
|
72
|
+
runCopy.set(stepIndex, new Map(metricsMap));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
this.allRunsData.push({
|
|
76
|
+
metadata: {
|
|
77
|
+
app: run.metadata.app,
|
|
78
|
+
scenario: run.metadata.scenario,
|
|
79
|
+
repetition: run.metadata.repetition,
|
|
80
|
+
success: success ? 1 : 0,
|
|
81
|
+
duration: duration
|
|
82
|
+
},
|
|
83
|
+
stepMetrics: runCopy
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Remove the run from active runs map
|
|
87
|
+
this.runs.delete(key);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
generateCSV() {
|
|
91
|
+
if (this.allRunsData.length === 0) {
|
|
92
|
+
console.warn('No step times recorded for report generation');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Collect all step indices and their metrics
|
|
97
|
+
const allStepMetrics = new Map(); // Map of stepIndex -> Set of metricNames
|
|
98
|
+
this.allRunsData.forEach(run => {
|
|
99
|
+
run.stepMetrics.forEach((metrics, stepIndex) => {
|
|
100
|
+
if (!allStepMetrics.has(stepIndex)) {
|
|
101
|
+
allStepMetrics.set(stepIndex, new Set());
|
|
102
|
+
}
|
|
103
|
+
metrics.forEach((_, metricName) => {
|
|
104
|
+
allStepMetrics.get(stepIndex).add(metricName);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Sort step indices
|
|
110
|
+
const sortedStepIndices = Array.from(allStepMetrics.keys()).sort((a, b) => a - b);
|
|
111
|
+
|
|
112
|
+
// Build column headers - start with app, scenario, repetition, success, and duration
|
|
113
|
+
const headers = ['app', 'scenario', 'repetition', 'success', 'duration'];
|
|
114
|
+
sortedStepIndices.forEach(stepIndex => {
|
|
115
|
+
const metricNames = Array.from(allStepMetrics.get(stepIndex)).sort();
|
|
116
|
+
metricNames.forEach(metricName => {
|
|
117
|
+
const columnName = this.stepColumns.get(stepIndex)?.get(metricName) ||
|
|
118
|
+
`step_${stepIndex + 1}_${metricName}`;
|
|
119
|
+
headers.push(columnName);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Create CSV data rows
|
|
124
|
+
const dataRows = this.allRunsData.map(run => {
|
|
125
|
+
// Start with metadata columns
|
|
126
|
+
const row = [
|
|
127
|
+
run.metadata.app,
|
|
128
|
+
run.metadata.scenario,
|
|
129
|
+
run.metadata.repetition,
|
|
130
|
+
run.metadata.success,
|
|
131
|
+
run.metadata.duration
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
// Add step metrics
|
|
135
|
+
sortedStepIndices.forEach(stepIndex => {
|
|
136
|
+
const stepMetrics = run.stepMetrics.get(stepIndex) || new Map();
|
|
137
|
+
const metricNames = Array.from(allStepMetrics.get(stepIndex)).sort();
|
|
138
|
+
metricNames.forEach(metricName => {
|
|
139
|
+
const value = stepMetrics.get(metricName);
|
|
140
|
+
row.push(value !== undefined ? value : '');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
return row.join(', ');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const csvContent = `${headers.join(', ')}\n${dataRows.join('\n')}\n`;
|
|
147
|
+
|
|
148
|
+
// Write to file
|
|
149
|
+
fs.writeFileSync(this.filePath, csvContent, 'utf8');
|
|
150
|
+
console.log(`Report generated: ${this.filePath}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
generateMetricsSummary() {
|
|
154
|
+
if (this.allRunsData.length === 0) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
console.log('\n=== METRICS SUMMARY ===');
|
|
159
|
+
|
|
160
|
+
// Collect all step indices and their metrics
|
|
161
|
+
const allStepMetrics = new Map(); // Map of stepIndex -> Set of metricNames
|
|
162
|
+
this.allRunsData.forEach(run => {
|
|
163
|
+
run.stepMetrics.forEach((metrics, stepIndex) => {
|
|
164
|
+
if (!allStepMetrics.has(stepIndex)) {
|
|
165
|
+
allStepMetrics.set(stepIndex, new Set());
|
|
166
|
+
}
|
|
167
|
+
metrics.forEach((_, metricName) => {
|
|
168
|
+
allStepMetrics.get(stepIndex).add(metricName);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const sortedStepIndices = Array.from(allStepMetrics.keys()).sort((a, b) => a - b);
|
|
174
|
+
|
|
175
|
+
if (sortedStepIndices.length === 0) {
|
|
176
|
+
console.log('No metrics collected during test runs.');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
sortedStepIndices.forEach(stepIndex => {
|
|
181
|
+
const metricNames = Array.from(allStepMetrics.get(stepIndex)).sort();
|
|
182
|
+
|
|
183
|
+
metricNames.forEach(metricName => {
|
|
184
|
+
const columnName = this.stepColumns.get(stepIndex)?.get(metricName) ||
|
|
185
|
+
`step_${stepIndex + 1}_${metricName}`;
|
|
186
|
+
const values = [];
|
|
187
|
+
|
|
188
|
+
this.allRunsData.forEach(run => {
|
|
189
|
+
const stepMetrics = run.stepMetrics.get(stepIndex);
|
|
190
|
+
if (stepMetrics && stepMetrics.has(metricName)) {
|
|
191
|
+
values.push(stepMetrics.get(metricName));
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
if (values.length > 0) {
|
|
196
|
+
const sum = values.reduce((a, b) => a + b, 0);
|
|
197
|
+
const average = sum / values.length;
|
|
198
|
+
const min = Math.min(...values);
|
|
199
|
+
const max = Math.max(...values);
|
|
200
|
+
|
|
201
|
+
// Calculate p50 (median)
|
|
202
|
+
const sortedValues = [...values].sort((a, b) => a - b);
|
|
203
|
+
let p50;
|
|
204
|
+
if (sortedValues.length % 2 === 0) {
|
|
205
|
+
// Even number of samples: average of two middle values
|
|
206
|
+
const mid1 = sortedValues[sortedValues.length / 2 - 1];
|
|
207
|
+
const mid2 = sortedValues[sortedValues.length / 2];
|
|
208
|
+
p50 = (mid1 + mid2) / 2;
|
|
209
|
+
} else {
|
|
210
|
+
// Odd number of samples: middle value
|
|
211
|
+
p50 = sortedValues[Math.floor(sortedValues.length / 2)];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Only add "ms" unit for elapsed_time metrics
|
|
215
|
+
const unit = metricName === 'elapsed_time' ? 'ms' : '';
|
|
216
|
+
const formatValue = (val) => unit ? `${Math.round(val)}${unit}` : val.toFixed(2);
|
|
217
|
+
|
|
218
|
+
console.log(`${columnName}:`);
|
|
219
|
+
console.log(` Average: ${formatValue(average)}`);
|
|
220
|
+
console.log(` Min: ${formatValue(min)}`);
|
|
221
|
+
console.log(` Max: ${formatValue(max)}`);
|
|
222
|
+
console.log(` p50: ${formatValue(p50)}`);
|
|
223
|
+
console.log('');
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
|
|
10
|
+
const app = express();
|
|
11
|
+
const PORT = process.env.HTTP_PORT || process.env.PORT || 3333;
|
|
12
|
+
|
|
13
|
+
// Serve static assets from the assets folder
|
|
14
|
+
app.use('/assets', express.static(path.join(__dirname, '..', 'assets')));
|
|
15
|
+
|
|
16
|
+
// Serve JavaScript files
|
|
17
|
+
app.use('/javascript', express.static(path.join(__dirname, '..', 'javascript')));
|
|
18
|
+
|
|
19
|
+
// Basic health check endpoint
|
|
20
|
+
app.get('/health', (req, res) => {
|
|
21
|
+
res.json({ status: 'OK', timestamp: new Date().toISOString() });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export function createServer() {
|
|
25
|
+
const server = app.listen(PORT, () => {
|
|
26
|
+
console.log(`Server running on http://localhost:${PORT}`);
|
|
27
|
+
});
|
|
28
|
+
return server;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default app;
|