mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-16 10:32:10 +00:00
test(performance): add a local pokédex benchmark and run it in CI
Measures the pokédex load against a local Supabase with a generated fixture, and compares runs so a regression shows up as a number rather than a hunch. Wired into test:ci and given its own job so the artifacts survive a failure.
This commit is contained in:
@@ -145,6 +145,30 @@ jobs:
|
|||||||
- if: always()
|
- if: always()
|
||||||
run: npx supabase stop
|
run: npx supabase stop
|
||||||
|
|
||||||
|
pokedex-performance:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
env:
|
||||||
|
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER: 'true'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npx playwright install --with-deps chromium
|
||||||
|
- run: npx supabase start
|
||||||
|
- run: npm run test:performance
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: pokedex-performance
|
||||||
|
path: test-results/performance/
|
||||||
|
if-no-files-found: ignore
|
||||||
|
- if: always()
|
||||||
|
run: npx supabase stop
|
||||||
|
|
||||||
cloudflare-preview-build:
|
cloudflare-preview-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
|
|||||||
+3
-2
@@ -36,7 +36,7 @@
|
|||||||
"test:build:inject-static": "npm run build-inject-manifest && vitest run --config vitest.build.config.mts",
|
"test:build:inject-static": "npm run build-inject-manifest && vitest run --config vitest.build.config.mts",
|
||||||
"test:build:inject-node": "npm run build-inject-manifest-node && NODE_ADAPTER=true vitest run --config vitest.build.config.mts",
|
"test:build:inject-node": "npm run build-inject-manifest-node && NODE_ADAPTER=true vitest run --config vitest.build.config.mts",
|
||||||
"test:build": "npm run test:build:generate-static && npm run test:build:generate-node && npm run test:build:inject-static && npm run test:build:inject-node",
|
"test:build": "npm run test:build:generate-static && npm run test:build:generate-node && npm run test:build:inject-static && npm run test:build:inject-node",
|
||||||
"test:ci": "npm run test:fast && npm run test:integration && npm run test:build && npm run test:bdd",
|
"test:ci": "npm run test:fast && npm run test:integration && npm run test:build && npm run test:bdd && npm run test:performance",
|
||||||
"test": "npm run test:ci",
|
"test": "npm run test:ci",
|
||||||
"supabase:start": "supabase start",
|
"supabase:start": "supabase start",
|
||||||
"supabase:stop": "supabase stop",
|
"supabase:stop": "supabase stop",
|
||||||
@@ -48,7 +48,8 @@
|
|||||||
"sprites:grid": "node scripts/grid-thumbnails.mjs",
|
"sprites:grid": "node scripts/grid-thumbnails.mjs",
|
||||||
"build:cloudflare": "DEPLOY_TARGET=cloudflare npm run build",
|
"build:cloudflare": "DEPLOY_TARGET=cloudflare npm run build",
|
||||||
"preview:cloudflare": "wrangler dev",
|
"preview:cloudflare": "wrangler dev",
|
||||||
"check:cloudflare": "wrangler deploy --dry-run"
|
"check:cloudflare": "wrangler deploy --dry-run",
|
||||||
|
"test:performance": "node scripts/run-with-local-supabase.mjs node scripts/performance/run-local.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lhci/cli": "^0.15.1",
|
"@lhci/cli": "^0.15.1",
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
// Configuration holds session-file paths, never passwords. Keep it outside the repository.
|
||||||
|
const [configPath, outputPath] = process.argv.slice(2);
|
||||||
|
if (!configPath || !outputPath)
|
||||||
|
throw new Error('Usage: node scripts/performance/benchmark.mjs CONFIG.json RESULTS.json');
|
||||||
|
const config = JSON.parse(await readFile(configPath, 'utf8'));
|
||||||
|
const samples = config.samples ?? 30;
|
||||||
|
if (samples < 30)
|
||||||
|
throw new Error('Host decisions require at least 30 warm samples per fixture/navigation');
|
||||||
|
if (!config.revision || !config.databaseLabel || !config.clientLocation)
|
||||||
|
throw new Error('Record revision, databaseLabel and clientLocation for comparable results');
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const results = [];
|
||||||
|
try {
|
||||||
|
for (const host of config.hosts) {
|
||||||
|
for (const fixture of host.fixtures) {
|
||||||
|
for (const navigation of ['direct', 'client']) {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
baseURL: host.url,
|
||||||
|
storageState: host.storageState,
|
||||||
|
viewport: { width: 1350, height: 940 },
|
||||||
|
deviceScaleFactor: 1
|
||||||
|
});
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
window.__perfShifts = [];
|
||||||
|
new PerformanceObserver((list) => {
|
||||||
|
for (const entry of list.getEntries())
|
||||||
|
if (!entry.hadRecentInput)
|
||||||
|
window.__perfShifts.push({ value: entry.value, time: entry.startTime });
|
||||||
|
}).observe({ type: 'layout-shift', buffered: true });
|
||||||
|
window.__watchGrid = () => {
|
||||||
|
window.__perfVisible = null;
|
||||||
|
function check() {
|
||||||
|
const cell = document.querySelector('[data-entry-index]');
|
||||||
|
if (
|
||||||
|
location.pathname.startsWith('/pokedex/') &&
|
||||||
|
cell &&
|
||||||
|
cell.getBoundingClientRect().top < innerHeight &&
|
||||||
|
cell.getBoundingClientRect().height > 0
|
||||||
|
) {
|
||||||
|
window.__perfVisible = performance.now();
|
||||||
|
performance.mark('pokedex:first-visible');
|
||||||
|
} else requestAnimationFrame(check);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(check);
|
||||||
|
};
|
||||||
|
window.__watchGrid();
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
for (let index = 0; index <= samples; index++) {
|
||||||
|
if (navigation === 'client') {
|
||||||
|
await page.goto('/my-pokedexes');
|
||||||
|
await page.getByText(fixture.name, { exact: true }).first().waitFor();
|
||||||
|
} else if (index > 0) await page.goto('about:blank');
|
||||||
|
const bodies = [];
|
||||||
|
const requests = { grid: 0, details: 0, snapshot: 0, backup: 0 };
|
||||||
|
const onRequest = (request) => {
|
||||||
|
const path = new URL(request.url()).pathname;
|
||||||
|
if (/\/pokedexes\/[^/]+\/(grid|combined-data)$/.test(path)) requests.grid++;
|
||||||
|
if (/\/pokedexes\/[^/]+\/entries\//.test(path)) requests.details++;
|
||||||
|
if (path === '/api/offline-snapshot') requests.snapshot++;
|
||||||
|
if (path === '/api/export-integrations') requests.backup++;
|
||||||
|
};
|
||||||
|
const onResponse = (response) => {
|
||||||
|
const path = new URL(response.url()).pathname;
|
||||||
|
if (!path.startsWith(`/pokedex/${fixture.id}`)) return;
|
||||||
|
bodies.push(
|
||||||
|
(async () => {
|
||||||
|
// Chromium may not expose bodies routed through a service worker.
|
||||||
|
const body = await response.body().catch(() => null);
|
||||||
|
return {
|
||||||
|
url: response.url(),
|
||||||
|
bytes: body?.length ?? null,
|
||||||
|
status: response.status(),
|
||||||
|
fromServiceWorker: response.fromServiceWorker(),
|
||||||
|
timings: response.request().timing(),
|
||||||
|
serverTiming: response.headers()['server-timing'] ?? null
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
page.on('request', onRequest);
|
||||||
|
page.on('response', onResponse);
|
||||||
|
let started = 0;
|
||||||
|
if (navigation === 'direct')
|
||||||
|
await page.goto(`/pokedex/${fixture.id}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
else {
|
||||||
|
started = await page.evaluate(() => {
|
||||||
|
performance.clearMarks('pokedex:first-interactive');
|
||||||
|
window.__watchGrid();
|
||||||
|
return performance.now();
|
||||||
|
});
|
||||||
|
await page
|
||||||
|
.locator('.card')
|
||||||
|
.filter({ hasText: fixture.name })
|
||||||
|
.first()
|
||||||
|
.getByRole('button', { name: 'View', exact: true })
|
||||||
|
.click();
|
||||||
|
}
|
||||||
|
await page.waitForSelector('[data-grid-interactive]');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__perfVisible !== null &&
|
||||||
|
performance.getEntriesByName('pokedex:first-interactive').length > 0
|
||||||
|
);
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
const browserData = await page.evaluate(
|
||||||
|
(start) => ({
|
||||||
|
visibleMs: window.__perfVisible - start,
|
||||||
|
interactiveMs:
|
||||||
|
performance.getEntriesByName('pokedex:first-interactive').at(-1).startTime - start,
|
||||||
|
cells: document.querySelectorAll('[data-entry-index]').length,
|
||||||
|
dom: document.querySelectorAll('*').length,
|
||||||
|
cls: window.__perfShifts
|
||||||
|
.filter((entry) => entry.time >= start)
|
||||||
|
.reduce((total, entry) => total + entry.value, 0)
|
||||||
|
}),
|
||||||
|
started
|
||||||
|
);
|
||||||
|
page.off('request', onRequest);
|
||||||
|
page.off('response', onResponse);
|
||||||
|
const resources = await page.evaluate(() =>
|
||||||
|
performance.getEntriesByType('resource').map((entry) => ({
|
||||||
|
url: entry.name,
|
||||||
|
decodedBytes: entry.decodedBodySize,
|
||||||
|
encodedBytes: entry.encodedBodySize,
|
||||||
|
transferBytes: entry.transferSize
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const responses = (await Promise.all(bodies)).map(({ url, ...response }) => {
|
||||||
|
const resource = resources.findLast((entry) => entry.url === url);
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
bytes: response.bytes ?? (resource?.decodedBytes || null),
|
||||||
|
byteSource: response.bytes !== null ? 'response-body' : 'resource-timing',
|
||||||
|
encodedBytes: resource?.encodedBytes ?? null,
|
||||||
|
transferBytes: resource?.transferBytes ?? null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
results.push({
|
||||||
|
host: host.label,
|
||||||
|
fixture: fixture.label,
|
||||||
|
navigation,
|
||||||
|
run: index === 0 ? 'first-observed' : 'warm',
|
||||||
|
...browserData,
|
||||||
|
requests,
|
||||||
|
responses
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await context.close();
|
||||||
|
console.log(
|
||||||
|
`Completed ${host.label}/${fixture.label}/${navigation}: ${samples} warm samples.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeFile(
|
||||||
|
outputPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
capturedAt: new Date().toISOString(),
|
||||||
|
revision: config.revision,
|
||||||
|
databaseLabel: config.databaseLabel,
|
||||||
|
clientLocation: config.clientLocation,
|
||||||
|
environment: config.environment ?? 'deployed',
|
||||||
|
compatibilityPassed: config.compatibilityPassed ?? false,
|
||||||
|
results
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`Recorded ${results.length} samples; first-observed runs are excluded from warm statistics.`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
const data = JSON.parse(await readFile(process.argv[2], 'utf8'));
|
||||||
|
const warm = data.results.filter((row) => row.run === 'warm');
|
||||||
|
const p75 = (values) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * 0.75) - 1];
|
||||||
|
const groups = [...new Set(warm.map((row) => `${row.fixture}/${row.navigation}`))];
|
||||||
|
const paired = groups.map((group) => {
|
||||||
|
const rows = (host) =>
|
||||||
|
warm.filter((row) => `${row.fixture}/${row.navigation}` === group && row.host === host);
|
||||||
|
for (const host of ['netlify', 'cloudflare'])
|
||||||
|
if (rows(host).length < 30) throw new Error(`Insufficient ${host} warm samples for ${group}`);
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
netlify: p75(rows('netlify').map((row) => row.interactiveMs)),
|
||||||
|
cloudflare: p75(rows('cloudflare').map((row) => row.interactiveMs))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (groups.length !== 4)
|
||||||
|
throw new Error(
|
||||||
|
'Expected national and scoped-form fixtures, each with direct and client navigation'
|
||||||
|
);
|
||||||
|
const netlify = p75(warm.filter((row) => row.host === 'netlify').map((row) => row.interactiveMs));
|
||||||
|
const cloudflare = p75(
|
||||||
|
warm.filter((row) => row.host === 'cloudflare').map((row) => row.interactiveMs)
|
||||||
|
);
|
||||||
|
const improvement = netlify - cloudflare;
|
||||||
|
const passes =
|
||||||
|
improvement >= 200 &&
|
||||||
|
improvement / netlify >= 0.2 &&
|
||||||
|
paired.every((row) => row.cloudflare <= row.netlify * 1.1);
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
overallP75: { netlify, cloudflare },
|
||||||
|
improvementMs: improvement,
|
||||||
|
improvementPercent: (100 * improvement) / netlify,
|
||||||
|
groups: paired,
|
||||||
|
performanceGatePassed: passes,
|
||||||
|
recommendation:
|
||||||
|
passes && data.compatibilityPassed && data.environment === 'deployed'
|
||||||
|
? 'Cloudflare qualifies for a migration proposal; review operating cost before cutover.'
|
||||||
|
: 'Retain Netlify: performance, deployed evidence or compatibility gate is unmet.'
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import { createServerClient } from '@supabase/ssr';
|
||||||
|
import { mkdir, readFile, writeFile, unlink } from 'node:fs/promises';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
const directory = process.env.PERF_FIXTURE_DIR ?? '/tmp/livingdex-grid-performance';
|
||||||
|
const url = process.env.PUBLIC_SUPABASE_URL;
|
||||||
|
if (!url || !['localhost', '127.0.0.1', '[::1]'].includes(new URL(url).hostname))
|
||||||
|
throw new Error('Fixtures require the local Supabase wrapper');
|
||||||
|
const admin = createClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY, {
|
||||||
|
auth: { persistSession: false }
|
||||||
|
});
|
||||||
|
const check = ({ data, error }) => {
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||||
|
const fixturePath = `${directory}/fixture.json`;
|
||||||
|
if (process.argv.includes('--cleanup')) {
|
||||||
|
const fixture = JSON.parse(await readFile(fixturePath, 'utf8'));
|
||||||
|
const { user } = check(await admin.auth.admin.getUserById(fixture.userId));
|
||||||
|
if (user.email !== fixture.email || !user.email.startsWith('grid-performance-'))
|
||||||
|
throw new Error('Refusing to remove a non-fixture account');
|
||||||
|
check(await admin.auth.admin.deleteUser(fixture.userId));
|
||||||
|
await unlink(fixturePath);
|
||||||
|
await unlink(`${directory}/storage-state.json`).catch(() => {});
|
||||||
|
console.log('Removed disposable performance account and session.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await readFile(fixturePath);
|
||||||
|
throw new Error('An existing fixture needs cleanup first');
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ENOENT') throw error;
|
||||||
|
}
|
||||||
|
const email = `grid-performance-${randomUUID()}@example.test`;
|
||||||
|
const password = randomUUID() + 'aA1!';
|
||||||
|
const { user } = check(await admin.auth.admin.createUser({ email, password, email_confirm: true }));
|
||||||
|
const fixture = { userId: user.id, email, password, dexes: [] };
|
||||||
|
await writeFile(fixturePath, JSON.stringify(fixture), { mode: 0o600 });
|
||||||
|
fixture.dexes = check(
|
||||||
|
await admin
|
||||||
|
.from('pokedexes')
|
||||||
|
.insert([
|
||||||
|
{ userId: user.id, name: 'Performance National', isLivingDex: true, isFormDex: false },
|
||||||
|
{
|
||||||
|
userId: user.id,
|
||||||
|
name: 'Performance Scarlet Forms',
|
||||||
|
isLivingDex: true,
|
||||||
|
isFormDex: true,
|
||||||
|
gameScope: 'Scarlet'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
.select()
|
||||||
|
);
|
||||||
|
const game = fixture.dexes.find((dex) => dex.gameScope);
|
||||||
|
check(
|
||||||
|
await admin.from('pokedex_dex_scopes').insert({ pokedexId: game.id, dexId: 'scarlet-paldea' })
|
||||||
|
);
|
||||||
|
const entries = [];
|
||||||
|
for (let from = 0; ; from += 1000) {
|
||||||
|
const page = check(
|
||||||
|
await admin
|
||||||
|
.from('pokemon')
|
||||||
|
.select('id,isDefaultForm')
|
||||||
|
.order('id')
|
||||||
|
.range(from, from + 999)
|
||||||
|
);
|
||||||
|
entries.push(...page);
|
||||||
|
if (page.length < 1000) break;
|
||||||
|
}
|
||||||
|
for (const dex of fixture.dexes) {
|
||||||
|
const selected = entries.filter((entry) => dex.isFormDex || entry.isDefaultForm);
|
||||||
|
for (let from = 0; from < selected.length; from += 500) {
|
||||||
|
check(
|
||||||
|
await admin.from('catch_records').insert(
|
||||||
|
selected.slice(from, from + 500).map((entry, index) => ({
|
||||||
|
userId: user.id,
|
||||||
|
pokedexId: dex.id,
|
||||||
|
pokemonId: entry.id,
|
||||||
|
caught: (from + index) % 3 === 0,
|
||||||
|
haveToEvolve: (from + index) % 3 === 1,
|
||||||
|
inHome: (from + index) % 7 === 0,
|
||||||
|
personalNotes: index === 0 ? 'Performance fixture note: preserve me' : ''
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeFile(fixturePath, JSON.stringify(fixture), { mode: 0o600 });
|
||||||
|
const cookies = [];
|
||||||
|
const client = createServerClient(url, process.env.PUBLIC_SUPABASE_ANON_KEY, {
|
||||||
|
cookies: { getAll: () => [], setAll: (values) => cookies.push(...values) }
|
||||||
|
});
|
||||||
|
check(await client.auth.signInWithPassword({ email, password }));
|
||||||
|
const base = new URL(process.env.PERF_BASE_URL ?? 'http://127.0.0.1:4173');
|
||||||
|
await writeFile(
|
||||||
|
`${directory}/storage-state.json`,
|
||||||
|
JSON.stringify({
|
||||||
|
cookies: cookies.map(({ name, value }) => ({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
domain: base.hostname,
|
||||||
|
path: '/',
|
||||||
|
httpOnly: false,
|
||||||
|
secure: base.protocol === 'https:',
|
||||||
|
sameSite: 'Lax'
|
||||||
|
})),
|
||||||
|
origins: []
|
||||||
|
}),
|
||||||
|
{ mode: 0o600 }
|
||||||
|
);
|
||||||
|
console.log('Prepared national and scoped-form fixtures and a private browser session.');
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { mkdtemp, mkdir, cp, rm, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), 'livingdex-grid-tests-'));
|
||||||
|
const port = process.env.PERF_PORT ?? '4185';
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
NODE_ADAPTER: 'true',
|
||||||
|
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER: 'true',
|
||||||
|
POKEDEX_PERFORMANCE: 'true',
|
||||||
|
PERF_FIXTURE_DIR: directory,
|
||||||
|
PERF_BASE_URL: `http://127.0.0.1:${port}`,
|
||||||
|
PORT: port,
|
||||||
|
HOST: '127.0.0.1'
|
||||||
|
};
|
||||||
|
function run(command, args) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const process = spawn(command, args, { env, stdio: 'inherit' });
|
||||||
|
process.once('error', reject);
|
||||||
|
process.once('exit', (code) =>
|
||||||
|
code === 0 ? resolve() : reject(new Error(`${command} failed (${code})`))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let server;
|
||||||
|
let fixture = false;
|
||||||
|
try {
|
||||||
|
await run('npm', ['run', 'build-inject-manifest-node']);
|
||||||
|
fixture = true;
|
||||||
|
await run('node', ['scripts/performance/fixture.mjs']);
|
||||||
|
server = spawn('node', ['build'], { env, stdio: 'inherit' });
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
if ((await fetch(env.PERF_BASE_URL)).ok) {
|
||||||
|
clearInterval(timer);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, 200);
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
clearInterval(timer);
|
||||||
|
reject(new Error('Local production server did not start'));
|
||||||
|
}, 15000);
|
||||||
|
server.once('error', (error) => {
|
||||||
|
clearInterval(timer);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
server.once('exit', (code) => {
|
||||||
|
clearInterval(timer);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(new Error(`Local server exited (${code})`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (process.env.PERF_BENCHMARK === 'true') {
|
||||||
|
const data = JSON.parse(await readFile(path.join(directory, 'fixture.json'), 'utf8'));
|
||||||
|
await writeFile(
|
||||||
|
path.join(directory, 'benchmark-config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
samples: 30,
|
||||||
|
revision: process.env.PERF_REVISION ?? 'local-working-tree',
|
||||||
|
databaseLabel: 'local-seeded-supabase',
|
||||||
|
clientLocation: 'local-loopback',
|
||||||
|
environment: 'local-node',
|
||||||
|
hosts: [
|
||||||
|
{
|
||||||
|
label: 'node',
|
||||||
|
url: env.PERF_BASE_URL,
|
||||||
|
storageState: path.join(directory, 'storage-state.json'),
|
||||||
|
fixtures: data.dexes.map((dex) => ({
|
||||||
|
id: dex.id,
|
||||||
|
name: dex.name,
|
||||||
|
label: dex.gameScope ? 'scoped-forms' : 'national'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await run('node', [
|
||||||
|
'scripts/performance/benchmark.mjs',
|
||||||
|
path.join(directory, 'benchmark-config.json'),
|
||||||
|
path.join(directory, 'benchmark.json')
|
||||||
|
]);
|
||||||
|
await mkdir('test-results/performance', { recursive: true });
|
||||||
|
await cp(path.join(directory, 'benchmark.json'), 'test-results/performance/benchmark.json');
|
||||||
|
}
|
||||||
|
await run('node', ['scripts/performance/verify.mjs']);
|
||||||
|
await run('node', ['scripts/performance/verify-behavior.mjs']);
|
||||||
|
await mkdir('test-results/performance', { recursive: true });
|
||||||
|
for (const file of ['verification.json', 'behavior.json', 'national.png', 'scoped.png'])
|
||||||
|
await cp(path.join(directory, file), path.join('test-results/performance', file));
|
||||||
|
} finally {
|
||||||
|
server?.kill('SIGTERM');
|
||||||
|
if (fixture)
|
||||||
|
await run('node', ['scripts/performance/fixture.mjs', '--cleanup']).catch((error) => {
|
||||||
|
throw new Error(`Fixture cleanup failed; retained recovery files in ${directory}`, {
|
||||||
|
cause: error
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
const dir = process.env.PERF_FIXTURE_DIR ?? '/tmp/livingdex-grid-performance';
|
||||||
|
const fixture = JSON.parse(await readFile(`${dir}/fixture.json`, 'utf8'));
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const context = await browser.newContext({
|
||||||
|
baseURL: process.env.PERF_BASE_URL ?? 'http://127.0.0.1:4173',
|
||||||
|
storageState: `${dir}/storage-state.json`,
|
||||||
|
viewport: { width: 1350, height: 940 }
|
||||||
|
});
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
window.__cls = 0;
|
||||||
|
new PerformanceObserver((list) => {
|
||||||
|
for (const entry of list.getEntries()) if (!entry.hadRecentInput) window.__cls += entry.value;
|
||||||
|
}).observe({ type: 'layout-shift', buffered: true });
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
const national = fixture.dexes.find((dex) => !dex.gameScope);
|
||||||
|
const scoped = fixture.dexes.find((dex) => dex.gameScope);
|
||||||
|
const results = [];
|
||||||
|
try {
|
||||||
|
await page.goto(`/pokedex/${national.id}`);
|
||||||
|
await page.waitForSelector('[data-grid-interactive]');
|
||||||
|
for (const width of [1350, 390]) {
|
||||||
|
await page.setViewportSize({ width, height: 940 });
|
||||||
|
for (const density of ['comfortable', 'compact', 'ultra']) {
|
||||||
|
await page.getByLabel('Choose box view layout density').selectOption(density);
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector('[data-grid-interactive]');
|
||||||
|
await page.waitForTimeout(350);
|
||||||
|
assert.equal(await page.getByLabel('Choose box view layout density').inputValue(), density);
|
||||||
|
const geometry = await page.evaluate(() => {
|
||||||
|
const cell = document.querySelector('[data-entry-index="0"]').getBoundingClientRect();
|
||||||
|
const shell = document.querySelector('[data-box-number="1"]').getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
cellWidth: cell.width,
|
||||||
|
cellHeight: cell.height,
|
||||||
|
cls: window.__cls,
|
||||||
|
horizontalOverflow: document.documentElement.scrollWidth > innerWidth,
|
||||||
|
shellHeight: shell.height
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.ok(Math.abs(geometry.cellWidth - geometry.cellHeight) < 1, JSON.stringify(geometry));
|
||||||
|
assert.ok(!geometry.horizontalOverflow, `Horizontal overflow at ${width}/${density}`);
|
||||||
|
assert.ok(geometry.cls <= 0.1, `CLS ${geometry.cls} at ${width}/${density}`);
|
||||||
|
results.push({ width, density, ...geometry });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.setViewportSize({ width: 1350, height: 940 });
|
||||||
|
await page.getByLabel('Choose box view layout density').selectOption('comfortable');
|
||||||
|
await page.getByLabel('Not caught', { exact: true }).check();
|
||||||
|
assert.equal(await page.locator('[data-entry-index="0"]').getAttribute('aria-disabled'), 'true');
|
||||||
|
assert.equal(await page.locator('[data-entry-index="0"]').getAttribute('data-entry-id'), '1');
|
||||||
|
await page.getByLabel('Not caught', { exact: true }).uncheck();
|
||||||
|
// Resizing across the mobile breakpoint keeps the same box at the scroll anchor.
|
||||||
|
await page.locator('[data-box-number="15"]').evaluate((node) => node.scrollIntoView());
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
const anchorTop = await page
|
||||||
|
.locator('[data-box-number="15"]')
|
||||||
|
.evaluate((node) => node.getBoundingClientRect().top);
|
||||||
|
await page.setViewportSize({ width: 390, height: 940 });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
const resizedTop = await page
|
||||||
|
.locator('[data-box-number="15"]')
|
||||||
|
.evaluate((node) => node.getBoundingClientRect().top);
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(anchorTop - resizedTop) < 2,
|
||||||
|
`Scroll anchor moved: ${anchorTop} to ${resizedTop}`
|
||||||
|
);
|
||||||
|
await page.setViewportSize({ width: 1350, height: 940 });
|
||||||
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||||
|
await page.waitForSelector('[data-entry-index="1024"]');
|
||||||
|
assert.ok((await page.locator('[data-entry-index]').count()) < 300);
|
||||||
|
await page.locator('[data-entry-index="1024"]').focus();
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() => document.activeElement.getAttribute('data-entry-index')),
|
||||||
|
'1024'
|
||||||
|
);
|
||||||
|
await page.evaluate(() => window.scrollTo(0, 0));
|
||||||
|
await page.waitForSelector('[data-entry-index="0"]');
|
||||||
|
// Closing an in-flight modal must prevent its response from replacing the next selection.
|
||||||
|
let release;
|
||||||
|
const held = new Promise((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
await page.route(`**/api/pokedexes/${national.id}/entries/1`, async (route) => {
|
||||||
|
await held;
|
||||||
|
await route.continue().catch(() => {});
|
||||||
|
});
|
||||||
|
await page.locator('[data-entry-index="0"]').click();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click();
|
||||||
|
await page.locator('[data-entry-index="1"]').click();
|
||||||
|
release();
|
||||||
|
await page.getByRole('dialog').getByLabel('Notes:', { exact: true }).waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await page.getByRole('dialog').getByRole('heading', { name: 'Ivysaur', exact: true }).count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.getByRole('dialog').getByRole('heading', { name: 'Bulbasaur', exact: true }).count(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click();
|
||||||
|
await page.unrouteAll({ behavior: 'wait' });
|
||||||
|
// Navigate through actual list cards, preserving fixture-specific totals and positions.
|
||||||
|
await page.getByRole('link', { name: 'My Pokédexes' }).click();
|
||||||
|
await page
|
||||||
|
.locator('.card')
|
||||||
|
.filter({ hasText: scoped.name })
|
||||||
|
.first()
|
||||||
|
.getByRole('button', { name: 'View', exact: true })
|
||||||
|
.click();
|
||||||
|
await page.waitForSelector('[data-grid-interactive]');
|
||||||
|
assert.ok((await page.locator('body').innerText()).includes('Showing 439 of 439'));
|
||||||
|
await page.goBack();
|
||||||
|
await page
|
||||||
|
.locator('.card')
|
||||||
|
.filter({ hasText: national.name })
|
||||||
|
.first()
|
||||||
|
.getByRole('button', { name: 'View', exact: true })
|
||||||
|
.click();
|
||||||
|
await page.waitForSelector('[data-grid-interactive]');
|
||||||
|
// Wait for the existing worker's full snapshot, then read an uncached detail without a network.
|
||||||
|
await page.waitForFunction(
|
||||||
|
async () => {
|
||||||
|
const meta = await (
|
||||||
|
await caches.open('livingdex-offline-meta-v1')
|
||||||
|
).match('/__offline/current');
|
||||||
|
return !!(await meta?.json())?.dataCache;
|
||||||
|
},
|
||||||
|
{ timeout: 15000 }
|
||||||
|
);
|
||||||
|
await context.setOffline(true);
|
||||||
|
await page.locator('[data-entry-index="5"]').click();
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
document.querySelector('[role="dialog"]')?.textContent.includes('Where to catch:')
|
||||||
|
);
|
||||||
|
assert.equal(await page.getByRole('dialog').locator('textarea').count(), 0);
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click();
|
||||||
|
// A mismatched cache owner is never used as fallback.
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const cache = await caches.open('livingdex-offline-meta-v1');
|
||||||
|
const response = await cache.match('/__offline/current');
|
||||||
|
const meta = await response.json();
|
||||||
|
await cache.put(
|
||||||
|
'/__offline/current',
|
||||||
|
new Response(JSON.stringify({ ...meta, userId: 'other-account' }))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await page.locator('[data-entry-index="6"]').click();
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'not saved for offline use' }).waitFor();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click();
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
await writeFile(`${dir}/behavior.json`, JSON.stringify(results, null, 2));
|
||||||
|
console.log(
|
||||||
|
'Passed density/mobile geometry, filtered placement, virtual boundaries, modal races, client navigation and isolated offline details.'
|
||||||
|
);
|
||||||
|
console.log(JSON.stringify(results, null, 2));
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
const directory = process.env.PERF_FIXTURE_DIR ?? '/tmp/livingdex-grid-performance';
|
||||||
|
const fixture = JSON.parse(await readFile(`${directory}/fixture.json`, 'utf8'));
|
||||||
|
const baseURL = process.env.PERF_BASE_URL ?? 'http://127.0.0.1:4173';
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({
|
||||||
|
baseURL,
|
||||||
|
storageState: `${directory}/storage-state.json`,
|
||||||
|
viewport: { width: 1350, height: 940 }
|
||||||
|
});
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
window.__layoutShifts = [];
|
||||||
|
window.__firstVisible = null;
|
||||||
|
new PerformanceObserver((list) => {
|
||||||
|
for (const entry of list.getEntries())
|
||||||
|
if (!entry.hadRecentInput) window.__layoutShifts.push(entry.value);
|
||||||
|
}).observe({ type: 'layout-shift', buffered: true });
|
||||||
|
function visible() {
|
||||||
|
const cell = document.querySelector('[data-entry-index]');
|
||||||
|
if (
|
||||||
|
cell &&
|
||||||
|
cell.getBoundingClientRect().height > 0 &&
|
||||||
|
cell.getBoundingClientRect().top < innerHeight
|
||||||
|
)
|
||||||
|
window.__firstVisible ??= performance.now();
|
||||||
|
if (window.__firstVisible === null) requestAnimationFrame(visible);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(visible);
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
const readJson = (path) =>
|
||||||
|
page.evaluate(async (path) => {
|
||||||
|
const response = await fetch(path);
|
||||||
|
if (!response.ok) throw new Error(`Fixture API failed: ${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
}, path);
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
const requests = [];
|
||||||
|
page.on('request', (request) => requests.push(new URL(request.url()).pathname));
|
||||||
|
const results = [];
|
||||||
|
try {
|
||||||
|
for (const dex of fixture.dexes) {
|
||||||
|
requests.length = 0;
|
||||||
|
const response = await page.goto(`/pokedex/${dex.id}`);
|
||||||
|
assert.equal(response.status(), 200);
|
||||||
|
const html = await response.text();
|
||||||
|
assert.ok(/data-entry-index=["']?0["'\s>]/.test(html), 'Initial cells must be server rendered');
|
||||||
|
await page.waitForSelector('[data-grid-interactive]');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
const stats = await page.evaluate(() => ({
|
||||||
|
cells: document.querySelectorAll('[data-entry-index]').length,
|
||||||
|
dom: document.querySelectorAll('*').length,
|
||||||
|
cls: window.__layoutShifts.reduce((sum, value) => sum + value, 0),
|
||||||
|
firstVisible: window.__firstVisible,
|
||||||
|
interactive: performance.getEntriesByName('pokedex:first-interactive').at(-1)?.startTime
|
||||||
|
}));
|
||||||
|
assert.ok(stats.cells <= 180, `Mounted cells: ${stats.cells}`);
|
||||||
|
assert.ok(stats.dom < 2500, `DOM elements: ${stats.dom}`);
|
||||||
|
assert.ok(stats.cls <= 0.1, `CLS: ${stats.cls}`);
|
||||||
|
assert.equal(
|
||||||
|
requests.filter((path) => /\/api\/pokedexes\/[^/]+\/(grid|combined-data)$/.test(path)).length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert.equal(await page.locator('button button').count(), 0);
|
||||||
|
await page.screenshot({ path: `${directory}/${dex.gameScope ? 'scoped' : 'national'}.png` });
|
||||||
|
const first = page.locator('[data-entry-index="0"]');
|
||||||
|
const entryId = await first.getAttribute('data-entry-id');
|
||||||
|
const notes = await readJson(`/api/pokedexes/${dex.id}/entries/${entryId}`);
|
||||||
|
await first.click();
|
||||||
|
const modal = page.getByRole('dialog', { name: 'Pokémon details' });
|
||||||
|
await modal.getByLabel('Notes:', { exact: true }).waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await modal.getByLabel('Notes:', { exact: true }).inputValue(),
|
||||||
|
notes.catchRecord.personalNotes
|
||||||
|
);
|
||||||
|
const image = modal.locator('img').first();
|
||||||
|
await image.waitFor();
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.querySelector('[role="dialog"] img')?.naturalWidth > 0
|
||||||
|
);
|
||||||
|
assert.equal(await image.evaluate((node) => node.naturalWidth), 512);
|
||||||
|
assert.ok(!(await image.getAttribute('src')).includes('sprites-grid'));
|
||||||
|
await modal.getByRole('button', { name: 'Close', exact: true }).click();
|
||||||
|
assert.equal(await first.evaluate((node) => document.activeElement === node), true);
|
||||||
|
// Reopening uses cached details; bulk edits must retain the unloaded personal notes.
|
||||||
|
const detailCalls = () =>
|
||||||
|
requests.filter((path) => path.endsWith(`/entries/${entryId}`)).length;
|
||||||
|
const before = detailCalls();
|
||||||
|
await first.click();
|
||||||
|
await modal.getByLabel('Notes:', { exact: true }).waitFor();
|
||||||
|
assert.equal(detailCalls(), before);
|
||||||
|
await modal.getByRole('button', { name: 'Close', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Open bulk actions menu' }).first().click();
|
||||||
|
await page.getByRole('button', { name: 'Mark box as In HOME', exact: true }).click();
|
||||||
|
await page.waitForResponse(
|
||||||
|
(r) =>
|
||||||
|
r.url().endsWith(`/pokedexes/${dex.id}/catch-records`) && r.request().method() === 'POST'
|
||||||
|
);
|
||||||
|
const after = await readJson(`/api/pokedexes/${dex.id}/entries/${entryId}`);
|
||||||
|
assert.equal(after.catchRecord.personalNotes, notes.catchRecord.personalNotes);
|
||||||
|
assert.equal(after.catchRecord.inHome, true);
|
||||||
|
// Focus navigation must reach entries that were not initially mounted.
|
||||||
|
await page.locator('[data-entry-index="29"]').focus();
|
||||||
|
for (let index = 0; index < 22; index++) await page.keyboard.press('ArrowDown');
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() => document.activeElement?.getAttribute('data-entry-index')),
|
||||||
|
'161'
|
||||||
|
);
|
||||||
|
await page.getByLabel('Render all boxes').check();
|
||||||
|
const grid = await readJson(`/api/pokedexes/${dex.id}/grid`);
|
||||||
|
assert.equal(await page.locator('[data-entry-index]').count(), grid.grid.length);
|
||||||
|
await page.getByLabel('Render all boxes').uncheck();
|
||||||
|
results.push({
|
||||||
|
fixture: dex.gameScope ? 'scoped-forms' : 'national',
|
||||||
|
...stats,
|
||||||
|
documentBytes: Buffer.byteLength(html),
|
||||||
|
gridBytes: Buffer.byteLength(JSON.stringify(grid))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert.deepEqual(errors, [], 'Browser errors');
|
||||||
|
await writeFile(`${directory}/verification.json`, JSON.stringify(results, null, 2));
|
||||||
|
console.log(JSON.stringify(results, null, 2));
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user