committed by
GitHub
42 changed files with 1799 additions and 1299 deletions
@ -1,2 +0,0 @@ |
|||
dist/build.tar |
|||
dist/output |
|||
@ -1,10 +0,0 @@ |
|||
FROM nginx:1.27.2-alpine |
|||
|
|||
RUN rm -r /usr/share/nginx/html \ |
|||
&& mkdir /usr/share/nginx/html |
|||
|
|||
WORKDIR /usr/share/nginx/html |
|||
|
|||
ADD dist . |
|||
|
|||
CMD nginx -g "daemon off;" |
|||
File diff suppressed because it is too large
@ -0,0 +1,2 @@ |
|||
../dist/build.tar |
|||
../dist/output |
|||
@ -0,0 +1,15 @@ |
|||
FROM nginx:1.27-alpine |
|||
|
|||
RUN rm -r /usr/share/nginx/html \ |
|||
&& mkdir -p /usr/share/nginx/html \ |
|||
&& mkdir -p /etc/nginx/conf.d |
|||
|
|||
WORKDIR /usr/share/nginx/html |
|||
|
|||
ADD dist . |
|||
|
|||
COPY ./infra/default.conf /etc/nginx/conf.d/default.conf |
|||
|
|||
EXPOSE 8080 |
|||
|
|||
CMD ["nginx", "-g", "daemon off;"] |
|||
@ -0,0 +1,42 @@ |
|||
server { |
|||
listen 8080; |
|||
server_name localhost; |
|||
|
|||
root /usr/share/nginx/html; |
|||
index index.html index.htm; |
|||
|
|||
location / { |
|||
try_files $uri $uri/ =404; |
|||
} |
|||
|
|||
error_page 500 502 503 504 /50x.html; |
|||
location = /50x.html { |
|||
internal; |
|||
} |
|||
|
|||
location ~ /\.ht { |
|||
deny all; |
|||
} |
|||
|
|||
gzip on; |
|||
gzip_disable "msie6"; |
|||
|
|||
gzip_vary on; |
|||
gzip_proxied any; |
|||
gzip_comp_level 6; |
|||
gzip_buffers 16 8k; |
|||
gzip_http_version 1.1; |
|||
gzip_types |
|||
text/plain |
|||
text/css |
|||
text/xml |
|||
text/javascript |
|||
application/javascript |
|||
application/x-javascript |
|||
application/json |
|||
application/xml |
|||
application/xml+rss |
|||
font/ttf |
|||
font/otf |
|||
image/svg+xml; |
|||
} |
|||
@ -1,6 +1,6 @@ |
|||
{ |
|||
"name": "meshtastic-web", |
|||
"version": "2.3.3-0", |
|||
"version": "2.6.0-0", |
|||
"type": "module", |
|||
"description": "Meshtastic web client", |
|||
"license": "GPL-3.0-only", |
|||
@ -34,11 +34,12 @@ |
|||
}, |
|||
"homepage": "https://meshtastic.org", |
|||
"dependencies": { |
|||
"@bufbuild/protobuf": "^2.2.3", |
|||
"@meshtastic/core": "npm:@jsr/[email protected]", |
|||
"@meshtastic/core": "npm:@jsr/[email protected]", |
|||
"@meshtastic/js": "npm:@jsr/[email protected]", |
|||
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http", |
|||
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth", |
|||
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial", |
|||
"@bufbuild/protobuf": "^2.2.5", |
|||
"@noble/curves": "^1.8.1", |
|||
"@radix-ui/react-accordion": "^1.2.3", |
|||
"@radix-ui/react-checkbox": "^1.1.4", |
|||
@ -59,49 +60,50 @@ |
|||
"class-validator": "^0.14.1", |
|||
"class-variance-authority": "^0.7.1", |
|||
"clsx": "^2.1.1", |
|||
"cmdk": "^1.0.4", |
|||
"cmdk": "^1.1.1", |
|||
"crypto-random-string": "^5.0.0", |
|||
"immer": "^10.1.1", |
|||
"js-cookie": "^3.0.5", |
|||
"lucide-react": "^0.477.0", |
|||
"maplibre-gl": "5.1.1", |
|||
"react": "^19.0.0", |
|||
"react-dom": "^19.0.0", |
|||
"lucide-react": "^0.486.0", |
|||
"maplibre-gl": "5.3.0", |
|||
"react": "^19.1.0", |
|||
"react-dom": "^19.1.0", |
|||
"react-error-boundary": "^5.0.0", |
|||
"react-hook-form": "^7.54.2", |
|||
"react-map-gl": "8.0.1", |
|||
"react-hook-form": "^7.55.0", |
|||
"react-map-gl": "8.0.2", |
|||
"react-qrcode-logo": "^3.0.0", |
|||
"rfc4648": "^1.5.4", |
|||
"vite-plugin-node-polyfills": "^0.23.0", |
|||
"zod": "^3.24.2", |
|||
"zustand": "5.0.3" |
|||
}, |
|||
"devDependencies": { |
|||
"@tailwindcss/postcss": "^4.0.9", |
|||
"@tailwindcss/postcss": "^4.1.0", |
|||
"@testing-library/jest-dom": "^6.6.3", |
|||
"@testing-library/react": "^16.2.0", |
|||
"@testing-library/user-event": "^14.6.1", |
|||
"@types/chrome": "^0.0.307", |
|||
"@types/chrome": "^0.0.313", |
|||
"@types/js-cookie": "^3.0.6", |
|||
"@types/node": "^22.13.7", |
|||
"@types/react": "^19.0.10", |
|||
"@types/node": "^22.13.17", |
|||
"@types/react": "^19.0.12", |
|||
"@types/react-dom": "^19.0.4", |
|||
"@types/serviceworker": "^0.0.123", |
|||
"@types/serviceworker": "^0.0.127", |
|||
"@types/w3c-web-serial": "^1.0.8", |
|||
"@types/web-bluetooth": "^0.0.21", |
|||
"@vitejs/plugin-react": "^4.3.4", |
|||
"autoprefixer": "^10.4.20", |
|||
"gzipper": "^8.2.0", |
|||
"happy-dom": "^17.2.2", |
|||
"autoprefixer": "^10.4.21", |
|||
"gzipper": "^8.2.1", |
|||
"happy-dom": "^17.4.4", |
|||
"postcss": "^8.5.3", |
|||
"simple-git-hooks": "^2.11.1", |
|||
"tailwind-merge": "^3.0.2", |
|||
"tailwindcss": "^4.0.9", |
|||
"simple-git-hooks": "^2.12.1", |
|||
"tailwind-merge": "^3.1.0", |
|||
"tailwindcss": "^4.1.0", |
|||
"tailwindcss-animate": "^1.0.7", |
|||
"tar": "^7.4.3", |
|||
"testing-library": "^0.0.2", |
|||
"typescript": "^5.8.2", |
|||
"vite": "^6.2.0", |
|||
"vitest": "^3.0.7", |
|||
"vite-plugin-pwa": "^0.21.1" |
|||
"vite": "^6.2.4", |
|||
"vitest": "^3.1.1", |
|||
"vite-plugin-pwa": "^1.0.0" |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,114 @@ |
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; |
|||
import { RebootOTADialog } from './RebootOTADialog.tsx'; |
|||
import { ReactNode } from "react"; |
|||
|
|||
const rebootOtaMock = vi.fn(); |
|||
let mockConnection: { rebootOta: (delay: number) => void } | undefined = { |
|||
rebootOta: rebootOtaMock, |
|||
}; |
|||
|
|||
vi.mock('@core/stores/deviceStore.ts', () => ({ |
|||
useDevice: () => ({ |
|||
connection: mockConnection, |
|||
}), |
|||
})); |
|||
|
|||
vi.mock('@components/UI/Button.tsx', async () => { |
|||
const actual = await vi.importActual('@components/UI/Button.tsx'); |
|||
return { |
|||
...actual, |
|||
Button: (props: any) => <button {...props} />, |
|||
}; |
|||
}); |
|||
|
|||
vi.mock('@components/UI/Input.tsx', async () => { |
|||
const actual = await vi.importActual('@components/UI/Input.tsx'); |
|||
return { |
|||
...actual, |
|||
Input: (props: any) => <input {...props} />, |
|||
}; |
|||
}); |
|||
|
|||
vi.mock('@components/UI/Dialog.tsx', () => { |
|||
return { |
|||
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>, |
|||
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>, |
|||
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>, |
|||
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>, |
|||
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>, |
|||
DialogClose: () => null, |
|||
}; |
|||
}); |
|||
|
|||
|
|||
describe('RebootOTADialog', () => { |
|||
beforeEach(() => { |
|||
vi.useFakeTimers(); |
|||
rebootOtaMock.mockClear(); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
vi.useRealTimers(); |
|||
}); |
|||
|
|||
it('renders dialog with default input value', () => { |
|||
render(<RebootOTADialog open={true} onOpenChange={() => { }} />); |
|||
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5); |
|||
expect(screen.getByText(/schedule reboot/i)).toBeInTheDocument(); |
|||
expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it('schedules a reboot with delay and calls rebootOta', async () => { |
|||
const onOpenChangeMock = vi.fn(); |
|||
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />); |
|||
|
|||
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), { |
|||
target: { value: '3' }, |
|||
}); |
|||
|
|||
fireEvent.click(screen.getByText(/schedule reboot/i)); |
|||
|
|||
expect(screen.getByText(/reboot has been scheduled/i)).toBeInTheDocument(); |
|||
|
|||
vi.advanceTimersByTime(3000); |
|||
|
|||
await waitFor(() => { |
|||
expect(rebootOtaMock).toHaveBeenCalledWith(0); |
|||
expect(onOpenChangeMock).toHaveBeenCalledWith(false); |
|||
}); |
|||
}); |
|||
|
|||
it('triggers an instant reboot', async () => { |
|||
const onOpenChangeMock = vi.fn(); |
|||
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />); |
|||
|
|||
fireEvent.click(screen.getByText(/reboot to ota mode now/i)); |
|||
|
|||
await waitFor(() => { |
|||
expect(rebootOtaMock).toHaveBeenCalledWith(5); |
|||
expect(onOpenChangeMock).toHaveBeenCalledWith(false); |
|||
}); |
|||
}); |
|||
|
|||
it('does not call reboot if connection is undefined', async () => { |
|||
const onOpenChangeMock = vi.fn(); |
|||
|
|||
// simulate no connection
|
|||
mockConnection = undefined; |
|||
|
|||
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />); |
|||
|
|||
fireEvent.click(screen.getByText(/schedule reboot/i)); |
|||
vi.advanceTimersByTime(5000); |
|||
|
|||
await waitFor(() => { |
|||
expect(rebootOtaMock).not.toHaveBeenCalled(); |
|||
expect(onOpenChangeMock).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
// reset connection for other tests
|
|||
mockConnection = { rebootOta: rebootOtaMock }; |
|||
}); |
|||
|
|||
}); |
|||
@ -0,0 +1,104 @@ |
|||
import { useState } from "react"; |
|||
import { ClockIcon, RefreshCwIcon } from "lucide-react"; |
|||
import { Button } from "@components/UI/Button.tsx"; |
|||
import { |
|||
Dialog, |
|||
DialogClose, |
|||
DialogContent, |
|||
DialogDescription, |
|||
DialogHeader, |
|||
DialogTitle, |
|||
} from "@components/UI/Dialog.tsx"; |
|||
import { Input } from "@components/UI/Input.tsx"; |
|||
import { useDevice } from "@core/stores/deviceStore.ts"; |
|||
|
|||
export interface RebootOTADialogProps { |
|||
open: boolean; |
|||
onOpenChange: (open: boolean) => void; |
|||
} |
|||
|
|||
const DEFAULT_REBOOT_DELAY = 5; // seconds
|
|||
|
|||
export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) => { |
|||
const { connection } = useDevice(); |
|||
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY); |
|||
const [isScheduled, setIsScheduled] = useState(false); |
|||
const [inputValue, setInputValue] = useState(DEFAULT_REBOOT_DELAY.toString()); |
|||
|
|||
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => { |
|||
if (!e.target.validity.valid) { |
|||
e.preventDefault(); |
|||
return |
|||
}; |
|||
|
|||
const val = e.target.value; |
|||
setInputValue(val); |
|||
|
|||
const parsed = Number(val); |
|||
if (!isNaN(parsed) && parsed > 0) { |
|||
setTime(parsed); |
|||
} |
|||
}; |
|||
|
|||
const handleRebootWithTimeout = async () => { |
|||
if (!connection) return; |
|||
setIsScheduled(true); |
|||
|
|||
const delay = time > 0 ? time : DEFAULT_REBOOT_DELAY; |
|||
|
|||
await new Promise<void>((resolve) => { |
|||
setTimeout(() => { |
|||
console.log("Rebooting..."); |
|||
resolve(); |
|||
}, delay * 1000); |
|||
}).finally(() => { |
|||
setIsScheduled(false); |
|||
onOpenChange(false); |
|||
setInputValue(DEFAULT_REBOOT_DELAY.toString()); |
|||
}); |
|||
connection.rebootOta(0); |
|||
}; |
|||
|
|||
const handleInstantReboot = async () => { |
|||
if (!connection) return; |
|||
|
|||
await connection.rebootOta(DEFAULT_REBOOT_DELAY); |
|||
onOpenChange(false); |
|||
}; |
|||
|
|||
return ( |
|||
<Dialog open={open} onOpenChange={onOpenChange}> |
|||
<DialogContent> |
|||
<DialogClose /> |
|||
<DialogHeader> |
|||
<DialogTitle>Reboot to OTA Mode</DialogTitle> |
|||
<DialogDescription> |
|||
Reboot the connected node after a delay into OTA (Over-the-Air) mode. |
|||
</DialogDescription> |
|||
</DialogHeader> |
|||
|
|||
<div className="flex gap-2 p-2 items-center relative"> |
|||
<Input |
|||
type="number" |
|||
min={1} |
|||
max={86400} |
|||
className="dark:text-slate-900 appearance-none" |
|||
value={inputValue} |
|||
onChange={handleSetTime} |
|||
placeholder="Enter delay (sec)" |
|||
/> |
|||
<Button onClick={() => handleRebootWithTimeout()} className="w-9/12"> |
|||
<ClockIcon className="mr-2" size={18} /> |
|||
{isScheduled ? 'Reboot has been scheduled' : 'Schedule Reboot'} |
|||
</Button> |
|||
</div> |
|||
|
|||
<Button variant="destructive" onClick={() => handleInstantReboot()}> |
|||
<RefreshCwIcon className="mr-2" size={16} /> |
|||
Reboot to OTA Mode Now |
|||
</Button> |
|||
</DialogContent> |
|||
</Dialog> |
|||
); |
|||
}; |
|||
|
|||
@ -0,0 +1,283 @@ |
|||
// import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|||
// import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|||
// import { Network } from '@components/PageComponents/Config/Network/index.tsx';
|
|||
// import { useDevice } from "@core/stores/deviceStore.ts";
|
|||
// import { Protobuf } from "@meshtastic/core";
|
|||
|
|||
// vi.mock('@core/stores/deviceStore', () => ({
|
|||
// useDevice: vi.fn()
|
|||
// }));
|
|||
|
|||
// vi.mock('@components/Form/DynamicForm', () => ({
|
|||
// DynamicForm: vi.fn(({ onSubmit }) => {
|
|||
// return (
|
|||
// <div data-testid="dynamic-form">
|
|||
// <select
|
|||
// data-testid="role-select"
|
|||
// onChange={(e) => {
|
|||
// const mockData = { role: e.target.value };
|
|||
// onSubmit(mockData);
|
|||
// }}
|
|||
// >
|
|||
// {Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map(([key, value]) => (
|
|||
// <option key={key} value={value}>
|
|||
// {key}
|
|||
// </option>
|
|||
// ))}
|
|||
// </select>
|
|||
// <button type="submit"
|
|||
// data-testid="submit-button"
|
|||
// onClick={() => onSubmit({ role: "CLIENT" })}
|
|||
// >
|
|||
// Submit
|
|||
// </button>
|
|||
// </div>
|
|||
// );
|
|||
// })
|
|||
// }));
|
|||
|
|||
// describe('Network component', () => {
|
|||
// const setWorkingConfigMock = vi.fn();
|
|||
// const mockDeviceConfig = {
|
|||
// role: "CLIENT",
|
|||
// buttonGpio: 0,
|
|||
// buzzerGpio: 0,
|
|||
// rebroadcastMode: "ALL",
|
|||
// nodeInfoBroadcastSecs: 300,
|
|||
// doubleTapAsButtonPress: false,
|
|||
// disableTripleClick: false,
|
|||
// ledHeartbeatDisabled: false,
|
|||
// };
|
|||
|
|||
// beforeEach(() => {
|
|||
// vi.resetAllMocks();
|
|||
|
|||
// (useDevice as any).mockReturnValue({
|
|||
// config: {
|
|||
// device: mockDeviceConfig
|
|||
// },
|
|||
// setWorkingConfig: setWorkingConfigMock
|
|||
// });
|
|||
|
|||
// });
|
|||
|
|||
// afterEach(() => {
|
|||
// vi.clearAllMocks();
|
|||
// });
|
|||
|
|||
// it('should render the Network form', () => {
|
|||
// render(<Network />);
|
|||
// expect(screen.getByTestId('dynamic-form')).toBeInTheDocument();
|
|||
// });
|
|||
|
|||
// it('should call setWorkingConfig when form is submitted', async () => {
|
|||
// render(<Network />);
|
|||
|
|||
// fireEvent.click(screen.getByTestId('submit-button'));
|
|||
|
|||
// await waitFor(() => {
|
|||
// expect(setWorkingConfigMock).toHaveBeenCalledWith(
|
|||
// expect.objectContaining({
|
|||
// payloadVariant: {
|
|||
// case: "device",
|
|||
// value: expect.objectContaining({ role: "CLIENT" })
|
|||
// }
|
|||
// })
|
|||
// );
|
|||
// });
|
|||
// });
|
|||
|
|||
|
|||
// it('should create config with proper structure', async () => {
|
|||
// render(<Network />);
|
|||
|
|||
// // Simulate form submission
|
|||
// fireEvent.click(screen.getByTestId('submit-button'));
|
|||
|
|||
// await waitFor(() => {
|
|||
// expect(setWorkingConfigMock).toHaveBeenCalledWith(
|
|||
// expect.objectContaining({
|
|||
// payloadVariant: {
|
|||
// case: "network",
|
|||
// value: expect.any(Object)
|
|||
// }
|
|||
// })
|
|||
// );
|
|||
// });
|
|||
// });
|
|||
// });
|
|||
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; |
|||
import { Network } from '@components/PageComponents/Config/Network/index.tsx'; |
|||
import { useDevice } from "@core/stores/deviceStore.ts"; |
|||
import { Protobuf } from "@meshtastic/core"; |
|||
|
|||
vi.mock('@core/stores/deviceStore', () => ({ |
|||
useDevice: vi.fn() |
|||
})); |
|||
|
|||
vi.mock('@components/Form/DynamicForm', async () => { |
|||
const React = await import('react'); |
|||
const { useState } = React; |
|||
|
|||
return { |
|||
DynamicForm: ({ onSubmit, defaultValues }: any) => { |
|||
const [wifiEnabled, setWifiEnabled] = useState(defaultValues.wifiEnabled ?? false); |
|||
const [ssid, setSsid] = useState(defaultValues.wifiSsid ?? ''); |
|||
const [psk, setPsk] = useState(defaultValues.wifiPsk ?? ''); |
|||
|
|||
return ( |
|||
<form |
|||
onSubmit={(e) => { |
|||
e.preventDefault(); |
|||
onSubmit({ |
|||
...defaultValues, |
|||
wifiEnabled, |
|||
wifiSsid: ssid, |
|||
wifiPsk: psk, |
|||
}); |
|||
}} |
|||
data-testid="dynamic-form" |
|||
> |
|||
<input |
|||
type="checkbox" |
|||
aria-label="WiFi Enabled" |
|||
checked={wifiEnabled} |
|||
onChange={(e) => setWifiEnabled(e.target.checked)} |
|||
/> |
|||
<input |
|||
aria-label="SSID" |
|||
value={ssid} |
|||
onChange={(e) => setSsid(e.target.value)} |
|||
disabled={!wifiEnabled} |
|||
/> |
|||
<input |
|||
aria-label="PSK" |
|||
value={psk} |
|||
onChange={(e) => setPsk(e.target.value)} |
|||
disabled={!wifiEnabled} |
|||
/> |
|||
<button type="submit" data-testid="submit-button"> |
|||
Submit |
|||
</button> |
|||
</form> |
|||
); |
|||
}, |
|||
}; |
|||
}); |
|||
; |
|||
|
|||
describe('Network component', () => { |
|||
const setWorkingConfigMock = vi.fn(); |
|||
const mockNetworkConfig = { |
|||
wifiEnabled: false, |
|||
wifiSsid: '', |
|||
wifiPsk: '', |
|||
ntpServer: '', |
|||
ethEnabled: false, |
|||
addressMode: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP, |
|||
ipv4Config: { |
|||
ip: 0, |
|||
gateway: 0, |
|||
subnet: 0, |
|||
dns: 0, |
|||
}, |
|||
enabledProtocols: |
|||
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST, |
|||
rsyslogServer: '', |
|||
}; |
|||
|
|||
beforeEach(() => { |
|||
vi.resetAllMocks(); |
|||
|
|||
(useDevice as any).mockReturnValue({ |
|||
config: { |
|||
network: mockNetworkConfig |
|||
}, |
|||
setWorkingConfig: setWorkingConfigMock |
|||
}); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
vi.clearAllMocks(); |
|||
}); |
|||
|
|||
it('should render the Network form', () => { |
|||
render(<Network />); |
|||
expect(screen.getByTestId('dynamic-form')).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it('should disable SSID and PSK fields when wifi is off', () => { |
|||
render(<Network />); |
|||
expect(screen.getByLabelText("SSID")).toBeDisabled(); |
|||
expect(screen.getByLabelText("PSK")).toBeDisabled(); |
|||
}); |
|||
|
|||
it('should enable SSID and PSK when wifi is toggled on', async () => { |
|||
render(<Network />); |
|||
const toggle = screen.getByLabelText("WiFi Enabled"); |
|||
screen.debug() |
|||
|
|||
fireEvent.click(toggle); // turns wifiEnabled = true
|
|||
|
|||
await waitFor(() => { |
|||
expect(screen.getByLabelText("SSID")).not.toBeDisabled(); |
|||
expect(screen.getByLabelText("PSK")).not.toBeDisabled(); |
|||
}); |
|||
}); |
|||
|
|||
it('should call setWorkingConfig with the right structure on submit', async () => { |
|||
render(<Network />); |
|||
|
|||
fireEvent.click(screen.getByTestId("submit-button")); |
|||
|
|||
await waitFor(() => { |
|||
expect(setWorkingConfigMock).toHaveBeenCalledWith( |
|||
expect.objectContaining({ |
|||
payloadVariant: { |
|||
case: "network", |
|||
value: expect.objectContaining({ |
|||
wifiEnabled: false, |
|||
wifiSsid: '', |
|||
wifiPsk: '', |
|||
ntpServer: '', |
|||
ethEnabled: false, |
|||
rsyslogServer: '', |
|||
}) |
|||
} |
|||
}) |
|||
); |
|||
}); |
|||
}); |
|||
|
|||
it('should submit valid data after enabling wifi and entering SSID and PSK', async () => { |
|||
render(<Network />); |
|||
fireEvent.click(screen.getByLabelText("WiFi Enabled")); |
|||
|
|||
fireEvent.change(screen.getByLabelText("SSID"), { |
|||
target: { value: "MySSID" } |
|||
}); |
|||
|
|||
fireEvent.change(screen.getByLabelText("PSK"), { |
|||
target: { value: "MySecretPSK" } |
|||
}); |
|||
|
|||
fireEvent.click(screen.getByTestId("submit-button")); |
|||
|
|||
await waitFor(() => { |
|||
expect(setWorkingConfigMock).toHaveBeenCalledWith( |
|||
expect.objectContaining({ |
|||
payloadVariant: { |
|||
case: "network", |
|||
value: expect.objectContaining({ |
|||
wifiEnabled: true, |
|||
wifiSsid: "MySSID", |
|||
wifiPsk: "MySecretPSK" |
|||
}) |
|||
} |
|||
}) |
|||
); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,52 @@ |
|||
import { renderHook, act } from '@testing-library/react' |
|||
import useLocalStorage from './useLocalStorage' |
|||
import { beforeEach, describe, expect, it } from "vitest"; |
|||
|
|||
describe('useLocalStorage', () => { |
|||
const key = 'test-key' |
|||
|
|||
beforeEach(() => { |
|||
localStorage.clear() |
|||
}) |
|||
|
|||
it('should initialize with initial value if localStorage is empty', () => { |
|||
const { result } = renderHook(() => useLocalStorage(key, 'initial')) |
|||
const [value] = result.current |
|||
expect(value).toBe('initial') |
|||
}) |
|||
|
|||
it('should read existing value from localStorage', () => { |
|||
localStorage.setItem(key, JSON.stringify('stored')) |
|||
const { result } = renderHook(() => useLocalStorage(key, 'initial')) |
|||
const [value] = result.current |
|||
expect(value).toBe('stored') |
|||
}) |
|||
|
|||
it('should update localStorage when setValue is called', () => { |
|||
const { result } = renderHook(() => useLocalStorage(key, 'initial')) |
|||
const [, setValue] = result.current |
|||
|
|||
act(() => { |
|||
setValue('updated') |
|||
}) |
|||
|
|||
expect(localStorage.getItem(key)).toBe(JSON.stringify('updated')) |
|||
expect(result.current[0]).toBe('updated') |
|||
}) |
|||
|
|||
it('should remove value from localStorage when removeValue is called', () => { |
|||
const { result } = renderHook(() => useLocalStorage(key, 'initial')) |
|||
const [, setValue, removeValue] = result.current |
|||
|
|||
act(() => { |
|||
setValue('to-be-removed') |
|||
}) |
|||
|
|||
act(() => { |
|||
removeValue() |
|||
}) |
|||
|
|||
expect(localStorage.getItem(key)).toBeNull() |
|||
expect(result.current[0]).toBe('initial') |
|||
}) |
|||
}) |
|||
@ -0,0 +1,65 @@ |
|||
import { renderHook, act } from "@testing-library/react"; |
|||
import { describe, it, expect, vi, beforeEach } from "vitest"; |
|||
import { usePinnedItems } from "./usePinnedItems.ts"; |
|||
|
|||
const mockSetPinnedItems = vi.fn(); |
|||
const mockUseLocalStorage = vi.fn(); |
|||
|
|||
vi.mock("@core/hooks/useLocalStorage.ts", () => ({ |
|||
default: (...args: any[]) => mockUseLocalStorage(...args), |
|||
})); |
|||
|
|||
describe("usePinnedItems", () => { |
|||
beforeEach(() => { |
|||
vi.clearAllMocks(); |
|||
}); |
|||
|
|||
it("returns default pinnedItems and togglePinnedItem", () => { |
|||
mockUseLocalStorage.mockReturnValue([[], mockSetPinnedItems]); |
|||
|
|||
const { result } = renderHook(() => |
|||
usePinnedItems({ storageName: "test-storage" }) |
|||
); |
|||
|
|||
expect(result.current.pinnedItems).toEqual([]); |
|||
expect(typeof result.current.togglePinnedItem).toBe("function"); |
|||
}); |
|||
|
|||
it("adds an item if it's not already pinned", () => { |
|||
mockUseLocalStorage.mockReturnValue([["item1"], mockSetPinnedItems]); |
|||
|
|||
const { result } = renderHook(() => |
|||
usePinnedItems({ storageName: "test-storage" }) |
|||
); |
|||
|
|||
act(() => { |
|||
result.current.togglePinnedItem("item2"); |
|||
}); |
|||
|
|||
expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function)); |
|||
|
|||
const updater = mockSetPinnedItems.mock.calls[0][0]; |
|||
const updated = updater(["item1"]); |
|||
|
|||
expect(updated).toEqual(["item1", "item2"]); |
|||
}); |
|||
|
|||
it("removes an item if it's already pinned", () => { |
|||
mockUseLocalStorage.mockReturnValue([["item1", "item2"], mockSetPinnedItems]); |
|||
|
|||
const { result } = renderHook(() => |
|||
usePinnedItems({ storageName: "test-storage" }) |
|||
); |
|||
|
|||
act(() => { |
|||
result.current.togglePinnedItem("item1"); |
|||
}); |
|||
|
|||
expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function)); |
|||
|
|||
const updater = mockSetPinnedItems.mock.calls[0][0]; |
|||
const updated = updater(["item1", "item2"]); |
|||
|
|||
expect(updated).toEqual(["item2"]); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,19 @@ |
|||
import useLocalStorage from "@core/hooks/useLocalStorage.ts"; |
|||
import { useCallback } from "react"; |
|||
|
|||
export function usePinnedItems({ storageName }: { storageName: string }) { |
|||
const [pinnedItems, setPinnedItems] = useLocalStorage<string[]>(storageName, []); |
|||
|
|||
const togglePinnedItem = useCallback((label: string) => { |
|||
setPinnedItems((prev) => |
|||
prev.includes(label) |
|||
? prev.filter((g) => g !== label) |
|||
: [...prev, label] |
|||
); |
|||
}, []); |
|||
|
|||
return { |
|||
pinnedItems, |
|||
togglePinnedItem, |
|||
}; |
|||
} |
|||
@ -0,0 +1,81 @@ |
|||
import { renderHook, act } from '@testing-library/react' |
|||
import { useToast } from "@core/hooks/useToast.ts" |
|||
import { Button } from '@components/UI/Button.tsx' |
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
|||
|
|||
describe('useToast', () => { |
|||
beforeEach(() => { |
|||
// Reset toast memory state before each test
|
|||
// our hook uses global memory to store toasts
|
|||
// @ts-expect-error - internal test reset
|
|||
globalThis.memoryState = { toasts: [] } |
|||
vi.useFakeTimers() |
|||
}) |
|||
|
|||
afterEach(() => { |
|||
vi.useRealTimers() |
|||
}) |
|||
|
|||
it('should create a toast with title, description, and action', () => { |
|||
const { result } = renderHook(() => useToast()) |
|||
|
|||
act(() => { |
|||
result.current.toast({ |
|||
title: 'Backup Reminder', |
|||
description: 'Don\'t forget to backup!', |
|||
action: <Button>Backup Now</Button> |
|||
}) |
|||
vi.runAllTimers() |
|||
}) |
|||
|
|||
const toast = result.current.toasts[0] |
|||
expect(result.current.toasts.length).toBe(1) |
|||
expect(toast.title).toBe('Backup Reminder') |
|||
expect(toast.description).toBe('Don\'t forget to backup!') |
|||
expect(toast.action).toBeTruthy() |
|||
expect(toast.open).toBe(true) |
|||
}) |
|||
it('should dismiss a toast using returned dismiss function', () => { |
|||
const { result } = renderHook(() => useToast()) |
|||
vi.useFakeTimers() |
|||
|
|||
let toastRef: { id: string, dismiss: () => void } |
|||
|
|||
act(() => { |
|||
toastRef = result.current.toast({ title: 'Dismiss Me' }) |
|||
vi.runAllTimers() // Flush ADD_TOAST
|
|||
}) |
|||
|
|||
act(() => { |
|||
toastRef.dismiss() |
|||
}) |
|||
|
|||
const toast = result.current.toasts.find(t => t.id === toastRef.id) |
|||
expect(toast?.open).toBe(false) |
|||
|
|||
vi.useRealTimers() |
|||
}) |
|||
|
|||
|
|||
it('should allow dismiss via hook dismiss function', () => { |
|||
const { result } = renderHook(() => useToast()) |
|||
vi.useFakeTimers() |
|||
|
|||
let toastRef: { id: string } |
|||
|
|||
act(() => { |
|||
toastRef = result.current.toast({ title: 'Manual Dismiss' }) |
|||
vi.runAllTimers() |
|||
}) |
|||
|
|||
act(() => { |
|||
result.current.dismiss(toastRef.id) |
|||
}) |
|||
|
|||
const toast = result.current.toasts.find(t => t.id === toastRef.id) |
|||
expect(toast?.open).toBe(false) |
|||
|
|||
vi.useRealTimers() |
|||
}) |
|||
|
|||
}) |
|||
@ -0,0 +1,13 @@ |
|||
import { ZodError, ZodSchema } from "zod"; |
|||
|
|||
export function validateSchema<T>( |
|||
schema: ZodSchema<T>, |
|||
data: unknown |
|||
): { success: true; data: T } | { success: false; errors: ZodError["issues"] } { |
|||
const result = schema.safeParse(data); |
|||
if (result.success) { |
|||
return { success: true, data: result.data }; |
|||
} else { |
|||
return { success: false, errors: result.error.issues }; |
|||
} |
|||
} |
|||
Loading…
Reference in new issue