diff --git a/docs/spikes/719-webrtc-coep-turn-audio-report.md b/docs/spikes/719-webrtc-coep-turn-audio-report.md new file mode 100644 index 00000000..2c7fd83b --- /dev/null +++ b/docs/spikes/719-webrtc-coep-turn-audio-report.md @@ -0,0 +1,92 @@ +# Spike #719 (ISP-049): WebRTC / COOP+COEP / background kill / dispatch audio + +Status: **partial**. This report records what was checked against the code, one +experiment, and one fix. The device-lab work the issue asks for is not done (see +"Not done"). + +## 1. Claims in the issue, checked against the repository + +Every row below was checked with `grep` or by reading the file, at commit `f87269c`. + +| Claim in the issue | What the repository shows | +|---|---| +| `server/middleware/csp.ts` emits `COOP: same-origin` + `COEP: credentialless` | **No.** `csp.ts` sets neither. The headers are set in `vite.config.ts` (`server.headers` and `preview.headers`), and `test/coep-headers-spike.test.js` asserts they also appear in `server/index.ts`. | +| `webrtc.ts` allocates TURN at `turn:turn.helphone.org:3478` with credentials from `server/routes/turn-credentials` | **No.** `src/lib/webrtc.ts` is 27 lines: a one-shot ICE benchmark using STUN only (`stun.l.google.com`, `stun.cloudflare.com`). There is no `turn:` URL, no credentials route (the path does not exist) and no coturn config anywhere in the repo. | +| `iceCandidatePoolSize: 10` | The only value in the repo is `2` (in that benchmark). | +| `iceConnectionState` flips to `disconnected` with no resurrection | `iceConnectionState` is not used anywhere in `src/` or `server/`. There is no long-lived peer connection to lose. | +| COEP blocks TURN allocation, so calls fall back to host/STUN | **Not supported by the experiment in section 2** (UDP relay connects under COEP). | +| Reconnect needs jittered backoff | Already implemented: `src/lib/wsClient.ts` does exponential backoff with up to 50% jitter. | +| Web Push wake path | Not present (no `pushManager`, VAPID or `push` handler). Not built here. | +| Battery-aware policy | Partly present: `src/services/networkTransport.js` already reads `navigator.getBattery`. No NAT-type policy table exists. | +| `test/audio-alert.test.js` is green | **It was failing on `main`.** vitest 4 cannot `new` an arrow-function mock, so both tests threw "is not a constructor". Fixed in this PR. | +| `audioAlert.ts` has no autoplay unlock | It has `unlock()`, but nothing ever called it, and `play()` could hang forever (section 3). | + +## 2. Experiment: does COOP/COEP block TURN relay in a real browser? + +`scripts/spikes/webrtc_coep_turn_experiment.mjs` starts a real TURN server +(`node-turn`, loopback) and serves a page under three header sets. In Chrome it +opens two `RTCPeerConnection`s with `iceTransportPolicy: 'relay'`, so a data +channel can open only if the TURN allocation succeeds and traffic is relayed. + +Run on Chrome 155.0.8059.80 (macOS), 10 runs per row. Raw data: +`docs/spikes/results/webrtc-coep-turn.json`. + +| Headers | TURN transport | crossOriginIsolated | Connected | Via relay pair | Median connect | +|---|---|---|---|---|---| +| none (control) | UDP | false | 10/10 | 10/10 | 167 ms | +| COOP same-origin + COEP credentialless (the repo's config) | UDP | true | **10/10** | 10/10 | 252 ms | +| COOP same-origin + COEP require-corp | UDP | true | **10/10** | 10/10 | 169 ms | +| all three | TCP | – | 0/10 | 0/10 | – | + +**Reading the result.** + +- Over UDP, cross-origin isolation did **not** stop relay allocation or media + flow, including with the repo's exact headers. This matches how the browser + works: COEP/CORP govern HTTP resource loads, and ICE/TURN traffic is not an + HTTP fetch, so a TURN 401 has no `CORP` header to be missing. +- The **TCP rows are inconclusive, not evidence of a COEP effect**. They fail + identically in the no-headers control, so the cause is this local TURN setup + (`node-turn` over TCP with Chrome), not the headers. +- If production calls fail to connect, the cause is more likely elsewhere: + no TURN server is configured at all today (section 1), or the credential + `fetch()` (which *is* an HTTP request and *would* be subject to COEP/CORS if it + were cross-origin) is what fails. Keeping that endpoint same-origin avoids it. + +**Limits.** One machine, one browser, loopback only. This shows what the +headers do to relay allocation. It does not emulate carrier NATs, Safari/Firefox, +or `turns:` (TLS). + +## 3. Fix: `EmergencyAudioAlert` could hang silently when autoplay is blocked + +`play()` awaited `AudioContext.resume()`. When the browser's autoplay policy +blocks audio, that promise stays pending forever (it neither resolves nor +rejects), so a siren triggered without a recent user gesture hung, made no sound, +and recorded nothing. `unlock()` existed but was never called. + +Changes (`src/lib/audioAlert.ts`, `src/lib/telemetry.ts`, `src/pages/Help.tsx`): + +- `play()` waits at most `RESUME_TIMEOUT_MS` (1 s). If the context is still not + running it vibrates (`[500, 250, 500]`, best effort) and returns `'blocked'` + instead of hanging; otherwise it returns `'played'`. +- `unlock()` now reports whether the context is running. +- `armOnFirstGesture()` unlocks audio on the first user gesture so a later + gesture-less alert can sound. `Help.tsx` arms it on mount. +- `trackAudioAlert('played' | 'blocked', { fallback })` emits an `audio.alert` + span, so the blocked rate is `blocked / (played + blocked)`. `getStats()` exposes + the same counts locally. +- The "Test siren" button now says when sound is blocked instead of claiming it played. + +Tests: `test/audio-alert.test.js` (10 tests; 9 fail on the old code). + +## 4. Not done + +Needs a real TURN deployment, devices or product decisions, so not attempted: + +- TURN credential proxy / per-call ephemeral credentials (there is no TURN server to proxy yet). +- Web Push wake path (VAPID keys, `server/routes/push`, service-worker `push` handler, permission UX). +- NAT-type × battery × network policy table and the Moto G measurements behind it. +- Playwright `webrtc-nat` and `background-kill` projects; background-kill drills on locked phones. +- `ice*` / `turn401` / `wsReaped` / `pushDelivered` metrics, dashboards and alerts in `server/middleware/metrics.ts`. +- NAT-mix and background-churn peers in `server/tests/load.js`. +- Rejoin allowlist in `rateLimiter.ts`; reap timing in `keepAlive`; `crashRecovery.ts` (not examined). +- The ops runbook in `docs/webrtc-architecture.md`. diff --git a/docs/spikes/results/webrtc-coep-turn.json b/docs/spikes/results/webrtc-coep-turn.json new file mode 100644 index 00000000..046a093d --- /dev/null +++ b/docs/spikes/results/webrtc-coep-turn.json @@ -0,0 +1,168 @@ +{ + "generatedAt": "2026-10-20T08:33:03.502Z", + "browser": "155.0.8059.80", + "turn": "node-turn on 127.0.0.1 (UDP+TCP), long-term credentials", + "runsPerVariant": 10, + "timeoutMs": 8000, + "variants": [ + { + "name": "control: no isolation headers", + "transport": "udp", + "headers": {}, + "crossOriginIsolated": false, + "connected": "10/10", + "viaRelayPair": "10/10", + "medianMsToConnect": 167, + "consoleErrors": [], + "sample": { + "connected": true, + "msToConnect": 100, + "iceConnectionState": { + "a": "connected", + "b": "connected" + }, + "candidateTypes": [ + "relay" + ], + "selectedPair": { + "local": "relay", + "remote": "relay" + }, + "crossOriginIsolated": false, + "consoleErrors": [] + } + }, + { + "name": "repo config: COOP same-origin + COEP credentialless", + "transport": "udp", + "headers": { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "credentialless" + }, + "crossOriginIsolated": true, + "connected": "10/10", + "viaRelayPair": "10/10", + "medianMsToConnect": 252, + "consoleErrors": [], + "sample": { + "connected": true, + "msToConnect": 303, + "iceConnectionState": { + "a": "connected", + "b": "connected" + }, + "candidateTypes": [ + "relay" + ], + "selectedPair": { + "local": "relay", + "remote": "relay" + }, + "crossOriginIsolated": true, + "consoleErrors": [] + } + }, + { + "name": "strict: COOP same-origin + COEP require-corp", + "transport": "udp", + "headers": { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp" + }, + "crossOriginIsolated": true, + "connected": "10/10", + "viaRelayPair": "10/10", + "medianMsToConnect": 169, + "consoleErrors": [], + "sample": { + "connected": true, + "msToConnect": 145, + "iceConnectionState": { + "a": "connected", + "b": "connected" + }, + "candidateTypes": [ + "relay" + ], + "selectedPair": { + "local": "relay", + "remote": "relay" + }, + "crossOriginIsolated": true, + "consoleErrors": [] + } + }, + { + "name": "control: no isolation headers", + "transport": "tcp", + "headers": {}, + "crossOriginIsolated": false, + "connected": "0/10", + "viaRelayPair": "0/10", + "medianMsToConnect": null, + "consoleErrors": [], + "sample": { + "connected": false, + "msToConnect": null, + "iceConnectionState": { + "a": "new", + "b": "new" + }, + "candidateTypes": [], + "selectedPair": null, + "crossOriginIsolated": false, + "consoleErrors": [] + } + }, + { + "name": "repo config: COOP same-origin + COEP credentialless", + "transport": "tcp", + "headers": { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "credentialless" + }, + "crossOriginIsolated": true, + "connected": "0/10", + "viaRelayPair": "0/10", + "medianMsToConnect": null, + "consoleErrors": [], + "sample": { + "connected": false, + "msToConnect": null, + "iceConnectionState": { + "a": "new", + "b": "new" + }, + "candidateTypes": [], + "selectedPair": null, + "crossOriginIsolated": true, + "consoleErrors": [] + } + }, + { + "name": "strict: COOP same-origin + COEP require-corp", + "transport": "tcp", + "headers": { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp" + }, + "crossOriginIsolated": true, + "connected": "0/10", + "viaRelayPair": "0/10", + "medianMsToConnect": null, + "consoleErrors": [], + "sample": { + "connected": false, + "msToConnect": null, + "iceConnectionState": { + "a": "new", + "b": "new" + }, + "candidateTypes": [], + "selectedPair": null, + "crossOriginIsolated": true, + "consoleErrors": [] + } + } + ] +} diff --git a/scripts/spikes/webrtc_coep_turn_experiment.mjs b/scripts/spikes/webrtc_coep_turn_experiment.mjs new file mode 100644 index 00000000..b647e29f --- /dev/null +++ b/scripts/spikes/webrtc_coep_turn_experiment.mjs @@ -0,0 +1,233 @@ +#!/usr/bin/env node +/** + * Spike ISP-049 (#719) — does cross-origin isolation block WebRTC TURN relay? + * + * The issue claims COOP `same-origin` + COEP `credentialless` make Chromium + * refuse TURN allocation, forcing host/STUN fallback that fails on symmetric + * NAT. This script tests that claim directly. It starts a real TURN server + * (node-turn) on loopback, serves a page under each header set, and in a real + * Chrome opens two RTCPeerConnections with `iceTransportPolicy: 'relay'`, so + * a data channel can only open if the TURN allocation succeeds and traffic is + * relayed. It reports whether it connected and which candidate pair carried it. + * + * It repeats each header set over TURN/UDP and TURN/TCP. + * + * Usage: + * npm i --no-save node-turn # test-only dependency, not in package.json + * node scripts/spikes/webrtc_coep_turn_experiment.mjs + * [--runs 5] [--timeout 10000] + * [--executable /path/to/chrome] (or CHROMIUM_PATH env) + * [--headed] + * [--out docs/spikes/results/webrtc-coep-turn.json] + * + * Browser: uses --executable / CHROMIUM_PATH if given, else the system Chrome + * (Playwright channel "chrome"), else Playwright's bundled Chromium. + * + * Limits: one machine, loopback only. It proves what COOP/COEP do to relay + * allocation in the browser. It does NOT emulate carrier NATs. + */ + +import http from "node:http"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { chromium } from "playwright-core"; + +const args = process.argv.slice(2); +const opt = (name, fallback) => { + const i = args.indexOf(`--${name}`); + return i >= 0 && args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : fallback; +}; +const RUNS = Number(opt("runs", 5)); +const TIMEOUT_MS = Number(opt("timeout", 10_000)); +const EXECUTABLE = opt("executable", process.env.CHROMIUM_PATH); +const HEADED = args.includes("--headed"); +const OUT = opt("out", null); + +const TURN_PORT = 34780; +const TURN_USER = "helphone"; +const TURN_PASS = "spike-secret"; + +let Turn; +try { + ({ default: Turn } = await import("node-turn")); +} catch { + console.error("node-turn is not installed. Run: npm i --no-save node-turn"); + process.exit(2); +} + +const TRANSPORTS = ["udp", "tcp"]; +const VARIANTS = [ + { name: "control: no isolation headers", headers: {} }, + { + name: "repo config: COOP same-origin + COEP credentialless", + headers: { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "credentialless", + }, + }, + { + name: "strict: COOP same-origin + COEP require-corp", + headers: { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", + }, + }, +]; + +const PAGE = `webrtc coep spike +`; + +function startPageServer() { + const server = http.createServer((req, res) => { + if (req.url.startsWith("/favicon.ico")) { res.writeHead(204).end(); return; } + const m = /^\/v(\d+)$/.exec(req.url.split("?")[0]); + const variant = m && VARIANTS[Number(m[1])]; + if (!variant) { res.writeHead(404).end(); return; } + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", ...variant.headers }); + res.end(PAGE); + }); + return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(server))); +} + +const turn = new Turn({ + authMech: "long-term", + credentials: { [TURN_USER]: TURN_PASS }, + listeningPort: TURN_PORT, + listeningIps: ["127.0.0.1"], + relayIps: ["127.0.0.1"], + debugLevel: "OFF", +}); +turn.start(); + +const pageServer = await startPageServer(); +const origin = `http://127.0.0.1:${pageServer.address().port}`; + +const launchOptions = { + headless: !HEADED, + args: ["--allow-loopback-in-peer-connection", "--disable-features=WebRtcHideLocalIpsWithMdns"], +}; +let browser; +try { + if (EXECUTABLE) browser = await chromium.launch({ ...launchOptions, executablePath: EXECUTABLE }); + else { + try { browser = await chromium.launch({ ...launchOptions, channel: "chrome" }); } + catch { browser = await chromium.launch(launchOptions); } + } +} catch (err) { + console.error(`Could not launch a browser: ${err.message}\nPass --executable /path/to/chrome or set CHROMIUM_PATH.`); + turn.stop?.(); pageServer.close(); + process.exit(2); +} + +const report = { + generatedAt: new Date().toISOString(), + browser: browser.version(), + turn: "node-turn on 127.0.0.1 (UDP+TCP), long-term credentials", + runsPerVariant: RUNS, + timeoutMs: TIMEOUT_MS, + variants: [], +}; + +for (const transport of TRANSPORTS) for (let v = 0; v < VARIANTS.length; v++) { + const variant = VARIANTS[v]; + const runs = []; + for (let i = 0; i < RUNS; i++) { + const page = await browser.newPage(); + const consoleErrors = []; + page.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); }); + await page.goto(`${origin}/v${v}`); + const result = await page.evaluate( + (p) => window.run(p), + { turnUrl: `turn:127.0.0.1:${TURN_PORT}?transport=${transport}`, user: TURN_USER, pass: TURN_PASS, timeoutMs: TIMEOUT_MS }, + ); + runs.push({ ...result, consoleErrors }); + await page.close(); + } + const connected = runs.filter((r) => r.connected).length; + const relayed = runs.filter((r) => r.selectedPair && (r.selectedPair.local === "relay" || r.selectedPair.remote === "relay")).length; + report.variants.push({ + name: variant.name, + transport, + headers: variant.headers, + crossOriginIsolated: runs.every((r) => r.crossOriginIsolated), + connected: `${connected}/${RUNS}`, + viaRelayPair: `${relayed}/${RUNS}`, + medianMsToConnect: median(runs.filter((r) => r.connected).map((r) => r.msToConnect)), + consoleErrors: [...new Set(runs.flatMap((r) => r.consoleErrors))], + sample: runs[0], + }); +} + +function median(xs) { + if (!xs.length) return null; + const s = [...xs].sort((a, b) => a - b); + return s[Math.floor(s.length / 2)]; +} + +await browser.close(); +pageServer.close(); +turn.stop?.(); + +console.log(`\nBrowser: ${report.browser} (relay-only peer connection through a local TURN server)\n`); +console.table(report.variants.map((v) => ({ + variant: v.name, + transport: v.transport, + crossOriginIsolated: v.crossOriginIsolated, + connected: v.connected, + "via relay pair": v.viaRelayPair, + "median ms": v.medianMsToConnect, + "console errors": v.consoleErrors.length, +}))); +for (const v of report.variants) { + if (v.consoleErrors.length) console.log(`${v.name} (${v.transport})\n console errors: ${JSON.stringify(v.consoleErrors)}`); +} + +if (OUT) { + mkdirSync(dirname(OUT), { recursive: true }); + writeFileSync(OUT, JSON.stringify(report, null, 2) + "\n"); + console.log(`\nWrote ${OUT}`); +} +process.exit(0); diff --git a/src/lib/audioAlert.ts b/src/lib/audioAlert.ts index ffa592b3..68ed9a62 100644 --- a/src/lib/audioAlert.ts +++ b/src/lib/audioAlert.ts @@ -1,12 +1,54 @@ +import { trackAudioAlert } from './telemetry' + export type AlertPosition = { distanceKm?: number bearingDegrees?: number } +/** `blocked` means the browser kept audio suspended (autoplay policy). */ +export type AlertOutcome = 'played' | 'blocked' + +/** How long to wait for AudioContext.resume() before treating audio as blocked. */ +export const RESUME_TIMEOUT_MS = 1_000 + +/** Played instead of the siren when audio is blocked (best-effort; needs a supporting device). */ +export const BLOCKED_VIBRATION_PATTERN = [500, 250, 500] + +// Input events the browser accepts as a "user activation" for audio. +const ACTIVATION_EVENTS = ['pointerdown', 'pointerup', 'touchend', 'keydown'] as const + +/** + * Resolves to the promise's value, or `undefined` if it has not settled after + * `ms`. AudioContext.resume() stays pending forever (it neither resolves nor + * rejects) when the autoplay policy blocks it, so it cannot be awaited bare. + */ +function settleWithin(promise: Promise, ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(undefined), ms) + promise.then( + (value) => { clearTimeout(timer); resolve(value) }, + () => { clearTimeout(timer); resolve(undefined) } + ) + }) +} + type AudioContextConstructor = typeof AudioContext export class EmergencyAudioAlert { private context: AudioContext | null = null + private stats = { played: 0, blocked: 0 } + + constructor(private readonly resumeTimeoutMs: number = RESUME_TIMEOUT_MS) {} + + /** Alerts that sounded vs. were blocked by the autoplay policy (ISP-049). */ + getStats(): { played: number; blocked: number } { + return { ...this.stats } + } + + /** True once the audio context is running, i.e. a siren can sound without a gesture. */ + get isUnlocked(): boolean { + return this.context?.state === 'running' + } private getAudioContext(): AudioContext { if (this.context) return this.context @@ -17,9 +59,15 @@ export class EmergencyAudioAlert { return this.context } - async play(position: AlertPosition = {}): Promise { + /** + * Sound the siren. If the browser keeps audio suspended (no user gesture yet, + * e.g. a dispatch arriving while the page was never touched) this does not + * hang: it gives up after `resumeTimeoutMs`, vibrates instead and returns + * `'blocked'` so the caller can show a visible alert. + */ + async play(position: AlertPosition = {}): Promise { const context = this.getAudioContext() - if (context.state === 'suspended') await context.resume() + if (!(await this.ensureRunning(context))) return this.handleBlocked() const distance = Math.max(0, position.distanceKm ?? 0) const attenuation = Math.max(0.2, 1 / (1 + distance / 5)) @@ -49,11 +97,51 @@ export class EmergencyAudioAlert { panner.disconnect() gain.disconnect() } + + this.stats.played += 1 + trackAudioAlert('played') + return 'played' } - async unlock(): Promise { - const context = this.getAudioContext() - if (context.state === 'suspended') await context.resume() + /** Resume audio from a user gesture. Resolves to whether the context is now running. */ + async unlock(): Promise { + return this.ensureRunning(this.getAudioContext()) + } + + /** + * Unlock audio on the first user gesture anywhere on the page, so an alert + * that arrives later with no gesture can still sound. Stays armed until a + * gesture actually succeeds. Returns a function that disarms it (for effect cleanup). + */ + armOnFirstGesture(target: Document | HTMLElement = document): () => void { + const disarm = () => { + for (const type of ACTIVATION_EVENTS) target.removeEventListener(type, onGesture) + } + const onGesture = () => { + this.unlock().then((running) => { if (running) disarm() }, () => undefined) + } + for (const type of ACTIVATION_EVENTS) target.addEventListener(type, onGesture) + return disarm + } + + private async ensureRunning(context: AudioContext): Promise { + if (context.state === 'running') return true + await settleWithin(context.resume(), this.resumeTimeoutMs) + return context.state === 'running' + } + + private handleBlocked(): AlertOutcome { + this.stats.blocked += 1 + let vibrated = false + try { + vibrated = typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function' + ? navigator.vibrate(BLOCKED_VIBRATION_PATTERN) + : false + } catch { + // Vibration is a best-effort fallback only. + } + trackAudioAlert('blocked', { fallback: vibrated ? 'vibrate' : 'none' }) + return 'blocked' } async close(): Promise { diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 542e93f6..b9e5803a 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -84,6 +84,32 @@ export function trackSubscriptionTransport( } } +/** + * Track whether an emergency audio alert was actually audible (ISP-049). + * `blocked` means the browser's autoplay policy kept the AudioContext + * suspended, so the siren could not sound; `fallback` records what was done + * instead. blocked / (played + blocked) is the "audio-blocked rate". + */ +export function trackAudioAlert( + outcome: 'played' | 'blocked', + details: { fallback?: 'vibrate' | 'none' } = {} +): void { + const span = tracer.startSpan('audio.alert') + + try { + span.setAttributes({ + 'audio.outcome': outcome, + 'audio.fallback': details.fallback ?? 'none', + }) + + if (outcome === 'blocked') { + span.setStatus({ code: SpanStatusCode.ERROR, message: 'autoplay blocked' }) + } + } finally { + span.end() + } +} + /** * Track video performance metrics (Issue #745) */ diff --git a/src/pages/Help.tsx b/src/pages/Help.tsx index be179750..43b57a64 100644 --- a/src/pages/Help.tsx +++ b/src/pages/Help.tsx @@ -168,10 +168,18 @@ export default function Help() { JSON.stringify({ contact, medicalNotes, nickname: '' }) ).length const overBudget = plaintextBytes > MAX_PAYLOAD_PLAINTEXT_BYTES + // ISP-049: the first tap anywhere unlocks audio, so a later alert that has no + // gesture of its own can sound instead of being silently blocked. + useEffect(() => emergencyAudioAlert.armOnFirstGesture(), []) + const handleTestSiren = async () => { try { - await emergencyAudioAlert.play() - setStatusMessage('Emergency siren preview played.') + const outcome = await emergencyAudioAlert.play() + setStatusMessage( + outcome === 'played' + ? 'Emergency siren preview played.' + : 'Your browser is blocking sound. Tap the page once, then try again; vibration was used instead.' + ) } catch (err: any) { setStatusMessage(err.message || 'Unable to play the emergency siren.') } diff --git a/test/audio-alert.test.js b/test/audio-alert.test.js index e8f8ce86..d251e590 100644 --- a/test/audio-alert.test.js +++ b/test/audio-alert.test.js @@ -1,5 +1,13 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { EmergencyAudioAlert } from '../src/lib/audioAlert.ts' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const trackAudioAlert = vi.fn() +vi.mock('../src/lib/telemetry.ts', () => ({ trackAudioAlert: (...a) => trackAudioAlert(...a) })) + +import { + BLOCKED_VIBRATION_PATTERN, + EmergencyAudioAlert, + RESUME_TIMEOUT_MS, +} from '../src/lib/audioAlert.ts' function makeAudioContext() { const param = { setValueAtTime: vi.fn(), linearRampToValueAtTime: vi.fn() } @@ -14,6 +22,12 @@ function makeAudioContext() { } } +// vitest 4 mocks must be `function`s to be callable with `new`; an arrow +// function (as this file used before) throws "is not a constructor". +function installable(context) { + return vi.fn(function AudioContext() { return context }) +} + describe('EmergencyAudioAlert', () => { const previousAudioContext = window.AudioContext afterEach(() => { @@ -23,7 +37,7 @@ describe('EmergencyAudioAlert', () => { it('synthesizes an alert and pans according to bearing', async () => { const context = makeAudioContext() - window.AudioContext = vi.fn(() => context) + window.AudioContext = installable(context) const alert = new EmergencyAudioAlert() await alert.play({ distanceKm: 5, bearingDegrees: 90 }) @@ -36,11 +50,131 @@ describe('EmergencyAudioAlert', () => { it('resumes a suspended context from the user gesture path', async () => { const context = makeAudioContext() context.state = 'suspended' - window.AudioContext = vi.fn(() => context) + context.resume = vi.fn(async () => { context.state = 'running' }) + window.AudioContext = installable(context) const alert = new EmergencyAudioAlert() - await alert.unlock() + await expect(alert.unlock()).resolves.toBe(true) expect(context.resume).toHaveBeenCalledOnce() + expect(alert.isUnlocked).toBe(true) + }) +}) + +describe('EmergencyAudioAlert autoplay-blocked handling (ISP-049)', () => { + const previousAudioContext = window.AudioContext + const previousVibrate = navigator.vibrate + let context + + beforeEach(() => { + vi.useFakeTimers() + trackAudioAlert.mockReset() + context = makeAudioContext() + context.state = 'suspended' + window.AudioContext = installable(context) + navigator.vibrate = vi.fn(() => true) + }) + + afterEach(() => { + vi.useRealTimers() + window.AudioContext = previousAudioContext + navigator.vibrate = previousVibrate + }) + + it('does not hang when resume() never settles: gives up, vibrates and reports blocked', async () => { + context.resume = vi.fn(() => new Promise(() => {})) // what Chromium does with no gesture + const alert = new EmergencyAudioAlert() + + const pending = alert.play() + await vi.advanceTimersByTimeAsync(RESUME_TIMEOUT_MS) + + await expect(pending).resolves.toBe('blocked') + expect(context.createOscillator).not.toHaveBeenCalled() + expect(navigator.vibrate).toHaveBeenCalledWith(BLOCKED_VIBRATION_PATTERN) + expect(alert.getStats()).toEqual({ played: 0, blocked: 1 }) + expect(trackAudioAlert).toHaveBeenCalledWith('blocked', { fallback: 'vibrate' }) + }) + + it('reports blocked when resume() settles but the context stays suspended', async () => { + context.resume = vi.fn(async () => {}) + const alert = new EmergencyAudioAlert() + + await expect(alert.play()).resolves.toBe('blocked') + expect(trackAudioAlert).toHaveBeenCalledWith('blocked', { fallback: 'vibrate' }) + }) + + it('reports blocked when resume() rejects', async () => { + context.resume = vi.fn(async () => { throw new Error('NotAllowedError') }) + const alert = new EmergencyAudioAlert() + + await expect(alert.play()).resolves.toBe('blocked') + }) + + it('records fallback "none" on devices without vibration', async () => { + context.resume = vi.fn(async () => {}) + navigator.vibrate = undefined + const alert = new EmergencyAudioAlert() + + await alert.play() + + expect(trackAudioAlert).toHaveBeenCalledWith('blocked', { fallback: 'none' }) + }) + + it('plays normally, without vibrating, when resume() succeeds', async () => { + context.resume = vi.fn(async () => { context.state = 'running' }) + const alert = new EmergencyAudioAlert() + + await expect(alert.play()).resolves.toBe('played') + expect(context.createOscillator).toHaveBeenCalledOnce() + expect(navigator.vibrate).not.toHaveBeenCalled() + expect(alert.getStats()).toEqual({ played: 1, blocked: 0 }) + expect(trackAudioAlert).toHaveBeenCalledWith('played') + }) + + it('a gesture unlocks audio so a later alert needs no resume', async () => { + context.resume = vi.fn(async () => { context.state = 'running' }) + const alert = new EmergencyAudioAlert() + const disarm = alert.armOnFirstGesture(document) + + document.dispatchEvent(new Event('pointerdown')) + await vi.advanceTimersByTimeAsync(0) + expect(alert.isUnlocked).toBe(true) + + document.dispatchEvent(new Event('keydown')) + await vi.advanceTimersByTimeAsync(0) + expect(context.resume).toHaveBeenCalledOnce() // listeners were removed after success + + context.resume.mockClear() + await expect(alert.play()).resolves.toBe('played') + expect(context.resume).not.toHaveBeenCalled() + disarm() + }) + + it('stays armed until a gesture succeeds', async () => { + let allow = false + context.resume = vi.fn(async () => { if (allow) context.state = 'running' }) + const alert = new EmergencyAudioAlert() + alert.armOnFirstGesture(document) + + document.dispatchEvent(new Event('pointerdown')) + await vi.advanceTimersByTimeAsync(RESUME_TIMEOUT_MS) + expect(alert.isUnlocked).toBe(false) + + allow = true + document.dispatchEvent(new Event('pointerup')) + await vi.advanceTimersByTimeAsync(0) + expect(alert.isUnlocked).toBe(true) + }) + + it('the returned disposer removes the listeners', async () => { + context.resume = vi.fn(async () => { context.state = 'running' }) + const alert = new EmergencyAudioAlert() + const disarm = alert.armOnFirstGesture(document) + + disarm() + document.dispatchEvent(new Event('pointerdown')) + await vi.advanceTimersByTimeAsync(0) + + expect(context.resume).not.toHaveBeenCalled() }) })