Owner wanted the first user on 1. Numbering ranges now choose the Next free suggestion (Settings → Numbering), the setup wizard takes a number per person, and a one-digit number can't also be a menu key of a digital receptionist with direct dialing. Dialplan patterns that take an internal number match one digit (_X!); golden case short-numbers proves it in Asterisk.
178 lines
6.7 KiB
JavaScript
178 lines
6.7 KiB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// Asterisk load test (03 §12.3): boot the asterisk image with each golden case's generated files and
|
|
// fail if Asterisk logs ERROR while loading, or if an endpoint/context we generated is missing.
|
|
// Needs Docker and a built image (ASTERISK_IMAGE, default "selfphoneng-asterisk").
|
|
// Usage: pnpm test:asterisk [case ...]
|
|
import { spawnSync } from 'node:child_process';
|
|
import {
|
|
cpSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
rmSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const GOLDEN = fileURLToPath(new URL('../../src/generator/__golden__', import.meta.url));
|
|
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
|
const NAME = `sp-golden-${process.pid}`;
|
|
|
|
const docker = (...args) => {
|
|
const r = spawnSync('docker', args, { encoding: 'utf8' });
|
|
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
|
|
};
|
|
const cli = (cmd) => docker('exec', NAME, 'asterisk', '-rx', cmd);
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
/** What the generated files promise Asterisk will have. */
|
|
function expectations(dir) {
|
|
const pjsip = readFileSync(join(dir, 'pjsip_selfphoneng.conf'), 'utf8');
|
|
const endpoints = [...pjsip.matchAll(/^\[([^\]]+)\]\ntype = endpoint$/gm)].map((m) => m[1]);
|
|
const ext = readFileSync(join(dir, 'extensions_selfphoneng.conf'), 'utf8');
|
|
const contexts = [...ext.matchAll(/^\[([^\]]+)\]$/gm)].map((m) => m[1]);
|
|
const vm = readFileSync(join(dir, 'voicemail_selfphoneng.conf'), 'utf8');
|
|
const mailboxes = [...vm.matchAll(/^(\d+) =>/gm)].map((m) => m[1]);
|
|
// queue number → how many agents it lists
|
|
const queues = readFileSync(join(dir, 'queues_selfphoneng.conf'), 'utf8')
|
|
.split(/^\[/m)
|
|
.slice(1)
|
|
.map((sec) => [sec.slice(0, sec.indexOf(']')), (sec.match(/^member =>/gm) ?? []).length]);
|
|
return { endpoints, contexts, mailboxes, queues };
|
|
}
|
|
|
|
async function check(name) {
|
|
const tmp = mkdtempSync(join(tmpdir(), `sp-golden-${name}-`));
|
|
cpSync(join(GOLDEN, name, 'expected'), tmp, { recursive: true });
|
|
const problems = [];
|
|
// The api makes each MOH class's folder when the class is created (routes/moh.ts); do the same.
|
|
const moh = mkdtempSync(join(tmpdir(), `sp-golden-moh-`));
|
|
const mohConf = readFileSync(join(tmp, 'musiconhold_selfphoneng.conf'), 'utf8');
|
|
for (const [, d] of mohConf.matchAll(/^directory = \/var\/lib\/asterisk\/moh\/(.+)$/gm))
|
|
mkdirSync(join(moh, d));
|
|
// transport-tls needs a certificate, or Asterisk quietly leaves it out: make a throwaway one.
|
|
const tls = mkdtempSync(join(tmpdir(), `sp-golden-tls-`));
|
|
const wantTls = /^\[transport-tls\]$/m.test(
|
|
readFileSync(join(tmp, 'pjsip_selfphoneng.conf'), 'utf8'),
|
|
);
|
|
if (wantTls) {
|
|
const cert = docker(
|
|
'run',
|
|
'--rm',
|
|
'-v',
|
|
`${tls}:/out`,
|
|
'--entrypoint',
|
|
'openssl',
|
|
IMAGE,
|
|
'req',
|
|
'-x509',
|
|
'-newkey',
|
|
'rsa:2048',
|
|
'-nodes',
|
|
'-days',
|
|
'1',
|
|
'-subj',
|
|
'/CN=golden.test',
|
|
'-keyout',
|
|
'/out/privkey.pem',
|
|
'-out',
|
|
'/out/fullchain.pem',
|
|
);
|
|
if (!cert.ok) throw new Error(`making a test certificate failed: ${cert.out}`);
|
|
docker(
|
|
'run',
|
|
'--rm',
|
|
'-v',
|
|
`${tls}:/out`,
|
|
'--entrypoint',
|
|
'chmod',
|
|
IMAGE,
|
|
'644',
|
|
'/out/privkey.pem',
|
|
);
|
|
}
|
|
const run = docker(
|
|
'run',
|
|
'-d',
|
|
'--rm',
|
|
'--name',
|
|
NAME,
|
|
'-e',
|
|
'AMI_SECRET=golden-test-secret',
|
|
'-v',
|
|
`${tmp}:/etc/asterisk/selfphoneng`,
|
|
'-v',
|
|
`${moh}:/var/lib/asterisk/moh`,
|
|
'-v',
|
|
`${tls}:/var/lib/selfphoneng/tls:ro`,
|
|
IMAGE,
|
|
);
|
|
if (!run.ok) throw new Error(`docker run failed: ${run.out}`);
|
|
try {
|
|
let booted = false;
|
|
for (let i = 0; i < 60 && !booted; i++) {
|
|
await sleep(500);
|
|
booted = cli('core waitfullybooted').ok;
|
|
}
|
|
if (!booted) problems.push('Asterisk did not finish booting');
|
|
await sleep(500);
|
|
|
|
const { endpoints, contexts, mailboxes, queues } = expectations(tmp);
|
|
if (wantTls && !/transport-tls\s+tls/.test(cli('pjsip show transports').out))
|
|
problems.push('transport-tls not loaded');
|
|
const shown = cli('pjsip show endpoints').out;
|
|
for (const e of endpoints)
|
|
if (!new RegExp(`Endpoint:\\s+${e}[\\s/]`).test(shown))
|
|
problems.push(`endpoint ${e} not loaded`);
|
|
for (const c of contexts) {
|
|
const d = cli(`dialplan show ${c}`).out;
|
|
if (!d.includes(`'${c}'`)) problems.push(`context ${c} not loaded`);
|
|
}
|
|
const users = cli('voicemail show users').out;
|
|
for (const m of mailboxes)
|
|
if (!new RegExp(`^default\\s+${m}\\s`, 'm').test(users))
|
|
problems.push(`mailbox ${m} not loaded`);
|
|
for (const [q, members] of queues) {
|
|
const shownQ = cli(`queue show ${q}`).out;
|
|
const listed = (shownQ.match(/Local\/\d+@sp-queue-member/g) ?? []).length;
|
|
if (!shownQ.startsWith(`${q} has`) || listed !== members)
|
|
problems.push(`queue ${q}: expected ${members} members, got: ${shownQ.trim()}`);
|
|
}
|
|
// A case can list [number, context] pairs Asterisk must match (short-numbers: one-digit
|
|
// numbers, which a pattern like _X. needs two digits for).
|
|
const snap = JSON.parse(readFileSync(join(GOLDEN, name, 'snapshot.json'), 'utf8'));
|
|
for (const [exten, ctx] of snap.asteriskMatches ?? []) {
|
|
const d = cli(`dialplan show ${exten}@${ctx}`).out;
|
|
if (!/-= \d+ extensions? \(/.test(d) || /There is no existence/.test(d))
|
|
problems.push(`${exten} doesn't match in ${ctx}`);
|
|
}
|
|
// Asterisk colors its log: strip the escape codes before looking for ERROR[.
|
|
// eslint-disable-next-line no-control-regex -- matching ANSI escapes is the point
|
|
const log = docker('logs', NAME).out.replace(/\x1b\[[0-9;]*m/g, '');
|
|
const errors = log.split('\n').filter((l) => /ERROR\[/.test(l));
|
|
problems.push(...errors.map((l) => `log: ${l.trim()}`));
|
|
return { name, problems, endpoints: endpoints.length, contexts: contexts.length };
|
|
} finally {
|
|
docker('rm', '-f', NAME);
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(moh, { recursive: true, force: true });
|
|
rmSync(tls, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
const cases = process.argv.slice(2).length
|
|
? process.argv.slice(2)
|
|
: readdirSync(GOLDEN).filter((d) => existsSync(join(GOLDEN, d, 'expected')));
|
|
let failed = 0;
|
|
for (const c of cases) {
|
|
const r = await check(c);
|
|
const status = r.problems.length ? 'FAIL' : 'ok ';
|
|
console.log(`${status} ${c} (${r.endpoints} endpoints, ${r.contexts} contexts)`);
|
|
for (const p of r.problems) console.log(` ${p}`);
|
|
if (r.problems.length) failed++;
|
|
}
|
|
process.exit(failed ? 1 : 0);
|