diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 084c25af..507b94b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,6 +142,49 @@ jobs: name: throttling-${{ matrix.profile }} path: test-results/ + # Multi-resolution visual layout matrix (tests/e2e/layout.spec.ts): one leg + # per device profile so a pull request names the exact resolution whose + # layout regressed. Each leg gates horizontal scrolling, viewport clipping + # and the committed layout baselines. + e2e-layout-matrix: + name: E2E layout matrix (${{ matrix.device }}) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + device: [iphone-se, iphone-14, pixel-7, ipad, laptop, display-4k] + steps: + - name: Checkout Codebase + uses: actions/checkout@v4 + + - name: Setup Node.js Environment + uses: actions/setup-node@v4 + with: + node-version: '22.x' + cache: 'npm' + + - name: Install Dependencies + run: npm ci || npm install + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Run layout matrix leg + # Called directly instead of through `npm run test:layout` so the leg + # runs exactly one resolution (Playwright unions repeated --project). + run: npx playwright test --project=layout-${{ matrix.device }} --reporter=line + + - name: Upload layout diff artifacts on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: layout-${{ matrix.device }} + path: | + test-results/ + tests/e2e/layout.spec.ts-snapshots/ + if-no-files-found: ignore + # Closes #540 — license gate (fails on GPL/unauthorized copyleft), # install-script allowlist, registry/integrity hijack checks, and a # committed licenses.json freshness check. Zero dependencies: no install. diff --git a/.gitignore b/.gitignore index a378b661..2a82cc42 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,10 @@ yarn-error.log* snapshots/ test-snapshots/ +# Playwright run artifacts (layout baselines ARE committed) +test-results/ +playwright-report/ + # Rust/Soroban build output contract/**/target/ contracts/**/target/ diff --git a/README.md b/README.md index ce4f1fb1..d3cadf0f 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ HelPhone is a React + Vite community emergency response application built on Ste - `npm run lint` (`eslint .`) - Code style & quality checks. - `npm run typecheck` (`tsc --noEmit`) - Strict TypeScript validation without building output. - `npm test` - Vitest test suite execution. -- **GitHub Actions CI**: `.github/workflows/ci.yml` enforces quality, linting, type-checking, state export verification, and crypto matrix tests on all pull requests and pushes. +- **GitHub Actions CI**: `.github/workflows/ci.yml` enforces quality, linting, type-checking, state export verification, crypto matrix tests, and the multi-resolution layout matrix on pull requests. ### 3. Dynamic Feature Canary Rollouts & State Evaluation - **Feature Flag Engine**: `src/lib/featureFlags.ts` evaluates feature flag toggles dynamically. @@ -34,6 +34,7 @@ HelPhone is a React + Vite community emergency response application built on Ste ### 5. Performance, Storage Security & Network Resilience - **HTTP Keep-Alive**: `server/middleware/keepAlive.ts` holds sockets open for 65 s (above the balancer's 60 s idle timeout) so sequential API and WebSocket traffic reuses one TCP connection. See [`docs/performance-optimization.md`](docs/performance-optimization.md). +- **HTTP/2 Push / Preload Manifest**: `server/middleware/http2Push.ts` reads Vite's `dist/.vite/manifest.json` at startup, walks the entry chunk graph and stamps `Link: ; rel=preload; as=script; type=module; crossorigin` on HTML responses (plus 103 Early Hints and, on HTTP/2, `pushStream`). The manifest is re-read when a release changes the asset hashes, so the header always matches what was deployed. See [`docs/performance-optimization.md`](docs/performance-optimization.md). - **Map Overlay Rendering**: `src/lib/offscreenCanvas.ts` + `src/workers/canvas-worker.js` animate map markers in a Web Worker via OffscreenCanvas, with a main-thread fallback and measured FPS. See [`docs/performance-optimization.md`](docs/performance-optimization.md). - **Client Storage Encryption**: `src/lib/pbkdf2Key.ts` + `src/lib/secureStorage.ts` derive an AES-256-GCM key via PBKDF2 (100k iterations, per-device salt in IndexedDB) to encrypt local data. See [`docs/security-architecture.md`](docs/security-architecture.md). - **Network Resilience Testing**: `tests/e2e/throttling.spec.ts` emulates 2G, 3G, a 500 kbps cap, and offline via CDP, with a CI matrix leg per profile. See [`docs/network-resilience.md`](docs/network-resilience.md). @@ -63,6 +64,15 @@ HelPhone is a React + Vite community emergency response application built on Ste --- +### 9. Multi-Resolution Visual Layout Matrix +- **Spec**: [`tests/e2e/layout.spec.ts`](tests/e2e/layout.spec.ts) replays the same layout gates over `/`, `/help` and `/ranking` on six device resolutions: iPhone SE (375×667), iPhone 14 (390×844), Pixel 7 (412×915), iPad (768×1024), Laptop (1366×768) and a 4K display (2560×1440). +- **Overflow Detection**: each leg fails when `document.documentElement.scrollWidth` exceeds `window.innerWidth` — horizontal DOM scrolling on a phone cannot be panned back — and when a visible element is clipped by the right edge of the viewport (this is what catches a fixed header bar whose links run past 375 px). Landmark geometry (nav, primary heading) is additionally asserted to stay inside the viewport. +- **Visual Baselines**: viewport screenshots live in [`tests/e2e/layout.spec.ts-snapshots/`](tests/e2e/layout.spec.ts-snapshots) with a 5 % pixel tolerance; non-replayable surfaces (Mapbox canvas, live RPC latency pill, video frames) are frozen or masked before capture so the shot records layout, not fresh data. Regenerate deliberately with `npm run test:layout:generate`. +- **Resolution Projects**: every device is its own Playwright project (`layout-iphone-se` … `layout-display-4k`) declared in `playwright.config.js`, so a failure names its resolution. `npm run test:layout` runs the whole matrix; `npx playwright test --project=layout-iphone-se` runs one leg. +- **CI Matrix**: the `e2e-layout-matrix` job in [`.github/workflows/ci.yml`](.github/workflows/ci.yml) fans out one leg per resolution on pull requests (`fail-fast: false`) and uploads `test-results/` plus the baselines when a leg fails. + +--- + ## Quick Start ```bash @@ -79,6 +89,9 @@ npm run typecheck # Run complete Vitest test suite npm test +# Run the multi-resolution Playwright layout matrix (6 device profiles) +npm run test:layout + # Export Soroban contract storage state manually npm run export:state ``` diff --git a/docs/performance-optimization.md b/docs/performance-optimization.md index 64c7fc5a..3b66b713 100644 --- a/docs/performance-optimization.md +++ b/docs/performance-optimization.md @@ -87,3 +87,70 @@ registerRouteLoaders({ "/thing": loadThing }); `resourceHints`) before the click; the landing page's initial requests no longer include the `mapbox` / `zk` chunks. - Lighthouse (mobile, throttled): compare FCP before/after on `/`, `/help`, `/ranking`. + +## HTTP/2 push & preload manifest (`server/middleware/http2Push.ts`) + +Goal: get the entry page's compiled chunks into the browser's preload scanner +before the HTML has been parsed, using the asset hashes this release actually +shipped — with no hand-maintained list to drift. + +### Layers + +| Layer | Where | What | +| --- | --- | --- | +| Manifest emission | `vite.config.ts` → `build.manifest: true` | Every `vite build` writes `dist/.vite/manifest.json`: entry chunk, its static `imports`, and per-chunk `css`, all under fingerprinted names. | +| Manifest reader | `createManifestStore()` | Reads the manifest once at startup (not on first request), caches the parsed asset list, and re-checks the file's mtime/size on a throttled interval (`refreshIntervalMs`, default 5 s; `0` = every request). A changed manifest is re-parsed, so a new deploy's hashes replace the old ones without a restart. | +| Entry graph | `collectEntryAssets()` | Depth-first over `index.html` → `isEntry` chunks → `imports` → their `css`, deduplicated. `dynamicImports` are **not** walked: Mapbox / ZK / WASM chunks stay on-intent, matching the `modulePreload.resolveDependencies` filter in `vite.config.ts` (they are additionally dropped by `HEAVY_CHUNK_RE`). | +| Push header | `buildLinkHeader()` | `Link: ; rel=preload; as=script; type=module; crossorigin, ; rel=preload; as=style; type=text/css` — capped at `maxAssets` (default 16) so the header stays under proxy header limits. | +| Middleware | `createHttp2PushMiddleware()` | Attached in `server/index.ts` before the static/HTML handlers. Applies to HTML document navigations only (never `/api`, `/zk`, `/metrics`, `/health`, assets with extensions, non-`GET`/`HEAD`, or `sec-fetch-dest` other than a document). Appends to any existing `Link` instead of overwriting it. | +| Early Hints | HTTP/1.1 | When the runtime exposes `res.writeEarlyHints`, the same list is sent as `103 Early Hints` before the document, then repeated in the final `Link` header. Best-effort: wrapped in `try/catch`, never fails a response. | +| Native push | HTTP/2 | If the origin really terminates HTTP/2 (`req.httpVersionMajor === 2` and `res.stream.pushStream` exists), each entry asset is pushed on its own stream from `dist/`, with `cache-control: public, max-age=31536000, immutable` (fingerprinted URLs). A missing file answers `404` on the pushed stream; a rejected push never throws. | + +### Asset hash sync across releases + +1. Startup load — the manifest is read when the middleware is constructed. +2. Throttled re-check — mtime/size comparison; only a *changed* manifest is re-parsed. +3. Forced re-read — `store.reload()` (deploy hook, tests) bypasses both caches and + re-runs file verification. +4. Optional `verifyFiles` (`HTTP2_PUSH_VERIFY_FILES=true`) drops assets whose file + is no longer on disk, so a header can never point at a pruned build. +5. Missing manifest = empty snapshot: API-only deploys and pre-build boots are + a no-op, and the header starts working as soon as `vite build` lands. + +### Configuration + +| Env var | Default | Notes | +| --- | --- | --- | +| `HTTP2_PUSH_ENABLED` | `true` | Master switch. | +| `HTTP2_PUSH_EARLY_HINTS` | `true` | 103 hints on HTTP/1.1. | +| `HTTP2_PUSH_NATIVE` | `true` | `pushStream` on HTTP/2 (browsers have mostly withdrawn push support; harmless when unsupported). | +| `HTTP2_PUSH_VERIFY_FILES` | `false` | Drop assets missing on disk. | +| `HTTP2_PUSH_INCLUDE_HEAVY` | `false` | Set `true` to push the Mapbox/ZK chunks too (usually a pessimization). | +| `HTTP2_PUSH_REFRESH_MS` | `5000` | Manifest mtime re-check interval; `0` = every request. | +| `HTTP2_PUSH_MAX_ASSETS` | `16` | Cap on assets in one `Link` header. | +| `HTTP2_PUSH_DIST_DIR` | `dist` | Override the build output directory. | +| `HTTP2_PUSH_MANIFEST` | – | Explicit manifest path (wins over discovery of `.vite/manifest.json` then `manifest.json`). | + +`render.yaml` sets `HTTP2_PUSH_ENABLED`, `HTTP2_PUSH_EARLY_HINTS`, +`HTTP2_PUSH_NATIVE` and `HTTP2_PUSH_VERIFY_FILES`. + +### Guard rails + +- No header on JSON/API responses, static asset requests, or unsafe methods — + preload hints there would only cost bytes. +- Link parts never carry unquoted `;`: MIME parameters are stripped before the + header is assembled (`type=text/css`, not `type="text/css; charset=utf-8"`). +- A half-written manifest (build in progress) is logged and ignored; the last + good snapshot keeps serving. +- Push and hints are strictly best-effort: every failure path falls through to + `next()` with the document served normally. + +### Verifying + +- Unit tests: `npm run test:http2-manifest` (36 cases: graph walking, header + format/caps, hash re-reads, `verifyFiles`, document gating, early hints, + HTTP/2 push incl. missing-asset 404, env parsing). +- `curl -I http://localhost:3001/` → `Link:` lists the current `assets/*.js` / + `*.css` fingerprints from `dist/.vite/manifest.json`. +- Rebuild (`npm run build`) without restarting: the header picks up the new + hashes on the next refresh window. diff --git a/package-lock.json b/package-lock.json index 8196896a..82be6554 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,8 +33,8 @@ "react-map-gl": "^8.1.1", "react-router-dom": "^7.18.0", "zod": "^3.25.76" - "ws": "^8.18.3" - "workbox-range-requests": "^7.4.1" + ,"ws": "^8.18.3" + ,"workbox-range-requests": "^7.4.1" }, "devDependencies": { "@eslint/js": "^9.0.0", diff --git a/package.json b/package.json index bbf5ee81..da19ece2 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "preview": "vite preview", "test": "vitest run", "benchmark:routing": "vitest run test/routing-benchmark-spike.test.js --reporter=verbose", + "test:http2-manifest": "vitest run test/http2-manifest.test.js", "security:lockfiles": "node scripts/verify-lockfile-hashes.js", "security:lockfiles:offline": "node scripts/verify-lockfile-hashes.js --offline", "security:install": "bash scripts/sandbox-install.sh", @@ -33,7 +34,10 @@ "test:ws-cluster": "node server/tests/ws-cluster.js", "zk:shard": "python circuits/scripts/shard-aegis.py circuits/target/aegis.json public/zk-assets", "test:watch": "vitest", + "test:update-snapshots": "vitest run test/snapshots.test.jsx -u", "test:e2e:throttling": "playwright test --project=throttling", + "test:layout": "playwright test --project=\"layout-*\"", + "test:layout:generate": "playwright test --project=\"layout-*\" --update-snapshots", "lint": "eslint .", "security:audit-deps": "node scripts/audit-deps.js", "security:audit-deps:check": "node scripts/audit-deps.js --check", @@ -104,6 +108,8 @@ "react-i18next": "^16.5.4", "react-map-gl": "^8.1.1", "react-router-dom": "^7.18.0", + "workbox-range-requests": "^7.4.1", + "ws": "^8.18.3", "zod": "^3.25.76" }, "apiDrift": { @@ -2497,6 +2503,5 @@ } } } - "workbox-range-requests": "^7.4.1" } } diff --git a/playwright.config.js b/playwright.config.js index dd5b8eba..bca0cab2 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -1,5 +1,27 @@ import { defineConfig } from '@playwright/test' +// Multi-resolution layout matrix — one project per device profile so a PR +// check names the exact resolution it broke on. The specs themselves are +// viewport-agnostic; tests/e2e/layout.spec.ts measures whatever viewport +// the project injects. `npm run test:layout` runs every leg, +// `npm run test:layout -- --project=layout-iphone-se` runs one. +const MOBILE_UA = { + 'layout-iphone-se': + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1', + 'layout-iphone-14': + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', + 'layout-pixel-7': + 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', + 'layout-ipad': + 'Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1', +} + +const layoutProject = (name, use) => ({ + name, + testMatch: /layout\.spec\.ts/, + use: { browserName: 'chromium', ...use }, +}) + export default defineConfig({ expect: { toHaveScreenshot: { maxDiffPixelRatio: 0.002, animations: 'disabled' }, @@ -22,12 +44,53 @@ export default defineConfig({ projects: [ // Throttling runs in its own project: slow-network navigations need a much // larger timeout and would make the default suite crawl. - { name: 'chromium', testIgnore: /throttling\.spec\.ts/, use: { browserName: 'chromium' } }, + { + name: 'chromium', + testIgnore: [/throttling\.spec\.ts/, /layout\.spec\.ts/], + use: { browserName: 'chromium' }, + }, { name: 'throttling', testMatch: /throttling\.spec\.ts/, timeout: 180_000, use: { browserName: 'chromium' }, }, + // ── Multi-resolution visual layout matrix ───────────────────── + layoutProject('layout-iphone-se', { + viewport: { width: 375, height: 667 }, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + userAgent: MOBILE_UA['layout-iphone-se'], + }), + layoutProject('layout-iphone-14', { + viewport: { width: 390, height: 844 }, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + userAgent: MOBILE_UA['layout-iphone-14'], + }), + layoutProject('layout-pixel-7', { + viewport: { width: 412, height: 915 }, + deviceScaleFactor: 2.625, + isMobile: true, + hasTouch: true, + userAgent: MOBILE_UA['layout-pixel-7'], + }), + layoutProject('layout-ipad', { + viewport: { width: 768, height: 1024 }, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + userAgent: MOBILE_UA['layout-ipad'], + }), + layoutProject('layout-laptop', { + viewport: { width: 1366, height: 768 }, + deviceScaleFactor: 1, + }), + layoutProject('layout-display-4k', { + viewport: { width: 2560, height: 1440 }, + deviceScaleFactor: 1, + }), ], }) diff --git a/render.yaml b/render.yaml index fd0cf8e1..9a395823 100644 --- a/render.yaml +++ b/render.yaml @@ -61,6 +61,22 @@ services: # Set to "true" to roll the policy out in report-only mode first. - key: CSP_REPORT_ONLY value: "false" + # HTTP/2 push / preload manifest (server/middleware/http2Push.ts): the + # server reads dist/.vite/manifest.json (emitted by `vite build`) and + # stamps `Link: <...>; rel=preload` on HTML responses, re-reading the + # manifest whenever a release changes the asset hashes. No-ops when the + # build output is absent (API-only deploy). + - key: HTTP2_PUSH_ENABLED + value: "true" + # Emit a 103 Early Hints `Link` ahead of the document on HTTP/1.1. + - key: HTTP2_PUSH_EARLY_HINTS + value: "true" + # Native `pushStream` when the origin terminates HTTP/2 itself. + - key: HTTP2_PUSH_NATIVE + value: "true" + # Drop assets missing on disk from the push list (guards mid-release). + - key: HTTP2_PUSH_VERIFY_FILES + value: "false" - type: cron name: soroban-daily-state-exporter diff --git a/server/index.ts b/server/index.ts index 4f618799..45090b0c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -17,6 +17,10 @@ import { import { SorobanStateExporter, loadLatestSnapshot } from './indexer/exporter.js' import { authMiddleware } from './middleware/auth.js' import { createCspMiddleware, createHtmlHandler } from './middleware/csp.js' +import { + createHttp2PushMiddleware, + optionsFromEnv as http2PushOptionsFromEnv, +} from './middleware/http2Push.js' import { createPasskeyAuthRouter } from './routes/passkey-auth.js' const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -103,6 +107,12 @@ app.post('/api/protected/action', authMiddleware, (req: Request, res: Response) // Built frontend (nonce-injected HTML). Only active when `vite build` output // exists, so an API-only deployment keeps behaving exactly as before. const DIST_DIR = join(__dirname, '..', 'dist') + +// HTTP/2 push / preload hints for the built entry chunks +// (see middleware/http2Push.ts). Reads dist/.vite/manifest.json at startup and +// re-reads it whenever a release changes the asset hashes, so the `Link` +// header always matches the files that were actually deployed. +app.use(createHttp2PushMiddleware({ ...http2PushOptionsFromEnv(process.env, { distDir: DIST_DIR }) })) app.use(express.static(DIST_DIR, { index: false })) app.get(/^\/(?!api\/|zk\/|health$|metrics).*/, createHtmlHandler({ htmlPath: join(DIST_DIR, 'index.html') })) diff --git a/server/middleware/http2Push.ts b/server/middleware/http2Push.ts new file mode 100644 index 00000000..d0c9435d --- /dev/null +++ b/server/middleware/http2Push.ts @@ -0,0 +1,583 @@ +/** + * server/middleware/http2Push.ts — HTTP/2 push & preload asset manifest builder + * + * Vite fingerprints every compiled chunk and records the mapping in + * `dist/.vite/manifest.json` (`build.manifest: true` in `vite.config.ts`). + * This module reads that manifest once at startup, walks the entry graph + * (`index.html` → its static `imports` → their `css`), and stamps + * + * Link: ; rel=preload; as=script; type=module; crossorigin + * + * onto HTML document responses, so the browser starts fetching the exact + * hashes that were deployed before it has parsed the HTML. + * + * Three things keep the header honest across releases: + * 1. the manifest's mtime/size is re-checked on a short interval and + * re-parsed whenever it changes, so a new build's hashes replace the old + * ones without a restart; + * 2. entries whose files are no longer on disk are dropped (`verifyFiles`); + * 3. only the entry graph is emitted — `dynamicImports` (Mapbox, the ZK/WASM + * prover) stay on-demand, matching the `modulePreload` filter in + * `vite.config.ts`. + * + * On HTTP/1.1 the same list is also sent as a 103 Early Hints `Link` when the + * runtime supports it. When the origin itself terminates HTTP/2, each asset is + * additionally pushed on the stream (`res.stream.pushStream`) straight from + * disk — guarded, because browsers have largely withdrawn push support. + */ + +import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs' +import { dirname, extname, join } from 'node:path' +import type { NextFunction, Request, RequestHandler, Response } from 'express' + +/** How often the manifest's mtime is re-checked (ms). `0` = on every request. */ +export const DEFAULT_REFRESH_MS = 5_000 + +/** Upper bound of assets in one `Link` header (protects header size limits). */ +export const DEFAULT_MAX_ASSETS = 16 + +/** + * Chunks the entry HTML never preloads (#542): Mapbox GL and the ZK/WASM + * prover are fetched on intent, never on the landing page's critical path. + */ +export const HEAVY_CHUNK_RE = /(^|\/)(mapbox|zk)-[^/]*\.js$/ + +/** Manifest locations Vite writes, most specific first (Vite ≥5 uses `.vite/`). */ +export const MANIFEST_CANDIDATES = ['.vite/manifest.json', 'manifest.json'] as const + +export const LINK_HEADER = 'Link' + +/** API/JSON prefixes — never HTML document responses. */ +const NON_DOCUMENT_PATH_RE = /^\/(?:api|zk|admin|metrics|health)(?:\/|$)/ + +/** Any path with an extension is a static asset request, not a document. */ +const ASSET_PATH_RE = /\.[a-z0-9]{2,5}$/i + +export interface ViteManifestEntry { + file: string + src?: string + name?: string + isEntry?: boolean + isDynamicEntry?: boolean + css?: string[] + imports?: string[] + dynamicImports?: string[] +} + +export type ViteManifest = Record + +export interface PushAsset { + /** Origin-relative URL (`/assets/index-abc.js`). */ + url: string + /** Path relative to the dist root — what is read from disk when pushing. */ + file: string + /** Value for the `Link` `as=` parameter. */ + type: string + /** ES module chunk → `type=module` instead of a MIME type. */ + module: boolean + /** MIME type (Link `type=` for non-modules; `Content-Type` when pushing). */ + mime: string + /** Fetch in CORS mode (`crossorigin`) — required for module scripts/fonts. */ + crossorigin: boolean +} + +interface PreloadHint { + as: string + mime: string + module?: boolean + crossorigin?: boolean +} + +const EXT_HINTS: Record = { + '.js': { as: 'script', mime: 'text/javascript; charset=utf-8', module: true, crossorigin: true }, + '.mjs': { as: 'script', mime: 'text/javascript; charset=utf-8', module: true, crossorigin: true }, + '.cjs': { as: 'script', mime: 'text/javascript; charset=utf-8', crossorigin: true }, + '.css': { as: 'style', mime: 'text/css; charset=utf-8' }, + '.woff2': { as: 'font', mime: 'font/woff2', crossorigin: true }, + '.woff': { as: 'font', mime: 'font/woff', crossorigin: true }, + '.ttf': { as: 'font', mime: 'font/ttf', crossorigin: true }, + '.otf': { as: 'font', mime: 'font/otf', crossorigin: true }, + '.eot': { as: 'font', mime: 'application/vnd.ms-fontobject', crossorigin: true }, + '.png': { as: 'image', mime: 'image/png' }, + '.jpg': { as: 'image', mime: 'image/jpeg' }, + '.jpeg': { as: 'image', mime: 'image/jpeg' }, + '.webp': { as: 'image', mime: 'image/webp' }, + '.avif': { as: 'image', mime: 'image/avif' }, + '.gif': { as: 'image', mime: 'image/gif' }, + '.svg': { as: 'image', mime: 'image/svg+xml' }, + '.ico': { as: 'image', mime: 'image/x-icon' }, +} + +const DEFAULT_HINT: PreloadHint = { as: 'fetch', mime: 'application/octet-stream' } + +export function hintFor(file: string): PreloadHint { + return EXT_HINTS[extname(file).toLowerCase()] ?? DEFAULT_HINT +} + +/** `assets/x.js` → `assets/x.js`; `/assets/x.js` → `assets/x.js`. */ +export function normalizeAssetPath(file: string): string { + return file.replace(/^[/\\]+/, '').replace(/\\/g, '/') +} + +export function parseManifest(raw: string): ViteManifest { + const parsed: unknown = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Vite manifest must be a JSON object') + } + return parsed as ViteManifest +} + +export interface CollectOptions { + /** + * Files matching this are dropped from the push list. `undefined` keeps the + * default heavy-chunk exclusion; `null` keeps everything. + */ + exclude?: RegExp | null +} + +/** + * Flatten the entry graph into an ordered preload list: for each entry, its + * CSS first, then the entry chunk, then its static imports depth-first. + * Dynamic imports are deliberately not walked. + */ +export function collectEntryAssets(manifest: ViteManifest, opts: CollectOptions = {}): PushAsset[] { + const exclude = opts.exclude === undefined ? HEAVY_CHUNK_RE : opts.exclude + + const entryKeys = Object.keys(manifest).filter((key) => manifest[key]?.isEntry) + const ordered = entryKeys.includes('index.html') + ? ['index.html', ...entryKeys.filter((key) => key !== 'index.html')] + : entryKeys + + const assets: PushAsset[] = [] + const seenFiles = new Set() + const seenKeys = new Set() + + const addFile = (rawFile: string | undefined) => { + if (!rawFile) return + const file = normalizeAssetPath(rawFile) + if (!file || seenFiles.has(file)) return + seenFiles.add(file) + if (/\.html?$/i.test(file)) return + if (exclude && exclude.test(file)) return + const hint = hintFor(file) + assets.push({ + url: `/${file}`, + file, + type: hint.as, + module: Boolean(hint.module), + mime: hint.mime, + crossorigin: Boolean(hint.crossorigin), + }) + } + + const visit = (key: string) => { + if (seenKeys.has(key)) return + const entry = manifest[key] + if (!entry) return + seenKeys.add(key) + for (const css of entry.css ?? []) addFile(css) + addFile(entry.file) + for (const imported of entry.imports ?? []) visit(imported) + } + + for (const key of ordered) visit(key) + return assets +} + +/** One `; rel=preload; …` element. */ +export function buildLinkPart(asset: PushAsset): string { + let part = `<${asset.url}>; rel=preload; as=${asset.type}` + if (asset.module) part += '; type=module' + else { + // Parameters may not carry `;` unquoted — strip any MIME parameters. + const mime = asset.mime.split(';')[0].trim() + if (mime) part += `; type=${mime}` + } + if (asset.crossorigin) part += '; crossorigin' + return part +} + +/** Comma-separated `Link` header value for the first `max` assets. */ +export function buildLinkHeader(assets: readonly PushAsset[], opts: { max?: number } = {}): string { + const max = opts.max ?? DEFAULT_MAX_ASSETS + return assets + .slice(0, Math.max(0, max)) + .map(buildLinkPart) + .join(', ') +} + +/** First existing manifest under `distDir`, or `null` (not built yet). */ +export function resolveManifestPath(distDir: string): string | null { + for (const candidate of MANIFEST_CANDIDATES) { + const path = join(distDir, candidate) + if (existsSync(path)) return path + } + return null +} + +/** The dist root for a manifest — `.vite/manifest.json` sits one level below it. */ +export function distDirForManifest(manifestPath: string): string { + const dir = dirname(manifestPath) + return dir.endsWith('.vite') ? dirname(dir) : dir +} + +export interface PushManifestSnapshot { + /** Absolute path of the manifest that produced this snapshot. */ + manifestPath: string | null + /** mtime of that manifest (0 when nothing was loaded). */ + mtimeMs: number + assets: readonly PushAsset[] + linkHeader: string + /** True when a manifest was found and parsed. */ + loaded: boolean +} + +export const EMPTY_SNAPSHOT: PushManifestSnapshot = Object.freeze({ + manifestPath: null, + mtimeMs: 0, + assets: Object.freeze([]) as readonly PushAsset[], + linkHeader: '', + loaded: false, +}) + +export interface ManifestStoreOptions { + /** Dist output directory (default `/dist`). */ + distDir?: string + /** Explicit manifest path; wins over `distDir` discovery. */ + manifestPath?: string + /** See `CollectOptions.exclude`. */ + exclude?: RegExp | null + /** Drop the default Mapbox/ZK exclusion (see `HEAVY_CHUNK_RE`). */ + excludeHeavy?: boolean + /** mtime re-check interval in ms; `0` re-checks on every read. */ + refreshIntervalMs?: number + /** Drop assets whose file is missing on disk. */ + verifyFiles?: boolean + /** Cap for the `Link` header. */ + maxAssets?: number +} + +export interface ManifestStore { + readonly distDir: string + readonly manifestPath: string | null + /** Throttled read: re-parses only when the manifest changed. */ + snapshot(): PushManifestSnapshot + /** Forces a re-read (deploy hook / tests). */ + reload(): PushManifestSnapshot +} + +/** + * Reads and caches the build manifest. Constructed eagerly at startup so the + * first response already carries a header; missing builds (API-only deploys) + * are a no-op and get picked up once `vite build` lands. + */ +export function createManifestStore(options: ManifestStoreOptions = {}): ManifestStore { + const distDir = + options.distDir ?? (options.manifestPath ? distDirForManifest(options.manifestPath) : join(process.cwd(), 'dist')) + const refreshIntervalMs = options.refreshIntervalMs ?? DEFAULT_REFRESH_MS + const verifyFiles = options.verifyFiles ?? false + const maxAssets = options.maxAssets ?? DEFAULT_MAX_ASSETS + const collectOptions: CollectOptions = + options.exclude !== undefined ? { exclude: options.exclude } : options.excludeHeavy === false ? { exclude: null } : {} + + let snapshot: PushManifestSnapshot = EMPTY_SNAPSHOT + let lastCheckAt = 0 + let statKey = '' + + function read(path: string, force = false): PushManifestSnapshot { + let stat + let raw: string + try { + stat = statSync(path) + raw = readFileSync(path, 'utf8') + } catch { + statKey = '' + snapshot = EMPTY_SNAPSHOT + return snapshot + } + + const key = `${path}|${stat.mtimeMs}|${stat.size}` + if (!force && key === statKey) return snapshot + + let assets: PushAsset[] + try { + assets = collectEntryAssets(parseManifest(raw), collectOptions) + } catch (err) { + // A half-written manifest during a build must never break responses. + console.warn(`[http2push] ignoring unreadable manifest ${path}: ${(err as Error).message}`) + return snapshot + } + if (verifyFiles) assets = assets.filter((asset) => existsSync(join(distDir, asset.file))) + + statKey = key + snapshot = { + manifestPath: path, + mtimeMs: stat.mtimeMs, + assets, + linkHeader: buildLinkHeader(assets, { max: maxAssets }), + loaded: true, + } + return snapshot + } + + function locate(): string | null { + return options.manifestPath ?? resolveManifestPath(distDir) + } + + function dropToEmpty(): PushManifestSnapshot { + statKey = '' + snapshot = EMPTY_SNAPSHOT + return snapshot + } + + /** Cheap re-check: stat the manifest, re-parse only when it actually changed. */ + function check(): PushManifestSnapshot { + lastCheckAt = Date.now() + const path = locate() + if (!path || !existsSync(path)) return dropToEmpty() + return read(path) + } + + /** Forced re-read (deploy hook, tests): re-parses even if the stat is unchanged. */ + function reload(): PushManifestSnapshot { + lastCheckAt = Date.now() + const path = locate() + if (!path || !existsSync(path)) return dropToEmpty() + return read(path, true) + } + + function current(): PushManifestSnapshot { + const now = Date.now() + if (refreshIntervalMs <= 0 || now - lastCheckAt >= refreshIntervalMs) return check() + return snapshot + } + + reload() + + return { + get distDir() { + return distDir + }, + get manifestPath() { + return snapshot.manifestPath + }, + snapshot: current, + reload, + } +} + +export interface Http2PushEnvOptions { + enabled?: boolean + earlyHints?: boolean + nativePush?: boolean + verifyFiles?: boolean + excludeHeavy?: boolean + refreshIntervalMs?: number + maxAssets?: number + manifestPath?: string + distDir?: string +} + +const truthy = (value: string | undefined): boolean => + value !== undefined && value !== '' && /^(1|true|yes|on)$/i.test(value) + +function readInt(value: string | undefined, fallback?: number): number | undefined { + if (value === undefined || value === '') return fallback + const parsed = Number(value) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback +} + +/** `HTTP2_PUSH_*` environment configuration (all optional). */ +export function optionsFromEnv( + env: NodeJS.ProcessEnv = process.env, + defaults: { distDir?: string } = {} +): Http2PushEnvOptions { + const includeHeavy = truthy(env.HTTP2_PUSH_INCLUDE_HEAVY) + const options: Http2PushEnvOptions = { + enabled: env.HTTP2_PUSH_ENABLED === undefined || env.HTTP2_PUSH_ENABLED === '' ? true : truthy(env.HTTP2_PUSH_ENABLED), + earlyHints: env.HTTP2_PUSH_EARLY_HINTS === undefined || env.HTTP2_PUSH_EARLY_HINTS === '' ? true : truthy(env.HTTP2_PUSH_EARLY_HINTS), + nativePush: env.HTTP2_PUSH_NATIVE === undefined || env.HTTP2_PUSH_NATIVE === '' ? true : truthy(env.HTTP2_PUSH_NATIVE), + verifyFiles: truthy(env.HTTP2_PUSH_VERIFY_FILES), + excludeHeavy: !includeHeavy, + } + const refreshIntervalMs = readInt(env.HTTP2_PUSH_REFRESH_MS) + const maxAssets = readInt(env.HTTP2_PUSH_MAX_ASSETS) + if (refreshIntervalMs !== undefined) options.refreshIntervalMs = refreshIntervalMs + if (maxAssets !== undefined) options.maxAssets = maxAssets + if (env.HTTP2_PUSH_MANIFEST) options.manifestPath = env.HTTP2_PUSH_MANIFEST + if (env.HTTP2_PUSH_DIST_DIR) options.distDir = env.HTTP2_PUSH_DIST_DIR + else if (defaults.distDir) options.distDir = defaults.distDir + return options +} + +/** Document navigations only: skip the JSON API, static assets and no-store APIs. */ +export function isDocumentRequest(req: Request): boolean { + if (req.method !== 'GET' && req.method !== 'HEAD') return false + const path = req.path || (req.url || '/').split('?')[0] + if (NON_DOCUMENT_PATH_RE.test(path)) return false + if (ASSET_PATH_RE.test(path)) return false + + const dest = String(req.headers['sec-fetch-dest'] || '') + if (dest && dest !== 'document' && dest !== 'iframe' && dest !== 'frame') return false + + const accept = String(req.headers.accept || '').toLowerCase() + if (accept.includes('text/html')) return true + if (accept.includes('application/json')) return false + return true +} + +function readExistingLink(res: Response): string { + const value = res.getHeader(LINK_HEADER) as string | string[] | number | undefined + if (value === undefined || value === null) return '' + if (Array.isArray(value)) return value.join(', ') + return String(value) +} + +interface PushStreamLike { + respond(headers: Record): void + end(chunk?: unknown): void + on?: (event: string, listener: (...args: never[]) => void) => unknown +} + +interface Http2PushableResponse { + stream?: { + pushStream?: ( + headers: Record, + options: Record, + callback: (err: Error | null, pushStream?: PushStreamLike) => void + ) => unknown + } + writeEarlyHints?: (hints: Record) => void +} + +function sendPushedAsset(pushStream: PushStreamLike, filePath: string, asset: PushAsset): void { + let size: number + try { + size = statSync(filePath).size + } catch { + try { + pushStream.respond({ ':status': 404, 'content-type': 'text/plain; charset=utf-8' }) + pushStream.end('Not Found') + } catch { + /* stream already closed */ + } + return + } + + try { + pushStream.respond({ + ':status': 200, + 'content-type': asset.mime, + 'content-length': size, + // Fingerprinted filenames are immutable. + 'cache-control': 'public, max-age=31536000, immutable', + }) + } catch { + return + } + + const file = createReadStream(filePath) + file.on('error', () => { + try { + pushStream.end() + } catch { + /* stream already closed */ + } + }) + file.pipe(pushStream as unknown as NodeJS.WritableStream) +} + +/** + * Push each entry asset on the HTTP/2 stream. Returns how many push streams + * were opened; never throws — a rejected push must not fail the document. + */ +export function pushAssetsOverHttp2( + req: Request, + res: Response, + assets: readonly PushAsset[], + distDir: string +): number { + const stream = (res as unknown as Http2PushableResponse).stream + if (!stream || typeof stream.pushStream !== 'function') return 0 + + const scheme = (req as unknown as { protocol?: string }).protocol === 'http' ? 'http' : 'https' + let attempted = 0 + + for (const asset of assets) { + const headers = { + ':method': 'GET', + ':path': asset.url, + ':scheme': scheme, + 'content-type': asset.mime, + } + try { + stream.pushStream!(headers, {}, (err, pushStream) => { + if (err || !pushStream) return + pushStream.on?.('error', () => undefined) + sendPushedAsset(pushStream, join(distDir, asset.file), asset) + }) + attempted += 1 + } catch { + // Session closing or duplicate :path — HTTP/2 allows the push to fail. + } + } + return attempted +} + +export interface Http2PushOptions extends ManifestStoreOptions { + /** Master switch (`HTTP2_PUSH_ENABLED`). Default true. */ + enabled?: boolean + /** Emit a 103 Early Hints `Link` on HTTP/1.1 (`HTTP2_PUSH_EARLY_HINTS`). Default true. */ + earlyHints?: boolean + /** Native `pushStream` on HTTP/2 (`HTTP2_PUSH_NATIVE`). Default true. */ + nativePush?: boolean + /** Inject a store (tests). */ + store?: ManifestStore +} + +/** + * Express middleware: attaches the manifest-derived preload list to HTML + * document responses as `Link`, optionally as 103 Early Hints and/or a native + * HTTP/2 push. + */ +export function createHttp2PushMiddleware(options: Http2PushOptions = {}): RequestHandler { + const enabled = options.enabled ?? true + const earlyHints = options.earlyHints ?? true + const nativePush = options.nativePush ?? true + + const storeOptions: ManifestStoreOptions = { ...options } + if (storeOptions.exclude === undefined && options.excludeHeavy === false) storeOptions.exclude = null + // Created eagerly so the manifest is read at startup, not on first request. + const store = options.store ?? (enabled ? createManifestStore(storeOptions) : undefined) + + return function http2PushMiddleware(req: Request, res: Response, next: NextFunction) { + if (!enabled || !store || !isDocumentRequest(req)) return next() + + const snapshot = store.snapshot() + if (!snapshot.linkHeader) return next() + + // 103 Early Hints travel ahead of the document; Node only implements them + // on HTTP/1.1 and refuses once headers are on the wire. + if (earlyHints && !res.headersSent && req.httpVersionMajor === 1) { + const writeEarlyHints = (res as unknown as Http2PushableResponse).writeEarlyHints + if (typeof writeEarlyHints === 'function') { + try { + writeEarlyHints.call(res, { Link: snapshot.linkHeader }) + } catch { + /* hinting is best-effort */ + } + } + } + + const existing = readExistingLink(res) + res.setHeader(LINK_HEADER, existing ? `${existing}, ${snapshot.linkHeader}` : snapshot.linkHeader) + + if (nativePush && req.httpVersionMajor === 2) { + pushAssetsOverHttp2(req, res, snapshot.assets, store.distDir) + } + + next() + } +} + +export default createHttp2PushMiddleware diff --git a/src/App.css b/src/App.css index bfba7531..abb1922d 100644 --- a/src/App.css +++ b/src/App.css @@ -7,3 +7,42 @@ @import "./styles/theme.css"; @import "./styles/animations.css"; @import "./styles/global.css"; + +/* Multi-resolution layout — the header bar has to survive narrow phones. + The e2e matrix (tests/e2e/layout.spec.ts) asserts that no element is + clipped by the viewport edge on every supported resolution. */ +.hp-app-nav { + padding: 1rem 2rem; + justify-content: space-between; + gap: 0.5rem 1rem; + row-gap: 0.5rem; +} + +.hp-app-nav-links { + justify-content: flex-end; + gap: 0.75rem 1.5rem; + min-width: 0; +} + +@media (max-width: 480px) { + .hp-app-nav { + padding: 0.75rem 1rem; + justify-content: center; + } + + .hp-app-nav-brand { + justify-content: center; + } + + .hp-app-nav-links { + width: 100%; + justify-content: center; + gap: 0.5rem 1rem; + } + + /* Status pills are decorative; they are the first thing to go when the + bar gets tight so the navigation itself always fits. */ + .hp-app-nav-status { + display: none; + } +} diff --git a/src/App.tsx b/src/App.tsx index 6b50f188..55361760 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -51,8 +51,10 @@ function AppContent() { return (
- {/* Header Navigation */} + {/* Header Navigation — `hp-app-nav-*` rules in App.css keep the bar + usable at phone widths (verified by tests/e2e/layout.spec.ts). */} +
+

