← back to Butlr
scripts/validate-twiml.js
64 lines
#!/usr/bin/env node
// Validate the TwiML emitted by /twilio/twiml/:call_id against the
// inline-Gather + inbound_track contract.
//
// Asserts:
// • response is well-formed XML wrapped in <Response>…</Response>
// • contains exactly one <Start><Stream … track="inbound_track" …/></Start>
// • Stream url uses wss:// and matches the wsBase host pattern
// • contains exactly one <Gather> with action=…/twilio/gather/<id>
// • does NOT contain <Redirect> (would tear down <Stream>)
// • Gather has actionOnEmptyResult="true"
//
// Usage:
// node scripts/validate-twiml.js <call_id> [--base https://butlr.agentabrams.com]
//
// Exits non-zero on first assertion failure with a clear message.
const callId = process.argv[2];
const baseIdx = process.argv.indexOf('--base');
const base = baseIdx > 0 ? process.argv[baseIdx + 1] : 'https://butlr.agentabrams.com';
if (!callId) {
console.error('usage: node scripts/validate-twiml.js <call_id> [--base URL]');
process.exit(64);
}
if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) {
console.error('bad call_id format'); process.exit(64);
}
const url = `${base}/twilio/twiml/${callId}`;
console.log(`→ POST ${url}`);
async function main() {
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: '' });
const xml = await r.text();
console.log(` status: ${r.status} content-type: ${r.headers.get('content-type')}`);
console.log(' body:'); console.log(xml.split('\n').map(l => ' ' + l).join('\n'));
const assertions = [
{ name: 'http 200', ok: r.status === 200 },
{ name: 'xml prolog', ok: /^<\?xml/.test(xml.trim()) },
{ name: '<Response> wrapper', ok: /<Response>[\s\S]*<\/Response>/.test(xml) },
{ name: '<Start><Stream> present', ok: /<Start>\s*<Stream\b/.test(xml) },
{ name: 'track="inbound_track"', ok: /<Stream[^>]*\btrack="inbound_track"/.test(xml) },
{ name: 'stream url is wss://', ok: /<Stream[^>]*\burl="wss:\/\//.test(xml) },
{ name: 'one <Gather> only', ok: (xml.match(/<Gather\b/g) || []).length === 1 },
{ name: 'gather action → /twilio/gather', ok: new RegExp(`action="[^"]*\\/twilio\\/gather\\/${callId}"`).test(xml) },
{ name: 'actionOnEmptyResult="true"', ok: /actionOnEmptyResult="true"/.test(xml) },
{ name: 'NO <Redirect>', ok: !/<Redirect\b/.test(xml) },
{ name: 'announce present (Play or Say)', ok: /<Play>|<Say\b/.test(xml) },
];
let pass = 0, fail = 0;
console.log('\nassertions:');
for (const a of assertions) {
console.log(` ${a.ok ? '✓' : '✗'} ${a.name}`);
if (a.ok) pass++; else fail++;
}
console.log(`\n${pass}/${pass + fail} passed${fail ? ` — ${fail} FAILED` : ''}`);
process.exit(fail ? 1 : 0);
}
main().catch(e => { console.error('error:', e.message); process.exit(2); });