mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-09-16 12:02:35 +00:00
Fix mobile layouts across frontend (#10)
Bound mobile navigation, controls, searches, and overlays while adding route-wide responsive browser coverage.
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
name: Frontend mobile
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- '.github/workflows/frontend-mobile.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- run: npm run test:mobile
|
||||
@@ -3,6 +3,8 @@ node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
frontend/test-results/
|
||||
frontend/playwright-report/
|
||||
|
||||
# Environment variables — never commit real credentials
|
||||
.env
|
||||
|
||||
Generated
+60
@@ -22,6 +22,7 @@
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
@@ -1422,6 +1423,21 @@
|
||||
"@math.gl/core": "4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
||||
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@probe.gl/env": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@probe.gl/env/-/env-4.1.1.tgz",
|
||||
@@ -2969,6 +2985,50 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/polygon-clipping": {
|
||||
"version": "0.15.7",
|
||||
"resolved": "https://registry.npmjs.org/polygon-clipping/-/polygon-clipping-0.15.7.tgz",
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test:mobile": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deck.gl/core": "^9.1.0",
|
||||
@@ -23,6 +24,7 @@
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './test/e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: Boolean(process.env['CI']),
|
||||
retries: process.env['CI'] ? 1 : 0,
|
||||
reporter: process.env['CI'] ? 'github' : 'list',
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
webServer: [
|
||||
{
|
||||
command: 'VITE_APP_HOSTNAME=app.invalid VITE_SITE=ukmesh npm run dev -- --host 127.0.0.1 --port 4173',
|
||||
port: 4173,
|
||||
reuseExistingServer: !process.env['CI'],
|
||||
},
|
||||
{
|
||||
command: 'VITE_APP_HOSTNAME=127.0.0.1 VITE_SITE=ukmesh VITE_NETWORK=ukmesh npm run dev -- --host 127.0.0.1 --port 4174',
|
||||
port: 4174,
|
||||
reuseExistingServer: !process.env['CI'],
|
||||
},
|
||||
{
|
||||
command: 'VITE_APP_HOSTNAME=app.invalid VITE_SITE=dev npm run dev -- --host 127.0.0.1 --port 4175',
|
||||
port: 4175,
|
||||
reuseExistingServer: !process.env['CI'],
|
||||
},
|
||||
{
|
||||
command: 'VITE_APP_HOSTNAME=app.invalid VITE_SITE=teesside npm run dev -- --host 127.0.0.1 --port 4176',
|
||||
port: 4176,
|
||||
reuseExistingServer: !process.env['CI'],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -727,8 +727,8 @@ export const StatsPage: React.FC = () => {
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="Packet types" sub="last 24 hours · all observer hits">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<ResponsiveContainer width="50%" height={220}>
|
||||
<div className="stats-page__pie-layout">
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data.packetTypes}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Link, NavLink, Outlet } from 'react-router-dom';
|
||||
|
||||
type SiteLayoutProps = {
|
||||
brandName: string;
|
||||
@@ -53,6 +53,7 @@ export const SiteLayout: React.FC<SiteLayoutProps> = ({
|
||||
}) => {
|
||||
const COOKIE_CONSENT_KEY = 'meshcore-cookie-consent-v1';
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
const [ownerLabel, setOwnerLabel] = useState<string | null>(null);
|
||||
const [cookieConsent, setCookieConsent] = useState<boolean>(() => {
|
||||
try {
|
||||
@@ -61,8 +62,6 @@ export const SiteLayout: React.FC<SiteLayoutProps> = ({
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ to: '/feed', label: 'Feed', enabled: showFeed },
|
||||
{ to: '/repeater', label: 'Repeaters', enabled: showRepeaterSearch },
|
||||
@@ -77,10 +76,24 @@ export const SiteLayout: React.FC<SiteLayoutProps> = ({
|
||||
];
|
||||
|
||||
const closeMenu = () => setMenuOpen(false);
|
||||
const handleNavClick = (to: string) => {
|
||||
closeMenu();
|
||||
navigate(to);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return undefined;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') closeMenu();
|
||||
};
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!navRef.current?.contains(event.target as Node)) closeMenu();
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener('pointerdown', handlePointerDown);
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -125,20 +138,23 @@ export const SiteLayout: React.FC<SiteLayoutProps> = ({
|
||||
|
||||
return (
|
||||
<div className="site-layout">
|
||||
<nav className="site-nav">
|
||||
<nav ref={navRef} className="site-nav">
|
||||
<Link to="/" className="site-nav__brand" onClick={closeMenu}>
|
||||
<span className="site-nav__icon">◈</span>
|
||||
<span className="site-nav__name">{brandName}</span>
|
||||
</Link>
|
||||
|
||||
<div className={`site-nav__links${menuOpen ? ' site-nav__links--open' : ''}`}>
|
||||
<div
|
||||
id="site-navigation"
|
||||
className={`site-nav__links${menuOpen ? ' site-nav__links--open' : ''}`}
|
||||
>
|
||||
{showLiveMap && <a href={appUrl} className="site-nav__link site-nav__link--map">Live Map ↗</a>}
|
||||
{navItems.filter((item) => item.enabled).map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
onClick={() => handleNavClick(item.to)}
|
||||
onClick={closeMenu}
|
||||
className={navClassName}
|
||||
>
|
||||
{item.label}
|
||||
@@ -148,7 +164,7 @@ export const SiteLayout: React.FC<SiteLayoutProps> = ({
|
||||
<a href="https://flasher.ukmesh.com" className="site-nav__link">Flasher</a>
|
||||
<NavLink
|
||||
to="/login"
|
||||
onClick={() => handleNavClick('/login')}
|
||||
onClick={closeMenu}
|
||||
className={({ isActive }) => isActive ? 'site-nav__app-btn site-nav__app-btn--active' : 'site-nav__app-btn'}
|
||||
>
|
||||
{ownerLabel ?? 'Login'}
|
||||
@@ -156,8 +172,11 @@ export const SiteLayout: React.FC<SiteLayoutProps> = ({
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="site-nav__hamburger"
|
||||
aria-label={menuOpen ? 'Close menu' : 'Open menu'}
|
||||
aria-controls="site-navigation"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
>
|
||||
{menuOpen ? '✕' : '☰'}
|
||||
|
||||
+105
-35
@@ -208,7 +208,10 @@ html, body, #root {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
max-height: min(280px, 40dvh);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.node-search__result {
|
||||
@@ -978,6 +981,7 @@ html, body, #root {
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.disclaimer-modal {
|
||||
background: var(--bg-panel);
|
||||
@@ -986,6 +990,10 @@ html, body, #root {
|
||||
padding: 28px 32px;
|
||||
max-width: 520px;
|
||||
width: 100%;
|
||||
max-height: calc(100dvh - 32px);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.disclaimer-modal__title {
|
||||
font-family: var(--font-mono);
|
||||
@@ -1066,6 +1074,9 @@ html, body, #root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.stats-page__path-modal-header {
|
||||
@@ -1088,6 +1099,7 @@ html, body, #root {
|
||||
margin: 6px 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.stats-page__path-modal-close {
|
||||
@@ -3780,6 +3792,51 @@ html, body, #root {
|
||||
}
|
||||
.site-nav__hamburger:hover { color: var(--text-primary); background: var(--bg-hover); }
|
||||
|
||||
/* Switch the shared navigation to a compact popover before links start
|
||||
wrapping. The two-column layout keeps every site journey reachable without
|
||||
covering the page on phone-sized or small-tablet viewports. */
|
||||
@media (max-width: 860px) {
|
||||
.site-nav__hamburger { display: flex; }
|
||||
|
||||
.site-nav__links {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 12px;
|
||||
width: min(360px, calc(100vw - 24px));
|
||||
max-height: min(56dvh, 360px);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-bright);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.55);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.site-nav__links--open { display: grid; }
|
||||
|
||||
.site-nav__link,
|
||||
.site-nav__links .site-nav__app-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
margin: 0;
|
||||
padding: 9px 10px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════ */
|
||||
/* ─── Mobile (≤640px) ────────────────────────────────────────────────────── */
|
||||
/* ══════════════════════════════════════════════════════════════════════════ */
|
||||
@@ -3810,6 +3867,9 @@ html, body, #root {
|
||||
.mobile-filter-grid .filter-row { padding: 8px 6px; }
|
||||
.mobile-filter-wrap {
|
||||
border-top: 1px solid var(--border);
|
||||
max-height: min(40dvh, 280px);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.mobile-filter-wrap--hidden {
|
||||
display: none;
|
||||
@@ -3867,42 +3927,12 @@ html, body, #root {
|
||||
/* ── Packet feed: full width at bottom, max 4 items ─────────────────── */
|
||||
.packet-feed { width: calc(100% - 32px); max-height: 180px; }
|
||||
|
||||
/* ── Website nav: keep bar fixed height, dropdown pops below it ─────── */
|
||||
/* ── Website nav ────────────────────────────────────────────────────── */
|
||||
.site-nav {
|
||||
height: 52px;
|
||||
padding: 0 16px;
|
||||
gap: 12px;
|
||||
position: sticky; /* already sticky — needed for dropdown anchor */
|
||||
}
|
||||
.site-nav__hamburger { display: flex; }
|
||||
|
||||
/* Dropdown pops out absolutely below the nav bar */
|
||||
.site-nav__links {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 52px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
flex-direction: column;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 8px 16px 14px;
|
||||
gap: 0;
|
||||
z-index: 999;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.site-nav__links--open { display: flex; }
|
||||
.site-nav__link { padding: 12px 4px; font-size: 14px; border-bottom: 1px solid var(--border); }
|
||||
/* App button inside dropdown: reset to plain link style */
|
||||
.site-nav__links .site-nav__app-btn {
|
||||
margin-left: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
padding: 12px 4px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
.cookie-banner {
|
||||
@@ -3945,7 +3975,7 @@ html, body, #root {
|
||||
.dev-status-list > div { flex-direction: column; align-items: flex-start; }
|
||||
.dev-status-list strong { text-align: left; }
|
||||
.dev-status-card--fixed { height: auto; }
|
||||
.site-prose { padding: 24px 0 56px; }
|
||||
.site-prose { padding: 24px 16px 56px; }
|
||||
.site-prose--wide { max-width: 100%; }
|
||||
.prose-section--muted { padding: 20px; }
|
||||
.owner-summary-grid { grid-template-columns: 1fr; }
|
||||
@@ -3960,6 +3990,30 @@ html, body, #root {
|
||||
.owner-roadmap { grid-template-columns: 1fr; }
|
||||
.owner-head { flex-direction: column; align-items: flex-start; }
|
||||
.owner-map { height: 260px; }
|
||||
.packet-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.site-footer { justify-content: flex-start; padding: 12px 16px; }
|
||||
|
||||
.disclaimer-overlay { padding: 10px; }
|
||||
.disclaimer-modal {
|
||||
max-height: calc(100dvh - 20px);
|
||||
padding: 20px;
|
||||
}
|
||||
.stats-page__path-modal {
|
||||
max-height: calc(100dvh - 20px);
|
||||
padding: 14px;
|
||||
gap: 12px;
|
||||
}
|
||||
.stats-page__path-modal-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
.stats-page__path-modal-close {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.stats-page__path-modal-map { height: min(46dvh, 320px); }
|
||||
.stats-page__path-modal-list { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
@@ -4306,6 +4360,13 @@ html, body, #root {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.stats-page__pie-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(140px, 1fr);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stats-page__pie-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -4357,6 +4418,14 @@ html, body, #root {
|
||||
.stats-page__observer-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.stats-page__pie-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.stats-page__chart-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Repeater Search Page */
|
||||
@@ -4391,8 +4460,9 @@ html, body, #root {
|
||||
border: 1px solid var(--border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
max-height: 320px;
|
||||
max-height: min(320px, 46dvh);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
const PHONE = { width: 375, height: 667 };
|
||||
|
||||
const chartStats = {
|
||||
packetsPerHour: [{ hour: '12:00', count: 24 }],
|
||||
packetsPerDay: [{ day: 'Mon', count: 120 }],
|
||||
radiosPerHour: [{ hour: '12:00', count: 8 }],
|
||||
radiosPerDay: [{ day: 'Mon', count: 32 }],
|
||||
packetTypes: [{ label: 'Advert', count: 80 }, { label: 'Group', count: 40 }],
|
||||
channelTraffic: [{ channel: 'Public', count: 40, pct: 50, allPct: 33 }],
|
||||
repeatersPerDay: [{ hour: '12:00', count: 20 }],
|
||||
hopDistribution: [{ hops: 1, count: 60 }],
|
||||
prefixCollisions: [{ prefix: 'ab', repeats: 3 }],
|
||||
observerRegions: [],
|
||||
pathHashes: {
|
||||
last24hHops: { one_byte: 30, two_byte: 20, three_byte: 10 },
|
||||
multibytePackets24h: 30,
|
||||
fullyDecodedMultibyte24h: 20,
|
||||
latestMultibyteAt: null,
|
||||
latestMultibyteHash: null,
|
||||
latestFullyDecodedAt: null,
|
||||
latestFullyDecodedHash: 'fixture-path',
|
||||
latestFullyDecodedHops: 1,
|
||||
latestFullyDecodedPath: 'North -> South',
|
||||
latestFullyDecodedNodes: [
|
||||
{ ord: 1, node_id: 'node-north', name: 'North', lat: 54.5, lon: -1.2 },
|
||||
{ ord: 2, node_id: 'node-south', name: 'South', lat: 53.5, lon: -1.1 },
|
||||
],
|
||||
longestFullyDecodedAt: null,
|
||||
longestFullyDecodedHash: null,
|
||||
longestFullyDecodedHops: null,
|
||||
longestFullyDecodedPath: null,
|
||||
longestFullyDecodedNodes: [],
|
||||
},
|
||||
summary: {
|
||||
totalPackets24h: 120,
|
||||
totalPackets7d: 720,
|
||||
uniqueRadios24h: 32,
|
||||
activeRepeaters: 20,
|
||||
staleRepeaters: 2,
|
||||
peakHour: '12:00',
|
||||
peakHourCount: 24,
|
||||
},
|
||||
};
|
||||
|
||||
const health = {
|
||||
system: {
|
||||
generated_at: '2026-07-18T12:00:00Z',
|
||||
cpu: { load_1m: 0.2, count: 2, load_pct: 10, usage_pct: 12 },
|
||||
memory: { total_mb: 1024, used_mb: 512, used_pct: 50 },
|
||||
disk: { total_gb: 20, used_gb: 8, used_pct: 40 },
|
||||
runtime: { uptime_s: 3600, node_version: 'v20', platform: 'linux', arch: 'x64' },
|
||||
},
|
||||
workers: [],
|
||||
frontend_errors_1h: 0,
|
||||
ingest: {
|
||||
stale_nodes: 0,
|
||||
active_nodes: 2,
|
||||
max_stale_minutes: 0,
|
||||
stale_threshold_minutes: 10,
|
||||
global_last_packet_at: '2026-07-18T12:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
const repeaterNodes = Array.from({ length: 12 }, (_, index) => ({
|
||||
node_id: `node-${index}`,
|
||||
name: `Repeater ${String(index + 1).padStart(2, '0')}`,
|
||||
lat: 54 + index * 0.01,
|
||||
lon: -1 - index * 0.01,
|
||||
iata: 'TST',
|
||||
role: 2,
|
||||
last_seen: '2026-07-18T12:00:00Z',
|
||||
is_online: true,
|
||||
public_key: `${String(index).padStart(2, '0')}${'a'.repeat(62)}`,
|
||||
}));
|
||||
|
||||
async function installApiFixtures(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('meshcore-cookie-consent-v1', '1');
|
||||
localStorage.setItem('meshcore-disclaimer-dismissed', '1');
|
||||
});
|
||||
|
||||
await page.route('**/api/**', async (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
if (pathname === '/api/stats/charts') return route.fulfill({ json: chartStats });
|
||||
if (pathname === '/api/stats') {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
packetsDay: 120,
|
||||
totalNodes: 32,
|
||||
internationalNodes: 1,
|
||||
internationalLastSeen: '2026-07-18T12:00:00Z',
|
||||
internationalLastCountry: 'France',
|
||||
mqttNodes: 20,
|
||||
mapNodes: 30,
|
||||
staleNodes: 2,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (pathname === '/api/health') return route.fulfill({ json: health });
|
||||
if (pathname.endsWith('/packets/recent')) return route.fulfill({ json: [] });
|
||||
if (pathname.endsWith('/path-beta/history')) return route.fulfill({ json: { segments: [] } });
|
||||
if (pathname.endsWith('/inferred-nodes')) {
|
||||
return route.fulfill({ json: { inferredNodes: [], inferredActiveNodeIds: [] } });
|
||||
}
|
||||
if (pathname === '/api/nodes') return route.fulfill({ json: repeaterNodes });
|
||||
if (pathname === '/api/node-status/latest') return route.fulfill({ json: [] });
|
||||
if (pathname === '/api/companion-activity') return route.fulfill({ json: [] });
|
||||
if (pathname === '/api/owner/session') return route.fulfill({ json: { ok: false } });
|
||||
return route.fulfill({ json: {} });
|
||||
});
|
||||
}
|
||||
|
||||
async function expectNoViewportOverflow(page: Page) {
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
viewportWidth: window.innerWidth,
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
bodyWidth: document.body.scrollWidth,
|
||||
}));
|
||||
expect(dimensions.documentWidth).toBeLessThanOrEqual(dimensions.viewportWidth);
|
||||
expect(dimensions.bodyWidth).toBeLessThanOrEqual(dimensions.viewportWidth);
|
||||
}
|
||||
|
||||
const routedViews = [
|
||||
['UK home', 'http://127.0.0.1:4173/'],
|
||||
['UK feed', 'http://127.0.0.1:4173/feed'],
|
||||
['UK repeaters', 'http://127.0.0.1:4173/repeater'],
|
||||
['UK companions', 'http://127.0.0.1:4173/companion'],
|
||||
['UK install', 'http://127.0.0.1:4173/install'],
|
||||
['UK open source', 'http://127.0.0.1:4173/open-source'],
|
||||
['UK stats', 'http://127.0.0.1:4173/stats'],
|
||||
['UK login', 'http://127.0.0.1:4173/login'],
|
||||
['Teesside install', 'http://127.0.0.1:4176/install'],
|
||||
['Teesside packets', 'http://127.0.0.1:4176/packets'],
|
||||
['Teesside open source', 'http://127.0.0.1:4176/open-source'],
|
||||
['Teesside stats', 'http://127.0.0.1:4176/stats'],
|
||||
['Teesside login', 'http://127.0.0.1:4176/login'],
|
||||
['test diagnostics', 'http://127.0.0.1:4175/'],
|
||||
] as const;
|
||||
|
||||
for (const [name, url] of routedViews) {
|
||||
test(`${name} fits a phone viewport`, async ({ page }) => {
|
||||
await page.setViewportSize(PHONE);
|
||||
await installApiFixtures(page);
|
||||
const pageErrors: string[] = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
await expectNoViewportOverflow(page);
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('shared navigation stays compact and dismisses without navigation', async ({ page }) => {
|
||||
await page.setViewportSize(PHONE);
|
||||
await installApiFixtures(page);
|
||||
await page.goto('http://127.0.0.1:4173/feed', { waitUntil: 'networkidle' });
|
||||
|
||||
const openMenu = page.getByRole('button', { name: 'Open menu' });
|
||||
await openMenu.click();
|
||||
const menu = page.locator('#site-navigation');
|
||||
await expect(menu).toBeVisible();
|
||||
const box = await menu.boundingBox();
|
||||
expect(box?.height ?? PHONE.height).toBeLessThan(PHONE.height * 0.45);
|
||||
expect(box?.width ?? PHONE.width).toBeLessThan(PHONE.width);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(menu).toBeHidden();
|
||||
await openMenu.click();
|
||||
await page.locator('.site-main').click({ position: { x: 5, y: 5 } });
|
||||
await expect(menu).toBeHidden();
|
||||
});
|
||||
|
||||
test('map controls and disclaimer leave the map usable on a phone', async ({ page }) => {
|
||||
await page.setViewportSize(PHONE);
|
||||
await installApiFixtures(page);
|
||||
await page.goto('http://127.0.0.1:4174/', { waitUntil: 'networkidle' });
|
||||
|
||||
await page.getByRole('button', { name: /Layers/ }).click();
|
||||
const controlsBox = await page.locator('.mobile-controls').boundingBox();
|
||||
expect(controlsBox?.height ?? PHONE.height).toBeLessThan(PHONE.height * 0.4);
|
||||
const mapBox = await page.locator('.map-area').boundingBox();
|
||||
expect(mapBox?.height ?? 0).toBeGreaterThan(300);
|
||||
|
||||
await page.getByRole('button', { name: 'Data disclaimer' }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Data disclaimer' });
|
||||
await expect(dialog).toBeVisible();
|
||||
const dialogBox = await page.locator('.disclaimer-modal').boundingBox();
|
||||
expect(dialogBox?.y ?? -1).toBeGreaterThanOrEqual(0);
|
||||
expect((dialogBox?.y ?? PHONE.height) + (dialogBox?.height ?? 1)).toBeLessThanOrEqual(PHONE.height);
|
||||
await expect(dialog.getByRole('button', { name: 'Got it' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('repeater search results stay bounded and scroll inside the menu', async ({ page }) => {
|
||||
await page.setViewportSize(PHONE);
|
||||
await installApiFixtures(page);
|
||||
await page.goto('http://127.0.0.1:4173/repeater', { waitUntil: 'networkidle' });
|
||||
|
||||
await page.getByRole('textbox').fill('Repeater');
|
||||
const results = page.locator('.repeater-search-box__results');
|
||||
await expect(results).toBeVisible();
|
||||
const box = await results.boundingBox();
|
||||
expect(box?.height ?? PHONE.height).toBeLessThanOrEqual(PHONE.height * 0.47);
|
||||
expect(await results.evaluate((element) => element.scrollHeight)).toBeGreaterThan(
|
||||
await results.evaluate((element) => element.clientHeight),
|
||||
);
|
||||
await expectNoViewportOverflow(page);
|
||||
});
|
||||
|
||||
test('stats path modal fits the viewport and keeps its close action reachable', async ({ page }) => {
|
||||
await page.setViewportSize(PHONE);
|
||||
await installApiFixtures(page);
|
||||
await page.goto('http://127.0.0.1:4173/stats', { waitUntil: 'networkidle' });
|
||||
|
||||
await page.getByRole('button', { name: 'North -> South' }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Decoded path map' });
|
||||
await expect(dialog).toBeVisible();
|
||||
const modalBox = await page.locator('.stats-page__path-modal').boundingBox();
|
||||
expect(modalBox?.y ?? -1).toBeGreaterThanOrEqual(0);
|
||||
expect((modalBox?.y ?? PHONE.height) + (modalBox?.height ?? 1)).toBeLessThanOrEqual(PHONE.height);
|
||||
await expect(dialog.getByRole('button', { name: 'Close' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('narrow 320px pages keep card grids inside the viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await installApiFixtures(page);
|
||||
await page.goto('http://127.0.0.1:4176/packets', { waitUntil: 'networkidle' });
|
||||
await expectNoViewportOverflow(page);
|
||||
const cardBox = await page.locator('.packet-card').first().boundingBox();
|
||||
expect(cardBox?.width ?? 320).toBeLessThan(320);
|
||||
});
|
||||
Reference in New Issue
Block a user