From daa471edf335d5643ac3de90534da1ec7004f880 Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Tue, 8 Sep 2026 08:23:44 +0100
Subject: [PATCH] test(web): verify incident convergence after native tab
suspension
The verified headed-tab control previously stopped at blank-page lifecycle evidence. Apply the same single-session mechanism to the production incident hook and panel with bounded synthetic HTTP responses, preserving the failed same-URL fixture attempt and separating this result from installed release qualification.
Change-source: pulse-maintainer
---
.../browser-tests/lifecycle-control.md | 39 +++++++++++++
.../check-browser-single-session-control.mjs | 56 ++++++++++++++++++-
scripts/check-incident-request-ownership.mjs | 27 +--------
scripts/incident-browser-fixture.mjs | 26 +++++++++
scripts/incident-convergence-server.mjs | 36 ++++++++++++
5 files changed, 156 insertions(+), 28 deletions(-)
create mode 100644 scripts/incident-browser-fixture.mjs
create mode 100644 scripts/incident-convergence-server.mjs
diff --git a/frontend-modern/browser-tests/lifecycle-control.md b/frontend-modern/browser-tests/lifecycle-control.md
index 1d897d449..27552b1f5 100644
--- a/frontend-modern/browser-tests/lifecycle-control.md
+++ b/frontend-modern/browser-tests/lifecycle-control.md
@@ -164,3 +164,42 @@ window minimisation. Earlier adverse results and the default mode's unreliable
acceptance ran. The next source-level convergence fixture must retain raw
single-session ownership, the genuine background preflight, and the suspension
probe rather than attach a Playwright page and assume equivalent behaviour.
+
+## Suspended incident component convergence — 8 September 2026
+
+```sh
+pulse-heavy-run -- xvfb-run -a -s '-screen 0 1280x800x24 -nolisten tcp' node scripts/check-browser-single-session-control.mjs --incident-convergence
+```
+
+This opt-in mode retains the raw single-session headed-tab preflight and probe,
+then mounts the production incident hook and panel through a loopback Vite
+fixture. No Playwright page is attached. It initiates two synthetic HTTP reads
+before freezing, sends the newer response followed by the obsolete response from
+the host while frozen, waits 2100ms, resumes and activates the original tab.
+Both native foreground recovery and ordered suspension must pass before checking
+convergence (ten-second bound): latest incident retained, obsolete incident
+absent, loading/error cleared and no notification error. Fixture button clicks
+are scripted setup, not a claim of native pointer/keyboard interaction.
+
+The first run used identical pending GET URLs and exited 1 waiting for the
+second request, before freeze. Only one request reached the fixture. Same-URL
+request handling was a possible confounder, not an established browser diagnosis
+or product failure. The revised fixture gives reads distinct query identifiers
+and disables fetch caching. One revised run exited 0: hidden → visible/focused →
+hidden preflight; freeze/resume both at tick 11; visible/focused at tick 17;
+latest incident rendered, obsolete absent, loading/error false, notifications 0.
+
+Both outputs are retained under maintainer run `20260908T072007Z-web-product`:
+`incident-convergence.log` (adverse) and
+`incident-convergence-distinct-reads.log` (passing). Runtime was Chrome
+141.0.7390.37, revision `9f043f63b0e5b728c8d09f3e3ddfc1681a4bd58e`,
+Playwright 1.56.1, Node v24.20.0 linux x64, owned Xvfb display :99.
+The existing ownership runner now imports the unchanged shared fixture string.
+
+This is **component-source evidence**, not full application convergence:
+authentication, application WebSocket refresh, installed restart, selected public/
+private release pair, native mobile lifecycle, and destination receipt are not
+exercised. The fixture replaces AlertsAPI's incident read with synthetic HTTP;
+it does not establish that the application initiates a refresh on foreground,
+nor guarantee network callback execution order from host response order. Earlier
+failed controls remain adverse evidence about their respective mechanisms.
diff --git a/scripts/check-browser-single-session-control.mjs b/scripts/check-browser-single-session-control.mjs
index 21f14bce1..4c62c6a2a 100644
--- a/scripts/check-browser-single-session-control.mjs
+++ b/scripts/check-browser-single-session-control.mjs
@@ -1,4 +1,4 @@
-// Owned blank-page diagnostic; no Playwright page session or application loaded.
+// Raw single-session diagnostic; optional synthetic production incident component fixture.
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtemp, rm } from 'node:fs/promises';
@@ -6,7 +6,8 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createRequire } from 'node:module';
import { chromium } from '@playwright/test';
-const headedTab = process.argv.includes('--headed-tab');
+const incidentConvergence = process.argv.includes('--incident-convergence');
+const headedTab = incidentConvergence || process.argv.includes('--headed-tab');
const headedWindow = headedTab || 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');
@@ -15,7 +16,9 @@ const profile = await mkdtemp(join(tmpdir(), 'pulse-lifecycle-'));
const child = spawn(chromium.executablePath(), [...(headedWindow ? [] : ['--headless']), '--no-sandbox',
'--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank']);
let socket;
+let fixtureServer;
try {
+ if (incidentConvergence) fixtureServer = await (await import('./incident-convergence-server.mjs')).startIncidentFixture();
const endpoint = await new Promise((resolve, reject) => {
let stderr = '';
const timer = setTimeout(() => reject(new Error('Browser endpoint timeout')), 10000);
@@ -51,7 +54,7 @@ try {
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(),
- headedWindow, headedTab, display: process.env.DISPLAY, observationBoundMs: headedWindow ? 10000 : 250 }));
+ headedWindow, headedTab, incidentConvergence, 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 (headedWindow ? [false] : [true, false])) {
@@ -78,6 +81,14 @@ try {
};
await command('Page.enable', {});
await command('Emulation.setFocusEmulationEnabled', { enabled: focusEnabled });
+ if (incidentConvergence) {
+ await command('Page.navigate', { url: 'http://127.0.0.1:5198/qualification' });
+ const deadline = Date.now() + 20000;
+ while (!await evaluate(`typeof window.snapshot === 'function'`)) {
+ if (Date.now() > deadline) throw new Error('Fixture load timeout');
+ await wait(100);
+ }
+ }
await evaluate(`window.probe = { ticks: 0, events: [] };
setInterval(() => window.probe.ticks++, 50);
for (const type of ['freeze', 'resume', 'visibilitychange'])
@@ -113,7 +124,23 @@ try {
await wait(250);
console.log(JSON.stringify({ stage: 'minimized', snapshot: await evaluate(snapshot) }));
}
+ if (incidentConvergence) {
+ for (const [index, label] of ['Open row', 'Overlap refresh'].entries()) {
+ await evaluate(`Array.from(document.querySelectorAll('button')).find(b => b.textContent === ${JSON.stringify(label)}).click()`);
+ const deadline = Date.now() + 10000;
+ while (fixtureServer.count() !== index + 1) {
+ if (Date.now() > deadline) throw new Error('Incident request timeout');
+ await wait(100);
+ }
+ }
+ console.log(JSON.stringify({ stage: 'incident-before-freeze', state: await evaluate('window.snapshot()') }));
+ }
await command('Page.setWebLifecycleState', { state: 'frozen' });
+ if (incidentConvergence) {
+ fixtureServer.finish(1, 'Latest incident');
+ await wait(100);
+ fixtureServer.finish(0, 'Obsolete incident');
+ }
await wait(2100); // Host wait: never evaluate while frozen.
await command('Page.setWebLifecycleState', { state: 'active' });
await wait(250);
@@ -139,6 +166,28 @@ try {
if (foreground.visibility === 'visible' && foreground.focus && foreground.ticks > after.ticks) break;
} while (Date.now() < deadline);
}
+ if (incidentConvergence) {
+ assert.ok(suspended, 'Fixture must demonstrably suspend and resume');
+ assert.ok(foreground.visibility === 'visible' && foreground.focus && foreground.ticks > after.ticks,
+ 'Fixture must return to native foreground with timer progress');
+ const resumeIndex = foreground.events.findIndex(e => e.type === 'resume');
+ assert.ok(foreground.events.some((e, i) => i > resumeIndex && e.type === 'visibilitychange' && e.visibility === 'visible'));
+ const deadline = Date.now() + 10000;
+ let state;
+ do {
+ state = await evaluate('window.snapshot()');
+ if (state.incidents.host?.[0]?.id === 'Latest incident' && state.loading.host === false) break;
+ await wait(250);
+ } while (Date.now() < deadline);
+ const rendered = await evaluate(`({text: document.querySelector('section').innerText, errors: document.querySelector('[data-testid="errors"]').textContent})`);
+ console.log(JSON.stringify({ stage: 'incident-convergence', state, rendered }));
+ assert.equal(state.incidents.host?.[0]?.id, 'Latest incident');
+ assert.equal(state.loading.host, false);
+ assert.equal(state.error.host, false);
+ assert.ok(rendered.text.includes('Latest incident'));
+ assert.ok(!rendered.text.includes('Obsolete incident'));
+ assert.equal(rendered.errors, '0');
+ }
const result = { focusEnabled, before, after, foreground, commands, suspended };
results.push(result); console.log(JSON.stringify(result));
if (otherTarget) await send('Target.closeTarget', { targetId: otherTarget });
@@ -161,6 +210,7 @@ try {
'Foreground timer must continue advancing');
}
} finally {
+ await fixtureServer?.close();
socket?.close();
const exited = new Promise(resolve => child.once('exit', resolve));
if (child.exitCode === null) { child.kill(); await exited; }
diff --git a/scripts/check-incident-request-ownership.mjs b/scripts/check-incident-request-ownership.mjs
index aac89fd2f..9d8292d0f 100644
--- a/scripts/check-incident-request-ownership.mjs
+++ b/scripts/check-incident-request-ownership.mjs
@@ -7,31 +7,8 @@ import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";
const root = resolve("frontend-modern");
process.chdir(root);
-const fixture = `
-import { render } from 'solid-js/web';
-import { createSignal, Show } from 'solid-js';
-import { AlertsAPI } from '/src/api/alerts';
-import { notificationStore } from '/src/stores/notifications';
-import { useAlertResourceIncidentsState } from '/src/features/alerts/useAlertResourceIncidentsState';
-import { AlertResourceIncidentsPanel } from '/src/features/alerts/AlertResourceIncidentsPanel';
-import '/src/index.css';
-const pending = [];
-const [errors, setErrors] = createSignal(0);
-notificationStore.error = () => setErrors(n => n + 1);
-AlertsAPI.getIncidentsForResource = () => new Promise((resolve, reject) => pending.push({resolve, reject}));
-window.finish = (i, status) => status === 'error' ? pending[i].reject(new Error('scripted read failure')) : pending[i].resolve(status === 'empty' ? [] : [{id:status, message:status, level:'warning', status:'open', openedAt:new Date().toISOString(), events:[]}]);
-window.count = () => pending.length;
-function Panel() {
- const s = useAlertResourceIncidentsState();
- window.snapshot = () => ({incidents:s.resourceIncidents(), loading:s.resourceIncidentLoading(), error:s.resourceIncidentError()});
- return Incident lifecycle qualification fixture
Incident lifecycle qualification fixture