Files
selfphoneng/scripts/e2e/m8-t4.mjs
T
iamdoubz e95271e694
ci / check (push) Successful in 1m7s
ci / images (push) Successful in 3m20s
paging: paging groups, *80 intercom, auto-answer headers
/api/paging-groups CRUD (members as memberIds). The generator renders
sp-page-{id} per 03 §6.11 (desk phones only, auto-answer headers via
sp-sub-autoanswer) plus the number in sp-local-numbers. Added the `s`
option so phones already on a call aren't paged, which also keeps a
pager who is a member from being paged themselves. A group with no
members just hangs up.

*80<ext> intercom checks the target first: digits only and an endpoint
in sp-from-ext, else ss-noservice (the spec's one-liner passed
${EXTEN:3} straight into Page()). Both written back into 03.

Web: Paging page (list, drawer form with member checkboxes). Golden
case paging. e2e m8-t4 with baresip phones that honor the headers
(sip_autoanswer): an ordinary call rings, a page is answered by itself
and muted, the busy pager is skipped, *80 is two-way, *80999 refused.
The check on a real Yealink/Grandstream stays open (09).

Task: M8-T4
2026-10-19 09:40:22 -05:00

199 lines
8.2 KiB
JavaScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// M8-T4 with real calls: paging group 850 built in the UI (320, 321, 322). 320 and 321 are
// "intercom" desk phones: they ring like any phone (checked first), but answer by themselves when
// the INVITE carries the auto-answer headers, as Yealink and Grandstream phones do. 322 pages the
// group: 320 and 321 answer muted (one-way) and 322, already on the call, isn't paged. *80320 is
// a two-way intercom to 320; *80 with something that isn't a desk phone is refused.
// No real Yealink/Grandstream here: the hardware check stays open (09 M8-T4).
// Runs on m2-live, after m1-t4.mjs.
// Usage: node scripts/e2e/m8-t4.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { launch } from './cdp.mjs';
import { startPhones } from './phones.mjs';
const NET = 'sp-live';
const AST = 'sp-live-ast';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const cli = (cmd) =>
spawnSync('docker', ['exec', AST, 'asterisk', '-rx', cmd], { encoding: 'utf8' }).stdout ?? '';
const astLog = () =>
// eslint-disable-next-line no-control-regex -- Asterisk colors its log
spawnSync('docker', ['logs', AST], { encoding: 'utf8' }).stdout.replace(/\x1b\[[0-9;]*m/g, '');
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) => {
const r = await fetch(`${base}${url}`, {
method,
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng', cookie },
body: body && JSON.stringify(body),
});
const json = await r.json();
assert.ok(r.ok, `${method} ${url}: ${r.status} ${JSON.stringify(json)}`);
return json;
};
const waitUntil = async (fn, what, ms = 30_000) => {
const end = Date.now() + ms;
while (Date.now() < end) {
const v = await fn();
if (v) return v;
await sleep(300);
}
throw new Error(`timeout waiting for: ${what}`);
};
/** State of each PJSIP/<ext> channel: { '320': 'Up', ... }. */
const states = () =>
Object.fromEntries(
cli('core show channels concise')
.split('\n')
.filter((l) => l.startsWith('PJSIP/'))
.map((l) => l.split('!'))
.map((f) => [f[0].slice(6).split('-')[0], f[4]]),
);
/** Paged participants (channel, flags) of every bridge Page() made. */
const paged = () =>
[...cli('confbridge list').matchAll(/^(\S+)\s+\d+/gm)]
.map((m) => m[1])
.filter((c) => c !== 'Conference')
.flatMap((room) =>
cli(`confbridge list ${room}`)
.split('\n')
.filter((l) => /^PJSIP\//.test(l))
.map((l) => ({ channel: l.split(/\s+/)[0], flags: l.split(/\s+/)[1] ?? '' })),
);
/** INVITEs Asterisk sent to an extension since log offset `at`. */
const invitesTo = (ext, at) =>
astLog()
.slice(at)
.split(/<--- Transmitting SIP request/)
.filter((m) => new RegExp(`^[^\\n]*\\n+INVITE sip:${ext}[-@]`).test(m));
const phoneList = [];
const extIds = {};
for (const [ext, name, answer] of [
['320', 'Pat Page', 'manual'],
['321', 'Pia Page', 'manual'],
['322', 'Paul Pager', 'auto'],
]) {
const e = await api('POST', '/api/extensions', { number: ext, name });
extIds[ext] = e.id;
const pass = (await api('GET', `/api/extensions/${e.id}/sip-credentials`)).password;
phoneList.push({ ext, password: pass, answer, intercom: answer === 'manual' });
}
const phones = startPhones({ name: 'sp-live-phones-m8t4', net: NET, ast: AST, phones: phoneList });
const p = await launch({ shotsDir });
const check = (text) =>
p.evaluate(`(() => {
const l = [...document.querySelectorAll('dialog[open] label')].find(l => l.textContent.includes(${JSON.stringify(text)}));
const box = l.querySelector('input[type=checkbox]');
if (!box.checked) box.click();
return true;
})()`);
try {
await waitUntil(
() =>
['320', '321', '322'].every((e) =>
new RegExp(`${e}/sip:.*Avail`).test(cli('pjsip show contacts')),
),
'phones',
180_000,
);
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'`);
// Group 850 in the UI.
await p.click('Paging');
await p.click('Add paging group');
await p.waitFor(`!!document.querySelector('dialog[open]')`);
await p.waitFor(
`[...document.querySelectorAll('dialog[open] label')].find(l => l.textContent === 'Number')?.control.value === '850'`,
'next free paging number suggested',
);
await p.fill('Name', 'Warehouse', 'dialog[open]');
for (const ext of ['320', '321', '322']) await check(`${ext} `);
await p.click('Save', 'dialog[open]');
await p.waitFor(
`document.body.innerText.includes('One-way: Pat Page, Pia Page, Paul Pager')`,
'saved',
);
await waitUntil(
() => /sp-page-/.test(cli('dialplan show 850@sp-local-numbers')),
'dialplan has 850',
);
await p.shot('m8-t4-01-paging');
console.log('paging group 850 built in the UI');
// Control: an ordinary call to 320 rings and stays ringing.
cli('channel originate PJSIP/322 extension 320@sp-from-ext');
await waitUntil(() => states()['320'] === 'Ringing', '320 rings');
await sleep(4000);
assert.equal(states()['320'], 'Ringing', 'an ordinary call is not auto-answered');
cli('channel request hangup all');
await waitUntil(() => !Object.keys(states()).length, 'all hung up');
console.log('ordinary call: 320 rings and does not answer by itself');
// 322 pages 850: 320 and 321 answer by themselves, muted; 322 isn't paged.
cli('pjsip set logger on');
let at = astLog().length;
cli('channel originate PJSIP/322 extension 850@sp-from-ext');
await waitUntil(() => paged().length === 3, '322 + 320 + 321 in the page', 20_000);
const s = states();
assert.ok(s['320'] === 'Up' && s['321'] === 'Up', `states: ${JSON.stringify(s)}`);
const inv = invitesTo('320', at);
assert.equal(inv.length, 1, 'one INVITE to 320');
assert.match(inv[0], /Call-Info: <sip:[^>]*>;answer-after=0/);
assert.match(inv[0], /Alert-Info: info=alert-autoanswer/);
assert.equal(invitesTo('322', at).length, 1, '322 only got the INVITE that started its call');
const flags = Object.fromEntries(paged().map((m) => [m.channel.slice(6, 9), m.flags]));
console.log(`page 850: flags ${JSON.stringify(flags)}`);
assert.ok(/m/.test(flags['320']) && /m/.test(flags['321']), 'paged phones are muted');
assert.ok(!/m/.test(flags['322'] ?? ''), 'the pager talks');
console.log('page 850: 320 and 321 answered by themselves, muted; 322 (busy) skipped');
cli('channel request hangup all');
await waitUntil(() => !Object.keys(states()).length, 'all hung up');
// *80320: two-way intercom.
cli('channel originate PJSIP/322 extension *80320@sp-from-ext');
await waitUntil(() => paged().length === 2, '322 + 320 in the intercom', 20_000);
const iflags = Object.fromEntries(paged().map((m) => [m.channel.slice(6, 9), m.flags]));
console.log(`*80320: flags ${JSON.stringify(iflags)}`);
assert.ok(!/m/.test(iflags['320']), '320 can talk back');
cli('channel request hangup all');
await waitUntil(() => !Object.keys(states()).length, 'all hung up');
console.log('*80320: 320 answered by itself and can talk back');
// *80 to something that isn't a desk phone.
at = astLog().length;
cli('channel originate PJSIP/322 extension *80999@sp-from-ext');
await waitUntil(() => /Playing 'ss-noservice/.test(astLog().slice(at)), '*80999 refused');
console.log('*80999: refused (ss-noservice)');
cli('pjsip set logger off');
console.log('M8-T4 (paging): PASS');
} catch (e) {
await p.shot('m8-t4-fail').catch(() => {});
console.log(`FAIL: ${e.message}`);
console.log(`confbridge: ${cli('confbridge list')}`);
console.log(`channels: ${cli('core show channels concise')}`);
console.log(`browser errors: ${JSON.stringify(p.consoleErrors)}`);
process.exitCode = 1;
} finally {
cli('pjsip set logger off');
cli('channel request hangup all');
await p.close();
phones.stop();
}