From ad8f7af6c15b303ae0f163d0b2a49d0a773e679a 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:52:15 +0100 Subject: [PATCH] test(web): record headed lifecycle activation control Add an opt-in owned-X-display diagnostic with window restoration and bounded foreground observations. Preserve the failed bare-Xvfb result: resumed timers do not establish restored visibility or Pulse convergence. Change-source: pulse-maintainer --- .../browser-tests/lifecycle-control.md | 40 ++++++++++++++ .../check-browser-single-session-control.mjs | 53 +++++++++++++++---- 2 files changed, 82 insertions(+), 11 deletions(-) diff --git a/frontend-modern/browser-tests/lifecycle-control.md b/frontend-modern/browser-tests/lifecycle-control.md index 65fb8432b..a7fc19009 100644 --- a/frontend-modern/browser-tests/lifecycle-control.md +++ b/frontend-modern/browser-tests/lifecycle-control.md @@ -97,3 +97,43 @@ observations. Repeating this activation unchanged, synthetically dispatching a visibility event, or enabling forced focus would not resolve the evidence gap. This diagnostic is not a release gate and proves nothing about installed incident state or destination receipt. + +## Headed window control — 8 September 2026 + +The opt-in headed diagnostic uses an owned display (never a user's desktop): + +```sh +pulse-heavy-run -- xvfb-run -a -s '-screen 0 1280x800x24 -nolisten tcp' node scripts/check-browser-single-session-control.mjs --headed-window +``` + +[Playwright's headed Linux guidance](https://playwright.dev/docs/ci#running-headed) +identifies Xvfb as the display prerequisite. This is not proof of window-manager +activation. This mode launches real headed Chromium with one raw page session, +without enabling focus emulation. It requests minimisation of the target's owned +window before freeze, restoration after resume, then tab activation. It records +all intervention commands and samples every 250ms for a preselected ten-second +foreground bound. Freeze/resume must be ordered with identical timer counts; +subsequent timer progress is required at foreground, rather than after the old +250ms hidden-page delay. A visible event after resume and visible/focused final +state are independently required. Default/headless checks retain their old bound. + +One run on Chrome 141.0.7390.37 (revision as above), Playwright 1.56.1, +Node v24.20.0, linux x64, Xvfb/xserver-common `2:21.1.12-1ubuntu1.6`, display +`:99`, exited 1 at `Must observe visible transition after resume`: + +- Baseline visible/focused at tick 5. +- Acknowledged minimise request: still visible/focused at tick 10, no event. +- Freeze/resume ordered at tick 10; post-resume hidden/unfocused at tick 10. +- Acknowledged restore and tab activation: hidden/focused throughout the bounded + observation; timers eventually reached tick 20, no visible event. + +Thus the short timer bound is no longer the immediate failed assertion, but +headed launch alone did not establish visibility restoration. No window manager +was started; openbox and xdotool were unavailable. The unchanged minimise sample +is adverse evidence against this window mechanism, not proof that the browser +actually minimised. Do not repeat this bare-Xvfb arrangement for favourable samples. +A subsequent experiment needs verified native window/tab state transitions, +not merely a larger timeout or successful CDP acknowledgements. No Pulse fixture, +installed convergence, destination receipt or release qualification was exercised. +Complete output is retained in maintainer run `20260908T065015Z-web-product`, +`headed-window-control.log`. Earlier failed controls remain relevant. diff --git a/scripts/check-browser-single-session-control.mjs b/scripts/check-browser-single-session-control.mjs index ce2f5b824..503a677d2 100644 --- a/scripts/check-browser-single-session-control.mjs +++ b/scripts/check-browser-single-session-control.mjs @@ -6,10 +6,12 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createRequire } from 'node:module'; import { chromium } from '@playwright/test'; -const checkForeground = process.argv.includes('--foreground'); +const headedWindow = process.argv.includes('--headed-window'); +const checkForeground = headedWindow || process.argv.includes('--foreground'); +if (headedWindow && !process.env.DISPLAY) throw new Error('--headed-window requires an owned X display'); 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', +const child = spawn(chromium.executablePath(), [...(headedWindow ? [] : ['--headless']), '--no-sandbox', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank']); let socket; try { @@ -47,14 +49,22 @@ try { 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() })); + platform: process.platform, arch: process.arch, executable: chromium.executablePath(), + headedWindow, display: process.env.DISPLAY, observationBoundMs: headedWindow ? 10000 : 250 })); const results = []; // Preselected negative (focus forced) and positive (no forced focus), fresh targets. - for (const focusEnabled of [true, false]) { + for (const focusEnabled of (headedWindow ? [false] : [true, false])) { const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); try { const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); const commands = []; + const windowCommand = async (method, params) => { + const response = await send(method, params); + commands.push({ method, params, response }); + return response; + }; + const windowInfo = headedWindow + ? await windowCommand('Browser.getWindowForTarget', { targetId }) : null; const command = async (method, params) => { const result = await send(method, params, sessionId); commands.push({ method, params, response: result }); @@ -75,6 +85,12 @@ try { const snapshot = `({...window.probe, visibility: document.visibilityState, focus: document.hasFocus()})`; await wait(250); const before = await evaluate(snapshot); + if (headedWindow) { + assert.ok(before.visibility === 'visible' && before.focus, 'Headed baseline must be visible and focused'); + await windowCommand('Browser.setWindowBounds', { windowId: windowInfo.windowId, bounds: { windowState: 'minimized' } }); + await wait(250); + console.log(JSON.stringify({ stage: 'minimized', snapshot: await evaluate(snapshot) })); + } await command('Page.setWebLifecycleState', { state: 'frozen' }); await wait(2100); // Host wait: never evaluate while frozen. await command('Page.setWebLifecycleState', { state: 'active' }); @@ -83,25 +99,40 @@ try { 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); + after.events.indexOf(freeze) < after.events.indexOf(resume) && (headedWindow || after.ticks > resume.ticks)); // Resume is not foreground activation. Observe the two transitions separately. let foreground; if (checkForeground) { + if (headedWindow) { + await windowCommand('Browser.setWindowBounds', { windowId: windowInfo.windowId, bounds: { windowState: 'normal' } }); + } await command('Page.bringToFront', {}); - await wait(250); - foreground = await evaluate(snapshot); + const deadline = Date.now() + (headedWindow ? 10000 : 250); + do { + await wait(250); + foreground = await evaluate(snapshot); + if (headedWindow) console.log(JSON.stringify({ stage: 'foreground-observation', snapshot: foreground })); + if (foreground.visibility === 'visible' && foreground.focus && foreground.ticks > after.ticks) break; + } while (Date.now() < deadline); } const result = { focusEnabled, before, after, foreground, 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, + if (!headedWindow) 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'); + const positive = results.at(-1); + assert.ok(positive.suspended, 'Positive control must suspend and resume timers'); + if (headedWindow) { + const events = positive.foreground.events; + const resumeIndex = events.findIndex(e => e.type === 'resume'); + assert.ok(events.some((e, i) => i > resumeIndex && e.type === 'visibilitychange' && e.visibility === 'visible'), + 'Must observe visible transition after resume'); + } if (checkForeground) { - assert.ok(results[1].foreground.visibility === 'visible' && results[1].foreground.focus, + assert.ok(positive.foreground.visibility === 'visible' && positive.foreground.focus, 'Positive control must return to visible and focused after tab activation'); - assert.ok(results[1].foreground.ticks > results[1].after.ticks, + assert.ok(positive.foreground.ticks > positive.after.ticks, 'Foreground timer must continue advancing'); } } finally {