mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-16 02:22:17 +00:00
2042e5a65a
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.
130 lines
5.7 KiB
JavaScript
130 lines
5.7 KiB
JavaScript
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();
|
|
}
|