diff --git a/scripts/incident-browser-checks.md b/scripts/incident-browser-checks.md index 8017e26b0..f816e306b 100644 --- a/scripts/incident-browser-checks.md +++ b/scripts/incident-browser-checks.md @@ -56,12 +56,26 @@ an exact release-pair, live WebSocket convergence, real incident-store, or deliv notification check. Keep any failed application run as evidence, not a component fixture success in its place. -Known harness limitation (8 September 2026): the first local run reached the real +Retained adverse run (8 September 2026, c98c0565a7): the first local run reached the real history row and settled initial incident details, then failed the native-hidden preflight: the original page stayed `visible` for ten seconds after another Playwright page was activated. The source-built frontend/backend succeeded and the backend was stopped by cleanup. Do not remove the visibility assertion or -replace it with a synthetic DOM event to claim foreground coverage. The native -browser-control prerequisite remains unresolved; the Refresh portion of this -application scenario has **not** been executed successfully. The raw single-CDP -component checks above do not clear this application failure. +replace it with a synthetic DOM event to claim foreground coverage. That revision did not successfully execute Refresh after return. The raw +single-CDP component checks above do not clear this application failure. + +The additive harness uses `tests/integration/tests/native-visibility.ts` only +when the opt-in is set. Playwright 1.56.1 enables focus emulation on its owning +CDP session (`crPage.js`); disabling it on a secondary session leaves the first +override active. A loopback-only CDP proxy forwards commands and replies, changing +only `Emulation.setFocusEmulationEnabled({enabled:true})` to `false` on that +owning session. It checks that this command was encountered. It does not inject +DOM visibility, fabricate events, freeze the page or change assertion results. +An owned Chromium/profile and proxy are cleaned up afterwards. Native tab +activation and visibility polling still have to pass before Refresh is clicked. +This version-sensitive harness must fail rather than infer backgrounding if +Chromium or Playwright changes. Ordinary runs use the unmodified base fixtures. + +For the ordinary-journey control, repeat the command above with +`PULSE_E2E_INCIDENT_FOREGROUND` unset. Retain both exact-revision results; +a prior success is not evidence for a later untested revision. diff --git a/tests/integration/package-lock.json b/tests/integration/package-lock.json index daed53535..4e29cbcc7 100644 --- a/tests/integration/package-lock.json +++ b/tests/integration/package-lock.json @@ -12,7 +12,9 @@ "@axe-core/playwright": "^4.13.0", "@playwright/test": "1.56.1", "@types/node": "^20.10.0", - "typescript": "^5.3.0" + "@types/ws": "^8.18.1", + "typescript": "^5.3.0", + "ws": "^8.21.3" } }, "node_modules/@axe-core/playwright": { @@ -54,6 +56,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/axe-core": { "version": "4.13.0", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", @@ -131,6 +143,28 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/tests/integration/package.json b/tests/integration/package.json index ab40d2fdf..1daf6d17e 100644 --- a/tests/integration/package.json +++ b/tests/integration/package.json @@ -32,6 +32,8 @@ "@axe-core/playwright": "^4.13.0", "@playwright/test": "1.56.1", "@types/node": "^20.10.0", - "typescript": "^5.3.0" + "@types/ws": "^8.18.1", + "typescript": "^5.3.0", + "ws": "^8.21.3" } } diff --git a/tests/integration/tests/36-vmware-alert-history-resource-incidents.spec.ts b/tests/integration/tests/36-vmware-alert-history-resource-incidents.spec.ts index e0f8a130e..42813dd9d 100644 --- a/tests/integration/tests/36-vmware-alert-history-resource-incidents.spec.ts +++ b/tests/integration/tests/36-vmware-alert-history-resource-incidents.spec.ts @@ -1,4 +1,5 @@ -import { expect, test } from "@playwright/test"; +import { expect } from "@playwright/test"; +import { test } from "./native-visibility"; import { ensureAuthenticated } from "./helpers"; const SCREENSHOT_PATH = "/tmp/vmware-alert-history-resource-incidents.png"; @@ -236,8 +237,6 @@ test.describe("VMware alert history resource incidents", () => { console.log(JSON.stringify({ stage: "application-incident-initial-read-settled", incidentReads, tokenAuthenticationPresent: await page.evaluate(() => Boolean(sessionStorage.getItem("pulse_auth"))), liveWebSocket: false, incidentHTTP: "mocked" })); - const cdp = await context.newCDPSession(page); - await cdp.send("Emulation.setFocusEmulationEnabled", { enabled: false }); const other = await context.newPage(); try { await other.goto("about:blank"); @@ -259,7 +258,6 @@ test.describe("VMware alert history resource incidents", () => { visibility: "native hidden then visible", freeze: false })); } finally { await other.close(); - await cdp.detach(); } } await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }); diff --git a/tests/integration/tests/native-visibility.ts b/tests/integration/tests/native-visibility.ts new file mode 100644 index 000000000..d3e0abcc8 --- /dev/null +++ b/tests/integration/tests/native-visibility.ts @@ -0,0 +1,84 @@ +import { test as base, expect } from '@playwright/test'; +import { spawn } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import WebSocket, { WebSocketServer } from 'ws'; + +// Playwright 1.56 enables focus emulation on its own CDP session. Disabling +// it on a second session does not clear that first session's override. This +// opt-in transport changes only that emulation command, on the owning session; +// visibility observations, input, HTTP routing and assertions remain untouched. +const nativeVisibilityTest = base.extend({ + browser: async ({ playwright, browserName, headless }, use) => { + if (browserName !== 'chromium' || headless || !process.env.DISPLAY) { + throw new Error('Native visibility requires headed Chromium on an owned display'); + } + const profile = await mkdtemp(join(tmpdir(), 'pulse-native-visibility-')); + const child = spawn(playwright.chromium.executablePath(), [ + '--no-sandbox', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank', + ], { stdio: ['ignore', 'ignore', 'pipe'] }); + const proxy = new WebSocketServer({ host: '127.0.0.1', port: 0 }); + const sockets = new Set(); + let overrides = 0; + try { + const endpoint = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Chromium endpoint timeout')), 15000); + let stderr = ''; + child.once('error', error => { clearTimeout(timer); reject(error); }); + child.once('exit', () => { clearTimeout(timer); reject(new Error('Chromium exited before connection')); }); + child.stderr!.on('data', data => { + stderr += data; + const match = stderr.match(/DevTools listening on (ws:\/\/\S+)/); + if (match) { clearTimeout(timer); resolve(match[1]); } + }); + }); + proxy.on('connection', downstream => { + const upstream = new WebSocket(endpoint); + sockets.add(downstream); sockets.add(upstream); + const queued: string[] = []; + downstream.on('message', data => { + const message = JSON.parse(data.toString()); + if (message.method === 'Emulation.setFocusEmulationEnabled' && message.params?.enabled === true) { + message.params.enabled = false; + overrides++; + } + const wire = JSON.stringify(message); + if (upstream.readyState === WebSocket.OPEN) upstream.send(wire); + else queued.push(wire); + }); + upstream.on('open', () => { for (const wire of queued) upstream.send(wire); queued.length = 0; }); + upstream.on('message', data => { + if (downstream.readyState === WebSocket.OPEN) downstream.send(data.toString()); + }); + upstream.on('error', () => downstream.close()); + downstream.on('error', () => upstream.close()); + downstream.on('close', () => upstream.close()); + upstream.on('close', () => downstream.close()); + }); + if (!proxy.address()) await new Promise(resolve => proxy.once('listening', resolve)); + const address = proxy.address(); + if (!address || typeof address === 'string') throw new Error('Missing proxy address'); + const browser = await playwright.chromium.connectOverCDP(`ws://127.0.0.1:${address.port}`); + try { + console.log(JSON.stringify({ nativeVisibilityTransport: 'owning-session focus emulation disabled', browser: browser.version() })); + await use(browser); + expect(overrides, 'Expected Playwright owning-session focus command').toBeGreaterThan(0); + } finally { await browser.close(); } + } finally { + for (const socket of sockets) socket.terminate(); + await new Promise(resolve => proxy.close(() => resolve())); + if (child.exitCode === null && child.signalCode === null) { + const stopped = new Promise(resolve => child.once('exit', () => resolve())); + child.kill('SIGTERM'); + const timer = setTimeout(() => child.kill('SIGKILL'), 5000); + try { await stopped; } finally { clearTimeout(timer); } + } + await rm(profile, { recursive: true, force: true }); + } + }, +}); + +// Keep the ordinary journey on Playwright's unmodified default fixtures. +export const test = process.env.PULSE_E2E_INCIDENT_FOREGROUND === '1' + ? nativeVisibilityTest : base;