Layout.test.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. /**
  2. * Tests for the Layout component.
  3. */
  4. import { describe, it, expect, beforeEach } from 'vitest';
  5. import { waitFor } from '@testing-library/react';
  6. import { render } from '../utils';
  7. import { Layout } from '../../components/Layout';
  8. import { http, HttpResponse } from 'msw';
  9. import { server } from '../mocks/server';
  10. describe('Layout', () => {
  11. beforeEach(() => {
  12. server.use(
  13. http.get('/api/v1/printers/', () => {
  14. return HttpResponse.json([
  15. { id: 1, name: 'X1 Carbon', model: 'X1C', enabled: true },
  16. ]);
  17. }),
  18. http.get('/api/v1/printers/:id/status', () => {
  19. return HttpResponse.json({
  20. connected: true,
  21. state: 'IDLE',
  22. });
  23. }),
  24. http.get('/api/v1/version', () => {
  25. return HttpResponse.json({ version: '0.1.6', build: 'test' });
  26. }),
  27. http.get('/api/v1/settings/', () => {
  28. return HttpResponse.json({
  29. check_updates: false,
  30. check_printer_firmware: false,
  31. auto_archive: true,
  32. });
  33. }),
  34. http.get('/api/v1/external-links/', () => {
  35. return HttpResponse.json([]);
  36. }),
  37. http.get('/api/v1/smart-plugs/', () => {
  38. return HttpResponse.json([]);
  39. }),
  40. http.get('/api/v1/support/debug-logging', () => {
  41. return HttpResponse.json({ enabled: false });
  42. }),
  43. http.get('/api/v1/queue/', () => {
  44. return HttpResponse.json([]);
  45. }),
  46. http.get('/api/v1/pending-uploads/count', () => {
  47. return HttpResponse.json({ count: 0 });
  48. }),
  49. http.get('/api/v1/updates/check', () => {
  50. return HttpResponse.json({ update_available: false });
  51. }),
  52. http.get('/api/v1/auth/status', () => {
  53. return HttpResponse.json({ auth_enabled: false, requires_setup: false });
  54. }),
  55. http.get('/api/v1/printers/developer-mode-warnings', () => {
  56. return HttpResponse.json([]);
  57. })
  58. );
  59. });
  60. describe('rendering', () => {
  61. it('renders the sidebar', async () => {
  62. render(<Layout />);
  63. // Layout renders as a flex container with sidebar
  64. await waitFor(() => {
  65. const sidebar = document.querySelector('aside');
  66. expect(sidebar).toBeInTheDocument();
  67. });
  68. });
  69. it('renders navigation links', async () => {
  70. render(<Layout />);
  71. await waitFor(() => {
  72. // Navigation links should be present
  73. const links = document.querySelectorAll('a');
  74. expect(links.length).toBeGreaterThan(0);
  75. });
  76. });
  77. });
  78. describe('navigation', () => {
  79. it('has navigation items', async () => {
  80. render(<Layout />);
  81. await waitFor(() => {
  82. // Should have multiple navigation links
  83. const navLinks = document.querySelectorAll('a[href]');
  84. expect(navLinks.length).toBeGreaterThan(0);
  85. });
  86. });
  87. it('includes settings link', async () => {
  88. render(<Layout />);
  89. await waitFor(() => {
  90. // Settings link should exist (route /settings)
  91. const settingsLink = document.querySelector('a[href="/settings"]');
  92. expect(settingsLink).toBeInTheDocument();
  93. });
  94. });
  95. });
  96. describe('version display', () => {
  97. it('shows version info', async () => {
  98. render(<Layout />);
  99. await waitFor(() => {
  100. // Version info is displayed in sidebar
  101. expect(document.body).toBeInTheDocument();
  102. });
  103. });
  104. });
  105. describe('theme toggle', () => {
  106. it('has theme toggle button', async () => {
  107. render(<Layout />);
  108. await waitFor(() => {
  109. // Theme toggle should be present
  110. const buttons = document.querySelectorAll('button');
  111. expect(buttons.length).toBeGreaterThan(0);
  112. });
  113. });
  114. it('cycles through dark → light → system → dark', async () => {
  115. localStorage.setItem('theme-mode', 'dark');
  116. render(<Layout />);
  117. await waitFor(() => {
  118. // In dark mode, title should say "Switch to light mode"
  119. const btn = document.querySelector('button[title="Switch to light mode"]');
  120. expect(btn).toBeInTheDocument();
  121. });
  122. // Click to go from dark → light
  123. const lightBtn = document.querySelector('button[title="Switch to light mode"]')!;
  124. lightBtn.click();
  125. await waitFor(() => {
  126. // In light mode, title should say "Switch to system mode"
  127. const btn = document.querySelector('button[title="Switch to system mode"]');
  128. expect(btn).toBeInTheDocument();
  129. });
  130. // Click to go from light → system
  131. const systemBtn = document.querySelector('button[title="Switch to system mode"]')!;
  132. systemBtn.click();
  133. await waitFor(() => {
  134. // In system mode, title should say "Switch to dark mode"
  135. const btn = document.querySelector('button[title="Switch to dark mode"]');
  136. expect(btn).toBeInTheDocument();
  137. });
  138. // Click to go from system → dark
  139. const darkBtn = document.querySelector('button[title="Switch to dark mode"]')!;
  140. darkBtn.click();
  141. await waitFor(() => {
  142. // Back to dark mode
  143. const btn = document.querySelector('button[title="Switch to light mode"]');
  144. expect(btn).toBeInTheDocument();
  145. });
  146. });
  147. });
  148. describe('plate detection alert modal', () => {
  149. it('shows modal when plate-not-empty event is dispatched', async () => {
  150. render(<Layout />);
  151. // Dispatch the plate-not-empty event
  152. window.dispatchEvent(
  153. new CustomEvent('plate-not-empty', {
  154. detail: {
  155. printer_id: 1,
  156. printer_name: 'Test Printer',
  157. message: 'Objects detected on build plate',
  158. },
  159. })
  160. );
  161. await waitFor(() => {
  162. // Modal should appear with "Print Paused!" text
  163. expect(document.body.textContent).toContain('Print Paused!');
  164. expect(document.body.textContent).toContain('Test Printer');
  165. });
  166. });
  167. it('closes modal when I Understand button is clicked', async () => {
  168. render(<Layout />);
  169. // Dispatch the plate-not-empty event
  170. window.dispatchEvent(
  171. new CustomEvent('plate-not-empty', {
  172. detail: {
  173. printer_id: 1,
  174. printer_name: 'Test Printer',
  175. message: 'Objects detected on build plate',
  176. },
  177. })
  178. );
  179. await waitFor(() => {
  180. expect(document.body.textContent).toContain('Print Paused!');
  181. });
  182. // Click the "I Understand" button
  183. const button = document.querySelector('button');
  184. if (button && button.textContent?.includes('I Understand')) {
  185. button.click();
  186. }
  187. // Find and click the "I Understand" button by searching all buttons
  188. const buttons = document.querySelectorAll('button');
  189. buttons.forEach((btn) => {
  190. if (btn.textContent?.includes('I Understand')) {
  191. btn.click();
  192. }
  193. });
  194. await waitFor(() => {
  195. // Modal should be closed
  196. expect(document.body.textContent).not.toContain('Print Paused!');
  197. });
  198. });
  199. });
  200. describe('developer mode warning banner', () => {
  201. it('shows warning banner when printers lack developer mode', async () => {
  202. server.use(
  203. http.get('/api/v1/printers/developer-mode-warnings', () => {
  204. return HttpResponse.json([
  205. { printer_id: 1, name: 'X1 Carbon' },
  206. ]);
  207. })
  208. );
  209. render(<Layout />);
  210. await waitFor(() => {
  211. expect(document.body.textContent).toContain('Developer LAN mode is not enabled on');
  212. expect(document.body.textContent).toContain('X1 Carbon');
  213. });
  214. });
  215. it('shows multiple printer names in warning banner', async () => {
  216. server.use(
  217. http.get('/api/v1/printers/developer-mode-warnings', () => {
  218. return HttpResponse.json([
  219. { printer_id: 1, name: 'X1 Carbon' },
  220. { printer_id: 2, name: 'P1S' },
  221. ]);
  222. })
  223. );
  224. render(<Layout />);
  225. await waitFor(() => {
  226. expect(document.body.textContent).toContain('X1 Carbon');
  227. expect(document.body.textContent).toContain('P1S');
  228. });
  229. });
  230. it('hides warning banner when no printers lack developer mode', async () => {
  231. // Default handler returns empty array
  232. render(<Layout />);
  233. await waitFor(() => {
  234. const sidebar = document.querySelector('aside');
  235. expect(sidebar).toBeInTheDocument();
  236. });
  237. // Banner should not be present
  238. expect(document.body.textContent).not.toContain('Developer LAN mode is not enabled on');
  239. });
  240. it('shows how to enable link in warning banner', async () => {
  241. server.use(
  242. http.get('/api/v1/printers/developer-mode-warnings', () => {
  243. return HttpResponse.json([
  244. { printer_id: 1, name: 'X1 Carbon' },
  245. ]);
  246. })
  247. );
  248. render(<Layout />);
  249. await waitFor(() => {
  250. expect(document.body.textContent).toContain('How to enable');
  251. const link = document.querySelector('a[href*="enable-developer-mode"]');
  252. expect(link).toBeInTheDocument();
  253. });
  254. });
  255. });
  256. describe('update banner suppression for HA addon', () => {
  257. // HA Supervisor surfaces its own update notification natively in the HA
  258. // UI, so the in-app banner would be duplicate noise that links to a page
  259. // that just says "update via HA". Suppress it for HA addon deployments.
  260. it('hides the update-available banner when running as an HA addon', async () => {
  261. server.use(
  262. http.get('/api/v1/updates/check', () => {
  263. return HttpResponse.json({
  264. update_available: true,
  265. current_version: '0.2.4',
  266. latest_version: '0.2.5',
  267. is_docker: true,
  268. is_ha_addon: true,
  269. update_method: 'ha_addon',
  270. });
  271. }),
  272. );
  273. render(<Layout />);
  274. await waitFor(() => {
  275. const sidebar = document.querySelector('aside');
  276. expect(sidebar).toBeInTheDocument();
  277. });
  278. expect(document.body.textContent).not.toContain('Update available');
  279. });
  280. it('still shows the update-available banner for plain Docker deployments', async () => {
  281. server.use(
  282. http.get('/api/v1/updates/check', () => {
  283. return HttpResponse.json({
  284. update_available: true,
  285. current_version: '0.2.4',
  286. latest_version: '0.2.5',
  287. is_docker: true,
  288. is_ha_addon: false,
  289. update_method: 'docker',
  290. });
  291. }),
  292. );
  293. render(<Layout />);
  294. await waitFor(() => {
  295. expect(document.body.textContent).toContain('0.2.5');
  296. });
  297. });
  298. });
  299. describe('MakerWorld sidebar permission gate (#1175)', () => {
  300. // The MakerWorld sidebar entry was visible to every authenticated user
  301. // regardless of group permissions because Layout's `navPermissions` map
  302. // had no entry for `makerworld`. Backend routes already gated on
  303. // `makerworld:view`, so users without the permission saw the entry,
  304. // clicked, and got 403'd by every API call inside the page. The fix
  305. // adds `makerworld: 'makerworld:view'` to the map so the entry is
  306. // hidden when the permission is absent — same shape as every other
  307. // sidebar entry.
  308. const enableAuthWithUser = (permissions: string[]) => {
  309. server.use(
  310. http.get('/api/v1/auth/status', () =>
  311. HttpResponse.json({ auth_enabled: true, requires_setup: false }),
  312. ),
  313. http.get('/api/v1/auth/me', () =>
  314. HttpResponse.json({
  315. id: 1,
  316. username: 'tester',
  317. role: 'user',
  318. is_active: true,
  319. is_admin: false,
  320. groups: [{ id: 2, name: 'Standard Users' }],
  321. permissions,
  322. created_at: '2026-01-01T00:00:00Z',
  323. }),
  324. ),
  325. );
  326. // AuthProvider needs a token in localStorage to fetch /auth/me; the
  327. // value isn't validated by the mocked server.
  328. window.localStorage.setItem('auth_token', 'test-token');
  329. };
  330. const findMakerWorldNavLink = () => {
  331. // Sidebar nav links use react-router's `to` prop, which renders as a
  332. // plain `<a href="/makerworld">`. Match on the href so the test isn't
  333. // coupled to whatever locale string is rendered.
  334. return document.querySelector('aside a[href="/makerworld"]');
  335. };
  336. it('hides the MakerWorld nav entry when the user lacks makerworld:view', async () => {
  337. // Standard user without the MakerWorld permission. Every other
  338. // permission they hold (library:read, etc.) is irrelevant here — the
  339. // gate is per-entry and the MakerWorld entry must not render.
  340. enableAuthWithUser(['library:read', 'archives:read', 'queue:read']);
  341. render(<Layout />);
  342. await waitFor(() => {
  343. // Wait for the auth resolution + sidebar render. Some other nav
  344. // entry (Files / Archives) confirms the sidebar finished mounting.
  345. const sidebar = document.querySelector('aside');
  346. expect(sidebar).toBeInTheDocument();
  347. expect(sidebar?.querySelector('a[href="/files"]')).toBeInTheDocument();
  348. });
  349. expect(findMakerWorldNavLink()).toBeNull();
  350. });
  351. it('shows the MakerWorld nav entry when the user has makerworld:view', async () => {
  352. enableAuthWithUser([
  353. 'library:read',
  354. 'archives:read',
  355. 'queue:read',
  356. 'makerworld:view',
  357. ]);
  358. render(<Layout />);
  359. await waitFor(() => {
  360. expect(findMakerWorldNavLink()).toBeInTheDocument();
  361. });
  362. });
  363. });
  364. });