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
This commit is contained in:
pulse-triage[bot]
2026-09-08 08:23:44 +01:00
parent d9bc8c4d76
commit daa471edf3
5 changed files with 156 additions and 28 deletions
@@ -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.
@@ -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; }
+2 -25
View File
@@ -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 <section><button onClick={() => s.openResourceIncidentPanel('host','Host','row')}>Open row</button><button onClick={s.refreshResourceIncidentPanel}>Overlap refresh</button><button onClick={s.resetResourceIncidentsState}>Reset</button><output style="display:block;overflow-wrap:anywhere" data-testid="state">{JSON.stringify(window.snapshot())}</output><AlertResourceIncidentsPanel state={s}/></section>;
}
function Fixture() {
const [mounted, setMounted] = createSignal(true);
return <main class="p-4"><h1>Incident lifecycle qualification fixture</h1><button onClick={() => setMounted(false)}>Unmount</button><output data-testid="errors">{errors()}</output><Show when={mounted()}><Panel/></Show></main>;
}
render(() => <Fixture/>, document.getElementById('root'));
`;
import { fixture } from './incident-browser-fixture.mjs';
const server = await createServer({
root,
configFile: false,
+26
View File
@@ -0,0 +1,26 @@
// Shared synthetic fixture mounting the production incident hook and panel.
export 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 <section><button onClick={() => s.openResourceIncidentPanel('host','Host','row')}>Open row</button><button onClick={s.refreshResourceIncidentPanel}>Overlap refresh</button><button onClick={s.resetResourceIncidentsState}>Reset</button><output style="display:block;overflow-wrap:anywhere" data-testid="state">{JSON.stringify(window.snapshot())}</output><AlertResourceIncidentsPanel state={s}/></section>;
}
function Fixture() {
const [mounted, setMounted] = createSignal(true);
return <main class="p-4"><h1>Incident lifecycle qualification fixture</h1><button onClick={() => setMounted(false)}>Unmount</button><output data-testid="errors">{errors()}</output><Show when={mounted()}><Panel/></Show></main>;
}
render(() => <Fixture/>, document.getElementById('root'));
`;
+36
View File
@@ -0,0 +1,36 @@
// Loopback synthetic HTTP responses; never an installed backend or destination.
import { createServer } from '../frontend-modern/node_modules/vite/dist/node/index.js';
import solid from '../frontend-modern/node_modules/vite-plugin-solid/dist/esm/index.mjs';
import { fileURLToPath } from 'node:url';
import { fixture } from './incident-browser-fixture.mjs';
export async function startIncidentFixture() {
const root = fileURLToPath(new URL('../frontend-modern', import.meta.url));
process.chdir(root);
const pending = [];
const source = fixture.replace(
'new Promise((resolve, reject) => pending.push({resolve, reject}))',
"fetch('/fixture/incidents?request=' + pending.push({}), { cache: 'no-store' }).then(r => r.json())");
const server = await createServer({ root, configFile: false,
optimizeDeps: { noDiscovery: true, entries: [], esbuildOptions: { target: 'esnext' } },
esbuild: { target: 'esnext' }, resolve: { alias: { '@': root + '/src' } },
plugins: [solid(), { name: 'incident-convergence',
configureServer(s) { s.middlewares.use((req, res, next) => {
if (req.url?.startsWith('/fixture/incidents?request=')) { pending.push(res); console.log(JSON.stringify({ stage: 'incident-request', index: pending.length - 1 })); }
else if (req.url === '/qualification') { res.setHeader('Content-Type', 'text/html'); res.end('<div id="root"></div><script type="module" src="/incident-fixture.tsx"></script>'); }
else next();
}); },
resolveId(id) { if (id === '/incident-fixture.tsx') return id; },
load(id) { if (id === '/incident-fixture.tsx') return source; },
}], server: { host: '127.0.0.1', port: 5198, strictPort: true },
});
await server.listen();
return { count: () => pending.length,
finish(index, id) {
const res = pending[index];
if (!res) throw new Error('Missing pending request ' + index);
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify([{ id, message: id, level: 'warning', status: 'open', openedAt: '2026-09-08T07:20:00Z', events: [] }]));
console.log(JSON.stringify({ stage: 'incident-response-sent', index, id }));
}, async close() { for (const res of pending) if (!res.writableEnded) res.end('[]'); await server.close(); },
};
}