wrolpi/app/src/components/admin/ControllerPage.test.js
Roland 3e559e1bf8 controller: Desktop start/stop toggle; rename hotspot/bluetooth actions to what they do
Add a Desktop toggle to the Controller (both UIs) that starts/stops
display-manager.service. Fail open: the unit is never enabled/disabled,
so the desktop always returns on reboot; users who want it disabled
across reboots can set the default target themselves.

Rename the hotspot and bluetooth actions to reflect their real behavior:
- enable/disable_hotspot -> start/stop_hotspot (/api/hotspot/start|stop)
- enable/disable_bluetooth -> unblock/block_bluetooth (rfkill semantics,
  /api/bluetooth/unblock|block)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 15:19:36 -06:00

345 lines
13 KiB
JavaScript

import React from 'react';
import {fireEvent, render, screen, waitFor} from '@testing-library/react';
import {BrowserRouter} from 'react-router';
import {ThemeContext, SettingsContext} from '../../contexts/contexts';
import {ControllerPage} from './ControllerPage';
// Mock the controller API
jest.mock('../../api/controller', () => ({
getServices: jest.fn().mockResolvedValue([
{
name: 'wrolpi-api',
port: 8081,
status: 'running',
viewable: true,
view_path: '/docs',
enabled: true,
kind: 'persistent',
group: 'core',
},
{
name: 'wrolpi-app',
port: 3000,
status: 'running',
viewable: true,
view_path: '',
enabled: true,
kind: 'persistent',
group: 'core',
},
{
name: 'smbd',
port: 445,
status: 'stopped',
viewable: false,
view_path: '',
enabled: false,
kind: 'persistent',
group: 'optional',
},
{
name: 'wrolpi-repair',
port: null,
status: 'stopped',
viewable: false,
view_path: '',
enabled: false,
kind: 'task',
group: 'optional',
},
]),
startService: jest.fn().mockResolvedValue({success: true}),
stopService: jest.fn().mockResolvedValue({success: true}),
restartService: jest.fn().mockResolvedValue({success: true}),
enableService: jest.fn().mockResolvedValue({success: true}),
disableService: jest.fn().mockResolvedValue({success: true}),
getServiceLogs: jest.fn().mockResolvedValue({logs: 'Sample log output'}),
getDisks: jest.fn().mockResolvedValue([]),
getMounts: jest.fn().mockResolvedValue([]),
getFstabEntries: jest.fn().mockResolvedValue([]),
getSmartStatus: jest.fn().mockResolvedValue({}),
mountDisk: jest.fn().mockResolvedValue({success: true}),
unmountDisk: jest.fn().mockResolvedValue(
{success: true, stopped_services: ['wrolpi-api']}),
addFstabEntry: jest.fn().mockResolvedValue({success: true}),
removeFstabEntry: jest.fn().mockResolvedValue({success: true}),
restartServices: jest.fn().mockResolvedValue({success: true}),
getSambaStatus: jest.fn().mockResolvedValue({enabled: false, available: true, shares: []}),
getHotspotSettings: jest.fn(),
getHotspotDevices: jest.fn(),
getHotspotProtocols: jest.fn(),
updateHotspotSettings: jest.fn(),
addSambaShare: jest.fn().mockResolvedValue({success: true}),
removeSambaShare: jest.fn().mockResolvedValue({success: true}),
}));
// Mock hooks
jest.mock('../../hooks/customHooks', () => ({
useDockerized: jest.fn().mockReturnValue(false),
useMediaDirectory: jest.fn().mockReturnValue('/media/wrolpi'),
useBluetooth: jest.fn().mockReturnValue({on: false, setBluetooth: jest.fn()}),
useDesktop: jest.fn().mockReturnValue({on: true, setDesktop: jest.fn()}),
useHotspot: jest.fn().mockReturnValue({on: true, setHotspot: jest.fn()}),
useThrottle: jest.fn().mockReturnValue({on: false, setThrottle: jest.fn()}),
}));
// Mock Common.js components that have complex dependencies
jest.mock('../Common', () => {
const actual = jest.requireActual('../Common');
return {
...actual,
BluetoothToggle: () => <div data-testid="bluetooth-toggle">BluetoothToggle</div>,
DesktopToggle: () => <div data-testid="desktop-toggle">DesktopToggle</div>,
HotspotToggle: () => <div data-testid="hotspot-toggle">HotspotToggle</div>,
ThrottleToggle: () => <div data-testid="throttle-toggle">ThrottleToggle</div>,
Toggle: ({label, checked, onChange}) => (
<label>
<input
type="checkbox"
checked={checked || false}
onChange={e => onChange && onChange(e.target.checked)}
/>
{label}
</label>
),
APIButton: ({children, onClick, disabled}) => (
<button onClick={onClick} disabled={disabled}>{children}</button>
),
DirectorySearch: ({onSelect, value}) => (
<input data-testid="directory-search" value={value || ''} onChange={e => onSelect && onSelect(e.target.value)}/>
),
};
});
// Mock Settings.js components
jest.mock('./Settings', () => ({
RestartButton: () => <button data-testid="restart-button">Restart</button>,
ShutdownButton: () => <button data-testid="shutdown-button">Shutdown</button>,
}));
const defaultTheme = {
t: {style: {}},
inverted: false,
};
const defaultSettings = {
settings: {},
saveSettings: jest.fn(),
fetchSettings: jest.fn(),
};
const renderControllerPage = () => {
return render(
<BrowserRouter>
<ThemeContext.Provider value={defaultTheme}>
<SettingsContext.Provider value={defaultSettings}>
<ControllerPage/>
</SettingsContext.Provider>
</ThemeContext.Provider>
</BrowserRouter>
);
};
describe('ControllerPage', () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset useDockerized to false for regular tests
const {useDockerized} = require('../../hooks/customHooks');
useDockerized.mockReturnValue(false);
// resetMocks clears module-factory implementations, so configure hotspot mocks here.
const controllerApi = require('../../api/controller');
controllerApi.getHotspotSettings.mockResolvedValue(
{device: 'wlan0', ssid: 'WROLPi', password: 'wrolpi hotspot', protocol: 'wpa2'});
controllerApi.getHotspotDevices.mockResolvedValue({devices: ['wlan0']});
controllerApi.getHotspotProtocols.mockResolvedValue({device: 'wlan0', protocols: ['wpa2', 'wpa3']});
controllerApi.updateHotspotSettings.mockResolvedValue(
{device: 'wlan0', ssid: 'WROLPi', password: 'wrolpi hotspot', protocol: 'wpa2'});
});
test('renders Services section', async () => {
renderControllerPage();
await waitFor(() => {
expect(screen.getByText('Services')).toBeInTheDocument();
});
});
test('renders Disk Management section', async () => {
renderControllerPage();
await waitFor(() => {
expect(screen.getByText('Disk Management')).toBeInTheDocument();
});
});
test('renders Hardware Controls section', async () => {
renderControllerPage();
await waitFor(() => {
expect(screen.getByText('Hardware Controls')).toBeInTheDocument();
});
expect(screen.getByTestId('hotspot-toggle')).toBeInTheDocument();
expect(screen.getByTestId('bluetooth-toggle')).toBeInTheDocument();
expect(screen.getByTestId('desktop-toggle')).toBeInTheDocument();
expect(screen.getByTestId('throttle-toggle')).toBeInTheDocument();
});
test('calls getServices on mount', async () => {
const {getServices} = require('../../api/controller');
renderControllerPage();
// Verify getServices was called
await waitFor(() => {
expect(getServices).toHaveBeenCalled();
});
});
test('shows Restart and Shutdown buttons in non-Docker mode', async () => {
renderControllerPage();
await waitFor(() => {
expect(screen.getByTestId('restart-button')).toBeInTheDocument();
expect(screen.getByTestId('shutdown-button')).toBeInTheDocument();
});
});
test('hotspot protocol dropdown lists the protocols supported by the device', async () => {
const {getHotspotProtocols} = require('../../api/controller');
renderControllerPage();
await waitFor(() => {
expect(getHotspotProtocols).toHaveBeenCalledWith('wlan0');
});
expect(screen.getByText('Hotspot Protocol')).toBeInTheDocument();
// The saved protocol is selected; WPA3 is offered because the device supports it.
// Semantic UI renders the selected value as both the dropdown text and an option.
await waitFor(() => {
expect(screen.getAllByText('WPA2 (most compatible)').length).toBeGreaterThan(0);
expect(screen.getAllByText('WPA3').length).toBeGreaterThan(0);
});
});
test('a saved protocol the device does not support is reset to a supported one', async () => {
const controllerApi = require('../../api/controller');
controllerApi.getHotspotSettings.mockResolvedValue(
{device: 'wlan0', ssid: 'WROLPi', password: 'wrolpi hotspot', protocol: 'wpa3'});
controllerApi.getHotspotProtocols.mockResolvedValue({device: 'wlan0', protocols: ['wpa2']});
renderControllerPage();
// The stale WPA3 selection is replaced by the only protocol the device supports.
await waitFor(() => {
expect(screen.getAllByText('WPA2 (most compatible)').length).toBeGreaterThan(0);
});
expect(screen.queryByText('WPA3')).not.toBeInTheDocument();
});
test('hotspot protocol dropdown only offers WPA2 on a Raspberry Pi', async () => {
const {getHotspotProtocols} = require('../../api/controller');
getHotspotProtocols.mockResolvedValue({device: 'wlan0', protocols: ['wpa2']});
renderControllerPage();
await waitFor(() => {
expect(getHotspotProtocols).toHaveBeenCalledWith('wlan0');
});
await waitFor(() => {
expect(screen.getAllByText('WPA2 (most compatible)').length).toBeGreaterThan(0);
});
expect(screen.queryByText('WPA3')).not.toBeInTheDocument();
});
});
describe('DiskSection primary unmount', () => {
const controllerApi = require('../../api/controller');
beforeEach(() => {
controllerApi.getDisks.mockResolvedValue([
{
name: 'sda1',
path: '/dev/sda1',
size: '500G',
fstype: 'ext4',
label: 'wrolpi',
mountpoint: '/media/wrolpi',
uuid: 'abc-123',
},
]);
controllerApi.unmountDisk.mockClear();
});
afterEach(() => {
controllerApi.getDisks.mockResolvedValue([]);
});
test('primary mount shows Unmount button; confirming warns and unmounts', async () => {
renderControllerPage();
// The primary mount is no longer labeled "System"; it gets Unmount.
const unmountButton = await screen.findByText('Unmount');
fireEvent.click(unmountButton);
// The confirm modal warns that the API service will be stopped.
await waitFor(() => {
expect(screen.getByText(/API service will/)).toBeInTheDocument();
});
// Semantic's Confirm renders confirmButton='Unmount' as the last match.
const buttons = screen.getAllByText('Unmount');
fireEvent.click(buttons[buttons.length - 1]);
await waitFor(() => {
expect(controllerApi.unmountDisk).toHaveBeenCalledWith('/media/wrolpi');
});
});
});
describe('groupServices', () => {
const {groupServices} = require('./ControllerPage');
test('splits core from optional by group field', () => {
const {core, optional} = groupServices([
{name: 'wrolpi-api', status: 'running', group: 'core'},
{name: 'smbd', status: 'stopped', group: 'optional'},
{name: 'wrolpi-repair', status: 'stopped', group: 'optional'},
]);
expect(core.map(s => s.name)).toEqual(['wrolpi-api']);
expect(optional.map(s => s.name)).toEqual(['smbd', 'wrolpi-repair']);
});
test('treats a missing group (Docker containers) as core', () => {
const {core, optional} = groupServices([
{name: 'api', status: 'running'},
{name: 'db', status: 'running'},
]);
expect(core).toHaveLength(2);
expect(optional).toHaveLength(0);
});
test('sorts problems first, then alphabetically, within a group', () => {
const {core} = groupServices([
{name: 'caddy', status: 'running', group: 'core'},
{name: 'wrolpi-api', status: 'failed', group: 'core'},
{name: 'wrolpi-app', status: 'running', group: 'core'},
]);
expect(core.map(s => s.name)).toEqual(['wrolpi-api', 'caddy', 'wrolpi-app']);
});
});
describe('ControllerPage in Docker mode', () => {
beforeEach(() => {
jest.clearAllMocks();
const {useDockerized} = require('../../hooks/customHooks');
useDockerized.mockReturnValue(true);
});
test('hides reboot/shutdown buttons in Docker mode', async () => {
renderControllerPage();
await waitFor(() => {
expect(screen.getByText('Hardware Controls')).toBeInTheDocument();
});
// Shutdown and Restart buttons should not be visible
expect(screen.queryByTestId('shutdown-button')).not.toBeInTheDocument();
expect(screen.queryByTestId('restart-button')).not.toBeInTheDocument();
});
test('shows disk management unavailable message in Docker mode', async () => {
renderControllerPage();
await waitFor(() => {
expect(screen.getByText('Disk management is not available in Docker environments.')).toBeInTheDocument();
});
});
});