mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
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:
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user