/** @vitest-environment jsdom */ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { en } from '../i18n/en'; import { AvatarUpload } from './AvatarUpload'; const { fileToBase64, resizeAvatarImage } = vi.hoisted(() => ({ fileToBase64: vi.fn(), resizeAvatarImage: vi.fn(), })); vi.mock('../imageUtils', () => ({ fileToBase64, resizeAvatarImage, UndecodableImageError: class UndecodableImageError extends Error {}, })); vi.mock('../storage', () => ({ hasConsent: () => false, setConsent: vi.fn(), })); const canvasContext = { save: vi.fn(), translate: vi.fn(), scale: vi.fn(), drawImage: vi.fn(), restore: vi.fn(), }; function stream(trackCount = 1) { const stops = Array.from({ length: trackCount }, () => vi.fn()); return { mediaStream: { getTracks: () => stops.map((stop) => ({ stop })) } as unknown as MediaStream, stop: stops[0], stops, }; } function setMediaDevices(getUserMedia?: ReturnType) { Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: getUserMedia ? { getUserMedia } : undefined, }); } beforeEach(() => { Object.defineProperty(window, 'isSecureContext', { configurable: true, value: true }); fileToBase64.mockReset().mockResolvedValue('data:image/jpeg;base64,RAW'); resizeAvatarImage.mockReset().mockResolvedValue('data:image/jpeg;base64,RESIZED'); vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); Object.defineProperty(HTMLVideoElement.prototype, 'videoWidth', { configurable: true, value: 640 }); Object.defineProperty(HTMLVideoElement.prototype, 'videoHeight', { configurable: true, value: 960 }); Object.values(canvasContext).forEach((mock) => mock.mockClear()); vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(canvasContext as unknown as CanvasRenderingContext2D); vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation((callback) => callback(new Blob(['frame'], { type: 'image/jpeg' }))); }); afterEach(() => { cleanup(); vi.restoreAllMocks(); }); describe('desktop camera capture', () => { it('requests permission only after the explicit action and reuses the upload pipeline', async () => { const first = stream(); const second = stream(); const getUserMedia = vi.fn() .mockResolvedValueOnce(first.mediaStream) .mockResolvedValueOnce(second.mediaStream); setMediaDevices(getUserMedia); const onAvatarSaved = vi.fn(); render(); expect(getUserMedia).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: en.upload.takePhoto })); const captureButton = await screen.findByRole('button', { name: en.upload.cameraCapture }); expect(getUserMedia).toHaveBeenCalledWith({ audio: false, video: { facingMode: 'user' } }); expect(document.activeElement).toBe(captureButton); fireEvent.click(captureButton); await waitFor(() => expect(resizeAvatarImage).toHaveBeenCalledWith('data:image/jpeg;base64,RAW')); expect(first.stop).toHaveBeenCalledTimes(1); expect(canvasContext.translate).toHaveBeenCalledWith(640, 0); expect(canvasContext.scale).toHaveBeenCalledWith(-1, 1); expect(canvasContext.drawImage).toHaveBeenCalledTimes(1); expect(onAvatarSaved).not.toHaveBeenCalled(); expect(screen.getByRole('button', { name: en.upload.cameraRetry })).toBeDefined(); fireEvent.click(screen.getByRole('checkbox')); fireEvent.click(screen.getByRole('button', { name: en.upload.useThis })); expect(onAvatarSaved).toHaveBeenCalledWith('data:image/jpeg;base64,RESIZED'); fireEvent.click(screen.getByRole('button', { name: en.upload.cameraRetry })); await waitFor(() => expect(getUserMedia).toHaveBeenCalledTimes(2)); }); it('stops every active track on switching to upload and on unmount', async () => { const first = stream(2); const second = stream(); const getUserMedia = vi.fn() .mockResolvedValueOnce(first.mediaStream) .mockResolvedValueOnce(second.mediaStream); setMediaDevices(getUserMedia); const view = render( {}} t={en.upload} />); fireEvent.click(screen.getByRole('button', { name: en.upload.takePhoto })); await screen.findByRole('button', { name: en.upload.cameraUseUpload }); fireEvent.click(screen.getByRole('button', { name: en.upload.cameraUseUpload })); for (const stop of first.stops) expect(stop).toHaveBeenCalledTimes(1); await waitFor(() => expect(document.activeElement).toBe(document.querySelector('.aisthetix-file-input'))); fireEvent.click(screen.getByRole('button', { name: en.upload.takePhoto })); await waitFor(() => expect(getUserMedia).toHaveBeenCalledTimes(2)); view.unmount(); expect(second.stop).toHaveBeenCalledTimes(1); }); it('stops a stream that resolves after the camera view was closed', async () => { let resolve!: (value: MediaStream) => void; const pending = new Promise((done) => { resolve = done; }); const late = stream(); setMediaDevices(vi.fn().mockReturnValue(pending)); const view = render( {}} t={en.upload} />); fireEvent.click(screen.getByRole('button', { name: en.upload.takePhoto })); view.unmount(); await act(async () => resolve(late.mediaStream)); expect(late.stop).toHaveBeenCalledTimes(1); }); it.each([ ['NotAllowedError', en.upload.cameraPermissionDenied], ['NotFoundError', en.upload.cameraNoDevice], ['NotReadableError', en.upload.cameraBusy], ['UnknownError', en.upload.cameraFailed], ])('keeps upload available after %s without retry loops', async (name, copy) => { const getUserMedia = vi.fn().mockRejectedValue(new DOMException('camera error', name)); setMediaDevices(getUserMedia); render( {}} t={en.upload} />); fireEvent.click(screen.getByRole('button', { name: en.upload.takePhoto })); await screen.findByText(copy); expect(getUserMedia).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByRole('button', { name: en.upload.cameraUseUpload })); expect(document.querySelector('input[type="file"]')).not.toBeNull(); expect(getUserMedia).toHaveBeenCalledTimes(1); }); it('explains unsupported and insecure contexts while preserving file upload', () => { setMediaDevices(); const { rerender } = render( {}} t={en.upload} />); expect(screen.getByText(en.upload.cameraUnavailable)).toBeDefined(); expect(document.querySelector('input[type="file"]')).not.toBeNull(); Object.defineProperty(window, 'isSecureContext', { configurable: true, value: false }); rerender( {}} t={en.upload} />); expect(screen.getByText(en.upload.cameraInsecure)).toBeDefined(); }); });