Layout.test.tsx 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107
  1. /**
  2. * Tests for the Layout component.
  3. */
  4. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  5. import { fireEvent, screen, waitFor, within } from '@testing-library/react';
  6. import { render } from '../utils';
  7. import { Layout } from '../../components/Layout';
  8. import { getAuthToken, setAuthToken } from '../../api/client';
  9. import { http, HttpResponse } from 'msw';
  10. import { server } from '../mocks/server';
  11. import { SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, SIDEBAR_ORDER_KEY } from '../../utils/sidebarLayout';
  12. describe('Layout', () => {
  13. beforeEach(() => {
  14. vi.mocked(localStorage.getItem).mockReset();
  15. vi.mocked(localStorage.setItem).mockReset();
  16. vi.mocked(localStorage.removeItem).mockReset();
  17. vi.mocked(localStorage.clear).mockReset();
  18. localStorage.clear();
  19. server.use(
  20. http.get('/api/v1/printers/', () => {
  21. return HttpResponse.json([
  22. { id: 1, name: 'X1 Carbon', model: 'X1C', enabled: true },
  23. ]);
  24. }),
  25. http.get('/api/v1/printers/:id/status', () => {
  26. return HttpResponse.json({
  27. connected: true,
  28. state: 'IDLE',
  29. });
  30. }),
  31. http.get('/api/v1/version', () => {
  32. return HttpResponse.json({ version: '0.1.6', build: 'test' });
  33. }),
  34. http.get('/api/v1/settings/', () => {
  35. return HttpResponse.json({
  36. check_updates: false,
  37. check_printer_firmware: false,
  38. auto_archive: true,
  39. });
  40. }),
  41. // What the sidebar actually gates on. Layout used to read these from
  42. // /settings/, which a non-admin cannot fetch (#3023).
  43. http.get('/api/v1/settings/ui-flags', () => {
  44. return HttpResponse.json({
  45. check_updates: false,
  46. billing_enabled: false,
  47. user_notifications_enabled: true,
  48. currency: 'EUR',
  49. });
  50. }),
  51. http.get('/api/v1/external-links/', () => {
  52. return HttpResponse.json([]);
  53. }),
  54. http.get('/api/v1/smart-plugs/', () => {
  55. return HttpResponse.json([]);
  56. }),
  57. http.get('/api/v1/support/debug-logging', () => {
  58. return HttpResponse.json({ enabled: false });
  59. }),
  60. http.get('/api/v1/queue/', () => {
  61. return HttpResponse.json([]);
  62. }),
  63. http.get('/api/v1/pending-uploads/count', () => {
  64. return HttpResponse.json({ count: 0 });
  65. }),
  66. http.get('/api/v1/updates/check', () => {
  67. return HttpResponse.json({ update_available: false });
  68. }),
  69. http.get('/api/v1/auth/status', () => {
  70. return HttpResponse.json({ auth_enabled: false, requires_setup: false });
  71. }),
  72. http.get('/api/v1/printers/developer-mode-warnings', () => {
  73. return HttpResponse.json([]);
  74. })
  75. );
  76. });
  77. describe('pending uploads badge', () => {
  78. it('counts review-mode uploads on the File Manager entry, where they wait', async () => {
  79. server.use(http.get('/api/v1/pending-uploads/count', () => HttpResponse.json({ count: 3 })));
  80. render(<Layout />);
  81. const files = await waitFor(() => {
  82. const link = document.querySelector('a[href="/files"]');
  83. if (!link?.textContent?.includes('3')) throw new Error('badge not shown yet');
  84. return link;
  85. });
  86. expect(files.textContent).toContain('3');
  87. expect(document.querySelector('a[href="/archives"]')?.textContent).not.toContain('3');
  88. });
  89. });
  90. describe('rendering', () => {
  91. it('renders the sidebar', async () => {
  92. render(<Layout />);
  93. // Layout renders as a flex container with sidebar
  94. await waitFor(() => {
  95. const sidebar = document.querySelector('aside');
  96. expect(sidebar).toBeInTheDocument();
  97. });
  98. });
  99. it('renders navigation links', async () => {
  100. render(<Layout />);
  101. await waitFor(() => {
  102. // Navigation links should be present
  103. const links = document.querySelectorAll('a');
  104. expect(links.length).toBeGreaterThan(0);
  105. });
  106. });
  107. });
  108. describe('external link count', () => {
  109. // A connected app (Bambuddy Orders) reports its To do count for the link
  110. // pointing at it; the sidebar shows it like the queue's badge.
  111. const link = (id: number, name: string, badge_count: number | null, open_in_new_tab = false) => ({
  112. id,
  113. name,
  114. url: `http://app${id}.local:8090`,
  115. icon: 'Package',
  116. open_in_new_tab,
  117. custom_icon: null,
  118. sort_order: id,
  119. created_at: '2026-10-21T00:00:00Z',
  120. updated_at: '2026-10-21T00:00:00Z',
  121. badge_count,
  122. });
  123. it('shows the count an app reports, capped at 99+', async () => {
  124. server.use(
  125. http.get('/api/v1/external-links/', () =>
  126. HttpResponse.json([
  127. link(1, 'Orders', 7),
  128. link(2, 'Shop', 150, true),
  129. link(3, 'Wiki', 0),
  130. link(4, 'Docs', null),
  131. ]),
  132. ),
  133. );
  134. render(<Layout />);
  135. const orders = await screen.findByTestId('external-link-badge-1');
  136. expect(orders).toHaveTextContent('7');
  137. expect(screen.getByTestId('external-link-badge-2')).toHaveTextContent('99+');
  138. expect(screen.queryByTestId('external-link-badge-3')).not.toBeInTheDocument();
  139. expect(screen.queryByTestId('external-link-badge-4')).not.toBeInTheDocument();
  140. });
  141. });
  142. describe('navigation', () => {
  143. it('has navigation items', async () => {
  144. render(<Layout />);
  145. await waitFor(() => {
  146. // Should have multiple navigation links
  147. const navLinks = document.querySelectorAll('a[href]');
  148. expect(navLinks.length).toBeGreaterThan(0);
  149. });
  150. });
  151. it('includes settings link', async () => {
  152. render(<Layout />);
  153. await waitFor(() => {
  154. // Settings link should exist (route /settings)
  155. const settingsLink = document.querySelector('a[href="/settings"]');
  156. expect(settingsLink).toBeInTheDocument();
  157. });
  158. });
  159. it('hides system nav items stored in sidebar layout preferences', async () => {
  160. vi.mocked(localStorage.getItem).mockImplementation((key) => {
  161. if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['printers']);
  162. return null;
  163. });
  164. render(<Layout />);
  165. await waitFor(() => {
  166. const sidebar = document.querySelector('aside');
  167. expect(sidebar).toBeInTheDocument();
  168. expect(sidebar?.querySelector('a[href="/inventory"]')).toBeInTheDocument();
  169. });
  170. expect(document.querySelector('aside a[href="/"]')).toBeNull();
  171. });
  172. it('applies admin default sidebar hidden state with the default order', async () => {
  173. const storage: Record<string, string> = {};
  174. vi.mocked(localStorage.getItem).mockImplementation((key) => storage[key] ?? null);
  175. vi.mocked(localStorage.setItem).mockImplementation((key, value) => {
  176. storage[key] = value;
  177. });
  178. server.use(
  179. http.get('/api/v1/settings/default-sidebar-order', () =>
  180. HttpResponse.json({
  181. default_sidebar_order: JSON.stringify({
  182. order: ['inventory', 'printers', 'settings'],
  183. hiddenSystemItemIds: ['printers'],
  184. }),
  185. }),
  186. ),
  187. );
  188. render(<Layout />);
  189. await waitFor(() => {
  190. const sidebar = document.querySelector('aside');
  191. expect(sidebar).toBeInTheDocument();
  192. expect(sidebar?.querySelector('a[href="/inventory"]')).toBeInTheDocument();
  193. });
  194. await waitFor(() => {
  195. expect(document.querySelector('aside a[href="/"]')).toBeNull();
  196. expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_ORDER_KEY, JSON.stringify(['inventory', 'printers', 'settings']));
  197. expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify(['printers']));
  198. });
  199. });
  200. });
  201. describe('finance nav item', () => {
  202. it('stays out of the sidebar while billing is off', async () => {
  203. // billing_enabled defaults to false and the Finance page has nothing to
  204. // show without it, so the entry must not be there at all.
  205. render(<Layout />);
  206. await waitFor(() => {
  207. expect(document.querySelector('aside a[href="/stats"]')).toBeInTheDocument();
  208. });
  209. expect(document.querySelector('aside a[href="/finance"]')).toBeNull();
  210. });
  211. it('appears between Statistics and Settings once billing is on', async () => {
  212. server.use(
  213. http.get('/api/v1/settings/ui-flags', () =>
  214. HttpResponse.json({
  215. check_updates: false,
  216. billing_enabled: true,
  217. user_notifications_enabled: true,
  218. currency: 'EUR',
  219. }),
  220. ),
  221. );
  222. render(<Layout />);
  223. await waitFor(() => {
  224. expect(document.querySelector('aside a[href="/finance"]')).toBeInTheDocument();
  225. });
  226. const sidebar = document.querySelector('aside');
  227. const hrefs = Array.from(sidebar?.querySelectorAll('a[href]') ?? []).map((a) => a.getAttribute('href'));
  228. expect(hrefs.indexOf('/finance')).toBeGreaterThan(hrefs.indexOf('/stats'));
  229. expect(hrefs.indexOf('/finance')).toBeLessThan(hrefs.indexOf('/settings'));
  230. });
  231. });
  232. describe('Sidebar gates survive a user who cannot read /settings (#3023)', () => {
  233. // Every gate below used to be fed by GET /settings, which requires
  234. // settings:read. A non-admin gets 403 there, so the value arrived
  235. // undefined and each gate silently took its fallback -- in opposite
  236. // directions, which is why only one of the two was ever reported.
  237. let priorToken: string | null = null;
  238. const asNonAdmin = (permissions: string[]) => {
  239. server.use(
  240. http.get('/api/v1/auth/status', () =>
  241. HttpResponse.json({ auth_enabled: true, requires_setup: false }),
  242. ),
  243. http.get('/api/v1/auth/me', () =>
  244. HttpResponse.json({
  245. id: 2,
  246. username: 'operator',
  247. role: 'user',
  248. is_active: true,
  249. is_admin: false,
  250. groups: [{ id: 2, name: 'Operators' }],
  251. permissions,
  252. created_at: '2026-01-01T00:00:00Z',
  253. }),
  254. ),
  255. // The 403 that started it. Layout must not need this call at all.
  256. http.get('/api/v1/settings/', () =>
  257. HttpResponse.json({ detail: 'Not enough permissions' }, { status: 403 }),
  258. ),
  259. );
  260. // localStorage is a no-op mock in setup.ts, so writing the key there
  261. // authenticates nobody. Set the client's token directly.
  262. priorToken = getAuthToken();
  263. setAuthToken('test-token', 'session');
  264. };
  265. afterEach(() => {
  266. setAuthToken(priorToken, 'session');
  267. priorToken = null;
  268. });
  269. it('shows Finance to a user with cost_centers:read_own and no settings:read', async () => {
  270. asNonAdmin(['cost_centers:read_own']);
  271. server.use(
  272. http.get('/api/v1/settings/ui-flags', () =>
  273. HttpResponse.json({ billing_enabled: true, user_notifications_enabled: true }),
  274. ),
  275. );
  276. render(<Layout />);
  277. await waitFor(() => {
  278. expect(document.querySelector('aside a[href="/finance"]')).toBeInTheDocument();
  279. });
  280. });
  281. it('still hides Finance from that user when billing is off', async () => {
  282. // Waits on Notifications appearing rather than on the sidebar existing.
  283. // Asserting absence the moment <aside> renders passes before the flags
  284. // query has even resolved, which makes the assertion prove nothing.
  285. asNonAdmin(['cost_centers:read_own', 'notifications:user_email']);
  286. server.use(
  287. http.get('/api/v1/auth/advanced-auth/status', () =>
  288. HttpResponse.json({ advanced_auth_enabled: true }),
  289. ),
  290. http.get('/api/v1/settings/ui-flags', () =>
  291. HttpResponse.json({ billing_enabled: false, user_notifications_enabled: true }),
  292. ),
  293. );
  294. render(<Layout />);
  295. await waitFor(() => {
  296. expect(document.querySelector('aside a[href="/notifications"]')).toBeInTheDocument();
  297. });
  298. expect(document.querySelector('aside a[href="/finance"]')).toBeNull();
  299. });
  300. it('hides Notifications from that user when user notifications are off', async () => {
  301. // The same 403, landing the other way up: this gate tests `=== false`,
  302. // which undefined never satisfies, so an administrator who switched user
  303. // notifications off still left the entry showing to the non-admins it
  304. // governs. Unreported, and invisible to an admin testing it.
  305. asNonAdmin(['notifications:user_email', 'cost_centers:read_own']);
  306. server.use(
  307. http.get('/api/v1/auth/advanced-auth/status', () =>
  308. HttpResponse.json({ advanced_auth_enabled: true }),
  309. ),
  310. http.get('/api/v1/settings/ui-flags', () =>
  311. HttpResponse.json({ billing_enabled: true, user_notifications_enabled: false }),
  312. ),
  313. );
  314. render(<Layout />);
  315. // Finance appearing is the proof that the flags arrived; only then does
  316. // the absence of Notifications mean anything.
  317. await waitFor(() => {
  318. expect(document.querySelector('aside a[href="/finance"]')).toBeInTheDocument();
  319. });
  320. expect(document.querySelector('aside a[href="/notifications"]')).toBeNull();
  321. });
  322. it('shows Notifications to that user when they are on', async () => {
  323. asNonAdmin(['notifications:user_email']);
  324. server.use(
  325. http.get('/api/v1/auth/advanced-auth/status', () =>
  326. HttpResponse.json({ advanced_auth_enabled: true }),
  327. ),
  328. http.get('/api/v1/settings/ui-flags', () =>
  329. HttpResponse.json({ billing_enabled: false, user_notifications_enabled: true }),
  330. ),
  331. );
  332. render(<Layout />);
  333. await waitFor(() => {
  334. expect(document.querySelector('aside a[href="/notifications"]')).toBeInTheDocument();
  335. });
  336. });
  337. });
  338. describe('version display', () => {
  339. it('shows version info', async () => {
  340. render(<Layout />);
  341. await waitFor(() => {
  342. // Version info is displayed in sidebar
  343. expect(document.body).toBeInTheDocument();
  344. });
  345. });
  346. });
  347. describe('camera launcher (#2737)', () => {
  348. const originalMatchMedia = window.matchMedia;
  349. const originalWidth = window.innerWidth;
  350. /** Below 1144px the sidebar is a drawer and the compact header shows. */
  351. function compactViewport() {
  352. Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 390 });
  353. Object.defineProperty(window, 'matchMedia', {
  354. writable: true,
  355. configurable: true,
  356. value: (query: string) => ({ ...originalMatchMedia(query), matches: /max-width/.test(query) }),
  357. });
  358. }
  359. beforeEach(() => {
  360. server.use(
  361. http.post('/api/v1/printers/:id/camera/stop', () => HttpResponse.json({ success: true })),
  362. http.get('/api/v1/printers/:id/camera/status', () => HttpResponse.json({ active: true, stalled: false })),
  363. );
  364. });
  365. afterEach(() => {
  366. Object.defineProperty(window, 'matchMedia', { writable: true, configurable: true, value: originalMatchMedia });
  367. Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: originalWidth });
  368. });
  369. it('is on the Printers row of the sidebar, not in its footer', async () => {
  370. render(<Layout />);
  371. await waitFor(() => expect(document.querySelector('header')).toBeNull());
  372. const aside = document.querySelector('aside')!;
  373. const button = within(aside).getByRole('button', { name: 'Cameras' });
  374. expect(button.closest('li')?.querySelector('a[href="/"]')).not.toBeNull();
  375. expect(within(aside).getAllByRole('button', { name: 'Cameras' })).toHaveLength(1);
  376. });
  377. it('opens the camera list with the C key', async () => {
  378. render(<Layout />);
  379. await waitFor(() => expect(document.querySelector('aside')).not.toBeNull());
  380. fireEvent.keyDown(document, { key: 'c' });
  381. expect(await screen.findByRole('button', { name: /X1 Carbon/ })).toBeInTheDocument();
  382. fireEvent.keyDown(document, { key: 'c' });
  383. await waitFor(() => expect(screen.queryByRole('region', { name: 'Cameras' })).toBeNull());
  384. });
  385. it('leaves the C key alone while typing', async () => {
  386. render(
  387. <>
  388. <input aria-label="search" />
  389. <Layout />
  390. </>,
  391. );
  392. await waitFor(() => expect(document.querySelector('aside')).not.toBeNull());
  393. fireEvent.keyDown(screen.getByRole('textbox', { name: 'search' }), { key: 'c' });
  394. expect(screen.queryByRole('region', { name: 'Cameras' })).toBeNull();
  395. });
  396. it('opens the header\'s list with the C key in the compact layout', async () => {
  397. compactViewport();
  398. render(<Layout />);
  399. await waitFor(() => expect(document.querySelector('header')).not.toBeNull());
  400. fireEvent.keyDown(document, { key: 'c' });
  401. expect(await screen.findByRole('region', { name: 'Cameras' })).toBeInTheDocument();
  402. // One list, the header's, not also the one in the closed drawer.
  403. expect(screen.getAllByRole('region', { name: 'Cameras' })).toHaveLength(1);
  404. expect(within(document.querySelector('header')!).getByRole('button', { name: 'Cameras' })).toHaveAttribute(
  405. 'aria-expanded',
  406. 'true',
  407. );
  408. });
  409. it('is in the compact header, beside the bug report', async () => {
  410. compactViewport();
  411. render(<Layout />);
  412. await waitFor(() => expect(document.querySelector('header')).not.toBeNull());
  413. expect(within(document.querySelector('header')!).getByRole('button', { name: 'Cameras' })).toBeInTheDocument();
  414. });
  415. it('opens a camera overlay on a page other than Printers', async () => {
  416. compactViewport();
  417. render(<Layout />);
  418. await waitFor(() => expect(document.querySelector('header')).not.toBeNull());
  419. fireEvent.click(within(document.querySelector('header')!).getByRole('button', { name: 'Cameras' }));
  420. fireEvent.click(await screen.findByRole('button', { name: /X1 Carbon/ }));
  421. expect(await screen.findByTitle('Refresh stream')).toBeInTheDocument();
  422. });
  423. });
  424. describe('theme toggle', () => {
  425. it('has theme toggle button', async () => {
  426. render(<Layout />);
  427. await waitFor(() => {
  428. // Theme toggle should be present
  429. const buttons = document.querySelectorAll('button');
  430. expect(buttons.length).toBeGreaterThan(0);
  431. });
  432. });
  433. it('cycles through dark → light → system → dark', async () => {
  434. localStorage.setItem('theme-mode', 'dark');
  435. render(<Layout />);
  436. await waitFor(() => {
  437. // In dark mode, title should say "Switch to light mode"
  438. const btn = document.querySelector('button[title="Switch to light mode"]');
  439. expect(btn).toBeInTheDocument();
  440. });
  441. // Click to go from dark → light
  442. const lightBtn = document.querySelector('button[title="Switch to light mode"]')!;
  443. lightBtn.click();
  444. await waitFor(() => {
  445. // In light mode, title should say "Switch to system mode"
  446. const btn = document.querySelector('button[title="Switch to system mode"]');
  447. expect(btn).toBeInTheDocument();
  448. });
  449. // Click to go from light → system
  450. const systemBtn = document.querySelector('button[title="Switch to system mode"]')!;
  451. systemBtn.click();
  452. await waitFor(() => {
  453. // In system mode, title should say "Switch to dark mode"
  454. const btn = document.querySelector('button[title="Switch to dark mode"]');
  455. expect(btn).toBeInTheDocument();
  456. });
  457. // Click to go from system → dark
  458. const darkBtn = document.querySelector('button[title="Switch to dark mode"]')!;
  459. darkBtn.click();
  460. await waitFor(() => {
  461. // Back to dark mode
  462. const btn = document.querySelector('button[title="Switch to light mode"]');
  463. expect(btn).toBeInTheDocument();
  464. });
  465. });
  466. });
  467. describe('plate detection alert modal', () => {
  468. it('shows modal when plate-not-empty event is dispatched', async () => {
  469. render(<Layout />);
  470. // Dispatch the plate-not-empty event
  471. window.dispatchEvent(
  472. new CustomEvent('plate-not-empty', {
  473. detail: {
  474. printer_id: 1,
  475. printer_name: 'Test Printer',
  476. message: 'Objects detected on build plate',
  477. },
  478. })
  479. );
  480. await waitFor(() => {
  481. // Modal should appear with "Print Paused!" text
  482. expect(document.body.textContent).toContain('Print Paused!');
  483. expect(document.body.textContent).toContain('Test Printer');
  484. });
  485. });
  486. it('warns when a plate check could not run, naming why', async () => {
  487. render(<Layout />);
  488. window.dispatchEvent(
  489. new CustomEvent('plate-check-failed', {
  490. detail: { printer_id: 1, printer_name: 'Test Printer', reason: 'no_camera_frame' },
  491. })
  492. );
  493. await waitFor(() => {
  494. expect(document.body.textContent).toContain(
  495. 'Plate check could not run on Test Printer (no camera image). The print continues.'
  496. );
  497. });
  498. });
  499. it('closes modal when I Understand button is clicked', async () => {
  500. render(<Layout />);
  501. // Dispatch the plate-not-empty event
  502. window.dispatchEvent(
  503. new CustomEvent('plate-not-empty', {
  504. detail: {
  505. printer_id: 1,
  506. printer_name: 'Test Printer',
  507. message: 'Objects detected on build plate',
  508. },
  509. })
  510. );
  511. await waitFor(() => {
  512. expect(document.body.textContent).toContain('Print Paused!');
  513. });
  514. // Click the "I Understand" button
  515. const button = document.querySelector('button');
  516. if (button && button.textContent?.includes('I Understand')) {
  517. button.click();
  518. }
  519. // Find and click the "I Understand" button by searching all buttons
  520. const buttons = document.querySelectorAll('button');
  521. buttons.forEach((btn) => {
  522. if (btn.textContent?.includes('I Understand')) {
  523. btn.click();
  524. }
  525. });
  526. await waitFor(() => {
  527. // Modal should be closed
  528. expect(document.body.textContent).not.toContain('Print Paused!');
  529. });
  530. });
  531. });
  532. describe('developer mode warning banner', () => {
  533. it('shows warning banner when printers lack developer mode', async () => {
  534. server.use(
  535. http.get('/api/v1/printers/developer-mode-warnings', () => {
  536. return HttpResponse.json([
  537. { printer_id: 1, name: 'X1 Carbon' },
  538. ]);
  539. })
  540. );
  541. render(<Layout />);
  542. await waitFor(() => {
  543. expect(document.body.textContent).toContain('Developer LAN mode is not enabled on');
  544. expect(document.body.textContent).toContain('X1 Carbon');
  545. });
  546. });
  547. it('shows multiple printer names in warning banner', async () => {
  548. server.use(
  549. http.get('/api/v1/printers/developer-mode-warnings', () => {
  550. return HttpResponse.json([
  551. { printer_id: 1, name: 'X1 Carbon' },
  552. { printer_id: 2, name: 'P1S' },
  553. ]);
  554. })
  555. );
  556. render(<Layout />);
  557. await waitFor(() => {
  558. expect(document.body.textContent).toContain('X1 Carbon');
  559. expect(document.body.textContent).toContain('P1S');
  560. });
  561. });
  562. it('hides warning banner when no printers lack developer mode', async () => {
  563. // Default handler returns empty array
  564. render(<Layout />);
  565. await waitFor(() => {
  566. const sidebar = document.querySelector('aside');
  567. expect(sidebar).toBeInTheDocument();
  568. });
  569. // Banner should not be present
  570. expect(document.body.textContent).not.toContain('Developer LAN mode is not enabled on');
  571. });
  572. it('shows how to enable link in warning banner', async () => {
  573. server.use(
  574. http.get('/api/v1/printers/developer-mode-warnings', () => {
  575. return HttpResponse.json([
  576. { printer_id: 1, name: 'X1 Carbon' },
  577. ]);
  578. })
  579. );
  580. render(<Layout />);
  581. await waitFor(() => {
  582. expect(document.body.textContent).toContain('How to enable');
  583. const link = document.querySelector('a[href*="enable-developer-mode"]');
  584. expect(link).toBeInTheDocument();
  585. });
  586. });
  587. });
  588. describe('update banner suppression for HA addon', () => {
  589. // HA Supervisor surfaces its own update notification natively in the HA
  590. // UI, so the in-app banner would be duplicate noise that links to a page
  591. // that just says "update via HA". Suppress it for HA addon deployments.
  592. it('hides the update-available banner when running as an HA addon', async () => {
  593. server.use(
  594. http.get('/api/v1/updates/check', () => {
  595. return HttpResponse.json({
  596. update_available: true,
  597. current_version: '0.2.4',
  598. latest_version: '0.2.5',
  599. is_docker: true,
  600. is_ha_addon: true,
  601. update_method: 'ha_addon',
  602. });
  603. }),
  604. );
  605. render(<Layout />);
  606. await waitFor(() => {
  607. const sidebar = document.querySelector('aside');
  608. expect(sidebar).toBeInTheDocument();
  609. });
  610. expect(document.body.textContent).not.toContain('Update available');
  611. });
  612. it('still shows the update-available banner for plain Docker deployments', async () => {
  613. server.use(
  614. http.get('/api/v1/updates/check', () => {
  615. return HttpResponse.json({
  616. update_available: true,
  617. current_version: '0.2.4',
  618. latest_version: '0.2.5',
  619. is_docker: true,
  620. is_ha_addon: false,
  621. update_method: 'docker',
  622. });
  623. }),
  624. );
  625. render(<Layout />);
  626. await waitFor(() => {
  627. expect(document.body.textContent).toContain('0.2.5');
  628. });
  629. });
  630. });
  631. describe('MakerWorld sidebar permission gate (#1175)', () => {
  632. // The MakerWorld sidebar entry was visible to every authenticated user
  633. // regardless of group permissions because Layout's `navPermissions` map
  634. // had no entry for `makerworld`. Backend routes already gated on
  635. // `makerworld:view`, so users without the permission saw the entry,
  636. // clicked, and got 403'd by every API call inside the page. The fix
  637. // adds `makerworld: 'makerworld:view'` to the map so the entry is
  638. // hidden when the permission is absent — same shape as every other
  639. // sidebar entry.
  640. const enableAuthWithUser = (permissions: string[]) => {
  641. server.use(
  642. http.get('/api/v1/auth/status', () =>
  643. HttpResponse.json({ auth_enabled: true, requires_setup: false }),
  644. ),
  645. http.get('/api/v1/auth/me', () =>
  646. HttpResponse.json({
  647. id: 1,
  648. username: 'tester',
  649. role: 'user',
  650. is_active: true,
  651. is_admin: false,
  652. groups: [{ id: 2, name: 'Standard Users' }],
  653. permissions,
  654. created_at: '2026-01-01T00:00:00Z',
  655. }),
  656. ),
  657. );
  658. // AuthProvider needs a token in localStorage to fetch /auth/me; the
  659. // value isn't validated by the mocked server.
  660. window.localStorage.setItem('auth_token', 'test-token');
  661. };
  662. const findMakerWorldNavLink = () => {
  663. // Sidebar nav links use react-router's `to` prop, which renders as a
  664. // plain `<a href="/model-sources">`. Match on the href so the test isn't
  665. // coupled to whatever locale string is rendered. The page was
  666. // MakerWorld-only until Manyfold joined it as a second tab (#1471).
  667. return document.querySelector('aside a[href="/model-sources"]');
  668. };
  669. it('hides the Model Sources nav entry when the user has neither source permission', async () => {
  670. // Standard user without the MakerWorld permission. Every other
  671. // permission they hold (library:read, etc.) is irrelevant here — the
  672. // gate is per-entry and the MakerWorld entry must not render.
  673. enableAuthWithUser(['library:read', 'archives:read', 'queue:read']);
  674. render(<Layout />);
  675. await waitFor(() => {
  676. // Wait for the auth resolution + sidebar render. Some other nav
  677. // entry (Files / Archives) confirms the sidebar finished mounting.
  678. const sidebar = document.querySelector('aside');
  679. expect(sidebar).toBeInTheDocument();
  680. expect(sidebar?.querySelector('a[href="/files"]')).toBeInTheDocument();
  681. });
  682. await waitFor(() => expect(findMakerWorldNavLink()).toBeNull());
  683. });
  684. it('shows the MakerWorld nav entry when the user has makerworld:view', async () => {
  685. enableAuthWithUser([
  686. 'library:read',
  687. 'archives:read',
  688. 'queue:read',
  689. 'makerworld:view',
  690. ]);
  691. render(<Layout />);
  692. await waitFor(() => {
  693. expect(findMakerWorldNavLink()).toBeInTheDocument();
  694. });
  695. });
  696. it('shows the Model Sources nav entry with manyfold:view alone (#1471)', async () => {
  697. enableAuthWithUser(['library:read', 'manyfold:view']);
  698. render(<Layout />);
  699. await waitFor(() => {
  700. expect(findMakerWorldNavLink()).toBeInTheDocument();
  701. });
  702. });
  703. });
  704. describe('Sidebar gate accepts granular read tiers (#1755)', () => {
  705. // Default Operators group is seeded with `*:read_own` only — never the
  706. // legacy `*:read`. Previously the sidebar gate checked the legacy alone,
  707. // so Archives / Queue / Files were hidden from every non-admin even
  708. // though the underlying API endpoints accepted their requests. These
  709. // tests pin that the gate accepts ANY of the three tiers (legacy /
  710. // _own / _all) for the three resources that ship granular variants.
  711. const enableAuthWithUser = (permissions: string[]) => {
  712. server.use(
  713. http.get('/api/v1/auth/status', () =>
  714. HttpResponse.json({ auth_enabled: true, requires_setup: false }),
  715. ),
  716. http.get('/api/v1/auth/me', () =>
  717. HttpResponse.json({
  718. id: 1,
  719. username: 'tester',
  720. role: 'user',
  721. is_active: true,
  722. is_admin: false,
  723. groups: [{ id: 2, name: 'Operators' }],
  724. permissions,
  725. created_at: '2026-01-01T00:00:00Z',
  726. }),
  727. ),
  728. );
  729. window.localStorage.setItem('auth_token', 'test-token');
  730. };
  731. const sidebarLink = (href: string) =>
  732. document.querySelector(`aside a[href="${href}"]`);
  733. it('shows Files in the sidebar when the user only has library:read_own', async () => {
  734. enableAuthWithUser(['library:read_own']);
  735. render(<Layout />);
  736. await waitFor(() => {
  737. expect(document.querySelector('aside')).toBeInTheDocument();
  738. expect(sidebarLink('/files')).toBeInTheDocument();
  739. });
  740. });
  741. it('shows Files in the sidebar when the user only has library:read_all', async () => {
  742. enableAuthWithUser(['library:read_all']);
  743. render(<Layout />);
  744. await waitFor(() => {
  745. expect(sidebarLink('/files')).toBeInTheDocument();
  746. });
  747. });
  748. it('shows Archives in the sidebar when the user only has archives:read_own', async () => {
  749. enableAuthWithUser(['archives:read_own']);
  750. render(<Layout />);
  751. await waitFor(() => {
  752. expect(sidebarLink('/archives')).toBeInTheDocument();
  753. });
  754. });
  755. it('shows Queue in the sidebar when the user only has queue:read_own', async () => {
  756. enableAuthWithUser(['queue:read_own']);
  757. render(<Layout />);
  758. await waitFor(() => {
  759. expect(sidebarLink('/queue')).toBeInTheDocument();
  760. });
  761. });
  762. it('still hides Files when the user has none of the three read tiers', async () => {
  763. enableAuthWithUser(['printers:read']);
  764. render(<Layout />);
  765. await waitFor(() => {
  766. expect(document.querySelector('aside')).toBeInTheDocument();
  767. });
  768. expect(sidebarLink('/files')).toBeNull();
  769. expect(sidebarLink('/archives')).toBeNull();
  770. expect(sidebarLink('/queue')).toBeNull();
  771. });
  772. });
  773. describe('outcome confirmation deep link (#1898)', () => {
  774. // The URL a Pushover / Bark notification opens in a brand new tab. Layout
  775. // is the PARENT of the page that renders at /archives, and React flushes a
  776. // child's effects before its parent's — so while the page owned this deep
  777. // link it dispatched `print-confirm-request` before Layout had added the
  778. // listener, and then stripped the parameter, leaving nothing to recover.
  779. // These tests pin the read where the dialog state lives.
  780. const archive = {
  781. id: 42,
  782. printer_id: 1,
  783. filename: 'bracket.gcode.3mf',
  784. print_name: 'Bracket',
  785. status: 'completed',
  786. photos: null,
  787. user_verdict: null,
  788. user_verdict_source: null,
  789. confirm_requested: true,
  790. };
  791. beforeEach(() => {
  792. server.use(
  793. http.get('/api/v1/archives/42', () => HttpResponse.json(archive))
  794. );
  795. });
  796. afterEach(() => {
  797. window.history.replaceState({}, '', '/');
  798. });
  799. it('opens the dialog on a cold load, with no page mounted to relay the event', async () => {
  800. window.history.replaceState({}, '', '/archives?confirm=42');
  801. render(<Layout />);
  802. expect(await screen.findByTestId('confirm-outcome-dialog')).toBeInTheDocument();
  803. });
  804. it('opens the dialog on the plate the link names', async () => {
  805. // Plates of one archive each have their own question (#1898).
  806. let requestedRun: string | null = null;
  807. server.use(
  808. http.get('/api/v1/archives/42', ({ request }) => {
  809. requestedRun = new URL(request.url).searchParams.get('run_id');
  810. return HttpResponse.json({ ...archive, run_id: 7 });
  811. }),
  812. );
  813. window.history.replaceState({}, '', '/archives?confirm=42&run=7&status=completed');
  814. render(<Layout />);
  815. await screen.findByTestId('confirm-outcome-dialog');
  816. await waitFor(() => {
  817. expect(requestedRun).toBe('7');
  818. });
  819. await waitFor(() => {
  820. expect(window.location.search).toBe('?status=completed');
  821. });
  822. });
  823. it('strips the parameter but keeps the rest of the query', async () => {
  824. window.history.replaceState({}, '', '/archives?confirm=42&status=completed');
  825. render(<Layout />);
  826. await screen.findByTestId('confirm-outcome-dialog');
  827. await waitFor(() => {
  828. expect(window.location.search).toBe('?status=completed');
  829. });
  830. expect(window.location.pathname).toBe('/archives');
  831. });
  832. it('ignores a confirm parameter that is not an archive id', async () => {
  833. window.history.replaceState({}, '', '/archives?confirm=not-an-id');
  834. render(<Layout />);
  835. await waitFor(() => {
  836. expect(document.querySelector('aside')).toBeInTheDocument();
  837. });
  838. expect(screen.queryByTestId('confirm-outcome-dialog')).toBeNull();
  839. // Nothing was consumed, so nothing is rewritten either.
  840. expect(window.location.search).toBe('?confirm=not-an-id');
  841. });
  842. // Round 4: the dialog's only outcome for a user who may not record a
  843. // verdict is a 403 from the PATCH, so it is gated like plate-not-empty —
  844. // on archives:update_all / archives:update_own, the names the June
  845. // permission migration left in the default groups.
  846. describe('without permission to record a verdict', () => {
  847. const withPermissions = (permissions: string[]) => {
  848. server.use(
  849. http.get('/api/v1/auth/status', () =>
  850. HttpResponse.json({ auth_enabled: true, requires_setup: false }),
  851. ),
  852. http.get('/api/v1/auth/me', () =>
  853. HttpResponse.json({
  854. id: 1,
  855. username: 'tester',
  856. role: 'user',
  857. is_active: true,
  858. is_admin: false,
  859. groups: [{ id: 2, name: 'Standard Users' }],
  860. permissions,
  861. created_at: '2026-01-01T00:00:00Z',
  862. }),
  863. ),
  864. );
  865. // localStorage is a mock in this suite, so the token goes through the
  866. // client the way the app sets it — AuthProvider will not fetch
  867. // /auth/me without one, and the permissions would stay empty.
  868. setAuthToken('test-token');
  869. };
  870. afterEach(() => {
  871. setAuthToken(null);
  872. });
  873. it('ignores the event', async () => {
  874. withPermissions(['archives:read_all']);
  875. render(<Layout />);
  876. await waitFor(() => {
  877. expect(document.querySelector('aside')).toBeInTheDocument();
  878. });
  879. window.dispatchEvent(
  880. new CustomEvent('print-confirm-request', { detail: { archive_id: 42 } })
  881. );
  882. await waitFor(() => {
  883. expect(screen.queryByTestId('confirm-outcome-dialog')).toBeNull();
  884. });
  885. });
  886. it('consumes the deep link without opening anything', async () => {
  887. withPermissions(['archives:read_all']);
  888. window.history.replaceState({}, '', '/archives?confirm=42');
  889. render(<Layout />);
  890. // The parameter still goes: a link that can open nothing should not
  891. // survive a reload either.
  892. await waitFor(() => {
  893. expect(window.location.search).toBe('');
  894. });
  895. expect(screen.queryByTestId('confirm-outcome-dialog')).toBeNull();
  896. });
  897. it('opens for a user who may update their own archives', async () => {
  898. withPermissions(['archives:read_own', 'archives:update_own']);
  899. render(<Layout />);
  900. await waitFor(() => {
  901. expect(document.querySelector('aside')).toBeInTheDocument();
  902. });
  903. window.dispatchEvent(
  904. new CustomEvent('print-confirm-request', { detail: { archive_id: 42 } })
  905. );
  906. expect(await screen.findByTestId('confirm-outcome-dialog')).toBeInTheDocument();
  907. });
  908. });
  909. it('still opens from the WebSocket event, which carries no URL', async () => {
  910. render(<Layout />);
  911. await waitFor(() => {
  912. expect(document.querySelector('aside')).toBeInTheDocument();
  913. });
  914. window.dispatchEvent(
  915. new CustomEvent('print-confirm-request', { detail: { archive_id: 42 } })
  916. );
  917. expect(await screen.findByTestId('confirm-outcome-dialog')).toBeInTheDocument();
  918. });
  919. it('opens from the WebSocket event on the plate it names', async () => {
  920. let requestedRun: string | null = null;
  921. server.use(
  922. http.get('/api/v1/archives/42', ({ request }) => {
  923. requestedRun = new URL(request.url).searchParams.get('run_id');
  924. return HttpResponse.json({ ...archive, run_id: 7 });
  925. }),
  926. );
  927. render(<Layout />);
  928. await waitFor(() => {
  929. expect(document.querySelector('aside')).toBeInTheDocument();
  930. });
  931. window.dispatchEvent(
  932. new CustomEvent('print-confirm-request', { detail: { archive_id: 42, run_id: 7 } })
  933. );
  934. await screen.findByTestId('confirm-outcome-dialog');
  935. await waitFor(() => {
  936. expect(requestedRun).toBe('7');
  937. });
  938. });
  939. });
  940. });