Add hidden timers Playwright verification script

This commit is contained in:
Jonathan Sykes
2026-10-07 17:21:18 +08:00
parent 09125f3f5c
commit eb6aae7ae7

208
perf/hidden-timers.mjs Normal file
View File

@@ -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);
});