diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fd24429..a1b4036 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 diff --git a/package.json b/package.json index e2a3f10..a44e3b3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/performance/benchmark.mjs b/scripts/performance/benchmark.mjs new file mode 100644 index 0000000..c235e04 --- /dev/null +++ b/scripts/performance/benchmark.mjs @@ -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(); +} diff --git a/scripts/performance/compare.mjs b/scripts/performance/compare.mjs new file mode 100644 index 0000000..829ccb6 --- /dev/null +++ b/scripts/performance/compare.mjs @@ -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 + ) +); diff --git a/scripts/performance/fixture.mjs b/scripts/performance/fixture.mjs new file mode 100644 index 0000000..4032b07 --- /dev/null +++ b/scripts/performance/fixture.mjs @@ -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.'); diff --git a/scripts/performance/run-local.mjs b/scripts/performance/run-local.mjs new file mode 100644 index 0000000..eff5e0c --- /dev/null +++ b/scripts/performance/run-local.mjs @@ -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 }); +} diff --git a/scripts/performance/verify-behavior.mjs b/scripts/performance/verify-behavior.mjs new file mode 100644 index 0000000..1b476df --- /dev/null +++ b/scripts/performance/verify-behavior.mjs @@ -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(); +} diff --git a/scripts/performance/verify.mjs b/scripts/performance/verify.mjs new file mode 100644 index 0000000..f5261f8 --- /dev/null +++ b/scripts/performance/verify.mjs @@ -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(); +}