From eb6aae7ae7470300c7c0a2ca2ae84524e26fa63b Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Wed, 7 Oct 2026 17:21:18 +0800 Subject: [PATCH] Add hidden timers Playwright verification script --- perf/hidden-timers.mjs | 208 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 perf/hidden-timers.mjs diff --git a/perf/hidden-timers.mjs b/perf/hidden-timers.mjs new file mode 100644 index 0000000..0d5885c --- /dev/null +++ b/perf/hidden-timers.mjs @@ -0,0 +1,208 @@ +#!/usr/bin/env node + +/** + * perf/hidden-timers.mjs + * + * Verifies that non-playback UI intervals are paused when the page is hidden + * and restored when the page becomes visible again. + * + * Assertions: + * - While hidden: none of [chips, reanchor, rotateChips, flush, checkBuildTag] is live. + * - After becoming visible again: all five gated intervals are live. + * - Must-run intervals (e.g. maybeCheck, 120s resume check) remain live while hidden. + * - No page errors during lifecycle transitions. + * - Exits non-zero on failure. + */ + +import { spawn } from 'node:child_process'; +import { existsSync, mkdirSync, rmSync, symlinkSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const REPO_ROOT = path.resolve(__dirname, '..'); + +let pw; +try { + pw = await import('playwright'); +} catch { + pw = await import(path.join(REPO_ROOT, 'node_modules', 'playwright', 'index.mjs')); +} +const { chromium } = pw; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function main() { + const scratchDir = path.join(REPO_ROOT, 'perf', '.tmp', `hidden-timers-${Date.now()}`); + mkdirSync(scratchDir, { recursive: true }); + const dataDir = path.join(scratchDir, 'data'); + mkdirSync(dataDir, { recursive: true }); + const srvCwd = path.join(scratchDir, 'srv'); + mkdirSync(srvCwd, { recursive: true }); + const publicLink = path.join(srvCwd, 'public'); + symlinkSync(path.join(REPO_ROOT, 'frontend'), publicLink); + + const port = 4900 + Math.floor(Math.random() * 2000); + const serverProcess = spawn('bun', [path.join(REPO_ROOT, 'server', 'server.js')], { + cwd: srvCwd, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + DB_PATH: path.join(dataDir, 'ytplayer.db'), + MEDIA_DIR: path.join(dataDir, 'media'), + }, + stdio: 'ignore', + }); + + const cleanup = () => { + try { + if (serverProcess) serverProcess.kill('SIGKILL'); + } catch {} + try { + rmSync(scratchDir, { recursive: true, force: true }); + } catch {} + }; + + process.on('SIGINT', () => { cleanup(); process.exit(1); }); + process.on('SIGTERM', () => { cleanup(); process.exit(1); }); + + let browser; + try { + let ready = false; + for (let i = 0; i < 50; i++) { + try { + const res = await fetch(`http://127.0.0.1:${port}/api/version`); + if (res.ok) { + ready = true; + break; + } + } catch {} + await sleep(100); + } + if (!ready) { + throw new Error(`Bun server failed to start on port ${port}`); + } + + browser = await chromium.launch({ headless: true }); + const ctx = await browser.newContext({ + viewport: { width: 390, height: 844 }, + hasTouch: true, + isMobile: true, + serviceWorkers: 'block', + }); + + await ctx.addInitScript(() => { + const act = new Map(); + const si = window.setInterval.bind(window); + const ci = window.clearInterval.bind(window); + window.setInterval = (fn, ms, ...a) => { + const id = si(fn, ms, ...a); + act.set(id, (fn && fn.name) || 'anon' + ms); + return id; + }; + window.clearInterval = (id) => { + act.delete(id); + return ci(id); + }; + window.__names = () => [...act.values()].sort(); + }); + + const page = await ctx.newPage(); + const pageErrors = []; + page.on('pageerror', (err) => pageErrors.push(err.message)); + + await page.goto(`http://127.0.0.1:${port}/index.html`); + await page.waitForTimeout(1500); + + const getNames = async () => (await page.evaluate(() => window.__names())); + const gated = ['chips', 'reanchor', 'rotateChips', 'flush', 'checkBuildTag']; + const mustRun = ['maybeCheck', 'anon120000']; + + const vt = await page.evaluate(() => typeof window.VisibleTimer); + if (vt !== 'object') { + throw new Error(`VisibleTimer not available on window (got ${vt})`); + } + + // 1. Initial visible state + const visible1 = await getNames(); + const visibleGated = gated.filter((g) => visible1.includes(g)); + + // 2. Transition to hidden + await page.evaluate(() => { + Object.defineProperty(document, 'hidden', { configurable: true, get: () => true }); + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' }); + document.dispatchEvent(new Event('visibilitychange')); + }); + await page.waitForTimeout(300); + const hidden = await getNames(); + const hiddenGated = gated.filter((g) => hidden.includes(g)); + + // 3. Transition back to visible + await page.evaluate(() => { + Object.defineProperty(document, 'hidden', { configurable: true, get: () => false }); + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' }); + document.dispatchEvent(new Event('visibilitychange')); + }); + await page.waitForTimeout(300); + const visible2 = await getNames(); + const backVisibleGated = gated.filter((g) => visible2.includes(g)); + + console.log('=== Hidden Timers Verification ==='); + console.log(`Initial visible intervals (${visible1.length}):`, visible1.join(', ')); + console.log(`Hidden intervals (${hidden.length}):`, hidden.join(', ')); + console.log(`Restored visible intervals (${visible2.length}):`, visible2.join(', ')); + console.log('Page errors:', pageErrors.length ? pageErrors.join(', ') : 'none'); + + // Assertions + const failures = []; + + // Assert all 5 gated timers were live initially + for (const name of gated) { + if (!visible1.includes(name)) { + failures.push(`Expected '${name}' to be live initially`); + } + } + + // Assert none of the 5 gated timers is live while hidden + if (hiddenGated.length > 0) { + failures.push(`Expected 0 gated timers while hidden, but found: ${hiddenGated.join(', ')}`); + } + + // Assert all 5 gated timers are live after becoming visible again + for (const name of gated) { + if (!visible2.includes(name)) { + failures.push(`Expected '${name}' to be live after becoming visible again`); + } + } + + // Assert must-run timers stay live while hidden + for (const name of mustRun) { + if (!hidden.includes(name)) { + failures.push(`Expected must-run timer '${name}' to stay live while hidden`); + } + } + + // Assert no page errors + if (pageErrors.length > 0) { + failures.push(`Encountered page errors: ${pageErrors.join('; ')}`); + } + + if (failures.length > 0) { + console.error('\nFAILURES:'); + for (const f of failures) console.error(` - ${f}`); + process.exitCode = 1; + } else { + console.log('\nResult: ALL ASSERTIONS PASSED'); + } + } finally { + if (browser) await browser.close(); + cleanup(); + } +} + +main().catch((err) => { + console.error('Fatal error in hidden-timers check:', err); + process.exit(1); +});