diff --git a/frontend/src/features/interview/lib/media/useVoiceRecorder.test.ts b/frontend/src/features/interview/lib/media/useVoiceRecorder.test.ts new file mode 100644 index 0000000..57bf357 --- /dev/null +++ b/frontend/src/features/interview/lib/media/useVoiceRecorder.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useVoiceRecorder } from './useVoiceRecorder' + +// 최소 MediaRecorder 스텁 — 가드가 깨져 늦은 스트림으로 녹음이 시작되면 +// 여기서 start() 가 불려 테스트가 그 사실을 잡을 수 있다. +class FakeMediaRecorder { + static isTypeSupported() { + return true + } + state = 'inactive' + ondataavailable: unknown = null + onstop: unknown = null + start() { + this.state = 'recording' + } + stop() { + this.state = 'inactive' + } +} + +function installMediaRecorder() { + vi.stubGlobal('MediaRecorder', FakeMediaRecorder) +} + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('useVoiceRecorder', () => { + it('권한 대기 중 언마운트되면 뒤늦게 도착한 트랙을 정지하고 녹음을 시작하지 않는다', async () => { + installMediaRecorder() + const stop = vi.fn() + const track = { stop } + let resolveStream!: (s: unknown) => void + const pending = new Promise((res) => { + resolveStream = res + }) + const getUserMedia = vi.fn().mockReturnValue(pending) + Object.defineProperty(navigator, 'mediaDevices', { + value: { getUserMedia }, + configurable: true, + }) + const startSpy = vi.spyOn(FakeMediaRecorder.prototype, 'start') + + const { result, unmount } = renderHook(() => useVoiceRecorder()) + + // 권한 프롬프트 대기(getUserMedia 미해결) 중 start 호출. + let startPromise!: Promise + act(() => { + startPromise = result.current.start() + }) + expect(result.current.status).toBe('requesting') + + // 대기 중 언마운트 → 뒤늦게 권한 허용. + unmount() + await act(async () => { + resolveStream({ getTracks: () => [track] }) + await startPromise + }) + + // 소유자 없는 트랙은 즉시 정지되고, 녹음(MediaRecorder.start)은 시작되지 않아야 한다. + expect(stop).toHaveBeenCalled() + expect(startSpy).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/features/interview/lib/media/useVoiceRecorder.ts b/frontend/src/features/interview/lib/media/useVoiceRecorder.ts index 0f28181..903d7e3 100644 --- a/frontend/src/features/interview/lib/media/useVoiceRecorder.ts +++ b/frontend/src/features/interview/lib/media/useVoiceRecorder.ts @@ -36,7 +36,16 @@ export function useVoiceRecorder() { setStream(null) }, []) - useEffect(() => cleanup, [cleanup]) + // 언마운트 정리. requestId 를 올려 아직 pending 인 getUserMedia 를 무효화한다 — + // 안 그러면 resolve 가 언마운트 뒤에 도착해 start() 가드(아래 requestId 비교)를 통과, + // 소유자 없는 녹음이 시작되며 마이크 표시등이 켜진 채 남는다. + useEffect( + () => () => { + requestIdRef.current += 1 + cleanup() + }, + [cleanup], + ) const start = useCallback(async (): Promise => { if (status === 'unsupported' || status === 'recording') return false diff --git a/frontend/src/features/interview/lib/media/useWebcamPreview.ts b/frontend/src/features/interview/lib/media/useWebcamPreview.ts index b4e9397..8218962 100644 --- a/frontend/src/features/interview/lib/media/useWebcamPreview.ts +++ b/frontend/src/features/interview/lib/media/useWebcamPreview.ts @@ -9,7 +9,13 @@ export function useWebcamPreview() { const streamRef = useRef(null) const [state, setState] = useState('idle') + // 권한 프롬프트는 사용자가 방치하면 오래 pending 이다. 그 사이 stop() 이나 언마운트가 + // 일어나면 이 값을 올려 뒤늦게 도착한 스트림을 무효화한다 — 안 그러면 소유자 없는 + // 트랙이 살아남아 카메라 LED 가 계속 켜진 채로 남는다. + const requestIdRef = useRef(0) + const stop = useCallback(() => { + requestIdRef.current += 1 streamRef.current?.getTracks().forEach((t) => t.stop()) streamRef.current = null if (videoRef.current) { @@ -23,13 +29,20 @@ export function useWebcamPreview() { setState('unsupported') return } + const requestId = ++requestIdRef.current setState('requesting') try { const stream = await navigator.mediaDevices.getUserMedia({ video: true }) + // 대기 중에 stop()/언마운트로 무효화됐으면 방금 열린 트랙을 바로 닫는다. + if (requestIdRef.current !== requestId) { + stream.getTracks().forEach((t) => t.stop()) + return + } streamRef.current = stream setState('live') } catch { - setState('denied') + // 무효화 뒤 늦게 거부가 오면 상태를 되돌리지 않는다. + if (requestIdRef.current === requestId) setState('denied') } }, []) @@ -41,8 +54,11 @@ export function useWebcamPreview() { }, [state]) // 언마운트 시 트랙 정리 (카메라 LED 가 계속 켜지는 것 방지 — 필수). + // requestId 를 올려 아직 pending 인 getUserMedia 도 무효화한다 (resolve 가 언마운트 + // 뒤에 와도 start() 가 그 트랙을 바로 닫는다). useEffect( () => () => { + requestIdRef.current += 1 streamRef.current?.getTracks().forEach((t) => t.stop()) }, [], diff --git a/frontend/src/features/interview/ui/live/WebcamSelfView.test.tsx b/frontend/src/features/interview/ui/live/WebcamSelfView.test.tsx index a91fd15..b7f659e 100644 --- a/frontend/src/features/interview/ui/live/WebcamSelfView.test.tsx +++ b/frontend/src/features/interview/ui/live/WebcamSelfView.test.tsx @@ -49,6 +49,25 @@ describe('WebcamSelfView', () => { unmount() expect(stop).toHaveBeenCalled() }) + + it('권한 대기 중 언마운트되면 뒤늦게 도착한 스트림도 정지한다 (LED 잔존 방지)', async () => { + const stop = vi.fn() + const stream = { getTracks: () => [{ stop }] } + let resolveStream!: (s: typeof stream) => void + const pending = new Promise((res) => { + resolveStream = res + }) + mockMediaDevices(vi.fn().mockReturnValue(pending)) + + const { unmount } = render() + await userEvent.click(screen.getByRole('button', { name: '카메라 켜기' })) + // 프롬프트 대기 중(getUserMedia 미해결) 상태에서 언마운트. + unmount() + // 권한이 뒤늦게 허용됨 → 소유자 없는 트랙은 즉시 정지되어야 한다. + resolveStream(stream) + await pending + expect(stop).toHaveBeenCalled() + }) }) beforeEach(() => {