28ddbe33d1
- 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.
146 lines
4.9 KiB
JavaScript
146 lines
4.9 KiB
JavaScript
// 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();
|
|
}
|
|
});
|