#!/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); });