mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-16 18:42:03 +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()
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
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-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: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",
|
||||
"supabase:start": "supabase start",
|
||||
"supabase:stop": "supabase stop",
|
||||
@@ -48,7 +48,8 @@
|
||||
"sprites:grid": "node scripts/grid-thumbnails.mjs",
|
||||
"build:cloudflare": "DEPLOY_TARGET=cloudflare npm run build",
|
||||
"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": {
|
||||
"@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