ConnectionDiagnosticModal.test.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. /**
  2. * Tests for the connection diagnostic modal.
  3. *
  4. * Covers the user-facing contract: the modal runs the diagnostic on mount,
  5. * renders each check's localized title and fix text keyed on id + status,
  6. * picks the right API for printer vs pre-add mode, and re-runs on retry.
  7. */
  8. import { describe, it, expect, vi } from 'vitest';
  9. import { render, screen, waitFor, fireEvent } from '@testing-library/react';
  10. import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
  11. import { I18nextProvider } from 'react-i18next';
  12. import i18n from '../../i18n';
  13. import { ConnectionDiagnosticModal } from '../../components/ConnectionDiagnostic';
  14. import { api, type PrinterDiagnosticResult } from '../../api/client';
  15. const PROBLEM_RESULT: PrinterDiagnosticResult = {
  16. printer_id: 1,
  17. ip_address: '192.168.1.50',
  18. overall: 'problems',
  19. checks: [
  20. { id: 'port_mqtt', status: 'pass', params: {} },
  21. { id: 'port_ftps', status: 'pass', params: {} },
  22. { id: 'port_rtsps', status: 'warn', params: {} },
  23. { id: 'network_mode', status: 'pass', params: { mode: 'host' } },
  24. { id: 'subnet', status: 'pass', params: {} },
  25. { id: 'mqtt_auth', status: 'pass', params: {} },
  26. { id: 'developer_mode', status: 'fail', params: {} },
  27. ],
  28. };
  29. function renderModal(props: Parameters<typeof ConnectionDiagnosticModal>[0]) {
  30. const queryClient = new QueryClient({
  31. defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
  32. });
  33. render(
  34. <QueryClientProvider client={queryClient}>
  35. <I18nextProvider i18n={i18n}>
  36. <ConnectionDiagnosticModal {...props} />
  37. </I18nextProvider>
  38. </QueryClientProvider>,
  39. );
  40. }
  41. describe('ConnectionDiagnosticModal', () => {
  42. it('runs the diagnostic on mount and renders check titles + the overall banner', async () => {
  43. const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue(PROBLEM_RESULT);
  44. renderModal({ printerId: 1, printerName: 'Test P1S', onClose: vi.fn() });
  45. await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
  46. expect(spy).toHaveBeenCalledWith(1);
  47. // Each check's localized title renders.
  48. expect(await screen.findByText(/Control port \(MQTT 8883\)/i)).toBeInTheDocument();
  49. expect(screen.getByText(/LAN Developer Mode/i)).toBeInTheDocument();
  50. // The failing developer_mode check shows its fix text.
  51. expect(screen.getByText(/Developer Mode is OFF/i)).toBeInTheDocument();
  52. // Overall banner reflects "problems".
  53. expect(
  54. screen.getByText(/Found problems that explain why the printer/i),
  55. ).toBeInTheDocument();
  56. spy.mockRestore();
  57. });
  58. it('uses the pre-add API when given a connection instead of a printerId', async () => {
  59. const spy = vi.spyOn(api, 'diagnoseConnection').mockResolvedValue({
  60. ...PROBLEM_RESULT,
  61. printer_id: null,
  62. });
  63. renderModal({
  64. connection: { ip_address: '192.168.1.99', serial_number: '01P', access_code: 'abc' },
  65. onClose: vi.fn(),
  66. });
  67. await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
  68. expect(spy).toHaveBeenCalledWith({
  69. ip_address: '192.168.1.99',
  70. serial_number: '01P',
  71. access_code: 'abc',
  72. });
  73. spy.mockRestore();
  74. });
  75. it('re-runs the diagnostic when the user clicks Run again', async () => {
  76. const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue(PROBLEM_RESULT);
  77. renderModal({ printerId: 1, printerName: 'Test P1S', onClose: vi.fn() });
  78. await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
  79. fireEvent.click(screen.getByText(/Run again/i));
  80. await waitFor(() => expect(spy).toHaveBeenCalledTimes(2));
  81. spy.mockRestore();
  82. });
  83. it('renders model-specific camera port diagnostics', async () => {
  84. const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
  85. ...PROBLEM_RESULT,
  86. overall: 'warnings',
  87. checks: [
  88. { id: 'port_mqtt', status: 'pass', params: {} },
  89. { id: 'port_ftps', status: 'pass', params: {} },
  90. {
  91. id: 'port_rtsps',
  92. status: 'warn',
  93. params: { protocol: 'Chamber Image', port: 6000 },
  94. },
  95. ],
  96. });
  97. renderModal({ printerId: 1, printerName: 'Test A1 Mini', onClose: vi.fn() });
  98. await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
  99. expect(await screen.findByText(/Camera port \(Chamber Image 6000\)/i)).toBeInTheDocument();
  100. expect(screen.getByText(/Port 6000 is unreachable/i)).toBeInTheDocument();
  101. spy.mockRestore();
  102. });
  103. it('shows the unsupported-model explanation for the external_storage skip reason (#2524)', async () => {
  104. const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
  105. ...PROBLEM_RESULT,
  106. overall: 'warnings',
  107. checks: [
  108. { id: 'external_storage', status: 'skip', params: { reason: 'unsupported_model' } },
  109. ],
  110. });
  111. renderModal({ printerId: 1, printerName: 'Test P1S', onClose: vi.fn() });
  112. await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
  113. // The reason-specific variant renders, NOT the generic "needs a live
  114. // MQTT connection" skip text.
  115. expect(await screen.findByText(/no way to turn the option on/i)).toBeInTheDocument();
  116. expect(screen.queryByText(/needs a live MQTT connection/i)).not.toBeInTheDocument();
  117. spy.mockRestore();
  118. });
  119. it('names a refused access code when the printer said so (#2698)', async () => {
  120. const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
  121. ...PROBLEM_RESULT,
  122. checks: [{ id: 'mqtt_auth', status: 'fail', params: { reason: 'auth_rejected' } }],
  123. });
  124. renderModal({ printerId: 1, printerName: 'Test A1', onClose: vi.fn() });
  125. await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
  126. // States the printer refused us, instead of the hedged "most likely wrong"
  127. // text used when all we know is that there's no session.
  128. expect(await screen.findByText(/refused Bambuddy's credentials/i)).toBeInTheDocument();
  129. expect(screen.queryByText(/most likely wrong/i)).not.toBeInTheDocument();
  130. spy.mockRestore();
  131. });
  132. it('hedges on the mqtt_auth failure when the printer gave no reason (#2698)', async () => {
  133. const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
  134. ...PROBLEM_RESULT,
  135. checks: [{ id: 'mqtt_auth', status: 'fail', params: {} }],
  136. });
  137. renderModal({ printerId: 1, printerName: 'Test A1', onClose: vi.fn() });
  138. await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
  139. expect(await screen.findByText(/most likely wrong/i)).toBeInTheDocument();
  140. expect(screen.queryByText(/refused Bambuddy's credentials/i)).not.toBeInTheDocument();
  141. spy.mockRestore();
  142. });
  143. it('names the container engine in the network-mode check (#3092)', async () => {
  144. const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
  145. ...PROBLEM_RESULT,
  146. overall: 'ok',
  147. checks: [{ id: 'network_mode', status: 'pass', params: { mode: 'host', runtime: 'Podman' } }],
  148. });
  149. renderModal({ printerId: 1, printerName: 'Test P1S', onClose: vi.fn() });
  150. await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
  151. // The title is no longer Docker-specific, and the engine the user
  152. // actually runs is named back to them.
  153. expect(await screen.findByText(/Container network mode/i)).toBeInTheDocument();
  154. expect(screen.getByText(/Running in Podman with host networking/i)).toBeInTheDocument();
  155. expect(screen.queryByText(/Docker network mode/i)).not.toBeInTheDocument();
  156. spy.mockRestore();
  157. });
  158. it('localizes an unnamed container engine instead of interpolating a raw word (#3092)', async () => {
  159. const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
  160. ...PROBLEM_RESULT,
  161. overall: 'ok',
  162. checks: [{ id: 'network_mode', status: 'pass', params: { mode: 'host', runtime: 'container' } }],
  163. });
  164. renderModal({ printerId: 1, printerName: 'Test P1S', onClose: vi.fn() });
  165. await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
  166. // "a container", not the bare backend sentinel — the same slot carries a
  167. // localized noun phrase in every other locale.
  168. expect(await screen.findByText(/Running in a container with host networking/i)).toBeInTheDocument();
  169. spy.mockRestore();
  170. });
  171. it('says so when a container network mode cannot be read, instead of guessing (#3092)', async () => {
  172. const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
  173. ...PROBLEM_RESULT,
  174. overall: 'warnings',
  175. checks: [{ id: 'network_mode', status: 'skip', params: { reason: 'unknown', runtime: 'Podman' } }],
  176. });
  177. renderModal({ printerId: 1, printerName: 'Test P1S', onClose: vi.fn() });
  178. await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
  179. expect(await screen.findByText(/network mode could not be determined/i)).toBeInTheDocument();
  180. // Must not claim bare metal, which is what sent the reporter looking
  181. // for the problem somewhere else.
  182. expect(screen.queryByText(/not running in a container/i)).not.toBeInTheDocument();
  183. spy.mockRestore();
  184. });
  185. it('does not offer host networking to a system container (#3092)', async () => {
  186. const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
  187. ...PROBLEM_RESULT,
  188. overall: 'ok',
  189. checks: [{ id: 'network_mode', status: 'skip', params: { reason: 'system_container', runtime: 'LXC' } }],
  190. });
  191. renderModal({ printerId: 1, printerName: 'Test P1S', onClose: vi.fn() });
  192. await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
  193. expect(await screen.findByText(/LXC system container/i)).toBeInTheDocument();
  194. expect(screen.queryByText(/recreate the container/i)).not.toBeInTheDocument();
  195. spy.mockRestore();
  196. });
  197. it('names the interpreter when macOS has no signature to grant against (#3114)', async () => {
  198. const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
  199. ...PROBLEM_RESULT,
  200. overall: 'problems',
  201. checks: [
  202. {
  203. id: 'macos_local_network',
  204. status: 'warn',
  205. params: {
  206. reason: 'unsigned',
  207. executable: '/usr/local/Cellar/python@3.14/3.14.7/Frameworks/Python.framework/Versions/3.14/bin/python3.14',
  208. },
  209. },
  210. ],
  211. });
  212. renderModal({ printerId: 1, printerName: 'Test P1S', onClose: vi.fn() });
  213. await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
  214. expect(await screen.findByText(/macOS Local Network permission/i)).toBeInTheDocument();
  215. expect(screen.getByText(/has no code signature/i)).toBeInTheDocument();
  216. // The path is what tells the user which of several Pythons is meant.
  217. expect(screen.getByText(/Versions\/3\.14\/bin\/python3\.14/)).toBeInTheDocument();
  218. spy.mockRestore();
  219. });
  220. it('points a signed-but-blocked macOS install at System Settings (#3114)', async () => {
  221. const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
  222. ...PROBLEM_RESULT,
  223. overall: 'problems',
  224. checks: [{ id: 'macos_local_network', status: 'warn', params: { reason: 'permission' } }],
  225. });
  226. renderModal({ printerId: 1, printerName: 'Test P1S', onClose: vi.fn() });
  227. await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
  228. expect(await screen.findByText(/Privacy & Security > Local Network/i)).toBeInTheDocument();
  229. // The signing repair must not be offered to a machine that is already
  230. // signed: re-signing it would revoke the grant it still has.
  231. expect(screen.queryByText(/update_macos\.sh/i)).not.toBeInTheDocument();
  232. spy.mockRestore();
  233. });
  234. it('falls back to the generic skip text when no reason is present', async () => {
  235. const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
  236. ...PROBLEM_RESULT,
  237. overall: 'warnings',
  238. checks: [{ id: 'external_storage', status: 'skip', params: {} }],
  239. });
  240. renderModal({ printerId: 1, printerName: 'Test X1C', onClose: vi.fn() });
  241. await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
  242. expect(await screen.findByText(/needs a live MQTT connection/i)).toBeInTheDocument();
  243. spy.mockRestore();
  244. });
  245. });