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
This commit is contained in:
1 parent
a6c423de97
commit
e95271e694
47 files changed
+1466
-22
No files matched your search
@@ -0,0 +1,198 @@
|
||||
// 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();
|
||||
}
|
||||
Reference in new issue
Block a user