mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
feat(discovery): scan a custom subnet for cross-router printers (#1564)
SSDP multicast (239.255.255.250:2021) doesn't traverse routers, so a printer behind a router on a different L3 segment was invisible to "Discover Printers on Network". Docker mode had a CIDR text input but only as a fallback when zero interface subnets were detected; native mode had no subnet field at all. AddPrinterModal now surfaces an always-visible subnet picker. Detected interface subnets stay as dropdown options, plus a "Custom subnet..." sentinel reveals a CIDR text input. When custom is picked, discovery routes through POST /discovery/scan with the typed CIDR instead of SSDP (which would no-op against a foreign subnet anyway). Last custom CIDR persisted to localStorage so VLAN users don't retype every time. Scan button label and scanning / no-printers-found strings all key off (isDocker || useCustomSubnet) so wording stays "Scan Subnet..." / "Scanning subnet..." whether the user is on Docker or just picked Custom. Backend unchanged: SubnetScanner.scan_subnet() already accepts any CIDR, already caps at /22 (1024 hosts), POST /discovery/scan already takes user-supplied input.
This commit is contained in:
@@ -109,15 +109,17 @@ describe('AddPrinterModal Discovery', () => {
|
||||
await userEvent.click(addButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show a select element (dropdown) with both subnets
|
||||
// Should show a select element (dropdown) with both subnets and
|
||||
// the trailing "Custom subnet..." sentinel that lets a user enter
|
||||
// a CIDR for a printer on a different L3 segment (#1564).
|
||||
const selectElement = screen.getByDisplayValue('192.168.1.0/24');
|
||||
expect(selectElement.tagName).toBe('SELECT');
|
||||
|
||||
// Both options should be available
|
||||
const options = selectElement.querySelectorAll('option');
|
||||
expect(options).toHaveLength(2);
|
||||
expect(options).toHaveLength(3);
|
||||
expect(options[0].textContent).toBe('192.168.1.0/24');
|
||||
expect(options[1].textContent).toBe('10.0.0.0/24');
|
||||
expect(options[2].textContent).toMatch(/custom subnet/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,7 +152,11 @@ describe('AddPrinterModal Discovery', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show subnet field in non-Docker mode', async () => {
|
||||
it('shows the subnet picker in non-Docker mode too, with a Custom option (#1564)', async () => {
|
||||
// Pre-#1564 the subnet picker was gated on isDocker and a native
|
||||
// install only saw the "Discover Printers" button. SSDP doesn't
|
||||
// cross routers, so users with a printer on a different L3 segment
|
||||
// had no path to scan it. The picker is now always visible.
|
||||
server.use(
|
||||
http.get('/api/v1/discovery/info', () => {
|
||||
return HttpResponse.json({
|
||||
@@ -171,13 +177,19 @@ describe('AddPrinterModal Discovery', () => {
|
||||
const addButton = screen.getByText(/add printer/i);
|
||||
await userEvent.click(addButton);
|
||||
|
||||
// Detected subnet is the default value; "Custom subnet..." sits
|
||||
// alongside it so the user can pick a foreign CIDR.
|
||||
await waitFor(() => {
|
||||
// Should show the discover button but NOT the subnet field
|
||||
expect(screen.getByText(/discover printers/i)).toBeInTheDocument();
|
||||
const selectElement = screen.getByDisplayValue('192.168.1.0/24');
|
||||
expect(selectElement.tagName).toBe('SELECT');
|
||||
const options = selectElement.querySelectorAll('option');
|
||||
expect(options).toHaveLength(2);
|
||||
expect(options[0].textContent).toBe('192.168.1.0/24');
|
||||
expect(options[1].textContent).toMatch(/custom subnet/i);
|
||||
});
|
||||
|
||||
// Subnet field should not exist
|
||||
expect(screen.queryByPlaceholderText('192.168.1.0/24')).not.toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('192.168.1.0/24')).not.toBeInTheDocument();
|
||||
// Default selection leaves the SSDP path in place — the button
|
||||
// still reads "Discover Printers on Network", not "Scan Subnet".
|
||||
expect(screen.getByText(/discover printers/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Discovery — custom-subnet picker (#1564).
|
||||
*
|
||||
* Reporters with a printer behind a router on a different L3 segment
|
||||
* (e.g. Bambuddy on 192.168.1.0/24, printer on 10.1.1.0/24) couldn't
|
||||
* scan that subnet because:
|
||||
* - SSDP multicast doesn't cross routers
|
||||
* - The Docker-mode subnet input was the only path that accepted a
|
||||
* CIDR, and it was hidden in native mode
|
||||
*
|
||||
* The fix surfaces the subnet picker in native mode too and adds a
|
||||
* "Custom..." option that reveals a CIDR text input. Picking it routes
|
||||
* through startSubnetScan(cidr) instead of startDiscovery().
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '../utils';
|
||||
import { AddPrinterModal } from '../../pages/PrintersPage';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../mocks/server';
|
||||
|
||||
describe('AddPrinterModal — custom subnet (#1564)', () => {
|
||||
let scanCalls: { subnet: string; timeout: number }[];
|
||||
let ssdpStarted: boolean;
|
||||
|
||||
beforeEach(() => {
|
||||
scanCalls = [];
|
||||
ssdpStarted = false;
|
||||
// localStorage is a vi.fn() spy in this test env (see setup.ts), so
|
||||
// clear call history rather than removeItem-ing the absent value.
|
||||
vi.mocked(localStorage.setItem).mockClear();
|
||||
vi.mocked(localStorage.getItem).mockClear();
|
||||
server.use(
|
||||
// Native install with one detected subnet.
|
||||
http.get('/api/v1/discovery/info', () =>
|
||||
HttpResponse.json({
|
||||
is_docker: false,
|
||||
ssdp_running: false,
|
||||
scan_running: false,
|
||||
subnets: ['192.168.1.0/24'],
|
||||
}),
|
||||
),
|
||||
http.post('/api/v1/discovery/start', () => {
|
||||
ssdpStarted = true;
|
||||
return HttpResponse.json({ running: true });
|
||||
}),
|
||||
http.post('/api/v1/discovery/stop', () =>
|
||||
HttpResponse.json({ running: false }),
|
||||
),
|
||||
http.post('/api/v1/discovery/scan', async ({ request }) => {
|
||||
const body = (await request.json()) as { subnet: string; timeout: number };
|
||||
scanCalls.push(body);
|
||||
return HttpResponse.json({ running: true, scanned: 0, total: 254 });
|
||||
}),
|
||||
http.post('/api/v1/discovery/scan/stop', () =>
|
||||
HttpResponse.json({ running: false, scanned: 0, total: 0 }),
|
||||
),
|
||||
http.get('/api/v1/discovery/scan/status', () =>
|
||||
HttpResponse.json({ running: false, scanned: 254, total: 254 }),
|
||||
),
|
||||
http.get('/api/v1/discovery/printers', () => HttpResponse.json([])),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the subnet picker even on a native (non-Docker) install', async () => {
|
||||
render(
|
||||
<AddPrinterModal
|
||||
onClose={() => {}}
|
||||
onAdd={() => {}}
|
||||
existingSerials={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The picker is now ungated; the detected subnet shows in the
|
||||
// dropdown alongside the "Custom..." sentinel.
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('option', { name: '192.168.1.0/24' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('option', { name: /custom subnet/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('routes a custom CIDR through startSubnetScan, not SSDP', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AddPrinterModal
|
||||
onClose={() => {}}
|
||||
onAdd={() => {}}
|
||||
existingSerials={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for discoveryApi.getInfo() to populate the dropdown.
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('option', { name: '192.168.1.0/24' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Pick the "Custom..." sentinel. Scope by display value because the
|
||||
// modal also has a model <select>.
|
||||
const select = screen.getByDisplayValue('192.168.1.0/24') as HTMLSelectElement;
|
||||
await user.selectOptions(select, '__custom__');
|
||||
|
||||
// The CIDR text input appears (aria-labelled "Custom subnet (CIDR)").
|
||||
const cidrInput = await screen.findByLabelText(/custom subnet \(cidr\)/i);
|
||||
await user.clear(cidrInput);
|
||||
await user.type(cidrInput, '10.1.1.0/24');
|
||||
|
||||
// Click the scan button — labelled "Scan Subnet..." now, not
|
||||
// "Discover Printers on Network", because the user picked custom.
|
||||
const scanButton = screen.getByRole('button', { name: /scan subnet/i });
|
||||
await user.click(scanButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(scanCalls.length).toBe(1);
|
||||
});
|
||||
expect(scanCalls[0].subnet).toBe('10.1.1.0/24');
|
||||
// SSDP must not start when a custom CIDR is in play — multicast
|
||||
// can't reach the foreign subnet anyway.
|
||||
expect(ssdpStarted).toBe(false);
|
||||
// And we persist the choice so the user doesn't retype next time.
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
'bambuddy.discovery.customSubnet',
|
||||
'10.1.1.0/24',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves the default SSDP path when the user keeps a detected subnet', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AddPrinterModal
|
||||
onClose={() => {}}
|
||||
onAdd={() => {}}
|
||||
existingSerials={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('option', { name: '192.168.1.0/24' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Don't change the selection — default is the detected subnet.
|
||||
const scanButton = screen.getByRole('button', {
|
||||
name: /discover printers on network/i,
|
||||
});
|
||||
await user.click(scanButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ssdpStarted).toBe(true);
|
||||
});
|
||||
expect(scanCalls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user