feat(web): frontend CI, vitest suite, navbar→React SPA shell — Phase 5

Frontend CI (closes the no-coverage gap for the TSX):
- ci.yml: new 'frontend' job — npm ci, tsc --noEmit, test:frontend, build.
- package.json: engines.node>=20, test:frontend/typecheck scripts.

vitest unit + component tests (jsdom, @testing-library/react):
- utils/charts.test.ts — tier math + every chart builder.
- utils/format.test.ts — parseAppDate, formatNumber, truncateKey, emoji helpers,
  formatRelativeTime.
- components/Navbar.test.tsx — feature-gated nav, custom pages, OIDC/maintenance
  auth gating.
- components/Announcements.test.tsx — banner rendering, ordering, dismiss +
  sessionStorage (covers behaviour moved out of the Python suite).

Navbar → React (full SPA shell):
- New Navbar/ThemeToggle/Announcements components + useNavItems hook (shared
  feature-gated nav for desktop + mobile); nav uses react-router NavLink
  (client-side nav + auto active) — drops the imperative data-nav-link bridge.
- main.tsx single root; App.tsx renders Navbar + Announcements above routed <main>.
- spa.html slimmed to SEO/config/footer shell (Jinja2 navbar/banners/theme script
  removed; #app is a plain div React fills).
- Backend: _build_config_json exposes system_announcement/network_announcement.

Tests (server-rendered nav/banner assertions → config):
- conftest.py: get_app_config() helper (robust __APP_CONFIG__ extraction).
- test_features/app/home/pages/dashboard rewritten to assert __APP_CONFIG__;
  dashboard client-rendered-stats tests replaced with a shell assertion.

Docs: REACT_MIGRATION.md Phase 5 (incl. deliberately-skipped react-query/
Storybook/Playwright), AGENTS.md Frontend section.

Verified: tsc clean, npm run build, vitest (49 passed), pytest tests/test_web
(251 passed), full suite (1459 passed), pre-commit (passed).
This commit is contained in:
Louis King
2026-07-21 20:06:52 +01:00
parent a5fabf7d46
commit 527c860bf8
28 changed files with 2305 additions and 394 deletions
+25
View File
@@ -36,6 +36,31 @@ jobs:
- name: Run pre-commit
uses: pre-commit/action@v3.0.1
frontend:
name: Frontend
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- name: Set up Node
uses: actions/setup-node@v5
with:
node-version: 24
cache: npm
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npx tsc --noEmit
- name: Unit tests
run: npm run test:frontend
- name: Build
run: npm run build
test:
name: Test
runs-on: ubuntu-latest
+13 -5
View File
@@ -44,16 +44,18 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile core ex
The web UI is a **React 19 + TypeScript + Vite** SPA in
`src/meshcore_hub/web/static/js/spa-react/` (alias `@/` → that dir). The Jinja2 shell
(`web/templates/spa.html`) renders the navbar/SEO/`window.__APP_CONFIG__`; React mounts into
`<main id="app">`. **Frontend tooling runs on the host** (not in Docker): `npm install`,
`npm run build` (Tailwind → vendor fonts → `vite build` → `static/dist/` + `assets.json`), and
`npx tsc --noEmit` (the TS gate — there is no JS linter in pre-commit). The Vite build is
required to serve the UI; there is no fallback bundle.
(`web/templates/spa.html`) renders only SEO/`window.__APP_CONFIG__`/footer; React renders the
navbar, banners, and routed pages into `<div id="app">`. **Frontend tooling runs on the host**
(not in Docker): `npm install`, `npm run build` (Tailwind → vendor fonts → `vite build` →
`static/dist/` + `assets.json`), `npx tsc --noEmit` (the TS gate — there is no JS linter in
pre-commit), and `npm run test:frontend` (vitest). The Vite build is required to serve the UI;
there is no fallback bundle.
```bash
npm install # host: install frontend deps
npm run build # host: produce static/dist/ + assets.json
npx tsc --noEmit # host: typecheck (must be clean)
npm run test:frontend # host: vitest unit + component tests
```
- Charts: **react-chartjs-2** — typed config builders in `utils/charts.ts`, wrappers in
@@ -62,9 +64,15 @@ npx tsc --noEmit # host: typecheck (must be clean)
That CSS ships in the Vite bundle, which `spa.html` loads in `<head>` **before** `app.css` so
the dark-mode map overrides win — don't reorder those `<link>`s.
- QR codes: **react-qr-code**.
- Navbar/shell: React (`components/Navbar.tsx`, `ThemeToggle.tsx`, `Announcements.tsx`,
`hooks/useNavItems.tsx`); nav uses react-router `NavLink` (client-side nav). Feature flags,
custom pages, and announcements all come from `window.__APP_CONFIG__`.
- Page conventions: `useSearchParams()` for filters/pagination/sort, typed `apiGet<T>()` with an
`AbortController` in `useEffect`, `usePageTitle('entities.x')`, shared components
(`Pagination`, `FilterForm`, `StatCard`, `NodeDisplay`, etc.).
- Tests: **vitest** + `@testing-library/react` (`*.test.ts(x)` next to code; setup in
`spa-react/test/`). Python web tests assert the embedded `__APP_CONFIG__`
(`tests/test_web/conftest.py::get_app_config`), not server-rendered nav HTML.
- Only **fonts** are vendored (`build.js` copies them); chart/map/QR libs are bundled by Vite.
## Tests & Quality
+33 -7
View File
@@ -10,7 +10,7 @@ Migration from lit-html (functional templates) to React 19 + TypeScript + Vite.
| 2 | Convert pages one-by-one from LitBridge to native React | **Complete** |
| 3 | Chart & map components (react-chartjs-2, react-leaflet) | **Complete** |
| 4 | Cleanup (remove lit-html, old spa/, LitBridge, @legacy alias) | **Complete** |
| 5 | Optional enhancements (tests, react-query, Storybook) | Not started |
| 5 | Frontend CI + vitest unit/component tests + navbar → React (SPA shell) | **Complete** |
> **Phase 3 status:** All `window.Chart` / `window.L` / `window.QRCode` globals and the
> `charts.js` helper script are gone. Charts now use **react-chartjs-2** (typed builders in
@@ -199,13 +199,39 @@ Python (`app.py`) loads this manifest at startup and passes `asset_app_js` / `as
- (Vendor script tags / `charts.js` / `build.js` vendor copy were already removed in Phase 3.)
- Updated `AGENTS.md` with the React frontend conventions.
## Phase 5: Optional Enhancements
## Phase 5: Frontend CI, Tests & SPA Shell — Complete
- Add `vitest` + `@testing-library/react` for component tests
- Add Playwright for E2E browser tests
- Consider `@tanstack/react-query` for data fetching
- Consider moving navbar from Jinja2 to React (full SPA shell)
- Add Storybook for component development
- **Frontend CI job** (`.github/workflows/ci.yml`): `npm ci` → `tsc --noEmit` →
`npm run test:frontend` → `npm run build` on every push/PR. Closes the gap where the
~9k lines of TSX had no CI coverage (pre-commit is Python-only).
- **vitest** (`vitest.config.ts`, jsdom env, `npm run test:frontend`):
- `utils/charts.test.ts` — tier math (`routeQualityToTier`, `averageRouteTier`) and every
chart builder (empty → null, dataset counts/labels/colors, stacked %, route-strip segments).
- `utils/format.test.ts` — `parseAppDate`, `formatNumber`, `truncateKey`, `typeEmoji`,
`extractFirstEmoji`, `getNodeEmoji`, `formatRelativeTime`.
- `components/Navbar.test.tsx` — feature-gated nav links, custom pages, OIDC/maintenance
auth gating (rendered with `MemoryRouter` + `AppConfigProvider`).
- `components/Announcements.test.tsx` — system/network banner rendering, ordering, dismiss
+ sessionStorage persistence (covers behaviour that moved out of the Python suite).
- **Navbar → React (full SPA shell)**:
- New `components/Navbar.tsx`, `components/ThemeToggle.tsx`, `components/Announcements.tsx`,
and `hooks/useNavItems.tsx` (shared feature-gated nav list used by desktop + mobile).
- `main.tsx` now renders a single root; `App.tsx` renders `<Navbar/>` + `<Announcements/>`
above the routed `<main>`. Nav uses react-router `NavLink` (client-side nav + auto active
class) — the imperative `data-nav-link` active-toggle and `#nav-loading` DOM bridge are gone.
- `spa.html` slimmed to a thin shell: the Jinja2 navbar, banners, and vanilla theme-toggle
script were removed; `<main id="app">` became a plain `<div id="app">` that React fills.
SEO `<head>`, footer, and the early theme-init script stay server-rendered.
- Backend: `_build_config_json` now exposes `system_announcement` / `network_announcement`
(pre-rendered Markdown) for the React banners.
- Python tests that asserted the server-rendered navbar/banners were rewritten to assert the
embedded `__APP_CONFIG__` (new `get_app_config()` helper in `tests/test_web/conftest.py`);
the flag→render path is now covered by the Navbar component test.
**Deliberately not done** (low ROI / high risk for this codebase): `@tanstack/react-query`
(conflicts with the deliberate `private, no-cache` + server-side Redis invalidation design and
is a 15-page refactor), Storybook (single-app component set), and Playwright E2E (needs the full
stack in CI; revisit if real browser coverage is wanted).
## Running & Testing
+1127 -1
View File
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -1,17 +1,27 @@
{
"private": true,
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"build": "node build.js",
"dev": "vite --config vite.config.ts"
"dev": "vite --config vite.config.ts",
"test:frontend": "vitest run",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@types/leaflet": "^1.9.17",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^4",
"jsdom": "^29.1.1",
"typescript": "^5.8",
"vite": "^6"
"vite": "^6",
"vitest": "^4.1.10"
},
"dependencies": {
"@fontsource-variable/ibm-plex-sans": "^5",
+2
View File
@@ -361,6 +361,8 @@ def _build_config_json(app: FastAPI, request: Request) -> str:
"locale_version": getattr(app.state, "locale_version", ""),
"system_maintenance": app.state.system_maintenance,
"spam_score_threshold": app.state.spam_score_threshold,
"system_announcement": app.state.system_announcement,
"network_announcement": app.state.network_announcement,
}
role_names = {
@@ -9,6 +9,8 @@ import {
} from "react-router";
import { useAppConfig } from "@/context/AppConfigContext";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { Navbar } from "@/components/Navbar";
import { Announcements } from "@/components/Announcements";
import { HomePage } from "@/pages/Home";
import { DashboardPage } from "@/pages/Dashboard";
import { Nodes } from "@/pages/Nodes";
@@ -33,21 +35,6 @@ function useNavActiveState() {
useEffect(() => {
const pathname = location.pathname;
document.querySelectorAll("[data-nav-link]").forEach((link) => {
const href = link.getAttribute("href");
let isActive = false;
if (href === "/") {
isActive = pathname === "/";
} else if (href === "/nodes") {
isActive = pathname.startsWith("/nodes");
} else if (href) {
isActive = pathname === href || pathname.startsWith(href + "/");
}
link.classList.toggle("active", isActive);
});
const loader = document.getElementById("nav-loading");
if (loader) loader.classList.add("hidden");
if (document.activeElement?.closest(".dropdown")) {
(document.activeElement as HTMLElement).blur();
@@ -266,10 +253,22 @@ function AppRoutes() {
);
}
function Shell() {
return (
<>
<Navbar />
<Announcements />
<main className="container mx-auto px-4 py-6 flex-1">
<AppRoutes />
</main>
</>
);
}
export function App() {
return (
<BrowserRouter>
<AppRoutes />
<Shell />
</BrowserRouter>
);
}
@@ -0,0 +1,86 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { AppConfigProvider } from "@/context/AppConfigContext";
import { Announcements } from "@/components/Announcements";
import { makeConfig } from "@/test/makeConfig";
import type { AppConfig } from "@/types/config";
function renderAnnouncements(config: AppConfig) {
return render(
<AppConfigProvider config={config}>
<Announcements />
</AppConfigProvider>,
);
}
beforeEach(() => {
sessionStorage.clear();
});
describe("Announcements", () => {
it("renders nothing when there are no announcements", () => {
const { container } = renderAnnouncements(makeConfig());
expect(container.firstChild).toBeNull();
});
it("renders the system banner content as HTML", () => {
const { container } = renderAnnouncements(
makeConfig({ system_announcement: "<strong>Outage</strong> at 22:00" }),
);
expect(container.querySelector("#system-banner")).not.toBeNull();
expect(screen.getByText("Outage").tagName).toBe("STRONG");
});
it("renders the network banner with a dismiss button", () => {
const { container } = renderAnnouncements(
makeConfig({ network_announcement: "<p>Notice</p>" }),
);
expect(container.querySelector("#flash-banner")).not.toBeNull();
expect(screen.getByLabelText("Dismiss")).toBeInTheDocument();
});
it("does not render a dismiss control on the system banner", () => {
const { container } = renderAnnouncements(
makeConfig({ system_announcement: "Heads up" }),
);
const banner = container.querySelector("#system-banner");
expect(banner).not.toBeNull();
expect(banner!.querySelector("button")).toBeNull();
});
it("renders the system banner above the network banner", () => {
const { container } = renderAnnouncements(
makeConfig({
system_announcement: "System notice",
network_announcement: "Network notice",
}),
);
const system = container.querySelector("#system-banner");
const network = container.querySelector("#flash-banner");
expect(system).not.toBeNull();
expect(network).not.toBeNull();
// network follows system in document order
expect(
system!.compareDocumentPosition(network!) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("dismisses the network banner and persists to sessionStorage", () => {
const { container } = renderAnnouncements(
makeConfig({ network_announcement: "Notice" }),
);
fireEvent.click(screen.getByLabelText("Dismiss"));
expect(container.querySelector("#flash-banner")).toBeNull();
expect(sessionStorage.getItem("flash-banner-dismissed")).toBe("1");
});
it("does not render a previously dismissed network banner", () => {
sessionStorage.setItem("flash-banner-dismissed", "1");
const { container } = renderAnnouncements(
makeConfig({ network_announcement: "Notice" }),
);
expect(container.querySelector("#flash-banner")).toBeNull();
});
});
@@ -0,0 +1,62 @@
import { useState } from "react";
import { useAppConfig } from "@/context/AppConfigContext";
export function Announcements() {
const config = useAppConfig();
const [dismissed, setDismissed] = useState(() => {
try {
return sessionStorage.getItem("flash-banner-dismissed") === "1";
} catch {
return false;
}
});
const system = config.system_announcement;
const network = config.network_announcement;
const dismiss = () => {
setDismissed(true);
try {
sessionStorage.setItem("flash-banner-dismissed", "1");
} catch {
// ignore
}
};
if (!system && (!network || dismissed)) return null;
return (
<>
{system && (
<div
id="system-banner"
className="alert alert-error rounded-none py-2 px-4 text-center text-sm"
>
<div
className="flash-banner-content"
dangerouslySetInnerHTML={{ __html: system }}
/>
</div>
)}
{network && !dismissed && (
<div
id="flash-banner"
className="alert alert-warning rounded-none py-2 px-4 text-center text-sm"
>
<div
className="flash-banner-content"
dangerouslySetInnerHTML={{ __html: network }}
/>
<button
aria-label="Dismiss"
onClick={dismiss}
className="btn btn-ghost btn-xs"
>
&times;
</button>
</div>
)}
</>
);
}
@@ -1,61 +1,21 @@
import { useTranslation } from "react-i18next";
import { useAppConfig } from "@/context/AppConfigContext";
import {
IconHome,
IconDashboard,
IconNodes,
IconAdvertisements,
IconMessages,
IconPackets,
IconMap,
IconMembers,
IconPage,
IconChannel,
IconPath,
} from "@/components/icons";
import { NavLink } from "react-router";
import { useNavItems } from "@/hooks/useNavItems";
export function MobileNav() {
const { t } = useTranslation();
const config = useAppConfig();
const features = config.features ?? {};
const customPages = config.custom_pages ?? [];
const items: { href: string; icon: React.ReactNode; label: string }[] = [
{ href: "/", icon: <IconHome className="h-5 w-5" />, label: t("entities.home") },
];
if (features.dashboard !== false)
items.push({ href: "/dashboard", icon: <IconDashboard className="h-5 w-5 nav-icon-dashboard" />, label: t("entities.dashboard") });
if (features.nodes !== false)
items.push({ href: "/nodes", icon: <IconNodes className="h-5 w-5 nav-icon-nodes" />, label: t("entities.nodes") });
if (features.advertisements !== false)
items.push({ href: "/advertisements", icon: <IconAdvertisements className="h-5 w-5 nav-icon-adverts" />, label: t("entities.advertisements") });
if (features.routes !== false)
items.push({ href: "/routes", icon: <IconPath className="h-5 w-5 nav-icon-routes" />, label: t("entities.routes") });
if (features.channels !== false)
items.push({ href: "/channels", icon: <IconChannel className="h-5 w-5 nav-icon-channels" />, label: t("entities.channels") });
if (features.messages !== false)
items.push({ href: "/messages", icon: <IconMessages className="h-5 w-5 nav-icon-messages" />, label: t("entities.messages") });
if (features.packets !== false)
items.push({ href: "/packets", icon: <IconPackets className="h-5 w-5 nav-icon-packets" />, label: t("entities.packets") });
if (features.map !== false)
items.push({ href: "/map", icon: <IconMap className="h-5 w-5 nav-icon-map" />, label: t("entities.map") });
if (features.members !== false)
items.push({ href: "/members", icon: <IconMembers className="h-5 w-5 nav-icon-members" />, label: t("entities.members") });
if (features.pages !== false) {
for (const page of customPages) {
items.push({ href: page.url, icon: <IconPage className="h-5 w-5" />, label: page.title });
}
}
const items = useNavItems("h-5 w-5");
return (
<>
{items.map((item) => (
<li key={item.href}>
<a href={item.href} data-nav-link>
<NavLink
to={item.href}
end={item.end}
className={({ isActive }) => (isActive ? "active" : undefined)}
>
{item.icon} {item.label}
</a>
</NavLink>
</li>
))}
</>
@@ -0,0 +1,119 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { AppConfigProvider } from "@/context/AppConfigContext";
import { Navbar } from "@/components/Navbar";
import { makeConfig } from "@/test/makeConfig";
import type { AppConfig } from "@/types/config";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: "en" },
}),
}));
function renderNavbar(config: AppConfig) {
return render(
<AppConfigProvider config={config}>
<MemoryRouter>
<Navbar />
</MemoryRouter>
</AppConfigProvider>,
);
}
// Each nav label renders twice (desktop menu + mobile dropdown).
const labelCount = (label: string) => screen.queryAllByText(label).length;
describe("Navbar feature gating", () => {
it("renders all feature links when every feature is enabled", () => {
renderNavbar(makeConfig());
expect(labelCount("entities.home")).toBeGreaterThan(0);
expect(labelCount("entities.dashboard")).toBeGreaterThan(0);
expect(labelCount("entities.nodes")).toBeGreaterThan(0);
expect(labelCount("entities.messages")).toBeGreaterThan(0);
expect(labelCount("entities.map")).toBeGreaterThan(0);
});
it("hides links for disabled features", () => {
renderNavbar(
makeConfig({
features: { dashboard: false, nodes: false, map: false },
}),
);
expect(labelCount("entities.dashboard")).toBe(0);
expect(labelCount("entities.nodes")).toBe(0);
expect(labelCount("entities.map")).toBe(0);
// Still-enabled features remain
expect(labelCount("entities.messages")).toBeGreaterThan(0);
expect(labelCount("entities.home")).toBeGreaterThan(0);
});
it("shows only Home when all features are off (maintenance)", () => {
renderNavbar(
makeConfig({
system_maintenance: true,
features: {
dashboard: false,
nodes: false,
advertisements: false,
routes: false,
channels: false,
messages: false,
packets: false,
map: false,
members: false,
pages: false,
},
}),
);
expect(labelCount("entities.home")).toBeGreaterThan(0);
expect(labelCount("entities.dashboard")).toBe(0);
expect(labelCount("entities.nodes")).toBe(0);
expect(labelCount("entities.messages")).toBe(0);
});
it("renders custom pages when the pages feature is enabled", () => {
renderNavbar(
makeConfig({
custom_pages: [
{ slug: "about", title: "About Us", url: "/pages/about", menu_order: 1 },
],
}),
);
expect(labelCount("About Us")).toBeGreaterThan(0);
});
it("hides custom pages when the pages feature is disabled", () => {
renderNavbar(
makeConfig({
features: { pages: false },
custom_pages: [
{ slug: "about", title: "About Us", url: "/pages/about", menu_order: 1 },
],
}),
);
expect(labelCount("About Us")).toBe(0);
});
});
describe("Navbar auth gating", () => {
it("shows the login button when OIDC is enabled and not in maintenance", () => {
renderNavbar(makeConfig({ oidc_enabled: true }));
expect(labelCount("auth.login")).toBeGreaterThan(0);
});
it("hides auth when OIDC is disabled", () => {
renderNavbar(makeConfig({ oidc_enabled: false }));
expect(labelCount("auth.login")).toBe(0);
});
it("hides auth in maintenance mode even when OIDC is enabled", () => {
renderNavbar(
makeConfig({ oidc_enabled: true, system_maintenance: true }),
);
expect(labelCount("auth.login")).toBe(0);
});
});
@@ -0,0 +1,69 @@
import { NavLink } from "react-router";
import { useAppConfig } from "@/context/AppConfigContext";
import { useNavItems } from "@/hooks/useNavItems";
import { AuthSection } from "@/components/AuthSection";
import { MobileNav } from "@/components/MobileNav";
import { ThemeToggle } from "@/components/ThemeToggle";
export function Navbar() {
const config = useAppConfig();
const items = useNavItems("h-4 w-4");
const logoClass = `theme-logo${
config.logo_invert_light ? " theme-logo--invert-light" : ""
} h-6 w-6 mr-2`;
return (
<div className="navbar bg-base-100 shadow-lg">
<div className="navbar-start">
<NavLink to="/" end className="btn btn-ghost text-xl">
<img src={config.logo_url} alt={config.network_name} className={logoClass} />
{config.network_name}
</NavLink>
</div>
<div className="navbar-center hidden lg:flex">
<ul className="menu menu-horizontal px-1">
{items.map((item) => (
<li key={item.href}>
<NavLink
to={item.href}
end={item.end}
className={({ isActive }) => (isActive ? "active" : undefined)}
>
{item.icon} {item.label}
</NavLink>
</li>
))}
</ul>
</div>
<div className="navbar-end gap-1 pr-2">
<ThemeToggle />
{config.oidc_enabled && !config.system_maintenance && <AuthSection />}
<div className="dropdown dropdown-end lg:hidden">
<div tabIndex={0} role="button" className="btn btn-ghost">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</div>
<ul
tabIndex={0}
className="dropdown-content menu z-50 p-2 shadow bg-base-100 rounded-box w-56 mt-3"
>
<MobileNav />
</ul>
</div>
</div>
</div>
);
}
@@ -0,0 +1,41 @@
import { useState, type ChangeEvent } from "react";
export function ThemeToggle() {
const [isLight, setIsLight] = useState(
() => document.documentElement.getAttribute("data-theme") === "light",
);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const light = e.currentTarget.checked;
const theme = light ? "light" : "dark";
document.documentElement.setAttribute("data-theme", theme);
try {
localStorage.setItem("meshcore-theme", theme);
} catch {
// ignore
}
setIsLight(light);
};
return (
<label className="swap swap-rotate btn btn-ghost btn-circle btn-sm">
<input type="checkbox" checked={isLight} onChange={handleChange} />
{/* sun icon - shown in dark mode (click to switch to light) */}
<svg
className="swap-off fill-current w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.51,3.51,0,0,1,12,15.5Z" />
</svg>
{/* moon icon - shown in light mode (click to switch to dark) */}
<svg
className="swap-on fill-current w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z" />
</svg>
</label>
);
}
@@ -0,0 +1,107 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useAppConfig } from "@/context/AppConfigContext";
import {
IconAdvertisements,
IconChannel,
IconDashboard,
IconHome,
IconMap,
IconMembers,
IconMessages,
IconNodes,
IconPackets,
IconPage,
IconPath,
} from "@/components/icons";
export interface NavItem {
href: string;
label: string;
icon: ReactNode;
end?: boolean;
}
export function useNavItems(sizeClass = "h-5 w-5"): NavItem[] {
const { t } = useTranslation();
const config = useAppConfig();
const features = config.features ?? {};
const customPages = config.custom_pages ?? [];
const items: NavItem[] = [
{
href: "/",
label: t("entities.home"),
icon: <IconHome className={sizeClass} />,
end: true,
},
];
if (features.dashboard !== false)
items.push({
href: "/dashboard",
label: t("entities.dashboard"),
icon: <IconDashboard className={`${sizeClass} nav-icon-dashboard`} />,
});
if (features.nodes !== false)
items.push({
href: "/nodes",
label: t("entities.nodes"),
icon: <IconNodes className={`${sizeClass} nav-icon-nodes`} />,
});
if (features.advertisements !== false)
items.push({
href: "/advertisements",
label: t("entities.advertisements"),
icon: <IconAdvertisements className={`${sizeClass} nav-icon-adverts`} />,
});
if (features.routes !== false)
items.push({
href: "/routes",
label: t("entities.routes"),
icon: <IconPath className={`${sizeClass} nav-icon-routes`} />,
});
if (features.channels !== false)
items.push({
href: "/channels",
label: t("entities.channels"),
icon: <IconChannel className={`${sizeClass} nav-icon-channels`} />,
});
if (features.messages !== false)
items.push({
href: "/messages",
label: t("entities.messages"),
icon: <IconMessages className={`${sizeClass} nav-icon-messages`} />,
});
if (features.packets !== false)
items.push({
href: "/packets",
label: t("entities.packets"),
icon: <IconPackets className={`${sizeClass} nav-icon-packets`} />,
});
if (features.map !== false)
items.push({
href: "/map",
label: t("entities.map"),
icon: <IconMap className={`${sizeClass} nav-icon-map`} />,
});
if (features.members !== false)
items.push({
href: "/members",
label: t("entities.members"),
icon: <IconMembers className={`${sizeClass} nav-icon-members`} />,
});
if (features.pages !== false) {
for (const page of customPages) {
items.push({
href: page.url,
label: page.title,
icon: <IconPage className={sizeClass} />,
});
}
}
return items;
}
@@ -3,8 +3,6 @@ import { createRoot } from "react-dom/client";
import { AppConfigProvider } from "@/context/AppConfigContext";
import { initI18n } from "@/i18n";
import { App } from "@/App";
import { AuthSection } from "@/components/AuthSection";
import { MobileNav } from "@/components/MobileNav";
import type { AppConfig } from "@/types/config";
async function bootstrap() {
@@ -21,23 +19,13 @@ async function bootstrap() {
const appContainer = document.getElementById("app");
if (!appContainer) return;
const wrap = (ui: React.ReactNode) => (
createRoot(appContainer).render(
<StrictMode>
<AppConfigProvider config={config}>{ui}</AppConfigProvider>
</StrictMode>
<AppConfigProvider config={config}>
<App />
</AppConfigProvider>
</StrictMode>,
);
createRoot(appContainer).render(wrap(<App />));
const authContainer = document.getElementById("auth-section");
if (authContainer) {
createRoot(authContainer).render(wrap(<AuthSection />));
}
const mobileNavContainer = document.getElementById("mobile-nav");
if (mobileNavContainer) {
createRoot(mobileNavContainer).render(wrap(<MobileNav />));
}
}
bootstrap();
@@ -0,0 +1,28 @@
import type { AppConfig } from "@/types/config";
export function makeConfig(overrides: Partial<AppConfig> = {}): AppConfig {
return {
network_name: "TestNet",
features: {},
custom_pages: [],
logo_url: "/logo.svg",
version: "1.0.0",
timezone: "UTC",
timezone_iana: "UTC",
default_theme: "dark",
locale: "en",
datetime_locale: "en-US",
auto_refresh_seconds: 30,
channel_labels: {},
logo_invert_light: false,
debug: false,
locale_version: "",
system_maintenance: false,
spam_score_threshold: 0,
oidc_enabled: false,
user: null,
roles: [],
role_names: {},
...overrides,
};
}
@@ -0,0 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});
@@ -51,6 +51,8 @@ export interface AppConfig {
user: OidcUser | null;
roles: string[];
role_names: Record<string, string>;
system_announcement?: string | null;
network_announcement?: string | null;
}
declare global {
@@ -0,0 +1,255 @@
import { describe, expect, it } from "vitest";
import type { TFunction } from "i18next";
import {
averageRouteTier,
buildActivityChart,
buildLineChart,
buildRouteDetailStrip,
buildRoutesTrend,
buildStackedBar,
ChartColors,
routeQualityToTier,
type ActivitySeries,
type BreakdownBucket,
type RouteOverviewEntry,
} from "@/utils/charts";
const t = ((key: string) => key) as unknown as TFunction;
const dayLabel = (date: string) =>
new Date(date).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
});
const series = (counts: number[]): ActivitySeries => ({
data: counts.map((count, i) => ({
date: `2026-02-0${i + 1}`,
count,
})),
});
describe("routeQualityToTier", () => {
it("maps clear/marginal through and everything else to failing", () => {
expect(routeQualityToTier("clear")).toBe("clear");
expect(routeQualityToTier("marginal")).toBe("marginal");
expect(routeQualityToTier("failing")).toBe("failing");
expect(routeQualityToTier("unknown")).toBe("failing");
expect(routeQualityToTier("no_coverage")).toBe("failing");
expect(routeQualityToTier(null)).toBe("failing");
expect(routeQualityToTier(undefined)).toBe("failing");
});
});
describe("averageRouteTier", () => {
it("falls back to failing on empty/absent history", () => {
expect(averageRouteTier(null)).toBe("failing");
expect(averageRouteTier([])).toBe("failing");
});
it("buckets the mean tier (clear=2, marginal=1, failing=0)", () => {
const q = (quality: string) => [{ quality }];
expect(averageRouteTier(q("clear"))).toBe("clear");
expect(averageRouteTier(q("marginal"))).toBe("marginal");
expect(averageRouteTier(q("failing"))).toBe("failing");
// mean (2+1)/2 = 1.5 -> clear
expect(averageRouteTier([{ quality: "clear" }, { quality: "marginal" }])).toBe(
"clear",
);
// mean (2+0)/2 = 1.0 -> marginal (>= 0.75)
expect(averageRouteTier([{ quality: "clear" }, { quality: "failing" }])).toBe(
"marginal",
);
// mean (1+0)/2 = 0.5 -> failing
expect(
averageRouteTier([{ quality: "marginal" }, { quality: "failing" }]),
).toBe("failing");
});
});
describe("buildLineChart", () => {
it("returns null for missing/empty data", () => {
expect(buildLineChart(null, "L", "b", "bg", true)).toBeNull();
expect(buildLineChart({ data: [] }, "L", "b", "bg", true)).toBeNull();
});
it("builds a single filled dataset with formatted labels", () => {
const cfg = buildLineChart(
series([5, 10]),
"Nodes",
"border",
"fill",
true,
);
expect(cfg).not.toBeNull();
expect(cfg!.data.labels).toEqual([dayLabel("2026-02-01"), dayLabel("2026-02-02")]);
expect(cfg!.data.datasets).toHaveLength(1);
const ds = cfg!.data.datasets[0] as { data: number[]; label: string; fill: boolean };
expect(ds.label).toBe("Nodes");
expect(ds.data).toEqual([5, 10]);
expect(ds.fill).toBe(true);
});
});
describe("buildActivityChart", () => {
it("returns null when both series are absent", () => {
expect(buildActivityChart(null, null, t)).toBeNull();
});
it("builds one dataset when only adverts are provided", () => {
const cfg = buildActivityChart(series([3, 4]), null, t);
expect(cfg).not.toBeNull();
expect(cfg!.data.datasets).toHaveLength(1);
expect((cfg!.data.datasets[0] as { label: string }).label).toBe(
"entities.advertisements",
);
});
it("builds two datasets when both series are provided", () => {
const cfg = buildActivityChart(series([3, 4]), series([1, 2]), t);
expect(cfg).not.toBeNull();
expect(cfg!.data.datasets).toHaveLength(2);
const labels = cfg!.data.datasets.map((d) => (d as { label: string }).label);
expect(labels).toEqual(["entities.advertisements", "entities.messages"]);
});
});
describe("buildStackedBar", () => {
it("returns null for empty buckets or zero total", () => {
expect(buildStackedBar(null, ["red"])).toBeNull();
expect(buildStackedBar([], ["red"])).toBeNull();
const zero: BreakdownBucket[] = [
{ label: "a", count: 0 },
{ label: "b", count: 0 },
];
expect(buildStackedBar(zero, ["red"])).toBeNull();
});
it("produces percentage datasets that sum to 100 with rawCount preserved", () => {
const buckets: BreakdownBucket[] = [
{ label: "a", count: 30 },
{ label: "b", count: 70 },
];
const cfg = buildStackedBar(buckets, ["red", "blue"]);
expect(cfg).not.toBeNull();
const datasets = cfg!.data.datasets as {
data: number[];
rawCount: number;
backgroundColor: string;
}[];
expect(datasets).toHaveLength(2);
expect(datasets[0].data[0] + datasets[1].data[0]).toBeCloseTo(100);
expect(datasets[0].rawCount).toBe(30);
expect(datasets[1].rawCount).toBe(70);
expect(datasets[0].backgroundColor).toBe("red");
expect(datasets[1].backgroundColor).toBe("blue");
});
});
describe("buildRoutesTrend", () => {
it("returns null for empty routes or routes without history", () => {
expect(buildRoutesTrend(null, t)).toBeNull();
expect(buildRoutesTrend([], t)).toBeNull();
expect(
buildRoutesTrend([{ from_label: "A", to_label: "B", history: [] }], t),
).toBeNull();
});
it("sorts by matched_count, uses categorical tiers, and colors by average tier", () => {
const routes: RouteOverviewEntry[] = [
{
from_label: "A",
to_label: "B",
matched_count: 1,
history: [
{ date: "2026-02-01", quality: "clear", matched_count: 1 },
{ date: "2026-02-02", quality: "clear", matched_count: 1 },
],
},
{
from_label: "C",
to_label: "D",
matched_count: 9,
history: [
{ date: "2026-02-01", quality: "failing", matched_count: 9 },
{ date: "2026-02-02", quality: "failing", matched_count: 9 },
],
},
];
const cfg = buildRoutesTrend(routes, t);
expect(cfg).not.toBeNull();
const datasets = cfg!.data.datasets as unknown as {
label: string;
data: string[];
borderColor: string;
_matched: number[];
}[];
// Higher matched_count first
expect(datasets[0].label).toBe("C \u2192 D");
expect(datasets[0].data).toEqual(["failing", "failing"]);
expect(datasets[0].borderColor).toBe(ChartColors.quality.failing);
expect(datasets[0]._matched).toEqual([9, 9]);
expect(datasets[1].data).toEqual(["clear", "clear"]);
expect(datasets[1].borderColor).toBe(ChartColors.quality.clear);
expect(cfg!.data.labels).toEqual([
dayLabel("2026-02-01"),
dayLabel("2026-02-02"),
]);
});
it("respects maxRoutes", () => {
const routes: RouteOverviewEntry[] = Array.from({ length: 8 }, (_, i) => ({
from_label: `A${i}`,
to_label: "B",
matched_count: i,
history: [{ date: "2026-02-01", quality: "clear", matched_count: i }],
}));
const cfg = buildRoutesTrend(routes, t, 6);
expect(cfg!.data.datasets).toHaveLength(6);
});
});
describe("buildRouteDetailStrip", () => {
it("returns null for missing/empty history", () => {
expect(buildRouteDetailStrip(null, t)).toBeNull();
expect(buildRouteDetailStrip({ data: [] }, t)).toBeNull();
expect(buildRouteDetailStrip({}, t)).toBeNull();
});
it("produces one colored segment per day", () => {
const cfg = buildRouteDetailStrip(
{
data: [
{ date: "2026-02-01", quality: "clear", matched_count: 5 },
{ date: "2026-02-02", quality: "failing", matched_count: 2 },
],
},
t,
);
expect(cfg).not.toBeNull();
const datasets = cfg!.data.datasets as {
data: number[];
backgroundColor: string;
_quality: string;
_matched_count: number;
}[];
expect(datasets).toHaveLength(2);
expect(datasets[0].data).toEqual([1]);
expect(datasets[0].backgroundColor).toBe(ChartColors.quality.clear);
expect(datasets[0]._quality).toBe("clear");
expect(datasets[0]._matched_count).toBe(5);
expect(datasets[1].backgroundColor).toBe(ChartColors.quality.failing);
expect(datasets[1]._matched_count).toBe(2);
});
it("falls back to no_coverage color for unknown quality", () => {
const cfg = buildRouteDetailStrip(
{ data: [{ date: "2026-02-01", quality: "weird", matched_count: 0 }] },
t,
);
const ds = cfg!.data.datasets[0] as { backgroundColor: string };
expect(ds.backgroundColor).toBe(ChartColors.quality.no_coverage);
});
});
@@ -0,0 +1,138 @@
import { afterEach, describe, expect, it } from "vitest";
import {
extractFirstEmoji,
formatNumber,
formatRelativeTime,
getNodeEmoji,
parseAppDate,
truncateKey,
typeEmoji,
} from "@/utils/format";
const fmt = (n: number) => new Intl.NumberFormat().format(n);
describe("parseAppDate", () => {
it("returns null for empty/invalid input", () => {
expect(parseAppDate(null)).toBeNull();
expect(parseAppDate("")).toBeNull();
expect(parseAppDate(" ")).toBeNull();
expect(parseAppDate("not a date")).toBeNull();
});
it("treats naive datetimes as UTC", () => {
const d = parseAppDate("2026-02-08 12:30:00");
expect(d).not.toBeNull();
expect(d!.getTime()).toBe(Date.parse("2026-02-08T12:30:00Z"));
});
it("preserves explicit timezone offsets", () => {
const d = parseAppDate("2026-02-08T12:30:00+02:00");
expect(d!.getTime()).toBe(Date.parse("2026-02-08T12:30:00+02:00"));
});
it("parses date-only strings", () => {
const d = parseAppDate("2026-02-08");
expect(d).not.toBeNull();
expect(d!.getTime()).toBe(Date.parse("2026-02-08"));
});
});
describe("formatNumber", () => {
it("returns empty string for null/undefined/empty", () => {
expect(formatNumber(null)).toBe("");
expect(formatNumber(undefined)).toBe("");
expect(formatNumber("")).toBe("");
});
it("returns the raw string for non-numeric input", () => {
expect(formatNumber("abc")).toBe("abc");
});
it("formats numbers with locale grouping", () => {
expect(formatNumber(1234)).toBe(fmt(1234));
expect(formatNumber("1234")).toBe(fmt(1234));
expect(formatNumber(0)).toBe(fmt(0));
});
});
describe("truncateKey", () => {
it("returns '-' for empty input", () => {
expect(truncateKey(null)).toBe("-");
});
it("returns short keys unchanged", () => {
expect(truncateKey("short")).toBe("short");
});
it("truncates long keys with an ellipsis", () => {
const key = "abcdefghijklmnopqrst";
expect(truncateKey(key)).toBe("abcdefghijkl...");
expect(truncateKey(key, 4)).toBe("abcd...");
});
});
describe("typeEmoji", () => {
it("maps node types to emoji (incl. inference from substrings)", () => {
expect(typeEmoji("chat")).toBe("\u{1F4AC}");
expect(typeEmoji("repeater")).toBe("\u{1F4E1}");
expect(typeEmoji("room")).toBe("\u{1FAA7}");
expect(typeEmoji("companion")).toBe("\u{1F4F1}");
expect(typeEmoji("Chat Node")).toBe("\u{1F4AC}");
expect(typeEmoji("My Repeater")).toBe("\u{1F4E1}");
});
it("falls back to a pin for unknown/null types", () => {
expect(typeEmoji(null)).toBe("\u{1F4CD}");
expect(typeEmoji("sensor")).toBe("\u{1F4CD}");
});
});
describe("extractFirstEmoji", () => {
it("returns null when there is no emoji", () => {
expect(extractFirstEmoji(null)).toBeNull();
expect(extractFirstEmoji("plain text")).toBeNull();
});
it("extracts the first emoji", () => {
expect(extractFirstEmoji("\u{1F525} hot node")).toBe("\u{1F525}");
});
});
describe("getNodeEmoji", () => {
it("prefers an emoji in the node name", () => {
expect(getNodeEmoji("\u{1F680} Rocket", null)).toBe("\u{1F680}");
});
it("infers from type/name when no name emoji", () => {
expect(getNodeEmoji("Living Room", null)).toBe("\u{1FAA7}");
expect(getNodeEmoji("X", "repeater")).toBe("\u{1F4E1}");
});
});
describe("formatRelativeTime", () => {
afterEach(() => {
delete (window as { t?: unknown }).t;
});
const withT = () => {
window.t = (key: string) => key;
};
const isoAgo = (ms: number) => new Date(Date.now() - ms).toISOString();
it("returns empty string for empty/invalid input", () => {
withT();
expect(formatRelativeTime(null)).toBe("");
});
it("buckets elapsed time into relative labels", () => {
withT();
expect(formatRelativeTime(isoAgo(10 * 1000))).toBe("time.less_than_minute");
expect(formatRelativeTime(isoAgo(5 * 60 * 1000))).toBe("time.minutes_ago");
expect(formatRelativeTime(isoAgo(3 * 60 * 60 * 1000))).toBe("time.hours_ago");
expect(formatRelativeTime(isoAgo(2 * 24 * 60 * 60 * 1000))).toBe(
"time.days_ago",
);
});
});
+2 -104
View File
@@ -53,94 +53,8 @@
<link rel="stylesheet" href="/static/css/app.css?v={{ version }}">
</head>
<body class="min-h-screen bg-base-200 flex flex-col">
<!-- Navbar -->
<div class="navbar bg-base-100 shadow-lg">
<div class="navbar-start">
<a href="/" class="btn btn-ghost text-xl">
<img src="{{ logo_url }}" alt="{{ network_name }}" class="theme-logo{% if logo_invert_light %} theme-logo--invert-light{% endif %} h-6 w-6 mr-2" />
{{ network_name }}
</a>
</div>
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
<li><a href="/" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg> {{ t('entities.home') }}</a></li>
{% if features.dashboard %}
<li><a href="/dashboard" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-dashboard" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> {{ t('entities.dashboard') }}</a></li>
{% endif %}
{% if features.nodes %}
<li><a href="/nodes" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-nodes" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg> {{ t('entities.nodes') }}</a></li>
{% endif %}
{% if features.advertisements %}
<li><a href="/advertisements" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-adverts" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" /></svg> {{ t('entities.advertisements') }}</a></li>
{% endif %}
{% if features.routes %}
<li><a href="/routes" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-routes" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z" /></svg> {{ t('entities.routes') }}</a></li>
{% endif %}
{% if features.channels %}
<li><a href="/channels" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-channels" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg> {{ t('entities.channels') }}</a></li>
{% endif %}
{% if features.messages %}
<li><a href="/messages" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-messages" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg> {{ t('entities.messages') }}</a></li>
{% endif %}
{% if features.packets %}
<li><a href="/packets" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-packets" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg> {{ t('entities.packets') }}</a></li>
{% endif %}
{% if features.map %}
<li><a href="/map" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-map" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" /></svg> {{ t('entities.map') }}</a></li>
{% endif %}
{% if features.members %}
<li><a href="/members" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-members" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg> {{ t('entities.members') }}</a></li>
{% endif %}
{% if features.pages %}
{% for page in custom_pages %}
<li><a href="{{ page.url }}" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg> {{ page.title }}</a></li>
{% endfor %}
{% endif %}
</ul>
</div>
<div class="navbar-end gap-1 pr-2">
<span id="nav-loading" class="loading loading-spinner loading-sm hidden"></span>
<label class="swap swap-rotate btn btn-ghost btn-circle btn-sm">
<input type="checkbox" id="theme-toggle" />
<!-- sun icon - shown in dark mode (click to switch to light) -->
<svg class="swap-off fill-current w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"/></svg>
<!-- moon icon - shown in light mode (click to switch to dark) -->
<svg class="swap-on fill-current w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"/></svg>
</label>
{% if oidc_enabled and not system_maintenance %}
<div id="auth-section"></div>
{% endif %}
<div class="dropdown dropdown-end lg:hidden">
<div tabindex="0" role="button" class="btn btn-ghost">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /></svg>
</div>
<ul id="mobile-nav" tabindex="0" class="dropdown-content menu z-50 p-2 shadow bg-base-100 rounded-box w-56 mt-3">
</ul>
</div>
</div>
</div>
{% if system_announcement %}
<div id="system-banner" class="alert alert-error rounded-none py-2 px-4 text-center text-sm">
<div class="flash-banner-content">{{ system_announcement | safe }}</div>
</div>
{% endif %}
{% if network_announcement %}
<div id="flash-banner" class="alert alert-warning rounded-none py-2 px-4 text-center text-sm">
<div class="flash-banner-content">{{ network_announcement | safe }}</div>
<button aria-label="Dismiss" onclick="document.getElementById('flash-banner').style.display='none'; sessionStorage.setItem('flash-banner-dismissed','1')" class="btn btn-ghost btn-xs">&times;</button>
</div>
<script>
if (sessionStorage.getItem('flash-banner-dismissed') === '1') {
document.getElementById('flash-banner').style.display = 'none';
}
</script>
{% endif %}
<!-- Main Content -->
<main class="container mx-auto px-4 py-6 flex-1" id="app">
</main>
<!-- React shell: navbar, announcements, and routed page content render here -->
<div id="app" class="flex-1 flex flex-col"></div>
<!-- Footer -->
<footer class="footer p-4 bg-base-100 text-base-content mt-auto">
@@ -191,22 +105,6 @@
window.__APP_CONFIG__ = {{ config_json|safe }};
</script>
<!-- Theme toggle initialization -->
<script>
(function() {
var toggle = document.getElementById('theme-toggle');
if (toggle) {
var current = document.documentElement.getAttribute('data-theme');
toggle.checked = current === 'light';
toggle.addEventListener('change', function() {
var theme = this.checked ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('meshcore-theme', theme);
});
}
})();
</script>
<!-- SPA Application (ES Module, built by Vite) -->
{% if asset_app_js %}
<script type="module" src="/static/dist/{{ asset_app_js }}"></script>
+18 -1
View File
@@ -1,6 +1,7 @@
"""Web dashboard test fixtures."""
from typing import Any, Generator
import json
from typing import Any, Generator, cast
from unittest.mock import MagicMock, patch
import pytest
@@ -22,6 +23,22 @@ ALL_FEATURES_ENABLED = {
}
def get_app_config(html: str) -> dict[str, Any]:
"""Extract the embedded ``window.__APP_CONFIG__`` object from SPA shell HTML.
The navbar, banners, and feature-gated nav are rendered client-side by React
from this config, so web tests assert on the config rather than on
server-rendered nav HTML.
"""
marker = "window.__APP_CONFIG__ = "
start = html.index(marker) + len(marker)
script_end = html.index("</script>", start)
# Use the last ";" before </script> so semicolons inside JSON string values
# (e.g. announcement HTML) don't truncate the object.
end = html.rindex(";", start, script_end)
return cast(dict[str, Any], json.loads(html[start:end]))
class MockHttpClient:
"""Mock HTTP client for testing web routes."""
+42 -80
View File
@@ -17,7 +17,7 @@ from meshcore_hub.web.app import (
create_app,
)
from .conftest import ALL_FEATURES_ENABLED, MockHttpClient
from .conftest import ALL_FEATURES_ENABLED, MockHttpClient, get_app_config
@pytest.fixture
@@ -330,7 +330,7 @@ class TestFlashBannerVisibility:
def test_banner_present_when_announcement_set(
self, mock_http_client: MockHttpClient
) -> None:
"""Banner HTML is present when network_announcement is set."""
"""Banner content is exposed in the SPA config when network_announcement is set."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
@@ -340,23 +340,19 @@ class TestFlashBannerVisibility:
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
response = client.get("/")
assert response.status_code == 200
html = response.text
assert 'id="flash-banner"' in html
assert "Scheduled maintenance at 22:00" in html
config = get_app_config(client.get("/").text)
assert config["network_announcement"]
assert "Scheduled maintenance at 22:00" in config["network_announcement"]
def test_banner_absent_when_announcement_none(self, client: TestClient) -> None:
"""Banner HTML is absent when network_announcement is not set."""
response = client.get("/")
assert response.status_code == 200
html = response.text
assert 'id="flash-banner"' not in html
"""Banner content is absent from the config when network_announcement is not set."""
config = get_app_config(client.get("/").text)
assert not config["network_announcement"]
def test_banner_absent_for_empty_string(
self, mock_http_client: MockHttpClient
) -> None:
"""Banner is not shown when announcement is an empty string."""
"""Banner is not exposed when announcement is an empty string."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
@@ -366,14 +362,13 @@ class TestFlashBannerVisibility:
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
response = client.get("/")
assert response.status_code == 200
assert 'id="flash-banner"' not in response.text
config = get_app_config(client.get("/").text)
assert not config["network_announcement"]
def test_banner_absent_for_whitespace_only(
self, mock_http_client: MockHttpClient
) -> None:
"""Banner is not shown when announcement is whitespace-only."""
"""Banner is not exposed when announcement is whitespace-only."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
@@ -383,16 +378,15 @@ class TestFlashBannerVisibility:
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
response = client.get("/")
assert response.status_code == 200
assert 'id="flash-banner"' not in response.text
config = get_app_config(client.get("/").text)
assert not config["network_announcement"]
class TestFlashBannerMarkdown:
"""Tests for Markdown rendering in the flash banner."""
def test_bold_rendered(self, mock_http_client: MockHttpClient) -> None:
"""Markdown bold is rendered to <strong>."""
"""Markdown bold is rendered to <strong> in the config content."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
@@ -402,12 +396,11 @@ class TestFlashBannerMarkdown:
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
response = client.get("/")
assert response.status_code == 200
assert "<strong>important</strong>" in response.text
config = get_app_config(client.get("/").text)
assert "<strong>important</strong>" in config["network_announcement"]
def test_link_rendered(self, mock_http_client: MockHttpClient) -> None:
"""Markdown link is rendered to <a> tag."""
"""Markdown link is rendered to <a> tag in the config content."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
@@ -417,16 +410,18 @@ class TestFlashBannerMarkdown:
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
response = client.get("/")
assert response.status_code == 200
assert '<a href="https://example.com">click here</a>' in response.text
config = get_app_config(client.get("/").text)
assert (
'<a href="https://example.com">click here</a>'
in config["network_announcement"]
)
def test_raw_html_passed_through(self, mock_http_client: MockHttpClient) -> None:
"""Raw HTML in announcement is passed through by the Markdown library.
This is safe because the announcement source is an operator-controlled
environment variable, not user input same trust model as custom pages
in pages.py.
in pages.py. The React banner renders it via dangerouslySetInnerHTML.
"""
app = create_app(
api_url="http://localhost:8000",
@@ -437,9 +432,8 @@ class TestFlashBannerMarkdown:
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
response = client.get("/")
assert response.status_code == 200
assert "<b>bold</b>" in response.text
config = get_app_config(client.get("/").text)
assert "<b>bold</b>" in config["network_announcement"]
class TestSystemAnnouncementBanner:
@@ -448,7 +442,7 @@ class TestSystemAnnouncementBanner:
def test_system_banner_present_when_set(
self, mock_http_client: MockHttpClient
) -> None:
"""System banner HTML is present and Markdown-rendered when set."""
"""System banner content is exposed and Markdown-rendered in the config."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
@@ -458,18 +452,19 @@ class TestSystemAnnouncementBanner:
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
html = client.get("/").text
assert 'id="system-banner"' in html
assert "<strong>Outage</strong> at 22:00" in html
config = get_app_config(client.get("/").text)
assert config["system_announcement"]
assert "<strong>Outage</strong> at 22:00" in config["system_announcement"]
def test_system_banner_absent_when_none(self, client: TestClient) -> None:
"""System banner HTML is absent when not set."""
assert 'id="system-banner"' not in client.get("/").text
"""System banner content is absent from the config when not set."""
config = get_app_config(client.get("/").text)
assert not config["system_announcement"]
def test_system_banner_absent_for_empty_string(
self, mock_http_client: MockHttpClient
) -> None:
"""System banner is not shown for an empty string."""
"""System banner is not exposed for an empty string."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
@@ -479,43 +474,12 @@ class TestSystemAnnouncementBanner:
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
assert 'id="system-banner"' not in client.get("/").text
config = get_app_config(client.get("/").text)
assert not config["system_announcement"]
def test_system_banner_not_dismissable(
self, mock_http_client: MockHttpClient
) -> None:
"""System banner has no dismiss button or sessionStorage script."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
system_announcement="Heads up",
features=ALL_FEATURES_ENABLED,
)
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
html = client.get("/").text
banner = html[html.index('id="system-banner"') :]
banner = banner[: banner.index("</div>")]
assert "Dismiss" not in banner
assert "sessionStorage" not in banner
def test_system_banner_stacked_above_network_banner(
self, mock_http_client: MockHttpClient
) -> None:
"""System banner is rendered above the network announcement banner."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
system_announcement="System notice",
network_announcement="Network notice",
features=ALL_FEATURES_ENABLED,
)
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
html = client.get("/").text
assert html.index('id="system-banner"') < html.index('id="flash-banner"')
# NOTE: system-banner stacking order and the absence of a dismiss control are
# now rendering behaviour of the React <Announcements> component, covered by
# the frontend test suite (components/Announcements.test.tsx).
class TestSystemMaintenance:
@@ -532,7 +496,7 @@ class TestSystemMaintenance:
assert all(value is False for value in app.state.features.values())
def test_maintenance_nav_only_home(self, mock_http_client: MockHttpClient) -> None:
"""Desktop nav contains only Home (no feature links) in maintenance."""
"""Config exposes all features off in maintenance, so the React nav shows only Home."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
@@ -542,10 +506,8 @@ class TestSystemMaintenance:
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
html = client.get("/dashboard").text
assert 'href="/dashboard"' not in html
assert 'href="/nodes"' not in html
assert 'href="/messages"' not in html
config = get_app_config(client.get("/dashboard").text)
assert not any(config["features"].values())
def test_maintenance_flag_in_config_json(
self, mock_http_client: MockHttpClient
+8 -16
View File
@@ -25,27 +25,19 @@ class TestDashboardPage:
response = client.get("/dashboard")
assert "Test Network" in response.text
def test_dashboard_displays_stats(
def test_dashboard_serves_spa_shell(
self, client: TestClient, mock_http_client: MockHttpClient
) -> None:
"""Test that dashboard page displays statistics."""
response = client.get("/dashboard")
# Check for stats from mock response
assert response.status_code == 200
# The mock returns total_nodes: 10, active_nodes: 5, etc.
# These should be displayed in the page
assert "10" in response.text # total_nodes
assert "5" in response.text # active_nodes
"""The dashboard route serves the SPA shell.
def test_dashboard_displays_message_counts(
self, client: TestClient, mock_http_client: MockHttpClient
) -> None:
"""Test that dashboard page displays message counts."""
Dashboard statistics are fetched and rendered client-side by React from
the API, so they are not present in the server-rendered shell; we assert
the mount point and embedded config instead.
"""
response = client.get("/dashboard")
assert response.status_code == 200
# Mock returns total_messages: 100, messages_today: 15
assert "100" in response.text
assert "15" in response.text
assert 'id="app"' in response.text
assert "window.__APP_CONFIG__" in response.text
class TestDashboardPageAPIErrors:
+46 -71
View File
@@ -1,12 +1,14 @@
"""Tests for feature flags functionality."""
import json
import pytest
from fastapi.testclient import TestClient
from meshcore_hub.web.app import create_app
from tests.test_web.conftest import ALL_FEATURES_ENABLED, MockHttpClient
from tests.test_web.conftest import (
ALL_FEATURES_ENABLED,
MockHttpClient,
get_app_config,
)
class TestFeatureFlagsConfig:
@@ -16,11 +18,7 @@ class TestFeatureFlagsConfig:
"""All non-OIDC features should be enabled by default in config JSON."""
response = client.get("/")
assert response.status_code == 200
html = response.text
# Extract config JSON from script tag
start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
end = html.index(";", start)
config = json.loads(html[start:end])
config = get_app_config(response.text)
features = config["features"]
non_oidc_features = {k: v for k, v in features.items() if k != "members"}
assert all(
@@ -30,10 +28,7 @@ class TestFeatureFlagsConfig:
def test_features_dict_has_all_keys(self, client: TestClient) -> None:
"""Features dict should have all 7 expected keys."""
response = client.get("/")
html = response.text
start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
end = html.index(";", start)
config = json.loads(html[start:end])
config = get_app_config(response.text)
features = config["features"]
expected_keys = {
"dashboard",
@@ -49,45 +44,41 @@ class TestFeatureFlagsConfig:
def test_disabled_features_in_config(self, client_no_features: TestClient) -> None:
"""Disabled features should be false in config JSON."""
response = client_no_features.get("/")
html = response.text
start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
end = html.index(";", start)
config = json.loads(html[start:end])
config = get_app_config(response.text)
features = config["features"]
assert all(not v for v in features.values()), "All features should be disabled"
class TestFeatureFlagsNav:
"""Test feature flags affect navigation."""
"""Test feature flags affect navigation (via the SPA config the nav reads)."""
def test_enabled_features_show_nav_links(self, client: TestClient) -> None:
"""Enabled features should show nav links."""
response = client.get("/")
html = response.text
assert 'href="/dashboard"' in html
assert 'href="/nodes"' in html
assert 'href="/advertisements"' in html
assert 'href="/messages"' in html
assert 'href="/map"' in html
"""Enabled features should be true in config so the React nav shows them."""
config = get_app_config(client.get("/").text)
features = config["features"]
for key in ("dashboard", "nodes", "advertisements", "messages", "map"):
assert features[key] is True
def test_disabled_features_hide_nav_links(
self, client_no_features: TestClient
) -> None:
"""Disabled features should not show nav links."""
response = client_no_features.get("/")
html = response.text
assert 'href="/dashboard"' not in html
assert 'href="/nodes"' not in html
assert 'href="/advertisements"' not in html
assert 'href="/messages"' not in html
assert 'href="/map"' not in html
assert 'href="/members"' not in html
"""Disabled features should be false in config so the React nav hides them."""
config = get_app_config(client_no_features.get("/").text)
features = config["features"]
for key in (
"dashboard",
"nodes",
"advertisements",
"messages",
"map",
"members",
):
assert features[key] is False
def test_home_link_always_present(self, client_no_features: TestClient) -> None:
"""Home link should always be present."""
"""The SPA mount (where the always-present Home nav renders) is in the shell."""
response = client_no_features.get("/")
html = response.text
assert 'href="/"' in html
assert 'id="app"' in response.text
class TestFeatureFlagsEndpoints:
@@ -118,11 +109,7 @@ class TestFeatureFlagsEndpoints:
self, client_no_features: TestClient
) -> None:
"""Custom pages should be empty in config when pages feature is disabled."""
response = client_no_features.get("/")
html = response.text
start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
end = html.index(";", start)
config = json.loads(html[start:end])
config = get_app_config(client_no_features.get("/").text)
assert config["custom_pages"] == []
@@ -213,18 +200,18 @@ class TestPacketsFeatureFlag:
) -> None:
"""The packets nav link is absent when the feature is off."""
client = self._make_app(mock_http_client, packets=False)
html = client.get("/").text
assert 'href="/packets"' not in html
config = get_app_config(client.get("/").text)
assert config["features"]["packets"] is False
# Messages still shows (ordering sanity)
assert 'href="/messages"' in html
assert config["features"]["messages"] is True
def test_packets_nav_shown_when_enabled(
self, mock_http_client: MockHttpClient
) -> None:
"""The packets nav link appears when the feature is on."""
client = self._make_app(mock_http_client, packets=True)
html = client.get("/").text
assert 'href="/packets"' in html
config = get_app_config(client.get("/").text)
assert config["features"]["packets"] is True
def test_packets_enabled_by_default_in_settings(self) -> None:
"""The declared default for feature_packets is True (env-independent)."""
@@ -266,11 +253,10 @@ class TestFeatureFlagsIndividual:
def test_disable_map_only(self, _make_client) -> None:
"""Disabling only map should hide map but show others."""
client = _make_client("map")
response = client.get("/")
html = response.text
assert 'href="/map"' not in html
assert 'href="/dashboard"' in html
assert 'href="/nodes"' in html
config = get_app_config(client.get("/").text)
assert config["features"]["map"] is False
assert config["features"]["dashboard"] is True
assert config["features"]["nodes"] is True
# Map data endpoint should 404
response = client.get("/map/data")
@@ -279,11 +265,10 @@ class TestFeatureFlagsIndividual:
def test_disable_dashboard_only(self, _make_client) -> None:
"""Disabling only dashboard should hide dashboard but show others."""
client = _make_client("dashboard")
response = client.get("/")
html = response.text
assert 'href="/dashboard"' not in html
assert 'href="/nodes"' in html
assert 'href="/map"' in html
config = get_app_config(client.get("/").text)
assert config["features"]["dashboard"] is False
assert config["features"]["nodes"] is True
assert config["features"]["map"] is True
class TestDashboardAutoDisable:
@@ -310,12 +295,7 @@ class TestDashboardAutoDisable:
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
response = client.get("/")
html = response.text
assert 'href="/dashboard"' not in html
# Check config JSON also reflects it
config = json.loads(html.split("window.__APP_CONFIG__ = ")[1].split(";")[0])
config = get_app_config(client.get("/").text)
assert config["features"]["dashboard"] is False
def test_map_auto_disabled_when_nodes_off(
@@ -339,12 +319,7 @@ class TestDashboardAutoDisable:
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
response = client.get("/")
html = response.text
assert 'href="/map"' not in html
# Check config JSON also reflects it
config = json.loads(html.split("window.__APP_CONFIG__ = ")[1].split(";")[0])
config = get_app_config(client.get("/").text)
assert config["features"]["map"] is False
# Map data endpoint should 404
@@ -372,5 +347,5 @@ class TestDashboardAutoDisable:
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
response = client.get("/")
assert 'href="/dashboard"' in response.text
config = get_app_config(client.get("/").text)
assert config["features"]["dashboard"] is True
-7
View File
@@ -76,13 +76,6 @@ class TestHomePage:
response = client.get("/")
assert "discord.gg/test" in response.text
def test_home_contains_navigation(self, client: TestClient) -> None:
"""Test that home page contains navigation links."""
response = client.get("/")
assert 'href="/"' in response.text
assert 'href="/nodes"' in response.text
assert 'href="/messages"' in response.text
def test_home_contains_spa_mount(self, client: TestClient) -> None:
"""Test that home page renders the React SPA mount point."""
response = client.get("/")
+11 -17
View File
@@ -1,6 +1,5 @@
"""Tests for custom pages functionality (SPA)."""
import json
import tempfile
from collections.abc import Generator
from pathlib import Path
@@ -10,6 +9,7 @@ import pytest
from fastapi.testclient import TestClient
from meshcore_hub.web.pages import CustomPage, PageLoader
from tests.test_web.conftest import get_app_config
class TestCustomPage:
@@ -484,32 +484,26 @@ Here are some answers.
assert "Frequently Asked Questions" in data["content_html"]
def test_pages_in_navigation(self, client_with_pages: TestClient) -> None:
"""Test that custom pages appear in navigation."""
"""Test that custom pages are exposed for the React navigation."""
response = client_with_pages.get("/")
assert response.status_code == 200
# Check for navigation links
assert 'href="/pages/about"' in response.text
assert 'href="/pages/faq"' in response.text
config = get_app_config(response.text)
urls = [p["url"] for p in config["custom_pages"]]
assert "/pages/about" in urls
assert "/pages/faq" in urls
def test_pages_sorted_in_navigation(self, client_with_pages: TestClient) -> None:
"""Test that pages are sorted by menu_order in navigation."""
"""Test that pages are sorted by menu_order for the React navigation."""
response = client_with_pages.get("/")
assert response.status_code == 200
config = get_app_config(response.text)
urls = [p["url"] for p in config["custom_pages"]]
# About (order 10) should appear before FAQ (order 20)
about_pos = response.text.find('href="/pages/about"')
faq_pos = response.text.find('href="/pages/faq"')
assert about_pos < faq_pos
assert urls.index("/pages/about") < urls.index("/pages/faq")
def test_pages_in_config(self, client_with_pages: TestClient) -> None:
"""Test that custom pages are included in SPA config."""
response = client_with_pages.get("/")
text = response.text
config_start = text.find("window.__APP_CONFIG__ = ") + len(
"window.__APP_CONFIG__ = "
)
config_end = text.find(";", config_start)
config = json.loads(text[config_start:config_end])
config = get_app_config(client_with_pages.get("/").text)
custom_pages = config["custom_pages"]
assert len(custom_pages) == 2
slugs = [p["slug"] for p in custom_pages]
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { resolve } from "node:path";
const SPA_REACT = resolve(
__dirname,
"src/meshcore_hub/web/static/js/spa-react",
);
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": SPA_REACT,
},
},
test: {
environment: "jsdom",
include: ["src/meshcore_hub/web/static/js/spa-react/**/*.test.{ts,tsx}"],
setupFiles: ["src/meshcore_hub/web/static/js/spa-react/test/setup.ts"],
},
});