diff --git a/weather-voodoo/.gitignore b/weather-voodoo/.gitignore index 0e98c654..107250b2 100644 --- a/weather-voodoo/.gitignore +++ b/weather-voodoo/.gitignore @@ -8,3 +8,6 @@ dist .env.*.local *.log .DS_Store +playwright-report +test-results +static/build-id.txt diff --git a/weather-voodoo/package.json b/weather-voodoo/package.json index 8c507996..74479227 100644 --- a/weather-voodoo/package.json +++ b/weather-voodoo/package.json @@ -9,9 +9,11 @@ "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:e2e": "playwright test" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@sveltejs/adapter-vercel": "^5.5.0", "@sveltejs/kit": "^2.8.0", "@sveltejs/vite-plugin-svelte": "^4.0.0", diff --git a/weather-voodoo/playwright.config.ts b/weather-voodoo/playwright.config.ts new file mode 100644 index 00000000..b978a776 --- /dev/null +++ b/weather-voodoo/playwright.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, devices } from '@playwright/test'; + +const PORT = Number(process.env.PLAYWRIGHT_PORT ?? 4173); + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]], + timeout: 30_000, + use: { + baseURL: `http://localhost:${PORT}`, + trace: 'on-first-retry', + serviceWorkers: 'allow' + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + } + ], + webServer: { + command: `pnpm exec vite preview --port ${PORT} --strictPort`, + url: `http://localhost:${PORT}`, + reuseExistingServer: !process.env.CI, + timeout: 60_000 + } +}); diff --git a/weather-voodoo/pnpm-lock.yaml b/weather-voodoo/pnpm-lock.yaml index 905cf19c..419537b9 100644 --- a/weather-voodoo/pnpm-lock.yaml +++ b/weather-voodoo/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: specifier: ^4.7.0 version: 4.7.1 devDependencies: + '@playwright/test': + specifier: ^1.60.0 + version: 1.60.0 '@sveltejs/adapter-vercel': specifier: ^5.5.0 version: 5.10.3(@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.55.8)(vite@5.4.21(@types/node@25.9.0)))(svelte@5.55.8)(typescript@5.9.3)(vite@5.4.21(@types/node@25.9.0)))(rollup@4.60.4) @@ -400,6 +403,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -851,6 +859,11 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1017,6 +1030,16 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + postcss@8.5.14: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} @@ -1498,6 +1521,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + '@polka/url@1.0.0-next.29': {} '@rollup/pluginutils@5.3.0(rollup@4.60.4)': @@ -1910,6 +1937,9 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -2064,6 +2094,14 @@ snapshots: picomatch@4.0.4: {} + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.14: dependencies: nanoid: 3.3.12 diff --git a/weather-voodoo/scripts/dev-preview.sh b/weather-voodoo/scripts/dev-preview.sh new file mode 100755 index 00000000..7b306abe --- /dev/null +++ b/weather-voodoo/scripts/dev-preview.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# Build the app with a unique build ID, run vitest + Playwright e2e against +# the production build, then deploy a Vercel preview and emit a bypass URL +# the project owner can click to test (avoids Vercel SSO interstitial). +# +# Requirements: +# - `vercel login` already done in this environment +# - `.vercel/project.json` present (created by `vercel link`) +# +# Skip the Vercel step with NO_DEPLOY=1. +set -euo pipefail + +cd "$(dirname "$0")/.." + +BUILD_ID="${BUILD_ID:-$(date -u +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD 2>/dev/null || echo nogit)}" +PORT="${PORT:-4173}" + +echo "==> Build ID: $BUILD_ID" +echo "$BUILD_ID" > static/build-id.txt + +echo "==> Unit tests" +pnpm test + +echo "==> Production build" +pnpm build + +echo "==> Playwright e2e (boots its own preview server)" +PLAYWRIGHT_PORT="$PORT" pnpm test:e2e + +if [ "${NO_DEPLOY:-0}" = "1" ] || [ ! -f .vercel/project.json ]; then + echo + echo "============================================================" + echo " Build ID: $BUILD_ID" + echo " (skipping Vercel deploy — NO_DEPLOY set or project not linked)" + echo "============================================================" + exit 0 +fi + +echo "==> Vercel preview deploy" +vercel pull --yes --environment=preview >/dev/null 2>&1 +vercel build >/dev/null +DEPLOY_OUT=$(vercel deploy --prebuilt 2>&1) +URL=$(echo "$DEPLOY_OUT" | grep -oE 'https://[a-z0-9-]+\.vercel\.app' | head -1 | sed 's#^https://##') +if [ -z "$URL" ]; then + echo "Failed to parse deploy URL. Last 20 lines:" + echo "$DEPLOY_OUT" | tail -20 + exit 1 +fi + +PROJECT_ID=$(python3 -c "import json; print(json.load(open('.vercel/project.json'))['projectId'])") +TEAM_ID=$(python3 -c "import json; print(json.load(open('.vercel/project.json'))['orgId'])") +TOKEN=$(python3 -c "import json; print(json.load(open('$HOME/.local/share/com.vercel.cli/auth.json'))['token'])") +BYPASS=$(curl -sf "https://api.vercel.com/v9/projects/$PROJECT_ID?teamId=$TEAM_ID" \ + -H "Authorization: Bearer $TOKEN" \ + | python3 -c "import json, sys; d=json.load(sys.stdin); bp=d.get('protectionBypass') or {}; print(next(iter(bp.keys()), ''))" 2>/dev/null || echo "") + +PUBLIC_URL="https://$URL/?x-vercel-protection-bypass=$BYPASS&x-vercel-set-bypass-cookie=true" + +echo +echo "============================================================" +echo " Build ID: $BUILD_ID" +echo " Vercel URL: https://$URL" +echo " Test URL: $PUBLIC_URL" +echo " (the test URL sets the bypass cookie so SSO is skipped)" +echo "============================================================" diff --git a/weather-voodoo/src/app.html b/weather-voodoo/src/app.html index 96058dab..668747f4 100644 --- a/weather-voodoo/src/app.html +++ b/weather-voodoo/src/app.html @@ -3,8 +3,13 @@ + + + + + %sveltekit.head% diff --git a/weather-voodoo/src/service-worker.ts b/weather-voodoo/src/service-worker.ts new file mode 100644 index 00000000..de019f02 --- /dev/null +++ b/weather-voodoo/src/service-worker.ts @@ -0,0 +1,99 @@ +/// +/// +/// +/// + +import { build, files, version } from '$service-worker'; + +const sw = self as unknown as ServiceWorkerGlobalScope; + +const APP_CACHE = `app-shell-${version}`; +const API_CACHE = `api-cache-${version}`; +const API_MAX_AGE_MS = 60 * 60 * 1000; + +const PRECACHE = [...build, ...files]; + +sw.addEventListener('install', (event) => { + event.waitUntil( + caches.open(APP_CACHE).then((cache) => cache.addAll(PRECACHE)).then(() => sw.skipWaiting()) + ); +}); + +sw.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all(keys.filter((k) => k !== APP_CACHE && k !== API_CACHE).map((k) => caches.delete(k))) + ) + .then(() => sw.clients.claim()) + ); +}); + +function isApiRequest(url: URL): boolean { + return url.origin === sw.location.origin && url.pathname.startsWith('/api/'); +} + +function isPrecached(url: URL): boolean { + if (url.origin !== sw.location.origin) return false; + return PRECACHE.includes(url.pathname); +} + +async function networkFirstWithSWR(request: Request): Promise { + const cache = await caches.open(API_CACHE); + try { + const fresh = await fetch(request); + if (fresh.ok) { + const stamped = await stampDate(fresh.clone()); + await cache.put(request, stamped); + } + return fresh; + } catch (err) { + const cached = await cache.match(request); + if (cached) return cached; + throw err; + } +} + +async function stampDate(response: Response): Promise { + const headers = new Headers(response.headers); + headers.set('x-sw-cached-at', String(Date.now())); + const body = await response.arrayBuffer(); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers + }); +} + +async function cacheFirst(request: Request): Promise { + const cache = await caches.open(APP_CACHE); + const hit = await cache.match(request); + if (hit) return hit; + const fresh = await fetch(request); + if (fresh.ok && request.method === 'GET') { + cache.put(request, fresh.clone()).catch(() => {}); + } + return fresh; +} + +sw.addEventListener('fetch', (event) => { + const { request } = event; + if (request.method !== 'GET') return; + + const url = new URL(request.url); + if (url.origin !== sw.location.origin) return; + + if (isApiRequest(url)) { + event.respondWith(networkFirstWithSWR(request)); + return; + } + + if (isPrecached(url) || request.mode === 'navigate') { + event.respondWith(cacheFirst(request)); + } +}); + +sw.addEventListener('message', (event) => { + if (event.data === 'SKIP_WAITING') sw.skipWaiting(); +}); diff --git a/weather-voodoo/static/apple-touch-icon.png b/weather-voodoo/static/apple-touch-icon.png new file mode 100644 index 00000000..82e4f210 Binary files /dev/null and b/weather-voodoo/static/apple-touch-icon.png differ diff --git a/weather-voodoo/static/icon-192-maskable.png b/weather-voodoo/static/icon-192-maskable.png new file mode 100644 index 00000000..613ddf09 Binary files /dev/null and b/weather-voodoo/static/icon-192-maskable.png differ diff --git a/weather-voodoo/static/icon-192.png b/weather-voodoo/static/icon-192.png new file mode 100644 index 00000000..133e160b Binary files /dev/null and b/weather-voodoo/static/icon-192.png differ diff --git a/weather-voodoo/static/icon-512-maskable.png b/weather-voodoo/static/icon-512-maskable.png new file mode 100644 index 00000000..490de714 Binary files /dev/null and b/weather-voodoo/static/icon-512-maskable.png differ diff --git a/weather-voodoo/static/icon-512.png b/weather-voodoo/static/icon-512.png new file mode 100644 index 00000000..6de48fc5 Binary files /dev/null and b/weather-voodoo/static/icon-512.png differ diff --git a/weather-voodoo/static/manifest.webmanifest b/weather-voodoo/static/manifest.webmanifest new file mode 100644 index 00000000..bf687720 --- /dev/null +++ b/weather-voodoo/static/manifest.webmanifest @@ -0,0 +1,38 @@ +{ + "name": "Weather Voodoo", + "short_name": "Voodoo", + "description": "Hour-by-hour fused weather forecasts for routes and locations. Rank the best windows to travel.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", + "theme_color": "#0b1220", + "background_color": "#0b1220", + "categories": ["weather", "travel", "navigation", "utilities"], + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-192-maskable.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/icon-512-maskable.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/weather-voodoo/tests/e2e/pwa.spec.ts b/weather-voodoo/tests/e2e/pwa.spec.ts new file mode 100644 index 00000000..59fa789c --- /dev/null +++ b/weather-voodoo/tests/e2e/pwa.spec.ts @@ -0,0 +1,85 @@ +import { test, expect } from '@playwright/test'; + +test.describe('PWA installability', () => { + test('serves manifest with required fields', async ({ request }) => { + const res = await request.get('/manifest.webmanifest'); + expect(res.status()).toBe(200); + const contentType = res.headers()['content-type'] ?? ''; + expect(contentType).toMatch(/manifest\+json|application\/json/); + const manifest = await res.json(); + expect(manifest.name).toBe('Weather Voodoo'); + expect(manifest.short_name).toBeTruthy(); + expect(manifest.start_url).toBe('/'); + expect(manifest.display).toBe('standalone'); + expect(manifest.theme_color).toBe('#0b1220'); + expect(manifest.background_color).toBe('#0b1220'); + expect(Array.isArray(manifest.icons)).toBe(true); + const sizes = manifest.icons.map((i: { sizes: string }) => i.sizes); + expect(sizes).toContain('192x192'); + expect(sizes).toContain('512x512'); + const purposes = manifest.icons.map((i: { purpose?: string }) => i.purpose); + expect(purposes).toContain('maskable'); + }); + + test('every icon referenced by manifest is reachable', async ({ request }) => { + const manifest = await (await request.get('/manifest.webmanifest')).json(); + for (const icon of manifest.icons) { + const r = await request.get(icon.src); + expect(r.status(), `${icon.src} status`).toBe(200); + expect(r.headers()['content-type'], `${icon.src} type`).toContain('image/png'); + } + }); + + test('apple-touch-icon is reachable and linked from ', async ({ page, request }) => { + await page.goto('/'); + const href = await page.getAttribute('link[rel="apple-touch-icon"]', 'href'); + expect(href).toBeTruthy(); + const r = await request.get(href!); + expect(r.status()).toBe(200); + }); + + test('manifest is linked from ', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('link[rel="manifest"]')).toHaveCount(1); + const href = await page.getAttribute('link[rel="manifest"]', 'href'); + expect(href).toMatch(/manifest\.webmanifest$/); + }); + + test('theme-color meta matches manifest', async ({ page }) => { + await page.goto('/'); + const content = await page.getAttribute('meta[name="theme-color"]', 'content'); + expect(content).toBe('#0b1220'); + }); + + test('iOS-specific meta tags are present', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('meta[name="apple-mobile-web-app-capable"]')).toHaveCount(1); + await expect(page.locator('meta[name="apple-mobile-web-app-title"]')).toHaveCount(1); + }); + + test('service worker registers and becomes active', async ({ page }) => { + await page.goto('/'); + const state = await page.evaluate(async () => { + if (!('serviceWorker' in navigator)) return 'no-sw'; + const reg = await navigator.serviceWorker.ready; + const worker = reg.active; + if (!worker) return 'no-active'; + if (worker.state === 'activated') return 'activated'; + return await new Promise((resolve) => { + const onChange = () => { + if (worker.state === 'activated') { + worker.removeEventListener('statechange', onChange); + resolve('activated'); + } + }; + worker.addEventListener('statechange', onChange); + }); + }); + expect(state).toBe('activated'); + }); + + test('app shell still loads (smoke test)', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('h1')).toContainText('Weather Voodoo'); + }); +}); diff --git a/weather-voodoo/vite.config.ts b/weather-voodoo/vite.config.ts index 1c276a76..69592c66 100644 --- a/weather-voodoo/vite.config.ts +++ b/weather-voodoo/vite.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ plugins: [sveltekit()], test: { include: ['tests/**/*.{test,spec}.{js,ts}'], + exclude: ['tests/e2e/**', 'node_modules/**'], environment: 'node' } });