fix(ui): catch useAudioPlayer's play() rejection instead of leaving it unhandled

play() on the underlying <audio> element rejects independently of its
'error' event (e.g. NotSupportedError when a peer-content request 404s and
there's no decodable source) — the 'error' listener already sets a friendly
message, but the unawaited play() promise still surfaced as a raw unhandled
rejection in the console. Follow-up from the .116->.228 peer-content
investigation (2026-07-01).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-01 09:53:50 -04:00
parent 5269d50039
commit 99cd82ab0a
2 changed files with 42 additions and 1 deletions

View File

@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useAudioPlayer } from '../useAudioPlayer'
// Mock HTMLAudioElement
let lastMockAudio: MockAudio | undefined
class MockAudio {
src = ''
currentTime = 0
@ -9,6 +11,10 @@ class MockAudio {
paused = true
private listeners: Record<string, Array<() => void>> = {}
constructor() {
lastMockAudio = this
}
addEventListener(event: string, handler: () => void) {
if (!this.listeners[event]) this.listeners[event] = []
this.listeners[event].push(handler)
@ -18,7 +24,12 @@ class MockAudio {
// no-op for tests
}
shouldRejectPlay = false
play() {
if (this.shouldRejectPlay) {
return Promise.reject(new DOMException('no supported source', 'NotSupportedError'))
}
this.paused = false
this.emit('play')
return Promise.resolve()
@ -47,6 +58,7 @@ describe('useAudioPlayer', () => {
// Reset singleton state by stopping any active playback
const player = useAudioPlayer()
player.stop()
if (lastMockAudio) lastMockAudio.shouldRejectPlay = false
})
it('returns all expected properties', () => {
@ -128,6 +140,27 @@ describe('useAudioPlayer', () => {
expect(player.progress.value).toBe(0)
})
it('play() rejection is caught, not left as an unhandled promise rejection', async () => {
// Regression: play() rejects independently of the 'error' event (e.g. a
// peer-content 404 with no decodable source) — this used to be an
// unhandled rejection in the browser console even though the 'error'
// listener already set a friendly message (2026-07-01).
const player = useAudioPlayer()
// Initialize the singleton Audio element first (a no-op play call).
player.play('/audio/warmup.mp3', 'Warmup')
player.stop()
lastMockAudio!.shouldRejectPlay = true
// Calling play() must not throw synchronously nor leave a rejected
// promise unhandled — if useAudioPlayer's play() didn't .catch() the
// rejection, `loading` would never flip back to false, since nothing
// else resets it on this path (that's the real regression signal).
expect(() => player.play('/audio/broken.mp3', 'Broken')).not.toThrow()
await Promise.resolve()
await Promise.resolve()
expect(player.loading.value).toBe(false)
})
it('shared state across multiple useAudioPlayer calls', () => {
const p1 = useAudioPlayer()
const p2 = useAudioPlayer()

View File

@ -69,7 +69,15 @@ function play(src: string, name: string) {
currentName.value = name
}
audio.value!.play()
// play() rejects (e.g. NotSupportedError when the peer 404s and there's no
// decodable source) independently of the 'error' event above — uncaught,
// this surfaces as a raw console error instead of the friendly message
// already wired up there. The 'error' listener sets the same state, so
// this just needs to stop the rejection from going unhandled.
audio.value!.play().catch(() => {
playing.value = false
loading.value = false
})
}
function pause() {