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).
109 lines
3.7 KiB
JavaScript
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();
|
|
});
|
|
});
|