Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions weather-voodoo/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ dist
.env.*.local
*.log
.DS_Store
playwright-report
test-results
static/build-id.txt
4 changes: 3 additions & 1 deletion weather-voodoo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 30 additions & 0 deletions weather-voodoo/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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
}
});
38 changes: 38 additions & 0 deletions weather-voodoo/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

65 changes: 65 additions & 0 deletions weather-voodoo/scripts/dev-preview.sh
Original file line number Diff line number Diff line change
@@ -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 "============================================================"
5 changes: 5 additions & 0 deletions weather-voodoo/src/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0b1220" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Weather Voodoo" />
<meta name="description" content="Hour-by-hour fused weather forecasts for routes and locations. Activity recommendations included." />
%sveltekit.head%
</head>
Expand Down
99 changes: 99 additions & 0 deletions weather-voodoo/src/service-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />

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<Response> {
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<Response> {
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<Response> {
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();
});
Binary file added weather-voodoo/static/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added weather-voodoo/static/icon-192-maskable.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added weather-voodoo/static/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added weather-voodoo/static/icon-512-maskable.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added weather-voodoo/static/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 38 additions & 0 deletions weather-voodoo/static/manifest.webmanifest
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
Loading