209 lines
6.8 KiB
JavaScript
209 lines
6.8 KiB
JavaScript
#!/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);
|
|
});
|