2025-12-11 18:32:55 -07:00
|
|
|
import React from 'react';
|
ui: migrate ControllerPage.js off Semantic UI onto the token component library
Replaces semantic-ui-react and Theme.tsx imports with components from
components/ui (Panel, Table, Modal, Confirm, Status, Select, TextInput,
Checkbox, IconButton, Message). Preserves every confirmation, disabled
state, and polling interval; service status now maps onto Status'
complete/active/pending/failed kinds instead of a raw color. Repairs
ControllerPage.test.js to render inside MantineProvider and to find the
unmount Confirm button by dialog role instead of DOM-order position,
since Mantine portals the modal ahead of the page content. Drops
ControllerPage.js from semantic-baseline.json now that it's migrated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:23:59 -06:00
|
|
|
import {fireEvent, render, screen, waitFor, within} from '@testing-library/react';
|
2025-12-11 18:32:55 -07:00
|
|
|
import {BrowserRouter} from 'react-router';
|
ui: migrate ControllerPage.js off Semantic UI onto the token component library
Replaces semantic-ui-react and Theme.tsx imports with components from
components/ui (Panel, Table, Modal, Confirm, Status, Select, TextInput,
Checkbox, IconButton, Message). Preserves every confirmation, disabled
state, and polling interval; service status now maps onto Status'
complete/active/pending/failed kinds instead of a raw color. Repairs
ControllerPage.test.js to render inside MantineProvider and to find the
unmount Confirm button by dialog role instead of DOM-order position,
since Mantine portals the modal ahead of the page content. Drops
ControllerPage.js from semantic-baseline.json now that it's migrated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:23:59 -06:00
|
|
|
import {MantineProvider} from '@mantine/core';
|
2025-12-11 18:32:55 -07:00
|
|
|
import {ThemeContext, SettingsContext} from '../../contexts/contexts';
|
ui: migrate ControllerPage.js off Semantic UI onto the token component library
Replaces semantic-ui-react and Theme.tsx imports with components from
components/ui (Panel, Table, Modal, Confirm, Status, Select, TextInput,
Checkbox, IconButton, Message). Preserves every confirmation, disabled
state, and polling interval; service status now maps onto Status'
complete/active/pending/failed kinds instead of a raw color. Repairs
ControllerPage.test.js to render inside MantineProvider and to find the
unmount Confirm button by dialog role instead of DOM-order position,
since Mantine portals the modal ahead of the page content. Drops
ControllerPage.js from semantic-baseline.json now that it's migrated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:23:59 -06:00
|
|
|
import {cssVariablesResolver, mantineTheme} from '../../themes/mantine';
|
2025-12-11 18:32:55 -07:00
|
|
|
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,
|
controller: group services into Core vs Optional & Maintenance
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.
Services are now split into two groups computed once in the backend and
exposed as `service.group`:
* Core Services - persistent services enabled at boot (api, app, caddy,
kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
upgrade, *-dev, discovered wrolpi-*), plus persistent
services the user has disabled at boot (e.g. Samba with
no shares configured).
Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.
Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:41:17 -06:00
|
|
|
kind: 'persistent',
|
|
|
|
|
group: 'core',
|
2025-12-11 18:32:55 -07:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: 'wrolpi-app',
|
|
|
|
|
port: 3000,
|
|
|
|
|
status: 'running',
|
|
|
|
|
viewable: true,
|
|
|
|
|
view_path: '',
|
|
|
|
|
enabled: true,
|
controller: group services into Core vs Optional & Maintenance
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.
Services are now split into two groups computed once in the backend and
exposed as `service.group`:
* Core Services - persistent services enabled at boot (api, app, caddy,
kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
upgrade, *-dev, discovered wrolpi-*), plus persistent
services the user has disabled at boot (e.g. Samba with
no shares configured).
Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.
Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:41:17 -06:00
|
|
|
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',
|
2025-12-11 18:32:55 -07:00
|
|
|
},
|
|
|
|
|
]),
|
|
|
|
|
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([]),
|
2026-07-23 19:09:23 -06:00
|
|
|
getFstabEntries: jest.fn().mockResolvedValue([]),
|
2025-12-11 18:32:55 -07:00
|
|
|
getSmartStatus: jest.fn().mockResolvedValue({}),
|
2026-07-23 19:09:23 -06:00
|
|
|
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}),
|
2025-12-11 18:32:55 -07:00
|
|
|
restartServices: jest.fn().mockResolvedValue({success: true}),
|
2026-03-28 16:28:47 -06:00
|
|
|
getSambaStatus: jest.fn().mockResolvedValue({enabled: false, available: true, shares: []}),
|
2026-07-28 19:52:39 -06:00
|
|
|
getHotspotSettings: jest.fn(),
|
|
|
|
|
getHotspotDevices: jest.fn(),
|
|
|
|
|
getHotspotProtocols: jest.fn(),
|
|
|
|
|
updateHotspotSettings: jest.fn(),
|
2026-03-28 16:28:47 -06:00
|
|
|
addSambaShare: jest.fn().mockResolvedValue({success: true}),
|
|
|
|
|
removeSambaShare: jest.fn().mockResolvedValue({success: true}),
|
2025-12-11 18:32:55 -07:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
2026-03-28 16:28:47 -06:00
|
|
|
// Mock hooks
|
2025-12-11 18:32:55 -07:00
|
|
|
jest.mock('../../hooks/customHooks', () => ({
|
|
|
|
|
useDockerized: jest.fn().mockReturnValue(false),
|
2026-03-28 16:28:47 -06:00
|
|
|
useMediaDirectory: jest.fn().mockReturnValue('/media/wrolpi'),
|
2026-03-28 10:09:58 -06:00
|
|
|
useBluetooth: jest.fn().mockReturnValue({on: false, setBluetooth: jest.fn()}),
|
2026-07-29 14:57:28 -06:00
|
|
|
useDesktop: jest.fn().mockReturnValue({on: true, setDesktop: jest.fn()}),
|
controller: VNC toggle, gated on the Desktop running
Add a VNC toggle to the Controller (both UIs) that starts/stops whichever VNC
backend is installed: wayvnc for Wayland (Raspberry Pi OS), RealVNC for X11.
Runtime-only, like the Desktop toggle: the unit is never enabled, so VNC does
not return after a reboot. Users who want it permanently on can enable it with
raspi-config.
VNC serves the desktop session, so starting it without a desktop gives clients
a blank screen. The gate lives in the backend, not just the UI, so curl callers
get it too (409, distinct from a systemctl failure). Stopping is never gated so
a running VNC server can't be stranded on with no way to turn it off.
Backend selection deliberately avoids raspi-config's live-compositor probe
(pgrep labwc), which reports X11 — and would pick the wrong backend — whenever
the desktop is stopped. wayvnc also requires its config to exist; that is
checked directly rather than via the unit's ConditionResult, which reports 'no'
for any unit that has never been started.
Extracts _unit_properties()/_systemctl_unit() so Desktop and VNC share one
systemd probe and start/stop path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 16:53:45 -06:00
|
|
|
useVnc: jest.fn().mockReturnValue({on: false, desktopRunning: true, setVnc: jest.fn()}),
|
2025-12-11 18:32:55 -07:00
|
|
|
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,
|
2026-03-28 10:09:58 -06:00
|
|
|
BluetoothToggle: () => <div data-testid="bluetooth-toggle">BluetoothToggle</div>,
|
2026-07-29 14:57:28 -06:00
|
|
|
DesktopToggle: () => <div data-testid="desktop-toggle">DesktopToggle</div>,
|
controller: VNC toggle, gated on the Desktop running
Add a VNC toggle to the Controller (both UIs) that starts/stops whichever VNC
backend is installed: wayvnc for Wayland (Raspberry Pi OS), RealVNC for X11.
Runtime-only, like the Desktop toggle: the unit is never enabled, so VNC does
not return after a reboot. Users who want it permanently on can enable it with
raspi-config.
VNC serves the desktop session, so starting it without a desktop gives clients
a blank screen. The gate lives in the backend, not just the UI, so curl callers
get it too (409, distinct from a systemctl failure). Stopping is never gated so
a running VNC server can't be stranded on with no way to turn it off.
Backend selection deliberately avoids raspi-config's live-compositor probe
(pgrep labwc), which reports X11 — and would pick the wrong backend — whenever
the desktop is stopped. wayvnc also requires its config to exist; that is
checked directly rather than via the unit's ConditionResult, which reports 'no'
for any unit that has never been started.
Extracts _unit_properties()/_systemctl_unit() so Desktop and VNC share one
systemd probe and start/stop path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 16:53:45 -06:00
|
|
|
VncToggle: () => <div data-testid="vnc-toggle">VncToggle</div>,
|
2025-12-11 18:32:55 -07:00
|
|
|
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>
|
|
|
|
|
),
|
2026-03-28 16:28:47 -06:00
|
|
|
DirectorySearch: ({onSelect, value}) => (
|
|
|
|
|
<input data-testid="directory-search" value={value || ''} onChange={e => onSelect && onSelect(e.target.value)}/>
|
|
|
|
|
),
|
2025-12-11 18:32:55 -07:00
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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>
|
ui: migrate ControllerPage.js off Semantic UI onto the token component library
Replaces semantic-ui-react and Theme.tsx imports with components from
components/ui (Panel, Table, Modal, Confirm, Status, Select, TextInput,
Checkbox, IconButton, Message). Preserves every confirmation, disabled
state, and polling interval; service status now maps onto Status'
complete/active/pending/failed kinds instead of a raw color. Repairs
ControllerPage.test.js to render inside MantineProvider and to find the
unmount Confirm button by dialog role instead of DOM-order position,
since Mantine portals the modal ahead of the page content. Drops
ControllerPage.js from semantic-baseline.json now that it's migrated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:23:59 -06:00
|
|
|
<MantineProvider theme={mantineTheme} cssVariablesResolver={cssVariablesResolver}>
|
|
|
|
|
<ThemeContext.Provider value={defaultTheme}>
|
|
|
|
|
<SettingsContext.Provider value={defaultSettings}>
|
|
|
|
|
<ControllerPage/>
|
|
|
|
|
</SettingsContext.Provider>
|
|
|
|
|
</ThemeContext.Provider>
|
|
|
|
|
</MantineProvider>
|
2025-12-11 18:32:55 -07:00
|
|
|
</BrowserRouter>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
describe('ControllerPage', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
jest.clearAllMocks();
|
|
|
|
|
// Reset useDockerized to false for regular tests
|
|
|
|
|
const {useDockerized} = require('../../hooks/customHooks');
|
|
|
|
|
useDockerized.mockReturnValue(false);
|
2026-07-28 19:52:39 -06:00
|
|
|
// 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'});
|
2025-12-11 18:32:55 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
2026-07-29 14:57:28 -06:00
|
|
|
expect(screen.getByTestId('hotspot-toggle')).toBeInTheDocument();
|
|
|
|
|
expect(screen.getByTestId('bluetooth-toggle')).toBeInTheDocument();
|
|
|
|
|
expect(screen.getByTestId('desktop-toggle')).toBeInTheDocument();
|
controller: VNC toggle, gated on the Desktop running
Add a VNC toggle to the Controller (both UIs) that starts/stops whichever VNC
backend is installed: wayvnc for Wayland (Raspberry Pi OS), RealVNC for X11.
Runtime-only, like the Desktop toggle: the unit is never enabled, so VNC does
not return after a reboot. Users who want it permanently on can enable it with
raspi-config.
VNC serves the desktop session, so starting it without a desktop gives clients
a blank screen. The gate lives in the backend, not just the UI, so curl callers
get it too (409, distinct from a systemctl failure). Stopping is never gated so
a running VNC server can't be stranded on with no way to turn it off.
Backend selection deliberately avoids raspi-config's live-compositor probe
(pgrep labwc), which reports X11 — and would pick the wrong backend — whenever
the desktop is stopped. wayvnc also requires its config to exist; that is
checked directly rather than via the unit's ConditionResult, which reports 'no'
for any unit that has never been started.
Extracts _unit_properties()/_systemctl_unit() so Desktop and VNC share one
systemd probe and start/stop path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 16:53:45 -06:00
|
|
|
expect(screen.getByTestId('vnc-toggle')).toBeInTheDocument();
|
2026-07-29 14:57:28 -06:00
|
|
|
expect(screen.getByTestId('throttle-toggle')).toBeInTheDocument();
|
2025-12-11 18:32:55 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|
controller: group services into Core vs Optional & Maintenance
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.
Services are now split into two groups computed once in the backend and
exposed as `service.group`:
* Core Services - persistent services enabled at boot (api, app, caddy,
kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
upgrade, *-dev, discovered wrolpi-*), plus persistent
services the user has disabled at boot (e.g. Samba with
no shares configured).
Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.
Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:41:17 -06:00
|
|
|
|
2026-07-28 19:52:39 -06:00
|
|
|
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.
|
ui: the last of Semantic UI, in name and in dead CSS
The library has been uninstalled for some time, but the codebase still read as
though it were there: identifiers named after it, rules written against its
class names, and about 180 comments explaining a rule in terms of a thing that
is no longer installed.
Renamed, keeping the vocabulary the app actually uses: semanticUIColorMap ->
navColorHexMap, semanticColorNames -> paletteColorNames, semanticNames ->
iconNameAliases, semanticSizes -> sizeAliases, and SemanticUIColors ->
NavColors in wrolpi/schema.py, which is what validates nav_color on the way in.
ColorToSemanticHexColor had no callers at all and is gone.
The baseline ratchet has done its job -- the list reached zero, so the JSON is
deleted and the guard keeps only the checks with teeth: no package, no import,
no `className="ui "`, no toast library. Its Theme.tsx check went with the
wrappers it was watching for. It also asserts its own walk found files, so a
clean result cannot mean it scanned nothing.
Deleted from App.css: every rule requiring `.ui`, `.inverted`, `.segment` or
`.modal`, none of which any component has set since the stylesheet stopped
loading, plus the form skeletons, ui-alerts and progress-text rules that
nothing renders. Two of them were doing harm rather than nothing: the drag
tints had a dark-theme variant under `.inverted` that stopped applying, leaving
15% of a fixed blue over a dark row, and now mix a token instead.
Two selectors naming markup that no longer exists were live bugs. The file
browser's filter had `.ui.input.file-browser-filter`, so it rendered at the
browser default width on a bar laid out to give it 34em -- now qualified by its
container, because `.wrolpi-action-input` sets `width: 100%` at the same
specificity and is loaded later. And SearchBox takes named props rather than a
rest spread, so the `id` DestinationForm hands it was dropped in silence and
the label it renders pointed at nothing; the download forms' Destination field
has had no associated label since the migration.
"Semantic roles" survives in four places, where it means what it says.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:15:57 -06:00
|
|
|
// The select renders its chosen value as both the field's text and an option.
|
2026-07-28 19:52:39 -06:00
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getAllByText('WPA2 (most compatible)').length).toBeGreaterThan(0);
|
|
|
|
|
expect(screen.getAllByText('WPA3').length).toBeGreaterThan(0);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-28 20:59:10 -06:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-28 19:52:39 -06:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
controller: group services into Core vs Optional & Maintenance
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.
Services are now split into two groups computed once in the backend and
exposed as `service.group`:
* Core Services - persistent services enabled at boot (api, app, caddy,
kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
upgrade, *-dev, discovered wrolpi-*), plus persistent
services the user has disabled at boot (e.g. Samba with
no shares configured).
Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.
Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:41:17 -06:00
|
|
|
});
|
|
|
|
|
|
2026-07-23 19:09:23 -06:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
ui: migrate ControllerPage.js off Semantic UI onto the token component library
Replaces semantic-ui-react and Theme.tsx imports with components from
components/ui (Panel, Table, Modal, Confirm, Status, Select, TextInput,
Checkbox, IconButton, Message). Preserves every confirmation, disabled
state, and polling interval; service status now maps onto Status'
complete/active/pending/failed kinds instead of a raw color. Repairs
ControllerPage.test.js to render inside MantineProvider and to find the
unmount Confirm button by dialog role instead of DOM-order position,
since Mantine portals the modal ahead of the page content. Drops
ControllerPage.js from semantic-baseline.json now that it's migrated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:23:59 -06:00
|
|
|
// The Confirm modal is portaled to the end of <body>, so it renders
|
|
|
|
|
// *before* the page content in DOM order -- scope the query to the
|
|
|
|
|
// dialog rather than assuming which match comes last.
|
|
|
|
|
const dialog = screen.getByRole('dialog');
|
|
|
|
|
fireEvent.click(within(dialog).getByText('Unmount'));
|
2026-07-23 19:09:23 -06:00
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(controllerApi.unmountDisk).toHaveBeenCalledWith('/media/wrolpi');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
controller: group services into Core vs Optional & Maintenance
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.
Services are now split into two groups computed once in the backend and
exposed as `service.group`:
* Core Services - persistent services enabled at boot (api, app, caddy,
kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
upgrade, *-dev, discovered wrolpi-*), plus persistent
services the user has disabled at boot (e.g. Samba with
no shares configured).
Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.
Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:41:17 -06:00
|
|
|
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']);
|
|
|
|
|
});
|
2025-12-11 18:32:55 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|