CameraPage.test.tsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  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. <ThemeProvider>
  43. <AuthProvider>
  44. <ToastProvider>
  45. <Routes>
  46. <Route path="/cameras/:printerId" element={<CameraPage />} />
  47. </Routes>
  48. </ToastProvider>
  49. </AuthProvider>
  50. </ThemeProvider>
  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('renders image src immediately when auth is disabled (no token required)', async () => {
  156. renderCameraPage(1);
  157. await waitFor(() => {
  158. const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
  159. expect(src).toContain(`/api/v1/printers/1/camera/stream`);
  160. expect(src).not.toContain('token=');
  161. });
  162. });
  163. });
  164. describe('fps URL parameter (#1131)', () => {
  165. it('defaults to fps=15 when no query parameter is provided', async () => {
  166. renderCameraPage(1);
  167. await waitFor(() => {
  168. const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
  169. expect(src).toContain('fps=15');
  170. });
  171. });
  172. it('honors fps query parameter from URL', async () => {
  173. renderCameraPage(1, '?fps=5');
  174. await waitFor(() => {
  175. const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
  176. expect(src).toContain('fps=5');
  177. });
  178. });
  179. it('clamps fps above 30 to 30', async () => {
  180. renderCameraPage(1, '?fps=60');
  181. await waitFor(() => {
  182. const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
  183. expect(src).toContain('fps=30');
  184. });
  185. });
  186. it('clamps fps below 1 to 1', async () => {
  187. renderCameraPage(1, '?fps=0');
  188. await waitFor(() => {
  189. const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
  190. expect(src).toContain('fps=1');
  191. });
  192. });
  193. it('falls back to 15 for non-numeric fps', async () => {
  194. renderCameraPage(1, '?fps=invalid');
  195. await waitFor(() => {
  196. const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
  197. expect(src).toContain('fps=15');
  198. });
  199. });
  200. });
  201. describe('invalid printer', () => {
  202. it('shows invalid printer message for ID 0', async () => {
  203. renderCameraPage(0);
  204. await waitFor(() => {
  205. // Check for translation key or translated text
  206. expect(screen.getByText(/Invalid printer ID|camera\.invalidPrinterId/)).toBeInTheDocument();
  207. });
  208. });
  209. });
  210. });