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
{JSON.stringify(window.snapshot())}
; -} -function Fixture() { - const [mounted, setMounted] = createSignal(true); - return

Incident lifecycle qualification fixture

{errors()}
; -} -render(() => , document.getElementById('root')); -`; +import { fixture } from './incident-browser-fixture.mjs'; + const server = await createServer({ root, configFile: false, diff --git a/scripts/incident-browser-fixture.mjs b/scripts/incident-browser-fixture.mjs new file mode 100644 index 000000000..ec7b64f54 --- /dev/null +++ b/scripts/incident-browser-fixture.mjs @@ -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
{JSON.stringify(window.snapshot())}
; +} +function Fixture() { + const [mounted, setMounted] = createSignal(true); + return

Incident lifecycle qualification fixture

{errors()}
; +} +render(() => , document.getElementById('root')); +`; diff --git a/scripts/incident-convergence-server.mjs b/scripts/incident-convergence-server.mjs new file mode 100644 index 000000000..dd415bd75 --- /dev/null +++ b/scripts/incident-convergence-server.mjs @@ -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('
'); } + 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(); }, + }; +}