Files
selfphoneng/scripts/e2e/m8-t2.mjs
T
iamdoubz 863a9d121c
ci / images (push) Canceled after 0s
ci / check (push) Canceled after 3s
parking: parked calls live, on the dashboard and Home, pick up from Home
res_parking was configured in M0 (700, slots 701-720, slot hints); this
makes parked calls visible. ParkingTracker loads ParkedCalls and follows
ParkedCall/UnParkedCall/ParkedCallTimeOut/ParkedCallGiveUp; everyone
gets parked.updated/parked.removed. Who parked comes from
ParkerDialString.

A blind transfer to 700 parks without running Park() in the dialplan,
so the live call's "parked" state now comes from those events by
ParkeeLinkedid; the picking-up phone runs ParkedCall() while talking,
which no longer counts as parked.

Web: "Parked calls" on the Dashboard and on Home, where "Pick up 701"
calls the slot like Call back (softphone, or the desk phone first).

CDR fixture park-pickup (recorded: 101 parks an inbound call, 103 picks
it up); the call counts as answered by 101 with 103 in rang. e2e m8-t2
with real phones: park by blind transfer, hint InUse, both lists, pick
up from Home, a second park on 702 (findslot = next) picked up by
dialing it from another phone. Specs 01/04/05 note what was verified.

Task: M8-T2
2026-10-19 04:23:21 -05:00

