From 90ff4e0b179001f0b365db5b9cdf0cf96d311913 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:12:46 +0100 Subject: [PATCH] test(web): establish single-session browser freeze control Compare forced focus with an independent unforced target using one CDP owner. Retain observed timer suspension and prior adverse evidence without claiming installed incident recovery. Change-source: pulse-maintainer --- .../browser-tests/lifecycle-control.md | 36 +++++++ .../check-browser-single-session-control.mjs | 98 +++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 scripts/check-browser-single-session-control.mjs diff --git a/frontend-modern/browser-tests/lifecycle-control.md b/frontend-modern/browser-tests/lifecycle-control.md index 1cca869b5..b234bdf04 100644 --- a/frontend-modern/browser-tests/lifecycle-control.md +++ b/frontend-modern/browser-tests/lifecycle-control.md @@ -24,3 +24,39 @@ 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. + +## Single-owner control — 8 September 2026 + +`pulse-heavy-run -- node scripts/check-browser-single-session-control.mjs` +launches the installed Chromium executable selected by Playwright, but uses one +raw CDP session per owned blank target. It does not attach a Playwright page +session, modify dependencies, or load Pulse. The preselected comparison is forced +focus (negative control) versus no forced focus (positive control), with fresh +targets. Both must meet their assertions for exit 0. Protocol calls have bounded +timeouts; the temporary profile and owned browser are cleaned up. + +Fresh [Playwright 1.56.1 source](https://raw.githubusercontent.com/microsoft/playwright/v1.56.1/packages/playwright-core/src/server/chromium/crPage.ts) +shows focus emulation enabled on its main-frame session. This suggested a +competing-session confounder in the earlier test, not another reason to repeat +false/true-to-false commands on the separate diagnostic session. +[Chrome lifecycle guidance](https://developer.chrome.com/docs/web-platform/page-lifecycle-api) +describes timer suspension in frozen pages. These sources informed the control; +only observed timer and event behaviour determines success. + +Observed on Chrome 141.0.7390.37, revision +`9f043f63b0e5b728c8d09f3e3ddfc1681a4bd58e`, Playwright 1.56.1, +Node v24.20.0, linux x64: + +- Forced focus: ticks 5 → 52, visible/focused, no lifecycle events. +- No forced focus: ticks 5 → 6; visibilitychange to hidden, freeze at tick 5, + resume at tick 5, then timer progress. All commands acknowledged; exit 0. + +The resumed target remained hidden/unfocused. This proves freeze/resume, **not** +foreground return, visibility restoration, incident convergence, or an installed +release. Preserve the earlier failed experiment: this uses a different protocol +ownership arrangement, not a passing rerun of that intervention. A Pulse browser +scenario must retain this ownership arrangement and its timer/event probe; adding +a Playwright page attachment may reintroduce the confounder. Before testing +foreground convergence, separately observe return to visible. Installed acceptance +still requires the authorised synthetic installation and exact byte identities +listed in `alert-recovery-acceptance.md`; neither is supplied by this control. diff --git a/scripts/check-browser-single-session-control.mjs b/scripts/check-browser-single-session-control.mjs new file mode 100644 index 000000000..e05385903 --- /dev/null +++ b/scripts/check-browser-single-session-control.mjs @@ -0,0 +1,98 @@ +// Owned blank-page diagnostic; no Playwright page session or application loaded. +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createRequire } from 'node:module'; +import { chromium } from '@playwright/test'; +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); +const profile = await mkdtemp(join(tmpdir(), 'pulse-lifecycle-')); +const child = spawn(chromium.executablePath(), ['--headless', '--no-sandbox', + '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank']); +let socket; +try { + const endpoint = await new Promise((resolve, reject) => { + let stderr = ''; + const timer = setTimeout(() => reject(new Error('Browser endpoint timeout')), 10000); + child.once('error', reject); + child.stderr.on('data', data => { + stderr += data; + const match = stderr.match(/DevTools listening on (ws:\/\/\S+)/); + if (match) { clearTimeout(timer); resolve(match[1]); } + }); + }); + socket = new WebSocket(endpoint); + await new Promise((resolve, reject) => { + socket.addEventListener('open', resolve, { once: true }); + socket.addEventListener('error', reject, { once: true }); + }); + let id = 0; + const pending = new Map(); + socket.addEventListener('message', ({ data }) => { + const message = JSON.parse(data); + if (!pending.has(message.id)) return; + const { resolve, reject, timer } = pending.get(message.id); + pending.delete(message.id); clearTimeout(timer); + if (message.error) reject(new Error(JSON.stringify(message.error))); + else resolve(message.result); + }); + const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => { + const requestId = ++id; + const timer = setTimeout(() => { pending.delete(requestId); reject(new Error(`${method} timeout`)); }, 10000); + pending.set(requestId, { resolve, reject, timer }); + socket.send(JSON.stringify({ id: requestId, method, params, sessionId })); + }); + const require = createRequire(import.meta.url); + console.log(JSON.stringify({ browser: await send('Browser.getVersion'), + playwright: require('@playwright/test/package.json').version, node: process.version, + platform: process.platform, arch: process.arch, executable: chromium.executablePath() })); + const results = []; + // Preselected negative (focus forced) and positive (no forced focus), fresh targets. + for (const focusEnabled of [true, false]) { + const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); + try { + const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); + const commands = []; + const command = async (method, params) => { + const result = await send(method, params, sessionId); + commands.push({ method, params, response: result }); + return result; + }; + const evaluate = async expression => { + const result = await send('Runtime.evaluate', { expression, returnByValue: true }, sessionId); + if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); + return result.result.value; + }; + await command('Page.enable', {}); + await command('Emulation.setFocusEmulationEnabled', { enabled: focusEnabled }); + await 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 snapshot = `({...window.probe, visibility: document.visibilityState, focus: document.hasFocus()})`; + await wait(250); + const before = await evaluate(snapshot); + await command('Page.setWebLifecycleState', { state: 'frozen' }); + await wait(2100); // Host wait: never evaluate while frozen. + await command('Page.setWebLifecycleState', { state: 'active' }); + await wait(250); + const after = await evaluate(snapshot); + const freeze = after.events.find(e => e.type === 'freeze'); + const resume = after.events.find(e => e.type === 'resume'); + const suspended = Boolean(freeze && resume && freeze.ticks === resume.ticks && + after.events.indexOf(freeze) < after.events.indexOf(resume) && after.ticks > resume.ticks); + const result = { focusEnabled, before, after, commands, suspended }; + results.push(result); console.log(JSON.stringify(result)); + } finally { await send('Target.closeTarget', { targetId }); } + } + assert.ok(!results[0].suspended && results[0].after.ticks - results[0].before.ticks >= 20, + 'Negative control must continue ticking'); + assert.ok(results[1].suspended, 'Positive control must suspend and resume timers'); +} finally { + socket?.close(); + const exited = new Promise(resolve => child.once('exit', resolve)); + if (child.exitCode === null) { child.kill(); await exited; } + await rm(profile, { recursive: true }); +}