Files
VoiceStudio/frontend/src/utils/audioUnlock.test.js
T
Paolo Antinori 74d63f099c test(audioUnlock): unit-test the gesture-driven AudioContext resume
Locks down the resume path from the parent commit:

- Every AudioContext is tracked at construction (the wrap is active)
- unlockAudio() resumes all suspended tracked contexts in parallel
- Idempotent: repeated calls do not re-resume
- Contexts created after unlock are not re-resumed by a second call
- resume() rejections are swallowed — one bad context doesn't block others
- installAudioUnlock() is idempotent (the _installed gate works)

The unlock path was the fix for the "click does nothing" bug on Linux
Firefox/Chrome and Android Chrome, where AudioContexts created before a
user gesture stay suspended — decodeAudioData hangs → WaveSurfer's ready
never fires → the play button never enables. Without this test, breaking
the gesture wiring silently regresses every non-macOS browser.

Adds the __resetForTesting() export so the unlock can be exercised
repeatedly against the same module instance (the unlock is meant to be
a one-shot per page load).
2026-06-17 06:48:43 +02:00

109 lines
3.7 KiB
JavaScript

import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
// Regression guard for the AudioContext autoplay-policy fix.
// On Linux Firefox/Chrome and Android Chrome, AudioContexts created before
// a user gesture stay suspended — decodeAudioData hangs, WaveSurfer's `ready`
// never fires, play button stays disabled. The fix patches `window.AudioContext`
// to track every instance and exposes unlockAudio() to resume them.
//
// Test strategy: install a fake AudioContext BEFORE importing audioUnlock.js
// (the patch wraps window.AudioContext at module-load time). Each test resets
// the singleton's unlocked flag so the resume path runs fresh.
class FakeAudioContext {
constructor() {
this.state = 'suspended';
this.resumeCalls = 0;
this.resumeImpl = () => {
this.state = 'running';
return Promise.resolve();
};
}
resume() {
this.resumeCalls += 1;
return this.resumeImpl();
}
close() {
this.state = 'closed';
return Promise.resolve();
}
}
let audioUnlock;
beforeAll(async () => {
// Install fake BEFORE the module's top-level patch runs.
window.AudioContext = FakeAudioContext;
audioUnlock = await import('./audioUnlock.js');
});
describe('audioUnlock', () => {
beforeEach(() => audioUnlock.__resetForTesting());
it('AudioContexts are wrapped at construction (proves patch is applied)', () => {
const ctx = new AudioContext();
// The patched class adds the __omnivoiceTracked marker and extends the
// fake (so state is 'suspended' from FakeAudioContext's constructor).
expect(window.AudioContext.__omnivoiceTracked).toBe(true);
expect(ctx.state).toBe('suspended');
expect(ctx.resumeCalls).toBe(0);
});
it('unlockAudio() resumes all suspended tracked contexts', async () => {
const ctx1 = new AudioContext();
const ctx2 = new AudioContext();
const ctx3 = new AudioContext();
await audioUnlock.unlockAudio();
expect(ctx1.state).toBe('running');
expect(ctx2.state).toBe('running');
expect(ctx3.state).toBe('running');
expect(ctx1.resumeCalls).toBe(1);
expect(ctx2.resumeCalls).toBe(1);
expect(ctx3.resumeCalls).toBe(1);
});
it('unlockAudio() is idempotent — repeated calls do not re-resume', async () => {
const ctx = new AudioContext();
await audioUnlock.unlockAudio();
expect(ctx.resumeCalls).toBe(1);
await audioUnlock.unlockAudio();
await audioUnlock.unlockAudio();
await audioUnlock.unlockAudio();
expect(ctx.resumeCalls).toBe(1);
});
it('contexts created AFTER unlock are not re-resumed by a second unlock', async () => {
const before = new AudioContext();
await audioUnlock.unlockAudio();
expect(before.resumeCalls).toBe(1);
const after = new AudioContext();
await audioUnlock.unlockAudio(); // no-op now (idempotent)
expect(after.resumeCalls).toBe(0);
});
it('resume() rejections are swallowed — one bad context does not block others', async () => {
const good = new AudioContext();
const bad = new AudioContext();
bad.resumeImpl = () => Promise.reject(new Error('policy blocked'));
// Should NOT throw — the catch in unlockAudio isolates failures.
await audioUnlock.unlockAudio();
expect(good.state).toBe('running');
expect(bad.state).toBe('suspended'); // its resume rejected; state unchanged
});
it('installAudioUnlock is idempotent — repeated calls are a no-op', () => {
// We can't directly enumerate window listeners, but the internal _installed
// gate guarantees one-time wiring. Calling repeatedly must not throw.
expect(() => {
audioUnlock.installAudioUnlock();
audioUnlock.installAudioUnlock();
audioUnlock.installAudioUnlock();
}).not.toThrow();
});
});