fix: remediate branch review findings

This commit is contained in:
Josh Creek
2026-09-14 11:47:58 +01:00
parent 4af33709a3
commit 86f1c21e4d
38 changed files with 65833 additions and 58545 deletions
+18
View File
@@ -9,6 +9,9 @@ concurrency:
group: tests-${{ github.ref }} group: tests-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: read
# `$env/static/public` is resolved at build time, so every variable imported from it must be # `$env/static/public` is resolved at build time, so every variable imported from it must be
# present for `vite build` and `svelte-check` to succeed on a clean checkout. These are the # present for `vite build` and `svelte-check` to succeed on a clean checkout. These are the
# local-stack defaults already published in .env.local.example - never real credentials. # local-stack defaults already published in .env.local.example - never real credentials.
@@ -20,16 +23,28 @@ env:
jobs: jobs:
quality: quality:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
cache: npm cache: npm
- run: npm ci - run: npm ci
- name: Check committed whitespace
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
git diff --check "${{ github.event.pull_request.base.sha }}...HEAD"
else
git diff --check "HEAD^...HEAD"
fi
- run: npm run check - run: npm run check
- run: npm run lint - run: npm run lint
- run: npm run test:fast - run: npm run test:fast
- name: Verify tests leave the checkout clean
run: test -z "$(git status --porcelain --untracked-files=all)"
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
@@ -39,6 +54,7 @@ jobs:
integration: integration:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
@@ -54,6 +70,7 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 25
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
@@ -65,6 +82,7 @@ jobs:
bdd: bdd:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
+2
View File
@@ -7,6 +7,8 @@ yarn.lock
# changes under tens of thousands of lines of churn. # changes under tens of thousands of lines of churn.
src/lib/helpers/pokeapi-pokemon.json src/lib/helpers/pokeapi-pokemon.json
static/sprites/pokemon.json static/sprites/pokemon.json
src/lib/helpers/pokedex.json
src/lib/helpers/sprites.json
# Machine-local editor and tool settings. # Machine-local editor and tool settings.
**/*.local.json **/*.local.json
+8 -4
View File
@@ -87,10 +87,14 @@ teardown deletes the users each run creates, so repeated local runs do not need
Chromium is the only configured browser project. Playwright traces and screenshots are retained on Chromium is the only configured browser project. Playwright traces and screenshots are retained on
failure under `test-results`. failure under `test-results`.
Known gap: the password-reset scenarios use an ordinary signed-in session rather than a recovery link, Password-reset scenarios follow recovery links generated by the local Supabase stack and verify both
because following a real recovery link currently bounces to `/signin` - the browser client in the rejected old password and accepted replacement password. The application waits for Supabase to
`src/routes/+layout.ts` has no cookie `set`/`remove` method, so it cannot persist the session it parses confirm the recovery session before enabling the replacement form.
out of the URL. `createRecoveryLink` in `tests/bdd/support/app.ts` is ready for when that is fixed.
After sign-in, the application automatically stores a versioned, per-user read-only copy of every
Pokédex and its referenced artwork. Offline navigation opens a static viewer; all mutation and
authentication controls remain unavailable until connectivity returns. A successful sign-out removes
the user-specific snapshot and artwork caches from the device.
The current National Dex maximum is deliberately asserted as 1025. When adding a new generation, The current National Dex maximum is deliberately asserted as 1025. When adding a new generation,
update that expectation together with Pokémon data, the corresponding game/dex files, database seed, update that expectation together with Pokémon data, the corresponding game/dex files, database seed,
+35 -6
View File
@@ -56,6 +56,34 @@ if (!anonKey || !serviceRoleKey) {
fail(`Supabase status did not return an anonymous and service-role key.\n${SETUP_HINT}`); fail(`Supabase status did not return an anonymous and service-role key.\n${SETUP_HINT}`);
} }
function isLoopbackUrl(value) {
try {
return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(value).hostname);
} catch {
return false;
}
}
if (!isLoopbackUrl(apiUrl)) {
fail(`Refusing to run local-stack tests against non-loopback Supabase URL: ${apiUrl}`);
}
const providerUrlVariables = [
'MOCK_PROVIDER_URL',
'GOOGLE_OAUTH_AUTHORIZE_URL',
'GOOGLE_OAUTH_TOKEN_URL',
'GOOGLE_DRIVE_API_URL',
'GOOGLE_DRIVE_UPLOAD_URL',
'DROPBOX_OAUTH_AUTHORIZE_URL',
'DROPBOX_OAUTH_TOKEN_URL',
'DROPBOX_UPLOAD_URL'
];
for (const name of providerUrlVariables) {
const value = process.env[name];
if (value && !isLoopbackUrl(value)) {
fail(`Refusing to run provider tests with non-loopback ${name}: ${value}`);
}
}
// A running-but-unseeded database is the most common broken state, and it surfaces downstream as // A running-but-unseeded database is the most common broken state, and it surfaces downstream as
// a confusing assertion failure. Check it here instead. // a confusing assertion failure. Check it here instead.
const probe = await fetch(`${apiUrl}/rest/v1/pokedex_entries?select=id&limit=1`, { const probe = await fetch(`${apiUrl}/rest/v1/pokedex_entries?select=id&limit=1`, {
@@ -79,12 +107,13 @@ const child = spawnSync(command, args, {
shell: useShell, shell: useShell,
env: { env: {
...process.env, ...process.env,
PUBLIC_SUPABASE_URL: process.env.PUBLIC_SUPABASE_URL ?? apiUrl, // Never allow an exported production value to redirect a local integration run.
PUBLIC_SUPABASE_ANON_KEY: process.env.PUBLIC_SUPABASE_ANON_KEY ?? anonKey, PUBLIC_SUPABASE_URL: apiUrl,
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY ?? serviceRoleKey, PUBLIC_SUPABASE_ANON_KEY: anonKey,
TEST_SUPABASE_URL: process.env.TEST_SUPABASE_URL ?? apiUrl, SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
TEST_SUPABASE_ANON_KEY: process.env.TEST_SUPABASE_ANON_KEY ?? anonKey, TEST_SUPABASE_URL: apiUrl,
E2E_SERVICE_ROLE_KEY: process.env.E2E_SERVICE_ROLE_KEY ?? serviceRoleKey TEST_SUPABASE_ANON_KEY: anonKey,
E2E_SERVICE_ROLE_KEY: serviceRoleKey
} }
}); });
+6 -47
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public'; import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public';
import { inView } from '$lib/actions/inView'; import { inView } from '$lib/actions/inView';
import { resolveSpriteUrl } from '$lib/utils/spriteUrl';
export let pokemonName: string; export let pokemonName: string;
export let pokedexNumber: string | number; export let pokedexNumber: string | number;
@@ -12,46 +13,7 @@
let imagePath = null as string | null; let imagePath = null as string | null;
let isInView = false; let isInView = false;
function isFemaleForm(value?: string) {
return /^female\b/i.test((value ?? '').trim());
}
function buildFallbackKey() {
const strippedPokedexNumber = pokedexNumber.toString().replace(/^0+/, '') || '0';
if (!form) return strippedPokedexNumber;
let formValue = form.trim();
formValue = formValue.replace(/^female[-\s]*/i, '');
formValue = formValue
.replace(/\s*\(.*?\)/g, '')
.replace(/\s*\[.*?\]/g, '')
.trim();
if (!formValue || formValue.toLowerCase() === 'male') return strippedPokedexNumber;
formValue = formValue
.toLowerCase()
.replace(/%/g, '')
.replace(/\balolan\b/g, 'alola')
.replace(/\bgalarian\b/g, 'galar')
.replace(/\bhisuian\b/g, 'hisui')
.replace(/\bpaldean\b/g, 'paldea')
.replace(/\bform(e)?$/, '')
.replace(/\bability$/, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
.replace(/2/g, 'two')
.replace(/3/g, 'three')
.replace(/4/g, 'four');
return formValue ? `${strippedPokedexNumber}-${formValue}` : strippedPokedexNumber;
}
$: { $: {
const rootFolderBase =
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'
? '/sprites-small/home'
: 'https://raw.githubusercontent.com/jcreek/LivingDexTracker/master/static/sprites-small/home';
const resolvedSpriteKey = spriteKey?.trim() || buildFallbackKey();
if (!spriteKey?.trim()) { if (!spriteKey?.trim()) {
console.warn('Missing sprite key for pokemon entry', { console.warn('Missing sprite key for pokemon entry', {
pokemonName, pokemonName,
@@ -60,14 +22,11 @@
}); });
} }
let rootFolder = rootFolderBase; imagePath = resolveSpriteUrl(
if (shiny) { { pokedexNumber: Number(pokedexNumber), form, spriteKey },
rootFolder += '/shiny'; !!shiny,
} PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'
if (isFemaleForm(form)) { );
rootFolder += '/female';
}
imagePath = `${rootFolder}/${resolvedSpriteKey}.webp`;
} }
$: if (loadingStrategy !== 'inView') { $: if (loadingStrategy !== 'inView') {
+29 -9
View File
@@ -2,7 +2,10 @@
import type { SupabaseClient } from '@supabase/supabase-js'; import type { SupabaseClient } from '@supabase/supabase-js';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { clearOfflineData } from '$lib/stores/offlineSync';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let errorMessage = '';
let isSigningOut = false;
function emitSignedOutEvent() { function emitSignedOutEvent() {
dispatch('signedOut', {}); dispatch('signedOut', {});
@@ -12,15 +15,32 @@
export let supabase: SupabaseClient; export let supabase: SupabaseClient;
async function signOut() { async function signOut() {
// `.then(() => {...})` resolved to undefined, so destructuring `error` off it threw a errorMessage = '';
// TypeError on every sign-out - after the event had already been emitted. isSigningOut = true;
const { error } = await supabase.auth.signOut(); try {
if (error) console.error('Sign out failed', error); const { error } = await supabase.auth.signOut();
emitSignedOutEvent(); if (error) {
// Signing out used to leave the user sitting on the protected page they were on, still errorMessage = `Sign out failed: ${error.message || 'Please try again.'}`;
// showing its content. Send them to the public home page and re-run the server loads. dispatch('signOutFailed', { message: errorMessage });
await goto('/', { invalidateAll: true }); return;
}
try {
await clearOfflineData();
} catch (cacheError) {
console.error('Signed out, but failed to clear offline data', cacheError);
}
emitSignedOutEvent();
await goto('/', { invalidateAll: true });
} catch (error) {
console.error('Sign out failed', error);
errorMessage = 'Sign out failed. Please try again.';
dispatch('signOutFailed', { message: errorMessage });
} finally {
isSigningOut = false;
}
} }
</script> </script>
<button on:click={signOut}>Sign Out</button> <button on:click={signOut} disabled={isSigningOut}>
{isSigningOut ? 'Signing Out…' : 'Sign Out'}
</button>
+35489 -29190
View File
File diff suppressed because it is too large Load Diff
+29197 -29101
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
import type { CombinedData } from './CombinedData';
import type { Pokedex } from './Pokedex';
export const OFFLINE_SNAPSHOT_VERSION = 1;
export type OfflinePokedexSnapshot = {
pokedex: Pokedex;
entries: CombinedData[];
};
export type OfflineSnapshot = {
version: typeof OFFLINE_SNAPSHOT_VERSION;
generatedAt: string;
userId: string;
pokedexes: OfflinePokedexSnapshot[];
};
+169
View File
@@ -0,0 +1,169 @@
import { writable } from 'svelte/store';
import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public';
import type { OfflineSnapshot } from '$lib/models/OfflineSnapshot';
import { resolveSpriteUrl } from '$lib/utils/spriteUrl';
export type OfflineSyncStatus = {
state: 'idle' | 'syncing' | 'ready' | 'partial' | 'error';
generatedAt: string | null;
message: string | null;
};
export const offlineSyncStatus = writable<OfflineSyncStatus>({
state: 'idle',
generatedAt: null,
message: null
});
const SYNC_EVENT = 'livingdex:offline-sync';
const OFFLINE_CACHE_PREFIX = 'livingdex-offline-';
const OFFLINE_META_CACHE = `${OFFLINE_CACHE_PREFIX}meta-v1`;
const OFFLINE_META_URL = '/__offline/current';
async function workerMessage(
message: unknown,
timeoutMs = 120_000,
waitForReady = true
): Promise<Record<string, unknown>> {
if (!('serviceWorker' in navigator)) throw new Error('Service workers are unavailable');
const registration = waitForReady
? await navigator.serviceWorker.ready
: await navigator.serviceWorker.getRegistration();
if (!registration?.active) throw new Error('Offline worker is not active');
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
const timeout = window.setTimeout(
() => reject(new Error('Offline worker timed out')),
timeoutMs
);
channel.port1.onmessage = (event) => {
window.clearTimeout(timeout);
const result = event.data as Record<string, unknown>;
if (result?.ok) resolve(result);
else reject(new Error(String(result?.error ?? 'Offline worker failed')));
};
registration.active?.postMessage(message, [channel.port2]);
});
}
export function requestOfflineSync(): void {
if (typeof window !== 'undefined') window.dispatchEvent(new Event(SYNC_EVENT));
}
async function deleteOfflineCaches(): Promise<void> {
if (typeof window === 'undefined' || !('caches' in window)) return;
const names = await caches.keys();
await Promise.all(
names.filter((name) => name.startsWith(OFFLINE_CACHE_PREFIX)).map((name) => caches.delete(name))
);
}
export async function claimOfflineData(userId: string): Promise<void> {
if (typeof window === 'undefined') return;
if ('caches' in window) {
const names = await caches.keys();
if (names.includes(OFFLINE_META_CACHE)) {
const response = await (await caches.open(OFFLINE_META_CACHE)).match(OFFLINE_META_URL);
const meta = await response?.json().catch(() => null);
if (typeof meta?.userId !== 'string' || meta.userId !== userId) await deleteOfflineCaches();
}
}
if ('serviceWorker' in navigator && (await navigator.serviceWorker.getRegistration())?.active) {
await workerMessage({ type: 'CLAIM_OFFLINE_USER', userId }, 10_000, false);
}
}
export async function clearOfflineData(): Promise<void> {
if (typeof window === 'undefined') return;
await deleteOfflineCaches();
if ('serviceWorker' in navigator && (await navigator.serviceWorker.getRegistration())?.active) {
await workerMessage({ type: 'CLEAR_OFFLINE_DATA' }, 10_000, false);
}
offlineSyncStatus.set({ state: 'idle', generatedAt: null, message: null });
}
export function startOfflineSync(getUserId: () => string | null): () => void {
let timer: number | null = null;
let stopped = false;
let running = false;
let rerun = false;
let lastGeneratedAt: string | null = null;
const synchronize = async () => {
if (stopped || !navigator.onLine) return;
if (running) {
rerun = true;
return;
}
const userId = getUserId();
if (!userId || !('serviceWorker' in navigator)) return;
running = true;
offlineSyncStatus.set({ state: 'syncing', generatedAt: null, message: null });
try {
await claimOfflineData(userId);
const response = await fetch('/api/offline-snapshot', {
credentials: 'include',
headers: { Accept: 'application/json' }
});
if (!response.ok) throw new Error(`Snapshot request failed (${response.status})`);
const snapshot = (await response.json()) as OfflineSnapshot;
if (snapshot.userId !== userId) throw new Error('Snapshot owner did not match the session');
const useLocal = PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true';
const artworkUrls = Array.from(
new Set(
snapshot.pokedexes.flatMap(({ pokedex, entries }) =>
entries.map(({ pokedexEntry }) => {
const url = resolveSpriteUrl(pokedexEntry, pokedex.isShinyDex, useLocal);
(
pokedexEntry as typeof pokedexEntry & { offlineSpriteUrl: string }
).offlineSpriteUrl = url;
return url;
})
)
)
);
const result = await workerMessage({
type: 'SYNC_OFFLINE_SNAPSHOT',
snapshot,
artworkUrls
});
const failed = Number(result.failedArtwork ?? 0);
offlineSyncStatus.set({
state: failed > 0 ? 'partial' : 'ready',
generatedAt: snapshot.generatedAt,
message: failed > 0 ? `${failed} artwork files could not be cached` : null
});
lastGeneratedAt = snapshot.generatedAt;
} catch (error) {
offlineSyncStatus.set({
state: 'error',
generatedAt: lastGeneratedAt,
message: error instanceof Error ? error.message : String(error)
});
} finally {
running = false;
if (rerun && !stopped) {
rerun = false;
schedule();
}
}
};
const schedule = () => {
if (timer !== null) window.clearTimeout(timer);
timer = window.setTimeout(() => {
timer = null;
void synchronize();
}, 1_000);
};
window.addEventListener(SYNC_EVENT, schedule);
window.addEventListener('online', schedule);
schedule();
return () => {
stopped = true;
if (timer !== null) window.clearTimeout(timer);
window.removeEventListener(SYNC_EVENT, schedule);
window.removeEventListener('online', schedule);
};
}
+43
View File
@@ -0,0 +1,43 @@
export function resolveSpriteUrl(
entry: { pokedexNumber: number; form?: string; spriteKey?: string },
shiny: boolean,
useLocalSprites: boolean
): string {
const form = entry.form?.trim() ?? '';
const strippedNumber = String(entry.pokedexNumber).replace(/^0+/, '') || '0';
let key = entry.spriteKey?.trim();
if (!key) {
let formKey = form
.replace(/^female[-\s]*/i, '')
.replace(/\s*\(.*?\)/g, '')
.replace(/\s*\[.*?\]/g, '')
.trim();
if (!formKey || formKey.toLowerCase() === 'male') {
key = strippedNumber;
} else {
formKey = formKey
.toLowerCase()
.replace(/%/g, '')
.replace(/\balolan\b/g, 'alola')
.replace(/\bgalarian\b/g, 'galar')
.replace(/\bhisuian\b/g, 'hisui')
.replace(/\bpaldean\b/g, 'paldea')
.replace(/\bform(e)?$/, '')
.replace(/\bability$/, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
.replace(/2/g, 'two')
.replace(/3/g, 'three')
.replace(/4/g, 'four');
key = formKey ? `${strippedNumber}-${formKey}` : strippedNumber;
}
}
let root = useLocalSprites
? '/sprites-small/home'
: 'https://raw.githubusercontent.com/jcreek/LivingDexTracker/master/static/sprites-small/home';
if (shiny) root += '/shiny';
if (/^female\b/i.test(form)) root += '/female';
return `${root}/${key}.webp`;
}
+5 -35
View File
@@ -2,26 +2,18 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
/// <reference no-default-lib="true"/> /// <reference no-default-lib="true"/>
/// <reference lib="esnext" /> /// <reference lib="esnext" />
import { import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching';
cleanupOutdatedCaches,
// createHandlerBoundToURL,
precacheAndRoute,
precache
} from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst } from 'workbox-strategies';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import { ExpirationPlugin } from 'workbox-expiration';
declare let self: ServiceWorkerGlobalScope; declare let self: ServiceWorkerGlobalScope;
// Kept as a static script so the same snapshot, artwork and navigation behavior can be imported
// by Workbox's generateSW output too.
self.importScripts('/offline-worker.js');
self.addEventListener('message', (event) => { self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting(); if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting();
}); });
// Add root route to precache manifest
precache([{ url: '/', revision: null }]);
// self.__WB_MANIFEST is default injection point // self.__WB_MANIFEST is default injection point
// Handle the case where __WB_MANIFEST might be undefined in development // Handle the case where __WB_MANIFEST might be undefined in development
const manifest = self.__WB_MANIFEST || []; const manifest = self.__WB_MANIFEST || [];
@@ -29,26 +21,4 @@ if (Array.isArray(manifest)) {
precacheAndRoute(manifest); precacheAndRoute(manifest);
} }
// clean old assets
cleanupOutdatedCaches(); cleanupOutdatedCaches();
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'image-cache',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 3000,
maxAgeSeconds: 60 * 60 * 24 * 30,
purgeOnQuotaError: true
})
]
})
);
// let allowlist: undefined | RegExp[];
// if (import.meta.env.DEV) allowlist = [/^\/$/];
// // to allow work offline
// registerRoute(new NavigationRoute(createHandlerBoundToURL('/'), { allowlist }));
+92 -22
View File
@@ -6,6 +6,13 @@
import SignIn from '$lib/components/SignIn.svelte'; import SignIn from '$lib/components/SignIn.svelte';
import SignOut from '$lib/components/SignOut.svelte'; import SignOut from '$lib/components/SignOut.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import {
claimOfflineData,
clearOfflineData,
offlineSyncStatus,
requestOfflineSync,
startOfflineSync
} from '$lib/stores/offlineSync';
import { pwaInfo } from 'virtual:pwa-info'; import { pwaInfo } from 'virtual:pwa-info';
import { pwaAssetsHead } from 'virtual:pwa-assets/head'; import { pwaAssetsHead } from 'virtual:pwa-assets/head';
@@ -24,9 +31,35 @@
onDestroy(unsubscribe); onDestroy(unsubscribe);
let authSubscription: { unsubscribe: () => void } | null = null; let authSubscription: { unsubscribe: () => void } | null = null;
let stopOfflineSync: (() => void) | null = null;
let isOnline = true;
let signOutError = '';
onMount(() => { onMount(() => {
void getUser(); isOnline = navigator.onLine;
const updateOnlineState = () => {
isOnline = navigator.onLine;
document.documentElement.classList.toggle('offline-readonly', !isOnline);
};
const blockOfflineMutation = (event: Event) => {
if (navigator.onLine) return;
const target = event.target instanceof Element ? event.target : null;
if (!target?.closest('button, input, textarea, select, form')) return;
event.preventDefault();
event.stopImmediatePropagation();
};
window.addEventListener('online', updateOnlineState);
window.addEventListener('offline', updateOnlineState);
for (const name of ['click', 'submit', 'input', 'change', 'keydown']) {
window.addEventListener(name, blockOfflineMutation, true);
}
updateOnlineState();
void getUser()
.then(async () => {
if (localUser) await claimOfflineData(localUser.id);
stopOfflineSync = startOfflineSync(() => localUser?.id ?? null);
})
.catch((error) => console.error('Unable to claim offline data', error));
// Listen for auth state changes to keep the user store in sync // Listen for auth state changes to keep the user store in sync
const { const {
@@ -34,35 +67,26 @@
} = supabase.auth.onAuthStateChange((event, session) => { } = supabase.auth.onAuthStateChange((event, session) => {
if (session) { if (session) {
localUser = session.user; localUser = session.user;
void claimOfflineData(session.user.id)
.then(requestOfflineSync)
.catch((error) => console.error('Unable to claim offline data', error));
} else { } else {
localUser = null; localUser = null;
if (event === 'SIGNED_OUT') void clearOfflineData();
} }
user.set(localUser); user.set(localUser);
}); });
authSubscription = subscription; authSubscription = subscription;
if (pwaInfo) {
void (async () => {
const { registerSW } = await import('virtual:pwa-register');
registerSW({
immediate: true,
onRegistered(r) {
// uncomment following code if you want check for updates
// r && setInterval(() => {
// console.log('Checking for sw update')
// r.update()
// }, 20000 /* 20s for testing purposes */)
console.log(`SW Registered: ${r}`);
},
onRegisterError(error) {
console.log('SW registration error', error);
}
});
})();
}
return () => { return () => {
authSubscription?.unsubscribe(); authSubscription?.unsubscribe();
stopOfflineSync?.();
window.removeEventListener('online', updateOnlineState);
window.removeEventListener('offline', updateOnlineState);
for (const name of ['click', 'submit', 'input', 'change', 'keydown']) {
window.removeEventListener(name, blockOfflineMutation, true);
}
document.documentElement.classList.remove('offline-readonly');
}; };
}); });
@@ -162,7 +186,16 @@
<li> <li>
<a href="/backup-settings"> Backup Settings </a> <a href="/backup-settings"> Backup Settings </a>
</li> </li>
<li><SignOut {supabase} on:signedOut={getUser} /></li> <li>
<SignOut
{supabase}
on:signedOut={() => {
signOutError = '';
void getUser();
}}
on:signOutFailed={(event) => (signOutError = event.detail.message)}
/>
</li>
{:else} {:else}
<li><SignIn {supabase} on:signedIn={getUser} /></li> <li><SignIn {supabase} on:signedIn={getUser} /></li>
{/if} {/if}
@@ -174,6 +207,33 @@
</div> </div>
</div> </div>
</header> </header>
{#if signOutError}
<div class="alert alert-error rounded-none" role="alert">
<span>{signOutError}</span>
</div>
{/if}
{#if !isOnline}
<div class="alert rounded-none" role="status">
<span>Offline read-only mode: saved data remains available, but changes are disabled.</span>
</div>
{:else if localUser && $offlineSyncStatus.state === 'error'}
<div class="alert alert-warning rounded-none" role="status">
<span>Offline copy could not be refreshed: {$offlineSyncStatus.message}</span>
<button class="btn btn-sm" on:click={requestOfflineSync}>Retry</button>
</div>
{:else if localUser && $offlineSyncStatus.state === 'partial'}
<div class="alert alert-warning rounded-none" role="status">
<span>Offline data is saved, but {$offlineSyncStatus.message}.</span>
<button class="btn btn-sm" on:click={requestOfflineSync}>Retry</button>
</div>
{/if}
{#if isOnline && localUser && $offlineSyncStatus.state === 'syncing'}
<p class="bg-base-200 px-4 py-1 text-center text-xs" role="status">Updating offline copy…</p>
{:else if isOnline && localUser && $offlineSyncStatus.generatedAt}
<p class="bg-base-200 px-4 py-1 text-center text-xs" role="status">
Offline copy updated {new Date($offlineSyncStatus.generatedAt).toLocaleString()}.
</p>
{/if}
<main class="flex-grow"> <main class="flex-grow">
<slot /> <slot />
@@ -223,3 +283,13 @@
<ReloadPrompt /> <ReloadPrompt />
{/await} {/await}
</div> </div>
<style>
:global(.offline-readonly button),
:global(.offline-readonly input),
:global(.offline-readonly textarea),
:global(.offline-readonly select) {
pointer-events: none;
opacity: 0.65;
}
</style>
+16 -2
View File
@@ -1,9 +1,15 @@
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'; import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public';
import type { LayoutLoad } from './$types'; import type { LayoutLoad } from './$types';
import { createBrowserClient, isBrowser, parse } from '@supabase/ssr'; import { createBrowserClient, isBrowser, parse, serialize } from '@supabase/ssr';
import type { CookieSerializeOptions } from 'cookie';
export const load: LayoutLoad = async ({ fetch, data, depends }) => { export const load: LayoutLoad = async ({ fetch, data, depends }) => {
depends('supabase:auth'); depends('supabase:auth');
const recoveryIntent =
isBrowser() &&
window.location.pathname === '/reset-password' &&
(new URLSearchParams(window.location.hash.slice(1)).get('type') === 'recovery' ||
new URL(window.location.href).searchParams.has('code'));
const supabase = createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { const supabase = createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
global: { global: {
@@ -17,6 +23,14 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => {
const cookie = parse(document.cookie); const cookie = parse(document.cookie);
return cookie[key]; return cookie[key];
},
set(key: string, value: string, options: CookieSerializeOptions) {
if (isBrowser()) document.cookie = serialize(key, value, { ...options, path: '/' });
},
remove(key: string, options: CookieSerializeOptions) {
if (isBrowser()) {
document.cookie = serialize(key, '', { ...options, path: '/', maxAge: 0 });
}
} }
} }
}); });
@@ -30,5 +44,5 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => {
data: { session } data: { session }
} = await supabase.auth.getSession(); } = await supabase.auth.getSession();
return { supabase, session }; return { supabase, session, recoveryIntent };
}; };
@@ -0,0 +1,42 @@
import { json, type RequestEvent } from '@sveltejs/kit';
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
import { OFFLINE_SNAPSHOT_VERSION, type OfflineSnapshot } from '$lib/models/OfflineSnapshot';
import { requireAuth } from '$lib/utils/auth';
export const GET = async (event: RequestEvent) => {
try {
const userId = await requireAuth(event);
const pokedexes = await new PokedexRepository(event.locals.supabase, userId).findAll();
const snapshots = await Promise.all(
pokedexes.map(async (pokedex) => {
const dexScopes = await resolveDexScopes(event.locals.supabase, pokedex);
const entries = await new CombinedDataRepository(
event.locals.supabase,
userId,
pokedex._id
).findAllCombinedData(userId, pokedex.isFormDex, '', pokedex.gameScope ?? '', dexScopes);
return { pokedex: { ...pokedex, dexScopes }, entries };
})
);
const snapshot: OfflineSnapshot = {
version: OFFLINE_SNAPSHOT_VERSION,
generatedAt: new Date().toISOString(),
userId,
pokedexes: snapshots
};
return json(snapshot, {
headers: {
'Cache-Control': 'private, no-store',
Vary: 'Cookie'
}
});
} catch (error) {
console.error('Unable to build offline snapshot:', error);
if (error && typeof error === 'object' && 'status' in error) throw error;
return json({ error: 'Unable to build offline snapshot' }, { status: 500 });
}
};
+4
View File
@@ -3,6 +3,7 @@
import type { Pokedex } from '$lib/models/Pokedex'; import type { Pokedex } from '$lib/models/Pokedex';
import PokedexCard from '$lib/components/pokedex/PokedexCard.svelte'; import PokedexCard from '$lib/components/pokedex/PokedexCard.svelte';
import PokedexForm from '$lib/components/pokedex/PokedexForm.svelte'; import PokedexForm from '$lib/components/pokedex/PokedexForm.svelte';
import { requestOfflineSync } from '$lib/stores/offlineSync';
export let data; export let data;
let { pokedexes } = data; let { pokedexes } = data;
@@ -93,6 +94,7 @@
closeModal(); closeModal();
await loadPokedexes(); await loadPokedexes();
requestOfflineSync();
return; return;
} else { } else {
// Create new pokédex // Create new pokédex
@@ -114,6 +116,7 @@
// Refresh local state BEFORE deciding whether this is the user's first pokédex. // Refresh local state BEFORE deciding whether this is the user's first pokédex.
closeModal(); closeModal();
await loadPokedexes(); await loadPokedexes();
requestOfflineSync();
// If the user's total pokédex count is now 1, this newly created one is their first. // If the user's total pokédex count is now 1, this newly created one is their first.
if (pokedexes.length === 1) { if (pokedexes.length === 1) {
@@ -149,6 +152,7 @@
} }
await loadPokedexes(); await loadPokedexes();
requestOfflineSync();
} catch (error) { } catch (error) {
console.error('Error deleting pokédex:', error); console.error('Error deleting pokédex:', error);
alert('An error occurred'); alert('An error occurred');
+11
View File
@@ -16,6 +16,7 @@
import PokedexEntryCatchRecord from '$lib/components/pokedex/PokedexEntryCatchRecord.svelte'; import PokedexEntryCatchRecord from '$lib/components/pokedex/PokedexEntryCatchRecord.svelte';
import type { Pokedex } from '$lib/models/Pokedex'; import type { Pokedex } from '$lib/models/Pokedex';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { requestOfflineSync } from '$lib/stores/offlineSync';
export let data: PageData; export let data: PageData;
@@ -59,6 +60,7 @@
lastFlushAttemptAt: null, lastFlushAttemptAt: null,
lastSuccessfulFlushAt: null lastSuccessfulFlushAt: null
}; };
let lastOfflineSyncFlush: number | null = null;
let exportAfterFlush = false; let exportAfterFlush = false;
let exportInFlight = false; let exportInFlight = false;
let exportTimer: ReturnType<typeof setTimeout> | null = null; let exportTimer: ReturnType<typeof setTimeout> | null = null;
@@ -179,6 +181,15 @@
catchWriteQueueUnsubscribe = catchWriteQueue.getStatus.subscribe((s) => { catchWriteQueueUnsubscribe = catchWriteQueue.getStatus.subscribe((s) => {
catchWriteStatus = s; catchWriteStatus = s;
if (
s.lastSuccessfulFlushAt &&
s.lastSuccessfulFlushAt !== lastOfflineSyncFlush &&
s.pending === 0 &&
s.inFlight === 0
) {
lastOfflineSyncFlush = s.lastSuccessfulFlushAt;
requestOfflineSync();
}
if (s.pending > 0 || s.inFlight > 0) { if (s.pending > 0 || s.inFlight > 0) {
if (exportTimer) { if (exportTimer) {
clearTimeout(exportTimer); clearTimeout(exportTimer);
+70 -32
View File
@@ -1,42 +1,74 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onMount } from 'svelte';
import { user } from '$lib/stores/user.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import type { User } from '@supabase/auth-js';
export let data; export let data;
let { supabase } = data; let { supabase } = data;
$: ({ supabase } = data); $: ({ supabase } = data);
let localUser: User | null = null;
const unsubscribe = user.subscribe((value) => {
localUser = value;
});
onDestroy(unsubscribe);
let showAnimation = false; let showAnimation = false;
let hasCheckedAuth = false; let authReady = false;
let authChecking = true;
let authError = '';
const recoveryMarkerKey = 'livingdex:password-recovery';
onMount(() => { onMount(() => {
// Start animation const animationTimer = setTimeout(() => {
setTimeout(() => {
showAnimation = true; showAnimation = true;
}, 100); }, 100);
let settled = false;
// Check authentication after a short delay to allow Supabase to process the token const settle = (hasSession: boolean) => {
setTimeout(() => { if (settled && !hasSession) return;
hasCheckedAuth = true; settled = hasSession;
if (!localUser) { authReady = hasSession;
goto('/signin'); authChecking = false;
authError = hasSession
? ''
: 'This password-reset link is invalid or expired. Request a new link and try again.';
};
const {
data: { subscription }
} = supabase.auth.onAuthStateChange((event, session) => {
if (event === 'PASSWORD_RECOVERY' && session) {
sessionStorage.setItem(
recoveryMarkerKey,
JSON.stringify({ userId: session.user.id, expiresAt: Date.now() + 30 * 60_000 })
);
settle(true);
} }
}, 500); });
void supabase.auth.getSession().then(({ data: { session }, error }) => {
if (error) {
authChecking = false;
authError = error.message;
return;
}
let marker: { userId?: string; expiresAt?: number } | null = null;
try {
marker = JSON.parse(sessionStorage.getItem(recoveryMarkerKey) ?? 'null');
} catch {
sessionStorage.removeItem(recoveryMarkerKey);
}
const suppliedRecoveryIntent = data.recoveryIntent === true;
const isRecoverySession =
!!session &&
(suppliedRecoveryIntent ||
(marker?.userId === session.user.id && Number(marker.expiresAt) > Date.now()));
if (session && suppliedRecoveryIntent) {
sessionStorage.setItem(
recoveryMarkerKey,
JSON.stringify({ userId: session.user.id, expiresAt: Date.now() + 30 * 60_000 })
);
}
if (!isRecoverySession) sessionStorage.removeItem(recoveryMarkerKey);
settle(isRecoverySession);
});
return () => {
clearTimeout(animationTimer);
subscription.unsubscribe();
};
}); });
// Reactive: redirect if user becomes null after initial check
$: if (hasCheckedAuth && !localUser) {
goto('/signin');
}
let password = ''; let password = '';
let confirmPassword = ''; let confirmPassword = '';
let isLoading = false; let isLoading = false;
@@ -44,6 +76,7 @@
let successMessage = ''; let successMessage = '';
async function updatePassword() { async function updatePassword() {
if (!authReady) return;
isLoading = true; isLoading = true;
errorMessage = ''; errorMessage = '';
successMessage = ''; successMessage = '';
@@ -74,11 +107,11 @@
} }
successMessage = 'Password updated successfully! Redirecting to sign in...'; successMessage = 'Password updated successfully! Redirecting to sign in...';
sessionStorage.removeItem(recoveryMarkerKey);
// Redirect to sign in after a short delay setTimeout(async () => {
setTimeout(() => { await supabase.auth.signOut();
goto('/signin'); await goto('/signin');
}, 2000); }, 1_000);
} catch (err) { } catch (err) {
console.error('Update password error:', err); console.error('Update password error:', err);
errorMessage = 'An unexpected error occurred. Please try again.'; errorMessage = 'An unexpected error occurred. Please try again.';
@@ -132,6 +165,11 @@
<!-- Reset Password Card --> <!-- Reset Password Card -->
<div class="card bg-base-200 shadow-xl {showAnimation ? 'animate-slide-up' : ''}"> <div class="card bg-base-200 shadow-xl {showAnimation ? 'animate-slide-up' : ''}">
<div class="card-body p-6 md:p-8"> <div class="card-body p-6 md:p-8">
{#if authChecking}
<div class="alert"><span>Validating your password-reset link…</span></div>
{:else if authError}
<div class="alert alert-error" role="alert"><span>{authError}</span></div>
{/if}
<!-- Error Message --> <!-- Error Message -->
{#if errorMessage} {#if errorMessage}
<div class="alert alert-error text-sm"> <div class="alert alert-error text-sm">
@@ -185,7 +223,7 @@
class="input input-bordered w-full pl-10" class="input input-bordered w-full pl-10"
bind:value={password} bind:value={password}
on:keypress={handleKeyPress} on:keypress={handleKeyPress}
disabled={isLoading} disabled={isLoading || !authReady}
/> />
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -215,7 +253,7 @@
class="input input-bordered w-full pl-10" class="input input-bordered w-full pl-10"
bind:value={confirmPassword} bind:value={confirmPassword}
on:keypress={handleKeyPress} on:keypress={handleKeyPress}
disabled={isLoading} disabled={isLoading || !authReady}
/> />
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -237,7 +275,7 @@
<button <button
class="btn btn-primary w-full" class="btn btn-primary w-full"
on:click={updatePassword} on:click={updatePassword}
disabled={isLoading || !password || !confirmPassword} disabled={isLoading || !authReady || !password || !confirmPassword}
> >
{#if isLoading} {#if isLoading}
<span class="loading loading-spinner loading-sm"></span> <span class="loading loading-spinner loading-sm"></span>
+92
View File
@@ -0,0 +1,92 @@
const META_CACHE = 'livingdex-offline-meta-v1';
const META_URL = '/__offline/current';
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function statusText(record) {
if (!record) return 'Not caught';
const values = [];
if (record.caught) values.push('Caught');
if (record.haveToEvolve) values.push('Needs evolution');
if (record.inHome) values.push('In HOME');
return values.join(' · ') || 'Not caught';
}
function renderSnapshot(snapshot) {
const content = document.querySelector('#offline-content');
for (const { pokedex, entries } of snapshot.pokedexes) {
const section = element('section', 'card bg-base-100 mb-6 shadow');
const body = element('div', 'card-body');
body.append(element('h2', 'card-title text-2xl', pokedex.name));
body.append(element('p', 'text-sm opacity-70', `${entries.length} entries`));
const grid = element(
'div',
'grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-6'
);
for (const { pokedexEntry: entry, catchRecord } of entries) {
const card = element('article', 'rounded border border-base-300 p-2');
const image = element('img', 'mx-auto h-20 w-20 object-contain');
image.alt = `${entry.pokemon}${entry.form ? `${entry.form}` : ''}`;
image.src = entry.offlineSpriteUrl ?? '/placeholder-bulb.png';
image.addEventListener(
'error',
() => {
image.src = '/placeholder-bulb.png';
},
{ once: true }
);
card.append(image);
card.append(element('h3', 'font-semibold', `#${entry.pokedexNumber} ${entry.pokemon}`));
if (entry.form) card.append(element('p', 'text-xs opacity-70', entry.form));
card.append(element('p', 'text-sm', statusText(catchRecord)));
if (catchRecord?.personalNotes)
card.append(element('p', 'mt-1 whitespace-pre-wrap text-xs', catchRecord.personalNotes));
grid.append(card);
}
body.append(grid);
section.append(body);
content.append(section);
}
}
async function load() {
const status = document.querySelector('#offline-status');
try {
const meta = await (await (await caches.open(META_CACHE)).match(META_URL))?.json();
if (!meta?.userId) throw new Error('No collection has been synchronized on this device.');
if (!meta.dataCache) throw new Error('The saved collection metadata is incomplete.');
const dataCache = await caches.open(meta.dataCache);
const response = await dataCache.match(
`/__offline/snapshot/${encodeURIComponent(meta.userId)}`
);
if (!response)
throw new Error('The saved collection is incomplete. Reconnect and synchronize again.');
const snapshot = await response.json();
if (snapshot.version !== 1 || snapshot.userId !== meta.userId)
throw new Error('The saved collection is incompatible with this app version.');
status.textContent = `Saved ${new Date(snapshot.generatedAt).toLocaleString()}.`;
renderSnapshot(snapshot);
} catch (error) {
status.className = 'alert alert-warning';
status.textContent = error instanceof Error ? error.message : String(error);
}
}
void load();
navigator.serviceWorker?.addEventListener('message', (event) => {
if (event.data?.type !== 'OFFLINE_DATA_CLEARED') return;
const content = document.querySelector('#offline-content');
if (content) content.replaceChildren();
const status = document.querySelector('#offline-status');
if (status) {
status.className = 'alert alert-warning';
status.textContent =
'The saved collection was removed because the account changed or signed out.';
}
});
+207
View File
@@ -0,0 +1,207 @@
const OFFLINE_CACHE_PREFIX = 'livingdex-offline-';
const OFFLINE_META_CACHE = `${OFFLINE_CACHE_PREFIX}meta-v1`;
const OFFLINE_META_URL = '/__offline/current';
let offlineEpoch = 0;
let offlineOperation = Promise.resolve();
function queueOfflineOperation(operation) {
const result = offlineOperation.then(operation, operation);
offlineOperation = result.catch(() => undefined);
return result;
}
function dataCacheName(userId, generation) {
return `${OFFLINE_CACHE_PREFIX}data-v1-${userId}-${generation}`;
}
function artworkCacheName(userId, generation) {
return `${OFFLINE_CACHE_PREFIX}art-v1-${userId}-${generation}`;
}
async function currentOfflineMeta() {
const response = await (await caches.open(OFFLINE_META_CACHE)).match(OFFLINE_META_URL);
if (!response) return null;
const data = await response.json().catch(() => null);
return typeof data?.userId === 'string' ? data : null;
}
async function clearOfflineData() {
const names = await caches.keys();
await Promise.all(
names.filter((name) => name.startsWith(OFFLINE_CACHE_PREFIX)).map((name) => caches.delete(name))
);
}
async function notifyOfflineDataCleared() {
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
for (const client of windows) client.postMessage({ type: 'OFFLINE_DATA_CLEARED' });
}
async function cacheArtwork(cache, urls) {
let next = 0;
let failed = 0;
const workers = Array.from({ length: Math.min(6, urls.length) }, async () => {
for (;;) {
const index = next++;
if (index >= urls.length) return;
const url = urls[index];
try {
const response = await fetch(url, {
mode: url.startsWith(self.location.origin) ? 'same-origin' : 'no-cors'
});
if (!response.ok && response.type !== 'opaque') throw new Error(`HTTP ${response.status}`);
await cache.put(url, response);
} catch {
failed++;
}
}
});
await Promise.all(workers);
return failed;
}
self.addEventListener('message', (event) => {
const reply = (value) => event.ports[0]?.postMessage(value);
if (event.data?.type === 'CLEAR_OFFLINE_DATA') {
offlineEpoch++;
event.waitUntil(
queueOfflineOperation(async () => {
await clearOfflineData();
await notifyOfflineDataCleared();
})
.then(() => reply({ ok: true }))
.catch((error) => reply({ ok: false, error: String(error) }))
);
return;
}
if (event.data?.type === 'CLAIM_OFFLINE_USER') {
offlineEpoch++;
event.waitUntil(
queueOfflineOperation(async () => {
if (typeof event.data.userId !== 'string') throw new Error('Invalid offline cache owner');
const meta = await currentOfflineMeta();
if (meta?.userId && meta.userId !== event.data.userId) {
await clearOfflineData();
await notifyOfflineDataCleared();
}
reply({ ok: true });
}).catch((error) => reply({ ok: false, error: String(error) }))
);
return;
}
if (event.data?.type !== 'SYNC_OFFLINE_SNAPSHOT') return;
const syncEpoch = offlineEpoch;
event.waitUntil(
queueOfflineOperation(async () => {
let nextData;
let nextArtwork;
try {
const snapshot = event.data.snapshot;
if (!snapshot || snapshot.version !== 1 || typeof snapshot.userId !== 'string') {
throw new Error('Unsupported offline snapshot');
}
const previousMeta = await currentOfflineMeta();
if (previousMeta?.userId && previousMeta.userId !== snapshot.userId)
await clearOfflineData();
const timestamp = String(snapshot.generatedAt).replace(/[^0-9]/g, '');
const generation = `${timestamp}-${crypto.randomUUID()}`;
nextData = dataCacheName(snapshot.userId, generation);
const dataCache = await caches.open(nextData);
await dataCache.put(
`/__offline/snapshot/${encodeURIComponent(snapshot.userId)}`,
new Response(JSON.stringify(snapshot), {
headers: { 'Content-Type': 'application/json' }
})
);
nextArtwork = artworkCacheName(snapshot.userId, generation);
const artworkCache = await caches.open(nextArtwork);
const failedArtwork = await cacheArtwork(
artworkCache,
Array.from(new Set(event.data.artworkUrls ?? []))
);
if (syncEpoch !== offlineEpoch) {
await Promise.all([caches.delete(nextData), caches.delete(nextArtwork)]);
throw new Error('Offline synchronization was superseded by an account change');
}
const metaCache = await caches.open(OFFLINE_META_CACHE);
await metaCache.put(
OFFLINE_META_URL,
new Response(
JSON.stringify({
userId: snapshot.userId,
generatedAt: snapshot.generatedAt,
dataCache: nextData,
artworkCache: nextArtwork
}),
{
headers: { 'Content-Type': 'application/json' }
}
)
);
if (syncEpoch !== offlineEpoch) {
const current = await currentOfflineMeta();
if (current?.dataCache === nextData) await metaCache.delete(OFFLINE_META_URL);
await Promise.all([caches.delete(nextData), caches.delete(nextArtwork)]);
throw new Error('Offline synchronization was superseded by an account change');
}
if (previousMeta?.artworkCache && previousMeta.artworkCache !== nextArtwork) {
await caches.delete(previousMeta.artworkCache);
}
if (previousMeta?.dataCache && previousMeta.dataCache !== nextData) {
await caches.delete(previousMeta.dataCache);
}
const currentCaches = await caches.keys();
await Promise.all(
currentCaches
.filter(
(name) =>
name.startsWith(OFFLINE_CACHE_PREFIX) &&
![OFFLINE_META_CACHE, nextData, nextArtwork].includes(name)
)
.map((name) => caches.delete(name))
);
reply({ ok: true, failedArtwork });
} catch (error) {
await Promise.allSettled(
[nextData, nextArtwork].filter(Boolean).map((name) => caches.delete(name))
);
reply({ ok: false, error: error instanceof Error ? error.message : String(error) });
}
})
);
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (event.request.mode === 'navigate' && !['/offline', '/offline.html'].includes(url.pathname)) {
const fallback = async () =>
(await caches.match('/offline', { ignoreSearch: true })) ??
(await caches.match('/offline.html', { ignoreSearch: true })) ??
Response.error();
event.respondWith(
self.navigator.onLine
? fetch(new Request(event.request, { cache: 'no-store' })).catch(fallback)
: fallback()
);
return;
}
if (event.request.destination !== 'image') return;
event.respondWith(
(async () => {
const meta = await currentOfflineMeta();
if (meta?.artworkCache) {
const cached = await (
await caches.open(meta.artworkCache)
).match(event.request, {
ignoreSearch: true
});
if (cached) return cached;
}
return fetch(event.request);
})()
);
});
+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#f00000" />
<title>Living Dex Tracker — Offline</title>
<link rel="stylesheet" href="/output.css" />
<script type="module" src="/offline-viewer.js"></script>
</head>
<body class="min-h-screen bg-base-200 text-base-content">
<header class="navbar bg-primary text-primary-content">
<div class="mx-auto w-full max-w-screen-2xl px-4 text-xl font-semibold">
Living Dex Tracker
</div>
</header>
<main class="mx-auto max-w-screen-2xl p-4">
<div class="alert mb-4" role="status">
<span
>You are offline. This is a read-only copy; changes, exports, and account actions are
disabled.</span
>
</div>
<p id="offline-status" class="mb-4">Loading the saved collection…</p>
<div id="offline-content"></div>
</main>
</body>
</html>
+3 -1
View File
@@ -13,7 +13,9 @@ const config = {
// Netlify by default, or the node adapter when NODE_ADAPTER=true. See adapter.mjs. // Netlify by default, or the node adapter when NODE_ADAPTER=true. See adapter.mjs.
adapter, adapter,
serviceWorker: { serviceWorker: {
register: true // VitePWA owns registration. Registering here as well requests SvelteKit's default
// /service-worker.js even though the inject-manifest output is /prompt-sw.js.
register: false
}, },
files: { files: {
// you don't need to do this if you're using generateSW strategy in your app // you don't need to do this if you're using generateSW strategy in your app
+17 -3
View File
@@ -26,22 +26,36 @@ Feature: Account access
Scenario: Sign out Scenario: Sign out
Given I am signed in Given I am signed in
And my offline copy is synchronized
When I sign out When I sign out
Then I return to the public home page Then I return to the public home page
And my offline copy is removed
Scenario: Keep the session when sign out fails
Given I am signed in
And my offline copy is synchronized
When the sign-out request fails
Then I remain signed in with an error
And my offline copy remains
Scenario: Request a password reset Scenario: Request a password reset
Given I have a confirmed account Given I have a confirmed account
When I request a password reset When I request a password reset
Then a password reset email is captured locally Then a password reset email is captured locally
Scenario: Reject a normal session on the recovery page
Given I am signed in
When I visit the password recovery page directly
Then the replacement password form is unavailable
@product-review @product-review
Scenario: Reject mismatched replacement passwords Scenario: Reject mismatched replacement passwords
Given I am signed in on the password reset page Given I follow a valid password reset link
When I enter two different replacement passwords When I enter two different replacement passwords
Then I am told that the passwords do not match Then I am told that the passwords do not match
Scenario: Complete a password reset Scenario: Complete a password reset
Given I am signed in on the password reset page Given I follow a valid password reset link
When I enter a valid replacement password When I enter a valid replacement password
Then I am told that my password was updated Then I am told that my password was updated
And only the replacement password signs me in
-1
View File
@@ -40,4 +40,3 @@ Feature: Backup and export
When I update collection progress When I update collection progress
Then the catch remains marked caught Then the catch remains marked caught
And the provider failure is shown in backup settings And the provider failure is shown in backup settings
@@ -29,4 +29,3 @@ Feature: Pokédex composition
Given I have a Form Dex named "Black Forms" scoped to game "Black" and dex "Unova" Given I have a Form Dex named "Black Forms" scoped to game "Black" and dex "Unova"
When I inspect its entries with forms When I inspect its entries with forms
Then Rotom includes its named default form without duplicate forms Then Rotom includes its named default form without duplicate forms
@@ -54,4 +54,3 @@ Feature: Pokédex lifecycle
Given another trainer has a Pokédex Given another trainer has a Pokédex
When I request the other trainer's Pokédex When I request the other trainer's Pokédex
Then the Pokédex is not disclosed Then the Pokédex is not disclosed
@@ -35,4 +35,3 @@ Feature: Progress tracking
When I select the "Compact" box layout When I select the "Compact" box layout
And I reload the Pokédex And I reload the Pokédex
Then the "Compact" box layout remains selected Then the "Compact" box layout remains selected
+12 -4
View File
@@ -7,16 +7,24 @@ Feature: Offline-friendly application
When I open the built application When I open the built application
Then a service worker controls the page Then a service worker controls the page
And the application shell is precached And the application shell is precached
And no legacy service worker is requested
Scenario: Reload the home page while offline Scenario: Reload the home page while offline
Given I have opened the built application online Given I have opened the built application online
When I go offline and reload the home page When I go offline and reload the home page
Then the application remains available Then the read-only offline viewer is available
Scenario: Navigate to another route while offline Scenario: Reload a nested route while offline
Given I have opened the built application online Given I have opened the built application online
When I go offline and navigate to the sign-in page When I go offline and reload the sign-in page
Then the sign-in form is available offline Then the read-only offline viewer is available
Scenario: Read a synchronized collection offline
Given I am signed in
And I have a Living Dex named "Offline Collection"
And my offline copy is synchronized
When I go offline and reload the current Pokédex
Then the offline copy contains "Offline Collection"
Scenario: Restore network access Scenario: Restore network access
Given I have opened the built application online Given I have opened the built application online
+3 -1
View File
@@ -11,6 +11,7 @@ export type ScenarioState = {
lastResponseStatus: number | null; lastResponseStatus: number | null;
lastMessage: string | null; lastMessage: string | null;
caughtEntryLabel: string | null; caughtEntryLabel: string | null;
legacyServiceWorkerRequested: boolean;
}; };
type Fixtures = { state: ScenarioState; providerMock: void }; type Fixtures = { state: ScenarioState; providerMock: void };
@@ -51,7 +52,8 @@ export const test = base.extend<Fixtures>({
entries: [], entries: [],
lastResponseStatus: null, lastResponseStatus: null,
lastMessage: null, lastMessage: null,
caughtEntryLabel: null caughtEntryLabel: null,
legacyServiceWorkerRequested: false
}); });
} }
}); });
+66 -12
View File
@@ -14,6 +14,28 @@ async function mailCountFor(email: string, subject: string): Promise<number> {
return body.total ?? body.messages?.length ?? 0; return body.total ?? body.messages?.length ?? 0;
} }
async function recoveryLinkFromMail(email: string): Promise<string> {
for (let attempt = 0; attempt < 50; attempt++) {
const search = await fetch(
`${MAILPIT_URL}/api/v1/search?query=${encodeURIComponent(`to:${email} subject:Reset`)}`
);
if (search.ok) {
const result = (await search.json()) as { messages?: Array<{ ID?: string; Id?: string }> };
const id = result.messages?.[0]?.ID ?? result.messages?.[0]?.Id;
if (id) {
const response = await fetch(`${MAILPIT_URL}/api/v1/message/${id}`);
if (response.ok) {
const message = JSON.stringify(await response.json());
const match = message.match(/https?:\/\/[^"'<>\s]+\/auth\/v1\/verify[^"'<>\s]+/);
if (match) return match[0].replaceAll('&amp;', '&').replaceAll('\\u0026', '&');
}
}
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
throw new Error('No password recovery link arrived in MailPit');
}
Given('I am a new visitor', async ({ page }) => { Given('I am a new visitor', async ({ page }) => {
await page.goto('/'); await page.goto('/');
}); });
@@ -28,19 +50,16 @@ Given('I am signed in', async ({ page, state }) => {
await expect(page).toHaveURL(/\/my-pokedexes$/); await expect(page).toHaveURL(/\/my-pokedexes$/);
}); });
/** Given('I follow a valid password reset link', async ({ page, state }) => {
* Deliberately an ordinary signed-in session, not a recovery one: following a real recovery
* action link currently bounces to /signin, because the browser client in src/routes/+layout.ts
* has no cookie `set`/`remove` method and so cannot persist the session it parses out of the
* URL. Until that is fixed, these scenarios cover the form, not the emailed-link flow - hence
* the step name. `createRecoveryLink` in ../support/app.ts is ready for when it is.
*/
Given('I am signed in on the password reset page', async ({ page, state }) => {
await createConfirmedUser(state); await createConfirmedUser(state);
await signIn(page, state); await page.goto('/forgot-password');
await expect(page).toHaveURL(/\/my-pokedexes$/); await page.getByLabel('Email').fill(state.email);
await page.goto('/reset-password'); await page.getByRole('button', { name: 'Send Reset Link' }).click();
await expect(page.getByLabel('New Password')).toBeVisible(); await expect(page.getByText('Check your email for the password reset link')).toBeVisible();
const actionLink = await recoveryLinkFromMail(state.email);
await page.goto(actionLink);
await expect(page).toHaveURL(/\/reset-password/);
await expect(page.getByLabel('New Password')).toBeEnabled();
}); });
When('I register with valid account details', async ({ page, state }) => { When('I register with valid account details', async ({ page, state }) => {
@@ -66,12 +85,28 @@ When('I sign out', async ({ page }) => {
await page.getByRole('button', { name: 'Sign Out', exact: true }).click(); await page.getByRole('button', { name: 'Sign Out', exact: true }).click();
}); });
When('the sign-out request fails', async ({ page }) => {
await page.route('**/auth/v1/logout*', (route) =>
route.fulfill({
status: 503,
contentType: 'application/json',
body: '{"message":"unavailable"}'
})
);
await page.getByRole('button', { name: 'usericon' }).click();
await page.getByRole('button', { name: 'Sign Out', exact: true }).click();
});
When('I request a password reset', async ({ page, state }) => { When('I request a password reset', async ({ page, state }) => {
await page.goto('/forgot-password'); await page.goto('/forgot-password');
await page.getByLabel('Email').fill(state.email); await page.getByLabel('Email').fill(state.email);
await page.getByRole('button', { name: 'Send Reset Link' }).click(); await page.getByRole('button', { name: 'Send Reset Link' }).click();
}); });
When('I visit the password recovery page directly', async ({ page }) => {
await page.goto('/reset-password');
});
When('I enter two different replacement passwords', async ({ page, state }) => { When('I enter two different replacement passwords', async ({ page, state }) => {
await page.getByLabel('New Password').fill(state.replacementPassword); await page.getByLabel('New Password').fill(state.replacementPassword);
await page.getByLabel('Confirm Password').fill(`${state.replacementPassword}-different`); await page.getByLabel('Confirm Password').fill(`${state.replacementPassword}-different`);
@@ -105,11 +140,21 @@ Then('I return to the public home page', async ({ page }) => {
await expect(page).toHaveURL(/\/$/); await expect(page).toHaveURL(/\/$/);
}); });
Then('I remain signed in with an error', async ({ page }) => {
await expect(page).toHaveURL(/\/my-pokedexes$/);
await expect(page.locator('.alert-error.rounded-none')).toContainText('Sign out failed');
});
Then('a password reset email is captured locally', async ({ page, state }) => { Then('a password reset email is captured locally', async ({ page, state }) => {
await expect(page.getByText('Check your email for the password reset link')).toBeVisible(); await expect(page.getByText('Check your email for the password reset link')).toBeVisible();
await expect.poll(() => mailCountFor(state.email, 'Reset')).toBeGreaterThan(0); await expect.poll(() => mailCountFor(state.email, 'Reset')).toBeGreaterThan(0);
}); });
Then('the replacement password form is unavailable', async ({ page }) => {
await expect(page.getByLabel('New Password')).toBeDisabled();
await expect(page.getByText(/invalid or expired/i)).toBeVisible();
});
Then('I am told that the passwords do not match', async ({ page }) => { Then('I am told that the passwords do not match', async ({ page }) => {
await expect(page.getByText('Passwords do not match')).toBeVisible(); await expect(page.getByText('Passwords do not match')).toBeVisible();
}); });
@@ -117,3 +162,12 @@ Then('I am told that the passwords do not match', async ({ page }) => {
Then('I am told that my password was updated', async ({ page }) => { Then('I am told that my password was updated', async ({ page }) => {
await expect(page.getByText(/Password updated successfully/)).toBeVisible(); await expect(page.getByText(/Password updated successfully/)).toBeVisible();
}); });
Then('only the replacement password signs me in', async ({ page, state }) => {
await expect(page).toHaveURL(/\/signin$/, { timeout: 10_000 });
await signIn(page, state, state.password);
await expect(page.locator('.alert-error')).toBeVisible();
await page.getByLabel('Password').fill(state.replacementPassword);
await page.getByRole('button', { name: 'Sign In' }).click();
await expect(page).toHaveURL(/\/my-pokedexes$/);
});
+91 -11
View File
@@ -28,12 +28,22 @@ async function cacheContents(page: Page) {
}); });
} }
When('I open the built application', async ({ page }) => { function recordLegacyWorkerRequest(page: Page, state: { legacyServiceWorkerRequested: boolean }) {
page.on('request', (request) => {
if (new URL(request.url()).pathname === '/service-worker.js') {
state.legacyServiceWorkerRequested = true;
}
});
}
When('I open the built application', async ({ page, state }) => {
recordLegacyWorkerRequest(page, state);
await page.goto('/'); await page.goto('/');
await waitForServiceWorker(page); await waitForServiceWorker(page);
}); });
Given('I have opened the built application online', async ({ page }) => { Given('I have opened the built application online', async ({ page, state }) => {
recordLegacyWorkerRequest(page, state);
await page.context().setOffline(false); await page.context().setOffline(false);
await page.goto('/'); await page.goto('/');
await waitForServiceWorker(page); await waitForServiceWorker(page);
@@ -45,11 +55,45 @@ When('I go offline and reload the home page', async ({ page }) => {
await page.reload({ waitUntil: 'domcontentloaded' }); await page.reload({ waitUntil: 'domcontentloaded' });
}); });
When('I go offline and navigate to the sign-in page', async ({ page }) => { When('I go offline and reload the sign-in page', async ({ page }) => {
await page.goto('/signin');
await page.context().setOffline(true); await page.context().setOffline(true);
// Client-side navigation, which only works if the route's chunks were precached. await page.reload({ waitUntil: 'domcontentloaded' });
await page.getByRole('link', { name: 'Sign In' }).click(); });
await expect(page).toHaveURL(/\/signin$/);
Given('my offline copy is synchronized', async ({ page, state }) => {
await waitForServiceWorker(page);
await expect
.poll(() =>
page.evaluate(async (userId) => {
const meta = await (
await caches.open('livingdex-offline-meta-v1')
).match('/__offline/current');
if (!meta) return false;
const value = await meta.json();
return value.userId === userId;
}, state.userId)
)
.toBe(true);
const serializedSnapshot = await page.evaluate(async () => {
const metaResponse = await (
await caches.open('livingdex-offline-meta-v1')
).match('/__offline/current');
if (!metaResponse) return '';
const meta = await metaResponse.json();
const snapshotResponse = await (
await caches.open(meta.dataCache)
).match(`/__offline/snapshot/${encodeURIComponent(meta.userId)}`);
return snapshotResponse ? await snapshotResponse.text() : '';
});
expect(serializedSnapshot).not.toMatch(/access_token|refresh_token/i);
});
When('I go offline and reload the current Pokédex', async ({ page, state }) => {
await page.context().setOffline(true);
await page.goto(`/pokedex/${state.pokedexId}/offline`, {
waitUntil: 'domcontentloaded'
});
}); });
When('I go offline and then return online', async ({ page }) => { When('I go offline and then return online', async ({ page }) => {
@@ -61,6 +105,15 @@ When('I go offline and then return online', async ({ page }) => {
Then('a service worker controls the page', async ({ page }) => { Then('a service worker controls the page', async ({ page }) => {
expect(await waitForServiceWorker(page)).toMatch(/\/(?:sw|prompt-sw)\.js$/); expect(await waitForServiceWorker(page)).toMatch(/\/(?:sw|prompt-sw)\.js$/);
expect(
await page.evaluate(() =>
navigator.serviceWorker.getRegistrations().then((items) => items.length)
)
).toBe(1);
});
Then('no legacy service worker is requested', async ({ state }) => {
expect(state.legacyServiceWorkerRequested).toBe(false);
}); });
/** /**
@@ -77,7 +130,11 @@ Then('the application shell is precached', async ({ page }) => {
const origin = new URL(page.url()).origin; const origin = new URL(page.url()).origin;
const urls = contents[names[0]].map((url) => url.slice(`${origin}/`.length)); const urls = contents[names[0]].map((url) => url.slice(`${origin}/`.length));
expect(urls, 'app shell is not precached').toContain(''); expect(urls, 'personalized SSR root must not be precached').not.toContain('');
expect(
urls.some((url) => /^offline(?:\.html)?(?:\?__WB_REVISION__=|$)/.test(url)),
'offline viewer is not precached'
).toBe(true);
expect( expect(
urls.some((url) => url.startsWith('manifest.webmanifest?__WB_REVISION__=')), urls.some((url) => url.startsWith('manifest.webmanifest?__WB_REVISION__=')),
'revisioned manifest.webmanifest is not precached' 'revisioned manifest.webmanifest is not precached'
@@ -100,8 +157,31 @@ Then('the application remains available', async ({ page }) => {
await expect(page.getByRole('heading', { name: /Start Your Pokédex Journey/ })).toBeVisible(); await expect(page.getByRole('heading', { name: /Start Your Pokédex Journey/ })).toBeVisible();
}); });
Then('the sign-in form is available offline', async ({ page }) => { Then('the read-only offline viewer is available', async ({ page }) => {
await expect(page.getByLabel('Email')).toBeVisible(); await expect(page.getByText(/offline.*read-only/i)).toBeVisible();
await expect(page.getByLabel('Password')).toBeVisible(); });
await expect(page.getByRole('button', { name: 'Sign In' })).toBeVisible();
Then('the offline copy contains {string}', async ({ page }, name: string) => {
await expect(page.getByRole('heading', { name })).toBeVisible();
await expect(page.getByText(/read-only copy/i)).toBeVisible();
await expect(page.locator('button, input, textarea, select')).toHaveCount(0);
});
Then('my offline copy is removed', async ({ page }) => {
await expect
.poll(() =>
page.evaluate(async () => {
const names = await caches.keys();
return names.some((name) => name.startsWith('livingdex-offline-'));
})
)
.toBe(false);
});
Then('my offline copy remains', async ({ page, state }) => {
const owner = await page.evaluate(async () => {
const meta = await (await caches.open('livingdex-offline-meta-v1')).match('/__offline/current');
return meta ? (await meta.json()).userId : null;
});
expect(owner).toBe(state.userId);
}); });
-28
View File
@@ -43,34 +43,6 @@ export async function deleteAllPokedexes(state: ScenarioState): Promise<void> {
if (!response.ok) throw new Error(`Unable to clear Pokédexes: ${await response.text()}`); if (!response.ok) throw new Error(`Unable to clear Pokédexes: ${await response.text()}`);
} }
/**
* Asks Supabase for a real recovery action link - the same token-bearing URL the emailed link
* carries - so the reset scenarios exercise token verification rather than an ordinary session.
* `redirectTo` must be listed in `auth.additional_redirect_urls` in supabase/config.toml.
*/
export async function createRecoveryLink(
state: ScenarioState,
redirectTo: string
): Promise<string> {
const key = requireServiceRoleKey();
const response = await fetch(`${SUPABASE_URL}/auth/v1/admin/generate_link`, {
method: 'POST',
headers: {
apikey: key,
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ type: 'recovery', email: state.email, redirect_to: redirectTo })
});
if (!response.ok)
throw new Error(
`Unable to generate a recovery link: ${response.status} ${await response.text()}`
);
const body = (await response.json()) as { action_link?: string };
if (!body.action_link) throw new Error('Supabase returned no recovery action link');
return body.action_link;
}
export async function signIn(page: Page, state: ScenarioState, password = state.password) { export async function signIn(page: Page, state: ScenarioState, password = state.password) {
await page.goto('/signin'); await page.goto('/signin');
await page.getByLabel('Email').fill(state.email); await page.getByLabel('Email').fill(state.email);
+7 -4
View File
@@ -29,13 +29,16 @@ describe(`test-build: ${nodeAdapter ? 'node' : 'static'} adapter`, () => {
match && match.length === 1, match && match.length === 1,
'missing manifest.webmanifest in sw precache manifest' 'missing manifest.webmanifest in sw precache manifest'
).toBeTruthy(); ).toBeTruthy();
// The generateSW manifest is emitted as JSON ("url": "/"), while prompt-sw.ts's own match = swContent.match(/"?url"?:\s*"\/?offline(?:\.html)?"/);
// `precache([{ url: '/' }])` survives minification as an unquoted key (url:"/").
match = swContent.match(/"?url"?:\s*"(?:\/|index\.html)"/);
expect( expect(
match && match.length === 1, match && match.length === 1,
'missing offline entry point in sw precache manifest' 'missing credential-free offline entry point in sw precache manifest'
).toBeTruthy(); ).toBeTruthy();
const outputRoot = `./build/${nodeAdapter ? 'client/' : ''}`;
expect(existsSync(`${outputRoot}offline.html`)).toBe(true);
expect(existsSync(`${outputRoot}offline-worker.js`)).toBe(true);
expect(existsSync(`${outputRoot}service-worker.js`)).toBe(false);
expect(swContent).not.toMatch(/"?url"?:\s*"\/"/);
if (nodeAdapter) { if (nodeAdapter) {
match = swContent.match(/"url":\s*"server\//); match = swContent.match(/"url":\s*"server\//);
expect(match === null, 'found server/ entries in sw precache manifest').toBeTruthy(); expect(match === null, 'found server/ entries in sw precache manifest').toBeTruthy();
@@ -1,17 +1,24 @@
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
import { beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const url = process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321'; const url = process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321';
const anonKey = process.env.TEST_SUPABASE_ANON_KEY; const anonKey = process.env.TEST_SUPABASE_ANON_KEY;
const serviceKey = process.env.E2E_SERVICE_ROLE_KEY ?? process.env.SUPABASE_SERVICE_ROLE_KEY; const serviceKey = process.env.E2E_SERVICE_ROLE_KEY ?? process.env.SUPABASE_SERVICE_ROLE_KEY;
describe('database integrity and ownership', () => { describe('database integrity and ownership', () => {
const createdUserIds: string[] = [];
beforeAll(() => { beforeAll(() => {
if (!anonKey || !serviceKey) { if (!anonKey || !serviceKey) {
throw new Error('Integration tests require TEST_SUPABASE_ANON_KEY and E2E_SERVICE_ROLE_KEY'); throw new Error('Integration tests require TEST_SUPABASE_ANON_KEY and E2E_SERVICE_ROLE_KEY');
} }
}); });
afterAll(async () => {
if (!serviceKey) return;
const admin = createClient(url, serviceKey);
await Promise.all(createdUserIds.map((id) => admin.auth.admin.deleteUser(id)));
});
it('enforces catch-record uniqueness and cascades records when a Pokédex is deleted', async () => { it('enforces catch-record uniqueness and cascades records when a Pokédex is deleted', async () => {
const admin = createClient(url, serviceKey!); const admin = createClient(url, serviceKey!);
const email = `integration-cascade-${Date.now()}@example.test`; const email = `integration-cascade-${Date.now()}@example.test`;
@@ -22,6 +29,7 @@ describe('database integrity and ownership', () => {
}); });
expect(userError).toBeNull(); expect(userError).toBeNull();
const userId = created.user!.id; const userId = created.user!.id;
createdUserIds.push(userId);
const { data: dex, error: dexError } = await admin const { data: dex, error: dexError } = await admin
.from('pokedexes') .from('pokedexes')
.insert({ userId, name: 'Cascade', isLivingDex: true }) .insert({ userId, name: 'Cascade', isLivingDex: true })
@@ -64,6 +72,7 @@ describe('database integrity and ownership', () => {
}); });
expect(first.error).toBeNull(); expect(first.error).toBeNull();
expect(second.error).toBeNull(); expect(second.error).toBeNull();
createdUserIds.push(first.data.user!.id, second.data.user!.id);
const { data: dex } = await admin const { data: dex } = await admin
.from('pokedexes') .from('pokedexes')
.insert({ userId: first.data.user!.id, name: 'Owner only', isLivingDex: true }) .insert({ userId: first.data.user!.id, name: 'Owner only', isLivingDex: true })
@@ -87,6 +96,7 @@ describe('database integrity and ownership', () => {
password: 'Integration123!', password: 'Integration123!',
email_confirm: true email_confirm: true
}); });
createdUserIds.push(created.data.user!.id);
const { data: dex } = await admin const { data: dex } = await admin
.from('pokedexes') .from('pokedexes')
.insert({ userId: created.data.user!.id, name: 'Unique mapping', isLivingDex: true }) .insert({ userId: created.data.user!.id, name: 'Unique mapping', isLivingDex: true })
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { resolveSpriteUrl } from '$lib/utils/spriteUrl';
describe('resolveSpriteUrl', () => {
it('uses a supplied sprite key and local shiny path', () => {
expect(
resolveSpriteUrl({ pokedexNumber: 25, form: '', spriteKey: '25-partner-cap' }, true, true)
).toBe('/sprites-small/home/shiny/25-partner-cap.webp');
});
it('places female forms in the female folder', () => {
expect(
resolveSpriteUrl({ pokedexNumber: 592, form: 'female', spriteKey: '592' }, false, true)
).toBe('/sprites-small/home/female/592.webp');
});
it('derives the established fallback key for regional forms', () => {
expect(resolveSpriteUrl({ pokedexNumber: 83, form: 'Galarian' }, false, false)).toBe(
'https://raw.githubusercontent.com/jcreek/LivingDexTracker/master/static/sprites-small/home/83-galar.webp'
);
});
it('uses the Pokédex number for the default male form', () => {
expect(resolveSpriteUrl({ pokedexNumber: 25, form: 'Male' }, false, true)).toBe(
'/sprites-small/home/25.webp'
);
});
it('normalizes decorated form names and an all-zero number', () => {
expect(
resolveSpriteUrl({ pokedexNumber: 0, form: 'Form 2 [event] (legacy)' }, false, true)
).toBe('/sprites-small/home/0-form-two.webp');
});
});
+4 -22
View File
@@ -45,32 +45,14 @@ export default defineConfig({
background_color: '#f0f0f0' background_color: '#f0f0f0'
}, },
injectManifest: { injectManifest: {
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2,webmanifest}'], globPatterns: ['client/**/*.{html,js,css,ico,png,svg,webp,woff,woff2,webmanifest}'],
globIgnores: ['**/sprites/**', '**/sprites-small/**'] globIgnores: ['**/sprites/**', '**/sprites-small/**']
}, },
workbox: { workbox: {
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2,webmanifest}'], globPatterns: ['client/**/*.{html,js,css,ico,png,svg,webp,woff,woff2,webmanifest}'],
globIgnores: ['**/sprites/**', '**/sprites-small/**'], globIgnores: ['**/sprites/**', '**/sprites-small/**'],
// No route is prerendered, so globbing finds no HTML document and a generateSW // Shared message/fetch handling keeps generateSW and injectManifest behavior equal.
// build would precache nothing navigable - i.e. no offline support at all. importScripts: ['/offline-worker.js']
// prompt-sw.ts does the equivalent with `precache([{ url: '/' }])`.
additionalManifestEntries: [{ url: '/', revision: null }],
navigateFallback: '/',
runtimeCaching: [
{
urlPattern: ({ request }) => request.destination === 'image',
handler: 'CacheFirst',
options: {
cacheName: 'image-cache',
cacheableResponse: { statuses: [0, 200] },
expiration: {
maxEntries: 3000,
maxAgeSeconds: 60 * 60 * 24 * 30,
purgeOnQuotaError: true
}
}
}
]
}, },
devOptions: { devOptions: {
enabled: false, enabled: false,
+4 -4
View File
@@ -22,10 +22,10 @@ export default defineConfig({
exclude: ['src/lib/models/**', 'src/lib/stores/**', 'src/lib/actions/**'], exclude: ['src/lib/models/**', 'src/lib/stores/**', 'src/lib/actions/**'],
// Set to the measured baseline. Ratchet these up as coverage grows; never down. // Set to the measured baseline. Ratchet these up as coverage grows; never down.
thresholds: { thresholds: {
statements: 33, statements: 34.58,
functions: 73, functions: 73.68,
lines: 33, lines: 34.58,
branches: 79 branches: 79.79
} }
} }
} }