Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions frontend/src/features/interview/lib/media/useVoiceRecorder.test.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>
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()
})
})
11 changes: 10 additions & 1 deletion frontend/src/features/interview/lib/media/useVoiceRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> => {
if (status === 'unsupported' || status === 'recording') return false
Expand Down
18 changes: 17 additions & 1 deletion frontend/src/features/interview/lib/media/useWebcamPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ export function useWebcamPreview() {
const streamRef = useRef<MediaStream | null>(null)
const [state, setState] = useState<WebcamState>('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) {
Expand All @@ -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')
}
}, [])

Expand All @@ -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())
},
[],
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/features/interview/ui/live/WebcamSelfView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof stream>((res) => {
resolveStream = res
})
mockMediaDevices(vi.fn().mockReturnValue(pending))

const { unmount } = render(<WebcamSelfView />)
await userEvent.click(screen.getByRole('button', { name: '카메라 켜기' }))
// 프롬프트 대기 중(getUserMedia 미해결) 상태에서 언마운트.
unmount()
// 권한이 뒤늦게 허용됨 → 소유자 없는 트랙은 즉시 정지되어야 한다.
resolveStream(stream)
await pending
expect(stop).toHaveBeenCalled()
})
})

beforeEach(() => {
Expand Down
Loading