diff --git a/frontend-modern/browser-tests/lifecycle-control.md b/frontend-modern/browser-tests/lifecycle-control.md new file mode 100644 index 000000000..1cca869b5 --- /dev/null +++ b/frontend-modern/browser-tests/lifecycle-control.md @@ -0,0 +1,26 @@ +# Browser suspension diagnostic + +Run `npm ci --ignore-scripts --no-audit --no-fund` at repository root, then +`pulse-heavy-run -- node scripts/check-browser-lifecycle-control.mjs` using an +installed Playwright Chromium. This uses owned blank pages only, no Pulse server +or credentials. It is deliberately not wired into CI or release qualification. + +The comparison is fresh-session focus false versus true then false, each in a +new context. Host-side waits avoid evaluating the frozen page. Success requires +ordered freeze/resume events with identical timer counts and subsequent timer +progress, not merely acknowledged protocol commands. Exit 1 means the +intervention did not establish the control; it does not mean Pulse is broken. + +On 8 September 2026, Chromium 141.0.7390.37 / Playwright 1.56.1 / Node 24.20.0 +returned successful commands for both cases, but each advanced from 5 to 52 +ticks and remained visible/focused without lifecycle events. The intervention +failed. No Pulse application was loaded and no incident-convergence assertion +was exercised. + +The hypothesis came from the version-matched Chromium +[emulation agent source](https://raw.githubusercontent.com/chromium/chromium/141.0.7390.37/third_party/blink/renderer/core/inspector/inspector_emulation_agent.cc): +the equality guard in `setFocusEmulationEnabled` can skip updating the page on +a fresh false request. Avoiding that guard did not establish suspension in +this experiment. Do not continue treating that guard as a sufficient diagnosis. +The next investigation must establish actual page visibility/lifecycle control +under the automation harness before attempting Pulse recovery acceptance. diff --git a/scripts/check-browser-lifecycle-control.mjs b/scripts/check-browser-lifecycle-control.mjs new file mode 100644 index 000000000..405b6b4a9 --- /dev/null +++ b/scripts/check-browser-lifecycle-control.mjs @@ -0,0 +1,61 @@ +// Diagnostic only: proves a blank-page suspension control, not Pulse recovery. +// Run through pulse-heavy-run -- node scripts/check-browser-lifecycle-control.mjs +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { chromium } from '@playwright/test'; + +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const browser = await chromium.launch({ headless: true }); +try { + const require = createRequire(import.meta.url); + console.log(JSON.stringify({ browser: browser.version(), + playwright: require('@playwright/test/package.json').version, + node: process.version, platform: process.platform, arch: process.arch })); + const results = []; + // Preselected comparison; independent contexts prevent carry-over. + for (const transition of [false, true]) { + const context = await browser.newContext(); + try { + const page = await context.newPage(); + await page.evaluate(() => { + window.probe = { ticks: 0, events: [] }; + setInterval(() => window.probe.ticks++, 50); + for (const type of ['freeze', 'resume', 'visibilitychange']) { + document.addEventListener(type, () => window.probe.events.push({ + type, ticks: window.probe.ticks, visibility: document.visibilityState, + })); + } + }); + const session = await context.newCDPSession(page); + const commands = []; + const send = async (method, params) => { + commands.push({ method, params, response: await session.send(method, params) }); + }; + if (transition) await send('Emulation.setFocusEmulationEnabled', { enabled: true }); + await send('Emulation.setFocusEmulationEnabled', { enabled: false }); + await wait(250); + const before = await page.evaluate(() => ({ ...window.probe, + visibility: document.visibilityState, focus: document.hasFocus() })); + await send('Page.setWebLifecycleState', { state: 'frozen' }); + // Do not evaluate the page while frozen. Wait on the host instead. + await wait(2100); + await send('Page.setWebLifecycleState', { state: 'active' }); + await wait(250); + const after = await page.evaluate(() => ({ ...window.probe, + visibility: document.visibilityState, focus: document.hasFocus() })); + const freeze = after.events.find((event) => event.type === 'freeze'); + const resume = after.events.find((event) => event.type === 'resume'); + const valid = Boolean(freeze && resume && freeze.ticks === resume.ticks && + after.events.indexOf(freeze) < after.events.indexOf(resume) && + after.ticks > resume.ticks); + const result = { transition, before, after, commands, valid }; + results.push(result); + console.log(JSON.stringify(result)); + } finally { + await context.close(); + } + } + assert.ok(results[1].valid, 'true-to-false intervention did not establish suspension'); +} finally { + await browser.close(); +}