diff --git a/src/components/KycDocumentCapture/KycDocumentCapture.css b/src/components/KycDocumentCapture/KycDocumentCapture.css index 82772d5..243abd5 100644 --- a/src/components/KycDocumentCapture/KycDocumentCapture.css +++ b/src/components/KycDocumentCapture/KycDocumentCapture.css @@ -443,6 +443,15 @@ background: #f1f5f9; } +.kdc-capture-error { + color: var(--error, #ef4444); + font-size: var(--font-size-sm); + text-align: center; + padding: var(--spacing-xs) var(--spacing-sm); + background: var(--error-bg, rgba(239, 68, 68, 0.1)); + border-radius: var(--radius-md); +} + .kdc-btn--full { width: 100%; } diff --git a/src/components/KycDocumentCapture/KycDocumentCapture.test.tsx b/src/components/KycDocumentCapture/KycDocumentCapture.test.tsx index be00ce2..3cf6068 100644 --- a/src/components/KycDocumentCapture/KycDocumentCapture.test.tsx +++ b/src/components/KycDocumentCapture/KycDocumentCapture.test.tsx @@ -168,8 +168,8 @@ describe('KycDocumentCapture', () => { }); it('shows denied state when camera permission is denied', async () => { - mockGetUserMedia.mockRejectedValueOnce( - new DOMException('Permission denied', 'NotAllowedError') + mockGetUserMedia.mockRejectedValue( + new DOMException('Permission denied', 'NotAllowedError'), ); render(); @@ -202,8 +202,8 @@ describe('KycDocumentCapture', () => { }); it('shows file upload option in denied state', async () => { - mockGetUserMedia.mockRejectedValueOnce( - new DOMException('Permission denied', 'NotAllowedError') + mockGetUserMedia.mockRejectedValue( + new DOMException('Permission denied', 'NotAllowedError'), ); render(); @@ -213,8 +213,8 @@ describe('KycDocumentCapture', () => { }); it('shows retry camera button in denied state', async () => { - mockGetUserMedia.mockRejectedValueOnce( - new DOMException('Permission denied', 'NotAllowedError') + mockGetUserMedia.mockRejectedValue( + new DOMException('Permission denied', 'NotAllowedError'), ); mockGetUserMedia.mockResolvedValueOnce(createMockStream(trackStopSpy)); @@ -233,8 +233,8 @@ describe('KycDocumentCapture', () => { }); it('has no axe violations on denied fallback', async () => { - mockGetUserMedia.mockRejectedValueOnce( - new DOMException('Permission denied', 'NotAllowedError') + mockGetUserMedia.mockRejectedValue( + new DOMException('Permission denied', 'NotAllowedError'), ); const { container } = render(); @@ -245,128 +245,111 @@ describe('KycDocumentCapture', () => { }); }); - describe('Camera lifecycle and cleanup (regression tests for #934)', () => { - it('stops camera tracks when component unmounts while getUserMedia is pending', async () => { - let resolveGetUserMedia: (stream: MediaStream) => void; - const pendingPromise = new Promise((resolve) => { - resolveGetUserMedia = resolve; - }); - mockGetUserMedia.mockReturnValueOnce(pendingPromise); - - const { unmount } = render(); - fireEvent.click(screen.getByText('Enable Camera')); - - // Unmount before getUserMedia resolves - unmount(); + describe('Camera stream attachment and capture flow', () => { + it('attaches stream to video.srcObject and calls play() after camera activation', async () => { + const mockTrack = { stop: vi.fn(), kind: 'video' }; + const fakeStream = { + getTracks: vi.fn().mockReturnValue([mockTrack]), + getVideoTracks: vi.fn().mockReturnValue([mockTrack]), + } as unknown as MediaStream; - // Now resolve the promise (simulating getUserMedia resolving after unmount) - // Use the same trackStopSpy from beforeEach - const stream = createMockStream(trackStopSpy); - resolveGetUserMedia!(stream); - await pendingPromise; + const playSpy = vi.fn().mockResolvedValue(undefined); + window.HTMLMediaElement.prototype.play = playSpy; - // Track stop should have been called because component was unmounted - expect(trackStopSpy).toHaveBeenCalled(); - }); - - it('stops camera when switching to file upload', async () => { - mockGetUserMedia.mockResolvedValue(createMockStream(trackStopSpy)); + mockGetUserMedia.mockResolvedValue(fakeStream); render(); fireEvent.click(screen.getByText('Enable Camera')); - await waitFor(() => { - expect(screen.findByTestId('camera-active')); - }); - - expect(await screen.findByTestId('camera-active')).toBeInTheDocument(); - - // Click "Upload file instead" - fireEvent.click(screen.getByText('Upload file instead')); - - // Track stop should have been called - expect(trackStopSpy).toHaveBeenCalled(); + const video = (await screen.findByTestId('camera-video')) as HTMLVideoElement; + expect(video).toBeTruthy(); + expect(video.srcObject).toBe(fakeStream); + expect(playSpy).toHaveBeenCalled(); }); - it('revokes object URL on unmount when document is captured', async () => { - const revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL'); - mockGetUserMedia.mockResolvedValue(createMockStream(trackStopSpy)); + it('keeps capture button disabled until loadedmetadata fires with non-zero videoWidth', async () => { + const mockTrack = { stop: vi.fn(), kind: 'video' }; + const fakeStream = { + getTracks: vi.fn().mockReturnValue([mockTrack]), + getVideoTracks: vi.fn().mockReturnValue([mockTrack]), + } as unknown as MediaStream; + window.HTMLMediaElement.prototype.play = vi.fn().mockResolvedValue(undefined); - const { unmount } = render(); - fireEvent.click(screen.getByText('Enable Camera')); + mockGetUserMedia.mockResolvedValue(fakeStream); - await waitFor(() => { - expect(screen.findByTestId('camera-active')); - }); + render(); + fireEvent.click(screen.getByText('Enable Camera')); - expect(await screen.findByTestId('camera-active')).toBeInTheDocument(); + const captureBtn = await screen.findByRole('button', { name: /take photo/i }); + const video = screen.getByTestId('camera-video') as HTMLVideoElement; - // Capture document by clicking the "Take photo" button - fireEvent.click(screen.getByRole('button', { name: /take photo/i })); + // Initially disabled before metadata + expect(captureBtn).toBeDisabled(); - await waitFor(() => { - expect(screen.getByTestId('captured-preview')).toBeInTheDocument(); - }); + // If loadedmetadata fires with videoWidth === 0, stays disabled + Object.defineProperty(video, 'videoWidth', { value: 0, configurable: true }); + fireEvent(video, new Event('loadedmetadata')); + expect(captureBtn).toBeDisabled(); - // Unmount while preview is shown - unmount(); + // Once loadedmetadata fires with non-zero videoWidth, becomes enabled + Object.defineProperty(video, 'videoWidth', { value: 1280, configurable: true }); + Object.defineProperty(video, 'videoHeight', { value: 720, configurable: true }); + fireEvent(video, new Event('loadedmetadata')); - // URL.revokeObjectURL should have been called for the captured image - expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url'); + expect(captureBtn).toBeEnabled(); }); - it('revokes object URL on retake', async () => { - const revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL'); - mockGetUserMedia.mockResolvedValue(createMockStream(trackStopSpy)); - - render(); - fireEvent.click(screen.getByText('Enable Camera')); - - await waitFor(() => { - expect(screen.findByTestId('camera-active')); - }); + it('handles null blob from toBlob with a visible error and keeps stream running for retry', async () => { + const mockTrack = { stop: vi.fn(), kind: 'video' }; + const fakeStream = { + getTracks: vi.fn().mockReturnValue([mockTrack]), + getVideoTracks: vi.fn().mockReturnValue([mockTrack]), + } as unknown as MediaStream; + window.HTMLMediaElement.prototype.play = vi.fn().mockResolvedValue(undefined); - expect(await screen.findByTestId('camera-active')).toBeInTheDocument(); + mockGetUserMedia.mockResolvedValue(fakeStream); - // Capture document by clicking the "Take photo" button - fireEvent.click(screen.getByRole('button', { name: /take photo/i })); + HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue({ + drawImage: vi.fn(), + }) as unknown as typeof HTMLCanvasElement.prototype.getContext; - await waitFor(() => { - expect(screen.getByTestId('captured-preview')).toBeInTheDocument(); + // First produce null blob + HTMLCanvasElement.prototype.toBlob = vi.fn((callback) => { + callback(null); }); - // Click retake - fireEvent.click(screen.getByText('Retake')); - - // URL.revokeObjectURL should have been called for the captured image - expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url'); - }); - - it('revokes object URL on confirm', async () => { - const revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL'); - mockGetUserMedia.mockResolvedValue(createMockStream(trackStopSpy)); - render(); fireEvent.click(screen.getByText('Enable Camera')); - await waitFor(() => { - expect(screen.findByTestId('camera-active')); - }); + const captureBtn = await screen.findByRole('button', { name: /take photo/i }); + const video = screen.getByTestId('camera-video') as HTMLVideoElement; + + Object.defineProperty(video, 'videoWidth', { value: 1280, configurable: true }); + Object.defineProperty(video, 'videoHeight', { value: 720, configurable: true }); + fireEvent(video, new Event('loadedmetadata')); + expect(captureBtn).toBeEnabled(); + + // Click capture with null blob + fireEvent.click(captureBtn); - expect(await screen.findByTestId('camera-active')).toBeInTheDocument(); + // Visible error shown + const errorMsg = await screen.findByRole('alert'); + expect(errorMsg).toHaveTextContent(/failed to capture document/i); - // Capture document by clicking the "Take photo" button - fireEvent.click(screen.getByRole('button', { name: /take photo/i })); + // Stream is not stopped (kept running for retry) + expect(mockTrack.stop).not.toHaveBeenCalled(); - await waitFor(() => { - expect(screen.getByTestId('captured-preview')).toBeInTheDocument(); + // Now retry when toBlob produces a valid blob + global.URL.createObjectURL = vi.fn().mockReturnValue('blob:http://localhost/doc-ok'); + (HTMLCanvasElement.prototype.toBlob as ReturnType).mockImplementation((callback) => { + callback(new Blob(['document-data'], { type: 'image/jpeg' })); }); - // Click confirm - fireEvent.click(screen.getByText('Use this photo')); + fireEvent.click(captureBtn); - // URL.revokeObjectURL should have been called for the captured image - expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url'); + // Transitions to preview and stops stream on success + await screen.findByText(/Use this photo/i); + expect(mockTrack.stop).toHaveBeenCalled(); }); }); }); diff --git a/src/components/KycDocumentCapture/KycDocumentCapture.tsx b/src/components/KycDocumentCapture/KycDocumentCapture.tsx index c99a1e5..7ff381c 100644 --- a/src/components/KycDocumentCapture/KycDocumentCapture.tsx +++ b/src/components/KycDocumentCapture/KycDocumentCapture.tsx @@ -190,26 +190,67 @@ function CameraActiveView({ onSwitchToUpload: () => void; }) { const canvasRef = useRef(null); + const [isVideoReady, setIsVideoReady] = useState(false); + const [isCapturing, setIsCapturing] = useState(false); + const [captureError, setCaptureError] = useState(null); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + const onLoadedMetadata = () => { + if (video.videoWidth > 0) { + setIsVideoReady(true); + } + }; + + if (video.videoWidth > 0) { + setIsVideoReady(true); + } + + video.addEventListener('loadedmetadata', onLoadedMetadata); + return () => { + video.removeEventListener('loadedmetadata', onLoadedMetadata); + }; + }, [videoRef]); + + const handleLoadedMetadata = useCallback((e: React.SyntheticEvent) => { + if (e.currentTarget.videoWidth > 0) { + setIsVideoReady(true); + } + }, []); const handleCapture = useCallback(() => { const video = videoRef.current; const canvas = canvasRef.current; - if (!video || !canvas) return; + if (!video || !canvas || !isVideoReady || isCapturing) return; + setIsCapturing(true); + setCaptureError(null); canvas.width = video.videoWidth; canvas.height = video.videoHeight; const ctx = canvas.getContext('2d'); - if (!ctx) return; + if (!ctx) { + setIsCapturing(false); + setCaptureError('Failed to capture document. Please try again.'); + return; + } ctx.drawImage(video, 0, 0); canvas.toBlob((blob) => { - if (!blob) return; + if (!blob) { + setCaptureError('Failed to capture document. Please try again.'); + setIsCapturing(false); + return; + } + setCaptureError(null); + setIsCapturing(false); const file = new File([blob], `document-capture-${Date.now()}.jpg`, { type: 'image/jpeg', }); onCapture(file); }, 'image/jpeg'); - }, [onCapture]); + }, [videoRef, isVideoReady, isCapturing, onCapture]); return (
@@ -221,10 +262,16 @@ function CameraActiveView({ muted className="kdc-video" data-testid="camera-video" + onLoadedMetadata={handleLoadedMetadata} />