Files
pulse/scripts/check-browser-single-session-control.mjs
T
pulse-triage[bot] c5dca69702 test(web): cover production incident refresh after tab suspension
Existing suspension coverage forces overlapping requests through a fixture-only control. Add a separate cached-read scenario using the enabled production Refresh button after native foreground return, so that ownership evidence is not mistaken for user refresh integration. Document the synthetic component and full-application boundary.

Change-source: pulse-maintainer
2026-09-08 08:42:45 +01:00

255 lines
14 KiB
JavaScript

// 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';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createRequire } from 'node:module';
import { chromium } from '@playwright/test';
const incidentUserRefresh = process.argv.includes('--incident-user-refresh');
const incidentConvergence = incidentUserRefresh || 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');
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
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);
child.once('error', reject);
child.stderr.on('data', data => {
stderr += data;
const match = stderr.match(/DevTools listening on (ws:\/\/\S+)/);
if (match) { clearTimeout(timer); resolve(match[1]); }
});
});
socket = new WebSocket(endpoint);
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true });
socket.addEventListener('error', reject, { once: true });
});
let id = 0;
const pending = new Map();
socket.addEventListener('message', ({ data }) => {
const message = JSON.parse(data);
if (!pending.has(message.id)) return;
const { resolve, reject, timer } = pending.get(message.id);
pending.delete(message.id); clearTimeout(timer);
if (message.error) reject(new Error(JSON.stringify(message.error)));
else resolve(message.result);
});
const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => {
const requestId = ++id;
const timer = setTimeout(() => { pending.delete(requestId); reject(new Error(`${method} timeout`)); }, 10000);
pending.set(requestId, { resolve, reject, timer });
socket.send(JSON.stringify({ id: requestId, method, params, sessionId }));
});
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(),
headedWindow, headedTab, incidentConvergence, incidentUserRefresh, 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])) {
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 });
return result;
};
const evaluate = async expression => {
const result = await send('Runtime.evaluate', { expression, returnByValue: true }, sessionId);
if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails));
return result.result.value;
};
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'])
document.addEventListener(type, () => window.probe.events.push({
type, ticks: window.probe.ticks, visibility: document.visibilityState }));`);
const snapshot = `({...window.probe, visibility: document.visibilityState, focus: document.hasFocus()})`;
await wait(250);
const before = await evaluate(snapshot);
let otherTarget;
const observe = async (stage, visibility) => {
const deadline = Date.now() + 10000;
let sample;
do {
await wait(250);
sample = await evaluate(snapshot);
console.log(JSON.stringify({ stage, snapshot: sample }));
if (sample.visibility === visibility && (visibility !== 'visible' || sample.focus)) return sample;
} while (Date.now() < deadline);
throw new Error(stage + ': native visibility transition not observed');
};
const awaitIncident = async id => {
const deadline = Date.now() + 10000;
while (!await evaluate(`window.snapshot().incidents.host?.[0]?.id === ${JSON.stringify(id)} && window.snapshot().loading.host === false`)) {
if (Date.now() > deadline) throw new Error('Incident settlement timeout: ' + id);
await wait(100);
}
};
if (incidentUserRefresh) {
await evaluate(`Array.from(document.querySelectorAll('button')).find(b => b.textContent === 'Open row').click()`);
const deadline = Date.now() + 10000;
while (fixtureServer.count() !== 1) {
if (Date.now() > deadline) throw new Error('Initial incident request timeout');
await wait(100);
}
fixtureServer.finish(0, 'Cached incident');
await awaitIncident('Cached incident');
}
if (headedTab) {
assert.ok(before.visibility === 'visible' && before.focus, 'Tab baseline must be active');
otherTarget = (await windowCommand('Target.createTarget', { url: 'about:blank' })).targetId;
await windowCommand('Target.activateTarget', { targetId: otherTarget });
await observe('tab-background-preflight', 'hidden');
await windowCommand('Target.activateTarget', { targetId });
await observe('tab-foreground-preflight', 'visible');
await windowCommand('Target.activateTarget', { targetId: otherTarget });
await observe('tab-background-before-freeze', 'hidden');
} else 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) }));
}
if (incidentConvergence && !incidentUserRefresh) {
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 && !incidentUserRefresh) {
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);
const after = await evaluate(snapshot);
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) && (headedWindow || after.ticks > resume.ticks));
// Resume is not foreground activation. Observe the two transitions separately.
let foreground;
if (checkForeground) {
if (headedTab) {
await windowCommand('Target.activateTarget', { targetId });
} else if (headedWindow) {
await windowCommand('Browser.setWindowBounds', { windowId: windowInfo.windowId, bounds: { windowState: 'normal' } });
}
await command('Page.bringToFront', {});
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);
}
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'));
if (incidentUserRefresh) {
assert.equal(fixtureServer.count(), 1, 'Resume alone must not be mistaken for an incident fetch');
await awaitIncident('Cached incident');
const clicked = await evaluate(`(() => {
const button = Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Refresh');
if (!button || button.disabled) return false;
button.click(); return true;
})()`);
assert.ok(clicked, 'Use the enabled production Refresh control');
const deadline = Date.now() + 10000;
while (fixtureServer.count() !== 2) {
if (Date.now() > deadline) throw new Error('User refresh request timeout');
await wait(100);
}
assert.equal(await evaluate('window.snapshot().loading.host'), true);
fixtureServer.finish(1, 'Latest incident');
}
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: incidentUserRefresh ? 'incident-user-refresh' : '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'));
if (incidentUserRefresh) assert.ok(!rendered.text.includes('Cached 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 });
} finally { await send('Target.closeTarget', { targetId }); }
}
if (!headedWindow) assert.ok(!results[0].suspended && results[0].after.ticks - results[0].before.ticks >= 20,
'Negative control must continue ticking');
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(positive.foreground.visibility === 'visible' && positive.foreground.focus,
'Positive control must return to visible and focused after tab activation');
assert.ok(positive.foreground.ticks > positive.after.ticks,
'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; }
await rm(profile, { recursive: true });
}