softphone: People panel with presence, click-to-call
People lists every extension with its status dot and a search box: a right-hand column on wide screens, /me/people on small ones. Call uses the browser softphone when it's on, else POST /api/me/click-to-call, which originates to the user's desk phone first (409 if none is registered). During a call: Transfer (blind), Talk first (consult), or drag the call card onto a person; a held call card offers Complete transfer whenever another call is active. useSoftphone moved to its own module. e2e m5-t3: the dot changes ~30 ms after Asterisk's hint; click-to-call rings 174 before 172. cdp click() takes a scope (People also has Call/Transfer buttons). trunk-test ignores the RTCP-at-teardown EPERM error seen once. Task: M5-T3
This commit is contained in:
1 parent
c7fd4df77c
commit
609965bba5
16 files changed
+452
-33
No files matched your search
@@ -0,0 +1,191 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M5-T3 acceptance: presence dots change in < 1 s after Asterisk's state changes, and
|
||||
// click-to-call from People rings the user's desk phone first, then the other person.
|
||||
// Run after m5-t2.mjs (extensions 172/173 and user web@example.com exist).
|
||||
// Usage: node scripts/e2e/m5-t3.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 WEB = { email: 'web@example.com', password: 'web-new-password-123' };
|
||||
const CC = { email: 'cc@example.com', password: 'cc-new-password-123' };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const cli = (cmd) =>
|
||||
spawnSync('docker', ['exec', AST, 'asterisk', '-rx', cmd], { encoding: 'utf8' }).stdout ?? '';
|
||||
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 exts = (await api('GET', '/api/extensions')).items;
|
||||
const secret = async (id) => (await api('GET', `/api/extensions/${id}/sip-credentials`)).password;
|
||||
const cc = await api('POST', '/api/users', { email: CC.email, displayName: 'Cam Clicker' });
|
||||
const ccExt = await api('POST', '/api/extensions', {
|
||||
number: '174',
|
||||
name: 'Cam Clicker',
|
||||
userId: cc.user.id,
|
||||
});
|
||||
const byNumber = (n) => exts.find((e) => e.number === n);
|
||||
const phones = startPhones({
|
||||
name: 'sp-live-phones-m5t3',
|
||||
net: NET,
|
||||
ast: AST,
|
||||
phones: [
|
||||
{ ext: '172', password: await secret(byNumber('172').id), answer: 'auto' },
|
||||
{ ext: '173', password: await secret(byNumber('173').id), answer: 'auto', toneHz: 660 },
|
||||
{ ext: '174', password: await secret(ccExt.id), answer: 'auto', toneHz: 550 },
|
||||
],
|
||||
});
|
||||
|
||||
const channels = () =>
|
||||
cli('core show channels concise')
|
||||
.split('\n')
|
||||
.filter((l) => l.startsWith('PJSIP/'))
|
||||
.map((l) => {
|
||||
const f = l.split('!');
|
||||
return { name: f[0], state: f[4], bridge: f[12], seq: Number(f[13]?.split('.')[1]) };
|
||||
});
|
||||
const hangupAll = async () => {
|
||||
cli('channel request hangup all');
|
||||
for (let i = 0; i < 40 && channels().length; i++) await sleep(250);
|
||||
await sleep(500);
|
||||
};
|
||||
async function signIn(p, who, temp) {
|
||||
await p.goto(`${base}/login`);
|
||||
await p.waitFor(`!!document.querySelector('input[type=email]')`);
|
||||
await p.fill('Email', who.email);
|
||||
await p.fill('Password', temp ?? who.password);
|
||||
await p.click('Sign in');
|
||||
if (temp) {
|
||||
await p.waitFor(`location.pathname === '/change-password'`, 'forced password change');
|
||||
await p.fill('Current password', temp);
|
||||
await p.fill('New password', who.password);
|
||||
await p.fill('Repeat new password', who.password);
|
||||
await p.click('Change password');
|
||||
}
|
||||
await p.waitFor(`location.pathname === '/me'`, 'my phone');
|
||||
}
|
||||
const dotLabel = (p, n) =>
|
||||
p.evaluate(`document.querySelector('aside li[data-number="${n}"] .sr-only')?.textContent ?? ''`);
|
||||
|
||||
const watcher = await launch({ shotsDir });
|
||||
const clicker = await launch({ shotsDir });
|
||||
try {
|
||||
for (let i = 0; i < 180; i++) {
|
||||
const c = cli('pjsip show contacts');
|
||||
if (['172', '173', '174'].every((n) => new RegExp(`${n}/sip:.*Avail`).test(c))) break;
|
||||
await sleep(1000);
|
||||
}
|
||||
const reg = cli('pjsip show contacts');
|
||||
for (const n of ['172', '173', '174'])
|
||||
assert.match(reg, new RegExp(`${n}/sip:.*Avail`), `${n} registered`);
|
||||
await signIn(watcher, WEB);
|
||||
await watcher.waitFor(`!!document.querySelector('aside li[data-number="172"]')`, 'People list');
|
||||
await watcher.waitFor(
|
||||
`document.querySelector('aside li[data-number="172"] .sr-only')?.textContent === 'Available'`,
|
||||
'172 shown available',
|
||||
10_000,
|
||||
);
|
||||
await watcher.shot('m5-t3-01-people');
|
||||
|
||||
// Presence: 173 calls 172. Poll Asterisk's hint and the dot side by side.
|
||||
const hintChange = (async () => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
if (!/State:Idle/.test(cli('core show hint 172'))) return Date.now();
|
||||
await sleep(20);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const dotChange = (async () => {
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
if ((await dotLabel(watcher, '172')) !== 'Available') return Date.now();
|
||||
await sleep(10);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
cli('channel originate PJSIP/173 extension 172@sp-from-ext');
|
||||
const [tAst, tUi] = await Promise.all([hintChange, dotChange]);
|
||||
assert.ok(tAst && tUi, `state change seen (asterisk ${tAst}, ui ${tUi})`);
|
||||
console.log(
|
||||
`presence: dot changed ${tUi - tAst} ms after Asterisk's hint (now "${await dotLabel(watcher, '172')}")`,
|
||||
);
|
||||
assert.ok(tUi - tAst < 1000, 'presence dot within 1 s');
|
||||
await watcher.waitFor(
|
||||
`document.querySelector('aside li[data-number="172"] .sr-only')?.textContent === 'On a call'`,
|
||||
'172 on a call',
|
||||
);
|
||||
await watcher.shot('m5-t3-02-on-a-call');
|
||||
await hangupAll();
|
||||
await watcher.waitFor(
|
||||
`document.querySelector('aside li[data-number="172"] .sr-only')?.textContent === 'Available'`,
|
||||
'172 available again',
|
||||
);
|
||||
console.log('presence: back to Available after hang-up');
|
||||
|
||||
// Click-to-call: a user without the browser softphone; the desk phone (174) rings first.
|
||||
await signIn(clicker, CC, cc.temporaryPassword);
|
||||
await clicker.waitFor(`!!document.querySelector('aside li[data-number="172"]')`, 'People list');
|
||||
await clicker.click('Call Pat Desk');
|
||||
// Asterisk numbers channels in creation order (the part of the uniqueid after the dot).
|
||||
const created = {};
|
||||
for (let i = 0; i < 100 && Object.keys(created).length < 2; i++) {
|
||||
for (const c of channels()) {
|
||||
const n = /^PJSIP\/(\d+)-/.exec(c.name)?.[1];
|
||||
if (n && !(n in created)) created[n] = c.seq;
|
||||
}
|
||||
await sleep(50);
|
||||
}
|
||||
assert.ok(created['174'] < created['172'], `desk phone rang first: ${JSON.stringify(created)}`);
|
||||
await clicker.waitFor(
|
||||
`document.body.innerText.includes('Your desk phone is ringing')`,
|
||||
'tells the user to pick up',
|
||||
);
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const c = channels();
|
||||
const a = c.find((x) => x.name.startsWith('PJSIP/174-'));
|
||||
const b = c.find((x) => x.name.startsWith('PJSIP/172-'));
|
||||
if (a?.bridge && a.bridge === b?.bridge) break;
|
||||
await sleep(250);
|
||||
}
|
||||
const c = channels();
|
||||
assert.equal(
|
||||
c.find((x) => x.name.startsWith('PJSIP/174-'))?.bridge,
|
||||
c.find((x) => x.name.startsWith('PJSIP/172-'))?.bridge,
|
||||
'174 and 172 talk',
|
||||
);
|
||||
console.log('click-to-call: 174 (desk) rang first, then 172; connected');
|
||||
await clicker.shot('m5-t3-03-click-to-call');
|
||||
await hangupAll();
|
||||
|
||||
console.log('M5-T3 (presence, click-to-call): PASS');
|
||||
} catch (e) {
|
||||
await watcher.shot('m5-t3-fail-watcher').catch(() => {});
|
||||
await clicker.shot('m5-t3-fail-clicker').catch(() => {});
|
||||
console.log(`FAIL: ${e.message}`);
|
||||
console.log(cli('pjsip show contacts'));
|
||||
console.log(cli('core show channels concise'));
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await watcher.close();
|
||||
await clicker.close();
|
||||
phones.stop();
|
||||
}
|
||||
Reference in new issue
Block a user