// E2E test for: 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(); } });