/api/sounds (admin) takes the raw file, converts it with ffmpeg in one pass to <id>.wav (8 kHz) and <id>.g722 (HD for G.722 calls) in SOUNDS_DIR, measures it with ffprobe, and stores it as custom/<id>. Delete answers 409 with what still plays it (IVR greeting/invalid sound, announcements, the recording notice). Sounds & music page: drop or pick a file (name suggested from the file name), play, rename, delete. MOH classes stay in M8. The web client and greetings share one raw-upload helper; the api test audio helpers moved to test/audio.ts. e2e m6-t1: drag-and-drop upload in the browser, Asterisk plays custom/<id>, rename, delete refused while in use, then deleted. Task: M6-T1
126 lines
5.6 KiB
JavaScript
126 lines
5.6 KiB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// M6-T1 in a browser: drop an audio file on Sounds & music, upload it, play it, rename it, see
|
|
// Asterisk play custom/<id>, and delete it (refused while it's the recording notice).
|
|
// Runs on the m2-live setup with a sounds dir (m2-live.mjs up <confDir> <vmDir> <soundsDir>) and
|
|
// the api started with SOUNDS_DIR=<soundsDir>, after m1-t4.mjs.
|
|
// Usage: node scripts/e2e/m6-t1.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 docker = (...a) => spawnSync('docker', a, { encoding: 'utf8' }).stdout ?? '';
|
|
const cli = (cmd) => docker('exec', AST, 'asterisk', '-rx', cmd);
|
|
const [base, email, pw, shotsDir] = process.argv.slice(2);
|
|
|
|
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'`);
|
|
await p.click('Sounds & music');
|
|
await p.waitFor(`document.body.innerText.includes('No sounds yet')`, 'empty list');
|
|
|
|
// A 2 s 440 Hz tone as a wav file, dropped on the upload area like a user would.
|
|
await p.evaluate(`(() => {
|
|
const rate = 8000, n = rate * 2, 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 * 440 * i) / rate) * 8000, true);
|
|
const dt = new DataTransfer();
|
|
dt.items.add(new File([buf], 'main_greeting.wav', { type: 'audio/wav' }));
|
|
const form = document.querySelector('form[aria-label="Upload a sound"]');
|
|
form.dispatchEvent(new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true }));
|
|
return true;
|
|
})()`);
|
|
await p.waitFor(`document.body.innerText.includes('Selected: main_greeting.wav')`, 'file picked');
|
|
assert.equal(
|
|
await p.evaluate(
|
|
`[...document.querySelectorAll('label')].find(l => l.textContent === 'Name').control.value`,
|
|
),
|
|
'main greeting',
|
|
'name suggested from the file name',
|
|
);
|
|
await p.fill('Name', 'Main greeting');
|
|
await p.click('Upload');
|
|
await p.waitFor(
|
|
`!!document.querySelector('audio[aria-label="Play Main greeting"]')`,
|
|
'uploaded',
|
|
15_000,
|
|
);
|
|
assert.match(await p.text(), /Main greeting 0:02/);
|
|
await p.shot('m6-t1-01-uploaded');
|
|
console.log('uploaded by drag and drop: "Main greeting" 0:02');
|
|
|
|
const sound = (await p.evaluate(`fetch('/api/sounds').then(r => r.json())`)).items[0];
|
|
const status = await p.evaluate(`fetch('/api/sounds/${sound.id}/audio').then(r => r.status)`);
|
|
assert.equal(status, 200, 'audio plays');
|
|
|
|
// Asterisk plays it: a Local channel answers with Playback(custom/<id>).
|
|
const before = docker('logs', AST).length;
|
|
cli(`channel originate Local/99@sp-vm-direct application Playback ${sound.asteriskName}`);
|
|
let played = '';
|
|
for (let i = 0; i < 20 && !played; i++) {
|
|
await sleep(250);
|
|
played = docker('logs', AST).slice(before);
|
|
}
|
|
assert.match(
|
|
played,
|
|
new RegExp(`Playing '${sound.asteriskName}\\.`),
|
|
'Asterisk played custom/<id>',
|
|
);
|
|
assert.doesNotMatch(played, /Unable to open|not found/i);
|
|
console.log(`Asterisk played ${sound.asteriskName}`);
|
|
|
|
await p.click('Rename');
|
|
await p.waitFor(`!!document.querySelector('dialog[open]')`);
|
|
await p.fill('Name', 'Welcome', 'dialog[open]');
|
|
await p.evaluate(
|
|
`[...document.querySelectorAll('dialog[open] button')].find(b => b.textContent === 'Save').click(), true`,
|
|
);
|
|
await p.waitFor(`!!document.querySelector('audio[aria-label="Play Welcome"]')`, 'renamed');
|
|
console.log('renamed to "Welcome"');
|
|
|
|
// In use as the recording notice → the delete is refused with the reason.
|
|
await p.evaluate(
|
|
`fetch('/api/settings', { method: 'PATCH', headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' }, body: JSON.stringify({ 'recordings.announcementSoundId': '${sound.id}' }) }).then(r => r.status)`,
|
|
);
|
|
await p.click('Delete');
|
|
await p.evaluate(
|
|
`[...document.querySelectorAll('dialog[open] button')].find(b => b.textContent === 'Delete').click(), true`,
|
|
);
|
|
await p.waitFor(
|
|
`document.body.innerText.includes('still used by the call recording notice')`,
|
|
'refused',
|
|
);
|
|
await p.shot('m6-t1-02-in-use');
|
|
console.log('delete refused while it is the recording notice');
|
|
await p.evaluate(
|
|
`fetch('/api/settings', { method: 'PATCH', headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' }, body: JSON.stringify({ 'recordings.announcementSoundId': null }) }).then(r => r.status)`,
|
|
);
|
|
await p.evaluate(
|
|
`[...document.querySelectorAll('dialog[open] button')].find(b => b.textContent === 'Delete').click(), true`,
|
|
);
|
|
await p.waitFor(`document.body.innerText.includes('No sounds yet')`, 'deleted');
|
|
console.log('deleted');
|
|
assert.deepEqual(
|
|
p.consoleErrors.filter((e) => !/409/.test(e)),
|
|
[],
|
|
'no console errors',
|
|
);
|
|
console.log('M6-T1 (sounds): PASS');
|
|
} catch (e) {
|
|
await p.shot('m6-t1-fail').catch(() => {});
|
|
console.log(`FAIL: ${e.message}`);
|
|
process.exitCode = 1;
|
|
} finally {
|
|
await p.close();
|
|
}
|