ToastContext.test.tsx 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. /**
  2. * Tests for ToastContext's post-unmount safety guards.
  3. *
  4. * Regression: a login response handler calling showToast AFTER the provider
  5. * had already been unmounted by Vitest's afterEach scheduled a 3s setTimeout
  6. * that fired during test teardown. The callback's setToasts then tried to
  7. * schedule a React update against a torn-down jsdom, producing
  8. * "window is not defined" as an uncaught exception.
  9. *
  10. * The provider now gates every setToasts call on an isMountedRef and
  11. * re-checks inside the auto-dismiss setTimeout callback so stale async
  12. * paths no-op instead of crashing.
  13. */
  14. import { describe, it, expect, beforeEach, vi } from 'vitest';
  15. import { act, render, renderHook } from '@testing-library/react';
  16. import { type ReactNode } from 'react';
  17. import { ToastProvider, useToast } from '../../contexts/ToastContext';
  18. function Wrapper({ children }: { children: ReactNode }) {
  19. return <ToastProvider>{children}</ToastProvider>;
  20. }
  21. describe('ToastContext post-unmount safety', () => {
  22. beforeEach(() => {
  23. vi.useRealTimers();
  24. });
  25. it('does not crash when showToast is called after unmount', () => {
  26. const { result, unmount } = renderHook(() => useToast(), { wrapper: Wrapper });
  27. // Capture the callbacks BEFORE unmount — a real stale-closure scenario.
  28. // (Async handlers that kicked off before unmount keep their captured
  29. // context value and will invoke this function after we tear down.)
  30. const { showToast } = result.current;
  31. unmount();
  32. // Post-unmount invocation is now a no-op; must not throw.
  33. expect(() => showToast('delayed error message', 'error')).not.toThrow();
  34. });
  35. it('does not invoke setToasts when the auto-dismiss timer fires after unmount', async () => {
  36. vi.useFakeTimers();
  37. const { result, unmount } = renderHook(() => useToast(), { wrapper: Wrapper });
  38. act(() => {
  39. result.current.showToast('will outlive the provider', 'error');
  40. });
  41. // Unmount BEFORE the 3s timer fires — the unmount effect clears pending
  42. // timers, but a belt-and-braces check inside the timer callback (for
  43. // cases where the timer was scheduled post-unmount) must also hold.
  44. unmount();
  45. // Advance past the 3s auto-dismiss window. If the guard isn't in place
  46. // this would throw "window is not defined" in a torn-down jsdom; we
  47. // simulate by asserting no error propagates.
  48. expect(() => {
  49. vi.advanceTimersByTime(5000);
  50. }).not.toThrow();
  51. vi.useRealTimers();
  52. });
  53. it('post-unmount showPersistentToast and dismissToast are no-ops', () => {
  54. const { result, unmount } = renderHook(() => useToast(), { wrapper: Wrapper });
  55. const { showPersistentToast, dismissToast } = result.current;
  56. unmount();
  57. // Both must short-circuit rather than attempt setState on a dead tree.
  58. expect(() => showPersistentToast('orphan', 'still here', 'info')).not.toThrow();
  59. expect(() => dismissToast('orphan')).not.toThrow();
  60. });
  61. it('normal showToast flow still displays and auto-dismisses while mounted', () => {
  62. vi.useFakeTimers();
  63. const { result } = renderHook(() => useToast(), { wrapper: Wrapper });
  64. act(() => {
  65. result.current.showToast('mounted path works', 'success');
  66. });
  67. // No easy way to read toast DOM from the hook alone; assert the timer
  68. // ran without throwing — that proves the isMountedRef guard didn't
  69. // incorrectly short-circuit the mounted path.
  70. expect(() => {
  71. act(() => {
  72. vi.advanceTimersByTime(3500);
  73. });
  74. }).not.toThrow();
  75. vi.useRealTimers();
  76. });
  77. });
  78. describe('ToastContext background dispatch — upload-done UX', () => {
  79. // Small fast files reach 100% upload before the printer's MQTT confirmation
  80. // arrives, leaving the bar parked at 100% for what feels like "stuck". When
  81. // status is still 'processing' but uploadProgressPct >= 99.9 the byte-count
  82. // line should switch to "Awaiting printer..." and the bar gets a pulse.
  83. function dispatchBackgroundEvent(detail: Record<string, unknown>) {
  84. window.dispatchEvent(new CustomEvent('background-dispatch', { detail }));
  85. }
  86. it('shows "Awaiting printer..." once upload is complete but printer has not confirmed', () => {
  87. const { container } = render(
  88. <ToastProvider>
  89. <div />
  90. </ToastProvider>
  91. );
  92. act(() => {
  93. dispatchBackgroundEvent({
  94. total: 1,
  95. dispatched: 0,
  96. processing: 1,
  97. completed: 0,
  98. failed: 0,
  99. active_jobs: [
  100. {
  101. job_id: 42,
  102. printer_name: 'X1C-2',
  103. source_name: 'Benchy.3mf',
  104. upload_bytes: 102400,
  105. upload_total_bytes: 102400,
  106. upload_progress_pct: 100.0,
  107. },
  108. ],
  109. });
  110. });
  111. // The byte-count line should be replaced with the awaiting-printer text.
  112. expect(container.textContent).toContain('Awaiting printer');
  113. // And the original bytes-progressed format must not be visible at the
  114. // same time — that is the "stuck at 100%" symptom we are fixing.
  115. expect(container.textContent).not.toContain('100.0%');
  116. // Bar gets the pulse class when in this state.
  117. const bar = container.querySelector('.animate-pulse');
  118. expect(bar).not.toBeNull();
  119. });
  120. it('still shows the byte/percent counter while upload is mid-flight', () => {
  121. const { container } = render(
  122. <ToastProvider>
  123. <div />
  124. </ToastProvider>
  125. );
  126. act(() => {
  127. dispatchBackgroundEvent({
  128. total: 1,
  129. dispatched: 0,
  130. processing: 1,
  131. completed: 0,
  132. failed: 0,
  133. active_jobs: [
  134. {
  135. job_id: 7,
  136. printer_name: 'X1C-2',
  137. source_name: 'Benchy.3mf',
  138. upload_bytes: 51200,
  139. upload_total_bytes: 102400,
  140. upload_progress_pct: 50.0,
  141. },
  142. ],
  143. });
  144. });
  145. expect(container.textContent).toContain('50.0%');
  146. expect(container.textContent).not.toContain('Awaiting printer');
  147. expect(container.querySelector('.animate-pulse')).toBeNull();
  148. });
  149. });
  150. describe('ToastContext viewport suppression', () => {
  151. // The kiosk layout flips setViewportSuppressed(true) on mount so the
  152. // SpoolBuddy display stays free of main-app toasts (background dispatch
  153. // progress, login flows, etc.). Verify the gate hides the visible viewport
  154. // without affecting the underlying state machine.
  155. function ViewportProbe() {
  156. const { showToast, setViewportSuppressed } = useToast();
  157. return (
  158. <>
  159. <button data-testid="show-toast" onClick={() => showToast('hello', 'success')} />
  160. <button data-testid="suppress-on" onClick={() => setViewportSuppressed(true)} />
  161. <button data-testid="suppress-off" onClick={() => setViewportSuppressed(false)} />
  162. </>
  163. );
  164. }
  165. it('hides the visible toast viewport when suppressed but keeps state alive', () => {
  166. const { container, getByTestId } = render(
  167. <ToastProvider>
  168. <ViewportProbe />
  169. </ToastProvider>
  170. );
  171. // Toast viewport is the fixed-position container with bottom-4 right-20.
  172. const findViewport = () => container.querySelector('div.fixed.bottom-4.right-20');
  173. expect(findViewport()?.className).not.toContain('hidden');
  174. act(() => {
  175. getByTestId('suppress-on').click();
  176. });
  177. expect(findViewport()?.className).toContain('hidden');
  178. // State is unaffected — emitting a toast while suppressed is fine; the
  179. // state container exists, just hidden.
  180. act(() => {
  181. getByTestId('show-toast').click();
  182. });
  183. expect(findViewport()?.className).toContain('hidden');
  184. // Restore on unmount of the kiosk layout (or via the setter directly).
  185. act(() => {
  186. getByTestId('suppress-off').click();
  187. });
  188. expect(findViewport()?.className).not.toContain('hidden');
  189. });
  190. });