mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-16 18:42:03 +00:00
perf: fix slow page loads and gate performance in CI
- Ship one hashed Tailwind stylesheet instead of two (one render-blocking) - Compress responses in-app (brotli/gzip, streaming-safe) and precompress the node build so local and CI measurements match production - Validate the Supabase session once per request - Render the homepage immediately and stream public stats - Stream a Pokedex's entries with the page instead of fetching after hydration, running the rows and count queries in parallel - Shrink the avatar and offline placeholder images, fix layout shift, contrast, link names and missing meta descriptions - Add Lighthouse CI (mobile + desktop) with score and metric budgets, bundle-size budgets in the build tests, and signed-in speed scenarios
This commit is contained in:
@@ -80,6 +80,34 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npm run test:build
|
||||
|
||||
lighthouse:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
preset: [mobile, desktop]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# The homepage streams public stats from the database, so audit against a real stack.
|
||||
- run: npx supabase start
|
||||
- run: npm run test:lighthouse
|
||||
env:
|
||||
LHCI_PRESET: ${{ matrix.preset }}
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
name: lighthouse-${{ matrix.preset }}
|
||||
path: .lighthouseci/
|
||||
if-no-files-found: ignore
|
||||
- if: always()
|
||||
run: npx supabase stop
|
||||
|
||||
bdd:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
@@ -9,6 +9,7 @@ node_modules
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
/static/output.css
|
||||
.lighthouseci
|
||||
.netlify
|
||||
.features-gen
|
||||
coverage
|
||||
|
||||
@@ -40,7 +40,17 @@ The test suite is split by responsibility so a failure points to the correct lay
|
||||
- `tests/integration` checks the migrated Supabase schema, views, constraints, RLS, and repositories.
|
||||
- `tests/bdd/features` is the executable Gherkin specification for user-visible behaviour. Step
|
||||
definitions and browser fixtures live beside it under `tests/bdd`.
|
||||
- `tests/build` verifies generated service-worker and manifest artifacts after each supported build.
|
||||
- `tests/build` verifies generated service-worker and manifest artifacts after each supported build,
|
||||
and fails if the gzipped JS or CSS every page loads grows past its budget.
|
||||
- `lighthouserc.cjs` audits the public pages with Lighthouse CI (`npm run test:lighthouse`, which
|
||||
builds and serves the Node output). PRs fail if Performance, Accessibility, Best Practices or SEO
|
||||
drops below 90, or if LCP, TBT, CLS, script, stylesheet or total transfer size exceeds its budget.
|
||||
CI runs it with both the mobile and desktop profiles (`LHCI_PRESET=desktop`).
|
||||
- `tests/bdd/features/performance.feature` holds time budgets for signed-in pages Lighthouse can't
|
||||
reach: opening a Pokédex and switching between it and the Pokédex list.
|
||||
|
||||
The budgets sit just above current measurements so regressions fail the PR. If a change genuinely
|
||||
needs more, raise the budget in the same PR so the cost is reviewed.
|
||||
|
||||
Run the offline suites while developing. `test:fast` includes the coverage run, so there is no need
|
||||
to run both:
|
||||
|
||||
+3
-2
@@ -5,9 +5,10 @@ import AdapterNetlify from '@sveltejs/adapter-netlify';
|
||||
export const nodeAdapter = process.env.NODE_ADAPTER === 'true';
|
||||
|
||||
// Netlify is the deployment target; the node adapter exists so the service worker
|
||||
// build tests can check the `build/client` layout a Node server produces.
|
||||
// build tests can check the `build/client` layout a Node server produces, and so Lighthouse CI
|
||||
// can audit a production build. Netlify's CDN compresses responses, so precompress here to match.
|
||||
export const adapter = nodeAdapter
|
||||
? AdapterNode()
|
||||
? AdapterNode({ precompress: true })
|
||||
: AdapterNetlify({
|
||||
// if true, will create a Netlify Edge Function rather
|
||||
// than using standard Node-based functions
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Lighthouse CI: `npm run test:lighthouse` builds the Node adapter output, serves it and audits the
|
||||
// public pages. Any category below 90 fails the run (and so the PR).
|
||||
// Set LHCI_PRESET=desktop to audit with the desktop profile; the default is Lighthouse's mobile
|
||||
// profile (slow 4G + CPU throttling), which is the stricter of the two.
|
||||
const preset = process.env.LHCI_PRESET === 'desktop' ? 'desktop' : undefined;
|
||||
|
||||
module.exports = {
|
||||
ci: {
|
||||
collect: {
|
||||
startServerCommand: 'npm run preview-node',
|
||||
startServerReadyPattern: 'Listening on',
|
||||
url: [
|
||||
'http://localhost:4173/',
|
||||
'http://localhost:4173/signin',
|
||||
'http://localhost:4173/welcome',
|
||||
'http://localhost:4173/offline-guide',
|
||||
'http://localhost:4173/forgot-password'
|
||||
],
|
||||
// Median of three runs smooths out noise from shared CI runners.
|
||||
numberOfRuns: 3,
|
||||
settings: {
|
||||
...(preset ? { preset } : {}),
|
||||
chromeFlags: '--no-sandbox --headless=new'
|
||||
}
|
||||
},
|
||||
assert: {
|
||||
assertions: {
|
||||
'categories:performance': ['error', { minScore: 0.9 }],
|
||||
'categories:accessibility': ['error', { minScore: 0.9 }],
|
||||
'categories:best-practices': ['error', { minScore: 0.9 }],
|
||||
'categories:seo': ['error', { minScore: 0.9 }],
|
||||
// The app compresses its own responses (see src/lib/server/compression.ts and the
|
||||
// precompressed build), so nothing may be served uncompressed.
|
||||
'uses-text-compression': ['error', { minScore: 1 }],
|
||||
// Regression budgets, set a little above what every audited page measured in September
|
||||
// 2026 (mobile profile: LCP 1.4-2.6 s, TBT 0 ms, CLS 0, ~100 KB script, ~14 KB CSS and
|
||||
// ~175 KB in total over the wire). A PR that makes pages meaningfully slower or heavier
|
||||
// fails here even while the category scores stay above 90. When a change legitimately
|
||||
// needs more, raise the number in the same PR so the cost is reviewed.
|
||||
'largest-contentful-paint': ['error', { maxNumericValue: 3000 }],
|
||||
'total-blocking-time': ['error', { maxNumericValue: 200 }],
|
||||
'cumulative-layout-shift': ['error', { maxNumericValue: 0.05 }],
|
||||
'resource-summary:script:size': ['error', { maxNumericValue: 115 * 1024 }],
|
||||
'resource-summary:stylesheet:size': ['error', { maxNumericValue: 20 * 1024 }],
|
||||
'resource-summary:total:size': ['error', { maxNumericValue: 220 * 1024 }]
|
||||
}
|
||||
},
|
||||
upload: {
|
||||
target: 'filesystem',
|
||||
outputDir: '.lighthouseci/reports'
|
||||
}
|
||||
}
|
||||
};
|
||||
Generated
+3098
-54
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -21,7 +21,8 @@
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"lint-fix": "npm run lint --fix",
|
||||
"format": "prettier --write .",
|
||||
"tailwind": "npx tailwindcss -i ./static/input.css -o ./static/output.css",
|
||||
"tailwind": "npx tailwindcss -i ./static/input.css -o ./static/output.css --minify",
|
||||
"test:lighthouse": "npm run build-inject-manifest-node && lhci autorun",
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:data": "vitest run tests/data",
|
||||
"test:coverage": "vitest run tests/unit --coverage",
|
||||
@@ -46,6 +47,7 @@
|
||||
"dev:supabase": "supabase start && npm run dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@playwright/test": "1.55.1",
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/adapter-netlify": "^4.1.0",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -18,7 +18,6 @@
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
<link rel="stylesheet" href="%sveltekit.assets%/output.css" />
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
|
||||
+10
-2
@@ -1,6 +1,7 @@
|
||||
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
||||
import { createServerClient } from '@supabase/ssr';
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { compressResponse } from '$lib/server/compression';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
||||
@@ -24,7 +25,11 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
* doesn't validate the JWT, this function validates the JWT by first calling
|
||||
* `getUser` and aborts early if the JWT signature is invalid.
|
||||
*/
|
||||
event.locals.safeGetSession = async () => {
|
||||
// getUser is a network round trip to Supabase Auth. Layout and page loads (and API routes) all
|
||||
// ask for the session, so validate once per request and share the result.
|
||||
let sessionPromise: ReturnType<App.Locals['safeGetSession']> | null = null;
|
||||
event.locals.safeGetSession = () => {
|
||||
sessionPromise ??= (async () => {
|
||||
const {
|
||||
data: { user },
|
||||
error
|
||||
@@ -37,11 +42,14 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
data: { session }
|
||||
} = await event.locals.supabase.auth.getSession();
|
||||
return { session, user };
|
||||
})();
|
||||
return sessionPromise;
|
||||
};
|
||||
|
||||
return resolve(event, {
|
||||
const response = await resolve(event, {
|
||||
filterSerializedResponseHeaders(name) {
|
||||
return name === 'content-range';
|
||||
}
|
||||
});
|
||||
return compressResponse(event.request, response);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import type { ReadableStream as NodeReadableStream } from 'node:stream/web';
|
||||
import { constants, createBrotliCompress, createGzip } from 'node:zlib';
|
||||
|
||||
export type Encoding = 'br' | 'gzip';
|
||||
|
||||
// Text responses the app renders or returns from API routes. Images, fonts and other binary
|
||||
// content is already compressed, so re-compressing it only costs CPU.
|
||||
const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml|manifest\+json)|image\/svg\+xml)/i;
|
||||
|
||||
/** Picks the best encoding the client accepts, preferring brotli. Honours `q=0` refusals. */
|
||||
export function pickEncoding(acceptEncoding: string | null): Encoding | null {
|
||||
if (!acceptEncoding) return null;
|
||||
const accepted = new Map<string, number>();
|
||||
for (const part of acceptEncoding.split(',')) {
|
||||
const [name, ...params] = part.trim().toLowerCase().split(';');
|
||||
const q = params.map((p) => p.trim()).find((p) => p.startsWith('q='));
|
||||
accepted.set(name, q ? Number(q.slice(2)) : 1);
|
||||
}
|
||||
const allows = (name: Encoding) => (accepted.get(name) ?? accepted.get('*') ?? 0) > 0;
|
||||
if (allows('br')) return 'br';
|
||||
if (allows('gzip')) return 'gzip';
|
||||
return null;
|
||||
}
|
||||
|
||||
// adapter-netlify's Lambda handler serialises text responses with `response.text()`, which would
|
||||
// corrupt a compressed body. Netlify compresses function responses itself, so skip it there.
|
||||
// Everywhere else (the Node build, local preview, CI) the app compresses its own responses.
|
||||
const serialisesBodiesAsText = () => Boolean(process.env.AWS_LAMBDA_FUNCTION_NAME);
|
||||
|
||||
function shouldCompress(request: Request, response: Response): boolean {
|
||||
if (serialisesBodiesAsText()) return false;
|
||||
if (!response.body || request.method === 'HEAD') return false;
|
||||
if (response.status < 200 || response.status === 204 || response.status === 304) return false;
|
||||
if (response.headers.has('content-encoding')) return false;
|
||||
return COMPRESSIBLE.test(response.headers.get('content-type') ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses a rendered page or API response so its transfer size doesn't depend on the host.
|
||||
* Every chunk is flushed as soon as it is written, so SvelteKit's streamed load data still reaches
|
||||
* the browser progressively instead of waiting for the whole body.
|
||||
*/
|
||||
export function compressResponse(request: Request, response: Response): Response {
|
||||
if (!shouldCompress(request, response)) return response;
|
||||
const encoding = pickEncoding(request.headers.get('accept-encoding'));
|
||||
|
||||
const headers = new Headers(response.headers);
|
||||
// Caches must key on the request encoding even when this response isn't compressed.
|
||||
headers.append('vary', 'Accept-Encoding');
|
||||
if (!encoding) return new Response(response.body, { status: response.status, headers });
|
||||
|
||||
const compressor =
|
||||
encoding === 'br'
|
||||
? createBrotliCompress({
|
||||
flush: constants.BROTLI_OPERATION_FLUSH,
|
||||
// Quality 11 is for build-time precompression; 5 is fast enough per request.
|
||||
params: { [constants.BROTLI_PARAM_QUALITY]: 5 }
|
||||
})
|
||||
: createGzip({ flush: constants.Z_SYNC_FLUSH, level: 6 });
|
||||
|
||||
const source = Readable.fromWeb(response.body as unknown as NodeReadableStream);
|
||||
source.on('error', (error) => compressor.destroy(error));
|
||||
source.pipe(compressor);
|
||||
|
||||
headers.set('content-encoding', encoding);
|
||||
headers.delete('content-length');
|
||||
return new Response(Readable.toWeb(compressor) as unknown as ReadableStream, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
|
||||
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
|
||||
import type { Pokedex } from '$lib/models/Pokedex';
|
||||
|
||||
export type CombinedDataQuery = {
|
||||
page: number;
|
||||
limit: number;
|
||||
enableForms: boolean;
|
||||
region?: string;
|
||||
game?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads one page of a Pokédex's entries joined with the owner's catch records. Shared by the
|
||||
* combined-data API and the Pokédex page's server load so both return exactly the same data.
|
||||
* The caller must already have checked that `userId` owns `pokedex`.
|
||||
*/
|
||||
export async function loadCombinedDataPage(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
pokedex: Pokedex,
|
||||
{ page, limit, enableForms, region = '', game = '' }: CombinedDataQuery
|
||||
) {
|
||||
// Use the pokédex's gameScope as the default filter if no manual game filter is set.
|
||||
const effectiveGame = game || pokedex.gameScope || '';
|
||||
const dexScopes = await resolveDexScopes(supabase, pokedex);
|
||||
const repo = new CombinedDataRepository(supabase, userId, pokedex._id);
|
||||
|
||||
// The rows and the count are independent queries, so run them together.
|
||||
const [combinedData, totalCount] = await Promise.all([
|
||||
repo.findCombinedData(userId, page, limit, enableForms, region, effectiveGame, dexScopes),
|
||||
repo.countCombinedData(enableForms, region, effectiveGame, dexScopes)
|
||||
]);
|
||||
|
||||
return {
|
||||
combinedData,
|
||||
totalPages: Math.ceil(totalCount / limit),
|
||||
currentPage: page,
|
||||
totalCount
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import 'tailwindcss/tailwind.css';
|
||||
// The only app stylesheet: Vite bundles, minifies and content-hashes it so it is cached for good.
|
||||
// static/output.css is built separately for the credential-free offline.html page only.
|
||||
import '../app.css';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { user } from '$lib/stores/user.js';
|
||||
import { type User } from '@supabase/auth-js';
|
||||
@@ -180,7 +182,7 @@
|
||||
{#if localUser}
|
||||
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar">
|
||||
<div class="w-10 rounded-full">
|
||||
<img alt="usericon" src="/OIG5.jpg" />
|
||||
<img alt="Account menu" src="/avatar.webp" width="40" height="40" />
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
@@ -247,8 +249,14 @@
|
||||
|
||||
<footer class="footer items-center p-4 bg-neutral text-neutral-content bottom-0">
|
||||
<aside class="items-center grid-flow-col">
|
||||
<a href="https://github.com/jcreek/LivingDexTracker" target="_blank">
|
||||
<a
|
||||
href="https://github.com/jcreek/LivingDexTracker"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Living Dex Tracker on GitHub"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="36"
|
||||
height="36"
|
||||
fill-rule="evenodd"
|
||||
@@ -269,8 +277,13 @@
|
||||
</p>
|
||||
</aside>
|
||||
<nav class="grid-flow-col gap-4 md:place-self-center md:justify-self-end">
|
||||
<a href="https://discord.gg/SQcJkaXDye" target="_blank"
|
||||
<a
|
||||
href="https://discord.gg/SQcJkaXDye"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Living Dex Tracker Discord community"
|
||||
><svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export type PublicStats = {
|
||||
pokemonCaught: number;
|
||||
users: number;
|
||||
livingDexesCompleted: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Server-side load function for the homepage
|
||||
*
|
||||
* Fetches public statistics from the database and passes them to the page component.
|
||||
* Stats are cached for 24 hours to improve performance.
|
||||
* Signed-in users go straight to their Pokédexes. For everyone else the page renders at once and
|
||||
* the public statistics stream in afterwards, so a slow stats query never delays the first paint.
|
||||
*/
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
// Fetch stats from database
|
||||
const statsResponse = await fetch('/api/stats');
|
||||
const statsData = await statsResponse.json();
|
||||
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
||||
const { user } = await locals.safeGetSession();
|
||||
if (user) {
|
||||
throw redirect(303, '/my-pokedexes');
|
||||
}
|
||||
|
||||
return {
|
||||
stats: statsData.error ? null : statsData
|
||||
};
|
||||
const stats: Promise<PublicStats | null> = fetch('/api/stats')
|
||||
.then((response) => response.json())
|
||||
.then((statsData) => (statsData.error ? null : statsData))
|
||||
.catch(() => null);
|
||||
|
||||
return { stats };
|
||||
};
|
||||
|
||||
+31
-67
@@ -1,35 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import SignUp from '$lib/components/SignUp.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
// Signed-in visitors are redirected by the server load, so the page renders straight away.
|
||||
export let data;
|
||||
let { supabase, stats } = data;
|
||||
$: ({ supabase, stats } = data);
|
||||
|
||||
// Redirection is decided from the live session below, so the user store is not needed here.
|
||||
let isCheckingSession = true;
|
||||
|
||||
onMount(() => {
|
||||
checkSessionAndRedirect();
|
||||
});
|
||||
|
||||
async function checkSessionAndRedirect() {
|
||||
try {
|
||||
const {
|
||||
data: { session }
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (session) {
|
||||
await goto('/my-pokedexes');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking session:', error);
|
||||
} finally {
|
||||
isCheckingSession = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignedUp() {
|
||||
await goto('/welcome');
|
||||
}
|
||||
@@ -44,16 +21,6 @@
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
// Get formatted stats or fallback to 0
|
||||
let pokemonCaught = '0';
|
||||
let users = '0';
|
||||
let livingDexesCompleted = '0';
|
||||
$: {
|
||||
pokemonCaught = formatNumber(stats?.pokemonCaught ?? 0);
|
||||
users = formatNumber(stats?.users ?? 0);
|
||||
livingDexesCompleted = formatNumber(stats?.livingDexesCompleted ?? 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -72,16 +39,8 @@
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
{#if isCheckingSession}
|
||||
<!-- Loading placeholder while checking session -->
|
||||
<div class="hero bg-base-100 my-36">
|
||||
<div class="hero-content flex-col">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Hero Section -->
|
||||
<div class="hero bg-base-100 my-36">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero bg-base-100 my-36">
|
||||
<div class="hero-content flex-col lg:flex-row-reverse">
|
||||
<div class="text-center lg:text-left">
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
@@ -185,30 +144,36 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Section -->
|
||||
<div class="bg-base-200 py-16">
|
||||
<!-- Stats Section -->
|
||||
<div class="bg-base-200 py-16">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow bg-neutral text-center w-full">
|
||||
<div class="stat">
|
||||
<div class="stat-title">Pokémon caught</div>
|
||||
<div class="stat-value text-primary">{pokemonCaught}</div>
|
||||
<div class="stat-value text-primary">
|
||||
{#await stats}–{:then value}{formatNumber(value?.pokemonCaught ?? 0)}{/await}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Users</div>
|
||||
<div class="stat-value text-primary">{users}</div>
|
||||
<div class="stat-value text-primary">
|
||||
{#await stats}–{:then value}{formatNumber(value?.users ?? 0)}{/await}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Living Dexes Completed</div>
|
||||
<div class="stat-value text-primary">{livingDexesCompleted}</div>
|
||||
<div class="stat-value text-primary">
|
||||
{#await stats}–{:then value}{formatNumber(value?.livingDexesCompleted ?? 0)}{/await}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- What is a Living Dex Section -->
|
||||
<div class="py-16 bg-base-100">
|
||||
<!-- What is a Living Dex Section -->
|
||||
<div class="py-16 bg-base-100">
|
||||
<div class="container mx-auto px-4 max-w-4xl">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
@@ -221,10 +186,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features Section -->
|
||||
<div class="py-16 bg-base-200">
|
||||
<!-- Features Section -->
|
||||
<div class="py-16 bg-base-200">
|
||||
<div class="container mx-auto px-4 max-w-6xl">
|
||||
<h2 class="text-4xl font-bold mb-12 text-center">Why Choose Living Dex Tracker?</h2>
|
||||
|
||||
@@ -283,8 +248,8 @@
|
||||
<div>
|
||||
<h2 class="card-title text-xl mb-2">Free & Open Source</h2>
|
||||
<p class="text-base-content/80">
|
||||
Completely open source and free to use, enabling the community to contribute
|
||||
updates as soon as new Pokémon are released.
|
||||
Completely open source and free to use, enabling the community to contribute updates
|
||||
as soon as new Pokémon are released.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -353,10 +318,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<div class="py-16 bg-base-100">
|
||||
<!-- CTA Section -->
|
||||
<div class="py-16 bg-base-100">
|
||||
<div class="container mx-auto px-4 text-center">
|
||||
<h2 class="text-4xl font-bold mb-6">Ready to Start Your Journey?</h2>
|
||||
<p class="text-xl mb-8 text-base-content/80 max-w-2xl mx-auto">
|
||||
@@ -372,10 +337,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legal Section -->
|
||||
<div class="py-8 bg-base-200">
|
||||
<!-- Legal Section -->
|
||||
<div class="py-8 bg-base-200">
|
||||
<div class="container mx-auto px-4 max-w-4xl">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
@@ -383,11 +348,10 @@
|
||||
<p class="text-sm text-base-content/70">
|
||||
Living Dex Tracker is a fan-made project. We do not claim ownership of any Pokémon
|
||||
characters, images, or other content featured on this website. This project is not
|
||||
affiliated with, endorsed, sponsored, or specifically approved by Nintendo, Game Freak,
|
||||
or The Pokémon Company.
|
||||
affiliated with, endorsed, sponsored, or specifically approved by Nintendo, Game Freak, or
|
||||
The Pokémon Company.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
|
||||
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||
import { getOptionalUserId } from '$lib/utils/auth';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
|
||||
import { loadCombinedDataPage } from '$lib/services/CombinedDataService';
|
||||
|
||||
// GET: Get combined data (pokédex entries + catch records) for specific pokédex
|
||||
export const GET = async (event: RequestEvent) => {
|
||||
@@ -23,48 +22,29 @@ export const GET = async (event: RequestEvent) => {
|
||||
const region = url.searchParams.get('region') || '';
|
||||
const game = url.searchParams.get('game') || '';
|
||||
|
||||
// If authenticated, verify user owns this pokédex and get its gameScope
|
||||
let pokedex;
|
||||
if (userId) {
|
||||
if (!userId) {
|
||||
// Anonymous users cannot view pokédexes
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Verify the user owns this pokédex and get its gameScope
|
||||
const pokedexRepo = new PokedexRepository(event.locals.supabase, userId);
|
||||
pokedex = await pokedexRepo.findById(pokedexId);
|
||||
const pokedex = await pokedexRepo.findById(pokedexId);
|
||||
|
||||
if (!pokedex) {
|
||||
// User is authenticated but doesn't own this pokédex (or it doesn't exist)
|
||||
return json({ error: 'Pokedex not found' }, { status: 404 });
|
||||
}
|
||||
} else {
|
||||
// Anonymous users cannot view pokédexes
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Use pokédex's gameScope as default filter if no manual game filter is set
|
||||
const effectiveGame = game || pokedex.gameScope || '';
|
||||
const dexScopes = await resolveDexScopes(event.locals.supabase, pokedex);
|
||||
|
||||
const repo = new CombinedDataRepository(event.locals.supabase, userId, pokedexId);
|
||||
|
||||
// Get paginated combined data
|
||||
const combinedData = await repo.findCombinedData(
|
||||
userId!,
|
||||
return json(
|
||||
await loadCombinedDataPage(event.locals.supabase, userId, pokedex, {
|
||||
page,
|
||||
limit,
|
||||
enableForms,
|
||||
region,
|
||||
effectiveGame,
|
||||
dexScopes
|
||||
game
|
||||
})
|
||||
);
|
||||
|
||||
// Get total count for pagination
|
||||
const totalCount = await repo.countCombinedData(enableForms, region, effectiveGame, dexScopes);
|
||||
const totalPages = Math.ceil(totalCount / limit);
|
||||
|
||||
return json({
|
||||
combinedData,
|
||||
totalPages,
|
||||
currentPage: page,
|
||||
totalCount
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (err && typeof err === 'object' && 'status' in err) {
|
||||
|
||||
@@ -27,6 +27,10 @@ export const GET = async (event: RequestEvent) => {
|
||||
}
|
||||
|
||||
const stats = data[0];
|
||||
// The figures refresh at most daily, so let browsers and the CDN reuse them.
|
||||
event.setHeaders({
|
||||
'cache-control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400'
|
||||
});
|
||||
return json({
|
||||
pokemonCaught: stats.pokemon_caught,
|
||||
users: stats.total_users,
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>Using Offline - Living Dex Tracker</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="How Living Dex Tracker keeps your Pokédexes and artwork available offline, and how to save everything before you lose signal."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto p-4 max-w-screen-lg">
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||
import { loadCombinedDataPage } from '$lib/services/CombinedDataService';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
// Must match the page's itemsPerPage: the box view needs the whole dex in one page.
|
||||
const INITIAL_PAGE_SIZE = 9999;
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
const { safeGetSession, supabase } = locals;
|
||||
const { session, user } = await safeGetSession();
|
||||
@@ -22,7 +26,22 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
throw error(404, 'Pokédex not found');
|
||||
}
|
||||
|
||||
// Streamed rather than awaited: the page shell renders straight away and the entries arrive in
|
||||
// the same response, instead of the browser requesting them after hydration. A failure resolves
|
||||
// to null so the page falls back to fetching (and reporting) through the API.
|
||||
const initialCombinedData = loadCombinedDataPage(supabase, user.id, pokedex, {
|
||||
page: 1,
|
||||
limit: INITIAL_PAGE_SIZE,
|
||||
enableForms: pokedex.isFormDex
|
||||
})
|
||||
.then((result) => result.combinedData)
|
||||
.catch((err) => {
|
||||
console.error('Unable to preload combined data', err);
|
||||
return null;
|
||||
});
|
||||
|
||||
return {
|
||||
pokedex
|
||||
pokedex,
|
||||
initialCombinedData
|
||||
};
|
||||
};
|
||||
|
||||
@@ -502,8 +502,35 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch data whenever pagination controls change (client-side only)
|
||||
$: if (browser && pokedexId) getData({ page: currentPage, perPage: itemsPerPage });
|
||||
// Show data whenever the dex or pagination changes (client-side only). The first page is streamed
|
||||
// from the server load, so it only needs fetching when that failed or the page changes.
|
||||
let shownKey = '';
|
||||
function showPage(
|
||||
id: string,
|
||||
page: number,
|
||||
perPage: number,
|
||||
initial: Promise<CombinedData[] | null> | undefined
|
||||
) {
|
||||
const key = `${id}:${page}:${perPage}`;
|
||||
if (key === shownKey) return;
|
||||
shownKey = key;
|
||||
if (page !== 1 || !initial) {
|
||||
void getData({ page, perPage });
|
||||
return;
|
||||
}
|
||||
combinedData = null;
|
||||
void initial.then((rows) => {
|
||||
if (shownKey !== key) return;
|
||||
if (!rows) {
|
||||
void getData({ page, perPage });
|
||||
return;
|
||||
}
|
||||
combinedData = rows;
|
||||
boxNumbers = calculateBoxNumbers(rows.length);
|
||||
});
|
||||
}
|
||||
$: if (browser && pokedexId)
|
||||
showPage(pokedexId, currentPage, itemsPerPage, data?.initialCombinedData);
|
||||
|
||||
onMount(() => {
|
||||
if (!browser) return;
|
||||
|
||||
@@ -22,8 +22,12 @@
|
||||
<div class="min-h-[calc(100vh-16rem)] bg-base-100 py-8 md:py-16 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<!-- Success Badge -->
|
||||
{#if showSuccess}
|
||||
<div class="flex justify-center mb-8 animate-fade-in">
|
||||
<!-- Always rendered (faded in) so the hero doesn't jump down when the badge appears. -->
|
||||
<div
|
||||
class="flex justify-center mb-8 transition-opacity duration-500"
|
||||
class:opacity-0={!showSuccess}
|
||||
>
|
||||
<div class="flex justify-center">
|
||||
<div class="badge badge-success badge-lg gap-2 p-6 shadow-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -42,7 +46,7 @@
|
||||
<span class="text-lg font-semibold">Account Created Successfully!</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Hero Content -->
|
||||
<div class="hero">
|
||||
@@ -315,21 +319,6 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
@@ -346,7 +335,6 @@
|
||||
|
||||
/* Respect user's motion preferences */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-fade-in,
|
||||
.animate-pulse {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 14 KiB |
+9
-4
@@ -1,9 +1,9 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
'./src/routes/**/*.{svelte,js,ts}',
|
||||
'./src/routes/**/**/*.{svelte,js,ts}',
|
||||
'./src/lib/components/**/*.{svelte,js,ts}'
|
||||
'./src/**/*.{html,svelte,js,ts}',
|
||||
'./static/offline.html',
|
||||
'./static/offline-viewer.js'
|
||||
],
|
||||
theme: {
|
||||
extend: {}
|
||||
@@ -16,11 +16,16 @@ export default {
|
||||
'dracula',
|
||||
{
|
||||
pokeball: {
|
||||
primary: '#ee1515',
|
||||
// A slightly deeper Poké Ball red: #ee1515 gave white text only 4.3:1 contrast, below
|
||||
// the WCAG AA 4.5:1 minimum that the Lighthouse accessibility gate checks.
|
||||
primary: '#d31111',
|
||||
'primary-content': '#ffffff',
|
||||
secondary: '#ffd700',
|
||||
'secondary-content': '#ffffff',
|
||||
accent: '#3b82c4',
|
||||
// daisyUI's default info blue is too light for text on the base colours.
|
||||
info: '#0369a1',
|
||||
'info-content': '#ffffff',
|
||||
neutral: '#ffffff',
|
||||
'base-100': '#f0f0f0',
|
||||
'base-content': '#222224'
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
Feature: Signed-in page speed
|
||||
As a trainer
|
||||
I want my Pokédexes to open and switch quickly
|
||||
So that tracking catches never feels sluggish
|
||||
|
||||
Lighthouse CI covers the public pages; these budgets cover the signed-in ones it can't reach.
|
||||
|
||||
Background:
|
||||
Given I am signed in
|
||||
And I have a Living Dex named "Speed Check"
|
||||
|
||||
Scenario: A Pokédex opens without a second round trip for its entries
|
||||
When I load the Pokédex page directly
|
||||
Then its entries appear within 5 seconds
|
||||
And the browser did not request the entries separately
|
||||
|
||||
Scenario: Moving between my Pokédex list and a Pokédex is quick
|
||||
When I switch between my Pokédex list and the Pokédex
|
||||
Then each switch finishes within 3 seconds
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createBdd } from 'playwright-bdd';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { test, expect } from '../fixtures';
|
||||
|
||||
const { When, Then } = createBdd(test);
|
||||
|
||||
// Per-page scratch values; scenarios run one at a time (workers: 1).
|
||||
const timings = new WeakMap<
|
||||
Page,
|
||||
{ entriesMs?: number; switchMs: number[]; entryRequests: number }
|
||||
>();
|
||||
|
||||
function entriesVisible(page: Page) {
|
||||
return expect(page.getByRole('button', { name: /^View details for / }).first()).toBeVisible({
|
||||
timeout: 30_000
|
||||
});
|
||||
}
|
||||
|
||||
When('I load the Pokédex page directly', async ({ page, state }) => {
|
||||
if (!state.pokedexId) throw new Error('A Pokédex must exist before it can be opened');
|
||||
const record = { switchMs: [], entryRequests: 0 };
|
||||
timings.set(page, record);
|
||||
// Only requests made while the page first loads matter; the page's 60s reconciliation refetch
|
||||
// can't fire within this window.
|
||||
const countEntryRequests = (request: { url(): string }) => {
|
||||
if (/\/api\/pokedexes\/[^/]+\/combined-data/.test(request.url())) record.entryRequests++;
|
||||
};
|
||||
page.on('request', countEntryRequests);
|
||||
|
||||
const started = Date.now();
|
||||
await page.goto(`/pokedex/${state.pokedexId}`);
|
||||
await entriesVisible(page);
|
||||
(record as { entriesMs?: number }).entriesMs = Date.now() - started;
|
||||
page.off('request', countEntryRequests);
|
||||
});
|
||||
|
||||
Then('its entries appear within {int} seconds', async ({ page }, seconds: number) => {
|
||||
const entriesMs = timings.get(page)?.entriesMs;
|
||||
expect(entriesMs, 'entries never became visible').toBeDefined();
|
||||
expect(entriesMs!).toBeLessThan(seconds * 1000);
|
||||
});
|
||||
|
||||
Then('the browser did not request the entries separately', async ({ page }) => {
|
||||
// The server load streams the first page of entries with the HTML, so the page must not make
|
||||
// the old hydrate-then-fetch round trip.
|
||||
expect(timings.get(page)?.entryRequests).toBe(0);
|
||||
});
|
||||
|
||||
When('I switch between my Pokédex list and the Pokédex', async ({ page, state }) => {
|
||||
if (!state.pokedexName) throw new Error('A Pokédex must exist before switching to it');
|
||||
const record = { switchMs: [] as number[], entryRequests: 0 };
|
||||
timings.set(page, record);
|
||||
await page.goto('/my-pokedexes');
|
||||
const card = page.locator('.card').filter({ hasText: state.pokedexName }).first();
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
for (let round = 0; round < 2; round++) {
|
||||
// List -> Pokédex: a client-side navigation through the card's View button.
|
||||
let started = Date.now();
|
||||
await card.getByRole('button', { name: 'View', exact: true }).click();
|
||||
await page.waitForURL('**/pokedex/**');
|
||||
await entriesVisible(page);
|
||||
record.switchMs.push(Date.now() - started);
|
||||
|
||||
// Pokédex -> list: back navigation is also handled by the client router.
|
||||
started = Date.now();
|
||||
await page.goBack();
|
||||
await expect(card).toBeVisible();
|
||||
record.switchMs.push(Date.now() - started);
|
||||
}
|
||||
});
|
||||
|
||||
Then('each switch finishes within {int} seconds', async ({ page }, seconds: number) => {
|
||||
const switchMs = timings.get(page)?.switchMs ?? [];
|
||||
expect(switchMs).toHaveLength(4);
|
||||
for (const ms of switchMs) expect(ms).toBeLessThan(seconds * 1000);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
import { generateSW } from '../../pwa.mjs';
|
||||
@@ -44,4 +45,44 @@ describe(`test-build: ${nodeAdapter ? 'node' : 'static'} adapter`, () => {
|
||||
expect(match === null, 'found server/ entries in sw precache manifest').toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
const outputRoot = `./build/${nodeAdapter ? 'client/' : ''}`;
|
||||
const nodesDir = './.svelte-kit/output/server/nodes/';
|
||||
const gzippedSize = (path: string) => gzipSync(readFileSync(`${outputRoot}${path}`)).length;
|
||||
|
||||
/** The client files a route node makes the browser load (its imports and stylesheets). */
|
||||
function assetsLoadedBy(node: string, extension: 'js' | 'css'): string[] {
|
||||
const pattern = new RegExp(`_app/immutable/[^"']+\\.${extension}`, 'g');
|
||||
return [...new Set(readFileSync(`${nodesDir}${node}`, 'utf-8').match(pattern) ?? [])];
|
||||
}
|
||||
|
||||
it('ships the app stylesheet once, hashed and small', () => {
|
||||
const referenced = new Set(
|
||||
readdirSync(nodesDir).flatMap((node) => assetsLoadedBy(node, 'css'))
|
||||
);
|
||||
// Every page loads Tailwind's preflight; exactly one served stylesheet may contain it.
|
||||
const withPreflight = [...referenced].filter((path) =>
|
||||
readFileSync(`${outputRoot}${path}`, 'utf-8').includes('--tw-content')
|
||||
);
|
||||
expect(withPreflight, 'Tailwind is bundled more than once').toHaveLength(1);
|
||||
|
||||
// The un-hashed output.css exists only for offline.html; pages must not block on it.
|
||||
const appHtml = readFileSync('./src/app.html', 'utf-8');
|
||||
expect(appHtml).not.toMatch(/output\.css/);
|
||||
});
|
||||
|
||||
// Regression budgets for what every page downloads before it can render: the root layout's
|
||||
// scripts and stylesheets. Unlike Lighthouse timings these sizes don't vary between runs, so any
|
||||
// growth past the budget fails the PR. Measured September 2026: 95.7 KB JS and 15.7 KB CSS
|
||||
// gzipped. Raise a budget in the same PR only when the extra weight is deliberate.
|
||||
it.each([
|
||||
['js', 105 * 1024],
|
||||
['css', 18 * 1024]
|
||||
] as const)('keeps the layout %s loaded on every page within budget', (extension, budget) => {
|
||||
const total = assetsLoadedBy('0.js', extension).reduce(
|
||||
(sum, path) => sum + gzippedSize(path),
|
||||
0
|
||||
);
|
||||
expect(total, `layout ${extension} is ${total} bytes gzipped`).toBeLessThan(budget);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Pokedex } from '$lib/models/Pokedex';
|
||||
|
||||
const findCombinedData = vi.fn();
|
||||
const countCombinedData = vi.fn();
|
||||
const constructed: unknown[][] = [];
|
||||
|
||||
vi.mock('$lib/repositories/CombinedDataRepository', () => ({
|
||||
default: class {
|
||||
constructor(...args: unknown[]) {
|
||||
constructed.push(args);
|
||||
}
|
||||
findCombinedData = findCombinedData;
|
||||
countCombinedData = countCombinedData;
|
||||
}
|
||||
}));
|
||||
vi.mock('$lib/services/PokedexDexScopeService', () => ({
|
||||
resolveDexScopes: vi.fn(async () => ['national'])
|
||||
}));
|
||||
|
||||
const { loadCombinedDataPage } = await import('$lib/services/CombinedDataService');
|
||||
|
||||
const supabase = {} as never;
|
||||
const pokedex = { _id: 'dex-1', gameScope: 'Black' } as unknown as Pokedex;
|
||||
|
||||
describe('loadCombinedDataPage', () => {
|
||||
beforeEach(() => {
|
||||
constructed.length = 0;
|
||||
findCombinedData.mockReset().mockResolvedValue([{ id: 'row' }]);
|
||||
countCombinedData.mockReset().mockResolvedValue(45);
|
||||
});
|
||||
|
||||
it("defaults to the Pokédex's game scope and reports pagination", async () => {
|
||||
const result = await loadCombinedDataPage(supabase, 'user-1', pokedex, {
|
||||
page: 2,
|
||||
limit: 20,
|
||||
enableForms: true
|
||||
});
|
||||
|
||||
expect(constructed).toEqual([[supabase, 'user-1', 'dex-1']]);
|
||||
expect(findCombinedData).toHaveBeenCalledWith('user-1', 2, 20, true, '', 'Black', ['national']);
|
||||
expect(countCombinedData).toHaveBeenCalledWith(true, '', 'Black', ['national']);
|
||||
expect(result).toEqual({
|
||||
combinedData: [{ id: 'row' }],
|
||||
totalPages: 3,
|
||||
currentPage: 2,
|
||||
totalCount: 45
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers an explicit game and region filter', async () => {
|
||||
await loadCombinedDataPage(supabase, 'user-1', pokedex, {
|
||||
page: 1,
|
||||
limit: 9999,
|
||||
enableForms: false,
|
||||
region: 'unova',
|
||||
game: 'White'
|
||||
});
|
||||
|
||||
expect(countCombinedData).toHaveBeenCalledWith(false, 'unova', 'White', ['national']);
|
||||
});
|
||||
|
||||
it('runs the rows and count queries at the same time', async () => {
|
||||
let releaseRows: (rows: unknown[]) => void = () => {};
|
||||
findCombinedData.mockReturnValue(new Promise((resolve) => (releaseRows = resolve)));
|
||||
const pending = loadCombinedDataPage(supabase, 'user-1', pokedex, {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
enableForms: false
|
||||
});
|
||||
|
||||
// The count starts before the rows query has finished.
|
||||
await vi.waitFor(() => expect(countCombinedData).toHaveBeenCalled());
|
||||
releaseRows([]);
|
||||
await expect(pending).resolves.toMatchObject({ totalCount: 45, totalPages: 5 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { brotliDecompressSync, gunzipSync } from 'node:zlib';
|
||||
import { compressResponse, pickEncoding } from '$lib/server/compression';
|
||||
|
||||
const html = '<!doctype html><p>' + 'Living Dex '.repeat(500) + '</p>';
|
||||
|
||||
function request(acceptEncoding?: string, method = 'GET') {
|
||||
return new Request('http://localhost/', {
|
||||
method,
|
||||
headers: acceptEncoding ? { 'accept-encoding': acceptEncoding } : {}
|
||||
});
|
||||
}
|
||||
|
||||
function page(body: BodyInit | null = html, init: ResponseInit = {}) {
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/html; charset=utf-8', 'content-length': '999' },
|
||||
...init
|
||||
});
|
||||
}
|
||||
|
||||
async function bytes(response: Response) {
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
describe('pickEncoding', () => {
|
||||
it.each([
|
||||
['gzip, deflate, br', 'br'],
|
||||
['gzip', 'gzip'],
|
||||
['br;q=0, gzip', 'gzip'],
|
||||
['*', 'br'],
|
||||
['identity', null],
|
||||
['gzip;q=0', null]
|
||||
])('%s -> %s', (header, expected) => {
|
||||
expect(pickEncoding(header)).toBe(expected);
|
||||
});
|
||||
|
||||
it('returns null when the client sends no Accept-Encoding', () => {
|
||||
expect(pickEncoding(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('compressResponse', () => {
|
||||
it('brotli-compresses HTML and the body round-trips', async () => {
|
||||
const response = compressResponse(request('gzip, br'), page());
|
||||
|
||||
expect(response.headers.get('content-encoding')).toBe('br');
|
||||
expect(response.headers.get('content-length')).toBeNull();
|
||||
expect(response.headers.get('vary')).toMatch(/Accept-Encoding/);
|
||||
const body = await bytes(response);
|
||||
expect(body.length).toBeLessThan(html.length / 4);
|
||||
expect(brotliDecompressSync(body).toString()).toBe(html);
|
||||
});
|
||||
|
||||
it('falls back to gzip for JSON', async () => {
|
||||
const json = JSON.stringify({ rows: Array.from({ length: 200 }, (_, i) => ({ i })) });
|
||||
const response = compressResponse(
|
||||
request('gzip'),
|
||||
new Response(json, { headers: { 'content-type': 'application/json' } })
|
||||
);
|
||||
|
||||
expect(response.headers.get('content-encoding')).toBe('gzip');
|
||||
expect(gunzipSync(await bytes(response)).toString()).toBe(json);
|
||||
});
|
||||
|
||||
it('keeps set-cookie headers and the status', async () => {
|
||||
const original = page(html, { status: 404 });
|
||||
original.headers.append('set-cookie', 'a=1; Path=/');
|
||||
original.headers.append('set-cookie', 'b=2; Path=/');
|
||||
const response = compressResponse(request('br'), original);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.getSetCookie()).toEqual(['a=1; Path=/', 'b=2; Path=/']);
|
||||
});
|
||||
|
||||
it('flushes each streamed chunk rather than buffering the whole body', async () => {
|
||||
let sendRest: () => void = () => {};
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('<p>shell</p>'));
|
||||
sendRest = () => {
|
||||
controller.enqueue(new TextEncoder().encode('<p>streamed data</p>'));
|
||||
controller.close();
|
||||
};
|
||||
}
|
||||
});
|
||||
const response = compressResponse(request('gzip'), page(stream));
|
||||
const reader = response.body!.getReader();
|
||||
|
||||
// The shell arrives while the stream is still open.
|
||||
const first = await reader.read();
|
||||
expect(gunzipSync(Buffer.from(first.value!), { finishFlush: 2 }).toString()).toBe(
|
||||
'<p>shell</p>'
|
||||
);
|
||||
|
||||
sendRest();
|
||||
const rest: Uint8Array[] = [Buffer.from(first.value!)];
|
||||
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
||||
rest.push(chunk.value);
|
||||
}
|
||||
expect(gunzipSync(Buffer.concat(rest)).toString()).toBe('<p>shell</p><p>streamed data</p>');
|
||||
});
|
||||
|
||||
it('passes responses through inside a Netlify (Lambda) function', () => {
|
||||
// adapter-netlify reads text bodies with response.text(), which would corrupt compressed bytes.
|
||||
process.env.AWS_LAMBDA_FUNCTION_NAME = 'sveltekit-render';
|
||||
try {
|
||||
const original = page();
|
||||
expect(compressResponse(request('br'), original)).toBe(original);
|
||||
} finally {
|
||||
delete process.env.AWS_LAMBDA_FUNCTION_NAME;
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a client that accepts no encoding', request(), page()],
|
||||
['a HEAD request', request('br', 'HEAD'), page(null)],
|
||||
['an image', request('br'), new Response('png', { headers: { 'content-type': 'image/png' } })],
|
||||
['a 304', request('br'), new Response(null, { status: 304 })],
|
||||
[
|
||||
'an already-encoded body',
|
||||
request('br'),
|
||||
new Response('x', { headers: { 'content-type': 'text/html', 'content-encoding': 'gzip' } })
|
||||
]
|
||||
])('leaves %s uncompressed', async (_label, req, res) => {
|
||||
const response = compressResponse(req, res);
|
||||
expect(response.headers.get('content-encoding')).toBe(res.headers.get('content-encoding'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const getUser = vi.fn();
|
||||
const getSession = vi.fn();
|
||||
|
||||
vi.mock('$env/static/public', () => ({
|
||||
PUBLIC_SUPABASE_URL: 'http://127.0.0.1:54321',
|
||||
PUBLIC_SUPABASE_ANON_KEY: 'anon'
|
||||
}));
|
||||
vi.mock('@supabase/ssr', () => ({
|
||||
createServerClient: () => ({ auth: { getUser, getSession } })
|
||||
}));
|
||||
|
||||
const { handle } = await import('../../src/hooks.server');
|
||||
|
||||
async function runHandle() {
|
||||
const event = {
|
||||
cookies: { getAll: () => [], set: vi.fn() },
|
||||
locals: {}
|
||||
} as unknown as Parameters<typeof handle>[0]['event'];
|
||||
await handle({ event, resolve: vi.fn(async () => new Response()) } as never);
|
||||
return event.locals;
|
||||
}
|
||||
|
||||
describe('safeGetSession', () => {
|
||||
it('validates the session with Supabase Auth only once per request', async () => {
|
||||
getUser.mockReset().mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null });
|
||||
getSession.mockReset().mockResolvedValue({ data: { session: { access_token: 't' } } });
|
||||
const locals = await runHandle();
|
||||
|
||||
const [first, second] = await Promise.all([locals.safeGetSession(), locals.safeGetSession()]);
|
||||
const third = await locals.safeGetSession();
|
||||
|
||||
expect(getUser).toHaveBeenCalledTimes(1);
|
||||
expect(first.user?.id).toBe('user-1');
|
||||
expect(second).toBe(first);
|
||||
expect(third).toBe(first);
|
||||
});
|
||||
|
||||
it('does not share a session between requests', async () => {
|
||||
getUser.mockReset().mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null });
|
||||
getSession.mockReset().mockResolvedValue({ data: { session: {} } });
|
||||
await (await runHandle()).safeGetSession();
|
||||
await (await runHandle()).safeGetSession();
|
||||
|
||||
expect(getUser).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns no session when the JWT is rejected', async () => {
|
||||
getUser.mockReset().mockResolvedValue({ data: { user: null }, error: new Error('bad jwt') });
|
||||
getSession.mockReset();
|
||||
const locals = await runHandle();
|
||||
|
||||
await expect(locals.safeGetSession()).resolves.toEqual({ session: null, user: null });
|
||||
expect(getSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user