/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
199 lines
8.2 KiB
JavaScript
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();
|
|
}
|