Files
selfphoneng/scripts/e2e/m8-t5.mjs
T
iamdoubz b8ff6e3386
ci / check (push) Successful in 51s
ci / images (push) Successful in 18s
moh: music-on-hold classes, uploads, pickers for users and queues
/api/moh-classes: create (slug, not "default"), delete (409 while an
extension or queue uses it), add/play/remove files. Files are 8 kHz
wav in MOH_DIR/<class>/ and sound_files rows of kind moh. Extensions
and queues may only pick default or an existing class.

Two things found with real calls, written back into 03 and 04:
- An empty class plays silence on Asterisk 24. Only classes with
  music are rendered, so a channel asking for an empty one gets
  default.
- The generator reloads only changed files, and a file upload changes
  no config (except a class's first/last). The route sends moh reload
  after every file change.

Web: a Music on hold section on Sounds & music, and a select of sets
in the user drawer's Phone tab and the queue form. load-golden makes
the class folders like the api does. Golden case queue gains two
classes. e2e m8-t5: set made in the UI, upload listed by Asterisk, a
queue caller hears it, a user's moh_suggest, delete refused while
used, an emptied set falls back to default.

Task: M8-T5
2026-10-19 10:10:01 -05:00

183 lines
7.3 KiB
JavaScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// M8-T5 with real calls: a music-on-hold set "sales-jazz" made on Sounds & music, a tone file
// uploaded into it. Asterisk lists the class with that file; a queue using it plays it to a
// waiting caller; a user using it gets moh_suggest (what the people they hold hear). An empty
// set falls back to the default music. Deleting the set is refused while the queue uses it.
// Runs on m2-live with a moh dir, after m1-t4.mjs.
// Usage: node scripts/e2e/m8-t5.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { launch } from './cdp.mjs';
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, ok = true) => {
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();
if (ok) assert.ok(r.ok, `${method} ${url}: ${r.status} ${JSON.stringify(json)}`);
return { status: r.status, 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}`);
};
/** A caller waiting in queue `q` (no agents) for 20 s. */
const caller = (q) => cli(`channel originate Local/${q}@sp-from-ext application Wait 20`);
const p = await launch({ shotsDir });
try {
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'`);
// The set and its music, in the UI.
await p.click('Sounds & music');
await p.waitFor(`document.body.innerText.includes('Music on hold')`);
await p.fill('New set', 'Sales Jazz');
await p.click('Add set');
await p.waitFor(
`document.body.innerText.includes('Lowercase letters, digits and dashes')`,
'bad name refused',
);
await p.fill('New set', 'sales-jazz');
await p.click('Add set');
await p.waitFor(
`!!document.querySelector('[aria-label="Music on hold sales-jazz"]')`,
'set created',
);
// A 3 s 523 Hz tone picked in the set's file input.
await p.evaluate(`(() => {
const rate = 8000, n = rate * 3, buf = new ArrayBuffer(44 + n * 2), v = new DataView(buf);
const s = (o, t) => [...t].forEach((c, i) => v.setUint8(o + i, c.charCodeAt(0)));
s(0, 'RIFF'); v.setUint32(4, 36 + n * 2, true); s(8, 'WAVEfmt '); v.setUint32(16, 16, true);
v.setUint16(20, 1, true); v.setUint16(22, 1, true); v.setUint32(24, rate, true);
v.setUint32(28, rate * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
s(36, 'data'); v.setUint32(40, n * 2, true);
for (let i = 0; i < n; i++) v.setInt16(44 + i * 2, Math.sin((2 * Math.PI * 523 * i) / rate) * 8000, true);
const dt = new DataTransfer();
dt.items.add(new File([buf], 'smooth_jazz.wav', { type: 'audio/wav' }));
const input = document.querySelector('[aria-label="Music on hold sales-jazz"] input[type=file]');
input.files = dt.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})()`);
await p.click('Upload', '[aria-label="Music on hold sales-jazz"]');
await p.waitFor(
`!!document.querySelector('audio[aria-label="Play smooth jazz"]')`,
'uploaded',
15_000,
);
assert.match(await p.text(), /smooth jazz 0:03/);
await p.shot('m8-t5-01-moh');
console.log('set sales-jazz made in the UI (a bad name refused), "smooth jazz" 0:03 uploaded');
// Asterisk has the class with that file.
await waitUntil(
() =>
/Class: sales-jazz\n\tFile: \/var\/lib\/asterisk\/moh\/sales-jazz\//.test(
cli('moh show files'),
),
'moh show files lists the upload',
);
console.log('Asterisk: class sales-jazz plays the uploaded file');
// A queue with it plays it to a waiting caller.
const sets = (await api('GET', '/api/moh-classes')).json.items;
const jazz = sets.find((c) => c.name === 'sales-jazz');
const queue = (
await api('POST', '/api/queues', {
number: '560',
name: 'Jazz line',
mohClass: 'sales-jazz',
joinWhenNoAgents: true,
timeoutDestination: { type: 'hangup' },
})
).json;
await waitUntil(() => /musicclass|560 has/.test(cli('queue show 560')), 'queue 560 loaded');
let at = astLog().length;
caller('560');
await waitUntil(
() => /Started music on hold, class 'sales-jazz'/.test(astLog().slice(at)),
'caller hears sales-jazz',
);
cli('channel request hangup all');
console.log('queue 560: the waiting caller hears sales-jazz');
// A user with it: what the people they put on hold hear.
const ext = (await api('POST', '/api/extensions', { number: '330', name: 'Hal Hold' })).json;
assert.equal(
(await api('PATCH', `/api/extensions/${ext.id}`, { mohClass: 'nope' }, false)).status,
400,
);
await api('PATCH', `/api/extensions/${ext.id}`, { mohClass: 'sales-jazz' });
await waitUntil(
() => /moh_suggest\s*:\s*sales-jazz/.test(cli('pjsip show endpoint 330')),
'endpoint 330 suggests sales-jazz',
);
console.log('user 330: moh_suggest sales-jazz (an unknown set was refused)');
// In use: can't delete. Emptied: the caller hears the default music.
const del = await api('DELETE', `/api/moh-classes/${jazz.id}`, undefined, false);
assert.equal(del.status, 409);
assert.match(del.json.error.message, /extension 330.*queue Jazz line|queue Jazz line/);
await api('DELETE', `/api/moh-classes/${jazz.id}/files/${jazz.files[0].id}`);
await waitUntil(
() =>
!/Class: sales-jazz\n\tFile: \/var\/lib\/asterisk\/moh\/sales-jazz\//.test(
cli('moh show files'),
),
'emptied',
);
at = astLog().length;
caller('560');
await waitUntil(
() => /Started music on hold, class 'default'/.test(astLog().slice(at)),
'empty set: default music',
);
cli('channel request hangup all');
console.log('delete refused while used; emptied set: the caller hears the default music');
await api('DELETE', `/api/queues/${queue.id}`);
console.log('M8-T5 (music on hold): PASS');
} catch (e) {
await p.shot('m8-t5-fail').catch(() => {});
console.log(`FAIL: ${e.message}`);
console.log(`moh: ${cli('moh show classes')}`);
console.log(`browser errors: ${JSON.stringify(p.consoleErrors)}`);
process.exitCode = 1;
} finally {
cli('channel request hangup all');
await p.close();
}