Files
selfphoneng/scripts/e2e/m7-t3.mjs
T
iamdoubz 04ec3761c1
ci / images (push) Canceled after 0s
ci / check (push) Canceled after 3s
live: drop events for channels already hung up; e2e waits for the page
Asterisk sends NewCallerid after a Local;1 channel's Hangup, which re-created
the channel and left a ghost ringing call on the dashboard. Only Newchannel
creates a channel now. The e2e waits that matched text also shown on the
previous page (now that the dashboard lists calls) wait for the route.

Task: M7-T4
2026-10-18 15:37:06 -05:00

199 lines
8.2 KiB
JavaScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// M7-T3 acceptance: "a recorded call is playable from history". In the UI: Settings → Recording
// picks the notice, Phone numbers → "Record every call". Then a caller (AMI Originate into the
// trunk entry, with a caller ID) reaches 240 (auto-answer): the notice plays, MixMonitor records,
// the call shows in Call history with a working player, and on the Recordings page.
// Runs on m2-live with sounds and recordings dirs (m2-live.mjs up <conf> <vm> <sounds> <recs>),
// api with SOUNDS_DIR and RECORDINGS_DIR, after m1-t4.mjs.
// Usage: node scripts/e2e/m7-t3.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { originate } from './ami.mjs';
import { launch } from './cdp.mjs';
import { startPhones } from './phones.mjs';
const NET = 'sp-live';
const AST = 'sp-live-ast';
const DID = '13125558888';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const cli = (cmd) =>
spawnSync('docker', ['exec', AST, 'asterisk', '-rx', cmd], { encoding: 'utf8' }).stdout ?? '';
// eslint-disable-next-line no-control-regex -- strips Asterisk's log colors
const ANSI = /\x1b\[[0-9;]*m/g;
const astLog = () =>
(spawnSync('docker', ['logs', AST], { encoding: 'utf8' }).stdout ?? '').replace(ANSI, '');
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, raw) => {
const r = await fetch(`${base}${url}`, {
method,
headers: {
'content-type': raw ? 'audio/wav' : 'application/json',
'x-requested-with': 'selfphoneng',
cookie,
},
body: raw ?? (body && JSON.stringify(body)),
});
const json = await r.json();
assert.ok(r.ok, `${method} ${url}: ${r.status} ${JSON.stringify(json)}`);
return json;
};
const tone = (secs, hz) => {
const n = 8000 * secs;
const b = Buffer.alloc(44 + n * 2);
b.write('RIFF', 0);
b.writeUInt32LE(36 + n * 2, 4);
b.write('WAVEfmt ', 8);
b.writeUInt32LE(16, 16);
b.writeUInt16LE(1, 20);
b.writeUInt16LE(1, 22);
b.writeUInt32LE(8000, 24);
b.writeUInt32LE(16000, 28);
b.writeUInt16LE(2, 32);
b.writeUInt16LE(16, 34);
b.write('data', 36);
b.writeUInt32LE(n * 2, 40);
for (let i = 0; i < n; i++)
b.writeInt16LE(Math.round(Math.sin((2 * Math.PI * hz * i) / 8000) * 8000), 44 + i * 2);
return b;
};
const notice = await api('POST', '/api/sounds?name=Recording%20notice', undefined, tone(2, 700));
const desk = await api('POST', '/api/extensions', { number: '240', name: 'Rita Recorded' });
const pass = (await api('GET', `/api/extensions/${desk.id}/sip-credentials`)).password;
const trunk = await api('POST', '/api/trunks', {
name: 'M7 recording',
serverHost: '127.0.0.1',
username: 'm7rec',
password: 'unused-pw',
});
await api('POST', '/api/dids', {
number: DID,
trunkId: trunk.id,
label: 'Recorded line',
destination: { type: 'user', id: desk.id },
});
const phones = startPhones({
name: 'sp-live-phones-m7t3',
net: NET,
ast: AST,
phones: [{ ext: '240', password: pass, answer: 'auto' }],
});
const waitUntil = async (fn, what, ms = 30_000) => {
const end = Date.now() + ms;
while (Date.now() < end) {
if (await fn()) return;
await sleep(300);
}
throw new Error(`timeout waiting for: ${what}`);
};
const pickInDialog = (label, text) =>
p.evaluate(`(() => {
const s = [...document.querySelectorAll('select')].find(s => s.labels?.[0]?.textContent === ${JSON.stringify(label)});
const o = [...s.options].find(o => o.textContent.includes(${JSON.stringify(text)}));
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value').set.call(s, o.value);
s.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})()`);
const p = await launch({ shotsDir });
try {
await waitUntil(() => /240\/sip:.*Avail/.test(cli('pjsip show contacts')), 'phone', 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'`);
// Settings → Recording: the notice.
await p.goto(`${base}/admin/settings/recording`);
await p.waitFor(`document.body.innerText.includes('Recording consent')`);
await pickInDialog('Notice played before a recorded call', 'Recording notice');
await p.click('Save');
await p.waitFor(`document.body.innerText.includes('Saved.')`);
// Phone numbers → Record every call.
await p.click('Phone numbers');
await p.waitFor(`document.body.innerText.includes('Recorded line')`);
await p.evaluate(
`[...document.querySelectorAll('li')].find(li => li.innerText.includes('Recorded line')).querySelector('button').click(), true`,
);
await p.waitFor(`!!document.querySelector('dialog[open]')`);
await p.evaluate(
`(() => { const c = [...document.querySelectorAll('dialog[open] label')].find(l => l.textContent.includes('Record every call')).querySelector('input'); c.click(); return c.checked; })()`,
);
await p.click('Save', 'dialog[open]');
await p.waitFor(`!document.querySelector('dialog[open]')`, 'saved');
await waitUntil(
() => cli(`dialplan show ${DID}@sp-inbound`).includes('sp-sub-record'),
'recording in the dialplan',
);
console.log('UI: recording notice set, the phone number records');
// A caller from outside.
const at = astLog().length;
await originate({
Channel: `Local/${DID}@sp-from-trunk-${trunk.id}`,
CallerID: '"Outside" <3125550777>',
Application: 'Wait',
Data: '20',
});
await waitUntil(() => /Called PJSIP\/240\//.test(astLog().slice(at)), '240 rings', 20_000);
const log = astLog().slice(at);
assert.match(log, new RegExp(`Playing '${notice.asteriskName}\\.`), 'notice before the call');
assert.match(log, /MixMonitor\(/, 'recording started');
await sleep(6000);
cli('channel request hangup all');
// Found by the DID: the Originate's own Local;1 leg carries 240's name as connected-line
// caller ID and can sort first, so "from" isn't the outside caller in this synthetic setup.
await waitUntil(
async () => (await api('GET', `/api/calls?q=5558888`)).items[0]?.recordingId,
'call with its recording in history',
);
const call = (await api('GET', `/api/calls?q=5558888`)).items[0];
console.log(`call in history (${call.status}, ${call.talkSeconds} s) with a recording`);
// Call history: expand, the player's audio is a real recording.
await p.click('Call history');
await p.waitFor(
`[...document.querySelectorAll('ul[aria-label=Calls] > li')].some(li => li.innerText.includes('(312) 555-8888'))`,
);
await p.evaluate(
`[...document.querySelectorAll('ul[aria-label=Calls] > li')].find(li => li.innerText.includes('(312) 555-8888')).querySelector('button[aria-expanded]').click(), true`,
);
await p.waitFor(`!!document.querySelector('audio[aria-label="Play the recording"]')`, 'player');
const audio = await p.evaluate(`(async () => {
const r = await fetch(document.querySelector('audio[aria-label="Play the recording"]').src);
const b = new Uint8Array(await r.arrayBuffer());
return { status: r.status, type: r.headers.get('content-type'), riff: String.fromCharCode(...b.slice(0, 4)), bytes: b.length };
})()`);
assert.equal(audio.status, 200);
assert.equal(audio.riff, 'RIFF');
assert.ok(audio.bytes > 8000 * 2 * 3, `recording is ${audio.bytes} bytes`);
await p.shot('m7-t3-01-history');
console.log(`playable from history: ${audio.type}, ${audio.bytes} bytes`);
await p.click('Recordings');
await p.waitFor(`document.querySelectorAll('ul[aria-label=Recordings] > li').length >= 1`);
await p.shot('m7-t3-02-recordings');
console.log('listed on the Recordings page');
console.log('M7-T3 (recording): PASS');
} catch (e) {
await p.shot('m7-t3-fail').catch(() => {});
console.log(`FAIL: ${e.message}`);
console.log(`browser errors: ${JSON.stringify(p.consoleErrors)}`);
process.exitCode = 1;
} finally {
await p.close();
phones.stop();
}