+ Community Emergency Response Web App +

+

+ Peer-to-peer decentralised emergency dispatch powered by Soroban smart contracts, ZK privacy proofs, and WebAuthn Passkeys. +

+
`; @@ -700,51 +620,51 @@ exports[`Component Snapshots > Ranking Page > should match snapshot 1`] = ` class="hp-layout__main" >
HELPHONE NETWORK

Community Responders

The people who show up when it matters.

# @@ -765,7 +685,7 @@ exports[`Component Snapshots > Ranking Page > should match snapshot 1`] = `

On-chain leaderboard · 0 diff --git a/test/http2-manifest.test.js b/test/http2-manifest.test.js new file mode 100644 index 00000000..5c3f612f --- /dev/null +++ b/test/http2-manifest.test.js @@ -0,0 +1,541 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + DEFAULT_MAX_ASSETS, + DEFAULT_REFRESH_MS, + HEAVY_CHUNK_RE, + MANIFEST_CANDIDATES, + buildLinkHeader, + buildLinkPart, + collectEntryAssets, + createHttp2PushMiddleware, + createManifestStore, + distDirForManifest, + hintFor, + isDocumentRequest, + optionsFromEnv, + parseManifest, + pushAssetsOverHttp2, + resolveManifestPath, +} from '../server/middleware/http2Push.js'; + +const FIXTURE_MANIFEST = { + 'index.html': { + file: 'assets/index-Abc123.js', + name: 'index', + src: 'index.html', + isEntry: true, + css: ['assets/index-XyZ987.css'], + imports: ['_vendor-Vendor01.js', '_react-core-Reac111.js', '_mapbox-Mapb222.js'], + dynamicImports: ['src/pages/Help-Load99.js'], + }, + '_vendor-Vendor01.js': { + file: 'assets/vendor-Vendor01.js', + imports: ['_shared-Share33.js'], + css: ['assets/vendor-Vendor01.css'], + }, + '_react-core-Reac111.js': { + file: 'assets/react-core-Reac111.js', + imports: ['_shared-Share33.js'], + }, + '_shared-Share33.js': { file: 'assets/shared-Share33.js' }, + '_mapbox-Mapb222.js': { file: 'assets/mapbox-Mapb222.js' }, + 'src/pages/Help-Load99.js': { + file: 'assets/help-Lazy888.js', + src: 'src/pages/Help.tsx', + isDynamicEntry: true, + }, +}; + +const FIXTURE_FILES = [ + '/assets/index-XyZ987.css', + '/assets/index-Abc123.js', + '/assets/vendor-Vendor01.css', + '/assets/vendor-Vendor01.js', + '/assets/shared-Share33.js', + '/assets/react-core-Reac111.js', +]; + +function mockReq(overrides = {}) { + const { headers, ...rest } = overrides; + return { + method: 'GET', + url: '/', + path: '/', + originalUrl: '/', + httpVersion: '1.1', + httpVersionMajor: 1, + httpVersionMinor: 1, + protocol: 'https', + headers: { accept: 'text/html', ...headers }, + ...rest, + }; +} + +function mockRes(overrides = {}) { + const headers = {}; + const calls = { earlyHints: [] }; + return { + headersSent: false, + statusCode: 200, + getHeader(name) { + return headers[name.toLowerCase()]; + }, + setHeader(name, val) { + headers[name.toLowerCase()] = val; + }, + writeEarlyHints(hints) { + calls.earlyHints.push(hints); + }, + headers, + calls, + ...overrides, + }; +} + +function runMiddleware(mw, req, res = mockRes()) { + let called = false; + mw(req, res, () => { + called = true; + }); + return { res, called }; +} + +describe('manifest parsing', () => { + it('accepts a Vite manifest object and rejects anything else', () => { + expect(parseManifest(JSON.stringify(FIXTURE_MANIFEST))['index.html'].isEntry).toBe(true); + expect(() => parseManifest('[]')).toThrow(/JSON object/); + expect(() => parseManifest('"nope"')).toThrow(/JSON object/); + expect(() => parseManifest('{')).toThrow(); + }); + + it('maps extensions to preload hints', () => { + expect(hintFor('assets/index.js')).toMatchObject({ as: 'script', module: true, crossorigin: true }); + expect(hintFor('assets/index.css')).toMatchObject({ as: 'style' }); + expect(hintFor('assets/font.woff2')).toMatchObject({ as: 'font', crossorigin: true }); + expect(hintFor('assets/icon.png')).toMatchObject({ as: 'image' }); + expect(hintFor('assets/data.bin')).toMatchObject({ as: 'fetch' }); + }); +}); + +describe('entry graph collection', () => { + const manifest = FIXTURE_MANIFEST; + + it('walks the entry graph depth-first: entry CSS, entry chunk, then static imports', () => { + const files = collectEntryAssets(manifest).map((asset) => asset.url); + expect(files).toEqual(FIXTURE_FILES); + }); + + it('deduplicates shared imports reached from several chunks', () => { + const files = collectEntryAssets(manifest).map((asset) => asset.file); + expect(files.filter((file) => file === 'assets/shared-Share33.js')).toHaveLength(1); + }); + + it('never walks dynamicImports (on-intent chunks stay out of the critical path)', () => { + const files = collectEntryAssets(manifest).map((asset) => asset.file); + expect(files).not.toContain('assets/help-Lazy888.js'); + expect(files.some((file) => file.includes('Lazy'))).toBe(false); + }); + + it('drops the heavy Mapbox/ZK chunks by default, matching the modulePreload filter', () => { + const files = collectEntryAssets(manifest).map((asset) => asset.file); + expect(files).not.toContain('assets/mapbox-Mapb222.js'); + expect(HEAVY_CHUNK_RE.test('assets/mapbox-Mapb222.js')).toBe(true); + expect(HEAVY_CHUNK_RE.test('assets/zk-prover-Dead99.js')).toBe(true); + + const kept = collectEntryAssets(manifest, { exclude: null }).map((asset) => asset.file); + expect(kept).toContain('assets/mapbox-Mapb222.js'); + }); + + it('tags module chunks so the header carries type=module + crossorigin', () => { + const js = collectEntryAssets(manifest).find((asset) => asset.file.endsWith('index-Abc123.js')); + expect(js).toMatchObject({ url: '/assets/index-Abc123.js', type: 'script', module: true, crossorigin: true }); + const css = collectEntryAssets(manifest).find((asset) => asset.file.endsWith('.css')); + expect(css).toMatchObject({ type: 'style', module: false, crossorigin: false }); + }); +}); + +describe('Link header generation', () => { + it('formats a preload link for scripts, styles and fonts', () => { + const js = collectEntryAssets(FIXTURE_MANIFEST).find((a) => a.file.endsWith('index-Abc123.js')); + expect(buildLinkPart(js)).toBe( + '; rel=preload; as=script; type=module; crossorigin' + ); + + const css = collectEntryAssets(FIXTURE_MANIFEST).find((a) => a.file.endsWith('.css')); + expect(buildLinkPart(css)).toBe('; rel=preload; as=style; type=text/css'); + + const font = hintFor('assets/font.woff2'); + expect( + buildLinkPart({ + url: '/assets/font.woff2', + file: 'assets/font.woff2', + type: font.as, + module: false, + mime: font.mime, + crossorigin: true, + }) + ).toBe('; rel=preload; as=font; type=font/woff2; crossorigin'); + }); + + it('joins every asset into one comma-separated header value', () => { + const header = buildLinkHeader(collectEntryAssets(FIXTURE_MANIFEST)); + expect(header.split(', ').map((part) => part.slice(1, part.indexOf('>')))).toEqual(FIXTURE_FILES); + }); + + it('caps the header at the configured asset budget', () => { + const header = buildLinkHeader(collectEntryAssets(FIXTURE_MANIFEST), { max: 2 }); + expect(header.split(', ')).toHaveLength(2); + expect(DEFAULT_MAX_ASSETS).toBeGreaterThan(2); + }); + + it('emits an empty header when there is nothing to push', () => { + expect(buildLinkHeader([])).toBe(''); + }); +}); + +describe('manifest store (asset hash sync)', () => { + let distDir; + + beforeEach(() => { + distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'helphone-http2-')); + fs.mkdirSync(path.join(distDir, '.vite'), { recursive: true }); + fs.mkdirSync(path.join(distDir, 'assets'), { recursive: true }); + fs.writeFileSync( + path.join(distDir, '.vite', 'manifest.json'), + JSON.stringify(FIXTURE_MANIFEST) + ); + for (const file of FIXTURE_FILES) { + fs.mkdirSync(path.dirname(path.join(distDir, file)), { recursive: true }); + fs.writeFileSync(path.join(distDir, file), `/* ${file} */`); + } + }); + + afterEach(() => { + fs.rmSync(distDir, { recursive: true, force: true }); + }); + + it('reads the manifest at construction (startup), not on first request', () => { + const store = createManifestStore({ distDir }); + expect(store.manifestPath).toBe(path.join(distDir, '.vite', 'manifest.json')); + expect(store.snapshot().loaded).toBe(true); + expect(store.snapshot().linkHeader).toContain('/assets/index-Abc123.js'); + }); + + it('finds the manifest in either Vite location and derives the dist root', () => { + expect(resolveManifestPath(distDir)).toBe(path.join(distDir, '.vite', 'manifest.json')); + expect(distDirForManifest(path.join(distDir, '.vite', 'manifest.json'))).toBe(distDir); + + fs.rmSync(path.join(distDir, '.vite', 'manifest.json')); + fs.writeFileSync(path.join(distDir, 'manifest.json'), JSON.stringify(FIXTURE_MANIFEST)); + expect(resolveManifestPath(distDir)).toBe(path.join(distDir, 'manifest.json')); + expect(distDirForManifest(path.join(distDir, 'manifest.json'))).toBe(distDir); + }); + + it('re-reads the manifest after a rebuild so hashes track the deployment', () => { + const store = createManifestStore({ distDir, refreshIntervalMs: 0 }); + expect(store.snapshot().linkHeader).toContain('index-Abc123'); + + const rebuilt = JSON.parse(JSON.stringify(FIXTURE_MANIFEST)); + rebuilt['index.html'].file = 'assets/index-Zzz999.js'; + rebuilt['index.html'].css = ['assets/index-Qqq888.css']; + const target = path.join(distDir, '.vite', 'manifest.json'); + fs.writeFileSync(target, JSON.stringify(rebuilt)); + const future = new Date(Date.now() + 5_000); + fs.utimesSync(target, future, future); + + const next = store.snapshot(); + expect(next.linkHeader).toContain('/assets/index-Zzz999.js'); + expect(next.linkHeader).toContain('/assets/index-Qqq888.css'); + expect(next.linkHeader).not.toContain('index-Abc123'); + }); + + it('throttles re-reads to the refresh interval', () => { + const store = createManifestStore({ distDir, refreshIntervalMs: 60_000 }); + const rebuilt = JSON.parse(JSON.stringify(FIXTURE_MANIFEST)); + rebuilt['index.html'].file = 'assets/index-New111.js'; + fs.writeFileSync(path.join(distDir, '.vite', 'manifest.json'), JSON.stringify(rebuilt)); + + // Inside the window the cached snapshot (old hashes) is served. + expect(store.snapshot().linkHeader).toContain('index-Abc123'); + // An explicit reload — e.g. a deploy hook — bypasses the window. + expect(store.reload().linkHeader).toContain('index-New111'); + }); + + it('returns an empty snapshot when no build exists (API-only deploys)', () => { + fs.rmSync(path.join(distDir, '.vite', 'manifest.json')); + const store = createManifestStore({ distDir, refreshIntervalMs: 0 }); + const snapshot = store.snapshot(); + expect(snapshot.loaded).toBe(false); + expect(snapshot.linkHeader).toBe(''); + expect(snapshot.assets).toEqual([]); + expect(store.manifestPath).toBeNull(); + }); + + it('drops entries whose file is gone when verifyFiles is on', () => { + const store = createManifestStore({ distDir, refreshIntervalMs: 0, verifyFiles: true }); + expect(store.snapshot().assets).toHaveLength(FIXTURE_FILES.length); + + fs.rmSync(path.join(distDir, 'assets', 'index-Abc123.js')); + const after = store.reload(); + expect(after.linkHeader).not.toContain('index-Abc123'); + expect(after.assets.map((a) => a.file)).not.toContain('assets/index-Abc123.js'); + expect(after.assets.map((a) => a.file)).toContain('assets/index-XyZ987.css'); + }); + + it('ignores a half-written manifest instead of failing requests', () => { + const target = path.join(distDir, '.vite', 'manifest.json'); + fs.writeFileSync(target, JSON.stringify(FIXTURE_MANIFEST)); + const store = createManifestStore({ distDir, refreshIntervalMs: 0 }); + expect(store.snapshot().loaded).toBe(true); + + fs.writeFileSync(target, '{"index.html": {"file": "assets/partial'); + const snapshot = store.reload(); + // Previous good snapshot is retained; nothing throws. + expect(snapshot.loaded).toBe(true); + expect(snapshot.linkHeader).toContain('index-Abc123'); + }); + + it('exposes a constant refresh default', () => { + expect(DEFAULT_REFRESH_MS).toBeGreaterThan(0); + expect(MANIFEST_CANDIDATES[0]).toBe('.vite/manifest.json'); + }); +}); + +describe('document request detection', () => { + it('targets HTML navigations only', () => { + expect(isDocumentRequest(mockReq())).toBe(true); + expect(isDocumentRequest(mockReq({ headers: { accept: 'text/html,application/xhtml+xml' } }))).toBe(true); + expect(isDocumentRequest(mockReq({ headers: { accept: '*/*' } }))).toBe(true); + expect(isDocumentRequest(mockReq({ headers: { accept: 'application/json' } }))).toBe(false); + expect(isDocumentRequest(mockReq({ headers: { 'sec-fetch-dest': 'script' } }))).toBe(false); + expect(isDocumentRequest(mockReq({ headers: { 'sec-fetch-dest': 'document' } }))).toBe(true); + }); + + it('skips the JSON API, metrics and static assets', () => { + expect(isDocumentRequest(mockReq({ path: '/api/state/export', url: '/api/state/export' }))).toBe(false); + expect(isDocumentRequest(mockReq({ path: '/health', url: '/health' }))).toBe(false); + expect(isDocumentRequest(mockReq({ path: '/metrics', url: '/metrics' }))).toBe(false); + expect(isDocumentRequest(mockReq({ path: '/zk/health', url: '/zk/health' }))).toBe(false); + expect(isDocumentRequest(mockReq({ path: '/assets/index-Abc123.js', url: '/assets/index-Abc123.js' }))).toBe(false); + }); + + it('skips non-idempotent methods', () => { + expect(isDocumentRequest(mockReq({ method: 'POST', path: '/help', url: '/help' }))).toBe(false); + expect(isDocumentRequest(mockReq({ method: 'HEAD', path: '/help', url: '/help' }))).toBe(true); + }); +}); + +describe('http2 push middleware', () => { + let distDir; + + beforeEach(() => { + distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'helphone-http2-mw-')); + fs.mkdirSync(path.join(distDir, '.vite'), { recursive: true }); + fs.mkdirSync(path.join(distDir, 'assets'), { recursive: true }); + fs.writeFileSync(path.join(distDir, '.vite', 'manifest.json'), JSON.stringify(FIXTURE_MANIFEST)); + for (const file of FIXTURE_FILES) { + fs.mkdirSync(path.dirname(path.join(distDir, file)), { recursive: true }); + fs.writeFileSync(path.join(distDir, file), `/* ${file} */`); + } + }); + + afterEach(() => { + fs.rmSync(distDir, { recursive: true, force: true }); + }); + + it('stamps the manifest-derived Link header on document responses', () => { + const mw = createHttp2PushMiddleware({ distDir, refreshIntervalMs: 0 }); + const { res, called } = runMiddleware(mw, mockReq()); + expect(called).toBe(true); + const link = res.getHeader('Link'); + expect(link).toContain('; rel=preload; as=script; type=module; crossorigin'); + expect(link).toContain('; rel=preload; as=style; type=text/css'); + expect(link).toContain('/assets/react-core-Reac111.js'); + expect(link).not.toContain('mapbox'); + expect(link).not.toContain('help-Lazy888'); + }); + + it('appends to an existing Link header instead of overwriting it', () => { + const mw = createHttp2PushMiddleware({ distDir, refreshIntervalMs: 0 }); + const res = mockRes(); + res.setHeader('Link', '; rel=preload; as=font'); + runMiddleware(mw, mockReq(), res); + const link = res.getHeader('Link'); + expect(link.startsWith('; rel=preload; as=font, ')).toBe(true); + expect(link).toContain('index-Abc123.js'); + }); + + it('sends 103 Early Hints with the same list on HTTP/1.1', () => { + const mw = createHttp2PushMiddleware({ distDir, refreshIntervalMs: 0 }); + const { res } = runMiddleware(mw, mockReq()); + expect(res.calls.earlyHints).toHaveLength(1); + expect(res.calls.earlyHints[0].Link).toBe(res.getHeader('Link')); + }); + + it('leaves API, asset and non-document responses untouched', () => { + const mw = createHttp2PushMiddleware({ distDir, refreshIntervalMs: 0 }); + for (const req of [ + mockReq({ path: '/api/state/export', url: '/api/state/export' }), + mockReq({ path: '/assets/index-Abc123.js', url: '/assets/index-Abc123.js' }), + mockReq({ method: 'POST', path: '/help', url: '/help' }), + mockReq({ headers: { accept: 'application/json' } }), + ]) { + const { res } = runMiddleware(mw, req); + expect(res.getHeader('Link')).toBeUndefined(); + expect(res.calls.earlyHints).toHaveLength(0); + } + }); + + it('is a no-op when disabled or when no build exists', () => { + const disabled = createHttp2PushMiddleware({ distDir, refreshIntervalMs: 0, enabled: false }); + expect(runMiddleware(disabled, mockReq()).res.getHeader('Link')).toBeUndefined(); + + fs.rmSync(path.join(distDir, '.vite', 'manifest.json')); + const unbuilt = createHttp2PushMiddleware({ distDir, refreshIntervalMs: 0 }); + expect(runMiddleware(unbuilt, mockReq()).res.getHeader('Link')).toBeUndefined(); + }); + + it('can run with an injected store', () => { + const store = createManifestStore({ distDir, refreshIntervalMs: 0 }); + const mw = createHttp2PushMiddleware({ store }); + expect(runMiddleware(mw, mockReq()).res.getHeader('Link')).toContain('index-Abc123.js'); + }); + + it('pushes the entry assets on a real HTTP/2 stream', async () => { + const mw = createHttp2PushMiddleware({ distDir, refreshIntervalMs: 0 }); + const pushes = []; + const res = mockRes({ + stream: { + pushStream(headers, _options, callback) { + const stream = { + requestHeaders: headers, + responded: null, + chunks: [], + respond(responseHeaders) { + this.responded = responseHeaders; + }, + on() {}, + destroy() {}, + end(chunk) { + if (chunk) this.chunks.push(chunk); + }, + write(chunk) { + this.chunks.push(chunk); + return true; + }, + }; + pushes.push(stream); + callback(null, stream); + }, + }, + }); + + const { res: result } = runMiddleware(mw, mockReq({ httpVersion: '2.0', httpVersionMajor: 2 }), res); + expect(pushes.length).toBeGreaterThan(0); + expect(pushes.map((push) => push.requestHeaders[':path'])).toEqual(FIXTURE_FILES); + expect(pushes.every((push) => push.requestHeaders[':method'] === 'GET')).toBe(true); + expect(pushes.every((push) => push.requestHeaders[':scheme'] === 'https')).toBe(true); + expect(result.getHeader('Link')).toContain('index-Abc123.js'); + + // Each pushed stream serves the deployed file from dist. + await new Promise((resolve) => setTimeout(resolve, 50)); + const cssPush = pushes.find((push) => push.requestHeaders[':path'].endsWith('.css')); + expect(cssPush.responded[':status']).toBe(200); + expect(cssPush.responded['content-type']).toBe('text/css; charset=utf-8'); + expect(cssPush.responded['cache-control']).toContain('immutable'); + expect(Buffer.concat(cssPush.chunks).toString()).toContain('/assets/index-XyZ987.css'); + }); + + it('never throws when the origin does not terminate HTTP/2', () => { + const res = mockRes(); + expect(pushAssetsOverHttp2(mockReq(), res, collectEntryAssets(FIXTURE_MANIFEST), distDir)).toBe(0); + const mw = createHttp2PushMiddleware({ distDir, refreshIntervalMs: 0 }); + expect(() => runMiddleware(mw, mockReq({ httpVersion: '2.0', httpVersionMajor: 2 }), res)).not.toThrow(); + }); + + it('survives a rejected push without failing the response', () => { + const res = mockRes({ + stream: { + pushStream() { + throw new Error('ERR_HTTP2_PUSH_DISABLED'); + }, + }, + }); + const attempted = pushAssetsOverHttp2( + mockReq({ httpVersion: '2.0', httpVersionMajor: 2 }), + res, + collectEntryAssets(FIXTURE_MANIFEST), + distDir + ); + expect(attempted).toBe(0); + }); + + it('answers 404 on the pushed stream when the asset vanished mid-release', async () => { + fs.rmSync(path.join(distDir, 'assets', 'vendor-Vendor01.css')); + const mw = createHttp2PushMiddleware({ distDir, refreshIntervalMs: 0 }); + const streams = []; + const res = mockRes({ + stream: { + pushStream(_headers, _options, callback) { + const stream = { + responded: null, + respond(headers) { + this.responded = headers; + }, + on() {}, + destroy() {}, + end() {}, + }; + streams.push(stream); + callback(null, stream); + }, + }, + }); + runMiddleware(mw, mockReq({ httpVersion: '2.0', httpVersionMajor: 2 }), res); + await new Promise((resolve) => setTimeout(resolve, 50)); + const missing = streams.find((stream) => stream.responded && stream.responded[':status'] === 404); + expect(missing).toBeTruthy(); + }); +}); + +describe('environment configuration', () => { + it('defaults to enabled with early hints and native push on', () => { + expect(optionsFromEnv({})).toEqual({ + enabled: true, + earlyHints: true, + nativePush: true, + verifyFiles: false, + excludeHeavy: true, + }); + }); + + it('parses feature switches', () => { + expect(optionsFromEnv({ HTTP2_PUSH_ENABLED: 'false' }).enabled).toBe(false); + expect(optionsFromEnv({ HTTP2_PUSH_EARLY_HINTS: '0' }).earlyHints).toBe(false); + expect(optionsFromEnv({ HTTP2_PUSH_NATIVE: 'off' }).nativePush).toBe(false); + expect(optionsFromEnv({ HTTP2_PUSH_VERIFY_FILES: 'true' }).verifyFiles).toBe(true); + expect(optionsFromEnv({ HTTP2_PUSH_INCLUDE_HEAVY: 'true' }).excludeHeavy).toBe(false); + expect(optionsFromEnv({ HTTP2_PUSH_ENABLED: '' }).enabled).toBe(true); + }); + + it('parses numeric and path overrides, ignoring invalid numbers', () => { + expect(optionsFromEnv({ HTTP2_PUSH_REFRESH_MS: '250' })).toMatchObject({ refreshIntervalMs: 250 }); + expect(optionsFromEnv({ HTTP2_PUSH_MAX_ASSETS: '4' })).toMatchObject({ maxAssets: 4 }); + expect(optionsFromEnv({ HTTP2_PUSH_MAX_ASSETS: 'lots' })).not.toHaveProperty('maxAssets'); + expect(optionsFromEnv({ HTTP2_PUSH_REFRESH_MS: '-5' })).not.toHaveProperty('refreshIntervalMs'); + expect(optionsFromEnv({ HTTP2_PUSH_MANIFEST: '/srv/app/dist/.vite/manifest.json' })).toMatchObject({ + manifestPath: '/srv/app/dist/.vite/manifest.json', + }); + expect(optionsFromEnv({ HTTP2_PUSH_DIST_DIR: '/srv/app/dist' })).toMatchObject({ distDir: '/srv/app/dist' }); + }); + + it('falls back to the caller-supplied dist directory', () => { + expect(optionsFromEnv({}, { distDir: '/srv/app/dist' })).toMatchObject({ distDir: '/srv/app/dist' }); + expect(optionsFromEnv({ HTTP2_PUSH_DIST_DIR: '/tmp/dist' }, { distDir: '/srv/app/dist' })).toMatchObject({ + distDir: '/tmp/dist', + }); + }); +}); diff --git a/test/snapshots.test.jsx b/test/snapshots.test.jsx index 318f583e..4ca7e917 100644 --- a/test/snapshots.test.jsx +++ b/test/snapshots.test.jsx @@ -1,8 +1,9 @@ -import { describe, it, expect } from 'vitest' +import { beforeAll, describe, it, expect } from 'vitest' import { render } from '@testing-library/react' import { BrowserRouter } from 'react-router-dom' +import App from '../src/App.tsx' import Help from '../src/pages/Help.jsx' -import Ranking from '../src/pages/Ranking.jsx' +import Ranking from '../src/pages/Ranking.tsx' /** * Snapshot tests for UI components @@ -12,7 +13,30 @@ import Ranking from '../src/pages/Ranking.jsx' * npm run test:update-snapshots */ +beforeAll(() => { + // jsdom ships neither IntersectionObserver nor the observer-backed scroll + // reveal used by the landing page; a no-op stub keeps the markup testable. + if (!globalThis.IntersectionObserver) { + globalThis.IntersectionObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } + } +}) + describe('Component Snapshots', () => { + describe('Landing Page', () => { + it('should match snapshot', () => { + const { container } = render( + + + + ) + expect(container.firstChild).toMatchSnapshot() + }) + }) + describe('Help Page', () => { it('should match snapshot', () => { const { container } = render( diff --git a/tests/e2e/layout.spec.ts b/tests/e2e/layout.spec.ts new file mode 100644 index 00000000..e7b8cc57 --- /dev/null +++ b/tests/e2e/layout.spec.ts @@ -0,0 +1,197 @@ +import { expect, test, type Page } from '@playwright/test' + +// Multi-resolution visual layout matrix +// +// The same layout gates run once per device profile defined in +// playwright.config.js (projects `layout-*`): iPhone SE, iPhone 14, +// Pixel 7, iPad, Laptop and a 4K display. Every leg asserts: +// +// 1. the document never scrolls horizontally — the failure a phone user +// sees when an over-wide element cannot be panned back into view, +// 2. no visible element is clipped by the right edge of the viewport +// (catches fixed bars that would otherwise silently hide content), +// 3. the page landmarks (nav, primary heading) stay inside the viewport, +// 4. a viewport screenshot for visual regression. +// +// Run the whole matrix with `npm run test:layout`, or a single resolution +// with `npx playwright test --project=layout-iphone-se`. + +interface Route { + name: string + path: string + /** Text that only appears once the route finished rendering. */ + readyText: string + /** Selector of the element that must be on screen before we measure. */ + landmark: string + /** + * CSS injected before the screenshot. Surfaces whose pixels cannot be + * replayed byte-for-byte (the Mapbox canvas, live RPC latency) are frozen + * to a flat colour so the shot only records layout, not fresh data. + */ + freezeCss?: string +} + +const HELP_FREEZE_CSS = ` + #helphone-help-map { background: #101b16 !important; } + #helphone-help-map * { visibility: hidden !important; } + [data-testid="network-status"] { visibility: hidden !important; } +` + +const ROUTES: Route[] = [ + { name: 'home', path: '/', readyText: 'Community Emergency Response Web App', landmark: 'nav' }, + { + name: 'help', + path: '/help', + readyText: 'Get Help', + landmark: '#helphone-help-wrap', + freezeCss: HELP_FREEZE_CSS, + }, + { name: 'ranking', path: '/ranking', readyText: 'Community Responders', landmark: 'h1' }, +] + +interface OverflowReport { + /** `scrollWidth` vs. `innerWidth` of the document. */ + document: { scrollWidth: number; innerWidth: number } + /** Visible elements whose right edge leaves the viewport. */ + offenders: Array<{ selector: string; right: number; width: number; text: string }> +} + +async function measureOverflow(page: Page): Promise { + return page.evaluate(() => { + const describe = (el: Element): string => { + const id = el.id ? `#${el.id}` : '' + const cls = + typeof el.className === 'string' && el.className.trim() + ? '.' + el.className.trim().split(/\s+/).slice(0, 3).join('.') + : '' + return `${el.tagName.toLowerCase()}${id}${cls}` + } + + const viewportWidth = document.documentElement.clientWidth + const offenders: Array<{ selector: string; right: number; width: number; text: string }> = [] + + for (const el of Array.from(document.body.querySelectorAll('*'))) { + const rect = el.getBoundingClientRect() + if (rect.width <= 0 || rect.height <= 0) continue + if (rect.right <= viewportWidth + 1) continue + + const style = window.getComputedStyle(el) + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') continue + + // Content parked off-canvas (closed drawers, translated sheets) cannot + // contribute to horizontal scrolling while it sits below the fold. + if (rect.bottom <= 0 || rect.top >= window.innerHeight) continue + + offenders.push({ + selector: describe(el), + right: Math.round(rect.right), + width: Math.round(rect.width), + text: (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 60), + }) + } + + return { + document: { + scrollWidth: document.documentElement.scrollWidth, + innerWidth: window.innerWidth, + }, + offenders: offenders.slice(0, 10), + } + }) +} + +for (const route of ROUTES) { + test.describe(`${route.name} layout`, () => { + test.beforeEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }) + }) + + test(`renders without horizontal scrolling or clipping — ${route.path}`, async ({ page }) => { + const project = test.info().project + const viewport = page.viewportSize() + const isMobile = Boolean(project.use.isMobile) + test.info().annotations.push({ + type: 'viewport', + description: `${project.name}: ${viewport?.width}x${viewport?.height}${isMobile ? ' (mobile)' : ''}`, + }) + + await page.goto(route.path, { waitUntil: 'domcontentloaded' }) + await page.getByText(route.readyText, { exact: false }).first().waitFor({ timeout: 30_000 }) + await page.waitForLoadState('networkidle').catch(() => {}) + await page.waitForTimeout(1000) + + // Pause any media so a playing frame cannot flicker under the shot. + await page.evaluate(() => { + document.querySelectorAll('video').forEach((video) => video.pause()) + }) + + const report = await measureOverflow(page) + + const scrollOverflow = report.document.scrollWidth - report.document.innerWidth + expect( + scrollOverflow, + `[${project.name}] ${route.path} scrolls horizontally by ${scrollOverflow}px at ` + + `${viewport?.width}px (scrollWidth=${report.document.scrollWidth}, ` + + `innerWidth=${report.document.innerWidth}; offenders: ` + + `${report.offenders.map((o) => o.selector).join(', ') || 'none captured'})`, + ).toBeLessThanOrEqual(1) + + expect( + report.offenders, + `[${project.name}] elements clipped by the right edge of ${viewport?.width}px viewport on ${route.path}`, + ).toEqual([]) + + const landmark = page.locator(route.landmark).first() + await expect(landmark, `${route.path} must render its ${route.landmark} landmark`).toBeVisible() + + const landmarkBox = await landmark.boundingBox() + expect(landmarkBox, `${route.path} landmark ${route.landmark} has no layout box`).not.toBeNull() + if (landmarkBox) { + expect( + landmarkBox.x + landmarkBox.width, + `${route.path} landmark ${route.landmark} must stay inside the viewport`, + ).toBeLessThanOrEqual((viewport?.width ?? 0) + 1) + } + + // Primary heading — asserted whenever the route ships an h1 (the /help + // workspace is a tool surface whose title lives in the sidebar). + const headings = page.getByRole('heading', { level: 1 }) + if ((await headings.count()) > 0) { + const headingBox = await headings.first().boundingBox() + expect(headingBox, `${route.path} primary heading has no layout box`).not.toBeNull() + if (headingBox) { + expect( + headingBox.x + headingBox.width, + `${route.path} primary heading must stay inside the viewport`, + ).toBeLessThanOrEqual((viewport?.width ?? 0) + 1) + } + } + }) + + test(`screenshot matches — ${route.path}`, async ({ page }) => { + await page.goto(route.path, { waitUntil: 'domcontentloaded' }) + await page.getByText(route.readyText, { exact: false }).first().waitFor({ timeout: 30_000 }) + await page.waitForLoadState('networkidle').catch(() => {}) + await page.waitForTimeout(1000) + + await page.evaluate(() => { + document.querySelectorAll('video').forEach((video) => video.pause()) + document.documentElement.dataset.layoutSnapshot = 'true' + }) + + if (route.freezeCss) { + await page.addStyleTag({ content: route.freezeCss }) + // Give the injected style one frame to repaint before capturing. + await page.waitForTimeout(150) + } + + await expect(page).toHaveScreenshot(`layout-${route.name}.png`, { + animations: 'disabled', + caret: 'hide', + // Video frames are the only pixel source that cannot be replayed. + mask: [page.locator('video')], + maxDiffPixelRatio: 0.05, + }) + }) + }) +} diff --git a/tests/e2e/layout.spec.ts-snapshots/layout-help-layout-display-4k-linux.png b/tests/e2e/layout.spec.ts-snapshots/layout-help-layout-display-4k-linux.png new file mode 100644 index 00000000..6ed07fc7 Binary files /dev/null and b/tests/e2e/layout.spec.ts-snapshots/layout-help-layout-display-4k-linux.png differ diff --git a/tests/e2e/layout.spec.ts-snapshots/layout-help-layout-ipad-linux.png b/tests/e2e/layout.spec.ts-snapshots/layout-help-layout-ipad-linux.png new file mode 100644 index 00000000..8202ca4e Binary files /dev/null and b/tests/e2e/layout.spec.ts-snapshots/layout-help-layout-ipad-linux.png differ diff --git a/tests/e2e/layout.spec.ts-snapshots/layout-help-layout-iphone-14-linux.png b/tests/e2e/layout.spec.ts-snapshots/layout-help-layout-iphone-14-linux.png new file mode 100644 index 00000000..05aeec35 Binary files /dev/null and b/tests/e2e/layout.spec.ts-snapshots/layout-help-layout-iphone-14-linux.png differ diff --git a/tests/e2e/layout.spec.ts-snapshots/layout-help-layout-iphone-se-linux.png b/tests/e2e/layout.spec.ts-snapshots/layout-help-layout-iphone-se-linux.png new file mode 100644 index 00000000..e85d03ab Binary files /dev/null and b/tests/e2e/layout.spec.ts-snapshots/layout-help-layout-iphone-se-linux.png differ diff --git a/tests/e2e/layout.spec.ts-snapshots/layout-help-layout-laptop-linux.png b/tests/e2e/layout.spec.ts-snapshots/layout-help-layout-laptop-linux.png new file mode 100644 index 00000000..1b16f28f Binary files /dev/null and b/tests/e2e/layout.spec.ts-snapshots/layout-help-layout-laptop-linux.png differ diff --git a/tests/e2e/layout.spec.ts-snapshots/layout-help-layout-pixel-7-linux.png b/tests/e2e/layout.spec.ts-snapshots/layout-help-layout-pixel-7-linux.png new file mode 100644 index 00000000..63597cf2 Binary files /dev/null and b/tests/e2e/layout.spec.ts-snapshots/layout-help-layout-pixel-7-linux.png differ diff --git a/tests/e2e/layout.spec.ts-snapshots/layout-home-layout-display-4k-linux.png b/tests/e2e/layout.spec.ts-snapshots/layout-home-layout-display-4k-linux.png new file mode 100644 index 00000000..073bc6f9 Binary files /dev/null and b/tests/e2e/layout.spec.ts-snapshots/layout-home-layout-display-4k-linux.png differ diff --git a/tests/e2e/layout.spec.ts-snapshots/layout-home-layout-ipad-linux.png b/tests/e2e/layout.spec.ts-snapshots/layout-home-layout-ipad-linux.png new file mode 100644 index 00000000..34b183e8 Binary files /dev/null and b/tests/e2e/layout.spec.ts-snapshots/layout-home-layout-ipad-linux.png differ diff --git a/tests/e2e/layout.spec.ts-snapshots/layout-home-layout-iphone-14-linux.png b/tests/e2e/layout.spec.ts-snapshots/layout-home-layout-iphone-14-linux.png new file mode 100644 index 00000000..632d5a24 Binary files /dev/null and b/tests/e2e/layout.spec.ts-snapshots/layout-home-layout-iphone-14-linux.png differ diff --git a/tests/e2e/layout.spec.ts-snapshots/layout-home-layout-iphone-se-linux.png b/tests/e2e/layout.spec.ts-snapshots/layout-home-layout-iphone-se-linux.png new file mode 100644 index 00000000..1311de70 Binary files /dev/null and b/tests/e2e/layout.spec.ts-snapshots/layout-home-layout-iphone-se-linux.png differ diff --git a/tests/e2e/layout.spec.ts-snapshots/layout-home-layout-laptop-linux.png b/tests/e2e/layout.spec.ts-snapshots/layout-home-layout-laptop-linux.png new file mode 100644 index 00000000..5e40d8f5 Binary files /dev/null and b/tests/e2e/layout.spec.ts-snapshots/layout-home-layout-laptop-linux.png differ diff --git a/tests/e2e/layout.spec.ts-snapshots/layout-home-layout-pixel-7-linux.png b/tests/e2e/layout.spec.ts-snapshots/layout-home-layout-pixel-7-linux.png new file mode 100644 index 00000000..42a8c327 Binary files /dev/null and b/tests/e2e/layout.spec.ts-snapshots/layout-home-layout-pixel-7-linux.png differ diff --git a/tests/e2e/layout.spec.ts-snapshots/layout-ranking-layout-display-4k-linux.png b/tests/e2e/layout.spec.ts-snapshots/layout-ranking-layout-display-4k-linux.png new file mode 100644 index 00000000..cd2e0515 Binary files /dev/null and b/tests/e2e/layout.spec.ts-snapshots/layout-ranking-layout-display-4k-linux.png differ diff --git a/tests/e2e/layout.spec.ts-snapshots/layout-ranking-layout-ipad-linux.png b/tests/e2e/layout.spec.ts-snapshots/layout-ranking-layout-ipad-linux.png new file mode 100644 index 00000000..949a2728 Binary files /dev/null and b/tests/e2e/layout.spec.ts-snapshots/layout-ranking-layout-ipad-linux.png differ diff --git a/tests/e2e/layout.spec.ts-snapshots/layout-ranking-layout-iphone-14-linux.png b/tests/e2e/layout.spec.ts-snapshots/layout-ranking-layout-iphone-14-linux.png new file mode 100644 index 00000000..94f1772e Binary files /dev/null and b/tests/e2e/layout.spec.ts-snapshots/layout-ranking-layout-iphone-14-linux.png differ diff --git a/tests/e2e/layout.spec.ts-snapshots/layout-ranking-layout-iphone-se-linux.png b/tests/e2e/layout.spec.ts-snapshots/layout-ranking-layout-iphone-se-linux.png new file mode 100644 index 00000000..c3338143 Binary files /dev/null and b/tests/e2e/layout.spec.ts-snapshots/layout-ranking-layout-iphone-se-linux.png differ diff --git a/tests/e2e/layout.spec.ts-snapshots/layout-ranking-layout-laptop-linux.png b/tests/e2e/layout.spec.ts-snapshots/layout-ranking-layout-laptop-linux.png new file mode 100644 index 00000000..cccd0d25 Binary files /dev/null and b/tests/e2e/layout.spec.ts-snapshots/layout-ranking-layout-laptop-linux.png differ diff --git a/tests/e2e/layout.spec.ts-snapshots/layout-ranking-layout-pixel-7-linux.png b/tests/e2e/layout.spec.ts-snapshots/layout-ranking-layout-pixel-7-linux.png new file mode 100644 index 00000000..82b54f33 Binary files /dev/null and b/tests/e2e/layout.spec.ts-snapshots/layout-ranking-layout-pixel-7-linux.png differ diff --git a/vite.config.ts b/vite.config.ts index 209d0a34..2b416bdc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -162,6 +162,10 @@ export default defineConfig(({ mode }) => ({ }, }, build: { + // Emit `dist/.vite/manifest.json` (entry chunk + hashed static imports) so + // server/middleware/http2Push.ts can build `Link: …; rel=preload` headers + // from the assets this release actually shipped. + manifest: true, chunkSizeWarningLimit: 500, // #542 FCP: keep heavy, route-specific chunks (Mapbox GL, ZK/WASM prover) // out of the entry HTML's modulepreload list; they are fetched on intent