PrintersPageDiscoveryCustomSubnet.test.tsx 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. /**
  2. * Discovery — custom-subnet picker (#1564).
  3. *
  4. * Reporters with a printer behind a router on a different L3 segment
  5. * (e.g. Bambuddy on 192.168.1.0/24, printer on 10.1.1.0/24) couldn't
  6. * scan that subnet because:
  7. * - SSDP multicast doesn't cross routers
  8. * - The Docker-mode subnet input was the only path that accepted a
  9. * CIDR, and it was hidden in native mode
  10. *
  11. * The fix surfaces the subnet picker in native mode too and adds a
  12. * "Custom..." option that reveals a CIDR text input. Picking it routes
  13. * through startSubnetScan(cidr) instead of startDiscovery().
  14. */
  15. import { describe, it, expect, beforeEach, vi } from 'vitest';
  16. import { screen, waitFor } from '@testing-library/react';
  17. import userEvent from '@testing-library/user-event';
  18. import { render } from '../utils';
  19. import { AddPrinterModal } from '../../pages/PrintersPage';
  20. import { http, HttpResponse } from 'msw';
  21. import { server } from '../mocks/server';
  22. describe('AddPrinterModal — custom subnet (#1564)', () => {
  23. let scanCalls: { subnet: string; timeout: number }[];
  24. let ssdpStarted: boolean;
  25. beforeEach(() => {
  26. scanCalls = [];
  27. ssdpStarted = false;
  28. // localStorage is a vi.fn() spy in this test env (see setup.ts), so
  29. // clear call history rather than removeItem-ing the absent value.
  30. vi.mocked(localStorage.setItem).mockClear();
  31. vi.mocked(localStorage.getItem).mockClear();
  32. server.use(
  33. // Native install with one detected subnet.
  34. http.get('/api/v1/discovery/info', () =>
  35. HttpResponse.json({
  36. is_docker: false,
  37. ssdp_running: false,
  38. scan_running: false,
  39. subnets: ['192.168.1.0/24'],
  40. }),
  41. ),
  42. http.post('/api/v1/discovery/start', () => {
  43. ssdpStarted = true;
  44. return HttpResponse.json({ running: true });
  45. }),
  46. http.post('/api/v1/discovery/stop', () =>
  47. HttpResponse.json({ running: false }),
  48. ),
  49. http.post('/api/v1/discovery/scan', async ({ request }) => {
  50. const body = (await request.json()) as { subnet: string; timeout: number };
  51. scanCalls.push(body);
  52. return HttpResponse.json({ running: true, scanned: 0, total: 254 });
  53. }),
  54. http.post('/api/v1/discovery/scan/stop', () =>
  55. HttpResponse.json({ running: false, scanned: 0, total: 0 }),
  56. ),
  57. http.get('/api/v1/discovery/scan/status', () =>
  58. HttpResponse.json({ running: false, scanned: 254, total: 254 }),
  59. ),
  60. http.get('/api/v1/discovery/printers', () => HttpResponse.json([])),
  61. );
  62. });
  63. it('renders the subnet picker even on a native (non-Docker) install', async () => {
  64. render(
  65. <AddPrinterModal
  66. onClose={() => {}}
  67. onAdd={() => {}}
  68. existingSerials={[]}
  69. />,
  70. );
  71. // The picker is now ungated; the detected subnet shows in the
  72. // dropdown alongside the "Custom..." sentinel.
  73. await waitFor(() => {
  74. expect(
  75. screen.getByRole('option', { name: '192.168.1.0/24' }),
  76. ).toBeInTheDocument();
  77. });
  78. expect(
  79. screen.getByRole('option', { name: /custom subnet/i }),
  80. ).toBeInTheDocument();
  81. });
  82. it('routes a custom CIDR through startSubnetScan, not SSDP', async () => {
  83. const user = userEvent.setup();
  84. render(
  85. <AddPrinterModal
  86. onClose={() => {}}
  87. onAdd={() => {}}
  88. existingSerials={[]}
  89. />,
  90. );
  91. // Wait for discoveryApi.getInfo() to populate the dropdown.
  92. await waitFor(() => {
  93. expect(
  94. screen.getByRole('option', { name: '192.168.1.0/24' }),
  95. ).toBeInTheDocument();
  96. });
  97. // Pick the "Custom..." sentinel. Scope by display value because the
  98. // modal also has a model <select>.
  99. const select = screen.getByDisplayValue('192.168.1.0/24') as HTMLSelectElement;
  100. await user.selectOptions(select, '__custom__');
  101. // The CIDR text input appears (aria-labelled "Custom subnet (CIDR)").
  102. const cidrInput = await screen.findByLabelText(/custom subnet \(cidr\)/i);
  103. await user.clear(cidrInput);
  104. await user.type(cidrInput, '10.1.1.0/24');
  105. // Click the scan button — labelled "Scan Subnet..." now, not
  106. // "Discover Printers on Network", because the user picked custom.
  107. const scanButton = screen.getByRole('button', { name: /scan subnet/i });
  108. await user.click(scanButton);
  109. await waitFor(() => {
  110. expect(scanCalls.length).toBe(1);
  111. });
  112. expect(scanCalls[0].subnet).toBe('10.1.1.0/24');
  113. // SSDP must not start when a custom CIDR is in play — multicast
  114. // can't reach the foreign subnet anyway.
  115. expect(ssdpStarted).toBe(false);
  116. // And we persist the choice so the user doesn't retype next time.
  117. expect(localStorage.setItem).toHaveBeenCalledWith(
  118. 'bambuddy.discovery.customSubnet',
  119. '10.1.1.0/24',
  120. );
  121. });
  122. it('preserves the default SSDP path when the user keeps a detected subnet', async () => {
  123. const user = userEvent.setup();
  124. render(
  125. <AddPrinterModal
  126. onClose={() => {}}
  127. onAdd={() => {}}
  128. existingSerials={[]}
  129. />,
  130. );
  131. await waitFor(() => {
  132. expect(
  133. screen.getByRole('option', { name: '192.168.1.0/24' }),
  134. ).toBeInTheDocument();
  135. });
  136. // Don't change the selection — default is the detected subnet.
  137. const scanButton = screen.getByRole('button', {
  138. name: /discover printers on network/i,
  139. });
  140. await user.click(scanButton);
  141. await waitFor(() => {
  142. expect(ssdpStarted).toBe(true);
  143. });
  144. expect(scanCalls.length).toBe(0);
  145. });
  146. });