PrintersPageFirmwareModalWiring.test.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. /**
  2. * Printer card -> firmware modal wiring (inline FirmwareUpdateModal in PrintersPage).
  3. *
  4. * Pins: the version the user picks is the version that is prepared and
  5. * uploaded to the printer's SD card, the upload status poll is shown (progress
  6. * and error), the Upload button only exists when the backend's prepare check
  7. * says it can proceed, and firmware:update gates both the prepare check and
  8. * the upload.
  9. */
  10. import { describe, it, expect, vi, beforeEach } from 'vitest';
  11. import { screen, waitFor, within, act } from '@testing-library/react';
  12. import userEvent from '@testing-library/user-event';
  13. import { http, HttpResponse } from 'msw';
  14. import { server } from '../mocks/server';
  15. import { QueryClient } from '@tanstack/react-query';
  16. const permissions = { granted: [] as string[] };
  17. const mockUseAuth = {
  18. user: { id: 1, username: 'operator', permissions: [] as string[] },
  19. authEnabled: true,
  20. requiresSetup: false,
  21. loading: false,
  22. isAdmin: false,
  23. login: vi.fn(),
  24. loginWithToken: vi.fn(),
  25. logout: vi.fn(),
  26. refreshUser: vi.fn(),
  27. refreshAuth: vi.fn(),
  28. hasPermission: vi.fn((permission: string) => permissions.granted.includes(permission)),
  29. hasAnyPermission: vi.fn(() => true),
  30. hasAllPermissions: vi.fn(() => true),
  31. canModify: vi.fn(() => true),
  32. };
  33. vi.mock('../../contexts/AuthContext', async (importOriginal) => {
  34. const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
  35. return { ...actual, useAuth: () => mockUseAuth };
  36. });
  37. import { render } from '../utils';
  38. import { PrintersPage } from '../../pages/PrintersPage';
  39. const PRINTER_ID = 5;
  40. const mockPrinter = {
  41. id: PRINTER_ID,
  42. name: 'Garage X1C',
  43. ip_address: '192.0.2.100',
  44. serial_number: '01P00A000000005',
  45. access_code: '12345678',
  46. model: 'X1C',
  47. enabled: true,
  48. nozzle_diameter: 0.4,
  49. nozzle_type: 'stainless_steel',
  50. location: null,
  51. auto_archive: true,
  52. created_at: '2024-01-01T00:00:00Z',
  53. updated_at: '2024-01-01T00:00:00Z',
  54. };
  55. const firmwareInfo = {
  56. printer_id: PRINTER_ID,
  57. printer_name: 'Garage X1C',
  58. model: 'X1C',
  59. current_version: '01.07.00.00',
  60. latest_version: '01.08.00.00',
  61. update_available: true,
  62. download_url: null,
  63. release_notes: 'Latest notes',
  64. available_versions: [
  65. { version: '01.08.00.00', file_available: true, download_url: null, release_notes: 'v8', release_time: null },
  66. { version: '01.07.00.00', file_available: true, download_url: null, release_notes: 'v7', release_time: null },
  67. { version: '01.06.00.00', file_available: true, download_url: null, release_notes: 'v6', release_time: null },
  68. { version: '01.05.00.00', file_available: false, download_url: null, release_notes: null, release_time: null },
  69. ],
  70. };
  71. const prepareOk = {
  72. can_proceed: true,
  73. sd_card_present: true,
  74. sd_card_free_space: 1e9,
  75. firmware_size: 1e8,
  76. space_sufficient: true,
  77. update_available: true,
  78. current_version: '01.07.00.00',
  79. latest_version: '01.08.00.00',
  80. target_version: null,
  81. firmware_filename: 'fw.bin',
  82. errors: [] as string[],
  83. };
  84. let prepareVersions: (string | null)[] = [];
  85. let uploadVersions: (string | null)[] = [];
  86. let statusPolls = 0;
  87. function renderPage({
  88. state = 'IDLE',
  89. prepare = prepareOk,
  90. statuses = [] as object[],
  91. }: { state?: string; prepare?: typeof prepareOk; statuses?: object[] } = {}) {
  92. prepareVersions = [];
  93. uploadVersions = [];
  94. statusPolls = 0;
  95. server.use(
  96. http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
  97. http.get('/api/v1/printers/:id/status', () =>
  98. HttpResponse.json({
  99. connected: true,
  100. state,
  101. progress: state === 'RUNNING' ? 40 : 0,
  102. layer_num: state === 'RUNNING' ? 10 : 0,
  103. total_layers: 100,
  104. temperatures: { nozzle: 25, bed: 25, chamber: 25 },
  105. remaining_time: 0,
  106. wifi_signal: -40,
  107. speed_level: 2,
  108. vt_tray: [],
  109. ams: [],
  110. firmware_version: '01.07.00.00',
  111. }),
  112. ),
  113. http.get('/api/v1/queue/', () => HttpResponse.json([])),
  114. http.get(`/api/v1/firmware/updates/${PRINTER_ID}`, () => HttpResponse.json(firmwareInfo)),
  115. http.get(`/api/v1/firmware/updates/${PRINTER_ID}/prepare`, ({ request }) => {
  116. prepareVersions.push(new URL(request.url).searchParams.get('version'));
  117. return HttpResponse.json(prepare);
  118. }),
  119. http.post('/api/v1/firmware/updates/:id/upload', ({ request, params }) => {
  120. expect(params.id).toBe(String(PRINTER_ID));
  121. uploadVersions.push(new URL(request.url).searchParams.get('version'));
  122. return HttpResponse.json({ started: true, message: 'started' });
  123. }),
  124. http.get(`/api/v1/firmware/updates/${PRINTER_ID}/upload/status`, () => {
  125. const s = statuses[Math.min(statusPolls, statuses.length - 1)];
  126. statusPolls += 1;
  127. return HttpResponse.json(s);
  128. }),
  129. );
  130. render(<PrintersPage />);
  131. }
  132. async function openModal() {
  133. const user = userEvent.setup();
  134. const badge = await screen.findByTitle(/Firmware update available/i);
  135. await user.click(badge);
  136. await screen.findByText('Firmware Update');
  137. return user;
  138. }
  139. // The version list rows are buttons; the "Current:" line shows the same text in a plain span.
  140. const versionRow = (v: string) => {
  141. const rows = screen
  142. .getAllByText(v, { selector: 'span.font-mono' })
  143. .map((el) => el.closest('button'))
  144. .filter((b): b is HTMLButtonElement => b !== null);
  145. expect(rows).toHaveLength(1);
  146. return rows[0];
  147. };
  148. describe('PrintersPage firmware modal wiring', () => {
  149. beforeEach(() => {
  150. permissions.granted = ['firmware:read', 'firmware:update', 'printers:control'];
  151. });
  152. it('uploads the version the user picked, not the latest', async () => {
  153. renderPage({
  154. statuses: [{ status: 'complete', progress: 100, message: 'Done', error: null, firmware_filename: 'fw.bin', firmware_version: '01.06.00.00' }],
  155. });
  156. const user = await openModal();
  157. // Latest is pre-selected and prepared first.
  158. await waitFor(() => expect(prepareVersions).toContain('01.08.00.00'));
  159. await user.click(versionRow('01.06.00.00'));
  160. await waitFor(() => expect(prepareVersions).toContain('01.06.00.00'));
  161. const upload = await screen.findByRole('button', { name: /Upload Firmware/ });
  162. await user.click(upload);
  163. await waitFor(() => expect(uploadVersions).toEqual(['01.06.00.00']));
  164. expect(await screen.findByText('Firmware uploaded to SD card!', {}, { timeout: 5000 })).toBeInTheDocument();
  165. }, 10000);
  166. it('shows polled progress and then the upload error', async () => {
  167. renderPage({
  168. statuses: [
  169. { status: 'uploading', progress: 45, message: 'Uploading to SD card', error: null, firmware_filename: 'fw.bin', firmware_version: '01.08.00.00' },
  170. { status: 'error', progress: 45, message: 'Upload failed', error: 'FTP connection reset', firmware_filename: 'fw.bin', firmware_version: '01.08.00.00' },
  171. ],
  172. });
  173. const user = await openModal();
  174. await user.click(await screen.findByRole('button', { name: /Upload Firmware/ }));
  175. await waitFor(() => expect(uploadVersions).toEqual(['01.08.00.00']));
  176. expect(await screen.findByText('45%', {}, { timeout: 5000 })).toBeInTheDocument();
  177. expect(await screen.findByText('FTP connection reset', {}, { timeout: 5000 })).toBeInTheDocument();
  178. // After an error the poll stops.
  179. const polls = statusPolls;
  180. await new Promise((r) => setTimeout(r, 2300));
  181. expect(statusPolls).toBe(polls);
  182. }, 15000);
  183. it('offers no upload when the prepare check refuses, and shows why', async () => {
  184. renderPage({ prepare: { ...prepareOk, can_proceed: false, sd_card_present: false, errors: ['No SD card inserted'] } });
  185. await openModal();
  186. expect(await screen.findByText('No SD card inserted')).toBeInTheDocument();
  187. expect(screen.queryByRole('button', { name: /Upload Firmware/ })).toBeNull();
  188. expect(uploadVersions).toEqual([]);
  189. });
  190. it('without firmware:update, versions are locked, nothing is prepared and there is no upload', async () => {
  191. permissions.granted = ['firmware:read', 'printers:control'];
  192. renderPage();
  193. await openModal();
  194. const dialog = screen.getByText('Firmware Update').closest('div.fixed') as HTMLElement;
  195. expect(versionRow('01.06.00.00')).toBeDisabled();
  196. expect(within(dialog).queryByRole('button', { name: /Upload Firmware/ })).toBeNull();
  197. await new Promise((r) => setTimeout(r, 50));
  198. expect(prepareVersions).toEqual([]);
  199. expect(uploadVersions).toEqual([]);
  200. });
  201. it('does not offer the installed version or one without a file', async () => {
  202. renderPage();
  203. await openModal();
  204. expect(versionRow('01.07.00.00')).toBeDisabled();
  205. expect(versionRow('01.05.00.00')).toBeDisabled();
  206. expect(versionRow('01.06.00.00')).toBeEnabled();
  207. });
  208. // Firmware goes onto the SD card the printer prints from: only while idle,
  209. // even when the prepare check (made earlier) still says it can proceed.
  210. it.each(['RUNNING', 'PAUSE', 'PREPARE', 'SLICING'])('offers no upload while the printer is %s', async (state) => {
  211. renderPage({ state });
  212. await openModal();
  213. await waitFor(() => expect(prepareVersions.length).toBeGreaterThan(0));
  214. expect(await screen.findByText(/can only be uploaded while it is idle/)).toBeInTheDocument();
  215. expect(screen.queryByRole('button', { name: /Upload Firmware/ })).toBeNull();
  216. expect(uploadVersions).toEqual([]);
  217. });
  218. it('asks the prepare check again once a busy printer turns idle, and then offers the upload', async () => {
  219. // The page's client: the status reaches the card through its cache, as from the WebSocket.
  220. const spy = vi.spyOn(QueryClient.prototype, 'mount');
  221. const busy = { ...prepareOk, can_proceed: false, errors: ['The printer is busy; firmware can only be uploaded while it is idle'] };
  222. renderPage({ state: 'RUNNING', prepare: busy });
  223. await openModal();
  224. expect(await screen.findByText(/firmware can only be uploaded while it is idle/)).toBeInTheDocument();
  225. const asked = prepareVersions.length;
  226. server.use(http.get(`/api/v1/firmware/updates/${PRINTER_ID}/prepare`, ({ request }) => {
  227. prepareVersions.push(new URL(request.url).searchParams.get('version'));
  228. return HttpResponse.json(prepareOk);
  229. }));
  230. const client = spy.mock.contexts[spy.mock.contexts.length - 1] as QueryClient;
  231. act(() => {
  232. client.setQueryData(['printerStatus', PRINTER_ID], (old: object | undefined) => ({ ...old, state: 'FINISH' }));
  233. });
  234. expect(await screen.findByRole('button', { name: /Upload Firmware/ })).toBeInTheDocument();
  235. expect(prepareVersions.length).toBeGreaterThan(asked);
  236. spy.mockRestore();
  237. });
  238. it.each(['FINISH', 'FAILED'])('offers the upload once the printer is %s', async (state) => {
  239. renderPage({ state });
  240. await openModal();
  241. expect(await screen.findByRole('button', { name: /Upload Firmware/ })).toBeInTheDocument();
  242. });
  243. });