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
This commit is contained in:
pulse-triage[bot]
2026-09-08 07:12:46 +01:00
parent 3e4b0b1370
commit 90ff4e0b17
2 changed files with 134 additions and 0 deletions
@@ -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.
@@ -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 });
}