197 lines
7.9 KiB
JavaScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// M8-T2 with real phones: 290 answers a call and parks it (blind transfer to 700, as a phone's
// transfer key does), it lands on 701, the slot's BLF hint goes busy, the dashboard and a user's
// Home list it, and the user picks it up with "Pick up 701": their desk phone 291 rings, then
// dials 701 and talks to the caller. A second park takes 702 (findslot = next) and another
// phone (292) picks it up by dialing 702.
// Runs on m2-live, after m1-t4.mjs.
// Usage: node scripts/e2e/m8-t2.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { action, originate } from './ami.mjs';
import { launch } from './cdp.mjs';
import { startPhones } from './phones.mjs';
const NET = 'sp-live';
const AST = 'sp-live-ast';
const PIA = { email: 'pia@example.com', password: 'pia-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 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}`);
};
const channels = () =>
cli('core show channels concise')
.split('\n')
.filter(Boolean)
.map((l) => {
const f = l.split('!');
return { name: f[0], app: f[5], callerId: f[7], bridge: f[12] };
});
const chan = (n) => channels().find((c) => c.name.startsWith(`PJSIP/${n}-`));
/** The caller's bridged half (Local;2) shares a bridge with phone n. */
const talkingTo = (n, caller) => {
const phone = chan(n);
return (
!!phone?.bridge &&
channels().some((c) => c.callerId === caller && c !== phone && c.bridge === phone.bridge)
);
};
const hint701 = () => /701@sp-parkedcalls.*State:(\w+)/.exec(cli('core show hint 701'))?.[1];
/** Occupied slot (findslot = next: each park takes the slot after the last one used). */
const parkedSlot = () =>
/Space\s*:\s*(\d+)/.exec(cli('parking show default').split('Parked Calls')[1] ?? '')?.[1];
const parkedOn701 = () => parkedSlot() === '701';
const user = await api('POST', '/api/users', { email: PIA.email, displayName: 'Pia Picker' });
const people = [
['290', 'Paul Parker', null],
['291', 'Pia Picker', user.user.id],
['292', 'Gil Grab', null],
];
const phoneList = [];
for (const [ext, name, userId] of people) {
const e = await api('POST', '/api/extensions', {
number: ext,
name,
...(userId ? { userId } : {}),
});
const pass = (await api('GET', `/api/extensions/${e.id}/sip-credentials`)).password;
phoneList.push({ ext, password: pass, answer: 'auto' });
}
const phones = startPhones({ name: 'sp-live-phones-m8t2', net: NET, ast: AST, phones: phoneList });
/** A caller rings 290, who answers and blind-transfers it to 700. */
async function callAndPark(caller, slot = '701') {
await originate({
Channel: 'Local/290@sp-from-ext',
Application: 'Wait',
Data: '90',
CallerID: `"Carla Caller" <${caller}>`,
});
await waitUntil(() => talkingTo('290', caller), '290 answers');
await action({
Action: 'BlindTransfer',
Channel: chan('290').name,
Context: 'sp-from-ext',
Exten: '700',
});
await waitUntil(() => parkedSlot() === slot, `parked on ${slot}`);
await waitUntil(() => !chan('290'), '290 hangs up after the transfer');
}
const admin = await launch({ shotsDir });
const pia = await launch({ shotsDir });
const ROW = `(document.querySelector('ul[aria-label="Parked calls"]')?.innerText ?? '')`;
const EMPTY = `document.body.innerText.includes('Transfer a call to 700 to park it')`;
try {
await waitUntil(
() =>
['290', '291', '292'].every((e) =>
new RegExp(`${e}/sip:.*Avail`).test(cli('pjsip show contacts')),
),
'phones',
180_000,
);
await admin.goto(`${base}/login`);
await admin.waitFor(`!!document.querySelector('input[type=email]')`);
await admin.fill('Email', email);
await admin.fill('Password', pw);
await admin.click('Sign in');
await admin.waitFor(`location.pathname === '/admin'`);
await admin.waitFor(EMPTY, 'dashboard: no parked calls');
await pia.goto(`${base}/login`);
await pia.waitFor(`!!document.querySelector('input[type=email]')`);
await pia.fill('Email', PIA.email);
await pia.fill('Password', user.temporaryPassword);
await pia.click('Sign in');
await pia.waitFor(`location.pathname === '/change-password'`, 'forced password change');
await pia.fill('Current password', user.temporaryPassword);
await pia.fill('New password', PIA.password);
await pia.fill('Repeat new password', PIA.password);
await pia.click('Change password');
await pia.waitFor(`location.pathname === '/me'`, 'my phone');
// An empty slot's hint is Unavailable (BLF off), a parked call makes it InUse.
assert.notEqual(hint701(), 'InUse', 'slot 701 free before');
await callAndPark('3125550101');
await waitUntil(() => hint701() === 'InUse', 'BLF hint 701 busy');
console.log('290 parked the call on 701; hint 701@sp-parkedcalls is InUse');
await admin.waitFor(
`${ROW}.includes('701') && ${ROW}.includes('parked by 290')`,
'dashboard 701',
);
assert.match(await admin.evaluate(ROW), /Carla Caller/);
await admin.waitFor(
`[...document.querySelectorAll('ul[aria-label="Calls now"] > li')].some(li => li.innerText.includes('Parked'))`,
'the live call shows Parked',
);
await pia.waitFor(`${ROW}.includes('701')`, 'Home lists 701');
await admin.shot('m8-t2-01-dashboard');
await pia.shot('m8-t2-02-home');
console.log('dashboard and Home list the parked call on 701 (Carla Caller, parked by 290)');
// Pick up from Home: 291 rings (auto-answers), then dials 701.
await pia.click('Pick up 701');
await waitUntil(() => talkingTo('291', '3125550101'), '291 talks to the caller', 20_000);
await waitUntil(() => !parkedOn701() && hint701() !== 'InUse', 'slot 701 free again');
await pia.waitFor(EMPTY, 'Home list empties');
await admin.waitFor(EMPTY, 'dashboard list empties');
console.log('"Pick up 701" on Home: 291 rang, dialed 701 and talks to the caller; slot free');
// Dialing the slot from another phone (the next park takes 702).
cli('channel request hangup all');
await waitUntil(() => channels().length === 0, 'calls ended');
await callAndPark('3125550102', '702');
cli('channel originate PJSIP/292 extension 702@sp-from-ext');
await waitUntil(() => talkingTo('292', '3125550102'), '292 talks to the caller', 20_000);
await waitUntil(() => !parkedSlot(), 'slot 702 free again');
console.log('the next park took 702; dialing 702 from 292 picked it up');
cli('channel request hangup all');
console.log('M8-T2 (parking): PASS');
} catch (e) {
await admin.shot('m8-t2-fail-admin').catch(() => {});
await pia.shot('m8-t2-fail-user').catch(() => {});
console.log(`FAIL: ${e.message}`);
console.log(`parking: ${cli('parking show default')}`);
console.log(`hint: ${cli('core show hint 701')}`);
console.log(`browser errors: ${JSON.stringify([...admin.consoleErrors, ...pia.consoleErrors])}`);
process.exitCode = 1;
} finally {
cli('channel request hangup all');
await admin.close();
await pia.close();
phones.stop();
}