SettingsPage.test.tsx 63 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714
  1. /**
  2. * Tests for the SettingsPage component.
  3. */
  4. import { describe, it, expect, beforeEach, vi } from 'vitest';
  5. import { act, fireEvent, render as rtlRender, screen, waitFor, within } from '@testing-library/react';
  6. import userEvent from '@testing-library/user-event';
  7. import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
  8. import { BrowserRouter } from 'react-router-dom';
  9. import { render } from '../utils';
  10. import { ThemeProvider } from '../../contexts/ThemeContext';
  11. import { ToastProvider } from '../../contexts/ToastContext';
  12. import { AuthProvider } from '../../contexts/AuthContext';
  13. import { SettingsPage } from '../../pages/SettingsPage';
  14. import { http, HttpResponse } from 'msw';
  15. import { server } from '../mocks/server';
  16. import { SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, SIDEBAR_ORDER_KEY } from '../../utils/sidebarLayout';
  17. import { setAuthToken } from '../../api/client';
  18. const mockSettings = {
  19. auto_archive: true,
  20. save_thumbnails: true,
  21. capture_finish_photo: true,
  22. default_filament_cost: 25.0,
  23. currency: 'USD',
  24. ams_humidity_good: 40,
  25. ams_humidity_fair: 60,
  26. ams_temp_good: 30,
  27. ams_temp_fair: 35,
  28. time_format: 'system',
  29. date_format: 'system',
  30. mqtt_enabled: false,
  31. mqtt_host: '',
  32. mqtt_port: 1883,
  33. spoolman_enabled: false,
  34. spoolman_url: '',
  35. ha_enabled: false,
  36. ha_url: '',
  37. ha_token: '',
  38. check_updates: false,
  39. check_printer_firmware: false,
  40. bed_cooled_threshold: 35,
  41. };
  42. describe('SettingsPage', () => {
  43. beforeEach(() => {
  44. // BrowserRouter shares window.location across tests; reset it so a tab
  45. // switch in one test (e.g. clicking "Workflow") doesn't carry into
  46. // sibling tests that expect to land on the default General tab.
  47. window.history.replaceState({}, '', '/');
  48. vi.mocked(localStorage.getItem).mockReset();
  49. vi.mocked(localStorage.setItem).mockReset();
  50. vi.mocked(localStorage.removeItem).mockReset();
  51. vi.mocked(localStorage.clear).mockReset();
  52. localStorage.clear();
  53. setAuthToken(null);
  54. server.use(
  55. http.get('/api/v1/settings/', () => {
  56. return HttpResponse.json(mockSettings);
  57. }),
  58. http.put('/api/v1/settings/', async ({ request }) => {
  59. const body = await request.json();
  60. return HttpResponse.json({ ...mockSettings, ...body });
  61. }),
  62. http.get('/api/v1/printers/', () => {
  63. return HttpResponse.json([]);
  64. }),
  65. http.get('/api/v1/smart-plugs/', () => {
  66. return HttpResponse.json([]);
  67. }),
  68. http.get('/api/v1/notifications/', () => {
  69. return HttpResponse.json([]);
  70. }),
  71. http.get('/api/v1/api-keys/', () => {
  72. return HttpResponse.json([]);
  73. }),
  74. http.get('/api/v1/mqtt/status', () => {
  75. return HttpResponse.json({ enabled: false });
  76. }),
  77. http.get('/api/v1/virtual-printer/status', () => {
  78. return HttpResponse.json({ running: false });
  79. }),
  80. http.get('/api/v1/auth/status', () => {
  81. return HttpResponse.json({ auth_enabled: false, requires_setup: false });
  82. }),
  83. http.get('/api/v1/external-links/', () => {
  84. return HttpResponse.json([]);
  85. })
  86. );
  87. });
  88. describe('rendering', () => {
  89. it('renders the page title', async () => {
  90. render(<SettingsPage />);
  91. await waitFor(() => {
  92. // Use role-based query to avoid conflicts with dropdown options
  93. expect(screen.getByRole('heading', { name: 'Settings' })).toBeInTheDocument();
  94. });
  95. });
  96. it('shows settings tabs', async () => {
  97. render(<SettingsPage />);
  98. await waitFor(() => {
  99. // Use getAllByText since "General" appears both as tab and section heading
  100. expect(screen.getAllByText('General').length).toBeGreaterThan(0);
  101. expect(screen.getByText('Smart Plugs')).toBeInTheDocument();
  102. expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0);
  103. expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
  104. expect(screen.getByText('Network')).toBeInTheDocument();
  105. expect(screen.getByText('API Keys')).toBeInTheDocument();
  106. });
  107. });
  108. });
  109. describe('finish photo plate restore (#2547)', () => {
  110. const restoreLabel = 'Restore plate for finish photo';
  111. it('offers the toggle while finish photos are enabled', async () => {
  112. render(<SettingsPage />);
  113. expect(await screen.findByText(restoreLabel)).toBeInTheDocument();
  114. });
  115. it('hides the toggle when finish photos are switched off', async () => {
  116. // It only describes how the finish photo is framed, so it is meaningless
  117. // when no finish photo is taken at all.
  118. server.use(
  119. http.get('/api/v1/settings/', () =>
  120. HttpResponse.json({ ...mockSettings, capture_finish_photo: false })
  121. )
  122. );
  123. render(<SettingsPage />);
  124. await screen.findByRole('heading', { name: 'Settings' });
  125. await waitFor(() => {
  126. expect(screen.queryByText(restoreLabel)).not.toBeInTheDocument();
  127. });
  128. });
  129. it('defaults to on when the backend has never stored the setting', async () => {
  130. // Existing installs have no row for it; the UI must not read that as off.
  131. render(<SettingsPage />);
  132. const label = await screen.findByText(restoreLabel);
  133. const row = label.closest('div')!.parentElement!;
  134. expect(within(row).getByRole('checkbox')).toBeChecked();
  135. });
  136. it('sends the new value on save', async () => {
  137. let saved: Record<string, unknown> | null = null;
  138. server.use(
  139. http.put('/api/v1/settings/', async ({ request }) => {
  140. saved = (await request.json()) as Record<string, unknown>;
  141. return HttpResponse.json({ ...mockSettings, ...saved });
  142. })
  143. );
  144. render(<SettingsPage />);
  145. const label = await screen.findByText(restoreLabel);
  146. // The page suppresses auto-save for 100ms after the settings load, so a
  147. // click landing inside that window is swallowed with no re-trigger.
  148. await new Promise((resolve) => setTimeout(resolve, 200));
  149. const row = label.closest('div')!.parentElement!;
  150. await userEvent.click(within(row).getByRole('checkbox'));
  151. // The page auto-saves on a 500ms debounce, so the default 1s waitFor
  152. // window is only just wide enough — give the request room to land.
  153. await waitFor(() => {
  154. expect(saved).not.toBeNull();
  155. }, { timeout: 3000 });
  156. expect(saved!.finish_photo_restore_plate).toBe(false);
  157. });
  158. });
  159. describe('general settings', () => {
  160. it('shows date format setting', async () => {
  161. render(<SettingsPage />);
  162. await waitFor(() => {
  163. expect(screen.getByText('Date Format')).toBeInTheDocument();
  164. });
  165. });
  166. it('shows time format setting', async () => {
  167. render(<SettingsPage />);
  168. await waitFor(() => {
  169. expect(screen.getByText('Time Format')).toBeInTheDocument();
  170. });
  171. });
  172. it('shows default printer setting', async () => {
  173. render(<SettingsPage />);
  174. await waitFor(() => {
  175. expect(screen.getByText('Default Printer')).toBeInTheDocument();
  176. });
  177. });
  178. it('shows preferred slicer setting on Workflow tab', async () => {
  179. const user = userEvent.setup();
  180. render(<SettingsPage />);
  181. await waitFor(() => {
  182. expect(screen.getByText('Workflow')).toBeInTheDocument();
  183. });
  184. await user.click(screen.getByText('Workflow'));
  185. await waitFor(() => {
  186. expect(screen.getByText('Preferred Slicer')).toBeInTheDocument();
  187. });
  188. });
  189. it('shows slicer dropdown with both options on Workflow tab', async () => {
  190. const user = userEvent.setup();
  191. render(<SettingsPage />);
  192. await waitFor(() => {
  193. expect(screen.getByText('Workflow')).toBeInTheDocument();
  194. });
  195. await user.click(screen.getByText('Workflow'));
  196. await waitFor(() => {
  197. const slicerSelect = screen.getAllByDisplayValue('Bambu Studio');
  198. expect(slicerSelect.length).toBeGreaterThan(0);
  199. });
  200. });
  201. it('shows appearance section', async () => {
  202. render(<SettingsPage />);
  203. await waitFor(() => {
  204. expect(screen.getByText('Appearance')).toBeInTheDocument();
  205. });
  206. });
  207. it('shows updates section with firmware toggle', async () => {
  208. render(<SettingsPage />);
  209. await waitFor(() => {
  210. expect(screen.getByText('Updates')).toBeInTheDocument();
  211. expect(screen.getByText('Check for updates')).toBeInTheDocument();
  212. expect(screen.getByText('Check printer firmware')).toBeInTheDocument();
  213. });
  214. });
  215. it('hides a Bambuddy sidebar page from Sidebar', async () => {
  216. const user = userEvent.setup();
  217. render(<SettingsPage />);
  218. await screen.findByRole('heading', { name: 'Sidebar' });
  219. await screen.findAllByText('Visible in sidebar');
  220. vi.mocked(localStorage.setItem).mockClear();
  221. await user.click((await screen.findAllByLabelText('Hide page'))[0]);
  222. expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify(['printers']));
  223. expect(screen.getByText('Hidden from sidebar')).toBeInTheDocument();
  224. });
  225. it('shows a previously hidden Bambuddy sidebar page from Sidebar', async () => {
  226. vi.mocked(localStorage.getItem).mockImplementation((key) => {
  227. if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['printers']);
  228. return null;
  229. });
  230. const user = userEvent.setup();
  231. render(<SettingsPage />);
  232. await screen.findByRole('heading', { name: 'Sidebar' });
  233. await screen.findByText('Hidden from sidebar');
  234. vi.mocked(localStorage.setItem).mockClear();
  235. await user.click(await screen.findByLabelText('Show page'));
  236. expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify([]));
  237. expect(screen.getAllByText('Visible in sidebar').length).toBeGreaterThan(0);
  238. });
  239. it('does not allow Settings to be hidden from Sidebar', async () => {
  240. render(<SettingsPage />);
  241. await screen.findByRole('heading', { name: 'Sidebar' });
  242. await screen.findByText('Required in sidebar');
  243. const settingsVisibilityButton = await screen.findByLabelText('Settings cannot be hidden');
  244. expect(settingsVisibilityButton).toBeDisabled();
  245. expect(screen.getByText('Required in sidebar')).toBeInTheDocument();
  246. });
  247. it('presents external links and Bambuddy pages in saved sidebar order', async () => {
  248. vi.mocked(localStorage.getItem).mockImplementation((key) => {
  249. if (key === SIDEBAR_ORDER_KEY) return JSON.stringify(['ext-7', 'printers', 'settings']);
  250. return null;
  251. });
  252. server.use(
  253. http.get('/api/v1/external-links/', () =>
  254. HttpResponse.json([
  255. {
  256. id: 7,
  257. name: 'Docs',
  258. url: 'https://docs.example.test',
  259. icon: 'Link',
  260. open_in_new_tab: true,
  261. custom_icon: null,
  262. sort_order: 0,
  263. created_at: '2026-01-01T00:00:00Z',
  264. updated_at: '2026-01-01T00:00:00Z',
  265. },
  266. ]),
  267. ),
  268. );
  269. render(<SettingsPage />);
  270. await screen.findByRole('heading', { name: 'Sidebar' });
  271. const docs = await screen.findByText('Docs');
  272. const printers = screen.getAllByText('Printers').find(element => element.closest('[draggable="true"]'));
  273. expect(printers).toBeDefined();
  274. expect(docs.compareDocumentPosition(printers) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
  275. });
  276. it('saves mixed Sidebar order when items are dragged', async () => {
  277. server.use(
  278. http.get('/api/v1/external-links/', () =>
  279. HttpResponse.json([
  280. {
  281. id: 7,
  282. name: 'Docs',
  283. url: 'https://docs.example.test',
  284. icon: 'Link',
  285. open_in_new_tab: true,
  286. custom_icon: null,
  287. sort_order: 0,
  288. created_at: '2026-01-01T00:00:00Z',
  289. updated_at: '2026-01-01T00:00:00Z',
  290. },
  291. ]),
  292. ),
  293. );
  294. render(<SettingsPage />);
  295. await screen.findByRole('heading', { name: 'Sidebar' });
  296. const docsRow = (await screen.findByText('Docs')).closest('[draggable="true"]');
  297. const printersRow = screen.getAllByText('Printers')
  298. .find(element => element.closest('[draggable="true"]'))
  299. ?.closest('[draggable="true"]');
  300. expect(docsRow).not.toBeNull();
  301. expect(printersRow).not.toBeNull();
  302. vi.mocked(localStorage.setItem).mockClear();
  303. const dataTransfer = {
  304. effectAllowed: '',
  305. dropEffect: '',
  306. setData: vi.fn(),
  307. };
  308. fireEvent.dragStart(docsRow!, { dataTransfer });
  309. fireEvent.dragOver(printersRow!, { dataTransfer });
  310. fireEvent.drop(printersRow!, { dataTransfer });
  311. expect(localStorage.setItem).toHaveBeenCalledWith(
  312. SIDEBAR_ORDER_KEY,
  313. JSON.stringify(['ext-7', 'printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'finance', 'notifications', 'settings']),
  314. );
  315. });
  316. it('resets Sidebar to all pages first and configured links at the bottom', async () => {
  317. vi.mocked(localStorage.getItem).mockImplementation((key) => {
  318. if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['printers', 'stats']);
  319. if (key === SIDEBAR_ORDER_KEY) return JSON.stringify(['ext-7', 'settings', 'printers']);
  320. return null;
  321. });
  322. server.use(
  323. http.get('/api/v1/external-links/', () =>
  324. HttpResponse.json([
  325. {
  326. id: 7,
  327. name: 'Docs',
  328. url: 'https://docs.example.test',
  329. icon: 'Link',
  330. open_in_new_tab: true,
  331. custom_icon: null,
  332. sort_order: 0,
  333. created_at: '2026-01-01T00:00:00Z',
  334. updated_at: '2026-01-01T00:00:00Z',
  335. },
  336. ]),
  337. ),
  338. );
  339. const user = userEvent.setup();
  340. render(<SettingsPage />);
  341. const heading = await screen.findByRole('heading', { name: 'Sidebar' });
  342. const card = heading.closest('#card-sidebar-links');
  343. expect(card).not.toBeNull();
  344. await screen.findByText('Docs');
  345. vi.mocked(localStorage.setItem).mockClear();
  346. await user.click(within(card as HTMLElement).getByRole('button', { name: /reset/i }));
  347. expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify([]));
  348. expect(localStorage.setItem).toHaveBeenCalledWith(
  349. SIDEBAR_ORDER_KEY,
  350. JSON.stringify(['printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'finance', 'notifications', 'settings', 'ext-7']),
  351. );
  352. const settingsRow = screen.getAllByText('Settings')
  353. .find(element => element.closest('[draggable="true"]'))
  354. ?.closest('[draggable="true"]');
  355. const docsRow = screen.getByText('Docs').closest('[draggable="true"]');
  356. expect(settingsRow).not.toBeNull();
  357. expect(docsRow).not.toBeNull();
  358. expect(settingsRow!.compareDocumentPosition(docsRow!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
  359. expect(screen.queryByText('Hidden from sidebar')).not.toBeInTheDocument();
  360. });
  361. it('sets the current Sidebar order as the backend default for settings admins', async () => {
  362. let defaultSidebarOrderPayload: string | null = null;
  363. vi.mocked(localStorage.getItem).mockImplementation((key) => {
  364. if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['stats']);
  365. return null;
  366. });
  367. server.use(
  368. http.get('/api/v1/auth/status', () =>
  369. HttpResponse.json({ auth_enabled: true, requires_setup: false }),
  370. ),
  371. http.get('/api/v1/auth/me', () =>
  372. HttpResponse.json({
  373. id: 1,
  374. username: 'admin',
  375. role: 'admin',
  376. is_active: true,
  377. is_admin: false,
  378. groups: [{ id: 1, name: 'Administrators' }],
  379. permissions: ['settings:update'],
  380. created_at: '2026-01-01T00:00:00Z',
  381. }),
  382. ),
  383. http.get('/api/v1/settings/', () =>
  384. HttpResponse.json({ ...mockSettings, default_sidebar_order: '' }),
  385. ),
  386. http.put('/api/v1/settings/', async ({ request }) => {
  387. const body = await request.json() as { default_sidebar_order?: string };
  388. defaultSidebarOrderPayload = body.default_sidebar_order ?? null;
  389. return HttpResponse.json({ ...mockSettings, ...body });
  390. }),
  391. );
  392. setAuthToken('test-token');
  393. const user = userEvent.setup();
  394. render(<SettingsPage />);
  395. const heading = await screen.findByRole('heading', { name: 'Sidebar' });
  396. const card = heading.closest('#card-sidebar-links');
  397. expect(card).not.toBeNull();
  398. await user.click(within(card as HTMLElement).getByRole('switch', { name: 'Set Default' }));
  399. await waitFor(() => {
  400. expect(defaultSidebarOrderPayload).not.toBeNull();
  401. });
  402. expect(JSON.parse(defaultSidebarOrderPayload!)).toEqual({
  403. order: [
  404. 'printers',
  405. 'inventory',
  406. 'archives',
  407. 'queue',
  408. 'projects',
  409. 'files',
  410. 'makerworld',
  411. 'profiles',
  412. 'maintenance',
  413. 'stats',
  414. 'finance',
  415. 'notifications',
  416. 'settings',
  417. ],
  418. hiddenSystemItemIds: ['stats'],
  419. });
  420. });
  421. });
  422. describe('update CTA per deployment shape', () => {
  423. // The update card branches on the deployment shape returned by
  424. // /updates/check. Each branch is mutually exclusive — verify the right
  425. // one wins so HA addon users never see the docker-compose snippet
  426. // (which they can't run from inside an HA addon container) and Docker
  427. // users never see the in-app Install button (which would no-op).
  428. const renderWithUpdateCheck = async (
  429. checkBody: Record<string, unknown>,
  430. settingsOverrides: Record<string, unknown> = {},
  431. ) => {
  432. server.use(
  433. http.get('/api/v1/settings/', () =>
  434. HttpResponse.json({ ...mockSettings, check_updates: true, ...settingsOverrides }),
  435. ),
  436. http.get('/api/v1/updates/check', () => HttpResponse.json(checkBody)),
  437. );
  438. render(<SettingsPage />);
  439. await waitFor(() => {
  440. expect(screen.getByText('Updates')).toBeInTheDocument();
  441. });
  442. };
  443. it('shows the HA Supervisor message when running as an HA addon', async () => {
  444. await renderWithUpdateCheck({
  445. update_available: true,
  446. current_version: '0.2.4',
  447. latest_version: '0.2.5',
  448. release_name: '0.2.5',
  449. release_notes: '',
  450. release_url: 'https://example.invalid/r',
  451. published_at: '2099-01-01T00:00:00Z',
  452. is_docker: true,
  453. is_ha_addon: true,
  454. update_method: 'ha_addon',
  455. });
  456. await waitFor(() => {
  457. expect(
  458. screen.getByText(/Home Assistant Supervisor/i),
  459. ).toBeInTheDocument();
  460. });
  461. // Docker hint must NOT render — HA branch wins.
  462. expect(screen.queryByText('docker compose pull && docker compose up -d')).not.toBeInTheDocument();
  463. expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument();
  464. });
  465. it('shows the docker-compose snippet for Docker (non-HA) deployments', async () => {
  466. await renderWithUpdateCheck({
  467. update_available: true,
  468. current_version: '0.2.4',
  469. latest_version: '0.2.5',
  470. release_name: '0.2.5',
  471. release_notes: '',
  472. release_url: 'https://example.invalid/r',
  473. published_at: '2099-01-01T00:00:00Z',
  474. is_docker: true,
  475. is_ha_addon: false,
  476. update_method: 'docker',
  477. });
  478. await waitFor(() => {
  479. expect(screen.getByText('docker compose pull && docker compose up -d')).toBeInTheDocument();
  480. });
  481. expect(screen.queryByText(/Home Assistant Supervisor/i)).not.toBeInTheDocument();
  482. expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument();
  483. });
  484. // #2664: the bare command only works if the user is already standing in
  485. // the directory holding their compose file, which is exactly the thing
  486. // they came to the page not knowing.
  487. const DOCKER_CHECK = {
  488. update_available: true,
  489. current_version: '0.2.4',
  490. latest_version: '0.2.5',
  491. release_name: '0.2.5',
  492. release_notes: '',
  493. release_url: 'https://example.invalid/r',
  494. published_at: '2099-01-01T00:00:00Z',
  495. is_docker: true,
  496. is_ha_addon: false,
  497. update_method: 'docker',
  498. };
  499. it('prefixes the command with cd when the backend detected a compose directory', async () => {
  500. await renderWithUpdateCheck({ ...DOCKER_CHECK, compose_dir_detected: '/opt/bambuddy' });
  501. await waitFor(() => {
  502. expect(
  503. screen.getByText('cd /opt/bambuddy && docker compose pull && docker compose up -d'),
  504. ).toBeInTheDocument();
  505. });
  506. });
  507. it('prefers the saved directory over the detected one', async () => {
  508. await renderWithUpdateCheck(
  509. { ...DOCKER_CHECK, compose_dir_detected: '/opt/guessed' },
  510. { docker_compose_dir: '/srv/stacks/bambuddy' },
  511. );
  512. await waitFor(() => {
  513. expect(
  514. screen.getByText('cd /srv/stacks/bambuddy && docker compose pull && docker compose up -d'),
  515. ).toBeInTheDocument();
  516. });
  517. expect(screen.queryByText(/\/opt\/guessed/)).not.toBeInTheDocument();
  518. });
  519. it('quotes a directory containing a space so the cd does not split', async () => {
  520. await renderWithUpdateCheck(DOCKER_CHECK, { docker_compose_dir: '/srv/bambu buddy' });
  521. await waitFor(() => {
  522. expect(
  523. screen.getByText('cd "/srv/bambu buddy" && docker compose pull && docker compose up -d'),
  524. ).toBeInTheDocument();
  525. });
  526. });
  527. it('copies the full command including the cd', async () => {
  528. const writeText = vi.fn().mockResolvedValue(undefined);
  529. Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
  530. Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true });
  531. await renderWithUpdateCheck({ ...DOCKER_CHECK, compose_dir_detected: '/opt/bambuddy' });
  532. const copy = await screen.findByRole('button', { name: /copy update command/i });
  533. await userEvent.click(copy);
  534. expect(writeText).toHaveBeenCalledWith(
  535. 'cd /opt/bambuddy && docker compose pull && docker compose up -d',
  536. );
  537. });
  538. it('offers an editable compose directory field seeded with the detected path', async () => {
  539. await renderWithUpdateCheck({ ...DOCKER_CHECK, compose_dir_detected: '/opt/bambuddy' });
  540. const field = await screen.findByPlaceholderText('/opt/bambuddy');
  541. // Placeholder, not value — the detected path is a guess the user has
  542. // not accepted, so saving must not silently adopt it.
  543. expect(field).toHaveValue('');
  544. });
  545. it('shows the installer-download link for Windows installer installs', async () => {
  546. const downloadUrl =
  547. 'https://github.com/maziggy/bambuddy/releases/download/v0.2.5/bambuddy-0.2.5-windows-x64-setup.exe';
  548. await renderWithUpdateCheck({
  549. update_available: true,
  550. current_version: '0.2.4',
  551. latest_version: '0.2.5',
  552. release_name: '0.2.5',
  553. release_notes: '',
  554. release_url: 'https://github.com/maziggy/bambuddy/releases/tag/v0.2.5',
  555. published_at: '2099-01-01T00:00:00Z',
  556. is_docker: false,
  557. is_ha_addon: false,
  558. is_windows_installer: true,
  559. update_method: 'windows_installer',
  560. installer_download_url: downloadUrl,
  561. });
  562. const link = await screen.findByRole('link', { name: /download installer for v0\.2\.5/i });
  563. expect(link).toHaveAttribute('href', downloadUrl);
  564. expect(link).toHaveAttribute('target', '_blank');
  565. expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
  566. // The in-app update button must NOT render — the git-fetch path can't
  567. // work from an installer payload.
  568. expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument();
  569. expect(screen.queryByText(/Home Assistant Supervisor/i)).not.toBeInTheDocument();
  570. expect(screen.queryByText('docker compose pull && docker compose up -d')).not.toBeInTheDocument();
  571. });
  572. });
  573. describe('tabs navigation', () => {
  574. it('can switch to Network tab', async () => {
  575. const user = userEvent.setup();
  576. render(<SettingsPage />);
  577. // Wait for settings to load first
  578. await waitFor(() => {
  579. expect(screen.getByText('Date Format')).toBeInTheDocument();
  580. });
  581. await user.click(screen.getByText('Network'));
  582. await waitFor(() => {
  583. // Network tab contains MQTT Publishing section
  584. expect(screen.getByText('MQTT Publishing')).toBeInTheDocument();
  585. });
  586. });
  587. it('can switch to Smart Plugs tab', async () => {
  588. const user = userEvent.setup();
  589. render(<SettingsPage />);
  590. await waitFor(() => {
  591. expect(screen.getByText('Smart Plugs')).toBeInTheDocument();
  592. });
  593. await user.click(screen.getByText('Smart Plugs'));
  594. await waitFor(() => {
  595. expect(screen.getByText('Add Smart Plug')).toBeInTheDocument();
  596. });
  597. });
  598. it('can switch to Notifications tab', async () => {
  599. const user = userEvent.setup();
  600. render(<SettingsPage />);
  601. await waitFor(() => {
  602. expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0);
  603. });
  604. // Click the tab button (not the mobile dropdown option)
  605. const notificationButtons = screen.getAllByText('Notifications');
  606. const tabButton = notificationButtons.find(el => el.tagName === 'BUTTON') || notificationButtons[0];
  607. await user.click(tabButton);
  608. await waitFor(() => {
  609. expect(screen.getByText('Add Provider')).toBeInTheDocument();
  610. });
  611. });
  612. it('can switch to Filament tab', async () => {
  613. const user = userEvent.setup();
  614. render(<SettingsPage />);
  615. await waitFor(() => {
  616. expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
  617. });
  618. await user.click(screen.getAllByText('Filament')[0]);
  619. await waitFor(() => {
  620. expect(screen.getByText('AMS Display Thresholds')).toBeInTheDocument();
  621. });
  622. });
  623. // #2770: an AMS reads 15-20% while its own heater runs, so a drying
  624. // threshold set below that can never be met and auto-drying ends one
  625. // cycle only to arm the next. The default of 60 must stay quiet.
  626. const openFilamentTab = async (humidityFair: number) => {
  627. server.use(
  628. http.get('/api/v1/settings/', () =>
  629. HttpResponse.json({ ...mockSettings, ams_humidity_fair: humidityFair })
  630. )
  631. );
  632. const user = userEvent.setup();
  633. render(<SettingsPage />);
  634. await waitFor(() => {
  635. expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
  636. });
  637. await user.click(screen.getAllByText('Filament')[0]);
  638. await waitFor(() => {
  639. expect(screen.getByText('AMS Display Thresholds')).toBeInTheDocument();
  640. });
  641. };
  642. it('warns when the humidity threshold is below what a drying AMS reports', async () => {
  643. await openFilamentTab(14);
  644. expect(await screen.findByText(/Auto-drying cannot reach this value/)).toBeInTheDocument();
  645. });
  646. it('stays quiet for a humidity threshold auto-drying can actually reach', async () => {
  647. await openFilamentTab(60);
  648. expect(screen.queryByText(/Auto-drying cannot reach this value/)).not.toBeInTheDocument();
  649. });
  650. });
  651. describe('Workflow tab', () => {
  652. it('can switch to Workflow tab', async () => {
  653. const user = userEvent.setup();
  654. render(<SettingsPage />);
  655. await waitFor(() => {
  656. expect(screen.getByText('Workflow')).toBeInTheDocument();
  657. });
  658. await user.click(screen.getByText('Workflow'));
  659. await waitFor(() => {
  660. expect(screen.getByText('Staggered Start')).toBeInTheDocument();
  661. });
  662. });
  663. it('shows stagger settings on Workflow tab', async () => {
  664. const user = userEvent.setup();
  665. render(<SettingsPage />);
  666. await waitFor(() => {
  667. expect(screen.getByText('Workflow')).toBeInTheDocument();
  668. });
  669. await user.click(screen.getByText('Workflow'));
  670. await waitFor(() => {
  671. expect(screen.getByText('Staggered Start')).toBeInTheDocument();
  672. expect(screen.getByText('Group size')).toBeInTheDocument();
  673. expect(screen.getByText('Interval (minutes)')).toBeInTheDocument();
  674. });
  675. });
  676. it('shows auto-drying settings on Workflow tab', async () => {
  677. const user = userEvent.setup();
  678. render(<SettingsPage />);
  679. await waitFor(() => {
  680. expect(screen.getByText('Workflow')).toBeInTheDocument();
  681. });
  682. await user.click(screen.getByText('Workflow'));
  683. await waitFor(() => {
  684. expect(screen.getByText('Queue Auto-Drying')).toBeInTheDocument();
  685. });
  686. });
  687. it('shows per-filament humidity threshold editor on Workflow tab (#1605)', async () => {
  688. const user = userEvent.setup();
  689. render(<SettingsPage />);
  690. await waitFor(() => {
  691. expect(screen.getByText('Workflow')).toBeInTheDocument();
  692. });
  693. await user.click(screen.getByText('Workflow'));
  694. await waitFor(() => {
  695. expect(screen.getByText('Humidity Thresholds')).toBeInTheDocument();
  696. // Default row is unique to the humidity editor (drying presets has no
  697. // default row), so we can pin it without disambiguating from the
  698. // adjacent drying-presets table that also lists PLA/ASA/etc.
  699. expect(screen.getByText('Default (unknown types)')).toBeInTheDocument();
  700. // Filament rows render in both tables — assert by count instead of
  701. // a single getByText. 8 default filaments × 2 tables = 16 PLAs etc.
  702. expect(screen.getAllByText('PLA').length).toBeGreaterThanOrEqual(2);
  703. expect(screen.getAllByText('ASA').length).toBeGreaterThanOrEqual(2);
  704. });
  705. });
  706. it('shows default print options on Workflow tab', async () => {
  707. const user = userEvent.setup();
  708. render(<SettingsPage />);
  709. await waitFor(() => {
  710. expect(screen.getByText('Workflow')).toBeInTheDocument();
  711. });
  712. await user.click(screen.getByText('Workflow'));
  713. await waitFor(() => {
  714. expect(screen.getByText('Default Print Options')).toBeInTheDocument();
  715. expect(screen.getByText('Bed Levelling')).toBeInTheDocument();
  716. expect(screen.getByText('Flow Calibration')).toBeInTheDocument();
  717. expect(screen.getByText('Vibration Calibration')).toBeInTheDocument();
  718. expect(screen.getByText('First Layer Inspection')).toBeInTheDocument();
  719. expect(screen.getByText('Timelapse')).toBeInTheDocument();
  720. });
  721. });
  722. it('shows default print options description', async () => {
  723. const user = userEvent.setup();
  724. render(<SettingsPage />);
  725. await waitFor(() => {
  726. expect(screen.getByText('Workflow')).toBeInTheDocument();
  727. });
  728. await user.click(screen.getByText('Workflow'));
  729. await waitFor(() => {
  730. expect(screen.getByText(/overridden per print in the print dialog/)).toBeInTheDocument();
  731. });
  732. });
  733. });
  734. describe('API Keys tab', () => {
  735. it('can switch to API Keys tab', async () => {
  736. const user = userEvent.setup();
  737. render(<SettingsPage />);
  738. await waitFor(() => {
  739. expect(screen.getByText('API Keys')).toBeInTheDocument();
  740. });
  741. await user.click(screen.getByText('API Keys'));
  742. await waitFor(() => {
  743. // Button text is "Create Key"
  744. expect(screen.getByText('Create Key')).toBeInTheDocument();
  745. });
  746. });
  747. });
  748. describe('SpoolBuddy tab badge', () => {
  749. const baseDevice = {
  750. id: 1,
  751. device_id: 'sb-0001',
  752. hostname: 'sb-kitchen',
  753. ip_address: '10.0.0.1',
  754. backend_url: null,
  755. firmware_version: '1.0.0',
  756. has_nfc: true,
  757. has_scale: true,
  758. tare_offset: 0,
  759. calibration_factor: 1.0,
  760. nfc_reader_type: null,
  761. nfc_connection: null,
  762. display_brightness: 100,
  763. display_blank_timeout: 0,
  764. has_backlight: false,
  765. last_calibrated_at: null,
  766. last_seen: new Date().toISOString(),
  767. pending_command: null,
  768. nfc_ok: true,
  769. scale_ok: true,
  770. uptime_s: 100,
  771. update_status: null,
  772. update_message: null,
  773. system_stats: null,
  774. online: true,
  775. created_at: '2024-01-01T00:00:00Z',
  776. updated_at: '2024-01-01T00:00:00Z',
  777. };
  778. it('shows device count and green bullet when at least one device is online', async () => {
  779. server.use(
  780. http.get('/api/v1/spoolbuddy/devices', () => {
  781. return HttpResponse.json([
  782. { ...baseDevice, id: 1, device_id: 'sb-0001', hostname: 'sb-kitchen', online: true },
  783. { ...baseDevice, id: 2, device_id: 'sb-0002', hostname: 'sb-ghost', online: false },
  784. ]);
  785. })
  786. );
  787. render(<SettingsPage />);
  788. // Find the tab button (not the header) — it's the <button> containing the SpoolBuddy text
  789. const tabButton = await waitFor(() => {
  790. const buttons = screen.getAllByRole('button').filter((b) => b.textContent?.includes('SpoolBuddy'));
  791. expect(buttons.length).toBeGreaterThan(0);
  792. return buttons[0];
  793. });
  794. // Count pill rendered
  795. await waitFor(() => {
  796. expect(tabButton.textContent).toContain('2');
  797. });
  798. // Green status bullet (at least one device online)
  799. await waitFor(() => {
  800. expect(tabButton.querySelector('.bg-green-400')).not.toBeNull();
  801. });
  802. });
  803. it('shows gray bullet when all devices are offline', async () => {
  804. server.use(
  805. http.get('/api/v1/spoolbuddy/devices', () => {
  806. return HttpResponse.json([{ ...baseDevice, online: false }]);
  807. })
  808. );
  809. render(<SettingsPage />);
  810. const tabButton = await waitFor(() => {
  811. const buttons = screen.getAllByRole('button').filter((b) => b.textContent?.includes('SpoolBuddy'));
  812. expect(buttons.length).toBeGreaterThan(0);
  813. return buttons[0];
  814. });
  815. await waitFor(() => {
  816. expect(tabButton.querySelector('.bg-gray-500')).not.toBeNull();
  817. expect(tabButton.querySelector('.bg-green-400')).toBeNull();
  818. });
  819. });
  820. it('hides the count pill when no devices are registered', async () => {
  821. server.use(
  822. http.get('/api/v1/spoolbuddy/devices', () => HttpResponse.json([]))
  823. );
  824. render(<SettingsPage />);
  825. const tabButton = await waitFor(() => {
  826. const buttons = screen.getAllByRole('button').filter((b) => b.textContent?.includes('SpoolBuddy'));
  827. expect(buttons.length).toBeGreaterThan(0);
  828. return buttons[0];
  829. });
  830. // The only numeric content should NOT be present — tab label only
  831. await waitFor(() => {
  832. expect(tabButton.textContent).toBe('SpoolBuddy');
  833. });
  834. });
  835. });
  836. describe('API Keys tab — delete flow', () => {
  837. // Without setQueryData on success the deleted row stayed visible until a
  838. // manual reload — invalidateQueries didn't reliably trigger a UI swap on
  839. // every browser. Pin the synchronous-removal contract here.
  840. it('removes a deleted key from the list without a page reload', async () => {
  841. const initialKeys = [
  842. {
  843. id: 42,
  844. name: 'CI deploy key',
  845. key_prefix: 'bk_abcd1234',
  846. can_queue: true,
  847. can_control_printer: false,
  848. can_read_status: true,
  849. printer_ids: null,
  850. enabled: true,
  851. last_used: null,
  852. created_at: '2026-01-01T00:00:00Z',
  853. expires_at: null,
  854. },
  855. ];
  856. let deleteCallCount = 0;
  857. server.use(
  858. http.get('/api/v1/api-keys/', () => HttpResponse.json(initialKeys)),
  859. http.delete('/api/v1/api-keys/:id', ({ params }) => {
  860. deleteCallCount += 1;
  861. expect(params.id).toBe('42');
  862. return HttpResponse.json({ message: 'API key deleted' });
  863. })
  864. );
  865. const user = userEvent.setup();
  866. render(<SettingsPage />);
  867. // Switch to API Keys tab. Both desktop tab + mobile dropdown render
  868. // the label, so just grab the button form.
  869. await waitFor(() => {
  870. expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
  871. });
  872. const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
  873. expect(tabButton).toBeDefined();
  874. await user.click(tabButton!);
  875. // Key is listed
  876. await waitFor(() => {
  877. expect(screen.getByText('CI deploy key')).toBeInTheDocument();
  878. });
  879. // Click the trash button on the row
  880. const cards = screen.getByText('CI deploy key').closest('.flex.items-center.justify-between');
  881. expect(cards).not.toBeNull();
  882. const trashButton = cards!.querySelectorAll('button');
  883. await user.click(trashButton[trashButton.length - 1]);
  884. // Confirm the deletion in the modal
  885. const confirmButton = await screen.findByRole('button', { name: /delete/i });
  886. await user.click(confirmButton);
  887. // The deleted key disappears from the list immediately — no manual
  888. // reload required. setQueryData drops it before any refetch could fire.
  889. await waitFor(() => {
  890. expect(screen.queryByText('CI deploy key')).not.toBeInTheDocument();
  891. });
  892. expect(deleteCallCount).toBe(1);
  893. });
  894. });
  895. describe('API Keys tab — #1182 cloud access + ownership UI', () => {
  896. // The list now exposes two new bits of information per row:
  897. // - "Cloud" badge when can_access_cloud=true
  898. // - "Legacy" badge when user_id IS NULL (created before per-user ownership)
  899. // These tell the operator at a glance which keys can read /cloud/* data
  900. // and which keys need to be recreated to gain that capability.
  901. it('renders the Cloud badge for keys with can_access_cloud=true and the Legacy badge for ownerless keys', async () => {
  902. const keys = [
  903. {
  904. id: 1,
  905. name: 'cloud-reader',
  906. key_prefix: 'bk_cloud123',
  907. user_id: 7,
  908. can_queue: false,
  909. can_control_printer: false,
  910. can_read_status: true,
  911. can_access_cloud: true,
  912. printer_ids: null,
  913. enabled: true,
  914. last_used: null,
  915. created_at: '2026-04-30T00:00:00Z',
  916. expires_at: null,
  917. },
  918. {
  919. id: 2,
  920. name: 'legacy-key',
  921. key_prefix: 'bk_legacy01',
  922. user_id: null,
  923. can_queue: true,
  924. can_control_printer: false,
  925. can_read_status: true,
  926. can_access_cloud: false,
  927. printer_ids: null,
  928. enabled: true,
  929. last_used: null,
  930. created_at: '2025-01-01T00:00:00Z',
  931. expires_at: null,
  932. },
  933. ];
  934. server.use(http.get('/api/v1/api-keys/', () => HttpResponse.json(keys)));
  935. const user = userEvent.setup();
  936. render(<SettingsPage />);
  937. await waitFor(() => {
  938. expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
  939. });
  940. const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
  941. await user.click(tabButton!);
  942. await waitFor(() => {
  943. expect(screen.getByText('cloud-reader')).toBeInTheDocument();
  944. expect(screen.getByText('legacy-key')).toBeInTheDocument();
  945. });
  946. // Cloud-enabled key gets the Cloud badge but NOT the Legacy badge.
  947. const cloudRow = screen.getByText('cloud-reader').closest('.flex.items-center.justify-between');
  948. expect(cloudRow).not.toBeNull();
  949. expect(cloudRow!.textContent).toContain('Cloud');
  950. expect(cloudRow!.textContent).not.toContain('Legacy');
  951. // Ownerless key gets Legacy but NOT Cloud (can_access_cloud=false).
  952. const legacyRow = screen.getByText('legacy-key').closest('.flex.items-center.justify-between');
  953. expect(legacyRow).not.toBeNull();
  954. expect(legacyRow!.textContent).toContain('Legacy');
  955. // Strip the Cloud-flag check by limiting to badge area — the
  956. // "Allow cloud access" text from the create form isn't visible here.
  957. expect(legacyRow!.querySelector('.bg-purple-500\\/20')).toBeNull();
  958. });
  959. it('passes can_access_cloud through to the create call when the toggle is checked', async () => {
  960. let posted: { name?: string; can_access_cloud?: boolean } | null = null;
  961. server.use(
  962. http.get('/api/v1/api-keys/', () => HttpResponse.json([])),
  963. http.post('/api/v1/api-keys/', async ({ request }) => {
  964. posted = (await request.json()) as { name?: string; can_access_cloud?: boolean };
  965. return HttpResponse.json({
  966. id: 99,
  967. key: 'bk_returnedkey',
  968. name: posted.name,
  969. key_prefix: 'bk_returne',
  970. user_id: 1,
  971. can_queue: true,
  972. can_control_printer: false,
  973. can_read_status: true,
  974. can_access_cloud: posted.can_access_cloud ?? false,
  975. printer_ids: null,
  976. enabled: true,
  977. last_used: null,
  978. created_at: '2026-05-01T00:00:00Z',
  979. expires_at: null,
  980. });
  981. })
  982. );
  983. const user = userEvent.setup();
  984. render(<SettingsPage />);
  985. await waitFor(() => {
  986. expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
  987. });
  988. const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
  989. await user.click(tabButton!);
  990. // Open the create form. With an empty key list the empty-state card
  991. // shows "Create Your First Key" — click that to open the form.
  992. const openButton = await screen.findByRole('button', { name: /Create Your First Key/i });
  993. await user.click(openButton);
  994. // Tick the new "Allow cloud access" checkbox. The label wraps the
  995. // input AND a sibling description div, so getByLabelText doesn't
  996. // resolve via implicit-label traversal — locate via text + closest
  997. // label, then grab the checkbox from the same scope.
  998. const cloudLabelText = await screen.findByText(/Allow cloud access/i);
  999. const cloudLabel = cloudLabelText.closest('label');
  1000. expect(cloudLabel).not.toBeNull();
  1001. const cloudCheckbox = cloudLabel!.querySelector('input[type="checkbox"]') as HTMLInputElement;
  1002. expect(cloudCheckbox).not.toBeNull();
  1003. await user.click(cloudCheckbox);
  1004. // Submit. Two "Create Key" buttons exist once the form is open (header
  1005. // CTA + form footer); the form-footer one is the actual submit and
  1006. // calls the mutation — find it by walking up from the cloud checkbox
  1007. // we just clicked, since both share the same form container.
  1008. const submitButtons = screen.getAllByRole('button', { name: /^Create Key$/i });
  1009. // Footer submit is the one inside the same form section as the
  1010. // checkbox. The header CTA is in a separate flex row.
  1011. const formSubmit = submitButtons.find(
  1012. (b) => b.closest('div')?.contains(cloudCheckbox) || cloudLabel?.parentElement?.parentElement?.contains(b),
  1013. );
  1014. await user.click(formSubmit ?? submitButtons[submitButtons.length - 1]);
  1015. await waitFor(() => {
  1016. expect(posted).not.toBeNull();
  1017. expect(posted!.can_access_cloud).toBe(true);
  1018. });
  1019. });
  1020. });
  1021. describe('API Keys tab — #1356 energy-cost write scope', () => {
  1022. /**
  1023. * The narrowly-scoped settings-write toggle. We pin two contracts here:
  1024. *
  1025. * 1. The "Energy" badge renders for keys that have can_update_energy_cost=true.
  1026. * Without a visible signal, an operator can't tell which key in their
  1027. * list is the one their HA automation depends on.
  1028. * 2. The create form sends can_update_energy_cost=true to the backend
  1029. * when the toggle is checked. The whole point of #1356 is that the
  1030. * flag must actually be persisted — a UI that drops it silently
  1031. * would put us right back where the bug started.
  1032. */
  1033. it('renders the Energy badge for keys with can_update_energy_cost=true', async () => {
  1034. const keys = [
  1035. {
  1036. id: 1,
  1037. name: 'tariff-pusher',
  1038. key_prefix: 'bk_tariff01',
  1039. user_id: 7,
  1040. can_queue: false,
  1041. can_control_printer: false,
  1042. can_read_status: true,
  1043. can_access_cloud: false,
  1044. can_update_energy_cost: true,
  1045. printer_ids: null,
  1046. enabled: true,
  1047. last_used: null,
  1048. created_at: '2026-05-15T00:00:00Z',
  1049. expires_at: null,
  1050. },
  1051. ];
  1052. server.use(http.get('/api/v1/api-keys/', () => HttpResponse.json(keys)));
  1053. const user = userEvent.setup();
  1054. render(<SettingsPage />);
  1055. await waitFor(() => {
  1056. expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
  1057. });
  1058. const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
  1059. await user.click(tabButton!);
  1060. await waitFor(() => {
  1061. expect(screen.getByText('tariff-pusher')).toBeInTheDocument();
  1062. });
  1063. const row = screen.getByText('tariff-pusher').closest('.flex.items-center.justify-between');
  1064. expect(row).not.toBeNull();
  1065. expect(row!.textContent).toContain('Energy');
  1066. });
  1067. it('passes can_update_energy_cost through to the create call when the toggle is checked', async () => {
  1068. let posted: { name?: string; can_update_energy_cost?: boolean } | null = null;
  1069. server.use(
  1070. http.get('/api/v1/api-keys/', () => HttpResponse.json([])),
  1071. http.post('/api/v1/api-keys/', async ({ request }) => {
  1072. posted = (await request.json()) as { name?: string; can_update_energy_cost?: boolean };
  1073. return HttpResponse.json({
  1074. id: 99,
  1075. key: 'bk_returnedkey',
  1076. name: posted.name,
  1077. key_prefix: 'bk_returne',
  1078. user_id: 1,
  1079. can_queue: true,
  1080. can_control_printer: false,
  1081. can_read_status: true,
  1082. can_access_cloud: false,
  1083. can_update_energy_cost: posted.can_update_energy_cost ?? false,
  1084. printer_ids: null,
  1085. enabled: true,
  1086. last_used: null,
  1087. created_at: '2026-05-15T00:00:00Z',
  1088. expires_at: null,
  1089. });
  1090. })
  1091. );
  1092. const user = userEvent.setup();
  1093. render(<SettingsPage />);
  1094. await waitFor(() => {
  1095. expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
  1096. });
  1097. const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
  1098. await user.click(tabButton!);
  1099. const openButton = await screen.findByRole('button', { name: /Create Your First Key/i });
  1100. await user.click(openButton);
  1101. const energyLabelText = await screen.findByText(/Update electricity price/i);
  1102. const energyLabel = energyLabelText.closest('label');
  1103. expect(energyLabel).not.toBeNull();
  1104. const energyCheckbox = energyLabel!.querySelector('input[type="checkbox"]') as HTMLInputElement;
  1105. expect(energyCheckbox).not.toBeNull();
  1106. await user.click(energyCheckbox);
  1107. const submitButtons = screen.getAllByRole('button', { name: /^Create Key$/i });
  1108. const formSubmit = submitButtons.find(
  1109. (b) => b.closest('div')?.contains(energyCheckbox) || energyLabel?.parentElement?.parentElement?.contains(b),
  1110. );
  1111. await user.click(formSubmit ?? submitButtons[submitButtons.length - 1]);
  1112. await waitFor(() => {
  1113. expect(posted).not.toBeNull();
  1114. expect(posted!.can_update_energy_cost).toBe(true);
  1115. });
  1116. });
  1117. });
  1118. describe('external camera snapshot URL override (#1177)', () => {
  1119. /**
  1120. * The snapshot URL input only appears for stream camera types where the
  1121. * MJPEG warm-up problem can occur (mjpeg / rtsp / usb). Pure HTTP
  1122. * snapshot sources don't need an override since their stream URL is
  1123. * already a single-frame endpoint.
  1124. */
  1125. const mjpegPrinter = {
  1126. id: 7,
  1127. name: 'go2rtc Cam',
  1128. serial_number: 'TEST123',
  1129. ip_address: '192.168.1.100',
  1130. access_code: 'XXXX',
  1131. model: 'P1S',
  1132. location: null,
  1133. nozzle_count: 1,
  1134. is_active: true,
  1135. auto_archive: true,
  1136. external_camera_url: 'http://192.168.1.61:1984/api/stream.mjpeg?src=printer',
  1137. external_camera_type: 'mjpeg',
  1138. external_camera_enabled: true,
  1139. external_camera_snapshot_url: null,
  1140. camera_rotation: 0,
  1141. plate_detection_enabled: false,
  1142. created_at: '2026-01-01T00:00:00Z',
  1143. updated_at: '2026-01-01T00:00:00Z',
  1144. };
  1145. it('renders the snapshot URL input when camera_type is mjpeg', async () => {
  1146. server.use(
  1147. http.get('/api/v1/printers/', () => HttpResponse.json([mjpegPrinter])),
  1148. );
  1149. render(<SettingsPage />);
  1150. await waitFor(() => {
  1151. expect(screen.getByPlaceholderText(/api\/frame\.jpeg\?src=printer/)).toBeInTheDocument();
  1152. });
  1153. });
  1154. it('hides the snapshot URL input when camera_type is snapshot (already a single-frame source)', async () => {
  1155. server.use(
  1156. http.get('/api/v1/printers/', () =>
  1157. HttpResponse.json([{ ...mjpegPrinter, external_camera_type: 'snapshot' }]),
  1158. ),
  1159. );
  1160. render(<SettingsPage />);
  1161. // Wait for the live-stream URL placeholder to render so we know the
  1162. // camera section finished mounting before asserting absence of the
  1163. // snapshot input below.
  1164. await waitFor(() => {
  1165. expect(screen.getByPlaceholderText(/Camera URL/i)).toBeInTheDocument();
  1166. });
  1167. expect(screen.queryByPlaceholderText(/api\/frame\.jpeg\?src=printer/)).not.toBeInTheDocument();
  1168. });
  1169. it(
  1170. 'PATCHes the printer with external_camera_snapshot_url when the user types into the input',
  1171. async () => {
  1172. let receivedBody: Record<string, unknown> | null = null;
  1173. server.use(
  1174. http.get('/api/v1/printers/', () => HttpResponse.json([mjpegPrinter])),
  1175. http.patch('/api/v1/printers/7', async ({ request }) => {
  1176. receivedBody = (await request.json()) as Record<string, unknown>;
  1177. return HttpResponse.json({ ...mjpegPrinter, ...receivedBody });
  1178. }),
  1179. );
  1180. render(<SettingsPage />);
  1181. const input = await waitFor(() =>
  1182. screen.getByPlaceholderText(/api\/frame\.jpeg\?src=printer/),
  1183. );
  1184. const user = userEvent.setup();
  1185. await user.type(input, 'http://192.168.1.61:1984/api/frame.jpeg?src=printer');
  1186. // Save is debounced by 800ms; assert the PATCH eventually fires with
  1187. // the typed snapshot URL.
  1188. await waitFor(
  1189. () => {
  1190. expect(receivedBody).not.toBeNull();
  1191. expect(receivedBody!.external_camera_snapshot_url).toBe(
  1192. 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
  1193. );
  1194. },
  1195. { timeout: 5000 },
  1196. );
  1197. },
  1198. // Per-test timeout raised to 15s — `user.type()` of a 49-char URL plus
  1199. // the 800ms save debounce fits in 5s locally (~2.3s typical) but blows
  1200. // past it on slow GitHub Actions runners (5000ms timeout was the failure
  1201. // mode on PR #1263).
  1202. 15_000,
  1203. );
  1204. });
  1205. describe('theme mode buttons', () => {
  1206. it('renders Dark, Light, and System buttons', async () => {
  1207. render(<SettingsPage />);
  1208. await waitFor(() => {
  1209. expect(screen.getByRole('button', { name: 'Dark' })).toBeInTheDocument();
  1210. expect(screen.getByRole('button', { name: 'Light' })).toBeInTheDocument();
  1211. expect(screen.getByRole('button', { name: 'System' })).toBeInTheDocument();
  1212. });
  1213. });
  1214. it('highlights the active mode button with green border', async () => {
  1215. render(<SettingsPage />);
  1216. const user = userEvent.setup();
  1217. await waitFor(() => {
  1218. expect(screen.getByRole('button', { name: 'System' })).toBeInTheDocument();
  1219. });
  1220. await user.click(screen.getByRole('button', { name: 'System' }));
  1221. await waitFor(() => {
  1222. const systemBtn = screen.getByRole('button', { name: 'System' });
  1223. expect(systemBtn.className).toContain('border-bambu-green');
  1224. });
  1225. });
  1226. it('clicking a theme button switches mode', async () => {
  1227. localStorage.setItem('theme-mode', 'dark');
  1228. render(<SettingsPage />);
  1229. const user = userEvent.setup();
  1230. await waitFor(() => {
  1231. const darkBtn = screen.getByRole('button', { name: 'Dark' });
  1232. expect(darkBtn.className).toContain('border-bambu-green');
  1233. });
  1234. const lightBtn = screen.getByRole('button', { name: 'Light' });
  1235. await user.click(lightBtn);
  1236. await waitFor(() => {
  1237. expect(lightBtn.className).toContain('border-bambu-green');
  1238. });
  1239. });
  1240. it('shows a toast when theme button is clicked', async () => {
  1241. render(<SettingsPage />);
  1242. const user = userEvent.setup();
  1243. await waitFor(() => {
  1244. expect(screen.getByRole('button', { name: 'System' })).toBeInTheDocument();
  1245. });
  1246. await user.click(screen.getByRole('button', { name: 'System' }));
  1247. await waitFor(() => {
  1248. expect(screen.getByText('Settings saved')).toBeInTheDocument();
  1249. });
  1250. });
  1251. });
  1252. // --------------------------------------------------------------------
  1253. // Slicer Pipelines (#1425) — Workflow tab sub-tabs
  1254. // --------------------------------------------------------------------
  1255. describe('workflow sub-tabs (#1425)', () => {
  1256. beforeEach(() => {
  1257. // Endpoints the Pipelines panel calls (#1425).
  1258. server.use(
  1259. http.get('/api/v1/slicer-pipelines/', () => HttpResponse.json({ pipelines: [] })),
  1260. http.get('/api/v1/slicer/presets', () =>
  1261. HttpResponse.json({
  1262. orca_cloud: { printer: [], process: [], filament: [] },
  1263. cloud: { printer: [], process: [], filament: [] },
  1264. local: { printer: [], process: [], filament: [] },
  1265. standard: { printer: [], process: [], filament: [] },
  1266. cloud_status: 'ok',
  1267. orca_cloud_status: 'ok',
  1268. }),
  1269. ),
  1270. );
  1271. });
  1272. it('renders Queue & Dispatch + Pipelines sub-tabs under Workflow', async () => {
  1273. render(<SettingsPage />);
  1274. const user = userEvent.setup();
  1275. await waitFor(() => {
  1276. // Workflow tab in the sidebar — exact match to avoid colliding with
  1277. // "Print Queue" or "Queue Settings" labels elsewhere on the page.
  1278. expect(screen.getByRole('button', { name: 'Workflow' })).toBeInTheDocument();
  1279. });
  1280. await user.click(screen.getByRole('button', { name: 'Workflow' }));
  1281. await waitFor(() => {
  1282. expect(screen.getByRole('button', { name: /Queue & Dispatch/i })).toBeInTheDocument();
  1283. expect(screen.getByRole('button', { name: /^Pipelines$/i })).toBeInTheDocument();
  1284. });
  1285. });
  1286. it('clicking Pipelines sub-tab shows the empty-state hint and updates the URL', async () => {
  1287. render(<SettingsPage />);
  1288. const user = userEvent.setup();
  1289. await waitFor(() => expect(screen.getByRole('button', { name: 'Workflow' })).toBeInTheDocument());
  1290. await user.click(screen.getByRole('button', { name: 'Workflow' }));
  1291. await user.click(screen.getByRole('button', { name: /^Pipelines$/i }));
  1292. await waitFor(() => {
  1293. expect(screen.getByText(/No pipelines yet/i)).toBeInTheDocument();
  1294. // Deep-link URL carries both ?tab=queue and ?sub=pipelines
  1295. expect(window.location.search).toContain('tab=queue');
  1296. expect(window.location.search).toContain('sub=pipelines');
  1297. });
  1298. });
  1299. });
  1300. });
  1301. /**
  1302. * Sponsor banner on Settings -> General.
  1303. *
  1304. * Below the fleet threshold it makes the community/donation ask; at or above it
  1305. * the same slot makes the commercial ask and points at business.html. A print
  1306. * farm asked to chip in $5 is a wasted impression, and a hobbyist pitched a
  1307. * support contract is an annoyed user — so both directions are pinned.
  1308. */
  1309. describe('SettingsPage — sponsor banner audience', () => {
  1310. beforeEach(() => {
  1311. // BrowserRouter shares window.location across tests and the banner only
  1312. // renders on the General tab — without this reset a prior test's ?tab=queue
  1313. // leaks in and the banner never mounts.
  1314. window.history.replaceState({}, '', '/');
  1315. });
  1316. const fleet = (count: number) =>
  1317. server.use(
  1318. http.get('/api/v1/printers/', () =>
  1319. HttpResponse.json(
  1320. Array.from({ length: count }, (_, i) => ({
  1321. id: i + 1,
  1322. name: `Printer ${i + 1}`,
  1323. serial_number: `SN${i + 1}`,
  1324. ip_address: '192.168.1.10',
  1325. model: 'X1C',
  1326. is_active: true,
  1327. })),
  1328. ),
  1329. ),
  1330. );
  1331. it('shows the community ask for a small fleet', async () => {
  1332. fleet(2);
  1333. render(<SettingsPage />);
  1334. const banner = await screen.findByRole('link', { name: /Independent & community-funded/i });
  1335. expect(banner).toHaveAttribute('href', 'https://bambuddy.cool/sponsors.html?from=app-settings');
  1336. expect(screen.queryByText(/Bambuddy for business/i)).not.toBeInTheDocument();
  1337. });
  1338. it('shows the commercial ask for a business-sized fleet', async () => {
  1339. fleet(6);
  1340. render(<SettingsPage />);
  1341. const banner = await screen.findByRole('link', { name: /Bambuddy for business/i });
  1342. expect(banner).toHaveAttribute('href', 'https://bambuddy.cool/business.html?from=app-settings');
  1343. // The donation copy is replaced, not merely supplemented.
  1344. expect(screen.queryByText(/Independent & community-funded/i)).not.toBeInTheDocument();
  1345. // The ask names the fleet back to them.
  1346. expect(screen.getByText(/6 printers/i)).toBeInTheDocument();
  1347. });
  1348. });
  1349. describe('SettingsPage — settings changed outside the page (#2716)', () => {
  1350. const restoreLabel = 'Restore plate for finish photo';
  1351. // external_url is deliberately populated: when the server has none the page
  1352. // detects one from the browser and saves it unprompted, which would show up
  1353. // as a PUT in tests that assert none was made. That behaviour has its own
  1354. // test at the end of this block.
  1355. const baseSettings = { ...mockSettings, external_url: window.location.origin };
  1356. let queryClient: QueryClient;
  1357. let puts: Record<string, unknown>[];
  1358. let served: Record<string, unknown>;
  1359. function renderPage() {
  1360. queryClient = new QueryClient({
  1361. defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } },
  1362. });
  1363. return rtlRender(
  1364. <QueryClientProvider client={queryClient}>
  1365. <BrowserRouter>
  1366. <AuthProvider>
  1367. <ThemeProvider>
  1368. <ToastProvider>
  1369. <SettingsPage />
  1370. </ToastProvider>
  1371. </ThemeProvider>
  1372. </AuthProvider>
  1373. </BrowserRouter>
  1374. </QueryClientProvider>
  1375. );
  1376. }
  1377. /** Change the settings row server-side and let the page's query observe it. */
  1378. async function changeOnServer(patch: Record<string, unknown>) {
  1379. served = { ...served, ...patch };
  1380. await act(async () => {
  1381. await queryClient.invalidateQueries({ queryKey: ['settings'] });
  1382. });
  1383. }
  1384. /** Wait out the 100ms initial-load suppression, then flip a checkbox. */
  1385. async function toggleRestorePlate() {
  1386. const label = await screen.findByText(restoreLabel);
  1387. await new Promise((resolve) => setTimeout(resolve, 200));
  1388. const row = label.closest('div')!.parentElement!;
  1389. await userEvent.click(within(row).getByRole('checkbox'));
  1390. }
  1391. beforeEach(() => {
  1392. window.history.replaceState({}, '', '/');
  1393. localStorage.clear();
  1394. setAuthToken(null);
  1395. puts = [];
  1396. served = { ...baseSettings };
  1397. server.use(
  1398. http.get('/api/v1/settings/', () => HttpResponse.json(served)),
  1399. http.put('/api/v1/settings/', async ({ request }) => {
  1400. const body = (await request.json()) as Record<string, unknown>;
  1401. puts.push(body);
  1402. served = { ...served, ...body };
  1403. return HttpResponse.json(served);
  1404. })
  1405. );
  1406. });
  1407. it('does not write its stale copy back over a server-side change', async () => {
  1408. // The defect: the page diffed the live query cache against its own copy, so
  1409. // a refetch that carried someone else's change read as a local edit and was
  1410. // reverted ~500ms later with no user interaction at all.
  1411. renderPage();
  1412. await screen.findByText(restoreLabel);
  1413. await new Promise((resolve) => setTimeout(resolve, 200));
  1414. await changeOnServer({ currency: 'EUR' });
  1415. // Well past the 500ms debounce.
  1416. await new Promise((resolve) => setTimeout(resolve, 1200));
  1417. expect(puts).toEqual([]);
  1418. });
  1419. it('adopts the server value, so a later save carries it rather than the stale one', async () => {
  1420. renderPage();
  1421. await new Promise((resolve) => setTimeout(resolve, 200));
  1422. await changeOnServer({ currency: 'EUR' });
  1423. await toggleRestorePlate();
  1424. await waitFor(() => expect(puts).toHaveLength(1), { timeout: 3000 });
  1425. // The user's edit is saved...
  1426. expect(puts[0].finish_photo_restore_plate).toBe(false);
  1427. // ...and the field they never touched goes back as the server's value, not
  1428. // the USD the page loaded with.
  1429. expect(puts[0].currency).toBe('EUR');
  1430. });
  1431. it('never reverts a pending user edit that the server changed too', async () => {
  1432. renderPage();
  1433. await toggleRestorePlate();
  1434. // Lands while the edit is still sitting in the 500ms debounce, i.e. before
  1435. // the page has committed it. Adopting the server's value here would throw
  1436. // the edit away silently.
  1437. await changeOnServer({ finish_photo_restore_plate: true });
  1438. await waitFor(() => expect(puts.length).toBeGreaterThan(0), { timeout: 3000 });
  1439. await new Promise((resolve) => setTimeout(resolve, 1200));
  1440. // Asserted over every request rather than a particular one: whichever order
  1441. // the refetch and the debounce happen to land in, no write may carry the
  1442. // server's value back over the user's.
  1443. expect(puts.map((p) => p.finish_photo_restore_plate)).toEqual(puts.map(() => false));
  1444. });
  1445. it('saves once per edit — the baseline moves with the saved row', async () => {
  1446. // Guards the failure mode the baseline introduces if it is not advanced on
  1447. // save: every render would diff against the pre-save snapshot and re-send.
  1448. renderPage();
  1449. await toggleRestorePlate();
  1450. await waitFor(() => expect(puts).toHaveLength(1), { timeout: 3000 });
  1451. await new Promise((resolve) => setTimeout(resolve, 1500));
  1452. expect(puts).toHaveLength(1);
  1453. });
  1454. it('still persists the external_url it detects from the browser', async () => {
  1455. // The page seeds external_url from window.location.origin when the server
  1456. // has none and relies on the auto-save to persist it. That only works
  1457. // because the baseline is the raw server row: seed the baseline from the
  1458. // adjusted copy instead and the detected URL matches it, so nothing ever
  1459. // marks it as needing a save.
  1460. served = { ...mockSettings };
  1461. renderPage();
  1462. await screen.findByText(restoreLabel);
  1463. await new Promise((resolve) => setTimeout(resolve, 200));
  1464. // A refetch carrying a field this page does not manage. It is enough to
  1465. // re-run the diff, and the only thing that differs is the detected URL.
  1466. await changeOnServer({ spoolman_url: 'http://spoolman.example' });
  1467. await waitFor(() => expect(puts).toHaveLength(1), { timeout: 3000 });
  1468. expect(puts[0].external_url).toBe(window.location.origin);
  1469. });
  1470. });