Files
selfphoneng/scripts/e2e/m6-check.mjs
T
iamdoubz 04ec3761c1
ci / images (push) Canceled after 0s
ci / check (push) Canceled after 3s
live: drop events for channels already hung up; e2e waits for the page
Asterisk sends NewCallerid after a Local;1 channel's Hangup, which re-created
the channel and left a ghost ringing call on the dashboard. Only Newchannel
creates a channel now. The e2e waits that matched text also shown on the
previous page (now that the dashboard lists calls) wait for the route.

Task: M7-T4
2026-10-18 15:37:06 -05:00

244 lines
8.8 KiB
JavaScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// M6 milestone check (09 M6), as real calls into a trunk's entry context:
// DID → office hours (open) → menu "1 sales (ring group, everyone at once), 2 support (102)";
// closed → voicemail of 101; *311 from an admin's phone forces open; a blocked caller is
// rejected. The blocked number is added in the UI (M6-T8).
// Phones (baresip, auto-answer): 220 (an admin's), 221, 222, 223. Runs on m2-live after m1-t4.
// Usage: node scripts/e2e/m6-check.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { originate } from './ami.mjs';
import { launch } from './cdp.mjs';
import { startPhones } from './phones.mjs';
const NET = 'sp-live';
const AST = 'sp-live-ast';
const DID = '13125557777';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const cli = (cmd) =>
spawnSync('docker', ['exec', AST, 'asterisk', '-rx', cmd], { encoding: 'utf8' }).stdout ?? '';
// eslint-disable-next-line no-control-regex -- strips Asterisk's log colors
const ANSI = /\x1b\[[0-9;]*m/g;
const astLog = () =>
(spawnSync('docker', ['logs', AST], { encoding: 'utf8' }).stdout ?? '').replace(ANSI, '');
const [base, email, pw, shotsDir] = process.argv.slice(2);
const login = await fetch(`${base}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' },
body: JSON.stringify({ email, password: pw }),
});
const cookie = login.headers
.getSetCookie()
.map((c) => c.split(';')[0])
.join('; ');
const api = async (method, url, body, raw) => {
const r = await fetch(`${base}${url}`, {
method,
headers: raw
? { 'content-type': 'audio/wav', 'x-requested-with': 'selfphoneng', cookie }
: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng', cookie },
body: raw ?? (body && JSON.stringify(body)),
});
const json = await r.json();
assert.ok(r.ok, `${method} ${url}: ${r.status} ${JSON.stringify(json)}`);
return json;
};
const next = async (kind) => (await api('GET', `/api/numbering/next?kind=${kind}`)).number;
// People and phones.
const boss = await api('POST', '/api/users', {
email: 'boss@example.com',
displayName: 'Bo Boss',
role: 'admin',
});
const ext = {};
const pass = {};
for (const [n, name, extra] of [
['220', 'Bo Boss', { userId: boss.user.id }],
['221', 'Sal Sales'],
['222', 'Sam Sales'],
['223', 'Sue Support'],
]) {
ext[n] = (await api('POST', '/api/extensions', { number: n, name, ...extra })).id;
pass[n] = (await api('GET', `/api/extensions/${ext[n]}/sip-credentials`)).password;
}
const phones = startPhones({
name: 'sp-live-phones-m6check',
net: NET,
ast: AST,
phones: ['220', '221', '222', '223'].map((n, i) => ({
ext: n,
password: pass[n],
answer: 'auto',
toneHz: 440 + 110 * i,
})),
});
// The call flow.
const wav = (() => {
const n = 8000 * 3;
const b = Buffer.alloc(44 + n * 2);
b.write('RIFF', 0);
b.writeUInt32LE(36 + n * 2, 4);
b.write('WAVEfmt ', 8);
b.writeUInt32LE(16, 16);
b.writeUInt16LE(1, 20);
b.writeUInt16LE(1, 22);
b.writeUInt32LE(8000, 24);
b.writeUInt32LE(16000, 28);
b.writeUInt16LE(2, 32);
b.writeUInt16LE(16, 34);
b.write('data', 36);
b.writeUInt32LE(n * 2, 40);
for (let i = 0; i < n; i++)
b.writeInt16LE(Math.round(Math.sin((2 * Math.PI * 500 * i) / 8000) * 8000), 44 + i * 2);
return b;
})();
const greeting = await api('POST', '/api/sounds?name=Welcome%20menu', undefined, wav);
const sales = await api('POST', '/api/ring-groups', {
number: await next('ringGroup'),
name: 'Check sales',
memberIds: [ext['221'], ext['222']],
noAnswerDestination: { type: 'voicemail', id: ext['220'] },
});
const menu = await api('POST', '/api/ivrs', {
number: await next('ivr'),
name: 'Check menu',
greetingSoundId: greeting.id,
options: [
{ digit: '1', destination: { type: 'ring_group', id: sales.id } },
{ digit: '2', destination: { type: 'user', id: ext['223'] } },
],
timeoutDestination: { type: 'voicemail', id: ext['220'] },
invalidDestination: { type: 'voicemail', id: ext['220'] },
});
const used = new Set((await api('GET', '/api/time-conditions')).items.map((t) => t.overrideDigit));
const digit = [1, 2, 3, 4, 5, 6, 7, 8, 9].find((d) => !used.has(d));
const hours = await api('POST', '/api/time-conditions', {
name: 'Check hours',
openRanges: [], // never open by the clock: overrides decide, so the check doesn't depend on now
overrideDigit: digit,
openDestination: { type: 'ivr', id: menu.id },
closedDestination: { type: 'voicemail', id: ext['220'] },
});
const trunk = await api('POST', '/api/trunks', {
name: 'M6 check',
serverHost: '127.0.0.1',
username: 'm6check',
password: 'unused-pw',
});
await api('POST', '/api/dids', {
number: DID,
trunkId: trunk.id,
destination: { type: 'time_condition', id: hours.id },
});
const waitUntil = async (fn, what, ms = 25_000) => {
const end = Date.now() + ms;
while (Date.now() < end) {
if (await fn()) return;
await sleep(200);
}
throw new Error(`timeout waiting for: ${what}`);
};
const hangupAll = async () => {
cli('channel request hangup all');
await waitUntil(() => !/(PJSIP|Local)\//.test(cli('core show channels concise')), 'calls ended');
await sleep(500);
};
/** A caller from outside: into the trunk's entry, presses `keys` once the greeting plays. */
async function inbound(from, keys = '') {
const at = astLog().length;
await originate({
Channel: `Local/${DID}@sp-from-trunk-${trunk.id}`,
CallerID: `"Outside" <${from}>`,
Application: keys ? 'SendDTMF' : 'Wait',
Data: keys ? `${'w'.repeat(10)}${keys}${'w'.repeat(40)}` : '25',
});
return () => astLog().slice(at);
}
const dialCode = async (code) => {
cli(`channel originate PJSIP/220 extension ${code}@sp-from-ext`);
await sleep(3000);
await hangupAll();
};
const p = await launch({ shotsDir });
try {
await waitUntil(
() =>
['220', '221', '222', '223'].every((n) =>
new RegExp(`${n}/sip:.*Avail`).test(cli('pjsip show contacts')),
),
'phones registered',
180_000,
);
await waitUntil(
() => cli(`dialplan show ${DID}@sp-inbound`).includes('sp-tc-'),
'dialplan ready',
);
// Closed (no hours, no override): straight to 220's voicemail.
let log = await inbound('3125550111');
await waitUntil(() => /VoiceMail\("[^"]+", "220@default,u"\)/.test(log()), 'closed → voicemail');
await hangupAll();
console.log('closed: the DID went to voicemail of 220');
// *31N from the admin's phone forces open → menu → 1 = sales (221 and 222 ring together).
await dialCode(`*31${digit}`);
await waitUntil(
async () => (await api('GET', `/api/time-conditions/${hours.id}`)).overrideState === 'open',
'forced open',
);
log = await inbound('3125550111', '1');
await waitUntil(
() => /Called PJSIP\/221\//.test(log()) && /Called PJSIP\/222\//.test(log()),
'sales rings',
);
assert.match(log(), new RegExp(`Playing '${greeting.asteriskName}\\.`), 'menu greeting played');
await hangupAll();
log = await inbound('3125550111', '2');
await waitUntil(() => /Called PJSIP\/223\//.test(log()), 'support rings');
await hangupAll();
console.log(`*31${digit} forced open: menu greeting, 1 → 221 + 222 together, 2 → 223`);
// Block the caller in the UI: the next call is rejected before anything rings.
await p.goto(`${base}/login`);
await p.waitFor(`!!document.querySelector('input[type=email]')`);
await p.fill('Email', email);
await p.fill('Password', pw);
await p.click('Sign in');
await p.waitFor(`location.pathname === '/admin'`);
await p.click('Blocked numbers');
await p.waitFor(`document.body.innerText.includes('Number to block')`);
await p.fill('Number to block', '(312) 555-0111');
await p.fill('Note (optional)', 'Test spammer');
await p.click('Block');
await p.waitFor(`document.body.innerText.includes('(312) 555-0111')`, 'listed');
await p.shot('m6-check-01-blocked');
assert.match(cli('database show SP'), /\/SP\/BLOCK\/13125550111\s*:\s*1/);
log = await inbound('3125550111', '1');
await waitUntil(() => /Hangup\("[^"]+", "21"\)/.test(log()), 'blocked caller rejected');
assert.doesNotMatch(log(), /Called PJSIP\//);
await hangupAll();
log = await inbound('3125550222', '2');
await waitUntil(() => /Called PJSIP\/223\//.test(log()), 'other callers still get through');
await hangupAll();
console.log('blocked in the UI: (312) 555-0111 rejected (cause 21); others still get through');
await dialCode(`*33${digit}`);
await waitUntil(
async () => (await api('GET', `/api/time-conditions/${hours.id}`)).overrideState === null,
'automatic',
);
console.log('M6 milestone check: PASS');
} catch (e) {
await p.shot('m6-check-fail').catch(() => {});
console.log(`FAIL: ${e.message}`);
process.exitCode = 1;
} finally {
await p.close();
phones.stop();
}