From d60da624c85e676a722d78ba514a62fd6b36cedd Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Sat, 5 Sep 2026 03:44:17 +0100
Subject: [PATCH] test(web): qualify mobile alert focus return in Chromium and
WebKit
The integrated removed-row focus repair had only jsdom coverage. Exercise populated Timeline and Resource dialogs with production styles in two browser engines so focus trapping, dismissal and fallback regressions are observable without live credentials or customer data. This is synthetic component qualification, not backend delivery evidence.
Change-source: pulse-maintainer
---
.../tests/browser/mobile-alert-history.html | 11 ++
.../tests/browser/mobile-alert-history.tsx | 115 ++++++++++++++++++
.../mobile-alert-history/README.md | 23 ++++
.../mobile-alert-history/run.mjs | 105 ++++++++++++++++
4 files changed, 254 insertions(+)
create mode 100644 frontend-modern/tests/browser/mobile-alert-history.html
create mode 100644 frontend-modern/tests/browser/mobile-alert-history.tsx
create mode 100644 tests/qualification/mobile-alert-history/README.md
create mode 100644 tests/qualification/mobile-alert-history/run.mjs
diff --git a/frontend-modern/tests/browser/mobile-alert-history.html b/frontend-modern/tests/browser/mobile-alert-history.html
new file mode 100644
index 000000000..97f99189e
--- /dev/null
+++ b/frontend-modern/tests/browser/mobile-alert-history.html
@@ -0,0 +1,11 @@
+
+
+
+
+ Mobile alert history qualification
+
+
+
+
+
+
diff --git a/frontend-modern/tests/browser/mobile-alert-history.tsx b/frontend-modern/tests/browser/mobile-alert-history.tsx
new file mode 100644
index 000000000..094478840
--- /dev/null
+++ b/frontend-modern/tests/browser/mobile-alert-history.tsx
@@ -0,0 +1,115 @@
+// Synthetic state: this qualifies browser interaction, not backend delivery.
+import { createSignal } from 'solid-js';
+import { render } from 'solid-js/web';
+import { AlertHistoryMobileList } from '@/features/alerts/AlertHistoryMobileList';
+import type { AlertHistoryState } from '@/features/alerts/useAlertHistoryState';
+import '@/index.css';
+const stub = (callback = (..._args: any[]) => {}) => callback;
+function createState() {
+ const alert = {
+ id: 'alert-1',
+ source: 'alert',
+ resourceId: 'node-1',
+ resourceName: 'pve-production-01',
+ resourceType: 'node',
+ title: 'Backup',
+ description: 'Backup failed after the target became unavailable.',
+ severity: 'warning',
+ status: 'resolved',
+ duration: '14m',
+ startTime: '2026-08-04T14:30:00.000Z',
+ node: 'pve-production-01',
+ nodeDisplayName: 'Production cluster',
+ rawAlertType: 'backup_failed',
+ };
+ const [expandedIncidents, setExpandedIncidents] = createSignal(new Set());
+ const [resourceIncidentPanel, setResourceIncidentPanel] = createSignal<{
+ resourceId: string;
+ resourceName: string;
+ rowKey: string;
+ } | null>(null);
+ const toggleIncidentTimeline = stub((rowKey: string) => {
+ setExpandedIncidents((current) => {
+ const next = new Set(current);
+ if (next.has(rowKey)) next.delete(rowKey);
+ else next.add(rowKey);
+ return next;
+ });
+ });
+ const openResourceIncidentPanel = stub(
+ (resourceId: string, resourceName: string, rowKey: string) => {
+ setResourceIncidentPanel({ resourceId, resourceName, rowKey });
+ },
+ );
+ const closeResourceIncidentPanel = stub(() => setResourceIncidentPanel(null));
+
+ const [groupedAlerts, setGroupedAlerts] = createSignal([
+ {
+ label: 'Today (August 4th)',
+ fullLabel: 'Today, August 4th 2026',
+ alerts: [alert],
+ },
+ ]);
+
+ const incident = {
+ id: 'incident-1',
+ alertIdentifier: alert.id,
+ alertType: 'backup_failed',
+ level: 'warning',
+ resourceId: 'node-1',
+ resourceName: alert.resourceName,
+ status: 'resolved',
+ openedAt: alert.startTime,
+ acknowledged: false,
+ message: 'Backup destination unavailable.',
+ events: [
+ {
+ id: 'event-1',
+ type: 'alert_fired',
+ timestamp: alert.startTime,
+ summary: 'Backup destination unavailable.',
+ },
+ ],
+ };
+ const state = {
+ groupedAlerts,
+ getIncidentRowKey: () => 'alert-1-row',
+ expandedIncidents,
+ incidentLoading: () => ({}),
+ incidentErrors: () => ({}),
+ incidentTimelines: () => ({ 'alert-1-row': incident }),
+ historyIncidentEventFilters: () => new Set(['alert_fired']),
+ setHistoryIncidentEventFilters: stub(),
+ incidentNoteDrafts: () => ({}),
+ setIncidentNoteDraft: stub(),
+ incidentNoteSaving: () => new Set(),
+ saveIncidentNote: stub(),
+ loadIncidentTimeline: stub(),
+ toggleIncidentTimeline,
+ openResourceIncidentPanel,
+ resourceIncidentPanel,
+ resourceIncidents: () => ({ 'node-1': [incident] }),
+ expandedResourceIncidentIds: () => new Set(['incident-1']),
+ toggleResourceIncidentExpanded: stub(),
+ resourceIncidentLoading: () => ({}),
+ resourceIncidentEventFilters: () => new Set(['alert_fired']),
+ setResourceIncidentEventFilters: stub(),
+ refreshResourceIncidentPanel: stub(),
+ setResourceIncidentPanel: closeResourceIncidentPanel,
+ getResource: () => undefined,
+ formatAlertRowTime: () => '14:30',
+ formatAlertRowTimestamp: () => 'Tuesday, 4 August 2026 at 14:30:00',
+ } as unknown as AlertHistoryState;
+
+ return {
+ closeResourceIncidentPanel,
+ openResourceIncidentPanel,
+ state,
+ setGroupedAlerts,
+ toggleIncidentTimeline,
+ };
+}
+
+const fixture = createState();
+Object.assign(window, { removeHistoryRows: () => fixture.setGroupedAlerts([]) });
+render(() => , document.getElementById('root')!);
diff --git a/tests/qualification/mobile-alert-history/README.md b/tests/qualification/mobile-alert-history/README.md
new file mode 100644
index 000000000..1dd9ab802
--- /dev/null
+++ b/tests/qualification/mobile-alert-history/README.md
@@ -0,0 +1,23 @@
+# Mobile alert history browser qualification
+
+From the repository root, after `npm ci` and `npm --prefix frontend-modern ci`:
+
+```sh
+pulse-heavy-run -- node tests/qualification/mobile-alert-history/run.mjs
+```
+
+Requires Playwright's Chromium and WebKit browsers and their system libraries.
+The runner starts a loopback-only Vite server on an ephemeral port and closes
+it and both browsers on completion. No backend, credentials or live data are used.
+
+The fixture renders the production mobile history list and dialog with synthetic
+history and incident events. At 390×844 it checks both Timeline and Resource
+investigations, Tab containment, Escape dismissal, focus return to the surviving
+trigger or list when the row disappears, and page overflow. A computed-style
+assertion guards against accidentally testing without production Tailwind CSS.
+
+This is component-browser evidence, not whole-application, physical-device,
+screen-reader, persistence or notification-delivery qualification. Fixture
+callbacks do not implement note saving, filtering or network operations.
+The fallback case should fail if `return action ?? list` in
+`AlertHistoryMobileList` is changed to `return action ?? null`.
diff --git a/tests/qualification/mobile-alert-history/run.mjs b/tests/qualification/mobile-alert-history/run.mjs
new file mode 100644
index 000000000..e9d457062
--- /dev/null
+++ b/tests/qualification/mobile-alert-history/run.mjs
@@ -0,0 +1,105 @@
+// Run from the repository root with pulse-heavy-run -- node .
+import assert from "node:assert/strict";
+import { chromium, webkit } from "@playwright/test";
+import { createServer } from "../../../frontend-modern/node_modules/vite/dist/node/index.js";
+import { fileURLToPath } from "node:url";
+const root = fileURLToPath(
+ new URL("../../../frontend-modern/", import.meta.url),
+);
+process.chdir(root); // Tailwind resolves its configuration from the frontend directory.
+const server = await createServer({
+ root,
+ configFile: `${root}/vite.config.ts`,
+ server: { host: "127.0.0.1", port: 0, strictPort: false },
+});
+try {
+ await server.listen();
+ const address = server.httpServer.address();
+ for (const [name, engine] of Object.entries({ chromium, webkit })) {
+ const browser = await engine.launch();
+ try {
+ for (const action of ["Timeline", "Resource"]) {
+ for (const removeRow of [false, true]) {
+ const page = await browser.newPage({
+ viewport: { width: 390, height: 844 },
+ isMobile: true,
+ hasTouch: true,
+ });
+ const errors = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await page.goto(
+ `http://127.0.0.1:${address.port}/tests/browser/mobile-alert-history.html`,
+ );
+ const trigger = page.getByRole("button", {
+ name: action,
+ exact: true,
+ });
+ await trigger.waitFor();
+ await trigger.focus();
+ await page.keyboard.press("Enter");
+ const dialog = page.getByRole("dialog");
+ await dialog.waitFor();
+ await dialog
+ .getByText("Backup destination unavailable.", { exact: true })
+ .first()
+ .waitFor();
+ assert.equal(
+ await dialog.evaluate(
+ (el) =>
+ getComputedStyle(el.closest("[data-dialog-layer]")).position,
+ ),
+ "fixed",
+ "Production Tailwind styles must be loaded",
+ );
+ assert.match(
+ await dialog.getAttribute("aria-label"),
+ /pve-production-01/,
+ );
+ for (let i = 0; i < 8; i++) {
+ await page.keyboard.press("Tab");
+ assert.ok(
+ await dialog.evaluate((el) =>
+ el.contains(document.activeElement),
+ ),
+ "Tab must stay in dialog",
+ );
+ }
+ if (removeRow) {
+ await page.evaluate(() => window.removeHistoryRows());
+ assert.equal(await page.locator("article").count(), 0);
+ }
+ await page.keyboard.press("Escape");
+ await dialog.waitFor({ state: "detached" });
+ await page.evaluate(
+ () =>
+ new Promise((resolve) =>
+ requestAnimationFrame(() => requestAnimationFrame(resolve)),
+ ),
+ );
+ const target = removeRow
+ ? page.getByTestId("alert-history-mobile-list")
+ : trigger;
+ assert.ok(
+ await target.evaluate((el) => el === document.activeElement),
+ "Focus must return to surviving trigger or list",
+ );
+ assert.ok(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= innerWidth + 1,
+ ),
+ "No horizontal overflow",
+ );
+ assert.deepEqual(errors, []);
+ console.log(
+ `PASS ${name}: ${action}, row ${removeRow ? "removed" : "retained"}, focus trap/Escape/return/overflow`,
+ );
+ await page.close();
+ }
+ }
+ } finally {
+ await browser.close();
+ }
+ }
+} finally {
+ await server.close();
+}