useWebSocket.test.ts 37 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183
  1. /**
  2. * Tests for the useWebSocket hook.
  3. *
  4. * Tests WebSocket connection management and message handling.
  5. * Uses vitest.mock to mock the entire module before MSW can intercept.
  6. */
  7. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
  8. import { renderHook, waitFor, act, screen } from '@testing-library/react';
  9. import React from 'react';
  10. import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
  11. import { ToastProvider } from '../../contexts/ToastContext';
  12. // Track WebSocket instances created during tests
  13. let wsInstances: MockWebSocket[] = [];
  14. let originalWebSocket: typeof WebSocket;
  15. // Mock react-i18next BEFORE any modules that use it are imported
  16. vi.mock('react-i18next', () => ({
  17. useTranslation: () => ({
  18. t: (key: string, options?: Record<string, unknown>) => {
  19. if (key === 'printers.toast.missingSpoolAssignment' && options) {
  20. const { printer, slots } = options as { printer: string; slots: string };
  21. return `Missing assignments for ${printer}: ${slots}`;
  22. }
  23. if (key === 'printers.toast.killSwitchTriggered' && options) {
  24. const { printer, filename } = options as { printer: string; filename: string };
  25. return `Billing kill switch stopped ${filename} on ${printer}`;
  26. }
  27. if (key === 'printers.toast.billingChargeFailed' && options) {
  28. const { printer, filename } = options as { printer: string; filename: string };
  29. return `Billing failed for ${filename} on ${printer}. The budget reservation was retained; check the server logs.`;
  30. }
  31. return key;
  32. },
  33. i18n: {},
  34. }),
  35. }));
  36. // Enhanced MockWebSocket that tracks instances
  37. class MockWebSocket {
  38. static readonly CONNECTING = 0;
  39. static readonly OPEN = 1;
  40. static readonly CLOSING = 2;
  41. static readonly CLOSED = 3;
  42. readyState = MockWebSocket.CONNECTING;
  43. onopen: ((event: Event) => void) | null = null;
  44. onclose: ((event: CloseEvent) => void) | null = null;
  45. onmessage: ((event: MessageEvent) => void) | null = null;
  46. onerror: ((event: Event) => void) | null = null;
  47. url: string;
  48. constructor(url: string) {
  49. this.url = url;
  50. wsInstances.push(this);
  51. }
  52. send = vi.fn();
  53. close = vi.fn(() => {
  54. this.readyState = MockWebSocket.CLOSED;
  55. if (this.onclose) {
  56. this.onclose(new CloseEvent('close'));
  57. }
  58. });
  59. // Required by MSW's interceptor - these are no-ops but prevent the error
  60. addEventListener = vi.fn();
  61. removeEventListener = vi.fn();
  62. // Helper to simulate connection opening
  63. open() {
  64. this.readyState = MockWebSocket.OPEN;
  65. if (this.onopen) {
  66. this.onopen(new Event('open'));
  67. }
  68. }
  69. // Helper to simulate the server closing with a specific code (e.g. 4401,
  70. // the /ws auth-rejection close code).
  71. simulateClose(code: number) {
  72. this.readyState = MockWebSocket.CLOSED;
  73. if (this.onclose) {
  74. this.onclose(new CloseEvent('close', { code }));
  75. }
  76. }
  77. // Helper to simulate receiving a message
  78. simulateMessage(data: unknown) {
  79. if (this.onmessage) {
  80. this.onmessage(
  81. new MessageEvent('message', {
  82. data: JSON.stringify(data),
  83. })
  84. );
  85. }
  86. }
  87. }
  88. // Create test QueryClient
  89. function createTestQueryClient() {
  90. return new QueryClient({
  91. defaultOptions: {
  92. queries: {
  93. retry: false,
  94. gcTime: 0,
  95. },
  96. },
  97. });
  98. }
  99. // Wrapper with QueryClient and ToastProvider for hook testing
  100. function createWrapper(queryClient: QueryClient) {
  101. return function Wrapper({ children }: { children: React.ReactNode }) {
  102. return React.createElement(
  103. ToastProvider,
  104. {},
  105. React.createElement(
  106. QueryClientProvider,
  107. { client: queryClient },
  108. children
  109. )
  110. );
  111. };
  112. }
  113. /**
  114. * After GHSA-r2qv, useWebSocket awaits a ws-token fetch before constructing
  115. * the WebSocket. The MockWebSocket isn't pushed into ``wsInstances`` until
  116. * that promise resolves. ``waitFor`` from testing-library uses real-time
  117. * polling and so wedges under ``vi.useFakeTimers()``; flushing microtasks
  118. * manually works under both real and fake timers because Promise resolution
  119. * runs on the microtask queue, not on the mocked clock.
  120. *
  121. * Two iterations suffice for ``await fetch(...)`` → ``await resp.json()``;
  122. * a small headroom lets future awaits land here without changing every
  123. * call site.
  124. */
  125. async function waitForWs(): Promise<MockWebSocket> {
  126. for (let i = 0; i < 10 && wsInstances.length === 0; i++) {
  127. await Promise.resolve();
  128. }
  129. const ws = wsInstances[wsInstances.length - 1];
  130. if (!ws) {
  131. throw new Error('WebSocket was not constructed after microtask flush');
  132. }
  133. return ws;
  134. }
  135. describe('useWebSocket hook', () => {
  136. let queryClient: QueryClient;
  137. beforeEach(() => {
  138. vi.clearAllMocks();
  139. wsInstances = [];
  140. queryClient = createTestQueryClient();
  141. // Save original and install mock
  142. originalWebSocket = globalThis.WebSocket;
  143. globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
  144. // After GHSA-r2qv, useWebSocket fetches a ws-token via api.getWebSocketToken
  145. // before opening the socket. ``api.request`` reads ``response.headers``
  146. // and ``response.status``; the stub must expose those (a missing
  147. // ``headers`` field throws inside request() and the silent catch in
  148. // useWebSocket then proceeds with an undefined token, so the assertion
  149. // "URL contains ?token=" fails without making the cause obvious).
  150. vi.stubGlobal(
  151. 'fetch',
  152. vi.fn(async () => ({
  153. ok: true,
  154. status: 200,
  155. statusText: 'OK',
  156. headers: { get: () => null },
  157. json: async () => ({ token: 'test-ws-token' }),
  158. })),
  159. );
  160. });
  161. afterEach(() => {
  162. vi.restoreAllMocks();
  163. vi.unstubAllGlobals();
  164. // Restore original WebSocket
  165. globalThis.WebSocket = originalWebSocket;
  166. });
  167. describe('WebSocket Mock', () => {
  168. it('creates WebSocket with correct URL', () => {
  169. const ws = new MockWebSocket('ws://test.local/ws');
  170. expect(ws.url).toBe('ws://test.local/ws');
  171. });
  172. it('starts in CONNECTING state', () => {
  173. const ws = new MockWebSocket('ws://test.local/ws');
  174. expect(ws.readyState).toBe(MockWebSocket.CONNECTING);
  175. });
  176. it('transitions to OPEN state', () => {
  177. const ws = new MockWebSocket('ws://test.local/ws');
  178. const onOpen = vi.fn();
  179. ws.onopen = onOpen;
  180. ws.open();
  181. expect(ws.readyState).toBe(MockWebSocket.OPEN);
  182. expect(onOpen).toHaveBeenCalled();
  183. });
  184. it('can receive messages', () => {
  185. const ws = new MockWebSocket('ws://test.local/ws');
  186. const onMessage = vi.fn();
  187. ws.onmessage = onMessage;
  188. ws.open();
  189. ws.simulateMessage({ type: 'status', data: { connected: true } });
  190. expect(onMessage).toHaveBeenCalled();
  191. });
  192. it('can close connection', () => {
  193. const ws = new MockWebSocket('ws://test.local/ws');
  194. const onClose = vi.fn();
  195. ws.onclose = onClose;
  196. ws.close();
  197. expect(ws.readyState).toBe(MockWebSocket.CLOSED);
  198. expect(onClose).toHaveBeenCalled();
  199. });
  200. it('tracks all instances', () => {
  201. wsInstances = [];
  202. new MockWebSocket('ws://a');
  203. new MockWebSocket('ws://b');
  204. expect(wsInstances.length).toBe(2);
  205. });
  206. });
  207. describe('hook connection', () => {
  208. it('connects to WebSocket on mount', async () => {
  209. const { useWebSocket } = await import('../../hooks/useWebSocket');
  210. renderHook(() => useWebSocket(), {
  211. wrapper: createWrapper(queryClient),
  212. });
  213. const ws = await waitForWs();
  214. expect(ws).toBeDefined();
  215. expect(ws.url).toContain('/api/v1/ws');
  216. // GHSA-r2qv: the ws-token mint result is appended as ?token=...
  217. expect(ws.url).toContain('token=test-ws-token');
  218. });
  219. it('reports connected state when WebSocket opens', async () => {
  220. const { useWebSocket } = await import('../../hooks/useWebSocket');
  221. const { result } = renderHook(() => useWebSocket(), {
  222. wrapper: createWrapper(queryClient),
  223. });
  224. // Initially not connected
  225. expect(result.current.isConnected).toBe(false);
  226. // Simulate connection opening
  227. const ws = await waitForWs();
  228. act(() => {
  229. ws.open();
  230. });
  231. await waitFor(() => {
  232. expect(result.current.isConnected).toBe(true);
  233. });
  234. });
  235. });
  236. describe('message handling', () => {
  237. it('updates printer status in query cache on printer_status message', async () => {
  238. // Test the printer status update logic directly using setQueryData
  239. // The WebSocket handler with throttling is complex to test with fake timers,
  240. // so we test the core behavior directly
  241. // Simulate what the throttled update does
  242. queryClient.setQueryData(
  243. ['printerStatus', 1],
  244. (old: Record<string, unknown> | undefined) => {
  245. const statusData = { state: 'IDLE', progress: 0 };
  246. const merged = { ...old, ...statusData };
  247. return merged;
  248. }
  249. );
  250. // Check query cache was updated
  251. const cachedData = queryClient.getQueryData(['printerStatus', 1]);
  252. expect(cachedData).toEqual({ state: 'IDLE', progress: 0 });
  253. });
  254. it('preserves wifi_signal when new value is null', async () => {
  255. // Test the wifi_signal preservation logic directly on QueryClient
  256. // The throttled WebSocket handler makes this hard to test end-to-end
  257. // This tests that the merge logic correctly preserves wifi_signal
  258. // Set initial data with wifi_signal
  259. queryClient.setQueryData(['printerStatus', 1], {
  260. wifi_signal: -65,
  261. state: 'IDLE',
  262. });
  263. // Simulate what the throttled update does - use setQueryData with updater function
  264. queryClient.setQueryData(
  265. ['printerStatus', 1],
  266. (old: Record<string, unknown> | undefined) => {
  267. const statusData = { state: 'RUNNING', wifi_signal: null };
  268. const merged = { ...old, ...statusData };
  269. // This is the preservation logic from useWebSocket
  270. if (merged.wifi_signal == null && old?.wifi_signal != null) {
  271. merged.wifi_signal = old.wifi_signal;
  272. }
  273. return merged;
  274. }
  275. );
  276. const cachedData = queryClient.getQueryData(['printerStatus', 1]) as Record<
  277. string,
  278. unknown
  279. >;
  280. expect(cachedData.wifi_signal).toBe(-65); // Preserved
  281. expect(cachedData.state).toBe('RUNNING'); // Updated
  282. });
  283. it('invalidates archives on print_complete message', async () => {
  284. vi.useFakeTimers();
  285. const { useWebSocket } = await import('../../hooks/useWebSocket');
  286. const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
  287. renderHook(() => useWebSocket(), {
  288. wrapper: createWrapper(queryClient),
  289. });
  290. const ws = await waitForWs();
  291. // Open connection
  292. act(() => {
  293. ws.open();
  294. });
  295. // Simulate print complete
  296. act(() => {
  297. ws.simulateMessage({
  298. type: 'print_complete',
  299. printer_id: 1,
  300. data: { status: 'completed' },
  301. });
  302. });
  303. // Advance timers to trigger debounced invalidation (3000ms delay + 500ms between each)
  304. await act(async () => {
  305. vi.advanceTimersByTime(4000);
  306. });
  307. expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['archives'] });
  308. expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['archiveStats'] });
  309. vi.useRealTimers();
  310. vi.unstubAllGlobals();
  311. });
  312. it('invalidates archives on archive_created message', async () => {
  313. vi.useFakeTimers();
  314. const { useWebSocket } = await import('../../hooks/useWebSocket');
  315. const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
  316. renderHook(() => useWebSocket(), {
  317. wrapper: createWrapper(queryClient),
  318. });
  319. const ws = await waitForWs();
  320. // Open connection
  321. act(() => {
  322. ws.open();
  323. });
  324. // Simulate archive created
  325. act(() => {
  326. ws.simulateMessage({
  327. type: 'archive_created',
  328. data: { id: 1, filename: 'test.3mf' },
  329. });
  330. });
  331. // Advance timers to trigger debounced invalidation (3000ms delay + 500ms between each)
  332. await act(async () => {
  333. vi.advanceTimersByTime(4000);
  334. });
  335. expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['archives'] });
  336. expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['archiveStats'] });
  337. vi.useRealTimers();
  338. vi.unstubAllGlobals();
  339. });
  340. it('invalidates archives on archive_updated message', async () => {
  341. vi.useFakeTimers();
  342. const { useWebSocket } = await import('../../hooks/useWebSocket');
  343. const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
  344. renderHook(() => useWebSocket(), {
  345. wrapper: createWrapper(queryClient),
  346. });
  347. const ws = await waitForWs();
  348. // Open connection
  349. act(() => {
  350. ws.open();
  351. });
  352. // Simulate archive updated (e.g., timelapse attached)
  353. act(() => {
  354. ws.simulateMessage({
  355. type: 'archive_updated',
  356. data: { id: 1, timelapse_attached: true },
  357. });
  358. });
  359. // Advance timers to trigger debounced invalidation (3000ms delay)
  360. await act(async () => {
  361. vi.advanceTimersByTime(4000);
  362. });
  363. expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['archives'] });
  364. vi.useRealTimers();
  365. vi.unstubAllGlobals();
  366. });
  367. it('re-reads announcements on announcements_changed, so the dot appears without a reload', async () => {
  368. vi.useFakeTimers();
  369. const { useWebSocket } = await import('../../hooks/useWebSocket');
  370. const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
  371. renderHook(() => useWebSocket(), {
  372. wrapper: createWrapper(queryClient),
  373. });
  374. const ws = await waitForWs();
  375. act(() => {
  376. ws.open();
  377. });
  378. act(() => {
  379. ws.simulateMessage({ type: 'announcements_changed' });
  380. });
  381. await act(async () => {
  382. vi.advanceTimersByTime(5000);
  383. });
  384. expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['announcements'] });
  385. vi.useRealTimers();
  386. vi.unstubAllGlobals();
  387. });
  388. it('invalidates inventory queries on inventory_changed message', async () => {
  389. vi.useFakeTimers();
  390. const { useWebSocket } = await import('../../hooks/useWebSocket');
  391. const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
  392. renderHook(() => useWebSocket(), {
  393. wrapper: createWrapper(queryClient),
  394. });
  395. const ws = await waitForWs();
  396. act(() => {
  397. ws.open();
  398. });
  399. act(() => {
  400. ws.simulateMessage({ type: 'inventory_changed' });
  401. });
  402. await act(async () => {
  403. vi.advanceTimersByTime(5000);
  404. });
  405. expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-spools'] });
  406. expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['spoolman-inventory-spools'] });
  407. expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-locations'] });
  408. // #2988: without this key the supplier broadcast reached nothing, so a
  409. // supplier created in one tab never showed up in another.
  410. expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-suppliers'] });
  411. // The per-material-number aggregate is derived from the same rows (#2870).
  412. expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['material-number-stats'] });
  413. vi.useRealTimers();
  414. vi.unstubAllGlobals();
  415. });
  416. /*
  417. * Swapping a spool leaves the previous spool's preset name on the AMS slot
  418. * card.
  419. *
  420. * The RFID auto-assign rewrites the slot's slot_preset_mappings row, and
  421. * PrintersPage reads `slotPreset?.preset_name` *ahead of* the live
  422. * tray_info_idx lookup -- so a cached row wins over correct data pushed
  423. * over the socket. Everything else on the card rides the status push and
  424. * updates instantly, which is why this surfaces as one wrong line rather
  425. * than a stale card: pull a Bambu ABS Orange, insert a PLA Matte Dark
  426. * Blue, and the card reads "Bambu ABS" against the new colour.
  427. *
  428. * `slotPresets` has a 2-minute staleTime and no refetch interval, so on a
  429. * dashboard left open and focused nothing ever refetches it.
  430. */
  431. it('invalidates slot presets on spool_auto_assigned message', async () => {
  432. vi.useFakeTimers();
  433. const { useWebSocket } = await import('../../hooks/useWebSocket');
  434. const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
  435. renderHook(() => useWebSocket(), {
  436. wrapper: createWrapper(queryClient),
  437. });
  438. const ws = await waitForWs();
  439. act(() => {
  440. ws.open();
  441. });
  442. act(() => {
  443. ws.simulateMessage({
  444. type: 'spool_auto_assigned',
  445. printer_id: 7,
  446. ams_id: 0,
  447. tray_id: 0,
  448. spool_id: 110,
  449. });
  450. });
  451. // No timer advance: the user is standing at the printer looking at the
  452. // card, so the slot's own queries must not wait out the 3s cascade
  453. // debounce (which any further event would restart anyway).
  454. expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['slotPresets'] });
  455. expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['spool-assignments'] });
  456. // The spool list is not on the card's critical path and stays debounced.
  457. expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: ['inventory-spools'] });
  458. await act(async () => {
  459. vi.advanceTimersByTime(5000);
  460. });
  461. expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-spools'] });
  462. vi.useRealTimers();
  463. vi.unstubAllGlobals();
  464. });
  465. /*
  466. * Spoolman mode reaches the same slot_preset_mappings row through its own
  467. * AMS sync, which raises spool_assignment_changed. Its slot rows live under
  468. * a different query key, so the internal-mode key alone left that half of
  469. * the UI on the previous spool.
  470. */
  471. it('invalidates both inventory modes on spool_assignment_changed message', async () => {
  472. vi.useFakeTimers();
  473. const { useWebSocket } = await import('../../hooks/useWebSocket');
  474. const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
  475. renderHook(() => useWebSocket(), {
  476. wrapper: createWrapper(queryClient),
  477. });
  478. const ws = await waitForWs();
  479. act(() => {
  480. ws.open();
  481. });
  482. act(() => {
  483. ws.simulateMessage({
  484. type: 'spool_assignment_changed',
  485. printer_id: 7,
  486. ams_id: 0,
  487. tray_id: 0,
  488. });
  489. });
  490. expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['slotPresets'] });
  491. expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['spool-assignments'] });
  492. expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['spoolman-slot-assignments'] });
  493. vi.useRealTimers();
  494. vi.unstubAllGlobals();
  495. });
  496. it('handles missing_spool_assignment message without error', async () => {
  497. const { useWebSocket } = await import('../../hooks/useWebSocket');
  498. renderHook(() => useWebSocket(), {
  499. wrapper: createWrapper(queryClient),
  500. });
  501. const ws = await waitForWs();
  502. act(() => {
  503. ws.open();
  504. });
  505. // This test verifies that the hook properly handles missing_spool_assignment messages
  506. // without throwing an error. The actual toast display is tested via the UI.
  507. expect(() => {
  508. act(() => {
  509. ws.simulateMessage({
  510. type: 'missing_spool_assignment',
  511. printer_id: 7,
  512. printer_name: 'Printer B',
  513. missing_slots: [{ slot: 'A2' }, { slot: 'Ext-L' }],
  514. });
  515. });
  516. }).not.toThrow();
  517. vi.unstubAllGlobals();
  518. });
  519. it('shows an error toast when the billing kill switch stops a print', async () => {
  520. const { useWebSocket } = await import('../../hooks/useWebSocket');
  521. renderHook(() => useWebSocket(), {
  522. wrapper: createWrapper(queryClient),
  523. });
  524. const ws = await waitForWs();
  525. act(() => {
  526. ws.open();
  527. ws.simulateMessage({
  528. type: 'kill_switch_triggered',
  529. printer_id: 7,
  530. printer_name: 'Printer B',
  531. filename: 'foreign_job.3mf',
  532. });
  533. });
  534. const toast = screen.getByText('Billing kill switch stopped foreign_job.3mf on Printer B');
  535. expect(toast.parentElement).toHaveClass('bg-red-500/10');
  536. });
  537. it('shows an error toast when a completed print could not be charged', async () => {
  538. const { useWebSocket } = await import('../../hooks/useWebSocket');
  539. renderHook(() => useWebSocket(), {
  540. wrapper: createWrapper(queryClient),
  541. });
  542. const ws = await waitForWs();
  543. act(() => {
  544. ws.open();
  545. ws.simulateMessage({
  546. type: 'billing_charge_failed',
  547. printer_id: 7,
  548. printer_name: 'Printer B',
  549. filename: 'paid-job.3mf',
  550. });
  551. });
  552. const toast = screen.getByText(
  553. 'Billing failed for paid-job.3mf on Printer B. The budget reservation was retained; check the server logs.',
  554. );
  555. expect(toast.parentElement).toHaveClass('bg-red-500/10');
  556. });
  557. it('handles spool_assignment_verified messages (success and failure) without error', async () => {
  558. const { useWebSocket } = await import('../../hooks/useWebSocket');
  559. renderHook(() => useWebSocket(), {
  560. wrapper: createWrapper(queryClient),
  561. });
  562. const ws = await waitForWs();
  563. act(() => {
  564. ws.open();
  565. });
  566. // #2582: verified (loaded), loaded-but-no-K-profile, and not-confirmed
  567. // all route to a toast — assert none of the branches throw.
  568. expect(() => {
  569. act(() => {
  570. ws.simulateMessage({
  571. type: 'spool_assignment_verified',
  572. printer_id: 3,
  573. printer_name: 'Printer A',
  574. slot: 'A1',
  575. verified: true,
  576. kprofile_applied: true,
  577. });
  578. ws.simulateMessage({
  579. type: 'spool_assignment_verified',
  580. printer_id: 3,
  581. printer_name: 'Printer A',
  582. slot: 'A1',
  583. verified: true,
  584. kprofile_applied: false,
  585. });
  586. ws.simulateMessage({
  587. type: 'spool_assignment_verified',
  588. printer_id: 3,
  589. printer_name: 'Printer A',
  590. slot: 'A1',
  591. verified: false,
  592. saw_tray: true,
  593. });
  594. });
  595. }).not.toThrow();
  596. vi.unstubAllGlobals();
  597. });
  598. it('ignores pong messages without error', async () => {
  599. const { useWebSocket } = await import('../../hooks/useWebSocket');
  600. const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
  601. renderHook(() => useWebSocket(), {
  602. wrapper: createWrapper(queryClient),
  603. });
  604. const ws = await waitForWs();
  605. // Open connection
  606. act(() => {
  607. ws.open();
  608. });
  609. // Simulate pong response
  610. act(() => {
  611. ws.simulateMessage({
  612. type: 'pong',
  613. });
  614. });
  615. // Should not invalidate any queries for pong
  616. expect(invalidateSpy).not.toHaveBeenCalled();
  617. });
  618. it('handles malformed JSON gracefully', async () => {
  619. const { useWebSocket } = await import('../../hooks/useWebSocket');
  620. renderHook(() => useWebSocket(), {
  621. wrapper: createWrapper(queryClient),
  622. });
  623. const ws = await waitForWs();
  624. // Open connection
  625. act(() => {
  626. ws.open();
  627. });
  628. // Simulate malformed message (should not throw)
  629. expect(() => {
  630. act(() => {
  631. if (ws.onmessage) {
  632. ws.onmessage(
  633. new MessageEvent('message', {
  634. data: 'not valid json{{{',
  635. })
  636. );
  637. }
  638. });
  639. }).not.toThrow();
  640. });
  641. it('handles unknown message types gracefully', async () => {
  642. const { useWebSocket } = await import('../../hooks/useWebSocket');
  643. const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
  644. renderHook(() => useWebSocket(), {
  645. wrapper: createWrapper(queryClient),
  646. });
  647. const ws = await waitForWs();
  648. // Open connection
  649. act(() => {
  650. ws.open();
  651. });
  652. // Simulate unknown message type
  653. expect(() => {
  654. act(() => {
  655. ws.simulateMessage({
  656. type: 'unknown_type',
  657. data: { foo: 'bar' },
  658. });
  659. });
  660. }).not.toThrow();
  661. expect(invalidateSpy).not.toHaveBeenCalled();
  662. });
  663. });
  664. /**
  665. * #2754 (reporter @mic4rd): live updates froze whenever the tab wasn't in
  666. * front, and caught up all at once on switching back.
  667. *
  668. * Two causes, fixed in two rounds. First the cache writes ran inside a
  669. * requestAnimationFrame, and a hidden tab gets no rendering opportunities —
  670. * the browser holds queued frame callbacks indefinitely rather than merely
  671. * throttling them. The rAF stub below is what makes those tests meaningful:
  672. * it hands back a handle and never invokes the callback, which is what a
  673. * real hidden tab does.
  674. *
  675. * Removing the frame callback did not close the report, because the 100ms
  676. * coalescing timer was still in the path and a hidden page's timers are
  677. * clamped to at best once a second — once a minute past five minutes hidden.
  678. * So the writes must not depend on a timer either while hidden, which is
  679. * what `writes without waiting on a timer` pins down. Note it deliberately
  680. * never advances the clock: a test that advances fake timers cannot tell a
  681. * throttled timer from a prompt one, which is exactly why the original tests
  682. * kept passing while the reporter's tab stayed frozen.
  683. */
  684. describe('hidden tab (#2754)', () => {
  685. let rafSpy: ReturnType<typeof vi.fn>;
  686. beforeEach(() => {
  687. // The shared test client sets gcTime: 0, which collects a query the
  688. // moment it has no observers — advancing timers past the 100ms
  689. // coalescing window would drop the entry we just wrote before we could
  690. // read it back. Nothing observes ['printerStatus', 1] here, so this
  691. // block needs a client that keeps unobserved data.
  692. queryClient = new QueryClient({
  693. defaultOptions: { queries: { retry: false, gcTime: Infinity } },
  694. });
  695. Object.defineProperty(document, 'hidden', { configurable: true, value: true });
  696. // Order matters: vi.useFakeTimers() fakes requestAnimationFrame as well
  697. // (backing it with the mock clock, so advanceTimersByTime would run it
  698. // and hide the very defect under test). Stub it afterwards so the
  699. // never-firing version is the one the hook sees.
  700. vi.useFakeTimers();
  701. rafSpy = vi.fn(() => 1);
  702. vi.stubGlobal('requestAnimationFrame', rafSpy);
  703. });
  704. afterEach(() => {
  705. vi.useRealTimers();
  706. Object.defineProperty(document, 'hidden', { configurable: true, value: false });
  707. });
  708. it('applies printer status to the query cache', async () => {
  709. const { useWebSocket } = await import('../../hooks/useWebSocket');
  710. renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
  711. const ws = await waitForWs();
  712. act(() => ws.open());
  713. act(() => {
  714. ws.simulateMessage({
  715. type: 'printer_status',
  716. printer_id: 1,
  717. data: { state: 'RUNNING', progress: 42 },
  718. });
  719. });
  720. // Past the 100ms coalescing window.
  721. await act(async () => {
  722. vi.advanceTimersByTime(200);
  723. });
  724. // This is the key the tab-title/favicon progress reads
  725. // (usePrintProgressTitle) and nothing else.
  726. expect(queryClient.getQueryData(['printerStatus', 1])).toMatchObject({
  727. state: 'RUNNING',
  728. progress: 42,
  729. });
  730. expect(rafSpy).not.toHaveBeenCalled();
  731. });
  732. it('writes without waiting on a timer', async () => {
  733. const { useWebSocket } = await import('../../hooks/useWebSocket');
  734. renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
  735. const ws = await waitForWs();
  736. act(() => ws.open());
  737. act(() => {
  738. ws.simulateMessage({
  739. type: 'printer_status',
  740. printer_id: 1,
  741. data: { state: 'RUNNING', progress: 42 },
  742. });
  743. });
  744. // No advanceTimersByTime: a hidden tab's timers are throttled to once a
  745. // second at best, so anything the title depends on has to have landed
  746. // already. Reintroduce the coalescing timer on this path and the cache
  747. // is still empty here.
  748. expect(queryClient.getQueryData(['printerStatus', 1])).toMatchObject({
  749. state: 'RUNNING',
  750. progress: 42,
  751. });
  752. });
  753. it('applies the newest value when several arrive before a frame would have run', async () => {
  754. const { useWebSocket } = await import('../../hooks/useWebSocket');
  755. renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
  756. const ws = await waitForWs();
  757. act(() => ws.open());
  758. act(() => {
  759. ws.simulateMessage({ type: 'printer_status', printer_id: 1, data: { progress: 40 } });
  760. ws.simulateMessage({ type: 'printer_status', printer_id: 1, data: { progress: 41 } });
  761. });
  762. // Writing through per message must not resurrect an earlier one: the
  763. // pending map is drained on each flush, so a stale entry cannot be
  764. // re-applied over the newer value.
  765. expect(queryClient.getQueryData(['printerStatus', 1])).toMatchObject({ progress: 41 });
  766. });
  767. it('drains queued messages instead of wedging the queue', async () => {
  768. const { useWebSocket } = await import('../../hooks/useWebSocket');
  769. const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
  770. renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
  771. const ws = await waitForWs();
  772. act(() => ws.open());
  773. // Everything other than printer_status goes through the message queue,
  774. // which used to stall with processingRef stuck true — messages then
  775. // piled up unbounded until the tab was shown again.
  776. act(() => {
  777. ws.simulateMessage({ type: 'print_complete', printer_id: 1, data: {} });
  778. });
  779. // 3s debounce, then the 500ms-apart stagger.
  780. await act(async () => {
  781. vi.advanceTimersByTime(4000);
  782. });
  783. expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['archives'] });
  784. expect(rafSpy).not.toHaveBeenCalled();
  785. });
  786. });
  787. describe('visible tab still coalesces (#2754)', () => {
  788. /**
  789. * The counterpart to the hidden-tab block: the write-through is scoped to
  790. * a hidden tab on purpose. A visible one is painting, and the 100ms window
  791. * is what stops a burst of status messages turning into a render cascade —
  792. * so "just always write through" is not the simplification it looks like.
  793. */
  794. it('defers the write while the tab is visible', async () => {
  795. const { useWebSocket } = await import('../../hooks/useWebSocket');
  796. const client = new QueryClient({
  797. defaultOptions: { queries: { retry: false, gcTime: Infinity } },
  798. });
  799. vi.useFakeTimers();
  800. try {
  801. renderHook(() => useWebSocket(), { wrapper: createWrapper(client) });
  802. const ws = await waitForWs();
  803. act(() => ws.open());
  804. act(() => {
  805. ws.simulateMessage({
  806. type: 'printer_status',
  807. printer_id: 1,
  808. data: { state: 'RUNNING', progress: 42 },
  809. });
  810. });
  811. expect(client.getQueryData(['printerStatus', 1])).toBeUndefined();
  812. await act(async () => {
  813. vi.advanceTimersByTime(200);
  814. });
  815. expect(client.getQueryData(['printerStatus', 1])).toMatchObject({ progress: 42 });
  816. } finally {
  817. vi.useRealTimers();
  818. }
  819. });
  820. });
  821. describe('sendMessage', () => {
  822. it('sends JSON message when connected', async () => {
  823. const { useWebSocket } = await import('../../hooks/useWebSocket');
  824. const { result } = renderHook(() => useWebSocket(), {
  825. wrapper: createWrapper(queryClient),
  826. });
  827. const ws = await waitForWs();
  828. // Open connection
  829. act(() => {
  830. ws.open();
  831. });
  832. act(() => {
  833. result.current.sendMessage({ type: 'test', data: 'hello' });
  834. });
  835. expect(ws.send).toHaveBeenCalledWith(
  836. JSON.stringify({ type: 'test', data: 'hello' })
  837. );
  838. });
  839. it('does not send when disconnected', async () => {
  840. const { useWebSocket } = await import('../../hooks/useWebSocket');
  841. const { result } = renderHook(() => useWebSocket(), {
  842. wrapper: createWrapper(queryClient),
  843. });
  844. const ws = await waitForWs();
  845. // Don't open connection - still in CONNECTING state
  846. act(() => {
  847. result.current.sendMessage({ type: 'test' });
  848. });
  849. expect(ws.send).not.toHaveBeenCalled();
  850. });
  851. });
  852. describe('reconnection', () => {
  853. it('reconnects after connection closes', async () => {
  854. vi.useFakeTimers();
  855. const { useWebSocket } = await import('../../hooks/useWebSocket');
  856. renderHook(() => useWebSocket(), {
  857. wrapper: createWrapper(queryClient),
  858. });
  859. // GHSA-r2qv: connect() awaits a ws-token fetch before constructing
  860. // the WebSocket. Flush microtasks under fake timers so the await
  861. // resolves and MockWebSocket is pushed into wsInstances.
  862. await vi.advanceTimersByTimeAsync(0);
  863. const firstWs = wsInstances[wsInstances.length - 1]!;
  864. // Open connection
  865. act(() => {
  866. firstWs.open();
  867. });
  868. const instanceCountBefore = wsInstances.length;
  869. // Close connection
  870. act(() => {
  871. firstWs.close();
  872. });
  873. // Wait for reconnect timeout (3 seconds) + microtask flush for the
  874. // async connect() that the reconnect schedules.
  875. await vi.advanceTimersByTimeAsync(3000);
  876. // Should have created new WebSocket
  877. expect(wsInstances.length).toBe(instanceCountBefore + 1);
  878. expect(wsInstances[wsInstances.length - 1]).not.toBe(firstWs);
  879. vi.useRealTimers();
  880. });
  881. it('does NOT reconnect after an auth-rejection close (4401)', async () => {
  882. // Regression: a 4401 (ws-token invalid/expired or caller lacks
  883. // WEBSOCKET_CONNECT) used to reschedule connect() every 3s, spamming
  884. // /auth/ws-token forever. It must be terminal now.
  885. vi.useFakeTimers();
  886. const { useWebSocket } = await import('../../hooks/useWebSocket');
  887. renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
  888. await vi.advanceTimersByTimeAsync(0);
  889. const firstWs = wsInstances[wsInstances.length - 1]!;
  890. act(() => {
  891. firstWs.open();
  892. });
  893. const instanceCountBefore = wsInstances.length;
  894. // Server rejects auth.
  895. act(() => {
  896. firstWs.simulateClose(4401);
  897. });
  898. // No reconnect even after the 3s window elapses.
  899. await vi.advanceTimersByTimeAsync(3000);
  900. expect(wsInstances.length).toBe(instanceCountBefore);
  901. vi.useRealTimers();
  902. });
  903. it('does NOT open a socket or reconnect when ws-token mint returns 403', async () => {
  904. // Mike/Forge's case: an authenticated user whose group lacks
  905. // WEBSOCKET_CONNECT. POST /auth/ws-token returns 403; the hook must NOT
  906. // fall through to a tokenless socket (server closes it 4401) and must NOT
  907. // enter the reconnect loop — it degrades to REST polling instead.
  908. vi.useFakeTimers();
  909. vi.stubGlobal(
  910. 'fetch',
  911. vi.fn(async () => ({
  912. ok: false,
  913. status: 403,
  914. statusText: 'Forbidden',
  915. headers: { get: () => null },
  916. json: async () => ({ detail: 'Insufficient permissions' }),
  917. })),
  918. );
  919. const { useWebSocket } = await import('../../hooks/useWebSocket');
  920. renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
  921. // Flush the token-mint rejection, then let the (would-be) reconnect
  922. // window pass. No socket should ever be constructed.
  923. await vi.advanceTimersByTimeAsync(0);
  924. await vi.advanceTimersByTimeAsync(3000);
  925. expect(wsInstances.length).toBe(0);
  926. vi.useRealTimers();
  927. });
  928. it('does NOT reconnect when a close fires during unmount', async () => {
  929. // The provider unmounting (e.g. logout redirect) must not leave a
  930. // scheduled reconnect behind — the cleanup marks disposed before
  931. // close(), so the resulting onclose is a no-op.
  932. vi.useFakeTimers();
  933. const { useWebSocket } = await import('../../hooks/useWebSocket');
  934. const { unmount } = renderHook(() => useWebSocket(), {
  935. wrapper: createWrapper(queryClient),
  936. });
  937. await vi.advanceTimersByTimeAsync(0);
  938. const ws = wsInstances[wsInstances.length - 1]!;
  939. act(() => {
  940. ws.open();
  941. });
  942. const instanceCountBefore = wsInstances.length;
  943. // Unmount closes the socket, which fires onclose synchronously.
  944. act(() => {
  945. unmount();
  946. });
  947. await vi.advanceTimersByTimeAsync(3000);
  948. expect(wsInstances.length).toBe(instanceCountBefore);
  949. vi.useRealTimers();
  950. });
  951. it('cleans up on unmount', async () => {
  952. const { useWebSocket } = await import('../../hooks/useWebSocket');
  953. const { unmount } = renderHook(() => useWebSocket(), {
  954. wrapper: createWrapper(queryClient),
  955. });
  956. const ws = await waitForWs();
  957. // Open connection
  958. act(() => {
  959. ws.open();
  960. });
  961. unmount();
  962. expect(ws.close).toHaveBeenCalled();
  963. });
  964. });
  965. });