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 }}
cancel-in-progress: true
permissions:
contents: read
# `$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
# local-stack defaults already published in .env.local.example - never real credentials.
@@ -20,16 +23,28 @@ env:
jobs:
quality:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- 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 lint
- 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
if: always()
with:
@@ -39,6 +54,7 @@ jobs:
integration:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -54,6 +70,7 @@ jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -65,6 +82,7 @@ jobs:
bdd:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
+2
View File
@@ -7,6 +7,8 @@ yarn.lock
# changes under tens of thousands of lines of churn.
src/lib/helpers/pokeapi-pokemon.json
static/sprites/pokemon.json
src/lib/helpers/pokedex.json
src/lib/helpers/sprites.json
# Machine-local editor and tool settings.
**/*.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
failure under `test-results`.
Known gap: the password-reset scenarios use an ordinary signed-in session rather than a recovery link,
because following a real recovery link currently bounces to `/signin` - the browser client in
`src/routes/+layout.ts` has no cookie `set`/`remove` method, so it cannot persist the session it parses
out of the URL. `createRecoveryLink` in `tests/bdd/support/app.ts` is ready for when that is fixed.
Password-reset scenarios follow recovery links generated by the local Supabase stack and verify both
the rejected old password and accepted replacement password. The application waits for Supabase to
confirm the recovery session before enabling the replacement form.
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,
update that expectation together with Pokémon data, the corresponding game/dex files, database seed,
@@ -59,7 +59,7 @@ dexNumber,pokemon,form,notes
58,Pyroar,,
59,Psyduck,,
60,Golduck,,
61,Farfetchd,,
61,Farfetchd,,
62,Riolu,,
63,Lucario,,
64,Ralts,,
1 dexNumber pokemon form notes
59 58 Pyroar
60 59 Psyduck
61 60 Golduck
62 61 Farfetch’d
63 62 Riolu
64 63 Lucario
65 64 Ralts
+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}`);
}
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 confusing assertion failure. Check it here instead.
const probe = await fetch(`${apiUrl}/rest/v1/pokedex_entries?select=id&limit=1`, {
@@ -79,12 +107,13 @@ const child = spawnSync(command, args, {
shell: useShell,
env: {
...process.env,
PUBLIC_SUPABASE_URL: process.env.PUBLIC_SUPABASE_URL ?? apiUrl,
PUBLIC_SUPABASE_ANON_KEY: process.env.PUBLIC_SUPABASE_ANON_KEY ?? anonKey,
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY ?? serviceRoleKey,
TEST_SUPABASE_URL: process.env.TEST_SUPABASE_URL ?? apiUrl,
TEST_SUPABASE_ANON_KEY: process.env.TEST_SUPABASE_ANON_KEY ?? anonKey,
E2E_SERVICE_ROLE_KEY: process.env.E2E_SERVICE_ROLE_KEY ?? serviceRoleKey
// Never allow an exported production value to redirect a local integration run.
PUBLIC_SUPABASE_URL: apiUrl,
PUBLIC_SUPABASE_ANON_KEY: anonKey,
SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
TEST_SUPABASE_URL: apiUrl,
TEST_SUPABASE_ANON_KEY: anonKey,
E2E_SERVICE_ROLE_KEY: serviceRoleKey
}
});
+6 -47
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public';
import { inView } from '$lib/actions/inView';
import { resolveSpriteUrl } from '$lib/utils/spriteUrl';
export let pokemonName: string;
export let pokedexNumber: string | number;
@@ -12,46 +13,7 @@
let imagePath = null as string | null;
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()) {
console.warn('Missing sprite key for pokemon entry', {
pokemonName,
@@ -60,14 +22,11 @@
});
}
let rootFolder = rootFolderBase;
if (shiny) {
rootFolder += '/shiny';
}
if (isFemaleForm(form)) {
rootFolder += '/female';
}
imagePath = `${rootFolder}/${resolvedSpriteKey}.webp`;
imagePath = resolveSpriteUrl(
{ pokedexNumber: Number(pokedexNumber), form, spriteKey },
!!shiny,
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'
);
}
$: if (loadingStrategy !== 'inView') {
+29 -9
View File
@@ -2,7 +2,10 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import { createEventDispatcher } from 'svelte';
import { goto } from '$app/navigation';
import { clearOfflineData } from '$lib/stores/offlineSync';
const dispatch = createEventDispatcher();
let errorMessage = '';
let isSigningOut = false;
function emitSignedOutEvent() {
dispatch('signedOut', {});
@@ -12,15 +15,32 @@
export let supabase: SupabaseClient;
async function signOut() {
// `.then(() => {...})` resolved to undefined, so destructuring `error` off it threw a
// TypeError on every sign-out - after the event had already been emitted.
const { error } = await supabase.auth.signOut();
if (error) console.error('Sign out failed', error);
emitSignedOutEvent();
// Signing out used to leave the user sitting on the protected page they were on, still
// showing its content. Send them to the public home page and re-run the server loads.
await goto('/', { invalidateAll: true });
errorMessage = '';
isSigningOut = true;
try {
const { error } = await supabase.auth.signOut();
if (error) {
errorMessage = `Sign out failed: ${error.message || 'Please try again.'}`;
dispatch('signOutFailed', { message: errorMessage });
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>
<button on:click={signOut}>Sign Out</button>
<button on:click={signOut} disabled={isSigningOut}>
{isSigningOut ? 'Signing Out…' : 'Sign Out'}
</button>
+35490 -29191
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 no-default-lib="true"/>
/// <reference lib="esnext" />
import {
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';
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching';
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) => {
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
// Handle the case where __WB_MANIFEST might be undefined in development
const manifest = self.__WB_MANIFEST || [];
@@ -29,26 +21,4 @@ if (Array.isArray(manifest)) {
precacheAndRoute(manifest);
}
// clean old assets
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 SignOut from '$lib/components/SignOut.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 { pwaAssetsHead } from 'virtual:pwa-assets/head';
@@ -24,9 +31,35 @@
onDestroy(unsubscribe);
let authSubscription: { unsubscribe: () => void } | null = null;
let stopOfflineSync: (() => void) | null = null;
let isOnline = true;
let signOutError = '';
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
const {
@@ -34,35 +67,26 @@
} = supabase.auth.onAuthStateChange((event, session) => {
if (session) {
localUser = session.user;
void claimOfflineData(session.user.id)
.then(requestOfflineSync)
.catch((error) => console.error('Unable to claim offline data', error));
} else {
localUser = null;
if (event === 'SIGNED_OUT') void clearOfflineData();
}
user.set(localUser);
});
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 () => {
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>
<a href="/backup-settings"> Backup Settings </a>
</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}
<li><SignIn {supabase} on:signedIn={getUser} /></li>
{/if}
@@ -174,6 +207,33 @@
</div>
</div>
</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">
<slot />
@@ -223,3 +283,13 @@
<ReloadPrompt />
{/await}
</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 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 }) => {
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, {
global: {
@@ -17,6 +23,14 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => {
const cookie = parse(document.cookie);
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 }
} = 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 PokedexCard from '$lib/components/pokedex/PokedexCard.svelte';
import PokedexForm from '$lib/components/pokedex/PokedexForm.svelte';
import { requestOfflineSync } from '$lib/stores/offlineSync';
export let data;
let { pokedexes } = data;
@@ -93,6 +94,7 @@
closeModal();
await loadPokedexes();
requestOfflineSync();
return;
} else {
// Create new pokédex
@@ -114,6 +116,7 @@
// Refresh local state BEFORE deciding whether this is the user's first pokédex.
closeModal();
await loadPokedexes();
requestOfflineSync();
// If the user's total pokédex count is now 1, this newly created one is their first.
if (pokedexes.length === 1) {
@@ -149,6 +152,7 @@
}
await loadPokedexes();
requestOfflineSync();
} catch (error) {
console.error('Error deleting pokédex:', error);
alert('An error occurred');
+11
View File
@@ -16,6 +16,7 @@
import PokedexEntryCatchRecord from '$lib/components/pokedex/PokedexEntryCatchRecord.svelte';
import type { Pokedex } from '$lib/models/Pokedex';
import type { PageData } from './$types';
import { requestOfflineSync } from '$lib/stores/offlineSync';
export let data: PageData;
@@ -59,6 +60,7 @@
lastFlushAttemptAt: null,
lastSuccessfulFlushAt: null
};
let lastOfflineSyncFlush: number | null = null;
let exportAfterFlush = false;
let exportInFlight = false;
let exportTimer: ReturnType<typeof setTimeout> | null = null;
@@ -179,6 +181,15 @@
catchWriteQueueUnsubscribe = catchWriteQueue.getStatus.subscribe((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 (exportTimer) {
clearTimeout(exportTimer);
+70 -32
View File
@@ -1,42 +1,74 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { user } from '$lib/stores/user.js';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import type { User } from '@supabase/auth-js';
export let data;
let { supabase } = data;
$: ({ supabase } = data);
let localUser: User | null = null;
const unsubscribe = user.subscribe((value) => {
localUser = value;
});
onDestroy(unsubscribe);
let showAnimation = false;
let hasCheckedAuth = false;
let authReady = false;
let authChecking = true;
let authError = '';
const recoveryMarkerKey = 'livingdex:password-recovery';
onMount(() => {
// Start animation
setTimeout(() => {
const animationTimer = setTimeout(() => {
showAnimation = true;
}, 100);
// Check authentication after a short delay to allow Supabase to process the token
setTimeout(() => {
hasCheckedAuth = true;
if (!localUser) {
goto('/signin');
let settled = false;
const settle = (hasSession: boolean) => {
if (settled && !hasSession) return;
settled = hasSession;
authReady = hasSession;
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 confirmPassword = '';
let isLoading = false;
@@ -44,6 +76,7 @@
let successMessage = '';
async function updatePassword() {
if (!authReady) return;
isLoading = true;
errorMessage = '';
successMessage = '';
@@ -74,11 +107,11 @@
}
successMessage = 'Password updated successfully! Redirecting to sign in...';
// Redirect to sign in after a short delay
setTimeout(() => {
goto('/signin');
}, 2000);
sessionStorage.removeItem(recoveryMarkerKey);
setTimeout(async () => {
await supabase.auth.signOut();
await goto('/signin');
}, 1_000);
} catch (err) {
console.error('Update password error:', err);
errorMessage = 'An unexpected error occurred. Please try again.';
@@ -132,6 +165,11 @@
<!-- Reset Password Card -->
<div class="card bg-base-200 shadow-xl {showAnimation ? 'animate-slide-up' : ''}">
<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 -->
{#if errorMessage}
<div class="alert alert-error text-sm">
@@ -185,7 +223,7 @@
class="input input-bordered w-full pl-10"
bind:value={password}
on:keypress={handleKeyPress}
disabled={isLoading}
disabled={isLoading || !authReady}
/>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -215,7 +253,7 @@
class="input input-bordered w-full pl-10"
bind:value={confirmPassword}
on:keypress={handleKeyPress}
disabled={isLoading}
disabled={isLoading || !authReady}
/>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -237,7 +275,7 @@
<button
class="btn btn-primary w-full"
on:click={updatePassword}
disabled={isLoading || !password || !confirmPassword}
disabled={isLoading || !authReady || !password || !confirmPassword}
>
{#if isLoading}
<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.
adapter,
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: {
// 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
Given I am signed in
And my offline copy is synchronized
When I sign out
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
Given I have a confirmed account
When I request a password reset
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
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
Then I am told that the passwords do not match
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
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
Then the catch remains marked caught
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"
When I inspect its entries with 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
When I request the other trainer's Pokédex
Then the Pokédex is not disclosed
@@ -35,4 +35,3 @@ Feature: Progress tracking
When I select the "Compact" box layout
And I reload the Pokédex
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
Then a service worker controls the page
And the application shell is precached
And no legacy service worker is requested
Scenario: Reload the home page while offline
Given I have opened the built application online
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
When I go offline and navigate to the sign-in page
Then the sign-in form is available offline
When I go offline and reload the sign-in page
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
Given I have opened the built application online
+3 -1
View File
@@ -11,6 +11,7 @@ export type ScenarioState = {
lastResponseStatus: number | null;
lastMessage: string | null;
caughtEntryLabel: string | null;
legacyServiceWorkerRequested: boolean;
};
type Fixtures = { state: ScenarioState; providerMock: void };
@@ -51,7 +52,8 @@ export const test = base.extend<Fixtures>({
entries: [],
lastResponseStatus: 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;
}
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 }) => {
await page.goto('/');
});
@@ -28,19 +50,16 @@ Given('I am signed in', async ({ page, state }) => {
await expect(page).toHaveURL(/\/my-pokedexes$/);
});
/**
* 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 }) => {
Given('I follow a valid password reset link', async ({ page, state }) => {
await createConfirmedUser(state);
await signIn(page, state);
await expect(page).toHaveURL(/\/my-pokedexes$/);
await page.goto('/reset-password');
await expect(page.getByLabel('New Password')).toBeVisible();
await page.goto('/forgot-password');
await page.getByLabel('Email').fill(state.email);
await page.getByRole('button', { name: 'Send Reset Link' }).click();
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 }) => {
@@ -66,12 +85,28 @@ When('I sign out', async ({ page }) => {
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 }) => {
await page.goto('/forgot-password');
await page.getByLabel('Email').fill(state.email);
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 }) => {
await page.getByLabel('New Password').fill(state.replacementPassword);
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(/\/$/);
});
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 }) => {
await expect(page.getByText('Check your email for the password reset link')).toBeVisible();
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 }) => {
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 }) => {
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 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.goto('/');
await waitForServiceWorker(page);
@@ -45,11 +55,45 @@ When('I go offline and reload the home page', async ({ page }) => {
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);
// Client-side navigation, which only works if the route's chunks were precached.
await page.getByRole('link', { name: 'Sign In' }).click();
await expect(page).toHaveURL(/\/signin$/);
await page.reload({ waitUntil: 'domcontentloaded' });
});
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 }) => {
@@ -61,6 +105,15 @@ When('I go offline and then return online', async ({ page }) => {
Then('a service worker controls the page', async ({ page }) => {
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 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(
urls.some((url) => url.startsWith('manifest.webmanifest?__WB_REVISION__=')),
'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();
});
Then('the sign-in form is available offline', async ({ page }) => {
await expect(page.getByLabel('Email')).toBeVisible();
await expect(page.getByLabel('Password')).toBeVisible();
await expect(page.getByRole('button', { name: 'Sign In' })).toBeVisible();
Then('the read-only offline viewer is available', async ({ page }) => {
await expect(page.getByText(/offline.*read-only/i)).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()}`);
}
/**
* 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) {
await page.goto('/signin');
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,
'missing manifest.webmanifest in sw precache manifest'
).toBeTruthy();
// The generateSW manifest is emitted as JSON ("url": "/"), while prompt-sw.ts's own
// `precache([{ url: '/' }])` survives minification as an unquoted key (url:"/").
match = swContent.match(/"?url"?:\s*"(?:\/|index\.html)"/);
match = swContent.match(/"?url"?:\s*"\/?offline(?:\.html)?"/);
expect(
match && match.length === 1,
'missing offline entry point in sw precache manifest'
'missing credential-free offline entry point in sw precache manifest'
).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) {
match = swContent.match(/"url":\s*"server\//);
expect(match === null, 'found server/ entries in sw precache manifest').toBeTruthy();
@@ -1,17 +1,24 @@
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 anonKey = process.env.TEST_SUPABASE_ANON_KEY;
const serviceKey = process.env.E2E_SERVICE_ROLE_KEY ?? process.env.SUPABASE_SERVICE_ROLE_KEY;
describe('database integrity and ownership', () => {
const createdUserIds: string[] = [];
beforeAll(() => {
if (!anonKey || !serviceKey) {
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 () => {
const admin = createClient(url, serviceKey!);
const email = `integration-cascade-${Date.now()}@example.test`;
@@ -22,6 +29,7 @@ describe('database integrity and ownership', () => {
});
expect(userError).toBeNull();
const userId = created.user!.id;
createdUserIds.push(userId);
const { data: dex, error: dexError } = await admin
.from('pokedexes')
.insert({ userId, name: 'Cascade', isLivingDex: true })
@@ -64,6 +72,7 @@ describe('database integrity and ownership', () => {
});
expect(first.error).toBeNull();
expect(second.error).toBeNull();
createdUserIds.push(first.data.user!.id, second.data.user!.id);
const { data: dex } = await admin
.from('pokedexes')
.insert({ userId: first.data.user!.id, name: 'Owner only', isLivingDex: true })
@@ -87,6 +96,7 @@ describe('database integrity and ownership', () => {
password: 'Integration123!',
email_confirm: true
});
createdUserIds.push(created.data.user!.id);
const { data: dex } = await admin
.from('pokedexes')
.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'
},
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/**']
},
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/**'],
// No route is prerendered, so globbing finds no HTML document and a generateSW
// build would precache nothing navigable - i.e. no offline support at all.
// 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
}
}
}
]
// Shared message/fetch handling keeps generateSW and injectManifest behavior equal.
importScripts: ['/offline-worker.js']
},
devOptions: {
enabled: false,
+4 -4
View File
@@ -22,10 +22,10 @@ export default defineConfig({
exclude: ['src/lib/models/**', 'src/lib/stores/**', 'src/lib/actions/**'],
// Set to the measured baseline. Ratchet these up as coverage grows; never down.
thresholds: {
statements: 33,
functions: 73,
lines: 33,
branches: 79
statements: 34.58,
functions: 73.68,
lines: 34.58,
branches: 79.79
}
}
}