Merge pull request #1953 from rcourtman/maintainer/20260907T102346Z

Keep Retry and Dismiss outcomes visible after toasts expire
This commit is contained in:
pulse-triage[bot]
2026-09-07 14:14:50 +01:00
committed by GitHub
27 changed files with 688 additions and 2 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

@@ -0,0 +1,66 @@
# Recovery feedback qualification — 7 September 2026
Run from the repository root:
```sh
pulse-heavy-run -- node scripts/check-recovery-feedback.mjs
```
The final run passed 12 cases: real OverviewTab and DestinationsTab, shared
Card/Button/ToastContainer, 1440/900/390px, light and dark. Source digests and
the result are adjacent; the exact changed-source receipt is
frontend-modern/browser-verification.json. Screenshots show retained feedback
after the final toast expires. Representative narrow, intermediate and desktop
screenshots were inspected for readable feedback and reachable controls.
The harness exercises keyboard Retry/Dismiss and native confirmation,
cancellation without mutation, superseding actions, ten-second error-toast
expiry (accelerated browser clock plus exit-animation timer), successful
mutation followed by failed health read, subsequent health refresh, and
keyboard clear with retained focus. On Destinations a healthy refresh removes
the health warning without removing action-failure information. Unit tests
add healthy reconciliation independently of the available Overview controls,
callback throw/rejection, and older completion versus clear/newer ownership.
54 focused tests passed across useNotificationDeliveryHealth,
AlertDeliveryHealthCard, useAlertDestinationsTabState,
OverviewTab.deliveryactions and OverviewTab.emptystate. TypeScript and
changed-file ESLint passed. No full suite, build, installed notification
delivery, recipient receipt, app-shell navigation, or screen-reader testing
is claimed.
## Failed harness attempts, retained rather than counted as passes
1. Initial run timed out waiting for Retry. The API route stub matched
/src/api modules as well as application API requests, leaving a blank page.
2. Diagnostic rerun confirmed an empty body and no pageerror event; failed.png
retains that image. The in-flight script had loaded before the correction.
3. With the route constrained to the fixture origin's /api/ prefix, Overview
rendered and passed, but the destination Refresh selector matched both
health and activity controls.
4. A remaining healthy-reconciliation Refresh selector had the same ambiguity.
Both selectors now identify the health card, not the first matching button.
5. The full destination render exposed a missing required email 'to' array in
the fixture (undefined.join). The fixture now supplies complete email and
Apprise settings and a correctly shaped empty delivery log.
The final rerun passed all cases with zero page errors. No runtime source was
changed to suppress these harness failures.
## Evidence and boundaries
The current demand-ledger named bet, “Recovery feedback without a reading
deadline” (pulse-pro FEATURE_REQUESTS.md, introduced by 9c25eaf1), authorises
this main-only scope. Fresh W3C guidance read on 7 September distinguishes
temporary information with an untimed equivalent from information lost at
expiry:
https://www.w3.org/WAI/WCAG22/Understanding/timing-adjustable.html
Its status technique supports an existing status container and explicit
atomic announcement semantics:
https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA22
This is not a WCAG verdict or a fix for the reporter's HTTP503. Feedback is
view-local: clear, a newer confirmed action, leaving the view or reload can
remove it. Queue acceptance is not delivery receipt. The optional activity
callback is tested separately; the current destination log loader already
handles its own read failures.
@@ -0,0 +1,10 @@
{
"result": "passed",
"cases": 12,
"viewports": [
1440,
900,
390
],
"scope": "Real OverviewTab and DestinationsTab, shared toast and feedback in Chromium; scripted API only, not installed delivery or recipient receipt."
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

@@ -0,0 +1,8 @@
{
"frontend-modern/src/features/alerts/AlertQueueActionFeedback.tsx": "4fd4e2757b11bc4adb52bad293eb5a7a8d55301e12e5f4c3816fdbe0682bc578",
"frontend-modern/src/features/alerts/OverviewTab.tsx": "6700ce02ad19b4133860a7b680ed145e21ce27fa7fd4053937ce9cd3fc0c02e1",
"frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx": "968d6d3f82d59235a13bfcd89b8405558d0286c89a22efc12bbc7e2ff5ad1600",
"frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts": "25d47c0ce64f433ce0fd8ca851434d3c09441970d1e929de8c7787f209ab1ddb",
"frontend-modern/src/features/alerts/useNotificationDeliveryHealth.ts": "863e1b167bc27df5d46976b704a883ba6df9066bb6e42133935dbaa2c02f0b03",
"scripts/check-recovery-feedback.mjs": "1cf11b716b9a65c710f16e4765645208663cfe98dd26b87bd953e5309a3b6c4b"
}
@@ -15,6 +15,34 @@
## Purpose
### Retained-queue recovery feedback has no reading deadline
Retry and Dismiss failures have a view-local untimed equivalent beside the
delivery-health card on Overview and Notifications. A health refresh, including
a verified healthy result that removes the warning, does not erase a rejected
action's message. Cancellation preserves it. Deliberate clear or the next
confirmed action supersedes it; reloading or leaving the view resets it.
The existing error toast remains transient.
An accepted queue mutation still emits success if the health read fails.
That read owns the existing unavailable warning. A synchronous throw or rejected
promise from the optional activity-refresh callback must not claim the mutation
failed: retained feedback explicitly distinguishes accepted action from failed
activity refresh. Older action/callback completions cannot restore feedback
after a clear or overwrite a newer action's message. Neither acceptance nor a
healthy queue proves intended-recipient receipt; no retry policy changes.
Proof: useNotificationDeliveryHealth.test.tsx pins expiry-independent state,
health reconciliation, cancellation, callback failure and ownership;
AlertDeliveryHealthCard.test.tsx pins mounted live-region composition and clear
focus. scripts/check-recovery-feedback.mjs exercises the real Overview and
destination views with scripted API responses, real toast expiry, keyboard
actions/clear, unavailable-to-healthy reconciliation and three viewport widths.
This is the main-only named bet “Recovery feedback without a reading deadline”
from the current demand ledger, not a backend failure diagnosis or backport.
Overview delivery diagnoses use latest-started refresh ownership. Older bulk
responses cannot overwrite newer card notification status, and an empty active
alert set invalidates outstanding reads. Disposal also prevents updates. Failed
@@ -20,6 +20,24 @@
## Purpose
### Recovery feedback composes shared controls without a timer
The alerts-owned AlertQueueActionFeedback composes Card and Button rather than
altering global toast lifetimes. Its polite atomic status region is mounted
before failure text arrives; the warning card uses semantic foreground and wraps
text and the explicit “Clear recovery message” control at narrow widths.
The enclosing labelled region remains mounted and receives focus before clear
removes the button, avoiding focus loss to the document body. Updating feedback
does not steal focus. This is view-local feedback, not durable delivery history.
The live region stays independent of delivery-health conditional rendering:
a later healthy observation can remove the health warning without removing
failed-action information. AlertDeliveryHealthCard.test.tsx verifies status
and focus composition. scripts/check-recovery-feedback.mjs verifies both real
feature views, keyboard activation/clear and text/control containment at 1440,
900 and 390px, with scripted responses and genuine toast expiry. It does not
establish screen-reader announcement quality or installed notification delivery.
The shared action evidence disclosure preserves the named observer independently
of the executor. Its observation timestamp uses the neutral label `Observed`,
followed by the separate Pulse receipt time. Independent Proxmox API evidence
@@ -11,6 +11,32 @@ describe('Toast', () => {
vi.useRealTimers();
});
it.each([
['error', 'alert', 'assertive'],
['warning', 'alert', 'assertive'],
['success', 'status', 'polite'],
['info', 'status', 'polite'],
] as const)('preserves %s announcement semantics without moving focus', (type, role, live) => {
render(() => (
<>
<button>Recovery action</button>
<ToastContainer />
</>
));
const action = screen.getByRole('button', { name: 'Recovery action' });
action.focus();
window.showToast(type, 'Recovery result', 'Action context');
const announcement = screen.getByRole(role);
expect(announcement).toHaveAttribute('aria-live', live);
expect(announcement).toHaveAttribute('aria-atomic', 'true');
expect(announcement).toHaveTextContent('Recovery result');
expect(announcement).toHaveTextContent('Action context');
expect(action).toHaveFocus();
expect(screen.queryByRole(role === 'alert' ? 'status' : 'alert')).not.toBeInTheDocument();
});
it('keeps all toasts created within a batch', () => {
render(() => <ToastContainer />);
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import type { NotificationQueueHealth } from '@/api/notifications';
import { AlertQueueActionFeedback } from './AlertQueueActionFeedback';
import { AlertDeliveryHealthCard } from './AlertDeliveryHealthCard';
const degradedHealth: NotificationQueueHealth = {
@@ -215,3 +216,19 @@ describe('AlertDeliveryHealthCard', () => {
);
});
});
describe('recovery feedback beside delivery health', () => {
afterEach(() => cleanup());
it('keeps a mounted live region and returns focus to it when deliberately cleared', () => {
const [message, setMessage] = createSignal<string | null>(null);
render(() => <AlertQueueActionFeedback message={message()} onClear={() => setMessage(null)} />);
expect(screen.getByRole('status')).toBeEmptyDOMElement();
setMessage('Unable to retry retained notification deliveries.');
expect(screen.getByRole('status')).toHaveTextContent('Unable to retry');
const clear = screen.getByRole('button', { name: 'Clear recovery message' });
clear.focus();
fireEvent.click(clear);
expect(screen.getByRole('status')).toBeEmptyDOMElement();
expect(screen.getByRole('region', { name: 'Notification recovery feedback' })).toHaveFocus();
});
});
@@ -0,0 +1,34 @@
import { Show } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { Button } from '@/components/shared/Button';
// Keep the live region mounted before its content changes, and retain a focus
// destination when the user clears the message. This is view-local, not history.
export function AlertQueueActionFeedback(props: { message: string | null; onClear: () => void }) {
let region!: HTMLDivElement;
return (
<div ref={region} role="region" aria-label="Notification recovery feedback" tabIndex={-1}>
<div role="status" aria-live="polite" aria-atomic="true">
<Show when={props.message}>
<Card tone="warning" padding="sm">
<div class="flex flex-wrap items-start justify-between gap-3">
<p class="min-w-0 flex-1 basis-72 break-words text-sm text-base-content">
{props.message}
</p>
<Button
variant="secondary"
size="sm"
onClick={() => {
region.focus();
props.onClear();
}}
>
Clear recovery message
</Button>
</div>
</Card>
</Show>
</div>
</div>
);
}
@@ -1,3 +1,4 @@
import { AlertQueueActionFeedback } from './AlertQueueActionFeedback';
import { createSignal, onCleanup, createEffect, onMount, Show } from 'solid-js';
import { useLocation } from '@solidjs/router';
@@ -78,6 +79,10 @@ export function OverviewTab(props: {
return (
<div class="space-y-4 sm:space-y-6">
<AlertQueueActionFeedback
message={deliveryHealthState.queueActionFeedback()}
onClear={deliveryHealthState.clearQueueActionFeedback}
/>
<Show when={deliveryHealthState.deliveryNeedsAttention()}>
<AlertDeliveryHealthCard
health={deliveryHealthState.deliveryHealth()?.queue ?? null}
@@ -2,6 +2,7 @@ import { createRoot } from 'solid-js';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { NotificationsAPI } from '@/api/notifications';
import { notificationStore } from '@/stores/notifications';
import { useNotificationDeliveryHealth } from '../useNotificationDeliveryHealth';
@@ -13,10 +14,16 @@ vi.mock('@/api/notifications', () => ({
},
}));
vi.mock('@/stores/notifications', () => ({
notificationStore: { success: vi.fn(), error: vi.fn() },
}));
const healthWith = (status: string) => ({ queue: { status, failed: 3, deadLetter: 1 } }) as never;
describe('useNotificationDeliveryHealth', () => {
beforeEach(() => {
vi.mocked(notificationStore.success).mockReset();
vi.mocked(notificationStore.error).mockReset();
vi.mocked(NotificationsAPI.getHealth).mockReset();
vi.mocked(NotificationsAPI.dismissTerminalFailures).mockReset();
vi.mocked(NotificationsAPI.retryTerminalFailures).mockReset();
@@ -200,6 +207,13 @@ describe('useNotificationDeliveryHealth', () => {
expect(NotificationsAPI[action]).toHaveBeenCalledOnce();
expect(NotificationsAPI.getHealth).toHaveBeenCalledTimes(3);
expect(onAfterQueueAction).toHaveBeenCalledOnce();
// A failed health read must not reclassify an accepted queue mutation.
expect(notificationStore.success).toHaveBeenCalledWith(
action === 'retryTerminalFailures'
? '2 retained deliveries queued for retry.'
: '2 retained failures dismissed.',
);
expect(notificationStore.error).not.toHaveBeenCalled();
expect(state.deliveryHealth()).toBeNull();
expect(state.deliveryHealthUnavailable()).toBe(true);
expect(state.deliveryNeedsAttention()).toBe(true);
@@ -242,6 +256,15 @@ describe('useNotificationDeliveryHealth', () => {
expect(NotificationsAPI[action]).toHaveBeenCalledTimes(outcome === 'rejected' ? 1 : 0);
expect(NotificationsAPI.getHealth).toHaveBeenCalledTimes(1);
expect(onAfterQueueAction).not.toHaveBeenCalled();
expect(notificationStore.success).not.toHaveBeenCalled();
expect(notificationStore.error).toHaveBeenCalledTimes(outcome === 'rejected' ? 1 : 0);
if (outcome === 'rejected') {
expect(notificationStore.error).toHaveBeenCalledWith(
action === 'retryTerminalFailures'
? 'Unable to retry retained notification deliveries.'
: 'Unable to dismiss retained notification failures.',
);
}
expect(state.deliveryHealth()).toBe(health);
expect(state.deliveryNeedsAttention()).toBe(true);
expect(state.deliveryHealthUnavailable()).toBe(false);
@@ -253,4 +276,148 @@ describe('useNotificationDeliveryHealth', () => {
}),
);
});
describe.each(['dismissTerminalFailures', 'retryTerminalFailures'] as const)(
'%s feedback',
(action) => {
it('retains failed action information across time, healthy reads and cancellation until cleared or superseded', () =>
createRoot(async (dispose) => {
vi.useFakeTimers();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
try {
vi.mocked(NotificationsAPI.getHealth).mockResolvedValue({
queue: { status: 'degraded', attentionRequired: 2 },
} as never);
vi.mocked(NotificationsAPI[action]).mockRejectedValueOnce(new Error('failed'));
const state = useNotificationDeliveryHealth();
await state.loadDeliveryHealth();
await state[action]();
const failure = state.queueActionFeedback();
expect(failure).toMatch(/^Unable to/);
await vi.advanceTimersByTimeAsync(11000);
vi.mocked(NotificationsAPI.getHealth).mockResolvedValueOnce(healthWith('healthy'));
await state.loadDeliveryHealth();
expect(state.deliveryNeedsAttention()).toBe(false);
expect(state.queueActionFeedback()).toBe(failure);
await state.loadDeliveryHealth();
confirmSpy.mockReturnValueOnce(false);
await state[action]();
expect(state.queueActionFeedback()).toBe(failure);
state.clearQueueActionFeedback();
expect(state.queueActionFeedback()).toBeNull();
vi.mocked(NotificationsAPI[action]).mockRejectedValueOnce(new Error('again'));
await state[action]();
expect(state.queueActionFeedback()).toBe(failure);
vi.mocked(NotificationsAPI[action]).mockResolvedValueOnce({ affected: 2 } as never);
await state[action]();
expect(state.queueActionFeedback()).toBeNull();
} finally {
vi.useRealTimers();
confirmSpy.mockRestore();
dispose();
}
}));
it.each(['throw', 'reject'] as const)(
'does not misreport an accepted mutation when the activity callback fails: %s',
(failure) =>
createRoot(async (dispose) => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
try {
vi.mocked(NotificationsAPI.getHealth).mockResolvedValue({
queue: { status: 'degraded', attentionRequired: 2 },
} as never);
vi.mocked(NotificationsAPI[action]).mockResolvedValue({ affected: 2 } as never);
const onAfterQueueAction = () => {
if (failure === 'throw') throw new Error('activity failed');
return Promise.reject(new Error('activity failed'));
};
const state = useNotificationDeliveryHealth({ onAfterQueueAction });
await state.loadDeliveryHealth();
await state[action]();
expect(notificationStore.success).toHaveBeenCalledOnce();
expect(notificationStore.error).not.toHaveBeenCalled();
expect(state.queueActionFeedback()).toMatch(
/^The queue action succeeded, but notification activity/,
);
expect(state.retryingTerminalFailures()).toBe(false);
expect(state.dismissingTerminalFailures()).toBe(false);
} finally {
confirmSpy.mockRestore();
dispose();
}
}),
);
it.each(['clear', 'newer'] as const)(
'does not restore stale callback feedback after %s',
(next) =>
createRoot(async (dispose) => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
try {
vi.mocked(NotificationsAPI.getHealth).mockResolvedValue({
queue: { status: 'degraded', attentionRequired: 2 },
} as never);
vi.mocked(NotificationsAPI[action]).mockResolvedValue({ affected: 2 } as never);
let rejectOld!: (error: Error) => void;
const onAfterQueueAction = vi.fn().mockImplementationOnce(
() =>
new Promise((_, reject) => {
rejectOld = reject;
}),
);
const state = useNotificationDeliveryHealth({ onAfterQueueAction });
await state.loadDeliveryHealth();
const pending = state[action]();
await Promise.resolve();
await Promise.resolve();
if (next === 'clear') state.clearQueueActionFeedback();
else {
vi.mocked(NotificationsAPI[action]).mockRejectedValueOnce(
new Error('new action failed'),
);
await state[action]();
}
const current = state.queueActionFeedback();
rejectOld(new Error('old activity failed'));
await pending;
expect(state.queueActionFeedback()).toBe(current);
if (next === 'newer') expect(current).toMatch(/^Unable to/);
} finally {
confirmSpy.mockRestore();
dispose();
}
}),
);
},
);
it('does not let an older rejected mutation overwrite the newer action failure', () =>
createRoot(async (dispose) => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
try {
vi.mocked(NotificationsAPI.getHealth).mockResolvedValue({
queue: { status: 'degraded', attentionRequired: 2 },
} as never);
let rejectOld!: (error: Error) => void;
vi.mocked(NotificationsAPI.retryTerminalFailures).mockReturnValueOnce(
new Promise((_, reject) => {
rejectOld = reject;
}),
);
vi.mocked(NotificationsAPI.dismissTerminalFailures).mockRejectedValueOnce(
new Error('new failure'),
);
const state = useNotificationDeliveryHealth();
await state.loadDeliveryHealth();
const old = state.retryTerminalFailures();
await state.dismissTerminalFailures();
rejectOld(new Error('old failure'));
await old;
expect(state.queueActionFeedback()).toBe(
'Unable to dismiss retained notification failures.',
);
} finally {
confirmSpy.mockRestore();
dispose();
}
}));
});
@@ -1,3 +1,4 @@
import { AlertQueueActionFeedback } from '../AlertQueueActionFeedback';
import { createMemo, createSignal, Show } from 'solid-js';
import { hasFeature } from '@/stores/license';
import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
@@ -79,6 +80,10 @@ export function DestinationsTab(props: DestinationsTabProps) {
)}
</Show>
<AlertQueueActionFeedback
message={state.queueActionFeedback()}
onClear={state.clearQueueActionFeedback}
/>
<Show when={state.deliveryNeedsAttention()}>
<AlertDeliveryHealthCard
health={state.deliveryHealth()?.queue ?? null}
@@ -36,6 +36,8 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro
const [testingEmail, setTestingEmail] = createSignal(false);
const [testingApprise, setTestingApprise] = createSignal(false);
const {
queueActionFeedback,
clearQueueActionFeedback,
deliveryHealth,
deliveryHealthUnavailable,
refreshingDeliveryHealth,
@@ -163,6 +165,8 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro
return {
appriseState,
queueActionFeedback,
clearQueueActionFeedback,
deliveryHealth,
deliveryHealthUnavailable,
deliveryLog,
@@ -55,6 +55,33 @@ export function useNotificationDeliveryHealth(options?: {
(deliveryHealthUnavailable() || deliveryHealth()?.queue.status === 'degraded'),
);
// Action failure is independent of current queue health. A healthy read is
// not evidence that a previously rejected action succeeded.
const [queueActionFeedback, setQueueActionFeedback] = createSignal<string | null>(null);
let latestAction = 0;
const clearQueueActionFeedback = () => {
++latestAction;
setQueueActionFeedback(null);
};
const refreshAfterAction = async (action: number) => {
await Promise.all([
loadDeliveryHealth(),
Promise.resolve()
.then(() => options?.onAfterQueueAction?.())
.catch((error) => {
logger.error(
'Failed to refresh notification activity after accepted queue action',
error,
);
if (action === latestAction) {
setQueueActionFeedback(
'The queue action succeeded, but notification activity could not be refreshed. Reload this view to check activity.',
);
}
}),
]);
};
const [retryingTerminalFailures, setRetryingTerminalFailures] = createSignal(false);
const [dismissingTerminalFailures, setDismissingTerminalFailures] = createSignal(false);
@@ -63,15 +90,19 @@ export function useNotificationDeliveryHealth(options?: {
if (count <= 0 || !confirm(getAlertDestinationsDeliveryRetryConfirmation(count))) {
return;
}
const action = ++latestAction;
setQueueActionFeedback(null);
setRetryingTerminalFailures(true);
try {
const result = await NotificationsAPI.retryTerminalFailures();
notificationStore.success(
`${result.affected} retained ${result.affected === 1 ? 'delivery' : 'deliveries'} queued for retry.`,
);
await Promise.all([loadDeliveryHealth(), Promise.resolve(options?.onAfterQueueAction?.())]);
await refreshAfterAction(action);
} catch (error) {
logger.error('Failed to retry retained notification deliveries', error);
if (action === latestAction)
setQueueActionFeedback('Unable to retry retained notification deliveries.');
notificationStore.error('Unable to retry retained notification deliveries.');
} finally {
setRetryingTerminalFailures(false);
@@ -83,15 +114,19 @@ export function useNotificationDeliveryHealth(options?: {
if (count <= 0 || !confirm(getAlertDestinationsDeliveryDismissConfirmation(count))) {
return;
}
const action = ++latestAction;
setQueueActionFeedback(null);
setDismissingTerminalFailures(true);
try {
const result = await NotificationsAPI.dismissTerminalFailures();
notificationStore.success(
`${result.affected} retained ${result.affected === 1 ? 'failure' : 'failures'} dismissed.`,
);
await Promise.all([loadDeliveryHealth(), Promise.resolve(options?.onAfterQueueAction?.())]);
await refreshAfterAction(action);
} catch (error) {
logger.error('Failed to dismiss retained notification failures', error);
if (action === latestAction)
setQueueActionFeedback('Unable to dismiss retained notification failures.');
notificationStore.error('Unable to dismiss retained notification failures.');
} finally {
setDismissingTerminalFailures(false);
@@ -99,6 +134,8 @@ export function useNotificationDeliveryHealth(options?: {
};
return {
queueActionFeedback,
clearQueueActionFeedback,
deliveryHealth,
deliveryHealthUnavailable,
refreshingDeliveryHealth,
+261
View File
@@ -0,0 +1,261 @@
// Isolated real-browser component qualification; no installed backend or delivery claim.
import { createServer } from "../frontend-modern/node_modules/vite/dist/node/index.js";
import solid from "../frontend-modern/node_modules/vite-plugin-solid/dist/esm/index.mjs";
import { chromium } from "@playwright/test";
import { resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";
const root = resolve("frontend-modern");
process.chdir(root);
const fixture = `
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { AlertsAPI } from '/src/api/alerts';
import { NotificationsAPI } from '/src/api/notifications';
import { OverviewTab } from '/src/features/alerts/OverviewTab';
import { DestinationsTab } from '/src/features/alerts/tabs/DestinationsTab';
import { ToastContainer } from '/src/components/Toast/Toast';
import '/src/index.css';
window.health = 'degraded'; window.action = 'reject';
window.mutations = 0;
NotificationsAPI.getHealth = async () => {
if(window.health === 'error') throw new Error('scripted offline');
return {queue:{status:window.health, attentionRequired:window.health==='healthy'?0:2, failed:2,deadLetter:0}};
};
NotificationsAPI.retryTerminalFailures = NotificationsAPI.dismissTerminalFailures = async () => {
window.mutations++;
if(window.action==='reject') throw new Error('scripted rejected');
return {affected:2};
};
NotificationsAPI.getDeliveryLog = async () => ({entries:[],windowDays:7,completedRetentionDays:7,deadLetterRetentionDays:30});
NotificationsAPI.getWebhooks = async () => [];
AlertsAPI.getEvents = async () => [];
AlertsAPI.getDeliveryDiagnoses = async () => [];
const noop = () => {};
function Overview() {return <OverviewTab overrides={[]} activeAlerts={{}} updateAlert={noop}
showQuickTip={()=>false} dismissQuickTip={noop} showAcknowledged={()=>true}
setShowAcknowledged={noop} alertsDisabled={()=>false}/>;}
function Destinations() {return <DestinationsTab
emailConfig={()=>({enabled:false,provider:'smtp',from:'',server:'',username:'',password:'',port:587,to:[],tls:true,startTLS:true,replyTo:'',maxRetries:3,retryDelay:5,rateLimit:60})} setEmailConfig={noop}
appriseConfig={()=>({enabled:false,mode:'cli',targetsText:'',configKey:'',serverUrl:'',timeoutSeconds:30,apiKey:'',apiKeyHeader:'X-API-KEY',hasApiKey:false,skipTlsVerify:false,cliPath:'apprise'})} setAppriseConfig={noop}
configLoadError={()=>null} isRetrying={()=>false} isLoadingDestinations={()=>false} onRetryLoad={noop}
webhooks={()=>[]} setHasUnsavedChanges={noop} deadManPingUrl={()=>''} setDeadManPingUrl={noop}
pushMinimumSeverity={()=>'all'} setPushMinimumSeverity={noop}/>;}
function Fixture() {return <main class="p-4"><ToastContainer/>{location.pathname.endsWith('destinations')?<Destinations/>:<Overview/>}</main>;}
render(()=><Router><Route path="/qualification/*" component={Fixture}/></Router>,document.getElementById('root'));
`;
const server = await createServer({
root,
configFile: false,
optimizeDeps: {
noDiscovery: true,
entries: [],
esbuildOptions: { target: "esnext" },
},
esbuild: { target: "esnext" },
plugins: [
solid(),
{
name: "recovery-fixture",
configureServer(s) {
s.middlewares.use((req, res, next) => {
if (req.url?.startsWith("/qualification")) {
res.setHeader("Content-Type", "text/html");
res.end(
'<div id="root"></div><script type="module" src="/recovery-fixture.tsx"></script>',
);
} else next();
});
},
resolveId(id) {
if (id === "/recovery-fixture.tsx") return id;
},
load(id) {
if (id === "/recovery-fixture.tsx") return fixture;
},
},
],
resolve: { alias: { "@": resolve(root, "src") } },
server: { host: "127.0.0.1", port: 5199, strictPort: true },
});
let browser;
const output = resolve("../docs/qualification/recovery-feedback");
try {
await server.listen();
browser = await chromium.launch({ headless: true });
mkdirSync(output, { recursive: true });
let cases = 0;
for (const width of [1440, 900, 390])
for (const surface of ["overview", "destinations"])
for (const theme of ["light", "dark"]) {
const page = await browser.newPage({
viewport: { width, height: 1000 },
});
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("http://127.0.0.1:5199/api/**", (route) =>
route.fulfill({ json: [] }),
);
await page.goto("http://127.0.0.1:5199/qualification/" + surface);
await page.evaluate(
(theme) =>
document.documentElement.classList.toggle("dark", theme === "dark"),
theme,
);
const feedback = page.getByRole("region", {
name: "Notification recovery feedback",
});
const retry = page.getByRole("button", {
name: "Retry retained deliveries",
exact: true,
});
const dismiss = page.getByRole("button", {
name: "Dismiss retained failures",
exact: true,
});
const healthCard = page.getByRole("alert").filter({ has: retry });
let accept = true;
page.on("dialog", (dialog) =>
accept ? dialog.accept() : dialog.dismiss(),
);
await retry.waitFor().catch(async (error) => {
console.error(
JSON.stringify({
surface,
width,
theme,
errors,
body: await page.locator("body").innerText(),
}),
);
await page.screenshot({
path: resolve(output, "failed.png"),
fullPage: true,
});
throw error;
});
await page.clock.install();
// Keyboard confirmation, genuine toast expiry, durable equivalent.
await retry.focus();
await page.keyboard.press("Enter");
await feedback
.getByText("Unable to retry retained notification deliveries.", {
exact: true,
})
.waitFor();
await page.clock.fastForward(12000);
await page.clock.runFor(350);
assert.match(await feedback.innerText(), /Unable to retry/);
assert.equal(
await page
.getByText("Unable to retry retained notification deliveries.", {
exact: true,
})
.count(),
1,
);
// Cancellation leaves the previous message and does not submit.
const before = await page.evaluate(() => window.mutations);
accept = false;
await dismiss.click();
assert.equal(await page.evaluate(() => window.mutations), before);
assert.match(await feedback.innerText(), /Unable to retry/);
accept = true;
await dismiss.focus();
await page.keyboard.press("Enter");
await feedback
.getByText("Unable to dismiss retained notification failures.", {
exact: true,
})
.waitFor();
await page.evaluate(() => {
window.action = "accept";
window.health = "error";
});
await retry.click();
await healthCard
.getByRole("button", { name: "Refresh delivery status", exact: true })
.waitFor();
assert.equal(await feedback.getByRole("button").count(), 0);
assert.equal(
await page
.getByText("Unable to retry retained notification deliveries.", {
exact: true,
})
.count(),
0,
);
// Recover the unavailable snapshot; an accepted mutation did not clear it.
await page.evaluate(() => {
window.health = "degraded";
window.action = "reject";
});
await healthCard
.getByRole("button", { name: "Refresh delivery status", exact: true })
.click();
await dismiss.click();
await feedback
.getByText("Unable to dismiss retained notification failures.", {
exact: true,
})
.waitFor();
if (surface === "destinations") {
await page.evaluate(() => {
window.health = "healthy";
});
await healthCard
.getByRole("button", {
name: "Refresh delivery status",
exact: true,
})
.click();
await healthCard.waitFor({ state: "detached" });
assert.match(await feedback.innerText(), /Unable to dismiss/);
}
assert.equal(
await feedback.evaluate((el) => {
const range = document.createRange();
range.selectNodeContents(el);
return [...range.getClientRects()].every(
(r) => r.left >= 0 && r.right <= innerWidth,
);
}),
true,
"feedback text and controls fit viewport",
);
await page.mouse.move(0, 0);
await page.clock.fastForward(12000);
await page.clock.runFor(400);
assert.match(await feedback.innerText(), /Unable to dismiss/);
await page.screenshot({
path: resolve(output, width + "-" + surface + "-" + theme + ".png"),
fullPage: true,
});
const clear = feedback.getByRole("button", {
name: "Clear recovery message",
});
await clear.focus();
await page.keyboard.press("Enter");
assert.equal(await feedback.getByRole("button").count(), 0);
assert.equal(
await feedback.evaluate((el) => document.activeElement === el),
true,
);
assert.deepEqual(errors, []);
await page.close();
cases++;
}
console.log(
JSON.stringify({
result: "passed",
cases,
viewports: [1440, 900, 390],
scope:
"Real OverviewTab and DestinationsTab, shared toast and feedback in Chromium; scripted API only, not installed delivery or recipient receipt.",
}),
);
} finally {
await browser?.close();
await server.close();
}