useWebSocket.test.ts 33 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052
  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('invalidates inventory queries on inventory_changed message', 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: 'inventory_changed' });
  380. });
  381. await act(async () => {
  382. vi.advanceTimersByTime(5000);
  383. });
  384. expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-spools'] });
  385. expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['spoolman-inventory-spools'] });
  386. expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-locations'] });
  387. vi.useRealTimers();
  388. vi.unstubAllGlobals();
  389. });
  390. it('handles missing_spool_assignment message without error', async () => {
  391. const { useWebSocket } = await import('../../hooks/useWebSocket');
  392. renderHook(() => useWebSocket(), {
  393. wrapper: createWrapper(queryClient),
  394. });
  395. const ws = await waitForWs();
  396. act(() => {
  397. ws.open();
  398. });
  399. // This test verifies that the hook properly handles missing_spool_assignment messages
  400. // without throwing an error. The actual toast display is tested via the UI.
  401. expect(() => {
  402. act(() => {
  403. ws.simulateMessage({
  404. type: 'missing_spool_assignment',
  405. printer_id: 7,
  406. printer_name: 'Printer B',
  407. missing_slots: [{ slot: 'A2' }, { slot: 'Ext-L' }],
  408. });
  409. });
  410. }).not.toThrow();
  411. vi.unstubAllGlobals();
  412. });
  413. it('shows an error toast when the billing kill switch stops a print', async () => {
  414. const { useWebSocket } = await import('../../hooks/useWebSocket');
  415. renderHook(() => useWebSocket(), {
  416. wrapper: createWrapper(queryClient),
  417. });
  418. const ws = await waitForWs();
  419. act(() => {
  420. ws.open();
  421. ws.simulateMessage({
  422. type: 'kill_switch_triggered',
  423. printer_id: 7,
  424. printer_name: 'Printer B',
  425. filename: 'foreign_job.3mf',
  426. });
  427. });
  428. const toast = screen.getByText('Billing kill switch stopped foreign_job.3mf on Printer B');
  429. expect(toast.parentElement).toHaveClass('bg-red-500/10');
  430. });
  431. it('shows an error toast when a completed print could not be charged', async () => {
  432. const { useWebSocket } = await import('../../hooks/useWebSocket');
  433. renderHook(() => useWebSocket(), {
  434. wrapper: createWrapper(queryClient),
  435. });
  436. const ws = await waitForWs();
  437. act(() => {
  438. ws.open();
  439. ws.simulateMessage({
  440. type: 'billing_charge_failed',
  441. printer_id: 7,
  442. printer_name: 'Printer B',
  443. filename: 'paid-job.3mf',
  444. });
  445. });
  446. const toast = screen.getByText(
  447. 'Billing failed for paid-job.3mf on Printer B. The budget reservation was retained; check the server logs.',
  448. );
  449. expect(toast.parentElement).toHaveClass('bg-red-500/10');
  450. });
  451. it('handles spool_assignment_verified messages (success and failure) without error', async () => {
  452. const { useWebSocket } = await import('../../hooks/useWebSocket');
  453. renderHook(() => useWebSocket(), {
  454. wrapper: createWrapper(queryClient),
  455. });
  456. const ws = await waitForWs();
  457. act(() => {
  458. ws.open();
  459. });
  460. // #2582: verified (loaded), loaded-but-no-K-profile, and not-confirmed
  461. // all route to a toast — assert none of the branches throw.
  462. expect(() => {
  463. act(() => {
  464. ws.simulateMessage({
  465. type: 'spool_assignment_verified',
  466. printer_id: 3,
  467. printer_name: 'Printer A',
  468. slot: 'A1',
  469. verified: true,
  470. kprofile_applied: true,
  471. });
  472. ws.simulateMessage({
  473. type: 'spool_assignment_verified',
  474. printer_id: 3,
  475. printer_name: 'Printer A',
  476. slot: 'A1',
  477. verified: true,
  478. kprofile_applied: false,
  479. });
  480. ws.simulateMessage({
  481. type: 'spool_assignment_verified',
  482. printer_id: 3,
  483. printer_name: 'Printer A',
  484. slot: 'A1',
  485. verified: false,
  486. saw_tray: true,
  487. });
  488. });
  489. }).not.toThrow();
  490. vi.unstubAllGlobals();
  491. });
  492. it('ignores pong messages without error', async () => {
  493. const { useWebSocket } = await import('../../hooks/useWebSocket');
  494. const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
  495. renderHook(() => useWebSocket(), {
  496. wrapper: createWrapper(queryClient),
  497. });
  498. const ws = await waitForWs();
  499. // Open connection
  500. act(() => {
  501. ws.open();
  502. });
  503. // Simulate pong response
  504. act(() => {
  505. ws.simulateMessage({
  506. type: 'pong',
  507. });
  508. });
  509. // Should not invalidate any queries for pong
  510. expect(invalidateSpy).not.toHaveBeenCalled();
  511. });
  512. it('handles malformed JSON gracefully', async () => {
  513. const { useWebSocket } = await import('../../hooks/useWebSocket');
  514. renderHook(() => useWebSocket(), {
  515. wrapper: createWrapper(queryClient),
  516. });
  517. const ws = await waitForWs();
  518. // Open connection
  519. act(() => {
  520. ws.open();
  521. });
  522. // Simulate malformed message (should not throw)
  523. expect(() => {
  524. act(() => {
  525. if (ws.onmessage) {
  526. ws.onmessage(
  527. new MessageEvent('message', {
  528. data: 'not valid json{{{',
  529. })
  530. );
  531. }
  532. });
  533. }).not.toThrow();
  534. });
  535. it('handles unknown message types gracefully', async () => {
  536. const { useWebSocket } = await import('../../hooks/useWebSocket');
  537. const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
  538. renderHook(() => useWebSocket(), {
  539. wrapper: createWrapper(queryClient),
  540. });
  541. const ws = await waitForWs();
  542. // Open connection
  543. act(() => {
  544. ws.open();
  545. });
  546. // Simulate unknown message type
  547. expect(() => {
  548. act(() => {
  549. ws.simulateMessage({
  550. type: 'unknown_type',
  551. data: { foo: 'bar' },
  552. });
  553. });
  554. }).not.toThrow();
  555. expect(invalidateSpy).not.toHaveBeenCalled();
  556. });
  557. });
  558. /**
  559. * #2754 (reporter @mic4rd): live updates froze whenever the tab wasn't in
  560. * front, and caught up all at once on switching back.
  561. *
  562. * Two causes, fixed in two rounds. First the cache writes ran inside a
  563. * requestAnimationFrame, and a hidden tab gets no rendering opportunities —
  564. * the browser holds queued frame callbacks indefinitely rather than merely
  565. * throttling them. The rAF stub below is what makes those tests meaningful:
  566. * it hands back a handle and never invokes the callback, which is what a
  567. * real hidden tab does.
  568. *
  569. * Removing the frame callback did not close the report, because the 100ms
  570. * coalescing timer was still in the path and a hidden page's timers are
  571. * clamped to at best once a second — once a minute past five minutes hidden.
  572. * So the writes must not depend on a timer either while hidden, which is
  573. * what `writes without waiting on a timer` pins down. Note it deliberately
  574. * never advances the clock: a test that advances fake timers cannot tell a
  575. * throttled timer from a prompt one, which is exactly why the original tests
  576. * kept passing while the reporter's tab stayed frozen.
  577. */
  578. describe('hidden tab (#2754)', () => {
  579. let rafSpy: ReturnType<typeof vi.fn>;
  580. beforeEach(() => {
  581. // The shared test client sets gcTime: 0, which collects a query the
  582. // moment it has no observers — advancing timers past the 100ms
  583. // coalescing window would drop the entry we just wrote before we could
  584. // read it back. Nothing observes ['printerStatus', 1] here, so this
  585. // block needs a client that keeps unobserved data.
  586. queryClient = new QueryClient({
  587. defaultOptions: { queries: { retry: false, gcTime: Infinity } },
  588. });
  589. Object.defineProperty(document, 'hidden', { configurable: true, value: true });
  590. // Order matters: vi.useFakeTimers() fakes requestAnimationFrame as well
  591. // (backing it with the mock clock, so advanceTimersByTime would run it
  592. // and hide the very defect under test). Stub it afterwards so the
  593. // never-firing version is the one the hook sees.
  594. vi.useFakeTimers();
  595. rafSpy = vi.fn(() => 1);
  596. vi.stubGlobal('requestAnimationFrame', rafSpy);
  597. });
  598. afterEach(() => {
  599. vi.useRealTimers();
  600. Object.defineProperty(document, 'hidden', { configurable: true, value: false });
  601. });
  602. it('applies printer status to the query cache', async () => {
  603. const { useWebSocket } = await import('../../hooks/useWebSocket');
  604. renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
  605. const ws = await waitForWs();
  606. act(() => ws.open());
  607. act(() => {
  608. ws.simulateMessage({
  609. type: 'printer_status',
  610. printer_id: 1,
  611. data: { state: 'RUNNING', progress: 42 },
  612. });
  613. });
  614. // Past the 100ms coalescing window.
  615. await act(async () => {
  616. vi.advanceTimersByTime(200);
  617. });
  618. // This is the key the tab-title/favicon progress reads
  619. // (usePrintProgressTitle) and nothing else.
  620. expect(queryClient.getQueryData(['printerStatus', 1])).toMatchObject({
  621. state: 'RUNNING',
  622. progress: 42,
  623. });
  624. expect(rafSpy).not.toHaveBeenCalled();
  625. });
  626. it('writes without waiting on a timer', async () => {
  627. const { useWebSocket } = await import('../../hooks/useWebSocket');
  628. renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
  629. const ws = await waitForWs();
  630. act(() => ws.open());
  631. act(() => {
  632. ws.simulateMessage({
  633. type: 'printer_status',
  634. printer_id: 1,
  635. data: { state: 'RUNNING', progress: 42 },
  636. });
  637. });
  638. // No advanceTimersByTime: a hidden tab's timers are throttled to once a
  639. // second at best, so anything the title depends on has to have landed
  640. // already. Reintroduce the coalescing timer on this path and the cache
  641. // is still empty here.
  642. expect(queryClient.getQueryData(['printerStatus', 1])).toMatchObject({
  643. state: 'RUNNING',
  644. progress: 42,
  645. });
  646. });
  647. it('applies the newest value when several arrive before a frame would have run', async () => {
  648. const { useWebSocket } = await import('../../hooks/useWebSocket');
  649. renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
  650. const ws = await waitForWs();
  651. act(() => ws.open());
  652. act(() => {
  653. ws.simulateMessage({ type: 'printer_status', printer_id: 1, data: { progress: 40 } });
  654. ws.simulateMessage({ type: 'printer_status', printer_id: 1, data: { progress: 41 } });
  655. });
  656. // Writing through per message must not resurrect an earlier one: the
  657. // pending map is drained on each flush, so a stale entry cannot be
  658. // re-applied over the newer value.
  659. expect(queryClient.getQueryData(['printerStatus', 1])).toMatchObject({ progress: 41 });
  660. });
  661. it('drains queued messages instead of wedging the queue', async () => {
  662. const { useWebSocket } = await import('../../hooks/useWebSocket');
  663. const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
  664. renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
  665. const ws = await waitForWs();
  666. act(() => ws.open());
  667. // Everything other than printer_status goes through the message queue,
  668. // which used to stall with processingRef stuck true — messages then
  669. // piled up unbounded until the tab was shown again.
  670. act(() => {
  671. ws.simulateMessage({ type: 'print_complete', printer_id: 1, data: {} });
  672. });
  673. // 3s debounce, then the 500ms-apart stagger.
  674. await act(async () => {
  675. vi.advanceTimersByTime(4000);
  676. });
  677. expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['archives'] });
  678. expect(rafSpy).not.toHaveBeenCalled();
  679. });
  680. });
  681. describe('visible tab still coalesces (#2754)', () => {
  682. /**
  683. * The counterpart to the hidden-tab block: the write-through is scoped to
  684. * a hidden tab on purpose. A visible one is painting, and the 100ms window
  685. * is what stops a burst of status messages turning into a render cascade —
  686. * so "just always write through" is not the simplification it looks like.
  687. */
  688. it('defers the write while the tab is visible', async () => {
  689. const { useWebSocket } = await import('../../hooks/useWebSocket');
  690. const client = new QueryClient({
  691. defaultOptions: { queries: { retry: false, gcTime: Infinity } },
  692. });
  693. vi.useFakeTimers();
  694. try {
  695. renderHook(() => useWebSocket(), { wrapper: createWrapper(client) });
  696. const ws = await waitForWs();
  697. act(() => ws.open());
  698. act(() => {
  699. ws.simulateMessage({
  700. type: 'printer_status',
  701. printer_id: 1,
  702. data: { state: 'RUNNING', progress: 42 },
  703. });
  704. });
  705. expect(client.getQueryData(['printerStatus', 1])).toBeUndefined();
  706. await act(async () => {
  707. vi.advanceTimersByTime(200);
  708. });
  709. expect(client.getQueryData(['printerStatus', 1])).toMatchObject({ progress: 42 });
  710. } finally {
  711. vi.useRealTimers();
  712. }
  713. });
  714. });
  715. describe('sendMessage', () => {
  716. it('sends JSON message when connected', async () => {
  717. const { useWebSocket } = await import('../../hooks/useWebSocket');
  718. const { result } = renderHook(() => useWebSocket(), {
  719. wrapper: createWrapper(queryClient),
  720. });
  721. const ws = await waitForWs();
  722. // Open connection
  723. act(() => {
  724. ws.open();
  725. });
  726. act(() => {
  727. result.current.sendMessage({ type: 'test', data: 'hello' });
  728. });
  729. expect(ws.send).toHaveBeenCalledWith(
  730. JSON.stringify({ type: 'test', data: 'hello' })
  731. );
  732. });
  733. it('does not send when disconnected', async () => {
  734. const { useWebSocket } = await import('../../hooks/useWebSocket');
  735. const { result } = renderHook(() => useWebSocket(), {
  736. wrapper: createWrapper(queryClient),
  737. });
  738. const ws = await waitForWs();
  739. // Don't open connection - still in CONNECTING state
  740. act(() => {
  741. result.current.sendMessage({ type: 'test' });
  742. });
  743. expect(ws.send).not.toHaveBeenCalled();
  744. });
  745. });
  746. describe('reconnection', () => {
  747. it('reconnects after connection closes', async () => {
  748. vi.useFakeTimers();
  749. const { useWebSocket } = await import('../../hooks/useWebSocket');
  750. renderHook(() => useWebSocket(), {
  751. wrapper: createWrapper(queryClient),
  752. });
  753. // GHSA-r2qv: connect() awaits a ws-token fetch before constructing
  754. // the WebSocket. Flush microtasks under fake timers so the await
  755. // resolves and MockWebSocket is pushed into wsInstances.
  756. await vi.advanceTimersByTimeAsync(0);
  757. const firstWs = wsInstances[wsInstances.length - 1]!;
  758. // Open connection
  759. act(() => {
  760. firstWs.open();
  761. });
  762. const instanceCountBefore = wsInstances.length;
  763. // Close connection
  764. act(() => {
  765. firstWs.close();
  766. });
  767. // Wait for reconnect timeout (3 seconds) + microtask flush for the
  768. // async connect() that the reconnect schedules.
  769. await vi.advanceTimersByTimeAsync(3000);
  770. // Should have created new WebSocket
  771. expect(wsInstances.length).toBe(instanceCountBefore + 1);
  772. expect(wsInstances[wsInstances.length - 1]).not.toBe(firstWs);
  773. vi.useRealTimers();
  774. });
  775. it('does NOT reconnect after an auth-rejection close (4401)', async () => {
  776. // Regression: a 4401 (ws-token invalid/expired or caller lacks
  777. // WEBSOCKET_CONNECT) used to reschedule connect() every 3s, spamming
  778. // /auth/ws-token forever. It must be terminal now.
  779. vi.useFakeTimers();
  780. const { useWebSocket } = await import('../../hooks/useWebSocket');
  781. renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
  782. await vi.advanceTimersByTimeAsync(0);
  783. const firstWs = wsInstances[wsInstances.length - 1]!;
  784. act(() => {
  785. firstWs.open();
  786. });
  787. const instanceCountBefore = wsInstances.length;
  788. // Server rejects auth.
  789. act(() => {
  790. firstWs.simulateClose(4401);
  791. });
  792. // No reconnect even after the 3s window elapses.
  793. await vi.advanceTimersByTimeAsync(3000);
  794. expect(wsInstances.length).toBe(instanceCountBefore);
  795. vi.useRealTimers();
  796. });
  797. it('does NOT open a socket or reconnect when ws-token mint returns 403', async () => {
  798. // Mike/Forge's case: an authenticated user whose group lacks
  799. // WEBSOCKET_CONNECT. POST /auth/ws-token returns 403; the hook must NOT
  800. // fall through to a tokenless socket (server closes it 4401) and must NOT
  801. // enter the reconnect loop — it degrades to REST polling instead.
  802. vi.useFakeTimers();
  803. vi.stubGlobal(
  804. 'fetch',
  805. vi.fn(async () => ({
  806. ok: false,
  807. status: 403,
  808. statusText: 'Forbidden',
  809. headers: { get: () => null },
  810. json: async () => ({ detail: 'Insufficient permissions' }),
  811. })),
  812. );
  813. const { useWebSocket } = await import('../../hooks/useWebSocket');
  814. renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
  815. // Flush the token-mint rejection, then let the (would-be) reconnect
  816. // window pass. No socket should ever be constructed.
  817. await vi.advanceTimersByTimeAsync(0);
  818. await vi.advanceTimersByTimeAsync(3000);
  819. expect(wsInstances.length).toBe(0);
  820. vi.useRealTimers();
  821. });
  822. it('does NOT reconnect when a close fires during unmount', async () => {
  823. // The provider unmounting (e.g. logout redirect) must not leave a
  824. // scheduled reconnect behind — the cleanup marks disposed before
  825. // close(), so the resulting onclose is a no-op.
  826. vi.useFakeTimers();
  827. const { useWebSocket } = await import('../../hooks/useWebSocket');
  828. const { unmount } = renderHook(() => useWebSocket(), {
  829. wrapper: createWrapper(queryClient),
  830. });
  831. await vi.advanceTimersByTimeAsync(0);
  832. const ws = wsInstances[wsInstances.length - 1]!;
  833. act(() => {
  834. ws.open();
  835. });
  836. const instanceCountBefore = wsInstances.length;
  837. // Unmount closes the socket, which fires onclose synchronously.
  838. act(() => {
  839. unmount();
  840. });
  841. await vi.advanceTimersByTimeAsync(3000);
  842. expect(wsInstances.length).toBe(instanceCountBefore);
  843. vi.useRealTimers();
  844. });
  845. it('cleans up on unmount', async () => {
  846. const { useWebSocket } = await import('../../hooks/useWebSocket');
  847. const { unmount } = renderHook(() => useWebSocket(), {
  848. wrapper: createWrapper(queryClient),
  849. });
  850. const ws = await waitForWs();
  851. // Open connection
  852. act(() => {
  853. ws.open();
  854. });
  855. unmount();
  856. expect(ws.close).toHaveBeenCalled();
  857. });
  858. });
  859. });