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:
parent
5269d50039
commit
99cd82ab0a
@ -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()
|
||||
|
||||
@ -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() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user