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(); +}