Files
selfphoneng/scripts/e2e/m8-check.mjs
T
iamdoubz 4742ed52ea
ci / check (push) Successful in 1m4s
ci / images (push) Successful in 19s
queues: service level counts callers who weren't answered
withinSlaPercent was "answered within sla_seconds / answered", so a
queue where a caller timed out still showed 100%. It is now the usual
service level: answered within sla_seconds out of every caller the
queue is done with, leaving out those who hung up before sla_seconds
(nobody could have answered them in time). Timed out and turned away
count as missed; callers still waiting aren't counted. Written into
04; the page label says "Callers answered within N s".

Real calls: m8-t1 now shows 50% (1 answered in time, 1 timed out, the
early hang-up left out) and the milestone check 67% (2 of 3), and
asserts it. The check moves to queue 552 (*45552) so it doesn't clash
with m8-t1's 550 on a shared database.

Task: M8-T1
2026-10-19 12:13:39 -05:00

219 lines
8.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: AGPL-3.0-or-later
// M8 milestone check (09 M8), as real calls from outside (into a trunk's entry context):
// DID → queue 552 with agents 340 and 341, max wait 30 s, then 342. 340 pauses with *45552 and
// 341 is paused too, so 3 callers wait. 341 comes back and gets the longest waiting caller,
// parks them (transfer to 700) on 701, and 343 picks them up by dialing 701. 341 is free again
// and takes the next caller; the last one waits past 30 s and goes to 342. Today's stats agree.
// Phones (baresip, auto-answer): 340–343. Runs on m2-live after m1-t4.
// Usage: node scripts/e2e/m8-check.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { action, originate } from './ami.mjs';
import { startPhones } from './phones.mjs';
const NET = 'sp-live';
const AST = 'sp-live-ast';
const DID = '13125558808';
const MAX_WAIT = 30;
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] = 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: {
...(body ? { '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}-`));
/** Phone n (directly, or as a queue agent's Local leg) shares a bridge with the caller (whose
* number the trunk entry has normalized to 1NXX…). */
const talkingTo = (n, caller) => {
const mine = channels().filter(
(c) => c.name.startsWith(`PJSIP/${n}-`) || c.name.startsWith(`Local/${n}@sp-queue-member-`),
);
const theirs = channels().filter((c) => c.callerId?.endsWith(caller) && c.bridge);
return mine.some((m) => m.bridge && theirs.some((t) => t.bridge === m.bridge && t !== m));
};
const queueShow = () => cli('queue show 552');
const parkedSlot = () =>
/Space\s*:\s*(\d+)/.exec(cli('parking show default').split('Parked Calls')[1] ?? '')?.[1];
const ext = {};
const phoneList = [];
for (const [n, name] of [
['340', 'Ann Agent'],
['341', 'Bob Agent'],
['342', 'Olga Overflow'],
['343', 'Gil Grab'],
]) {
const e = await api('POST', '/api/extensions', { number: n, name });
ext[n] = e.id;
const pass = (await api('GET', `/api/extensions/${e.id}/sip-credentials`)).password;
phoneList.push({ ext: n, password: pass, answer: 'auto' });
}
const phones = startPhones({ name: 'sp-live-phones-m8chk', net: NET, ast: AST, phones: phoneList });
const queue = await api('POST', '/api/queues', {
number: '552',
name: 'Support',
members: [
{ extensionId: ext['340'], penalty: 0 },
{ extensionId: ext['341'], penalty: 0 },
],
maxWaitSeconds: MAX_WAIT,
retrySeconds: 1,
joinWhenNoAgents: true,
timeoutDestination: { type: 'user', id: ext['342'] },
});
const trunk = await api('POST', '/api/trunks', {
name: 'M8 check',
serverHost: '127.0.0.1',
username: 'm8check',
password: 'unused-pw',
});
await api('POST', '/api/dids', {
number: DID,
trunkId: trunk.id,
destination: { type: 'queue', id: queue.id },
});
const callers = ['3125552201', '3125552202', '3125552203'];
const inbound = (n) =>
originate({
Channel: `Local/${DID}@sp-from-trunk-${trunk.id}`,
CallerID: `"Caller ${n.slice(-1)}" <${n}>`,
Application: 'Wait',
Data: '120',
});
try {
await waitUntil(
() =>
['340', '341', '342', '343'].every((e) =>
new RegExp(`${e}/sip:.*Avail`).test(cli('pjsip show contacts')),
),
'phones',
180_000,
);
await waitUntil(
() => cli(`dialplan show ${DID}@sp-inbound`).includes('sp-queue-'),
'DID routes to the queue',
);
// 340 pauses with *45552; 341 is paused by an admin, so nobody answers yet.
const at = astLog().length;
cli('channel originate PJSIP/340 extension *45552@sp-from-ext');
await waitUntil(() => /Playing 'agent-loggedoff/.test(astLog().slice(at)), '*45 says logged off');
await waitUntil(
() => /Local\/340@sp-queue-member\/n.*\(paused/.test(queueShow()),
'340 paused in app_queue',
);
await api('POST', `/api/queues/${queue.id}/members/${ext['341']}/pause`);
await waitUntil(() => /Local\/341@sp-queue-member\/n.*\(paused/.test(queueShow()), '341 paused');
await waitUntil(() => !chan('340'), '*45 call over');
console.log('340 paused with *45552, 341 paused by an admin');
// Three callers from outside wait.
const joined = [];
for (const n of callers) {
await inbound(n);
joined.push(Date.now());
await sleep(2500);
}
await waitUntil(() => /552 has 3 calls/.test(queueShow()), '3 callers waiting');
console.log('3 callers from the DID are waiting in queue 552 with 2 agents, both paused');
// 341 is back: the longest waiting caller reaches 341.
await api('POST', `/api/queues/${queue.id}/members/${ext['341']}/unpause`);
await waitUntil(() => talkingTo('341', callers[0]), 'caller 1 talks to 341', 20_000);
console.log('341 unpaused: caller 1 (longest waiting) talks to 341');
// 341 parks caller 1 (blind transfer to 700, the phone's transfer key) and 343 picks up 701.
await action({
Action: 'BlindTransfer',
Channel: chan('341').name,
Context: 'sp-from-ext',
Exten: '700',
});
await waitUntil(() => parkedSlot() === '701', 'caller 1 parked on 701');
console.log('341 parked caller 1 on 701');
cli('channel originate PJSIP/343 extension 701@sp-from-ext');
await waitUntil(() => talkingTo('343', callers[0]), '343 talks to caller 1', 20_000);
await waitUntil(() => !parkedSlot(), 'slot 701 free');
console.log('343 dialed 701 and talks to caller 1');
// 341 is free again: caller 2 reaches 341; caller 3 waits past the limit and goes to 342.
await waitUntil(() => talkingTo('341', callers[1]), 'caller 2 talks to 341', 20_000);
console.log('341 is free again: caller 2 talks to 341');
await waitUntil(
() => talkingTo('342', callers[2]),
'caller 3 reaches 342',
(MAX_WAIT + 15) * 1000,
);
const waited = (Date.now() - joined[2]) / 1000;
assert.ok(waited >= MAX_WAIT - 1, `caller 3 left after ${waited.toFixed(1)} s`);
console.log(`caller 3 waited ${waited.toFixed(0)} s (max ${MAX_WAIT}), then rang 342`);
cli('channel request hangup all');
await waitUntil(() => channels().length === 0, 'calls ended');
const s = await waitUntil(async () => {
const r = await api('GET', `/api/queues/${queue.id}/stats`);
return r.offered === 3 && r;
}, 'stats count 3 calls');
assert.deepEqual(
{ answered: s.answered, abandoned: s.abandoned, timedOut: s.timedOut },
{ answered: 2, abandoned: 0, timedOut: 1 },
JSON.stringify(s),
);
// service level: the timed-out caller counts as missed, so at most 2 of 3
assert.ok(s.withinSlaPercent <= 67, `service level ${s.withinSlaPercent}%`);
console.log(`stats: ${JSON.stringify(s)}`);
console.log('M8 milestone check: PASS');
} catch (e) {
console.log(`FAIL: ${e.message}`);
console.log(`queue show: ${queueShow()}`);
console.log(`parking: ${cli('parking show default')}`);
console.log(`channels: ${cli('core show channels concise')}`);
process.exitCode = 1;
} finally {
cli('channel request hangup all');
phones.stop();
}