@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,138 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from '@jest/globals';
|
|
2
|
+
import { VoiceAgentTester } from '../src/voice-agent-tester.js';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import YAML from 'yaml';
|
|
6
|
+
|
|
7
|
+
describe('Integration Tests', () => {
|
|
8
|
+
let tester;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
tester = new VoiceAgentTester({
|
|
12
|
+
verbose: false,
|
|
13
|
+
headless: true
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
if (tester) {
|
|
19
|
+
await tester.close();
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('should run a complete scenario', async () => {
|
|
24
|
+
// Create a simple test page
|
|
25
|
+
const testPageContent = `
|
|
26
|
+
<html>
|
|
27
|
+
<body>
|
|
28
|
+
<h1>Voice Agent Test Page</h1>
|
|
29
|
+
<button id="start">Start Test</button>
|
|
30
|
+
<div id="status">Not started</div>
|
|
31
|
+
<div id="speech-output"></div>
|
|
32
|
+
<script>
|
|
33
|
+
document.getElementById('start').addEventListener('click', () => {
|
|
34
|
+
document.getElementById('status').textContent = 'Test started';
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Mock speech synthesis for testing
|
|
38
|
+
window.speechSynthesis = {
|
|
39
|
+
speak: (utterance) => {
|
|
40
|
+
document.getElementById('speech-output').textContent = utterance.text;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
window.SpeechSynthesisUtterance = function(text) {
|
|
44
|
+
this.text = text;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Mock __speak function that will be called by the tester
|
|
48
|
+
// This needs to be in the page itself since evaluateOnNewDocument runs before navigation
|
|
49
|
+
window.__speak = (text) => {
|
|
50
|
+
document.getElementById('speech-output').textContent = text;
|
|
51
|
+
// Signal speech end after a small delay to allow waitForAudioEvent to be set up
|
|
52
|
+
setTimeout(() => {
|
|
53
|
+
if (window.__publishEvent) {
|
|
54
|
+
window.__publishEvent('speechend', {});
|
|
55
|
+
}
|
|
56
|
+
}, 10);
|
|
57
|
+
};
|
|
58
|
+
</script>
|
|
59
|
+
</body>
|
|
60
|
+
</html>
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
// Use data URL to avoid file system dependencies
|
|
64
|
+
const testUrl = `data:text/html,${encodeURIComponent(testPageContent)}`;
|
|
65
|
+
|
|
66
|
+
const appSteps = [
|
|
67
|
+
{ action: 'click', selector: '#start' }
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const scenarioSteps = [
|
|
71
|
+
{ action: 'speak', text: 'Hello, this is a test.' }
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
await tester.runScenario(testUrl, appSteps, scenarioSteps, 'test-app', 'test-scenario', 1);
|
|
75
|
+
|
|
76
|
+
// The scenario should complete without throwing errors
|
|
77
|
+
expect(true).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('should handle scenario with wait step', async () => {
|
|
81
|
+
const testPageContent = `
|
|
82
|
+
<html>
|
|
83
|
+
<body>
|
|
84
|
+
<h1>Wait Test Page</h1>
|
|
85
|
+
<button id="trigger">Trigger</button>
|
|
86
|
+
<script>
|
|
87
|
+
document.getElementById('trigger').addEventListener('click', () => {
|
|
88
|
+
setTimeout(() => {
|
|
89
|
+
const newDiv = document.createElement('div');
|
|
90
|
+
newDiv.id = 'dynamic-element';
|
|
91
|
+
newDiv.textContent = 'Dynamic content appeared';
|
|
92
|
+
document.body.appendChild(newDiv);
|
|
93
|
+
}, 50);
|
|
94
|
+
});
|
|
95
|
+
</script>
|
|
96
|
+
</body>
|
|
97
|
+
</html>
|
|
98
|
+
`;
|
|
99
|
+
|
|
100
|
+
const testUrl = `data:text/html,${encodeURIComponent(testPageContent)}`;
|
|
101
|
+
|
|
102
|
+
const appSteps = [
|
|
103
|
+
{ action: 'click', selector: '#trigger' }
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
const scenarioSteps = [
|
|
107
|
+
{ action: 'wait', selector: '#dynamic-element' }
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
await tester.runScenario(testUrl, appSteps, scenarioSteps, 'test-app', 'test-scenario', 1);
|
|
111
|
+
|
|
112
|
+
// The scenario should complete without throwing errors (runScenario closes the browser)
|
|
113
|
+
expect(true).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('should handle JavaScript injection', async () => {
|
|
117
|
+
// Create a minimal test scenario to verify JS injection works
|
|
118
|
+
const testPageContent = `
|
|
119
|
+
<html>
|
|
120
|
+
<body>
|
|
121
|
+
<div id="js-test">No JS loaded</div>
|
|
122
|
+
</body>
|
|
123
|
+
</html>
|
|
124
|
+
`;
|
|
125
|
+
|
|
126
|
+
const testUrl = `data:text/html,${encodeURIComponent(testPageContent)}`;
|
|
127
|
+
|
|
128
|
+
await tester.launch(testUrl);
|
|
129
|
+
await tester.page.goto(testUrl);
|
|
130
|
+
|
|
131
|
+
// Call injectJavaScriptFiles (should handle missing directory gracefully)
|
|
132
|
+
await tester.injectJavaScriptFiles();
|
|
133
|
+
|
|
134
|
+
// Verify the page still works after injection attempt
|
|
135
|
+
const content = await tester.page.evaluate(() => document.getElementById('js-test').textContent);
|
|
136
|
+
expect(content).toBe('No JS loaded');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from '@jest/globals';
|
|
2
|
+
import { VoiceAgentTester } from '../src/voice-agent-tester.js';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
describe('VoiceAgentTester', () => {
|
|
7
|
+
let tester;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
tester = new VoiceAgentTester({
|
|
11
|
+
verbose: false,
|
|
12
|
+
headless: true
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
if (tester) {
|
|
18
|
+
await tester.close();
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('should create instance with default options', () => {
|
|
23
|
+
const defaultTester = new VoiceAgentTester();
|
|
24
|
+
expect(defaultTester.verbose).toBe(false);
|
|
25
|
+
expect(defaultTester.headless).toBe(false);
|
|
26
|
+
expect(defaultTester.browser).toBe(null);
|
|
27
|
+
expect(defaultTester.page).toBe(null);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('should create instance with custom options', () => {
|
|
31
|
+
const customTester = new VoiceAgentTester({
|
|
32
|
+
verbose: true,
|
|
33
|
+
headless: true
|
|
34
|
+
});
|
|
35
|
+
expect(customTester.verbose).toBe(true);
|
|
36
|
+
expect(customTester.headless).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('should launch browser successfully', async () => {
|
|
40
|
+
const testUrl = 'data:text/html,<html><body></body></html>';
|
|
41
|
+
await tester.launch(testUrl);
|
|
42
|
+
expect(tester.browser).not.toBe(null);
|
|
43
|
+
expect(tester.page).not.toBe(null);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('should close browser successfully', async () => {
|
|
47
|
+
const testUrl = 'data:text/html,<html><body></body></html>';
|
|
48
|
+
await tester.launch(testUrl);
|
|
49
|
+
expect(tester.browser).not.toBe(null);
|
|
50
|
+
|
|
51
|
+
await tester.close();
|
|
52
|
+
expect(tester.browser).toBe(null);
|
|
53
|
+
expect(tester.page).toBe(null);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('should handle basic navigation', async () => {
|
|
57
|
+
const testUrl = 'data:text/html,<html><body><h1>Test Page</h1></body></html>';
|
|
58
|
+
await tester.launch(testUrl);
|
|
59
|
+
|
|
60
|
+
// Navigate to a basic page
|
|
61
|
+
await tester.page.goto(testUrl);
|
|
62
|
+
|
|
63
|
+
const title = await tester.page.evaluate(() => document.querySelector('h1').textContent);
|
|
64
|
+
expect(title).toBe('Test Page');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('should execute click step', async () => {
|
|
68
|
+
const testUrl = 'data:text/html,<html><body><button id="test-btn">Click Me</button><div id="result"></div></body></html>';
|
|
69
|
+
await tester.launch(testUrl);
|
|
70
|
+
|
|
71
|
+
// Navigate to the test page
|
|
72
|
+
await tester.page.goto(testUrl);
|
|
73
|
+
|
|
74
|
+
// Add click handler
|
|
75
|
+
await tester.page.evaluate(() => {
|
|
76
|
+
document.getElementById('test-btn').addEventListener('click', () => {
|
|
77
|
+
document.getElementById('result').textContent = 'clicked';
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Execute click step
|
|
82
|
+
await tester.executeStep({
|
|
83
|
+
action: 'click',
|
|
84
|
+
selector: '#test-btn'
|
|
85
|
+
}, 0, 'scenario');
|
|
86
|
+
|
|
87
|
+
// Verify the click worked
|
|
88
|
+
const result = await tester.page.evaluate(() => document.getElementById('result').textContent);
|
|
89
|
+
expect(result).toBe('clicked');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('should execute wait step', async () => {
|
|
93
|
+
const testUrl = 'data:text/html,<html><body><div id="container"></div></body></html>';
|
|
94
|
+
await tester.launch(testUrl);
|
|
95
|
+
|
|
96
|
+
// Navigate to the test page
|
|
97
|
+
await tester.page.goto(testUrl);
|
|
98
|
+
|
|
99
|
+
// Add the element after a short delay
|
|
100
|
+
await tester.page.evaluate(() => {
|
|
101
|
+
setTimeout(() => {
|
|
102
|
+
const newDiv = document.createElement('div');
|
|
103
|
+
newDiv.id = 'delayed-element';
|
|
104
|
+
newDiv.textContent = 'I appeared!';
|
|
105
|
+
document.getElementById('container').appendChild(newDiv);
|
|
106
|
+
}, 100);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Execute wait step
|
|
110
|
+
await tester.executeStep({
|
|
111
|
+
action: 'wait',
|
|
112
|
+
selector: '#delayed-element'
|
|
113
|
+
}, 0, 'scenario');
|
|
114
|
+
|
|
115
|
+
// Verify the element exists
|
|
116
|
+
const element = await tester.page.$('#delayed-element');
|
|
117
|
+
expect(element).not.toBe(null);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('should handle speak step', async () => {
|
|
121
|
+
const testUrl = 'data:text/html,<html><body><div id="speech-test"></div></body></html>';
|
|
122
|
+
await tester.launch(testUrl);
|
|
123
|
+
|
|
124
|
+
await tester.page.goto(testUrl);
|
|
125
|
+
|
|
126
|
+
// Mock __speak to capture the speak call and publish speechend event after a small delay
|
|
127
|
+
await tester.page.evaluate(() => {
|
|
128
|
+
window.__speak = (text) => {
|
|
129
|
+
document.getElementById('speech-test').textContent = text;
|
|
130
|
+
// Signal speech end after a small delay to allow waitForAudioEvent to be set up
|
|
131
|
+
setTimeout(() => {
|
|
132
|
+
if (window.__publishEvent) {
|
|
133
|
+
window.__publishEvent('speechend', {});
|
|
134
|
+
}
|
|
135
|
+
}, 10);
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await tester.executeStep({
|
|
140
|
+
action: 'speak',
|
|
141
|
+
text: 'Hello, this is a test'
|
|
142
|
+
}, 0, 'scenario');
|
|
143
|
+
|
|
144
|
+
// Verify speech was triggered
|
|
145
|
+
const speechText = await tester.page.evaluate(() => document.getElementById('speech-test').textContent);
|
|
146
|
+
expect(speechText).toBe('Hello, this is a test');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('should handle unknown action gracefully', async () => {
|
|
150
|
+
const testUrl = 'data:text/html,<html><body></body></html>';
|
|
151
|
+
await tester.launch(testUrl);
|
|
152
|
+
await tester.page.goto(testUrl);
|
|
153
|
+
|
|
154
|
+
// Mock console.log to capture the output
|
|
155
|
+
const originalLog = console.log;
|
|
156
|
+
let logMessages = [];
|
|
157
|
+
console.log = (message) => {
|
|
158
|
+
logMessages.push(message);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
await tester.executeStep({
|
|
162
|
+
action: 'unknown_action'
|
|
163
|
+
}, 0, 'scenario');
|
|
164
|
+
|
|
165
|
+
// Find the unknown action message
|
|
166
|
+
const unknownActionMessage = logMessages.find(msg => msg.includes('Unknown action'));
|
|
167
|
+
expect(unknownActionMessage).toBe('Unknown action: unknown_action');
|
|
168
|
+
|
|
169
|
+
// Restore console.log
|
|
170
|
+
console.log = originalLog;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('should throw error for missing required parameters', async () => {
|
|
174
|
+
const testUrl = 'data:text/html,<html><body></body></html>';
|
|
175
|
+
await tester.launch(testUrl);
|
|
176
|
+
await tester.page.goto(testUrl);
|
|
177
|
+
|
|
178
|
+
// Test click without selector
|
|
179
|
+
await expect(tester.executeStep({ action: 'click' }, 0, 'scenario'))
|
|
180
|
+
.rejects.toThrow('No selector specified for click action');
|
|
181
|
+
|
|
182
|
+
// Test wait without selector
|
|
183
|
+
await expect(tester.executeStep({ action: 'wait' }, 0, 'scenario'))
|
|
184
|
+
.rejects.toThrow('No selector specified for wait action');
|
|
185
|
+
|
|
186
|
+
// Test speak without text
|
|
187
|
+
await expect(tester.executeStep({ action: 'speak' }, 0, 'scenario'))
|
|
188
|
+
.rejects.toThrow('No text or file specified for speak action');
|
|
189
|
+
});
|
|
190
|
+
});
|