) => {
+ 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}
/>
+ {captureError && (
+
+ {captureError}
+
+ )}
@@ -422,7 +471,7 @@ export const KycDocumentCapture: React.FC = ({
// Cleanup stream and object URL on unmount
useEffect(() => {
return () => {
- if (streamRef.current) {
+ if (streamRef.current && typeof streamRef.current.getTracks === 'function') {
streamRef.current.getTracks().forEach((t) => t.stop());
}
if (capturedUrlRef.current) {
@@ -431,6 +480,21 @@ export const KycDocumentCapture: React.FC = ({
};
}, []);
+ useEffect(() => {
+ if (cameraState === 'active' && videoRef.current && streamRef.current) {
+ const video = videoRef.current;
+ video.srcObject = streamRef.current;
+ try {
+ const playPromise = video.play?.();
+ if (playPromise && typeof playPromise.catch === 'function') {
+ playPromise.catch(() => {});
+ }
+ } catch {
+ // Ignore play interruption or unsupported environments
+ }
+ }
+ }, [cameraState]);
+
const requestCamera = useCallback(async () => {
setCameraState('requesting');
try {
@@ -442,9 +506,6 @@ export const KycDocumentCapture: React.FC = ({
return;
}
streamRef.current = stream;
- if (videoRef.current) {
- videoRef.current.srcObject = stream;
- }
setCameraState('active');
} catch (err) {
if (!isMountedRef.current) return;
@@ -457,7 +518,7 @@ export const KycDocumentCapture: React.FC = ({
}, []);
const stopCamera = useCallback(() => {
- if (streamRef.current) {
+ if (streamRef.current && typeof streamRef.current.getTracks === 'function') {
streamRef.current.getTracks().forEach((t) => t.stop());
streamRef.current = null;
}
diff --git a/src/components/KycSelfieCapture/KycSelfieCapture.css b/src/components/KycSelfieCapture/KycSelfieCapture.css
index 90f9cc0..e9cac5a 100644
--- a/src/components/KycSelfieCapture/KycSelfieCapture.css
+++ b/src/components/KycSelfieCapture/KycSelfieCapture.css
@@ -255,6 +255,21 @@
pointer-events: none;
}
+.ksc-capture-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ pointer-events: none;
+}
+
+.ksc-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);
+}
+
/* ─── Preview & Retake ───────────────────────────────────────────────── */
.ksc-preview {
diff --git a/src/components/KycSelfieCapture/KycSelfieCapture.test.tsx b/src/components/KycSelfieCapture/KycSelfieCapture.test.tsx
index 9ed6eae..698c5d2 100644
--- a/src/components/KycSelfieCapture/KycSelfieCapture.test.tsx
+++ b/src/components/KycSelfieCapture/KycSelfieCapture.test.tsx
@@ -151,105 +151,122 @@ describe("KycSelfieCapture", () => {
expect(heading).toBeTruthy();
});
- 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;
+ 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;
+
+ const playSpy = vi.fn().mockResolvedValue(undefined);
+ window.HTMLMediaElement.prototype.play = playSpy;
+
+ Object.defineProperty(navigator, "mediaDevices", {
+ value: { getUserMedia: vi.fn().mockResolvedValue(fakeStream) },
+ configurable: true,
});
- mockGetUserMedia.mockReturnValueOnce(pendingPromise);
- const { unmount } = render();
+ render();
fireEvent.click(screen.getByRole("button", { name: /allow camera access/i }));
- // Verify getUserMedia was called
- await waitFor(() => {
- expect(mockGetUserMedia).toHaveBeenCalled();
- });
-
- // Unmount before getUserMedia resolves
- unmount();
-
- // Now resolve the promise (simulating getUserMedia resolving after unmount)
- // Use the same trackStopSpy from beforeEach
- const stream = createMockStream(trackStopSpy);
- resolveGetUserMedia!(stream);
- await pendingPromise;
-
- // Track stop should have been called because component was unmounted
- expect(trackStopSpy).toHaveBeenCalled();
+ const video = await screen.findByLabelText("Camera feed") as HTMLVideoElement;
+ expect(video).toBeTruthy();
+ expect(video.srcObject).toBe(fakeStream);
+ expect(playSpy).toHaveBeenCalled();
});
- it("revokes object URL on unmount when selfie is captured", async () => {
- const revokeObjectURLSpy = vi.spyOn(URL, "revokeObjectURL");
+ 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);
+
+ Object.defineProperty(navigator, "mediaDevices", {
+ value: { getUserMedia: vi.fn().mockResolvedValue(fakeStream) },
+ configurable: true,
+ });
- const { unmount } = render();
+ render();
fireEvent.click(screen.getByRole("button", { name: /allow camera access/i }));
- await waitFor(() => {
- expect(screen.getByText("Take a Selfie")).toBeTruthy();
- });
+ const captureBtn = await screen.findByRole("button", { name: /capture photo/i });
+ const video = screen.getByLabelText("Camera feed") as HTMLVideoElement;
- // Capture photo
- fireEvent.click(screen.getByRole("button", { name: /capture photo/i }));
+ // Initially disabled before metadata
+ expect(captureBtn).toBeDisabled();
- await waitFor(() => {
- expect(screen.getByText("Review Your Selfie")).toBeTruthy();
- });
+ // 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: 720, configurable: true });
+ Object.defineProperty(video, "videoHeight", { value: 960, 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");
-
- render();
- fireEvent.click(screen.getByRole("button", { name: /allow camera access/i }));
-
- await waitFor(() => {
- expect(screen.getByText("Take a Selfie")).toBeTruthy();
+ 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);
+
+ Object.defineProperty(navigator, "mediaDevices", {
+ value: { getUserMedia: vi.fn().mockResolvedValue(fakeStream) },
+ configurable: true,
});
- fireEvent.click(screen.getByRole("button", { name: /capture photo/i }));
+ HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue({
+ translate: vi.fn(),
+ scale: vi.fn(),
+ drawImage: vi.fn(),
+ }) as unknown as typeof HTMLCanvasElement.prototype.getContext;
- await waitFor(() => {
- expect(screen.getByText("Review Your Selfie")).toBeTruthy();
+ // First produce null blob
+ HTMLCanvasElement.prototype.toBlob = vi.fn((callback) => {
+ callback(null);
});
- // Click retake
- fireEvent.click(screen.getByRole("button", { name: /retake photo/i }));
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /allow camera access/i }));
- // URL.revokeObjectURL should have been called for the captured image
- expect(revokeObjectURLSpy).toHaveBeenCalledWith("blob:mock-url");
- });
+ const captureBtn = await screen.findByRole("button", { name: /capture photo/i });
+ const video = screen.getByLabelText("Camera feed") as HTMLVideoElement;
- it("revokes object URL on confirm", async () => {
- const revokeObjectURLSpy = vi.spyOn(URL, "revokeObjectURL");
- const onCapture = vi.fn();
+ Object.defineProperty(video, "videoWidth", { value: 720, configurable: true });
+ Object.defineProperty(video, "videoHeight", { value: 960, configurable: true });
+ fireEvent(video, new Event("loadedmetadata"));
+ expect(captureBtn).toBeEnabled();
- render();
- fireEvent.click(screen.getByRole("button", { name: /allow camera access/i }));
+ // Click capture with null blob
+ fireEvent.click(captureBtn);
- await waitFor(() => {
- expect(screen.getByText("Take a Selfie")).toBeTruthy();
- });
+ // Visible error shown
+ const errorMsg = await screen.findByRole("alert");
+ expect(errorMsg).toHaveTextContent(/failed to capture/i);
- fireEvent.click(screen.getByRole("button", { name: /capture photo/i }));
+ // Stream is not stopped (kept running for retry)
+ expect(mockTrack.stop).not.toHaveBeenCalled();
- await waitFor(() => {
- expect(screen.getByText("Review Your Selfie")).toBeTruthy();
+ // Now retry when toBlob produces a valid blob
+ global.URL.createObjectURL = vi.fn().mockReturnValue("blob:http://localhost/selfie-ok");
+ (HTMLCanvasElement.prototype.toBlob as ReturnType).mockImplementation((callback) => {
+ callback(new Blob(["selfie"], { type: "image/png" }));
});
- // Click confirm
- fireEvent.click(screen.getByRole("button", { name: /use this photo/i }));
+ fireEvent.click(captureBtn);
- // URL.revokeObjectURL should have been called for the captured image
- expect(revokeObjectURLSpy).toHaveBeenCalledWith("blob:mock-url");
- expect(onCapture).toHaveBeenCalled();
+ // Transitions to preview and stops stream on success
+ await screen.findByRole("heading", { level: 2, name: /review your selfie/i });
+ expect(mockTrack.stop).toHaveBeenCalled();
});
});
});
diff --git a/src/components/KycSelfieCapture/KycSelfieCapture.tsx b/src/components/KycSelfieCapture/KycSelfieCapture.tsx
index 6bc0466..d104d1b 100644
--- a/src/components/KycSelfieCapture/KycSelfieCapture.tsx
+++ b/src/components/KycSelfieCapture/KycSelfieCapture.tsx
@@ -65,6 +65,8 @@ export default function KycSelfieCapture({
const [capturedImage, setCapturedImage] = useState(null);
const [capturedFile, setCapturedFile] = useState(null);
const [isCapturing, setIsCapturing] = useState(false);
+ const [isVideoReady, setIsVideoReady] = useState(false);
+ const [captureError, setCaptureError] = useState(null);
// Track mounted state
useEffect(() => {
@@ -77,7 +79,7 @@ export default function KycSelfieCapture({
// Cleanup stream and object URL on unmount
useEffect(() => {
return () => {
- if (streamRef.current) {
+ if (streamRef.current && typeof streamRef.current.getTracks === 'function') {
streamRef.current.getTracks().forEach((t) => t.stop());
}
if (capturedImageRef.current) {
@@ -87,14 +89,52 @@ export default function KycSelfieCapture({
}, []);
const stopStream = useCallback(() => {
- if (streamRef.current) {
+ if (streamRef.current && typeof streamRef.current.getTracks === 'function') {
streamRef.current.getTracks().forEach((t) => t.stop());
streamRef.current = null;
}
+ if (videoRef.current) {
+ videoRef.current.srcObject = null;
+ }
+ setIsVideoReady(false);
}, []);
+ useEffect(() => {
+ if (cameraState === 'active' && videoRef.current && streamRef.current) {
+ const video = videoRef.current;
+ video.srcObject = streamRef.current;
+ try {
+ const playPromise = video.play?.();
+ if (playPromise && typeof playPromise.catch === 'function') {
+ playPromise.catch(() => {});
+ }
+ } catch {
+ // Ignore play interruption or unsupported environments
+ }
+
+ const onLoadedMetadata = () => {
+ if (video.videoWidth > 0) {
+ setIsVideoReady(true);
+ }
+ };
+
+ if (video.videoWidth > 0) {
+ setIsVideoReady(true);
+ }
+
+ video.addEventListener('loadedmetadata', onLoadedMetadata);
+ return () => {
+ video.removeEventListener('loadedmetadata', onLoadedMetadata);
+ };
+ } else {
+ setIsVideoReady(false);
+ }
+ }, [cameraState]);
+
const startCamera = useCallback(async () => {
setCameraState('requesting');
+ setIsVideoReady(false);
+ setCaptureError(null);
await Promise.resolve();
try {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
@@ -114,9 +154,6 @@ export default function KycSelfieCapture({
return;
}
streamRef.current = stream;
- if (videoRef.current) {
- videoRef.current.srcObject = stream;
- }
setCameraState('active');
} catch (err: unknown) {
if (!isMountedRef.current) return;
@@ -134,17 +171,25 @@ export default function KycSelfieCapture({
}
}, []);
+ const handleLoadedMetadata = useCallback((e: React.SyntheticEvent) => {
+ if (e.currentTarget.videoWidth > 0) {
+ setIsVideoReady(true);
+ }
+ }, []);
+
const capturePhoto = 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) {
setIsCapturing(false);
+ setCaptureError('Failed to capture photo. Please try again.');
return;
}
@@ -155,16 +200,18 @@ export default function KycSelfieCapture({
canvas.toBlob((blob) => {
if (blob) {
+ setCaptureError(null);
const file = new File([blob], 'selfie.png', { type: 'image/png' });
setCapturedFile(file);
- const objectUrl = URL.createObjectURL(blob);
- capturedImageRef.current = objectUrl;
- setCapturedImage(objectUrl);
+ setCapturedImage(URL.createObjectURL(blob));
+ setIsCapturing(false);
+ stopStream();
+ } else {
+ setCaptureError('Failed to capture photo. Please try again.');
+ setIsCapturing(false);
}
- setIsCapturing(false);
- stopStream();
}, 'image/png');
- }, [stopStream]);
+ }, [isVideoReady, isCapturing, stopStream]);
const handleRetake = useCallback(() => {
if (capturedImageRef.current) {
@@ -173,6 +220,7 @@ export default function KycSelfieCapture({
}
setCapturedImage(null);
setCapturedFile(null);
+ setCaptureError(null);
startCamera();
}, [startCamera]);
@@ -421,7 +469,14 @@ export default function KycSelfieCapture({
-
+
Align your face within the frame
@@ -429,6 +484,12 @@ export default function KycSelfieCapture({
+ {captureError && (
+
+ {captureError}
+
+ )}
+
diff --git a/src/components/KycSelfieCapture/index.test.tsx b/src/components/KycSelfieCapture/index.test.tsx
index bf42838..6dc09c5 100644
--- a/src/components/KycSelfieCapture/index.test.tsx
+++ b/src/components/KycSelfieCapture/index.test.tsx
@@ -12,11 +12,24 @@ import DirectKycSelfieCapture from './KycSelfieCapture';
expect.extend(toHaveNoViolations);
+beforeEach(() => {
+ window.HTMLMediaElement.prototype.play = vi.fn().mockResolvedValue(undefined);
+});
+
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
+const simulateVideoLoadedMetadata = (videoWidth = 720, videoHeight = 960) => {
+ const video = document.querySelector('video') as HTMLVideoElement;
+ if (video) {
+ Object.defineProperty(video, 'videoWidth', { value: videoWidth, configurable: true, writable: true });
+ Object.defineProperty(video, 'videoHeight', { value: videoHeight, configurable: true, writable: true });
+ fireEvent(video, new Event('loadedmetadata'));
+ }
+};
+
describe('KycSelfieCapture index module - public contract & exported behavior', () => {
it('exposes KycSelfieCapture as default named export matching the direct implementation', () => {
expect(IndexBarrel.KycSelfieCapture).toBeDefined();
@@ -201,6 +214,8 @@ describe('KycSelfieCapture index export - primary state transitions', () => {
expect(screen.getByRole('button', { name: /capture photo/i })).toBeTruthy();
});
+ simulateVideoLoadedMetadata();
+
fireEvent.click(screen.getByRole('button', { name: /capture photo/i }));
await waitFor(() => {
@@ -222,6 +237,8 @@ describe('KycSelfieCapture index export - primary state transitions', () => {
expect(screen.getByRole('button', { name: /capture photo/i })).toBeTruthy();
});
+ simulateVideoLoadedMetadata();
+
fireEvent.click(screen.getByRole('button', { name: /capture photo/i }));
await waitFor(() => {
@@ -248,6 +265,8 @@ describe('KycSelfieCapture index export - primary state transitions', () => {
expect(screen.getByRole('button', { name: /capture photo/i })).toBeTruthy();
});
+ simulateVideoLoadedMetadata();
+
fireEvent.click(screen.getByRole('button', { name: /capture photo/i }));
await waitFor(() => {
@@ -401,6 +420,8 @@ describe('KycSelfieCapture index export - representative invalid inputs & bounda
expect(screen.getByRole('button', { name: /capture photo/i })).toBeTruthy();
});
+ simulateVideoLoadedMetadata();
+
// Capture photo
fireEvent.click(screen.getByRole('button', { name: /capture photo/i }));
@@ -465,6 +486,8 @@ describe('KycSelfieCapture index export - representative invalid inputs & bounda
expect(screen.getByRole('button', { name: /capture photo/i })).toBeTruthy();
});
+ simulateVideoLoadedMetadata();
+
// Clicking capture when 2d context fails returns cleanly without crashing
expect(() => {
fireEvent.click(screen.getByRole('button', { name: /capture photo/i }));
@@ -503,9 +526,19 @@ describe('KycSelfieCapture index export - representative invalid inputs & bounda
expect(screen.getByRole('button', { name: /capture photo/i })).toBeTruthy();
});
+ simulateVideoLoadedMetadata();
+
expect(() => {
fireEvent.click(screen.getByRole('button', { name: /capture photo/i }));
}).not.toThrow();
+
+ await waitFor(() => {
+ expect(screen.getByRole('alert')).toBeTruthy();
+ expect(screen.getByText(/Failed to capture photo/i)).toBeTruthy();
+ });
+
+ // Stream tracks should still be active for retry (not stopped)
+ expect(mockStream.getTracks()[0].stop).not.toHaveBeenCalled();
});
it('stops media tracks on unmount when stream is active', async () => {
diff --git a/src/components/KycSelfieCapture/kycCaptureRegression.test.tsx b/src/components/KycSelfieCapture/kycCaptureRegression.test.tsx
new file mode 100644
index 0000000..43c20c3
--- /dev/null
+++ b/src/components/KycSelfieCapture/kycCaptureRegression.test.tsx
@@ -0,0 +1,151 @@
+// @vitest-environment jsdom
+
+import React from 'react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, fireEvent, cleanup } from '@testing-library/react';
+import KycSelfieCapture from './KycSelfieCapture';
+import { KycDocumentCapture } from '../KycDocumentCapture/KycDocumentCapture';
+
+describe('KYC Capture Stream Attachment Regression', () => {
+ let mockTrack: { stop: ReturnType; kind: string };
+ let fakeStream: MediaStream;
+
+ beforeEach(() => {
+ mockTrack = { stop: vi.fn(), kind: 'video' };
+ fakeStream = {
+ getTracks: vi.fn().mockReturnValue([mockTrack]),
+ getVideoTracks: vi.fn().mockReturnValue([mockTrack]),
+ } as unknown as MediaStream;
+
+ window.HTMLMediaElement.prototype.play = vi.fn().mockResolvedValue(undefined);
+
+ Object.defineProperty(navigator, 'mediaDevices', {
+ value: {
+ getUserMedia: vi.fn().mockResolvedValue(fakeStream),
+ },
+ configurable: true,
+ });
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it('regression: KycSelfieCapture attaches MediaStream to video.srcObject after activation instead of leaving it null', async () => {
+ render();
+
+ // Click permission primer button to activate camera
+ fireEvent.click(screen.getByRole('button', { name: /allow camera access/i }));
+
+ // On master, videoRef.current.srcObject was assigned while state was 'requesting',
+ // so videoRef.current was null and video.srcObject was never attached.
+ const video = (await screen.findByLabelText('Camera feed')) as HTMLVideoElement;
+ expect(video).toBeInTheDocument();
+ expect(video.srcObject).toBe(fakeStream);
+ });
+
+ it('regression: KycDocumentCapture attaches MediaStream to video.srcObject after activation instead of leaving it null', async () => {
+ render();
+
+ // Click permission prompt to activate camera
+ fireEvent.click(screen.getByRole('button', { name: /enable camera/i }));
+
+ // On master, CameraActiveView only mounted after active state, leaving videoRef.current null during requestCamera
+ const video = (await screen.findByTestId('camera-video')) as HTMLVideoElement;
+ expect(video).toBeInTheDocument();
+ expect(video.srcObject).toBe(fakeStream);
+ });
+
+ it('regression: KycSelfieCapture disables capture until loadedmetadata with non-zero videoWidth', async () => {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /allow camera access/i }));
+
+ const captureBtn = await screen.findByRole('button', { name: /capture photo/i });
+ const video = screen.getByLabelText('Camera feed') as HTMLVideoElement;
+
+ // Disabled before metadata loads
+ expect(captureBtn).toBeDisabled();
+
+ // Still disabled if loadedmetadata fires with 0 width
+ Object.defineProperty(video, 'videoWidth', { value: 0, configurable: true });
+ fireEvent(video, new Event('loadedmetadata'));
+ expect(captureBtn).toBeDisabled();
+
+ // Enabled only when videoWidth > 0
+ Object.defineProperty(video, 'videoWidth', { value: 720, configurable: true });
+ Object.defineProperty(video, 'videoHeight', { value: 960, configurable: true });
+ fireEvent(video, new Event('loadedmetadata'));
+ expect(captureBtn).toBeEnabled();
+ });
+
+ it('regression: KycDocumentCapture disables capture until loadedmetadata with non-zero videoWidth', async () => {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /enable camera/i }));
+
+ const captureBtn = await screen.findByRole('button', { name: /take photo/i });
+ const video = screen.getByTestId('camera-video') as HTMLVideoElement;
+
+ // Disabled before metadata loads
+ expect(captureBtn).toBeDisabled();
+
+ // Still disabled if loadedmetadata fires with 0 width
+ Object.defineProperty(video, 'videoWidth', { value: 0, configurable: true });
+ fireEvent(video, new Event('loadedmetadata'));
+ expect(captureBtn).toBeDisabled();
+
+ // Enabled only when videoWidth > 0
+ Object.defineProperty(video, 'videoWidth', { value: 1280, configurable: true });
+ Object.defineProperty(video, 'videoHeight', { value: 720, configurable: true });
+ fireEvent(video, new Event('loadedmetadata'));
+ expect(captureBtn).toBeEnabled();
+ });
+
+ it('regression: null blob in KycSelfieCapture displays visible error and keeps stream alive for retry', async () => {
+ HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue({
+ translate: vi.fn(),
+ scale: vi.fn(),
+ drawImage: vi.fn(),
+ }) as unknown as typeof HTMLCanvasElement.prototype.getContext;
+
+ HTMLCanvasElement.prototype.toBlob = vi.fn((cb) => cb(null));
+
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /allow camera access/i }));
+
+ const captureBtn = await screen.findByRole('button', { name: /capture photo/i });
+ const video = screen.getByLabelText('Camera feed') as HTMLVideoElement;
+ Object.defineProperty(video, 'videoWidth', { value: 720, configurable: true });
+ Object.defineProperty(video, 'videoHeight', { value: 960, configurable: true });
+ fireEvent(video, new Event('loadedmetadata'));
+
+ fireEvent.click(captureBtn);
+
+ const alert = await screen.findByRole('alert');
+ expect(alert).toHaveTextContent(/failed to capture/i);
+ expect(mockTrack.stop).not.toHaveBeenCalled();
+ });
+
+ it('regression: null blob in KycDocumentCapture displays visible error and keeps stream alive for retry', async () => {
+ HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue({
+ drawImage: vi.fn(),
+ }) as unknown as typeof HTMLCanvasElement.prototype.getContext;
+
+ HTMLCanvasElement.prototype.toBlob = vi.fn((cb) => cb(null));
+
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /enable camera/i }));
+
+ 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'));
+
+ fireEvent.click(captureBtn);
+
+ const alert = await screen.findByRole('alert');
+ expect(alert).toHaveTextContent(/failed to capture document/i);
+ expect(mockTrack.stop).not.toHaveBeenCalled();
+ });
+});