Files
kandev/apps/web/lib/notifications/sound.test.ts
T
João Salavisa c81b60a06d feat(web): add opt-in notification sound for waiting-for-input (#1689)
* feat(web): add opt-in notification sound for waiting-for-input

Play a configurable per-device sound when a session starts waiting for
user input. Sounds are synthesized with the Web Audio API (no binary
assets), follow the same suppression rules as desktop notifications,
and work independently of the browser Notification permission.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): drop stale sound plays when AudioContext resume is delayed

Scheduling notes on a suspended context queued every pending alert and
played them stacked once the user finally interacted. Schedule only
after resume() fulfills and drop plays older than 2s.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): harden notification sound playback edge cases

Catch AudioContext construction failures so a broken audio stack cannot
abort the WS handler before the desktop notification is shown, route
interrupted (iOS) contexts through the resume path, and coalesce plays
queued while the context is not running so only one pending cue fires.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): make sound test stubs constructible and refine resume queue

vi.fn with an arrow implementation is not constructible, so the
AudioContext stubs silently misbehaved on CI's Node runtime. Also keep
the newest pending play instead of dropping later requests, recheck the
preference before a resumed waiting-for-input cue fires, and validate
picker values with the existing type guard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-15 10:27:23 +01:00

270 lines
8.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
function makeFakeAudioContext() {
const oscillator = {
type: "",
frequency: { value: 0 },
connect: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
};
const gain = {
gain: {
setValueAtTime: vi.fn(),
linearRampToValueAtTime: vi.fn(),
exponentialRampToValueAtTime: vi.fn(),
},
connect: vi.fn(),
};
const ctx = {
currentTime: 0,
state: "running",
destination: {},
resume: vi.fn().mockResolvedValue(undefined),
createOscillator: vi.fn(() => oscillator),
createGain: vi.fn(() => gain),
};
return { ctx, oscillator, gain };
}
async function loadSoundModule() {
return import("./sound");
}
// The stub is invoked with `new`, so the implementation must be a real
// `function` — an arrow implementation is not constructible.
function stubAudioContextCtor(implementation: () => unknown) {
const ctor = vi.fn(function (this: unknown) {
return implementation();
});
vi.stubGlobal("AudioContext", ctor);
return ctor;
}
describe("sound preferences", () => {
beforeEach(() => {
vi.resetModules();
window.localStorage.clear();
});
it("defaults to disabled with the plim preset", async () => {
const { getSoundPreferences } = await loadSoundModule();
expect(getSoundPreferences()).toEqual({ enabled: false, presetId: "plim" });
});
it("round-trips preferences through localStorage", async () => {
const { getSoundPreferences, setSoundPreferences } = await loadSoundModule();
setSoundPreferences({ enabled: true, presetId: "chime" });
expect(getSoundPreferences()).toEqual({ enabled: true, presetId: "chime" });
});
it("falls back to the default preset for unknown stored ids", async () => {
const { getSoundPreferences } = await loadSoundModule();
window.localStorage.setItem(
"kandev.notifications.sound",
JSON.stringify({ enabled: true, presetId: "airhorn" }),
);
expect(getSoundPreferences()).toEqual({ enabled: true, presetId: "plim" });
});
it("ignores corrupted stored values", async () => {
const { getSoundPreferences } = await loadSoundModule();
window.localStorage.setItem("kandev.notifications.sound", "not-json");
expect(getSoundPreferences()).toEqual({ enabled: false, presetId: "plim" });
});
});
describe("playSoundPreset", () => {
beforeEach(() => {
vi.resetModules();
window.localStorage.clear();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("schedules one oscillator per note of the preset", async () => {
const { ctx } = makeFakeAudioContext();
stubAudioContextCtor(() => ctx);
const { playSoundPreset, SOUND_PRESETS } = await loadSoundModule();
playSoundPreset("chime");
const chime = SOUND_PRESETS.find((p) => p.id === "chime")!;
expect(ctx.createOscillator).toHaveBeenCalledTimes(chime.notes.length);
});
it("falls back to the first preset for unknown ids", async () => {
const { ctx } = makeFakeAudioContext();
stubAudioContextCtor(() => ctx);
const { playSoundPreset, SOUND_PRESETS } = await loadSoundModule();
playSoundPreset("nope");
expect(ctx.createOscillator).toHaveBeenCalledTimes(SOUND_PRESETS[0].notes.length);
});
it("does not throw when AudioContext construction fails", async () => {
stubAudioContextCtor(() => {
throw new Error("audio stack broken");
});
const { playSoundPreset } = await loadSoundModule();
expect(() => playSoundPreset("plim")).not.toThrow();
});
it("reuses a single AudioContext across plays", async () => {
const { ctx } = makeFakeAudioContext();
const ctor = stubAudioContextCtor(() => ctx);
const { playSoundPreset } = await loadSoundModule();
playSoundPreset("plim");
playSoundPreset("ding");
expect(ctor).toHaveBeenCalledTimes(1);
});
it("is a no-op when AudioContext is unavailable", async () => {
const { playSoundPreset } = await loadSoundModule();
expect(() => playSoundPreset("plim")).not.toThrow();
});
});
describe("playSoundPreset with a non-running context", () => {
beforeEach(() => {
vi.resetModules();
window.localStorage.clear();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("plays after a suspended context resumes promptly", async () => {
const { ctx } = makeFakeAudioContext();
ctx.state = "suspended";
stubAudioContextCtor(() => ctx);
const { playSoundPreset, SOUND_PRESETS } = await loadSoundModule();
playSoundPreset("plim");
expect(ctx.resume).toHaveBeenCalled();
expect(ctx.createOscillator).not.toHaveBeenCalled();
await new Promise((resolve) => setTimeout(resolve, 0));
const plim = SOUND_PRESETS.find((p) => p.id === "plim")!;
expect(ctx.createOscillator).toHaveBeenCalledTimes(plim.notes.length);
});
it("drops the play when a suspended context resumes too late", async () => {
const { ctx } = makeFakeAudioContext();
ctx.state = "suspended";
let resolveResume!: () => void;
ctx.resume = vi.fn(() => new Promise<void>((resolve) => (resolveResume = resolve)));
stubAudioContextCtor(() => ctx);
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(0);
const { playSoundPreset } = await loadSoundModule();
playSoundPreset("plim");
nowSpy.mockReturnValue(60_000);
resolveResume();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(ctx.createOscillator).not.toHaveBeenCalled();
});
it("coalesces plays queued while the context is not running", async () => {
const { ctx } = makeFakeAudioContext();
ctx.state = "suspended";
stubAudioContextCtor(() => ctx);
const { playSoundPreset, SOUND_PRESETS } = await loadSoundModule();
playSoundPreset("plim");
playSoundPreset("plim");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(ctx.resume).toHaveBeenCalledTimes(1);
const plim = SOUND_PRESETS.find((p) => p.id === "plim")!;
expect(ctx.createOscillator).toHaveBeenCalledTimes(plim.notes.length);
});
it("keeps only the newest request while a resume is in flight", async () => {
const { ctx } = makeFakeAudioContext();
ctx.state = "suspended";
stubAudioContextCtor(() => ctx);
const { playSoundPreset, SOUND_PRESETS } = await loadSoundModule();
playSoundPreset("plim");
playSoundPreset("ding");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(ctx.resume).toHaveBeenCalledTimes(1);
const ding = SOUND_PRESETS.find((p) => p.id === "ding")!;
expect(ctx.createOscillator).toHaveBeenCalledTimes(ding.notes.length);
});
it("resumes an interrupted context before playing", async () => {
const { ctx } = makeFakeAudioContext();
ctx.state = "interrupted";
stubAudioContextCtor(() => ctx);
const { playSoundPreset, SOUND_PRESETS } = await loadSoundModule();
playSoundPreset("ding");
await new Promise((resolve) => setTimeout(resolve, 0));
const ding = SOUND_PRESETS.find((p) => p.id === "ding")!;
expect(ctx.createOscillator).toHaveBeenCalledTimes(ding.notes.length);
});
});
describe("playWaitingForInputSound", () => {
beforeEach(() => {
vi.resetModules();
window.localStorage.clear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("does not play a resumed cue when sound was disabled while waiting", async () => {
const { ctx } = makeFakeAudioContext();
ctx.state = "suspended";
stubAudioContextCtor(() => ctx);
const { playWaitingForInputSound, setSoundPreferences } = await loadSoundModule();
setSoundPreferences({ enabled: true, presetId: "plim" });
playWaitingForInputSound();
setSoundPreferences({ enabled: false, presetId: "plim" });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(ctx.resume).toHaveBeenCalled();
expect(ctx.createOscillator).not.toHaveBeenCalled();
});
it("does not play when sound is disabled (default)", async () => {
const { ctx } = makeFakeAudioContext();
stubAudioContextCtor(() => ctx);
const { playWaitingForInputSound } = await loadSoundModule();
playWaitingForInputSound();
expect(ctx.createOscillator).not.toHaveBeenCalled();
});
it("plays the configured preset when enabled", async () => {
const { ctx } = makeFakeAudioContext();
stubAudioContextCtor(() => ctx);
const { playWaitingForInputSound, setSoundPreferences, SOUND_PRESETS } =
await loadSoundModule();
setSoundPreferences({ enabled: true, presetId: "ding" });
playWaitingForInputSound();
const ding = SOUND_PRESETS.find((p) => p.id === "ding")!;
expect(ctx.createOscillator).toHaveBeenCalledTimes(ding.notes.length);
});
});