diff --git a/.github/workflows/frontend-mobile.yml b/.github/workflows/frontend-mobile.yml new file mode 100644 index 0000000..e4a4a95 --- /dev/null +++ b/.github/workflows/frontend-mobile.yml @@ -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 diff --git a/.gitignore b/.gitignore index c385cb0..c29e40a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ node_modules/ # Build output dist/ +frontend/test-results/ +frontend/playwright-report/ # Environment variables — never commit real credentials .env diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c87d987..567c3b7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index c388d6d..892a78f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..e3b77c1 --- /dev/null +++ b/frontend/playwright.config.ts @@ -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'], + }, + ], +}); diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx index b3e7b20..edb1c5b 100644 --- a/frontend/src/pages/StatsPage.tsx +++ b/frontend/src/pages/StatsPage.tsx @@ -727,8 +727,8 @@ export const StatsPage: React.FC = () => { -
- +
+ = ({ }) => { const COOKIE_CONSENT_KEY = 'meshcore-cookie-consent-v1'; const [menuOpen, setMenuOpen] = useState(false); + const navRef = useRef(null); const [ownerLabel, setOwnerLabel] = useState(null); const [cookieConsent, setCookieConsent] = useState(() => { try { @@ -61,8 +62,6 @@ export const SiteLayout: React.FC = ({ 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 = ({ ]; 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 = ({ return (
-