mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-16 02:22:17 +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:
@@ -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