diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md index f3aede4e4..4af89eeee 100644 --- a/changelog.d/1282-polling-issue-fix.md +++ b/changelog.d/1282-polling-issue-fix.md @@ -2,3 +2,5 @@ - Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282). - Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282). - Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282). +- Keep visible-page Transit probes focused through the browser testing API (asturksever, #1282). +- Add a real-browser regression gate for hidden-tab periodic work, explicit refresh, resume and teardown (asturksever, #1282). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 508b6264d..3eacee1c6 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4002,6 +4002,8 @@ animation. Polls, surface completions and trail fades request individual renders Disable clears vehicles, detection references, requests, timers and holds; destruction also releases collections and registrations. +The `scripts/qa-layer-visibility.mjs` gate uses the development-only registration seam and real browser tab visibility to prove periodic work pauses while hidden, explicit refresh still works, resume performs one overdue refresh, and disabled layers stay stopped. + **Acceptance harness.** Run against an already running development server: ```sh diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 543b0f9a4..b894cbe9a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1070,5 +1070,6 @@ "scripts/assemble-changelog.mjs", "src/tooling/changelogFragments.test.mjs", "src/data/gbfsProxyRateLimit.test.mjs", - "src/terrainHeightsBounds.test.mjs" + "src/terrainHeightsBounds.test.mjs", + "scripts/qa-layer-visibility.mjs" ] diff --git a/scripts/qa-layer-visibility.mjs b/scripts/qa-layer-visibility.mjs new file mode 100644 index 000000000..8a903918e --- /dev/null +++ b/scripts/qa-layer-visibility.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +/** + * Real-browser proof for manager-owned periodic work and document visibility. + * Uses the app's development-only layer registration seam; no provider keys. + * Run: node scripts/qa-layer-visibility.mjs --url http://localhost:4173 + * Hiding uses a real background tab, never a patched document.hidden getter. + */ +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; + +const args = process.argv.slice(2); +const urlIndex = args.indexOf('--url'); +const url = new URL( + (urlIndex >= 0 ? args[urlIndex + 1] : undefined) || + process.env.QA_BASE_URL || + 'http://localhost:4173', +); +url.searchParams.set('welcome', '0'); +const browser = await puppeteer.launch({ + executablePath: chromeExecutablePath(), + headless: true, + args: [ + '--no-sandbox', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + ], +}); +const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const check = (name, actual, expected) => { + assert.equal(actual, expected, name); + console.log(`PASS ${name}: ${actual}`); +}; +try { + const page = await browser.newPage(); + const focus = await page.createCDPSession(); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); + await page.goto(url.href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => window.__gevQaRegisterLayer && window.__godsEyeView?.dataManager, + ); + await page.evaluate(async () => { + const manager = window.__godsEyeView.dataManager; + window.__qaVisibilityUpdates = 0; + window.__gevQaRegisterLayer(manager, { + id: 'qa-document-visibility', + name: 'Visibility QA', + updateInterval: 750, + init() {}, + enable() {}, + disable() {}, + destroy() {}, + async update() { + window.__qaVisibilityUpdates++; + return true; + }, + getStats() { + return { count: window.__qaVisibilityUpdates }; + }, + }); + await manager.setEnabled('qa-document-visibility', true, { origin: 'qa' }); + }); + const count = () => page.evaluate(() => window.__qaVisibilityUpdates); + await page.waitForFunction(() => window.__qaVisibilityUpdates >= 3); + check('periodic updates run while visible', (await count()) >= 3, true); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false }); + const cover = await browser.newPage(); + await cover.bringToFront(); + await page.waitForFunction(() => document.visibilityState === 'hidden'); + const hiddenCount = await count(); + await pause(2200); + check('hidden tab makes no periodic updates', await count(), hiddenCount); + check( + 'explicit refresh remains available while hidden', + await page.evaluate(() => + window.__godsEyeView.dataManager.refreshLayer('qa-document-visibility'), + ), + true, + ); + check('explicit hidden refresh runs once', await count(), hiddenCount + 1); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); + await page.waitForFunction( + () => + document.visibilityState === 'visible' && + window.__qaVisibilityUpdates > 0, + ); + await pause(200); + check( + 'resume performs one overdue refresh without a catch-up burst', + await count(), + hiddenCount + 2, + ); + await page.evaluate(() => + window.__godsEyeView.dataManager.setEnabled( + 'qa-document-visibility', + false, + { origin: 'qa' }, + ), + ); + const stopped = await count(); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false }); + await cover.bringToFront(); + await page.waitForFunction(() => document.visibilityState === 'hidden'); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); + await pause(1100); + check( + 'disabled layer does not resume on visibility change', + await count(), + stopped, + ); + check( + 'QA layer destroys cleanly', + await page.evaluate(() => + window.__gevQaUnregisterLayer( + window.__godsEyeView.dataManager, + 'qa-document-visibility', + ), + ), + true, + ); + console.log('RESULT: 7 passed'); +} finally { + await browser.close(); +} diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index 628983e74..47eafc94d 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -275,6 +275,10 @@ const browser = await puppeteer.launch({ ], }); const page = await browser.newPage(); +// This gate measures visible-page motion. OS minimization must not silently +// switch it into the app's intentional hidden-tab polling pause. +const focus = await page.createCDPSession(); +await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); boundPageEvaluations(page); await page.setViewport({ width: 1920, height: 1080 }); const pageErrors = [];