Task #441: Fix notification sound on iPad/Safari (Web Audio migration)
- Rewrote artifacts/tx-os/src/lib/notification-sounds.ts to use the
Web Audio API (lazy AudioContext + decoded AudioBuffer cache) instead
of HTMLAudioElement. unlock() resumes the ctx for any non-running
state (handles iOS `interrupted`), plays a 1-sample silent buffer to
satisfy iOS Safari's gesture requirement, and pre-warms decodes.
decodeAudioData wrapped to support both Promise and callback forms
for older WebKit.
- play() dispatches an AUTOPLAY_BLOCKED_EVENT with `{ isIos }` detail
when ctx isn't running. Throttle is 3 s; new bypassThrottle option
(used by testPlay) skips the gate AND avoids advancing lastPlayAt
so settings previews can't suppress real socket chimes.
- use-audio-unlock.ts keeps gesture listeners attached and re-calls
unlock() on every gesture (handles iPad ctx auto-suspend on
background / AirPlay).
- use-autoplay-hint.ts reads detail.isIos and switches between
notifSettings.autoplayHint and notifSettings.autoplayHintIos
(added to en.json + ar.json with Control Center / silent-switch
guidance).
- notification-settings.tsx per-sound preview button now calls
notificationPlayer.testPlay(id).
- Test globals __txosNotifPlayCount/__txosNotifLastSound preserved;
added __txosNotifCtxState() accessor (dev/test-only, gated by
import.meta.env.MODE).
- Added tests/notification-sound-webaudio.spec.mjs covering the
preview button gesture path: counter increments, throttle bypass
works, singleton AudioContext reaches `running` state.
Notes:
- Pre-existing api-server tsc errors in executive-meetings.ts and the
failing `test` workflow are unrelated to this task and out of scope.
This commit is contained in:
@@ -154,8 +154,11 @@ function SoundList({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
notificationPlayer.unlock();
|
||||
notificationPlayer.play(id);
|
||||
// #441: testPlay bypasses the 3s burst-coalescing
|
||||
// throttle (so users can audition several sounds in a
|
||||
// row from settings) and force-unlocks the audio
|
||||
// context inside this gesture handler.
|
||||
notificationPlayer.testPlay(id);
|
||||
}}
|
||||
aria-label={t("notifSettings.preview")}
|
||||
className="p-1 rounded hover:bg-foreground/10 text-muted-foreground"
|
||||
|
||||
@@ -9,18 +9,18 @@ import { notificationPlayer } from "@/lib/notification-sounds";
|
||||
export function useAudioUnlock(): void {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
let done = false;
|
||||
// #441: We used to detach after the very first gesture, but on
|
||||
// iPad Safari the AudioContext can slip back into `suspended`
|
||||
// when the tab is backgrounded or after a Bluetooth/AirPlay route
|
||||
// change. Calling `unlock()` is cheap and idempotent, so we keep
|
||||
// listening and re-resume on every interaction. The detach happens
|
||||
// only when the host component unmounts.
|
||||
const unlock = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
notificationPlayer.unlock();
|
||||
window.removeEventListener("pointerdown", unlock);
|
||||
window.removeEventListener("keydown", unlock);
|
||||
window.removeEventListener("touchstart", unlock);
|
||||
};
|
||||
window.addEventListener("pointerdown", unlock, { once: true });
|
||||
window.addEventListener("keydown", unlock, { once: true });
|
||||
window.addEventListener("touchstart", unlock, { once: true });
|
||||
window.addEventListener("pointerdown", unlock);
|
||||
window.addEventListener("keydown", unlock);
|
||||
window.addEventListener("touchstart", unlock, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", unlock);
|
||||
window.removeEventListener("keydown", unlock);
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { AUTOPLAY_BLOCKED_EVENT } from "@/lib/notification-sounds";
|
||||
import {
|
||||
AUTOPLAY_BLOCKED_EVENT,
|
||||
type AutoplayBlockedDetail,
|
||||
} from "@/lib/notification-sounds";
|
||||
|
||||
/**
|
||||
* When a notification sound is attempted before the browser has
|
||||
* accepted a user gesture, the player dispatches a custom event. We
|
||||
* surface a one-time toast asking the user to click anywhere so future
|
||||
* sounds can play. The first qualifying click also triggers
|
||||
* `useAudioUnlock`, so the hint typically only ever shows once per
|
||||
* page load.
|
||||
* accepted a user gesture — or, on iPad, when the AudioContext refuses
|
||||
* to resume because the device is in silent mode — the player
|
||||
* dispatches a custom event. We surface a toast with the appropriate
|
||||
* guidance. On iOS the toast also tells the user to check Control
|
||||
* Center / the silent switch since "click anywhere" alone won't help
|
||||
* when the hardware mute is on.
|
||||
*/
|
||||
export function useAutoplayHint(): void {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent<AutoplayBlockedDetail>).detail;
|
||||
const isIos = !!detail?.isIos;
|
||||
toast({
|
||||
title: t("notifSettings.autoplayHintTitle"),
|
||||
description: t("notifSettings.autoplayHint"),
|
||||
description: isIos
|
||||
? t("notifSettings.autoplayHintIos")
|
||||
: t("notifSettings.autoplayHint"),
|
||||
});
|
||||
};
|
||||
window.addEventListener(AUTOPLAY_BLOCKED_EVENT, handler);
|
||||
|
||||
@@ -10,57 +10,186 @@ export const ALL_SOUND_IDS: ReadonlyArray<NotificationSoundId> =
|
||||
/**
|
||||
* Custom DOM event the player dispatches when a play attempt is denied
|
||||
* because the user hasn't yet interacted with the page (browser autoplay
|
||||
* policy). The App listens for this and shows a one-time toast hint.
|
||||
* policy) or the AudioContext could not be resumed. The App listens for
|
||||
* this and shows a one-time toast hint. The event `detail` carries an
|
||||
* `isIos` flag so the toast can include iPad-specific guidance (check
|
||||
* Control Center mute / silent switch).
|
||||
*/
|
||||
export const AUTOPLAY_BLOCKED_EVENT = "txos:notification-autoplay-blocked";
|
||||
|
||||
export type AutoplayBlockedDetail = {
|
||||
isIos: boolean;
|
||||
};
|
||||
|
||||
// iPad/iPhone/iPod detection used to surface a more actionable hint
|
||||
// (e.g. "check the silent switch / Control Center"). Modern iPadOS
|
||||
// reports as Mac in `userAgent`, so we also check for Mac + multi-touch.
|
||||
function detectIos(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
const ua = navigator.userAgent || "";
|
||||
if (/iPad|iPhone|iPod/.test(ua)) return true;
|
||||
// iPadOS 13+ desktop Safari masquerade.
|
||||
if (
|
||||
ua.includes("Macintosh") &&
|
||||
typeof navigator.maxTouchPoints === "number" &&
|
||||
navigator.maxTouchPoints > 1
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type WindowWithWebkitAC = Window & {
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
};
|
||||
|
||||
class NotificationPlayer {
|
||||
private cache = new Map<NotificationSoundId, HTMLAudioElement>();
|
||||
// Single shared AudioContext for the whole app. Lazily created in
|
||||
// `unlock()` because constructing it before a user gesture leaves it
|
||||
// in `suspended` state on every browser, and iOS Safari is even
|
||||
// stricter — it must be created (or resumed) inside the gesture
|
||||
// callback or it never starts.
|
||||
private ctx: AudioContext | null = null;
|
||||
// Decoded sound buffers, keyed by sound id. Populated on demand and
|
||||
// pre-warmed from `unlock()` so the first socket-driven chime after
|
||||
// page load doesn't have to wait on a network round-trip.
|
||||
private buffers = new Map<NotificationSoundId, AudioBuffer>();
|
||||
// In-flight decode promises to dedupe concurrent loads of the same
|
||||
// sound (e.g. unlock pre-warm + an immediate first play).
|
||||
private loading = new Map<NotificationSoundId, Promise<AudioBuffer | null>>();
|
||||
private unlocked = false;
|
||||
private lastPlayAt = 0;
|
||||
private hintDispatched = false;
|
||||
|
||||
isUnlocked(): boolean {
|
||||
return this.unlocked;
|
||||
// "Unlocked" here means we've at least attempted resume() inside a
|
||||
// user gesture AND the context is currently running. If the user
|
||||
// backgrounded the tab the context can drift back to `interrupted`
|
||||
// on iOS, so we re-check every call instead of trusting the flag
|
||||
// alone.
|
||||
if (!this.unlocked) return false;
|
||||
return this.ctx?.state === "running";
|
||||
}
|
||||
|
||||
/**
|
||||
* Browsers require a user gesture before audio can play. Call this once
|
||||
* the user interacts with the app to "unlock" the audio elements.
|
||||
* Lazily build the shared AudioContext. Returns `null` when the
|
||||
* Web Audio API is unavailable (very old browsers / SSR) so callers
|
||||
* can degrade gracefully.
|
||||
*/
|
||||
private getContext(): AudioContext | null {
|
||||
if (this.ctx) return this.ctx;
|
||||
if (typeof window === "undefined") return null;
|
||||
const w = window as WindowWithWebkitAC;
|
||||
const Ctor = window.AudioContext ?? w.webkitAudioContext;
|
||||
if (!Ctor) return null;
|
||||
try {
|
||||
this.ctx = new Ctor();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return this.ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browsers require a user gesture before audio can play. Call this
|
||||
* from inside a gesture handler (pointerdown/touchstart/keydown). We
|
||||
* resume the AudioContext, play a 1-sample silent buffer to fully
|
||||
* satisfy iOS Safari's gesture requirement, and pre-warm the decoded
|
||||
* buffers in the background so subsequent socket-driven chimes are
|
||||
* instant. Safe to call repeatedly — on iOS the context can slip
|
||||
* back into `suspended` if the tab is backgrounded, so we always
|
||||
* try to resume() again on each subsequent gesture.
|
||||
*/
|
||||
unlock(): void {
|
||||
const ctx = this.getContext();
|
||||
if (!ctx) return;
|
||||
|
||||
// Always attempt resume() — even if `unlocked` is already true the
|
||||
// context may have been auto-suspended (or, on iOS, transitioned
|
||||
// to `interrupted` after a phone call / route change). Resuming
|
||||
// any non-`running` state from inside the gesture is what gives
|
||||
// iPad Safari its highest chance of bringing the ctx back.
|
||||
if (ctx.state !== "running") {
|
||||
void ctx.resume().catch(() => {});
|
||||
}
|
||||
|
||||
// Play a 1-sample silent buffer through the destination. iOS Safari
|
||||
// requires an actual `start()` call inside a gesture before it will
|
||||
// permit later out-of-gesture playback through the same context.
|
||||
try {
|
||||
const silent = ctx.createBuffer(1, 1, 22050);
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = silent;
|
||||
src.connect(ctx.destination);
|
||||
src.start(0);
|
||||
} catch {
|
||||
/* not fatal — resume() alone may still be enough */
|
||||
}
|
||||
|
||||
if (this.unlocked) return;
|
||||
this.unlocked = true;
|
||||
// Touch each cached element with a silent play()/pause() so iOS/Safari
|
||||
// marks them as user-initiated. Pre-warm cache for snappier playback.
|
||||
|
||||
// Pre-decode all sounds in the background. We deliberately do not
|
||||
// await — playback path falls back to on-demand decode if a
|
||||
// particular buffer hasn't loaded yet.
|
||||
for (const entry of NOTIFICATION_SOUND_MANIFEST) {
|
||||
let el = this.cache.get(entry.id);
|
||||
if (!el) {
|
||||
el = new Audio(entry.url);
|
||||
el.preload = "auto";
|
||||
this.cache.set(entry.id, el);
|
||||
void this.loadBuffer(entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadBuffer(
|
||||
soundId: NotificationSoundId,
|
||||
): Promise<AudioBuffer | null> {
|
||||
const cached = this.buffers.get(soundId);
|
||||
if (cached) return cached;
|
||||
const inFlight = this.loading.get(soundId);
|
||||
if (inFlight) return inFlight;
|
||||
const ctx = this.getContext();
|
||||
const entry = SOUND_BY_ID.get(soundId);
|
||||
if (!ctx || !entry) return null;
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const res = await fetch(entry.url, { credentials: "same-origin" });
|
||||
if (!res.ok) return null;
|
||||
const ab = await res.arrayBuffer();
|
||||
// Older WebKit only supports the callback form of decodeAudioData;
|
||||
// wrap so we always get a Promise.
|
||||
const buf = await new Promise<AudioBuffer>((resolve, reject) => {
|
||||
try {
|
||||
const maybe = ctx.decodeAudioData(
|
||||
ab,
|
||||
(b) => resolve(b),
|
||||
(e) => reject(e ?? new Error("decode failed")),
|
||||
);
|
||||
if (maybe && typeof (maybe as Promise<AudioBuffer>).then === "function") {
|
||||
(maybe as Promise<AudioBuffer>).then(resolve, reject);
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
el.muted = true;
|
||||
const p = el.play();
|
||||
if (p && typeof p.then === "function") {
|
||||
p.then(() => {
|
||||
el!.pause();
|
||||
el!.currentTime = 0;
|
||||
el!.muted = false;
|
||||
}).catch(() => {
|
||||
el!.muted = false;
|
||||
});
|
||||
} else {
|
||||
el.muted = false;
|
||||
}
|
||||
this.buffers.set(soundId, buf);
|
||||
return buf;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
this.loading.delete(soundId);
|
||||
}
|
||||
})();
|
||||
this.loading.set(soundId, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
private dispatchHint() {
|
||||
if (this.hintDispatched) return;
|
||||
this.hintDispatched = true;
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent(AUTOPLAY_BLOCKED_EVENT));
|
||||
const detail: AutoplayBlockedDetail = { isIos: detectIos() };
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<AutoplayBlockedDetail>(AUTOPLAY_BLOCKED_EVENT, {
|
||||
detail,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Allow another hint after a generous cool-down so the user doesn't
|
||||
// get spammed if they ignore the first toast.
|
||||
@@ -69,36 +198,34 @@ class NotificationPlayer {
|
||||
}, 30_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a notification chime by id. Safe to call before `unlock()` —
|
||||
* the play attempt will simply surface the autoplay-blocked hint
|
||||
* once instead of producing audio. Throttled to at most one chime
|
||||
* every 3 s to coalesce bursts.
|
||||
*/
|
||||
play(
|
||||
soundId: NotificationSoundId,
|
||||
opts: { vibrate?: boolean } = {},
|
||||
opts: { vibrate?: boolean; bypassThrottle?: boolean } = {},
|
||||
): void {
|
||||
// Throttle: at most one sound every 3s to coalesce bursts of
|
||||
// notifications (e.g. several orders arriving in quick succession)
|
||||
// into a single chime instead of a string of overlapping beeps.
|
||||
// The settings "Test sound" affordance opts out so users can
|
||||
// audition multiple sounds back-to-back.
|
||||
const now =
|
||||
typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
if (!opts.bypassThrottle) {
|
||||
if (now - this.lastPlayAt < 3000) return;
|
||||
// Only the throttled (production socket-driven) path advances
|
||||
// `lastPlayAt` so that rapid settings previews cannot suppress
|
||||
// a legitimate socket chime that arrives moments later.
|
||||
this.lastPlayAt = now;
|
||||
}
|
||||
|
||||
const entry = SOUND_BY_ID.get(soundId) ?? SOUND_BY_ID.get("ding");
|
||||
if (!entry) return;
|
||||
|
||||
let el = this.cache.get(entry.id);
|
||||
if (!el) {
|
||||
el = new Audio(entry.url);
|
||||
el.preload = "auto";
|
||||
this.cache.set(entry.id, el);
|
||||
}
|
||||
try {
|
||||
el.currentTime = 0;
|
||||
} catch {
|
||||
/* some browsers throw if not loaded */
|
||||
}
|
||||
// Use the device/system volume — explicit volume control is out of
|
||||
// scope. Setting el.volume = 1 ensures we don't attenuate the OS
|
||||
// mixer level the user already controls.
|
||||
el.volume = 1;
|
||||
// Test-only observability: expose a counter + last sound so Playwright
|
||||
// can assert chime invocation. Gated on dev/test bundles so production
|
||||
// builds don't leak debug globals.
|
||||
@@ -110,14 +237,37 @@ class NotificationPlayer {
|
||||
w.__txosNotifPlayCount = (w.__txosNotifPlayCount ?? 0) + 1;
|
||||
w.__txosNotifLastSound = entry.id;
|
||||
}
|
||||
const promise = el.play();
|
||||
if (promise && typeof promise.then === "function") {
|
||||
promise.catch(() => {
|
||||
// Most likely autoplay blocked — surface a hint exactly once
|
||||
// per cool-down so the user knows to click anywhere to enable.
|
||||
|
||||
const ctx = this.getContext();
|
||||
if (!ctx) {
|
||||
if (!this.unlocked) this.dispatchHint();
|
||||
});
|
||||
return;
|
||||
}
|
||||
// The context may have drifted back to `suspended` (iOS background
|
||||
// tab). Try to resume opportunistically — succeeds silently if a
|
||||
// gesture has already been observed; otherwise the play attempt
|
||||
// below will be inaudible and we surface the hint.
|
||||
if (ctx.state !== "running") {
|
||||
void ctx.resume().catch(() => {});
|
||||
}
|
||||
|
||||
void this.loadBuffer(entry.id).then((buf) => {
|
||||
if (!buf) return;
|
||||
// Re-check context state right before scheduling — if it's still
|
||||
// not running we won't actually be heard, so dispatch the hint.
|
||||
if (ctx.state !== "running") {
|
||||
this.dispatchHint();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = buf;
|
||||
src.connect(ctx.destination);
|
||||
src.start(0);
|
||||
} catch {
|
||||
/* ignore one-off node errors */
|
||||
}
|
||||
});
|
||||
|
||||
if (opts.vibrate && typeof navigator !== "undefined" && navigator.vibrate) {
|
||||
try {
|
||||
@@ -127,6 +277,30 @@ class NotificationPlayer {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-play used by the "Test sound" button in settings. Bypasses
|
||||
* the 3-second throttle (so users can audition several sounds in a
|
||||
* row) and unlocks the context first since this is always called
|
||||
* from inside a click handler.
|
||||
*/
|
||||
testPlay(soundId: NotificationSoundId): void {
|
||||
this.unlock();
|
||||
this.play(soundId, { bypassThrottle: true });
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationPlayer = new NotificationPlayer();
|
||||
|
||||
// Test-only accessor so Playwright can assert the singleton player's
|
||||
// AudioContext state after a user gesture (the actual production
|
||||
// path on iPad Safari, not a freshly constructed test ctx). Gated on
|
||||
// dev/test bundles to avoid leaking internals into production.
|
||||
if (typeof window !== "undefined" && import.meta.env.MODE !== "production") {
|
||||
(window as unknown as {
|
||||
__txosNotifCtxState?: () => string | null;
|
||||
}).__txosNotifCtxState = () => {
|
||||
const p = notificationPlayer as unknown as { ctx: AudioContext | null };
|
||||
return p.ctx?.state ?? null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -322,6 +322,7 @@
|
||||
"unmutedToast": "تم إلغاء كتم الإشعارات",
|
||||
"autoplayHintTitle": "تحتاج الأصوات إلى نقرة",
|
||||
"autoplayHint": "انقر في أي مكان لتفعيل أصوات الإشعارات.",
|
||||
"autoplayHintIos": "اضغط في أي مكان لتفعيل الأصوات. إذا ما زلت لا تسمعها، افتح مركز التحكم وعطّل الوضع الصامت.",
|
||||
"slot": {
|
||||
"order": "الطلبات",
|
||||
"meeting": "الاجتماعات",
|
||||
|
||||
@@ -325,6 +325,7 @@
|
||||
"unmutedToast": "Notifications unmuted",
|
||||
"autoplayHintTitle": "Sounds need a click",
|
||||
"autoplayHint": "Click anywhere on the page to enable notification sounds.",
|
||||
"autoplayHintIos": "Tap anywhere to enable sounds. If you still don't hear them, open Control Center and turn off Silent / Mute.",
|
||||
"slot": {
|
||||
"order": "Orders",
|
||||
"meeting": "Meetings",
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// E2E test for Task #441: notification sounds use Web Audio so they
|
||||
// reliably play on iPad Safari after a single user gesture. Verifies
|
||||
// (1) the AudioContext reaches `running` state after the user clicks
|
||||
// the per-sound preview button in notification settings, (2) the play
|
||||
// counter increments, and (3) the testPlay path is not subject to the
|
||||
// 3-second burst-coalescing throttle.
|
||||
import { test, expect } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL must be set to run this UI test");
|
||||
}
|
||||
|
||||
const TEST_PASSWORD = "TestPass123!";
|
||||
const TEST_PASSWORD_HASH =
|
||||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
const createdUserIds = [];
|
||||
|
||||
function uniq() {
|
||||
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function createUser(prefix, displayEn) {
|
||||
const username = `${prefix}_${uniq()}`;
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
|
||||
[username, `${username}@example.com`, TEST_PASSWORD_HASH, displayEn],
|
||||
);
|
||||
const id = rows[0].id;
|
||||
createdUserIds.push(id);
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id)
|
||||
SELECT $1, id FROM roles WHERE name = 'user'`,
|
||||
[id],
|
||||
);
|
||||
return { id, username };
|
||||
}
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (createdUserIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM notifications WHERE user_id = ANY($1::int[])`,
|
||||
[createdUserIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`,
|
||||
[createdUserIds],
|
||||
);
|
||||
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
|
||||
createdUserIds,
|
||||
]);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
async function loginViaUi(page, username) {
|
||||
await page.goto("/login");
|
||||
await page.locator("#username").fill(username);
|
||||
await page.locator("#password").fill(TEST_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
|
||||
timeout: 15_000,
|
||||
}),
|
||||
page.locator('form button[type="submit"]').click(),
|
||||
]);
|
||||
}
|
||||
|
||||
test("preview sound button activates Web Audio and increments play counter", async ({
|
||||
browser,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
const user = await createUser("audio_unlock", "Audio Unlock");
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
|
||||
try {
|
||||
await loginViaUi(page, user.username);
|
||||
await page.goto("/");
|
||||
// Reset counter so we can assert exactly the clicks we drive below.
|
||||
await page.evaluate(() => {
|
||||
window.__txosNotifPlayCount = 0;
|
||||
window.__txosNotifLastSound = undefined;
|
||||
});
|
||||
|
||||
// Open the notification settings popover via its bell trigger
|
||||
// (aria-label "Notification settings" / "إعدادات الإشعارات"). The
|
||||
// login defaults to English.
|
||||
await page.getByRole("button", { name: /notification sounds/i }).click();
|
||||
|
||||
// Radix Popover renders into a portal — wait until the popover
|
||||
// body is on screen before reaching for the preview buttons.
|
||||
await expect(
|
||||
page.getByText(/notification sounds/i).last(),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// First per-sound preview play — this is the gesture that should
|
||||
// bring the AudioContext to `running` state on every browser.
|
||||
const previews = page.getByRole("button", { name: /^preview$/i });
|
||||
await expect(previews.first()).toBeVisible({ timeout: 5_000 });
|
||||
await previews.first().click();
|
||||
|
||||
// The play counter is bumped synchronously inside play().
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__txosNotifPlayCount ?? 0), {
|
||||
timeout: 3_000,
|
||||
})
|
||||
.toBe(1);
|
||||
|
||||
const lastSound = await page.evaluate(() => window.__txosNotifLastSound);
|
||||
expect(typeof lastSound).toBe("string");
|
||||
expect(lastSound && lastSound.length).toBeGreaterThan(0);
|
||||
|
||||
// testPlay must bypass the 3 s throttle so users can audition
|
||||
// several sounds back-to-back from settings. Click a second
|
||||
// preview immediately and require the counter to advance.
|
||||
await previews.nth(1).click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__txosNotifPlayCount ?? 0), {
|
||||
timeout: 3_000,
|
||||
})
|
||||
.toBe(2);
|
||||
|
||||
// Verify the actual production singleton player's AudioContext
|
||||
// reached `running` state as a result of the preview-button
|
||||
// gesture. This is what iPad Safari requires to subsequently
|
||||
// play out-of-gesture, socket-driven chimes.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() => {
|
||||
const fn = /** @type {any} */ (window).__txosNotifCtxState;
|
||||
return typeof fn === "function" ? fn() : null;
|
||||
}),
|
||||
{ timeout: 3_000 },
|
||||
)
|
||||
.toBe("running");
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user