softphone: People panel with presence, click-to-call
ci / images (push) Canceled after 0s
ci / check (push) Canceled after 4s

People lists every extension with its status dot and a search box: a
right-hand column on wide screens, /me/people on small ones. Call uses the
browser softphone when it's on, else POST /api/me/click-to-call, which
originates to the user's desk phone first (409 if none is registered).
During a call: Transfer (blind), Talk first (consult), or drag the call
card onto a person; a held call card offers Complete transfer whenever
another call is active. useSoftphone moved to its own module.

e2e m5-t3: the dot changes ~30 ms after Asterisk's hint; click-to-call
rings 174 before 172. cdp click() takes a scope (People also has
Call/Transfer buttons). trunk-test ignores the RTCP-at-teardown EPERM
error seen once.

Task: M5-T3
This commit is contained in:
iamdoubz committed 2026-10-18 10:06:28 -05:00
1 parent c7fd4df77c
commit 609965bba5
16 files changed
+447 -28

No files matched your search

+9
View File
@@ -86,6 +86,15 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
return admin ? { ...rest, contacts } : rest;
}
/** A desk phone or phone app (not the browser) is registered for this extension. */
deskOnline(number: string): boolean {
return (
this.exts
.get(number)
?.contacts.some((c) => c.endpoint === number && c.status !== 'unreachable') ?? false
);
}
private setAmi(connected: boolean) {
if (this.amiConnected === connected) return;
this.amiConnected = connected;
+29 -1
View File
@@ -16,7 +16,7 @@ import { decrypt } from '../crypto/secrets.ts';
import { getSetting } from '../services/settings.ts';
import { requireAuth } from '../auth/guards.ts';
import { extensions, voicemailMessages } from '../db/schema.ts';
import { badRequest, HttpError, notFound } from '../errors.ts';
import { badRequest, conflict, HttpError, notFound } from '../errors.ts';
import { AudioError, MAX_UPLOAD_BYTES, toPcmWav } from '../media/audio.ts';
import { toVoicemailDto, type watchVoicemail } from '../services/voicemail.ts';
@@ -29,6 +29,13 @@ declare module 'fastify' {
const IdParams = z.object({ id: z.uuid() });
const KindParams = z.object({ kind: z.enum(GREETINGS) });
const MoveBody = z.object({ folder: z.enum(['INBOX', 'Old']) });
/** Anything a person would type: "(312) 555-1234", "+44 20 …", "172". */
const ClickToCallBody = z.object({
number: z
.string()
.max(40)
.regex(/^[0-9*#+()\s.-]*[0-9*#][0-9*#+()\s.-]*$/, 'Enter a number'),
});
export async function meRoutes(app: FastifyInstance) {
const { db } = app;
@@ -95,6 +102,27 @@ export async function meRoutes(app: FastifyInstance) {
};
});
/** Rings the user's desk phone first; when they pick up, it dials `number` (04 §7.3). */
app.post('/api/me/click-to-call', async (req) => {
const { number } = ClickToCallBody.parse(req.body);
const ext = await myExtension(req.user!.id);
if (!app.ami.isConnected())
throw new HttpError(503, 'unavailable', 'Asterisk is not connected');
if (!app.live.deskOnline(ext.number))
throw conflict("Your desk phone isn't connected, so it can't ring");
const target = number.replace(/[^0-9*#+]/g, '');
await app.ami.action({
Action: 'Originate',
Channel: `PJSIP/${ext.number}`,
Context: 'sp-from-ext',
Exten: target,
Priority: '1',
CallerID: `"${target}" <${ext.number}>`,
Async: 'true',
});
return { ok: true };
});
app.get('/api/me/voicemail', async (req): Promise<ListResponse<VoicemailDto>> => {
const ext = await myExtension(req.user!.id);
const rows = await db
+5 -1
View File
@@ -471,7 +471,11 @@ async function main() {
// eslint-disable-next-line no-control-regex -- strips Asterisk's ANSI colors
const plainLog = docker('logs', PBX).out.replace(/\x1b\[[0-9;]*m/g, '');
const errors = plainLog.split('\n').filter((l) => /ERROR\[/.test(l));
// An RTCP report sent while a test container's network is going away fails with EPERM;
// that's teardown noise, not a config problem.
const errors = plainLog
.split('\n')
.filter((l) => /ERROR\[/.test(l) && !/RTCP SR transmission error/.test(l));
problems.push(...errors.map((l) => `pbx log: ${l.trim()}`));
} catch (e) {
problems.push((e as Error).message);
+19 -1
View File
@@ -77,7 +77,7 @@ describe.skipIf(!hasTestDb)('/api/me/voicemail and greetings', () => {
// Asterisk's side of VoicemailMove/VoicemailRemove, on disk (one message, msg0000).
t.ami.action = async function (f: Record<string, string>) {
this.actions.push(f);
const from = join(box(), f.Folder!, 'msg0000');
const from = join(box(), f.Folder ?? '', 'msg0000');
if (f.Action === 'VoicemailMove') {
mkdirSync(join(box(), f.ToFolder!), { recursive: true });
for (const x of ['wav', 'txt'])
@@ -144,6 +144,24 @@ describe.skipIf(!hasTestDb)('/api/me/voicemail and greetings', () => {
t.ami.connected = true;
});
it('click-to-call: rings my desk phone first, then dials the number', async () => {
expect((await dan('POST', '/api/me/click-to-call', { number: '172' })).statusCode).toBe(409);
t.live.deskOnline = (n) => n === '101';
const r = await dan('POST', '/api/me/click-to-call', { number: '(312) 555-1234' });
expect(r.statusCode).toBe(200);
expect(t.ami.actions.at(-1)).toEqual({
Action: 'Originate',
Channel: 'PJSIP/101',
Context: 'sp-from-ext',
Exten: '3125551234',
Priority: '1',
CallerID: '"3125551234" <101>',
Async: 'true',
});
expect((await dan('POST', '/api/me/click-to-call', { number: 'abc' })).statusCode).toBe(400);
expect((await eve('POST', '/api/me/click-to-call', { number: '172' })).statusCode).toBe(404);
});
it('webrtc: creds for <ext>-web with ICE servers; 404 without an extension', async () => {
await setSetting(t.db, 'network.stunServer', 'stun.example.org:3478');
const r = await dan('GET', '/api/me/webrtc');
+1
View File
@@ -147,6 +147,7 @@ export const systemApi = {
export const meApi = {
webrtc: () => api<WebRtcCreds>('GET', '/api/me/webrtc'),
clickToCall: (number: string) => api<{ ok: true }>('POST', '/api/me/click-to-call', { number }),
voicemail: () => api<ListResponse<VoicemailDto>>('GET', '/api/me/voicemail').then((r) => r.items),
audioUrl: (id: string, download = false) =>
`/api/me/voicemail/${id}/audio${download ? '?download=1' : ''}`,
+15 -1
View File
@@ -10,6 +10,7 @@ import { ThemeToggle } from '../../components/ThemeToggle.tsx';
import { Alert, Button } from '../../components/ui.tsx';
import { subscribeLive, useLive } from '../../live/store.ts';
import { SoftphoneDock } from '../../softphone/SoftphoneDock.tsx';
import { PresencePanel } from '../../softphone/PresencePanel.tsx';
import { VoicemailPage, callerOf, isNew, mmss } from './VoicemailPage.tsx';
const link = 'text-sky-700 underline dark:text-sky-400';
@@ -17,6 +18,7 @@ const link = 'text-sky-700 underline dark:text-sky-400';
export function MePage({ user }: { user: AuthUser }) {
const qc = useQueryClient();
const nav = useNavigate();
const me = user.extension?.number ?? null;
useEffect(subscribeLive, []);
const voicemailSeq = useLive((s) => s.voicemailSeq);
useEffect(() => {
@@ -26,7 +28,7 @@ export function MePage({ user }: { user: AuthUser }) {
const tab = ({ isActive }: { isActive: boolean }) =>
`rounded-md px-3 py-2 text-sm font-medium ${isActive ? 'bg-zinc-100 dark:bg-zinc-800' : 'hover:bg-zinc-100 dark:hover:bg-zinc-800'}`;
return (
<main className="mx-auto flex min-h-dvh max-w-3xl flex-col gap-6 p-4 sm:p-8">
<main className="mx-auto flex min-h-dvh max-w-6xl flex-col gap-6 p-4 sm:p-8">
<header className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-xl font-semibold">{APP_NAME}</h1>
<div className="flex items-center gap-2">
@@ -58,12 +60,24 @@ export function MePage({ user }: { user: AuthUser }) {
<NavLink to="/me/voicemail" className={tab}>
Voicemail
</NavLink>
{/* People is a right-hand column on wide screens, a tab on small ones (05 §4.1). */}
<NavLink to="/me/people" className={(s) => `${tab(s)} lg:hidden`}>
People
</NavLink>
</nav>
<div className="grid flex-1 gap-8 lg:grid-cols-[1fr_20rem]">
<div className="flex min-w-0 flex-col gap-6">
<Routes>
<Route index element={<Home user={user} />} />
<Route path="voicemail" element={<VoicemailPage />} />
<Route path="people" element={<PresencePanel me={me} />} />
</Routes>
<SoftphoneDock />
</div>
<aside className="hidden lg:block">
<PresencePanel me={me} />
</aside>
</div>
</main>
);
}
+114
View File
@@ -0,0 +1,114 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Presence panel (05 §4.1): everyone's status, search, click to call. During a call: transfer
// (blind), talk first (attended), or drag the call card onto a person (blind).
import { useState } from 'react';
import type { ExtensionLive } from '@selfphoneng/shared';
import { meApi } from '../api/client.ts';
import { StatusDot, statusLabel } from '../components/StatusDot.tsx';
import { Button, inputClass } from '../components/ui.tsx';
import { useLive } from '../live/store.ts';
import { useSoftphone } from './useSoftphone.ts';
import { CALL_DRAG, softphone } from './SoftphoneEngine.ts';
export function PresencePanel({ me }: { me: string | null }) {
const exts = useLive((s) => s.extensions);
const { reg, calls } = useSoftphone();
const [q, setQ] = useState('');
const [note, setNote] = useState<string | null>(null);
const active = calls.find((c) => c.state === 'active' && !c.held);
const query = q.trim().toLowerCase();
const people = Object.values(exts)
.filter((e) => e.number !== me)
.filter((e) => !query || e.name.toLowerCase().includes(query) || e.number.includes(query))
.sort((a, b) => Number(a.number) - Number(b.number));
const run = (p: Promise<unknown>, ok?: string) => {
setNote(null);
p.then(
() => ok && setNote(ok),
(e: Error) => setNote(e.message),
);
};
// The softphone when it's on; otherwise the desk phone rings first (click-to-call).
const call = (e: ExtensionLive) =>
reg === 'registered'
? run(softphone.call(e.number))
: run(
meApi.clickToCall(e.number),
`Your desk phone is ringing. Pick it up to call ${e.name}.`,
);
return (
<section aria-labelledby="people-h" className="flex flex-col gap-2">
<h2 id="people-h" className="text-lg font-semibold">
People
</h2>
<label htmlFor="people-q" className="sr-only">
Search people
</label>
<input
id="people-q"
className={inputClass}
placeholder="Search people"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
{note && (
<p role="status" className="text-sm text-zinc-600 dark:text-zinc-400">
{note}
</p>
)}
<ul className="flex flex-col">
{people.map((e) => (
<li
key={e.number}
data-number={e.number}
className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-900"
onDragOver={(ev) => {
if (active && ev.dataTransfer.types.includes(CALL_DRAG)) ev.preventDefault();
}}
onDrop={(ev) => {
const id = ev.dataTransfer.getData(CALL_DRAG);
if (id) run(softphone.blindTransfer(id, e.number));
}}
>
<StatusDot ext={e} />
<span className="min-w-0 flex-1 truncate text-sm">
<span className="font-medium">{e.name}</span>{' '}
<span className="text-zinc-500">
{e.number} · {statusLabel(e)}
</span>
</span>
{active ? (
<>
<Button
className="px-2 py-1"
aria-label={`Transfer to ${e.name}`}
onClick={() => run(softphone.blindTransfer(active.id, e.number))}
>
Transfer
</Button>
<Button
className="px-2 py-1"
aria-label={`Talk to ${e.name} first`}
onClick={() => run(softphone.call(e.number))}
>
Talk first
</Button>
</>
) : (
<Button
className="px-2 py-1"
aria-label={`Call ${e.name}`}
disabled={calls.length >= 2}
onClick={() => call(e)}
>
Call
</Button>
)}
</li>
))}
</ul>
</section>
);
}
+18 -5
View File
@@ -2,14 +2,15 @@
// Softphone on every /me page (05 §5.3): a bar at the bottom that opens a panel with the
// dialpad and audio devices; call cards with mute/hold/keypad/transfer; the incoming call dialog.
// Not yet: DND toggle (M6), Record via *1 and recent calls (M7).
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { useEffect, useRef, useState } from 'react';
import { formatNumber } from '@selfphoneng/shared';
import { meApi } from '../api/client.ts';
import { Dialog } from '../components/Dialog.tsx';
import { Button, inputClass } from '../components/ui.tsx';
import { useLive } from '../live/store.ts';
import { onTakeOver, stepDown, takeOver, tryLead } from './leader.ts';
import { softphone, type CallInfo } from './SoftphoneEngine.ts';
import { CALL_DRAG, softphone, type CallInfo } from './SoftphoneEngine.ts';
import { useSoftphone } from './useSoftphone.ts';
import { startRinging, stopRinging } from './sounds.ts';
const KEY = 'softphone.enabled';
@@ -29,8 +30,6 @@ const remember = (on: boolean) => {
}
};
export const useSoftphone = () => useSyncExternalStore(softphone.subscribe, () => softphone.state);
onTakeOver(async () => {
await softphone.stop();
softphone.setElsewhere();
@@ -290,7 +289,10 @@ function CallCard({
const [transfer, setTransfer] = useState(false);
const [target, setTarget] = useState('');
const [consultId, setConsultId] = useState<string | null>(null);
const consult = useSoftphone().calls.find((c) => c.id === consultId);
const all = useSoftphone().calls;
const consult = all.find((c) => c.id === consultId);
// Held here while talking on another call (call waiting, or "Talk first" from People).
const other = all.find((c) => c.id !== call.id && c.state === 'active' && !c.held);
const run = (p: Promise<unknown>) => {
onError(null);
p.catch((e: Error) => onError(e.message));
@@ -310,6 +312,9 @@ function CallCard({
role="group"
aria-label={`${label}: ${name}`}
className="flex flex-col gap-2 rounded-md bg-zinc-100 p-2 dark:bg-zinc-900"
// Drop it on a person in People to transfer it there.
draggable={call.state === 'active'}
onDragStart={(e) => e.dataTransfer.setData(CALL_DRAG, call.id)}
>
<div className="flex flex-wrap items-center gap-2">
<span className="flex-1 text-sm">
@@ -330,6 +335,14 @@ function CallCard({
</>
) : (
<>
{call.held && other && !consultId && (
<Button
variant="primary"
onClick={() => run(softphone.attendedTransfer(call.id, other.id))}
>
Complete transfer
</Button>
)}
{call.state === 'active' && (
<>
<Button
@@ -379,3 +379,6 @@ export class SoftphoneEngine {
/** One engine per tab. */
export const softphone = new SoftphoneEngine();
/** dataTransfer type for dragging a call card onto a person in People (blind transfer). */
export const CALL_DRAG = 'application/x-selfphoneng-call';
+6
View File
@@ -0,0 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// React view of the softphone engine (05 §5.1).
import { useSyncExternalStore } from 'react';
import { softphone } from './SoftphoneEngine.ts';
export const useSoftphone = () => useSyncExternalStore(softphone.subscribe, () => softphone.state);
+6
View File
@@ -106,6 +106,12 @@ Right column on wide screens, a tab on mobile. Lists every extension with its
StatusDot, name and number, plus a search box. Click to call, or to transfer when
in a call (drop the active call onto a person = blind transfer; a menu offers attended).
As built (M5-T3): the tab is `/me/people`, shown only below the `lg` breakpoint. **Call** uses the
browser softphone when it's registered, and otherwise click-to-call: the desk phone rings first,
or a 409 comes back if no desk phone is registered. During a call each person shows **Transfer**
(blind) and **Talk first** (a consult call; the first call is held). A held call card offers
**Complete transfer** whenever another call is active, which also covers call waiting.
## 5. Browser softphone (`src/softphone/`)
### 5.1 Structure
+5
View File
@@ -69,3 +69,8 @@ directions.
`phones.mjs` and checks the softphone features: the incoming call dialog, DTMF, hold (music on hold
at the other end), call waiting, blind and attended transfer, and "Use here" moving the softphone to
a second tab (`cdp.mjs` `newTab()`).
`m5-t3.mjs` (after `m5-t2.mjs`) is the M5-T3 acceptance. It watches People in one browser while 173
calls 172 and measures the time from Asterisk's hint changing to the dot changing (must be < 1 s).
Then a second browser, as a new user with desk phone 174 and the softphone off, clicks **Call** on
172: 174 must ring first (Asterisk channel order), then the two are connected.
+5 -2
View File
@@ -115,10 +115,13 @@ async function attach(wsUrl, { width, height, shotsDir }) {
return true;
})()`),
/** Clicks the first visible button/link whose text or aria-label matches. */
click: (text) =>
/** `within`: a CSS selector to search inside, when the same label appears elsewhere. */
click: (text, within = 'body') =>
evaluate(`(() => {
const t = ${JSON.stringify(text)};
const el = [...document.querySelectorAll('button, a')].reverse().find(e => (e.textContent.trim() === t || e.getAttribute('aria-label') === t) && e.getClientRects().length);
const root = document.querySelector(${JSON.stringify(within)});
if (!root) throw new Error('no ' + ${JSON.stringify(within)});
const el = [...root.querySelectorAll('button, a')].reverse().find(e => (e.textContent.trim() === t || e.getAttribute('aria-label') === t) && e.getClientRects().length);
if (!el) throw new Error('no visible button ' + t);
el.click();
return true;
+7 -5
View File
@@ -18,6 +18,8 @@ const AST = 'sp-live-ast';
const PHONE = 'sp-live-phone171';
const USER = { email: 'web@example.com', password: 'web-new-password-123' };
const SECONDS = 8;
/** The softphone's own buttons (People rows also have Call/Transfer). */
const SP = '[aria-label=Softphone]';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const docker = (...a) => {
const r = spawnSync('docker', a, { encoding: 'utf8' });
@@ -165,14 +167,14 @@ try {
await p.shot('m5-t1-01-ready');
// Browser → desk phone.
await p.click('Keypad');
await p.click('Keypad', SP);
await p.fill('Number to call', '171');
await p.click('Call');
await p.click('Call', SP);
await p.waitFor(`document.body.innerText.includes('On call')`, 'outgoing call answered', 15_000);
await sleep(SECONDS * 1000);
await p.shot('m5-t1-02-outgoing');
assertTwoWay('browser → desk');
await p.click('Hang up');
await p.click('Hang up', SP);
await p.waitFor(`!document.body.innerText.includes('On call')`, 'hung up');
await hangupAll();
@@ -180,11 +182,11 @@ try {
cli(`channel originate PJSIP/171 extension 170@sp-from-ext`);
await p.waitFor(`document.body.innerText.includes('Incoming call')`, 'incoming call', 15_000);
await p.shot('m5-t1-03-incoming');
await p.click('Answer');
await p.click('Answer', SP);
await p.waitFor(`document.body.innerText.includes('On call')`, 'incoming answered', 10_000);
await sleep(SECONDS * 1000);
assertTwoWay('desk → browser');
await p.click('Hang up');
await p.click('Hang up', SP);
await p.waitFor(`!document.body.innerText.includes('On call')`, 'hung up');
await hangupAll();
+14 -12
View File
@@ -12,6 +12,8 @@ import { startPhones } from './phones.mjs';
const NET = 'sp-live';
const AST = 'sp-live-ast';
const USER = { email: 'web@example.com', password: 'web-new-password-123' };
/** The softphone's own buttons (People rows also have Call/Transfer). */
const SP = '[aria-label=Softphone]';
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);
@@ -132,7 +134,7 @@ try {
console.log('incoming dialog → answered');
// 2. DTMF from the keypad reaches the other phone.
await p.click('Keypad');
await p.click('Keypad', SP);
await p.evaluate(
`[...document.querySelectorAll('[aria-label="Send tones"] button')].find(b => b.textContent === '5').click(), true`,
);
@@ -141,11 +143,11 @@ try {
// 3. Hold / resume: Asterisk plays music on hold to 172 while held.
let at = mark();
await p.click('Hold');
await p.click('Hold', SP);
await p.waitFor(`document.body.innerText.includes('On hold')`, 'held in the UI');
await waitUntil(() => /music on hold.*PJSIP\/172-/i.test(logSince(at)), 'MOH to 172', 5000);
at = mark();
await p.click('Resume');
await p.click('Resume', SP);
await waitUntil(
() => /Stopped music on hold.*PJSIP\/172-/i.test(logSince(at)),
'MOH stopped',
@@ -162,7 +164,7 @@ try {
15_000,
);
await p.shot('m5-t2-02-waiting');
await p.click('Answer');
await p.click('Answer', SP);
await waitUntil(() => /music on hold.*PJSIP\/172-/i.test(logSince(at)), '172 held', 8000);
await p.waitFor(
`document.body.innerText.includes('On hold') && document.body.innerText.includes('On call')`,
@@ -172,13 +174,13 @@ try {
await p.waitFor(`!document.body.innerText.includes('On call')`, 'calls gone in the UI');
// 5. Blind transfer: browser calls 172, transfers it to 173.
await p.click('Keypad');
await p.click('Keypad', SP);
await p.fill('Number to call', '172');
await p.click('Call');
await p.click('Call', SP);
await p.waitFor(`document.body.innerText.includes('On call')`, 'browser → 172 answered', 15_000);
await p.click('Transfer');
await p.click('Transfer', SP);
await p.fill('Transfer to', '173');
await p.click('Transfer now');
await p.click('Transfer now', SP);
await waitUntil(transferred, 'blind transfer: 172 ↔ 173');
await p.waitFor(`!document.body.innerText.includes('On call')`, 'browser left the call');
console.log('blind transfer: 172 now talks to 173');
@@ -186,18 +188,18 @@ try {
// 6. Attended transfer: browser calls 172, talks to 173 first, then completes.
await p.fill('Number to call', '172');
await p.click('Call');
await p.click('Call', SP);
await p.waitFor(`document.body.innerText.includes('On call')`, 'browser → 172 answered', 15_000);
await p.click('Transfer');
await p.click('Transfer', SP);
await p.fill('Transfer to', '173');
await p.click('Talk first');
await p.click('Talk first', SP);
await p.waitFor(
`[...document.querySelectorAll('button')].some(b => b.textContent === 'Complete transfer' && !b.disabled)`,
'consult call to 173 answered',
15_000,
);
await p.shot('m5-t2-03-consult');
await p.click('Complete transfer');
await p.click('Complete transfer', SP);
await waitUntil(transferred, 'attended transfer: 172 ↔ 173');
console.log('attended transfer: 172 now talks to 173');
await hangupAll();
+191
View File
@@ -0,0 +1,191 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M5-T3 acceptance: presence dots change in < 1 s after Asterisk's state changes, and
// click-to-call from People rings the user's desk phone first, then the other person.
// Run after m5-t2.mjs (extensions 172/173 and user web@example.com exist).
// Usage: node scripts/e2e/m5-t3.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { launch } from './cdp.mjs';
import { startPhones } from './phones.mjs';
const NET = 'sp-live';
const AST = 'sp-live-ast';
const WEB = { email: 'web@example.com', password: 'web-new-password-123' };
const CC = { email: 'cc@example.com', password: 'cc-new-password-123' };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const cli = (cmd) =>
spawnSync('docker', ['exec', AST, 'asterisk', '-rx', cmd], { encoding: 'utf8' }).stdout ?? '';
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) => {
const r = await fetch(`${base}${url}`, {
method,
headers: { '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 exts = (await api('GET', '/api/extensions')).items;
const secret = async (id) => (await api('GET', `/api/extensions/${id}/sip-credentials`)).password;
const cc = await api('POST', '/api/users', { email: CC.email, displayName: 'Cam Clicker' });
const ccExt = await api('POST', '/api/extensions', {
number: '174',
name: 'Cam Clicker',
userId: cc.user.id,
});
const byNumber = (n) => exts.find((e) => e.number === n);
const phones = startPhones({
name: 'sp-live-phones-m5t3',
net: NET,
ast: AST,
phones: [
{ ext: '172', password: await secret(byNumber('172').id), answer: 'auto' },
{ ext: '173', password: await secret(byNumber('173').id), answer: 'auto', toneHz: 660 },
{ ext: '174', password: await secret(ccExt.id), answer: 'auto', toneHz: 550 },
],
});
const channels = () =>
cli('core show channels concise')
.split('\n')
.filter((l) => l.startsWith('PJSIP/'))
.map((l) => {
const f = l.split('!');
return { name: f[0], state: f[4], bridge: f[12], seq: Number(f[13]?.split('.')[1]) };
});
const hangupAll = async () => {
cli('channel request hangup all');
for (let i = 0; i < 40 && channels().length; i++) await sleep(250);
await sleep(500);
};
async function signIn(p, who, temp) {
await p.goto(`${base}/login`);
await p.waitFor(`!!document.querySelector('input[type=email]')`);
await p.fill('Email', who.email);
await p.fill('Password', temp ?? who.password);
await p.click('Sign in');
if (temp) {
await p.waitFor(`location.pathname === '/change-password'`, 'forced password change');
await p.fill('Current password', temp);
await p.fill('New password', who.password);
await p.fill('Repeat new password', who.password);
await p.click('Change password');
}
await p.waitFor(`location.pathname === '/me'`, 'my phone');
}
const dotLabel = (p, n) =>
p.evaluate(`document.querySelector('aside li[data-number="${n}"] .sr-only')?.textContent ?? ''`);
const watcher = await launch({ shotsDir });
const clicker = await launch({ shotsDir });
try {
for (let i = 0; i < 180; i++) {
const c = cli('pjsip show contacts');
if (['172', '173', '174'].every((n) => new RegExp(`${n}/sip:.*Avail`).test(c))) break;
await sleep(1000);
}
const reg = cli('pjsip show contacts');
for (const n of ['172', '173', '174'])
assert.match(reg, new RegExp(`${n}/sip:.*Avail`), `${n} registered`);
await signIn(watcher, WEB);
await watcher.waitFor(`!!document.querySelector('aside li[data-number="172"]')`, 'People list');
await watcher.waitFor(
`document.querySelector('aside li[data-number="172"] .sr-only')?.textContent === 'Available'`,
'172 shown available',
10_000,
);
await watcher.shot('m5-t3-01-people');
// Presence: 173 calls 172. Poll Asterisk's hint and the dot side by side.
const hintChange = (async () => {
for (let i = 0; i < 200; i++) {
if (!/State:Idle/.test(cli('core show hint 172'))) return Date.now();
await sleep(20);
}
return null;
})();
const dotChange = (async () => {
for (let i = 0; i < 1000; i++) {
if ((await dotLabel(watcher, '172')) !== 'Available') return Date.now();
await sleep(10);
}
return null;
})();
cli('channel originate PJSIP/173 extension 172@sp-from-ext');
const [tAst, tUi] = await Promise.all([hintChange, dotChange]);
assert.ok(tAst && tUi, `state change seen (asterisk ${tAst}, ui ${tUi})`);
console.log(
`presence: dot changed ${tUi - tAst} ms after Asterisk's hint (now "${await dotLabel(watcher, '172')}")`,
);
assert.ok(tUi - tAst < 1000, 'presence dot within 1 s');
await watcher.waitFor(
`document.querySelector('aside li[data-number="172"] .sr-only')?.textContent === 'On a call'`,
'172 on a call',
);
await watcher.shot('m5-t3-02-on-a-call');
await hangupAll();
await watcher.waitFor(
`document.querySelector('aside li[data-number="172"] .sr-only')?.textContent === 'Available'`,
'172 available again',
);
console.log('presence: back to Available after hang-up');
// Click-to-call: a user without the browser softphone; the desk phone (174) rings first.
await signIn(clicker, CC, cc.temporaryPassword);
await clicker.waitFor(`!!document.querySelector('aside li[data-number="172"]')`, 'People list');
await clicker.click('Call Pat Desk');
// Asterisk numbers channels in creation order (the part of the uniqueid after the dot).
const created = {};
for (let i = 0; i < 100 && Object.keys(created).length < 2; i++) {
for (const c of channels()) {
const n = /^PJSIP\/(\d+)-/.exec(c.name)?.[1];
if (n && !(n in created)) created[n] = c.seq;
}
await sleep(50);
}
assert.ok(created['174'] < created['172'], `desk phone rang first: ${JSON.stringify(created)}`);
await clicker.waitFor(
`document.body.innerText.includes('Your desk phone is ringing')`,
'tells the user to pick up',
);
for (let i = 0; i < 40; i++) {
const c = channels();
const a = c.find((x) => x.name.startsWith('PJSIP/174-'));
const b = c.find((x) => x.name.startsWith('PJSIP/172-'));
if (a?.bridge && a.bridge === b?.bridge) break;
await sleep(250);
}
const c = channels();
assert.equal(
c.find((x) => x.name.startsWith('PJSIP/174-'))?.bridge,
c.find((x) => x.name.startsWith('PJSIP/172-'))?.bridge,
'174 and 172 talk',
);
console.log('click-to-call: 174 (desk) rang first, then 172; connected');
await clicker.shot('m5-t3-03-click-to-call');
await hangupAll();
console.log('M5-T3 (presence, click-to-call): PASS');
} catch (e) {
await watcher.shot('m5-t3-fail-watcher').catch(() => {});
await clicker.shot('m5-t3-fail-clicker').catch(() => {});
console.log(`FAIL: ${e.message}`);
console.log(cli('pjsip show contacts'));
console.log(cli('core show channels concise'));
process.exitCode = 1;
} finally {
await watcher.close();
await clicker.close();
phones.stop();
}