CameraPage.test.tsx 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. /**
  2. * Tests for the CameraPage component.
  3. */
  4. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  5. import { screen, waitFor, render as rtlRender } from '@testing-library/react';
  6. import { CameraPage } from '../../pages/CameraPage';
  7. import { http, HttpResponse } from 'msw';
  8. import { server } from '../mocks/server';
  9. import { MemoryRouter, Route, Routes } from 'react-router-dom';
  10. import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
  11. import { ThemeProvider } from '../../contexts/ThemeContext';
  12. import { ToastProvider } from '../../contexts/ToastContext';
  13. import { AuthProvider } from '../../contexts/AuthContext';
  14. import { I18nextProvider } from 'react-i18next';
  15. import i18n from '../../i18n';
  16. // Mock navigator.sendBeacon which isn't available in jsdom
  17. vi.stubGlobal('navigator', {
  18. ...navigator,
  19. sendBeacon: vi.fn().mockReturnValue(true),
  20. });
  21. const mockPrinter = {
  22. id: 1,
  23. name: 'X1 Carbon',
  24. ip_address: '192.168.1.100',
  25. serial_number: '00M09A350100001',
  26. access_code: '12345678',
  27. model: 'X1C',
  28. enabled: true,
  29. };
  30. // Custom render for CameraPage which needs specific route params
  31. function renderCameraPage(printerId: number, search = '') {
  32. const queryClient = new QueryClient({
  33. defaultOptions: {
  34. queries: { retry: false, gcTime: 0 },
  35. mutations: { retry: false },
  36. },
  37. });
  38. return rtlRender(
  39. <QueryClientProvider client={queryClient}>
  40. <I18nextProvider i18n={i18n}>
  41. <MemoryRouter initialEntries={[`/cameras/${printerId}${search}`]}>
  42. <AuthProvider>
  43. <ThemeProvider>
  44. <ToastProvider>
  45. <Routes>
  46. <Route path="/cameras/:printerId" element={<CameraPage />} />
  47. </Routes>
  48. </ToastProvider>
  49. </ThemeProvider>
  50. </AuthProvider>
  51. </MemoryRouter>
  52. </I18nextProvider>
  53. </QueryClientProvider>
  54. );
  55. }
  56. describe('CameraPage', () => {
  57. const originalTitle = document.title;
  58. beforeEach(() => {
  59. server.use(
  60. http.get('/api/v1/printers/:id', () => {
  61. return HttpResponse.json(mockPrinter);
  62. }),
  63. http.get('/api/v1/printers/:id/status', () => {
  64. return HttpResponse.json({
  65. connected: true,
  66. state: 'IDLE',
  67. progress: 0,
  68. });
  69. }),
  70. http.post('/api/v1/printers/:id/camera/stop', () => {
  71. return HttpResponse.json({ success: true });
  72. }),
  73. http.get('/api/v1/printers/:id/camera/status', () => {
  74. return HttpResponse.json({ active: true, stalled: false });
  75. })
  76. );
  77. });
  78. afterEach(() => {
  79. document.title = originalTitle;
  80. });
  81. describe('rendering', () => {
  82. it('renders camera page for printer', async () => {
  83. renderCameraPage(1);
  84. // Camera page should load - look for the header with camera icon
  85. await waitFor(() => {
  86. expect(screen.getByRole('heading')).toBeInTheDocument();
  87. });
  88. });
  89. it('shows live and snapshot mode buttons', async () => {
  90. renderCameraPage(1);
  91. await waitFor(() => {
  92. // Check for translation key or translated text
  93. expect(screen.getByText(/Live|camera\.live/)).toBeInTheDocument();
  94. expect(screen.getByText(/Snapshot|camera\.snapshot/)).toBeInTheDocument();
  95. });
  96. });
  97. it('shows printer name in header', async () => {
  98. renderCameraPage(1);
  99. await waitFor(() => {
  100. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  101. });
  102. });
  103. });
  104. describe('camera controls', () => {
  105. it('renders without crashing', async () => {
  106. renderCameraPage(1);
  107. // Just verify no crash during render
  108. await waitFor(() => {
  109. expect(document.body).toBeInTheDocument();
  110. });
  111. });
  112. it('shows the camera diagnostic (stethoscope) button in the control bar (#1395)', async () => {
  113. // The diagnostic shipped wired into the embedded viewer only; window mode
  114. // (this page) was missing it. The control-bar button must be present here.
  115. renderCameraPage(1);
  116. await waitFor(() => {
  117. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  118. });
  119. expect(screen.getByTitle('Diagnose')).toBeInTheDocument();
  120. });
  121. });
  122. describe('stream token handling (#979)', () => {
  123. it('does not render image src until stream token arrives when auth is enabled', async () => {
  124. let resolveToken!: (value: unknown) => void;
  125. const tokenPromise = new Promise((resolve) => {
  126. resolveToken = resolve;
  127. });
  128. server.use(
  129. http.get('*/api/v1/auth/status', () =>
  130. HttpResponse.json({ auth_enabled: true, requires_setup: false })
  131. ),
  132. http.post('*/api/v1/printers/camera/stream-token', async () => {
  133. await tokenPromise;
  134. return HttpResponse.json({ token: 'tok-abc' });
  135. })
  136. );
  137. renderCameraPage(1);
  138. // Before the token resolves the <img> should not have a src pointing at
  139. // the stream endpoint — otherwise the backend would 401 with the
  140. // "Valid camera stream token required" error from #979.
  141. await waitFor(() => {
  142. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  143. });
  144. const img = document.querySelector('img') as HTMLImageElement | null;
  145. expect(img).not.toBeNull();
  146. expect(img?.getAttribute('src') || '').not.toContain('/camera/stream');
  147. resolveToken(undefined);
  148. // After the token resolves the image src picks it up as ?token=...
  149. await waitFor(() => {
  150. const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
  151. expect(src).toContain('/camera/stream');
  152. expect(src).toContain('token=tok-abc');
  153. });
  154. });
  155. it('does not fire a tokenless stream request when auth is disabled (#2521)', async () => {
  156. // The stream-token query runs whether or not auth is enabled, and this page
  157. // subscribes to it. So the src used to be rendered on the first pass with no
  158. // token, and swapped once the token landed. Changing img.src makes the
  159. // browser abort the in-flight request and issue a second one — and with auth
  160. // disabled no token is required, so BOTH reached the backend and attached to
  161. // the camera fan-out. Every page load put two viewers on a printer that
  162. // allows one socket, then abandoned one of them.
  163. let resolveToken!: () => void;
  164. const gate = new Promise<void>((resolve) => {
  165. resolveToken = resolve;
  166. });
  167. server.use(
  168. http.post('*/api/v1/printers/camera/stream-token', async () => {
  169. await gate;
  170. return HttpResponse.json({ token: 'tok-xyz' });
  171. })
  172. );
  173. renderCameraPage(1);
  174. await waitFor(() => {
  175. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  176. });
  177. // Token still in flight: the <img> must not already be pulling the stream.
  178. const early = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
  179. expect(early).not.toContain('/camera/stream');
  180. resolveToken();
  181. // The first — and only — stream URL the browser ever sees is the tokened one.
  182. await waitFor(() => {
  183. const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
  184. expect(src).toContain('/api/v1/printers/1/camera/stream');
  185. expect(src).toContain('token=tok-xyz');
  186. });
  187. });
  188. it('still streams when auth is disabled and the token endpoint fails', async () => {
  189. // Waiting for the token must not become a way to never render at all: an
  190. // auth-disabled backend doesn't need one. Once the query settles — even
  191. // unsuccessfully — the stream loads.
  192. server.use(
  193. http.post('*/api/v1/printers/camera/stream-token', () => new HttpResponse(null, { status: 500 }))
  194. );
  195. renderCameraPage(1);
  196. await waitFor(() => {
  197. const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
  198. expect(src).toContain('/api/v1/printers/1/camera/stream');
  199. });
  200. });
  201. });
  202. describe('fps URL parameter (#1131)', () => {
  203. it('defaults to fps=15 when no query parameter is provided', async () => {
  204. renderCameraPage(1);
  205. await waitFor(() => {
  206. const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
  207. expect(src).toContain('fps=15');
  208. });
  209. });
  210. it('honors fps query parameter from URL', async () => {
  211. renderCameraPage(1, '?fps=5');
  212. await waitFor(() => {
  213. const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
  214. expect(src).toContain('fps=5');
  215. });
  216. });
  217. it('clamps fps above 30 to 30', async () => {
  218. renderCameraPage(1, '?fps=60');
  219. await waitFor(() => {
  220. const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
  221. expect(src).toContain('fps=30');
  222. });
  223. });
  224. it('clamps fps below 1 to 1', async () => {
  225. renderCameraPage(1, '?fps=0');
  226. await waitFor(() => {
  227. const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
  228. expect(src).toContain('fps=1');
  229. });
  230. });
  231. it('falls back to 15 for non-numeric fps', async () => {
  232. renderCameraPage(1, '?fps=invalid');
  233. await waitFor(() => {
  234. const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
  235. expect(src).toContain('fps=15');
  236. });
  237. });
  238. });
  239. describe('invalid printer', () => {
  240. it('shows invalid printer message for ID 0', async () => {
  241. renderCameraPage(0);
  242. await waitFor(() => {
  243. // Check for translation key or translated text
  244. expect(screen.getByText(/Invalid printer ID|camera\.invalidPrinterId/)).toBeInTheDocument();
  245. });
  246. });
  247. });
  248. });