mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-16 18:42:03 +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.
182 lines
6.4 KiB
JavaScript
182 lines
6.4 KiB
JavaScript
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();
|
|
}
|