CameraTile.test.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. import { StrictMode } from 'react';
  2. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
  3. import { act, screen } from '@testing-library/react';
  4. import { render } from '../utils';
  5. import { CameraTile } from '../../components/CameraTile';
  6. import { setStreamToken } from '../../api/client';
  7. // The shared render() util mounts AuthProvider, which fires an async
  8. // /auth/me probe on mount. Each test absorbs that settle with a single
  9. // `await act(async () => {})` after render so the AuthProvider state
  10. // update doesn't bleed into the assertion phase as an act() warning.
  11. async function flushMicrotasks() {
  12. await act(async () => {
  13. await Promise.resolve();
  14. });
  15. }
  16. describe('CameraTile', () => {
  17. beforeEach(() => {
  18. vi.useFakeTimers();
  19. vi.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 200 }));
  20. });
  21. afterEach(() => {
  22. vi.useRealTimers();
  23. vi.restoreAllMocks();
  24. });
  25. it('renders the live stream URL in live mode', async () => {
  26. render(
  27. <CameraTile
  28. printerId={42}
  29. printerName="X1C-Lab"
  30. mode="live"
  31. snapshotIntervalMs={5000}
  32. connected
  33. />,
  34. );
  35. await flushMicrotasks();
  36. const img = screen.getByAltText('X1C-Lab') as HTMLImageElement;
  37. expect(img.src).toContain('/api/v1/printers/42/camera/stream');
  38. expect(img.src).toContain('fps=8');
  39. });
  40. it('renders the snapshot URL and refreshes on the interval', async () => {
  41. render(
  42. <CameraTile
  43. printerId={7}
  44. printerName="P1S-Garage"
  45. mode="snapshot"
  46. snapshotIntervalMs={1000}
  47. connected
  48. />,
  49. );
  50. await flushMicrotasks();
  51. const initial = (screen.getByAltText('P1S-Garage') as HTMLImageElement).src;
  52. expect(initial).toContain('/api/v1/printers/7/camera/snapshot');
  53. await act(async () => {
  54. vi.advanceTimersByTime(1500);
  55. });
  56. const refreshed = (screen.getByAltText('P1S-Garage') as HTMLImageElement).src;
  57. expect(refreshed).toContain('/api/v1/printers/7/camera/snapshot');
  58. expect(refreshed).not.toBe(initial);
  59. });
  60. it('shows an offline placeholder when not connected', async () => {
  61. render(
  62. <CameraTile
  63. printerId={1}
  64. printerName="A1-Offline"
  65. mode="live"
  66. snapshotIntervalMs={5000}
  67. connected={false}
  68. />,
  69. );
  70. await flushMicrotasks();
  71. expect(screen.queryByAltText('A1-Offline')).toBeNull();
  72. });
  73. it('shows the paused placeholder in paused mode', async () => {
  74. render(
  75. <CameraTile
  76. printerId={9}
  77. printerName="H2D-Booth"
  78. mode="paused"
  79. snapshotIntervalMs={5000}
  80. connected
  81. />,
  82. );
  83. await flushMicrotasks();
  84. expect(screen.queryByAltText('H2D-Booth')).toBeNull();
  85. });
  86. // External cameras run one stream per viewer; the stop names this tile's own,
  87. // so it ends this tile's stream and not another viewer's of the printer.
  88. it('stops its own stream by the viewer id it opened it with', async () => {
  89. const fetchMock = vi.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 200 }));
  90. const { rerender } = render(
  91. <CameraTile printerId={11} printerName="X1C-Viewer" mode="live" snapshotIntervalMs={5000} connected />,
  92. );
  93. await flushMicrotasks();
  94. const viewer = new URL((screen.getByAltText('X1C-Viewer') as HTMLImageElement).src).searchParams.get('viewer');
  95. expect(viewer).toMatch(/^[A-Za-z0-9_-]{1,64}$/);
  96. await act(async () => {
  97. rerender(<CameraTile printerId={11} printerName="X1C-Viewer" mode="snapshot" snapshotIntervalMs={5000} connected />);
  98. });
  99. const stop = fetchMock.mock.calls.map(([url]) => String(url)).find((url) => url.includes('/camera/stop'));
  100. expect(new URL(stop!, 'http://x').searchParams.get('viewer')).toBe(viewer);
  101. });
  102. it('gives each tile its own viewer id', async () => {
  103. render(
  104. <>
  105. <CameraTile printerId={11} printerName="Tile-A" mode="live" snapshotIntervalMs={5000} connected />
  106. <CameraTile printerId={11} printerName="Tile-B" mode="live" snapshotIntervalMs={5000} connected />
  107. </>,
  108. );
  109. await flushMicrotasks();
  110. const id = (name: string) => new URL((screen.getByAltText(name) as HTMLImageElement).src).searchParams.get('viewer');
  111. expect(id('Tile-A')).not.toBe(id('Tile-B'));
  112. });
  113. it('POSTs /camera/stop when leaving live mode', async () => {
  114. const fetchMock = vi.spyOn(global, 'fetch').mockResolvedValue(
  115. new Response(null, { status: 200 }),
  116. );
  117. const { rerender } = render(
  118. <CameraTile
  119. printerId={11}
  120. printerName="X1C-Stop"
  121. mode="live"
  122. snapshotIntervalMs={5000}
  123. connected
  124. />,
  125. );
  126. await flushMicrotasks();
  127. fetchMock.mockClear();
  128. await act(async () => {
  129. rerender(
  130. <CameraTile
  131. printerId={11}
  132. printerName="X1C-Stop"
  133. mode="snapshot"
  134. snapshotIntervalMs={5000}
  135. connected
  136. />,
  137. );
  138. });
  139. const stopCalls = fetchMock.mock.calls.filter(([url]) =>
  140. String(url).includes('/api/v1/printers/11/camera/stop'),
  141. );
  142. expect(stopCalls.length).toBeGreaterThan(0);
  143. });
  144. // A server-ended MJPEG stream fires no img event (load once in Chromium, per
  145. // frame in Firefox, nothing at the end), so a live tile renews its stream.
  146. it('renews a live stream every minute without stopping it', async () => {
  147. render(
  148. <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />,
  149. );
  150. await flushMicrotasks();
  151. const initial = (screen.getByAltText('X1C-Lab') as HTMLImageElement).src;
  152. await act(async () => {
  153. vi.advanceTimersByTime(59_000);
  154. });
  155. expect((screen.getByAltText('X1C-Lab') as HTMLImageElement).src).toBe(initial);
  156. await act(async () => {
  157. vi.advanceTimersByTime(2_000);
  158. });
  159. const renewed = (screen.getByAltText('X1C-Lab') as HTMLImageElement).src;
  160. expect(renewed).toContain('/api/v1/printers/42/camera/stream');
  161. expect(renewed).not.toBe(initial);
  162. // /camera/stop would end the stream the other viewers of this printer share.
  163. expect(vi.mocked(global.fetch).mock.calls.filter(([url]) => String(url).includes('/camera/stop'))).toHaveLength(0);
  164. });
  165. it('closes the old stream before opening the renewed one', async () => {
  166. // A detached img keeps its MJPEG connection open (measured in Chromium):
  167. // the browser's per-host pool fills and a USB camera stays held.
  168. render(
  169. <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />,
  170. );
  171. await flushMicrotasks();
  172. const old = screen.getByAltText('X1C-Lab') as HTMLImageElement;
  173. await act(async () => {
  174. vi.advanceTimersByTime(61_000);
  175. });
  176. expect(old.getAttribute('src')).toBeNull();
  177. expect(screen.getByAltText('X1C-Lab')).not.toBe(old);
  178. });
  179. it('takes the error of a cleared img for no failure', async () => {
  180. // Browsers fire error on an img whose src is set to '' (the renewal's close).
  181. render(
  182. <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />,
  183. );
  184. await flushMicrotasks();
  185. const img = screen.getByAltText('X1C-Lab') as HTMLImageElement;
  186. await act(async () => {
  187. img.setAttribute('src', '');
  188. img.dispatchEvent(new Event('error'));
  189. });
  190. expect(screen.getByAltText('X1C-Lab')).toBeTruthy();
  191. });
  192. it('waits a moment between closing the old stream and opening the new one', async () => {
  193. // A USB camera serves one reader: the old ffmpeg must let go of it first.
  194. render(
  195. <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />,
  196. );
  197. await flushMicrotasks();
  198. const old = screen.getByAltText('X1C-Lab') as HTMLImageElement;
  199. await act(async () => {
  200. vi.advanceTimersByTime(60_100);
  201. });
  202. expect(old.getAttribute('src')).toBeNull();
  203. expect(screen.getByAltText('X1C-Lab')).toBe(old);
  204. await act(async () => {
  205. vi.advanceTimersByTime(200);
  206. });
  207. expect(screen.getByAltText('X1C-Lab')).not.toBe(old);
  208. });
  209. it('does not renew while the page is hidden', async () => {
  210. // The stream token is not refreshed in a background tab; a renewal there
  211. // would ask with an expired one.
  212. const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true);
  213. render(
  214. <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />,
  215. );
  216. await flushMicrotasks();
  217. const old = screen.getByAltText('X1C-Lab') as HTMLImageElement;
  218. const src = old.src;
  219. await act(async () => {
  220. vi.advanceTimersByTime(125_000);
  221. });
  222. expect(screen.getByAltText('X1C-Lab')).toBe(old);
  223. expect(old.src).toBe(src);
  224. hidden.mockRestore();
  225. });
  226. async function renewedThenFailed() {
  227. render(
  228. <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />,
  229. );
  230. await flushMicrotasks();
  231. await act(async () => {
  232. vi.advanceTimersByTime(60_500);
  233. });
  234. const renewed = screen.getByAltText('X1C-Lab') as HTMLImageElement;
  235. await act(async () => {
  236. renewed.dispatchEvent(new Event('error'));
  237. });
  238. return renewed;
  239. }
  240. it('retries a renewal that failed once, after a few seconds', async () => {
  241. const renewed = await renewedThenFailed();
  242. expect(screen.getByAltText('X1C-Lab')).toBe(renewed);
  243. await act(async () => {
  244. vi.advanceTimersByTime(5_500);
  245. });
  246. const retried = screen.getByAltText('X1C-Lab') as HTMLImageElement;
  247. expect(retried).not.toBe(renewed);
  248. expect(retried.src).toContain('/api/v1/printers/42/camera/stream');
  249. });
  250. it('keeps the stream closed while it waits, whatever re-renders the tile', async () => {
  251. // A failed renewal makes the app mint a new stream token; the re-render it
  252. // causes must not reconnect the img before the retry is due.
  253. const { rerender } = render(
  254. <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />,
  255. );
  256. await flushMicrotasks();
  257. await act(async () => {
  258. vi.advanceTimersByTime(60_500);
  259. });
  260. await act(async () => {
  261. screen.getByAltText('X1C-Lab').dispatchEvent(new Event('error'));
  262. });
  263. await act(async () => {
  264. setStreamToken('fresh-token');
  265. rerender(
  266. <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected progress={1} />,
  267. );
  268. });
  269. expect(screen.getByAltText('X1C-Lab').getAttribute('src')).toBeNull();
  270. setStreamToken(null);
  271. });
  272. it('shows no signal when the retry fails too', async () => {
  273. await renewedThenFailed();
  274. await act(async () => {
  275. vi.advanceTimersByTime(5_500);
  276. });
  277. await act(async () => {
  278. screen.getByAltText('X1C-Lab').dispatchEvent(new Event('error'));
  279. });
  280. expect(screen.queryByAltText('X1C-Lab')).toBeNull();
  281. });
  282. it('treats a renewed stream that showed a frame like any other', async () => {
  283. render(
  284. <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />,
  285. );
  286. await flushMicrotasks();
  287. await act(async () => {
  288. vi.advanceTimersByTime(60_500);
  289. });
  290. const renewed = screen.getByAltText('X1C-Lab');
  291. await act(async () => {
  292. renewed.dispatchEvent(new Event('load'));
  293. });
  294. await act(async () => {
  295. renewed.dispatchEvent(new Event('error'));
  296. });
  297. expect(screen.queryByAltText('X1C-Lab')).toBeNull();
  298. });
  299. it('closes the live stream when the tile leaves live mode', async () => {
  300. const { rerender } = render(
  301. <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />,
  302. );
  303. await flushMicrotasks();
  304. const live = screen.getByAltText('X1C-Lab') as HTMLImageElement;
  305. await act(async () => {
  306. rerender(<CameraTile printerId={42} printerName="X1C-Lab" mode="paused" snapshotIntervalMs={5000} connected />);
  307. });
  308. await flushMicrotasks();
  309. expect(live.getAttribute('src')).toBeNull();
  310. });
  311. it('closes the live stream when the tile goes away', async () => {
  312. const { unmount } = render(
  313. <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />,
  314. );
  315. await flushMicrotasks();
  316. const live = screen.getByAltText('X1C-Lab') as HTMLImageElement;
  317. unmount();
  318. await flushMicrotasks();
  319. expect(live.getAttribute('src')).toBeNull();
  320. });
  321. // A snapshot camera that is down keeps its stream open without a frame: the
  322. // img fires neither load nor error, and the tile would stay blank.
  323. async function renewedWithoutFrame() {
  324. render(
  325. <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />,
  326. );
  327. await flushMicrotasks();
  328. await act(async () => {
  329. vi.advanceTimersByTime(60_500);
  330. });
  331. return screen.getByAltText('X1C-Lab') as HTMLImageElement;
  332. }
  333. it('retries a renewal that shows no frame within 15 seconds', async () => {
  334. const renewed = await renewedWithoutFrame();
  335. await act(async () => {
  336. vi.advanceTimersByTime(15_000);
  337. });
  338. expect(screen.getByAltText('X1C-Lab')).toBe(renewed);
  339. await act(async () => {
  340. vi.advanceTimersByTime(5_500);
  341. });
  342. expect(screen.getByAltText('X1C-Lab')).not.toBe(renewed);
  343. });
  344. it('shows no signal when the retry shows no frame either', async () => {
  345. await renewedWithoutFrame();
  346. await act(async () => {
  347. vi.advanceTimersByTime(20_500);
  348. });
  349. await act(async () => {
  350. vi.advanceTimersByTime(15_500);
  351. });
  352. expect(screen.queryByAltText('X1C-Lab')).toBeNull();
  353. });
  354. it('keeps a renewal that showed a frame', async () => {
  355. const renewed = await renewedWithoutFrame();
  356. await act(async () => {
  357. renewed.dispatchEvent(new Event('load'));
  358. });
  359. await act(async () => {
  360. vi.advanceTimersByTime(30_000);
  361. });
  362. expect(screen.getByAltText('X1C-Lab')).toBe(renewed);
  363. });
  364. it('keeps its stream under StrictMode', async () => {
  365. // React 19 runs a ref's cleanup once on mount in development; that must
  366. // not close the stream of the img that stays.
  367. render(
  368. <StrictMode>
  369. <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />
  370. </StrictMode>,
  371. );
  372. await flushMicrotasks();
  373. expect(screen.getByAltText('X1C-Lab').getAttribute('src')).toContain('/api/v1/printers/42/camera/stream');
  374. });
  375. it('does not count the time a hidden page cannot load the renewal', async () => {
  376. // A lazy img does not load in a background tab; the tab hidden right after
  377. // a renewal must not end a working camera on no signal.
  378. const renewed = await renewedWithoutFrame();
  379. const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true);
  380. await act(async () => {
  381. vi.advanceTimersByTime(45_000);
  382. });
  383. hidden.mockRestore();
  384. expect(screen.getByAltText('X1C-Lab')).toBe(renewed);
  385. });
  386. it('leaves a tile showing no signal alone', async () => {
  387. // Its retry would fail again, and every failed camera image makes the app
  388. // mint a new stream token and reconnect every camera on the page.
  389. render(
  390. <CameraTile printerId={42} printerName="X1C-Lab" mode="live" snapshotIntervalMs={5000} connected />,
  391. );
  392. await flushMicrotasks();
  393. await act(async () => {
  394. screen.getByAltText('X1C-Lab').dispatchEvent(new Event('error'));
  395. });
  396. await act(async () => {
  397. vi.advanceTimersByTime(121_000);
  398. });
  399. expect(screen.queryByAltText('X1C-Lab')).toBeNull();
  400. });
  401. describe('a failed snapshot', () => {
  402. // The server answers 503 while a live view of the printer is starting:
  403. // it will not take the camera from that viewer. One failure is retried.
  404. async function snapshotTile() {
  405. render(
  406. <CameraTile printerId={7} printerName="P1S-Garage" mode="snapshot" snapshotIntervalMs={2000} connected />,
  407. );
  408. await flushMicrotasks();
  409. }
  410. const fail = async () =>
  411. act(async () => {
  412. screen.getByAltText('P1S-Garage').dispatchEvent(new Event('error'));
  413. });
  414. const tick = async () =>
  415. act(async () => {
  416. vi.advanceTimersByTime(2100);
  417. });
  418. it('is tried again at the next refresh', async () => {
  419. await snapshotTile();
  420. const first = (screen.getByAltText('P1S-Garage') as HTMLImageElement).src;
  421. await fail();
  422. expect(screen.queryByText('No signal')).toBeNull();
  423. await tick();
  424. const retried = screen.getByAltText('P1S-Garage') as HTMLImageElement;
  425. expect(retried.src).toContain('/api/v1/printers/7/camera/snapshot');
  426. expect(retried.src).not.toBe(first);
  427. });
  428. it('shows no signal when the retry fails too', async () => {
  429. await snapshotTile();
  430. await fail();
  431. await tick();
  432. await fail();
  433. expect(screen.getByText('No signal')).toBeInTheDocument();
  434. });
  435. it('gets a fresh retry after a snapshot loads', async () => {
  436. await snapshotTile();
  437. await fail();
  438. await tick();
  439. await act(async () => {
  440. screen.getByAltText('P1S-Garage').dispatchEvent(new Event('load'));
  441. });
  442. await tick();
  443. await fail();
  444. expect(screen.queryByText('No signal')).toBeNull();
  445. });
  446. });
  447. });