fix(alerts): retain notification recovery failure feedback
Recovery failures otherwise disappear with their toast, leaving slower readers without the action outcome. Keep a view-local untimed equivalent on Overview and Notifications until clear or a newer confirmed action, independently of queue health. Separate accepted queue mutations from optional activity-refresh failures. Cover ownership and cancellation in focused tests, and verify both real views with scripted APIs in Chromium at three widths and both themes. This implements the current main-only recovery feedback bet, not a backend delivery fix or release-line backport. Change-source: pulse-maintainer
|
After Width: | Height: | Size: 258 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
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."
|
||||
}
|
||||
|
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,26 @@
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
|
||||
Disk I/O presentation preserves each observed direction independently. Shared
|
||||
formatting renders a missing rate as a dash and measured idle as numeric zero.
|
||||
Partial observations cannot form a complete throughput total for sorting or
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "560dbf314c4fc3744f52aa4e5a6a204cafe3aa7d",
|
||||
"verified_at": "2026-09-07T08:40:47Z",
|
||||
"base_sha": "e949c15c3d0e751e6cd7b1d8d46a1147840ae3c8",
|
||||
"verified_at": "2026-09-07T10:44:07Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/AI/Chat/index.tsx",
|
||||
"frontend-modern/src/components/AI/FindingsPanel.tsx",
|
||||
"frontend-modern/src/components/patrol/ApprovalSection.tsx",
|
||||
"frontend-modern/src/types/resource.ts"
|
||||
"frontend-modern/src/features/alerts/AlertQueueActionFeedback.tsx",
|
||||
"frontend-modern/src/features/alerts/OverviewTab.tsx",
|
||||
"frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx",
|
||||
"frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts",
|
||||
"frontend-modern/src/features/alerts/useNotificationDeliveryHealth.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/AI/Chat/index.tsx": "a31459ed9eeda0e96a192c0b6b471746c1a6da70cd8c68ab75b833ca763071f8",
|
||||
"frontend-modern/src/components/AI/FindingsPanel.tsx": "92f1fd73740cb71c868c2d7054323d43f93ebc53dbb5cacdc1f828c5c2f2a868",
|
||||
"frontend-modern/src/components/patrol/ApprovalSection.tsx": "b04e131f5a82d538f37aa9cb3aadaf0f7303266cfc318ff04a42a8f7f6a4f6ba",
|
||||
"frontend-modern/src/types/resource.ts": "54217a487ce295d433a9e016bf655ab8572a903383ae0516fdd52e5004524ea7"
|
||||
"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"
|
||||
},
|
||||
"routes": [
|
||||
"/patrol",
|
||||
"/actions?action=act_185eebf5dfc24652e6332881dbe94f6c",
|
||||
"/actions?action=act_7687850d214e9c5f7038a436c6cc2ff7",
|
||||
"/actions?action=act_e5e1f06da3dc42215a97360731d7e36b"
|
||||
"/qualification/overview",
|
||||
"/qualification/destinations"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
@@ -36,26 +36,20 @@
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Activity with All findings including resolved records",
|
||||
"Expired action with needs-attention investigation and retained linked action",
|
||||
"Completed action with independent verification",
|
||||
"Rejected action without execution controls",
|
||||
"Expanded investigation thread with raw filesystem evidence and nested output scroll",
|
||||
"Assistant empty conversation with attached finding",
|
||||
"New Assistant conversation with context cleared and ordinary starters restored",
|
||||
"Existing Assistant conversation loaded from recent sessions",
|
||||
"Reloaded exact action review"
|
||||
"Rejected Retry and Dismiss with untimed feedback after real toast timers expire using the Playwright clock",
|
||||
"Cancelled action retains prior feedback without another API mutation",
|
||||
"Accepted mutation with failed health read preserves unavailable warning and does not report mutation failure",
|
||||
"Newer confirmed action supersedes prior feedback",
|
||||
"Destination healthy refresh removes health warning but retains failed-action information",
|
||||
"Deliberate clear removes feedback and retains focus in its labelled region",
|
||||
"Light and dark themes at desktop, intermediate and narrow widths"
|
||||
],
|
||||
"interactions": [
|
||||
"Keyboard focus and Enter open the finding review and investigation thread",
|
||||
"Expand tool results and scroll inside the native filesystem observation output",
|
||||
"Discuss with Assistant retains the finding and expired action context without unrelated starters",
|
||||
"Start new Assistant session clears context and restores welcome and recent sessions",
|
||||
"Load existing session and inspect its transcript",
|
||||
"Follow exact Actions links, inspect planned state and recorded independent verification",
|
||||
"Expand policy evidence, evidence details and delivery identifiers with keyboard",
|
||||
"Reload exact action URLs, close with Escape and the explicit close button",
|
||||
"Inspect desktop, intermediate and narrow placement, nested scrolling, footer reachability and document overflow"
|
||||
"Focus Retry and Dismiss and activate with Enter; accept or cancel native confirmation",
|
||||
"Advance browser clock beyond ten-second error duration and complete toast exit timer",
|
||||
"Refresh unavailable health; reconcile destination health to healthy",
|
||||
"Focus and activate Clear recovery message using Enter",
|
||||
"Assert feedback text and controls fit viewport; inspect representative screenshots"
|
||||
],
|
||||
"notes": "Real bundled development runtime SHA256 1f1f71d2fd77b89f43a980d1e990a4010d2ec467502551555d5ab702f1aa71e6. Playwright scripts and screenshots are in workspace tmp/patrol-filesystem-evidence/{storage-browser,action-browser}. Final pixels were reviewed after transitions settled. Browser proof covers the named changes, not full product readiness. Independent qualification still fails missing-access continuity and VM execution. An existing Assistant verification-policy message leaking into historical transcript is retained as a separate canonical orchestration gap. Mock mode is off. Browser scripts allow only login and selected-provider readiness POSTs. Repeated after the shared incremental alert-history change. Eighteen bounded concurrent attention-summary requests completed and the final goroutine check found no history walkers. This functional check is not a workstation performance benchmark."
|
||||
"notes": "Real source OverviewTab and DestinationsTab with shared Card, Button and ToastContainer in isolated Vite/Chromium fixture; scripted APIs, not full app-shell routing, installed backend delivery or intended-recipient receipt. Callback throw/rejection and stale completion ownership are unit-tested, not browser-injected. No screen-reader reproduction or WCAG conformance claim. Evidence and five unsuccessful harness attempts documented in docs/qualification/recovery-feedback/README.md. Final run: 12 cases, zero page errors. Runtime source was unchanged throughout harness repairs."
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -276,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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||