mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
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
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Mobile alert history qualification</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./mobile-alert-history.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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<string>());
|
||||
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<string>(),
|
||||
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(() => <AlertHistoryMobileList state={fixture.state} />, document.getElementById('root')!);
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,105 @@
|
||||
// Run from the repository root with pulse-heavy-run -- node <this file>.
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user