import { StrictMode } from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { act, screen } from '@testing-library/react'; import { render } from '../utils'; import { CameraTile } from '../../components/CameraTile'; import { setStreamToken } from '../../api/client'; // The shared render() util mounts AuthProvider, which fires an async // /auth/me probe on mount. Each test absorbs that settle with a single // `await act(async () => {})` after render so the AuthProvider state // update doesn't bleed into the assertion phase as an act() warning. async function flushMicrotasks() { await act(async () => { await Promise.resolve(); }); } describe('CameraTile', () => { beforeEach(() => { vi.useFakeTimers(); vi.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 200 })); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); it('renders the live stream URL in live mode', async () => { render( , ); await flushMicrotasks(); const img = screen.getByAltText('X1C-Lab') as HTMLImageElement; expect(img.src).toContain('/api/v1/printers/42/camera/stream'); expect(img.src).toContain('fps=8'); }); it('renders the snapshot URL and refreshes on the interval', async () => { render( , ); await flushMicrotasks(); const initial = (screen.getByAltText('P1S-Garage') as HTMLImageElement).src; expect(initial).toContain('/api/v1/printers/7/camera/snapshot'); await act(async () => { vi.advanceTimersByTime(1500); }); const refreshed = (screen.getByAltText('P1S-Garage') as HTMLImageElement).src; expect(refreshed).toContain('/api/v1/printers/7/camera/snapshot'); expect(refreshed).not.toBe(initial); }); it('shows an offline placeholder when not connected', async () => { render( , ); await flushMicrotasks(); expect(screen.queryByAltText('A1-Offline')).toBeNull(); }); it('shows the paused placeholder in paused mode', async () => { render( , ); await flushMicrotasks(); expect(screen.queryByAltText('H2D-Booth')).toBeNull(); }); // External cameras run one stream per viewer; the stop names this tile's own, // so it ends this tile's stream and not another viewer's of the printer. it('stops its own stream by the viewer id it opened it with', async () => { const fetchMock = vi.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 200 })); const { rerender } = render( , ); await flushMicrotasks(); const viewer = new URL((screen.getByAltText('X1C-Viewer') as HTMLImageElement).src).searchParams.get('viewer'); expect(viewer).toMatch(/^[A-Za-z0-9_-]{1,64}$/); await act(async () => { rerender(); }); const stop = fetchMock.mock.calls.map(([url]) => String(url)).find((url) => url.includes('/camera/stop')); expect(new URL(stop!, 'http://x').searchParams.get('viewer')).toBe(viewer); }); it('gives each tile its own viewer id', async () => { render( <> , ); await flushMicrotasks(); const id = (name: string) => new URL((screen.getByAltText(name) as HTMLImageElement).src).searchParams.get('viewer'); expect(id('Tile-A')).not.toBe(id('Tile-B')); }); it('POSTs /camera/stop when leaving live mode', async () => { const fetchMock = vi.spyOn(global, 'fetch').mockResolvedValue( new Response(null, { status: 200 }), ); const { rerender } = render( , ); await flushMicrotasks(); fetchMock.mockClear(); await act(async () => { rerender( , ); }); const stopCalls = fetchMock.mock.calls.filter(([url]) => String(url).includes('/api/v1/printers/11/camera/stop'), ); expect(stopCalls.length).toBeGreaterThan(0); }); // A server-ended MJPEG stream fires no img event (load once in Chromium, per // frame in Firefox, nothing at the end), so a live tile renews its stream. it('renews a live stream every minute without stopping it', async () => { render( , ); await flushMicrotasks(); const initial = (screen.getByAltText('X1C-Lab') as HTMLImageElement).src; await act(async () => { vi.advanceTimersByTime(59_000); }); expect((screen.getByAltText('X1C-Lab') as HTMLImageElement).src).toBe(initial); await act(async () => { vi.advanceTimersByTime(2_000); }); const renewed = (screen.getByAltText('X1C-Lab') as HTMLImageElement).src; expect(renewed).toContain('/api/v1/printers/42/camera/stream'); expect(renewed).not.toBe(initial); // /camera/stop would end the stream the other viewers of this printer share. expect(vi.mocked(global.fetch).mock.calls.filter(([url]) => String(url).includes('/camera/stop'))).toHaveLength(0); }); it('closes the old stream before opening the renewed one', async () => { // A detached img keeps its MJPEG connection open (measured in Chromium): // the browser's per-host pool fills and a USB camera stays held. render( , ); await flushMicrotasks(); const old = screen.getByAltText('X1C-Lab') as HTMLImageElement; await act(async () => { vi.advanceTimersByTime(61_000); }); expect(old.getAttribute('src')).toBeNull(); expect(screen.getByAltText('X1C-Lab')).not.toBe(old); }); it('takes the error of a cleared img for no failure', async () => { // Browsers fire error on an img whose src is set to '' (the renewal's close). render( , ); await flushMicrotasks(); const img = screen.getByAltText('X1C-Lab') as HTMLImageElement; await act(async () => { img.setAttribute('src', ''); img.dispatchEvent(new Event('error')); }); expect(screen.getByAltText('X1C-Lab')).toBeTruthy(); }); it('waits a moment between closing the old stream and opening the new one', async () => { // A USB camera serves one reader: the old ffmpeg must let go of it first. render( , ); await flushMicrotasks(); const old = screen.getByAltText('X1C-Lab') as HTMLImageElement; await act(async () => { vi.advanceTimersByTime(60_100); }); expect(old.getAttribute('src')).toBeNull(); expect(screen.getByAltText('X1C-Lab')).toBe(old); await act(async () => { vi.advanceTimersByTime(200); }); expect(screen.getByAltText('X1C-Lab')).not.toBe(old); }); it('does not renew while the page is hidden', async () => { // The stream token is not refreshed in a background tab; a renewal there // would ask with an expired one. const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true); render( , ); await flushMicrotasks(); const old = screen.getByAltText('X1C-Lab') as HTMLImageElement; const src = old.src; await act(async () => { vi.advanceTimersByTime(125_000); }); expect(screen.getByAltText('X1C-Lab')).toBe(old); expect(old.src).toBe(src); hidden.mockRestore(); }); async function renewedThenFailed() { render( , ); await flushMicrotasks(); await act(async () => { vi.advanceTimersByTime(60_500); }); const renewed = screen.getByAltText('X1C-Lab') as HTMLImageElement; await act(async () => { renewed.dispatchEvent(new Event('error')); }); return renewed; } it('retries a renewal that failed once, after a few seconds', async () => { const renewed = await renewedThenFailed(); expect(screen.getByAltText('X1C-Lab')).toBe(renewed); await act(async () => { vi.advanceTimersByTime(5_500); }); const retried = screen.getByAltText('X1C-Lab') as HTMLImageElement; expect(retried).not.toBe(renewed); expect(retried.src).toContain('/api/v1/printers/42/camera/stream'); }); it('keeps the stream closed while it waits, whatever re-renders the tile', async () => { // A failed renewal makes the app mint a new stream token; the re-render it // causes must not reconnect the img before the retry is due. const { rerender } = render( , ); await flushMicrotasks(); await act(async () => { vi.advanceTimersByTime(60_500); }); await act(async () => { screen.getByAltText('X1C-Lab').dispatchEvent(new Event('error')); }); await act(async () => { setStreamToken('fresh-token'); rerender( , ); }); expect(screen.getByAltText('X1C-Lab').getAttribute('src')).toBeNull(); setStreamToken(null); }); it('shows no signal when the retry fails too', async () => { await renewedThenFailed(); await act(async () => { vi.advanceTimersByTime(5_500); }); await act(async () => { screen.getByAltText('X1C-Lab').dispatchEvent(new Event('error')); }); expect(screen.queryByAltText('X1C-Lab')).toBeNull(); }); it('treats a renewed stream that showed a frame like any other', async () => { render( , ); await flushMicrotasks(); await act(async () => { vi.advanceTimersByTime(60_500); }); const renewed = screen.getByAltText('X1C-Lab'); await act(async () => { renewed.dispatchEvent(new Event('load')); }); await act(async () => { renewed.dispatchEvent(new Event('error')); }); expect(screen.queryByAltText('X1C-Lab')).toBeNull(); }); it('closes the live stream when the tile leaves live mode', async () => { const { rerender } = render( , ); await flushMicrotasks(); const live = screen.getByAltText('X1C-Lab') as HTMLImageElement; await act(async () => { rerender(); }); await flushMicrotasks(); expect(live.getAttribute('src')).toBeNull(); }); it('closes the live stream when the tile goes away', async () => { const { unmount } = render( , ); await flushMicrotasks(); const live = screen.getByAltText('X1C-Lab') as HTMLImageElement; unmount(); await flushMicrotasks(); expect(live.getAttribute('src')).toBeNull(); }); // A snapshot camera that is down keeps its stream open without a frame: the // img fires neither load nor error, and the tile would stay blank. async function renewedWithoutFrame() { render( , ); await flushMicrotasks(); await act(async () => { vi.advanceTimersByTime(60_500); }); return screen.getByAltText('X1C-Lab') as HTMLImageElement; } it('retries a renewal that shows no frame within 15 seconds', async () => { const renewed = await renewedWithoutFrame(); await act(async () => { vi.advanceTimersByTime(15_000); }); expect(screen.getByAltText('X1C-Lab')).toBe(renewed); await act(async () => { vi.advanceTimersByTime(5_500); }); expect(screen.getByAltText('X1C-Lab')).not.toBe(renewed); }); it('shows no signal when the retry shows no frame either', async () => { await renewedWithoutFrame(); await act(async () => { vi.advanceTimersByTime(20_500); }); await act(async () => { vi.advanceTimersByTime(15_500); }); expect(screen.queryByAltText('X1C-Lab')).toBeNull(); }); it('keeps a renewal that showed a frame', async () => { const renewed = await renewedWithoutFrame(); await act(async () => { renewed.dispatchEvent(new Event('load')); }); await act(async () => { vi.advanceTimersByTime(30_000); }); expect(screen.getByAltText('X1C-Lab')).toBe(renewed); }); it('keeps its stream under StrictMode', async () => { // React 19 runs a ref's cleanup once on mount in development; that must // not close the stream of the img that stays. render( , ); await flushMicrotasks(); expect(screen.getByAltText('X1C-Lab').getAttribute('src')).toContain('/api/v1/printers/42/camera/stream'); }); it('does not count the time a hidden page cannot load the renewal', async () => { // A lazy img does not load in a background tab; the tab hidden right after // a renewal must not end a working camera on no signal. const renewed = await renewedWithoutFrame(); const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true); await act(async () => { vi.advanceTimersByTime(45_000); }); hidden.mockRestore(); expect(screen.getByAltText('X1C-Lab')).toBe(renewed); }); it('leaves a tile showing no signal alone', async () => { // Its retry would fail again, and every failed camera image makes the app // mint a new stream token and reconnect every camera on the page. render( , ); await flushMicrotasks(); await act(async () => { screen.getByAltText('X1C-Lab').dispatchEvent(new Event('error')); }); await act(async () => { vi.advanceTimersByTime(121_000); }); expect(screen.queryByAltText('X1C-Lab')).toBeNull(); }); describe('a failed snapshot', () => { // The server answers 503 while a live view of the printer is starting: // it will not take the camera from that viewer. One failure is retried. async function snapshotTile() { render( , ); await flushMicrotasks(); } const fail = async () => act(async () => { screen.getByAltText('P1S-Garage').dispatchEvent(new Event('error')); }); const tick = async () => act(async () => { vi.advanceTimersByTime(2100); }); it('is tried again at the next refresh', async () => { await snapshotTile(); const first = (screen.getByAltText('P1S-Garage') as HTMLImageElement).src; await fail(); expect(screen.queryByText('No signal')).toBeNull(); await tick(); const retried = screen.getByAltText('P1S-Garage') as HTMLImageElement; expect(retried.src).toContain('/api/v1/printers/7/camera/snapshot'); expect(retried.src).not.toBe(first); }); it('shows no signal when the retry fails too', async () => { await snapshotTile(); await fail(); await tick(); await fail(); expect(screen.getByText('No signal')).toBeInTheDocument(); }); it('gets a fresh retry after a snapshot loads', async () => { await snapshotTile(); await fail(); await tick(); await act(async () => { screen.getByAltText('P1S-Garage').dispatchEvent(new Event('load')); }); await tick(); await fail(); expect(screen.queryByText('No signal')).toBeNull(); }); }); });