fix(web): path-node popup scrolls with page; wire frontend tests/typecheck into make + pre-commit

- PacketGroupDetail: render the path-node popover via createPortal(document.body)
  with position:absolute + document coordinates (rect + scrollX/scrollY) instead of
  position:fixed with one-shot viewport coords, so it scrolls with the page rather
  than staying pinned to the viewport. Outside-click/Escape close unchanged (DOM-based).
- Makefile: 'make test' now runs pytest then the frontend vitest suite via a new
  'test-frontend' target (npm run test:frontend).
- pre-commit: add a local 'frontend-typecheck' hook (language: system) running
  'npm run typecheck' (tsc --noEmit) on TS/TSX + tsconfig/package(-lock).json changes.
- AGENTS.md: document the pre-commit TS gate and that 'make test' includes the frontend.

Verified: tsc clean, vitest 49 passed, pre-commit --all-files green, make test
(1459 backend passed + 49 frontend passed).
This commit is contained in:
Louis King committed 2026-07-21 20:25:50 +01:00
1 parent 527c860bf8
commit bf8e0620a5
4 files changed
+100 -75

No files matched your search

+12
View File
@@ -41,3 +41,15 @@ repos:
- alembic>=1.7.0
- types-paho-mqtt>=1.6.0
- types-PyYAML>=6.0.0
# Frontend TypeScript gate. Runs in the host node toolchain (language: system)
# so it resolves types from the project's node_modules — requires `npm install`.
# `pass_filenames: false`: tsc typechecks the whole project (tsconfig include).
- repo: local
hooks:
- id: frontend-typecheck
name: frontend typecheck (tsc --noEmit)
entry: npm run typecheck
language: system
pass_filenames: false
files: '(spa-react/.*\.tsx?$|^tsconfig\.json$|^package(-lock)?\.json$)'
+7 -5
View File
@@ -47,8 +47,9 @@ The web UI is a **React 19 + TypeScript + Vite** SPA in
(`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;
`static/dist/` + `assets.json`), `npx tsc --noEmit` (the TS gate — also run by the
`frontend-typecheck` pre-commit hook; 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
@@ -84,9 +85,10 @@ Coverage is **opt-in**; add `--cov=meshcore_hub` (or `make test-cov`) when you w
pytest -nauto --no-cov 2>&1 | grep -iE "passed|failed" | tail -3
# Makefile shorthands
make test # pytest -nauto --no-cov (parallel dev loop)
make test-cov # full run with coverage report
make test-unit # parallel, fast unit suites only (skips e2e)
make test # backend (pytest -nauto --no-cov) then frontend vitest
make test-cov # full backend run with coverage report
make test-unit # parallel, fast unit suites only (skips e2e)
make test-frontend # frontend vitest only (npm run test:frontend)
# Targeted by component (run only what you changed)
pytest --no-cov tests/test_web/ # templates, static JS, web routes
+6 -1
View File
@@ -4,7 +4,7 @@ COMPOSE_FILES = -f docker-compose.yml -f docker-compose.dev.yml
VOLUMES = $(COMPOSE_PROJECT_NAME)_data $(COMPOSE_PROJECT_NAME)_mqtt_data \
$(COMPOSE_PROJECT_NAME)_observer_data
.PHONY: build up down logs backup restore test test-cov test-unit
.PHONY: build up down logs backup restore test test-cov test-unit test-frontend
build:
docker compose $(COMPOSE_FILES) --profile all build --no-cache
@@ -36,11 +36,16 @@ restore:
# --- Tests ---------------------------------------------------------------
# Coverage is opt-in (use test-cov). Dev loop runs in parallel across cores.
# `test` runs the backend suite then the frontend (vitest) suite.
test:
pytest -nauto --no-cov
$(MAKE) test-frontend
test-cov:
pytest --cov=meshcore_hub --cov-report=term-missing
test-unit:
pytest -nauto --no-cov tests/test_common/ tests/test_api/ tests/test_collector/ tests/test_web/
test-frontend:
npm run test:frontend
@@ -5,6 +5,7 @@ import {
useState,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Link, useParams } from "react-router";
import { useAppConfig } from "@/context/AppConfigContext";
@@ -351,7 +352,10 @@ export function PacketGroupDetail() {
) {
top = popover.top - ph - 4;
}
setPopoverPos({ left, top });
setPopoverPos({
left: left + window.scrollX,
top: top + window.scrollY,
});
}, [popover, popoverNodes, popoverError]);
const openPathPopover = (e: React.MouseEvent, pathHash: string) => {
@@ -629,74 +633,76 @@ export function PacketGroupDetail() {
</>
)}
{popover && (
<div
ref={popoverRef}
className="fixed z-[1000] w-64 max-w-[90vw] max-h-[60vh] overflow-y-auto bg-base-100 rounded-box shadow-lg border border-base-300"
style={{
left: popoverPos?.left ?? -9999,
top: popoverPos?.top ?? -9999,
}}
>
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-base-200 sticky top-0 bg-base-100 rounded-t-box">
<span className="text-xs font-semibold uppercase opacity-70">
{t("packets.path_nodes_title", { hash: popover.hash })}
</span>
<button
className="btn btn-xs btn-ghost btn-circle"
aria-label={t("common.close")}
onClick={() => setPopover(null)}
>
✕
</button>
</div>
<div>
{popoverError ? (
<div className="p-3">
<WarningBadge message={popoverError} />
</div>
) : popoverNodes === null ? (
<div className="p-3">
<Loading />
</div>
) : popoverNodes.length === 0 ? (
<div className="px-3 py-4 text-sm opacity-60 text-center">
{t("packets.path_no_nodes")}
</div>
) : (
<ul className="menu menu-sm w-full">
{popoverNodes.map((n) => (
<li key={n.public_key}>
<Link
to={`/nodes/${n.public_key}`}
onClick={() => setPopover(null)}
className="flex flex-col items-start gap-0"
>
<span className="text-sm">{nodeDisplayName(n)}</span>
<span className="font-mono text-xs opacity-50">
{truncateKey(n.public_key, 16)}
</span>
</Link>
</li>
))}
{moreCount > 0 && (
<li>
<Link
to={`/nodes?pubkey_prefix=${popover.hash}`}
onClick={() => setPopover(null)}
className="text-xs opacity-70"
>
{t("packets.path_nodes_more", {
count: formatNumber(moreCount),
})}
</Link>
</li>
)}
</ul>
)}
</div>
</div>
)}
{popover &&
createPortal(
<div
ref={popoverRef}
className="absolute z-[1000] w-64 max-w-[90vw] max-h-[60vh] overflow-y-auto bg-base-100 rounded-box shadow-lg border border-base-300"
style={{
left: popoverPos?.left ?? -9999,
top: popoverPos?.top ?? -9999,
}}
>
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-base-200 sticky top-0 bg-base-100 rounded-t-box">
<span className="text-xs font-semibold uppercase opacity-70">
{t("packets.path_nodes_title", { hash: popover.hash })}
</span>
<button
className="btn btn-xs btn-ghost btn-circle"
aria-label={t("common.close")}
onClick={() => setPopover(null)}
>
✕
</button>
</div>
<div>
{popoverError ? (
<div className="p-3">
<WarningBadge message={popoverError} />
</div>
) : popoverNodes === null ? (
<div className="p-3">
<Loading />
</div>
) : popoverNodes.length === 0 ? (
<div className="px-3 py-4 text-sm opacity-60 text-center">
{t("packets.path_no_nodes")}
</div>
) : (
<ul className="menu menu-sm w-full">
{popoverNodes.map((n) => (
<li key={n.public_key}>
<Link
to={`/nodes/${n.public_key}`}
onClick={() => setPopover(null)}
className="flex flex-col items-start gap-0"
>
<span className="text-sm">{nodeDisplayName(n)}</span>
<span className="font-mono text-xs opacity-50">
{truncateKey(n.public_key, 16)}
</span>
</Link>
</li>
))}
{moreCount > 0 && (
<li>
<Link
to={`/nodes?pubkey_prefix=${popover.hash}`}
onClick={() => setPopover(null)}
className="text-xs opacity-70"
>
{t("packets.path_nodes_more", {
count: formatNumber(moreCount),
})}
</Link>
</li>
)}
</ul>
)}
</div>
</div>,
document.body,
)}
</div>
);
}