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