diff --git a/scripts/check-navigation-admission-race.mjs b/scripts/check-navigation-admission-race.mjs index 2692f9ead..f4510a9df 100644 --- a/scripts/check-navigation-admission-race.mjs +++ b/scripts/check-navigation-admission-race.mjs @@ -1,5 +1,5 @@ // Full application, real organisation selector; synthetic HTTP and cold socket. -// Run with PULSE_PROOF_WIDTH=390|1440 and PULSE_PROOF_MODE=failure|success|reverse. +// Run with PULSE_PROOF_WIDTH=390|1440 and PULSE_PROOF_MODE=failure|success|reverse|superseded. // pulse-heavy-run -- node scripts/check-navigation-admission-race.mjs import { createServer } from "../frontend-modern/node_modules/vite/dist/node/index.js"; import { @@ -18,9 +18,9 @@ const height = width === 390 ? 844 : 900; const mode = process.env.PULSE_PROOF_MODE || "failure"; if ( ![390, 1440].includes(width) || - !["failure", "success", "reverse"].includes(mode) + !["failure", "success", "reverse", "superseded"].includes(mode) ) { - throw new Error("Use width 390 or 1440 and mode failure, success or reverse"); + throw new Error("Use width 390 or 1440 and mode failure, success, reverse or superseded"); } const phase = process.env.PULSE_PROOF_PHASE === "baseline" ? "baseline" : "repaired"; @@ -95,6 +95,10 @@ try { outgoing = route; return; } + if (mode === "superseded") { + if (!incoming) { incoming = route; return; } + return json(emptyAdmission); + } incoming = route; if (mode === "reverse") return; if (mode === "success") return json(emptyAdmission); @@ -110,6 +114,7 @@ try { return json([ { id: "default", displayName: "Default Organization" }, { id: "acme", displayName: "Acme" }, + { id: "third", displayName: "Third" }, ]); if (path === "/api/license/runtime-capabilities") return json({ @@ -147,7 +152,17 @@ try { await org.selectOption("acme"); await expect(org).toHaveValue("acme"); await expect.poll(() => Boolean(incoming)).toBe(true); - if (mode !== "reverse") { + const expectedRequests = ["default", "default", "acme"]; + if (mode === "superseded") { + const thirdResponse = page.waitForResponse(r => + r.url().includes("/api/resources?page=1&limit=1") && + r.request().headers()["x-pulse-org-id"] === "third"); + await org.selectOption("third"); + await (await thirdResponse).finished(); + await expect(org).toHaveValue("third"); + expectedRequests.push("third"); + } + if (mode !== "reverse" && mode !== "superseded") { await expect .poll(() => incoming @@ -197,18 +212,23 @@ try { ); // Remove the transient switch toast so the narrow navigation is inspectable. const dismiss = page.getByRole("button", { name: "Dismiss notification" }); - for (const button of await dismiss.all()) await button.click(); - await expect(dismiss).toHaveCount(0); + // Multiple switches animate/remove toasts; wait for their normal expiry rather + // than retaining locators that can detach while Playwright waits for stability. + await expect(dismiss).toHaveCount(0, { timeout: 15_000 }); await page.screenshot({ path: `${output}/after-outgoing-response.png` }); await expect(platform).toHaveCount(0); - expect(requests).toEqual(["default", "default", "acme"]); + expect(requests).toEqual(expectedRequests); expect(failed).toBe(mode === "failure" ? 1 : 0); - if (mode === "reverse") { + if (mode === "reverse" || mode === "superseded") { const newestResponse = page.waitForResponse( (r) => r.request() === incoming.request(), ); - await incoming.fulfill({ json: emptyAdmission }); + await incoming.fulfill({ json: mode === "superseded" + ? { aggregations: { platformAdmission: { proxmox: true, docker: true } } } + : emptyAdmission }); await (await newestResponse).finished(); + await page.evaluate(() => new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(resolve)))); await expect(platform).toHaveCount(0); } if (width < 1280) { @@ -235,7 +255,7 @@ try { hold = false; await org.selectOption("default"); await expect(platform).toBeVisible(); - expect(requests).toEqual(["default", "default", "acme", "default"]); + expect(requests).toEqual([...expectedRequests, "default"]); if (width < 1280) { await platform.click(); const menu = page.getByRole("menu", { @@ -254,9 +274,9 @@ try { "websocket_reconnected", ), ); - await expect.poll(() => requests.length).toBe(5); + await expect.poll(() => requests.length).toBe(expectedRequests.length + 2); await expect(platform).toBeVisible(); - expect(requests).toEqual(["default", "default", "acme", "default", "default"]); + expect(requests).toEqual([...expectedRequests, "default", "default"]); expect(await page.evaluate(() => performance.timeOrigin)).toBe( documentIdentity, ); diff --git a/tests/integration/tests/96-navigation-socket-recovery.spec.ts b/tests/integration/tests/96-navigation-socket-recovery.spec.ts index 16ec84230..079123fe7 100644 --- a/tests/integration/tests/96-navigation-socket-recovery.spec.ts +++ b/tests/integration/tests/96-navigation-socket-recovery.spec.ts @@ -122,6 +122,46 @@ for (const admissionFailure of [false, true]) { if (width === 390) await mobileDestinations(page); expect(await page.evaluate(() => performance.timeOrigin)).toBe(documentIdentity); await capture('recovered'); + if (width === 390 && !admissionFailure && process.env.PULSE_E2E_TABLE_ACCESS === '1') { + const row = page.locator('tr[data-docker-container-row]').filter({ hasText: 'notification' }).first(); + const table = row.locator('xpath=ancestor::table'); + const wrapper = table.locator('..'); + const measure = () => wrapper.evaluate(el => ({ + width: el.clientWidth, scrollWidth: el.scrollWidth, left: el.scrollLeft, + overflowX: getComputedStyle(el).overflowX, tabIndex: (el as HTMLElement).tabIndex, + cells: Array.from(el.querySelector('tr[data-docker-container-row]')!.children).map(cell => ({ + text: cell.textContent, x: cell.getBoundingClientRect().x, + width: cell.getBoundingClientRect().width, + titles: Array.from(cell.querySelectorAll('[title]')).map(n => n.getAttribute('title')), + })), + })); + await row.scrollIntoViewIfNeeded(); + const initial = await measure(); + await row.hover(); + await page.mouse.wheel(2000, 0); + await page.waitForTimeout(350); + const pointer = await measure(); + await capture('table-after-horizontal-wheel'); + await wrapper.evaluate(el => { el.scrollLeft = 0; }); + // Start on a real focusable descendant, without adding tabindex to the UI. + const toggle = row.getByRole('button').first(); + await toggle.focus(); + await page.keyboard.press('ArrowRight'); + await page.waitForTimeout(350); + const keyboard = await measure(); + const accessibleRow = await row.ariaSnapshot(); + await page.keyboard.press('Enter'); + await capture('table-keyboard-detail'); + const expanded = await toggle.getAttribute('aria-expanded'); + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + const detailId = await toggle.getAttribute('aria-controls'); + const detailText = detailId ? await page.locator(`[id="${detailId}"]`).innerText() : null; + await testInfo.attach('narrow-table-access', { + body: JSON.stringify({ initial, pointer, keyboard, accessibleRow, expanded, detailText }, null, 2), + contentType: 'application/json', + }); + } + await testInfo.attach('environment', { body: JSON.stringify({ browser: browser.version(), width, height: width === 390 ? 844 : 900, zoom: 1, admissionFailure, failedAdmissions, before }), contentType: 'application/json' }); }); } diff --git a/tests/qualification/navigation-reconnect/2026-09-05-access/README.md b/tests/qualification/navigation-reconnect/2026-09-05-access/README.md new file mode 100644 index 000000000..34a78763d --- /dev/null +++ b/tests/qualification/navigation-reconnect/2026-09-05-access/README.md @@ -0,0 +1,79 @@ +# Access and interruption qualification — 5 September 2026 + +Base: `76fd854da1b42402ad742258297ff31461f1bb39`, plus accompanying test-only +changes. Chromium 141.0.7390.37; local synthetic inventory. No product change. + +## Access diagnosis + +Fresh external reference: https://www.w3.org/WAI/WCAG22/Understanding/reflow.html +(accessed September 5). W3C permits two-dimensional data-table scrolling but +not indiscriminate loss of information. This challenges treating a clipped +screenshot as either an automatic failure or a mobile pass. It supports access +diagnosis, not demand for a new surface or a conformance assertion. + +At 390×844, Docker's wrapper measures 362px client/scroll width, uses +`overflow-x: clip`, and remains at scrollLeft 0 after horizontal wheel input +and ArrowRight from an existing detail-toggle button. The last update cell is +48.44px wide; its badge is visibly clipped in both viewed fresh wheel screenshots. +This is not off-screen content recoverable by horizontal scrolling. Source +`frontend-modern/src/index.css` applies `overflow: clip` to phone platform +wrappers below 40rem. Do not simply restore nested scrolling: that rule addresses +Android vertical gesture ownership, which needs regression protection. + +The first-row accessibility snapshot retains the full update action despite +badge clipping. A second run selects truncated `notifications-worker`: its +accessibility snapshot retains the full name, Enter sets expanded=true, and +rendered detail text includes the full hostname. The viewed detail screenshot +still truncates the drawer heading. This proves accessible naming and keyboard +expansion, not screen-reader/device testing or sighted access to every long value; +scrolling to the identity field was not exercised. Pointer input here means +desktop Chromium horizontal wheel, not Android touch. + +Text receipts are human-readable reporter extracts, not JSON. Cell geometry +samples the first container row even in the long-name run; accessibleRow and +detailText describe the selected long-name row. The opt-in diagnostic does not +assert that clipping is acceptable. + +## Real full-stack interruption + +`run-tests.sh multi-tenant` source-built mock and e2e_runtime Docker images and +started isolated Compose. The watcher waited for an owned cookie file and live +Chromium descendants, recorded project container inventory, then sent SIGTERM +to the supervisor. `interruption.json`: exit 143; server/mock running and seed +exited before the signal; no owned containers, volumes, sampled descendant PIDs +or auth/report/result directories afterwards. No cookie contents retained. + +An initial watcher did not recognise headless_shell; that run completed normally +(six passes, one expected skip), so it was not interruption proof. The corrected +run supplies the receipt. The reproduction watcher also explicitly fails if +criteria are unmet. SIGINT/SIGHUP have existing stub coverage only. SIGKILL/host +failure, every possible new descendant and preservation of unrelated stacks +were not qualified. No shared runtime was interrupted or deployed. + +## Reproduction and checks + +From repository root, install locked frontend-modern and tests/integration +packages and pinned Chromium: + +```sh +pulse-heavy-run -- python3 tests/qualification/navigation-reconnect/2026-09-05-access/interrupt.py +pulse-heavy-run -- env PULSE_E2E_USE_LOCAL_BACKEND=1 PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL=1 PULSE_MOCK_MODE=true PULSE_E2E_NAVIGATION_RECOVERY=1 PULSE_E2E_TABLE_ACCESS=1 PULSE_E2E_LOCAL_BACKEND_PORT=18765 npm --prefix tests/integration test -- tests/96-navigation-socket-recovery.spec.ts --project=chromium --grep '390px.*false' +pulse-heavy-run -- bash -c 'set -e; for width in 390 1440; do for mode in failure success reverse superseded; do PULSE_PROOF_WIDTH=$width PULSE_PROOF_MODE=$mode node scripts/check-navigation-admission-race.mjs; done; done' +node --test tests/integration/scripts/run-tests-interruption.test.mjs tests/integration/scripts/run-tests-cleanup.test.mjs +``` + +Both narrow backend runs passed and stopped; both recorded backend PIDs were +absent afterwards. Twelve focused shell/cleanup tests passed. No full repository +suite. Admission results are retained separately. Multiple-switch toast +locators initially detached; the harness now waits for normal expiry instead +of clicking a changing list. This is a harness correction, not a product fix. + +## Next maintenance + +Preserve supersession and real interruption coverage. Investigate fitting phone +update actions into allocated cells while retaining accessible naming, keyboard +access and Android page-owned vertical scrolling. Check demand-ledger scope +before product work; this pass introduced no bet or user-visible surface. +Installed alert delivery, release acceptance and physical-device accessibility +remain separate dependencies. Full-stack SIGTERM proof is no longer missing; +no Docker blocker is established. diff --git a/tests/qualification/navigation-reconnect/2026-09-05-access/admission-results.json b/tests/qualification/navigation-reconnect/2026-09-05-access/admission-results.json new file mode 100644 index 000000000..4287a79d3 --- /dev/null +++ b/tests/qualification/navigation-reconnect/2026-09-05-access/admission-results.json @@ -0,0 +1,164 @@ +[ + { + "result": "passed", + "browser": "141.0.7390.37", + "viewport": { + "width": 390, + "height": 844 + }, + "mode": "failure", + "phase": "repaired", + "requests": [ + "default", + "default", + "acme", + "default", + "default" + ], + "failed": 1, + "verified_at": "2026-09-05T16:54:49.496Z", + "source_sha256": "d1de019dad5bff7fcb78116a8cf53663f1ddcd4c631bdbf0260b68580ebe0eb7" + }, + { + "result": "passed", + "browser": "141.0.7390.37", + "viewport": { + "width": 390, + "height": 844 + }, + "mode": "success", + "phase": "repaired", + "requests": [ + "default", + "default", + "acme", + "default", + "default" + ], + "failed": 0, + "verified_at": "2026-09-05T16:55:12.726Z", + "source_sha256": "d1de019dad5bff7fcb78116a8cf53663f1ddcd4c631bdbf0260b68580ebe0eb7" + }, + { + "result": "passed", + "browser": "141.0.7390.37", + "viewport": { + "width": 390, + "height": 844 + }, + "mode": "reverse", + "phase": "repaired", + "requests": [ + "default", + "default", + "acme", + "default", + "default" + ], + "failed": 0, + "verified_at": "2026-09-05T16:55:35.941Z", + "source_sha256": "d1de019dad5bff7fcb78116a8cf53663f1ddcd4c631bdbf0260b68580ebe0eb7" + }, + { + "result": "passed", + "browser": "141.0.7390.37", + "viewport": { + "width": 390, + "height": 844 + }, + "mode": "superseded", + "phase": "repaired", + "requests": [ + "default", + "default", + "acme", + "third", + "default", + "default" + ], + "failed": 0, + "verified_at": "2026-09-05T16:55:59.091Z", + "source_sha256": "d1de019dad5bff7fcb78116a8cf53663f1ddcd4c631bdbf0260b68580ebe0eb7" + }, + { + "result": "passed", + "browser": "141.0.7390.37", + "viewport": { + "width": 1440, + "height": 900 + }, + "mode": "failure", + "phase": "repaired", + "requests": [ + "default", + "default", + "acme", + "default", + "default" + ], + "failed": 1, + "verified_at": "2026-09-05T16:56:19.211Z", + "source_sha256": "d1de019dad5bff7fcb78116a8cf53663f1ddcd4c631bdbf0260b68580ebe0eb7" + }, + { + "result": "passed", + "browser": "141.0.7390.37", + "viewport": { + "width": 1440, + "height": 900 + }, + "mode": "success", + "phase": "repaired", + "requests": [ + "default", + "default", + "acme", + "default", + "default" + ], + "failed": 0, + "verified_at": "2026-09-05T16:56:42.832Z", + "source_sha256": "d1de019dad5bff7fcb78116a8cf53663f1ddcd4c631bdbf0260b68580ebe0eb7" + }, + { + "result": "passed", + "browser": "141.0.7390.37", + "viewport": { + "width": 1440, + "height": 900 + }, + "mode": "reverse", + "phase": "repaired", + "requests": [ + "default", + "default", + "acme", + "default", + "default" + ], + "failed": 0, + "verified_at": "2026-09-05T16:57:00.634Z", + "source_sha256": "d1de019dad5bff7fcb78116a8cf53663f1ddcd4c631bdbf0260b68580ebe0eb7" + }, + { + "result": "passed", + "browser": "141.0.7390.37", + "viewport": { + "width": 1440, + "height": 900 + }, + "mode": "superseded", + "phase": "repaired", + "requests": [ + "default", + "default", + "acme", + "third", + "default", + "default" + ], + "failed": 0, + "verified_at": "2026-09-05T16:57:19.715Z", + "source_sha256": "d1de019dad5bff7fcb78116a8cf53663f1ddcd4c631bdbf0260b68580ebe0eb7" + } +] diff --git a/tests/qualification/navigation-reconnect/2026-09-05-access/interrupt.py b/tests/qualification/navigation-reconnect/2026-09-05-access/interrupt.py new file mode 100644 index 000000000..ada87648d --- /dev/null +++ b/tests/qualification/navigation-reconnect/2026-09-05-access/interrupt.py @@ -0,0 +1,38 @@ +import subprocess, time, re, pathlib, signal, json, os +root=pathlib.Path.cwd(); out=root/'tmp/web-followup'; out.mkdir(parents=True, exist_ok=True); log=out/'interruption.log' +with log.open('w') as f: + p=subprocess.Popen(['bash','tests/integration/scripts/run-tests.sh','multi-tenant'],stdout=f,stderr=subprocess.STDOUT) + run=None; before=None; interrupted=False + try: + deadline=time.time()+1200 + while p.poll() is None and time.time()