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'
}
});