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:
Josh Creek
2026-09-15 17:47:21 +01:00
parent 2766e5ea67
commit 2042e5a65a
8 changed files with 766 additions and 2 deletions
+181
View File
@@ -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();
}
+46
View File
@@ -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
)
);
+113
View File
@@ -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.');
+105
View File
@@ -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 });
}
+165
View File
@@ -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();
}
+129
View File
@@ -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();
}