Point Patrol trigger contracts at the Patrol settings page

27ba85ddc6 removed the Patrol page's configuration popover, and schedule,
trigger and model settings moved to Settings > Pulse Intelligence >
Patrol. Several contract clauses still described the popover: the
alert-trigger severity selector saving optimistically through its own
updateSettings call with revert-on-error, toggles named through
ariaLabelledBy, a header "Schedule & model" drawer, a header settings
surface with trigger toggles, and a Patrol mode dialog.

None of that matches the code. The selector is a field in the settings
form, nothing is written until "Save Patrol settings", a rejected save
keeps the edit in the form, the model readiness check runs as its own
action, and the toggles carry explicit ariaLabel strings that contain
their visible labels. The clauses in patrol-intelligence,
frontend-primitives and api-contracts now say where the controls live
and how they save.

The new AISettings tests pin the rewritten claims: the selector renders
only while alert triggers are on and writes nothing before Save, a
rejected save leaves the edit for a retry, a successful save reloads the
form and does not resend an unchanged severity, and each trigger
toggle's accessible name contains its visible label. Each assertion
fails against a deliberately broken build.
This commit is contained in:
rcourtman
2026-10-07 16:21:49 +01:00
parent 7432995fe8
commit d8f7d957b8
4 changed files with 223 additions and 58 deletions
@@ -3167,10 +3167,11 @@ a new API state machine, queue contract, or verification-accounting field.
`Safe auto-fix`, and
`Autopilot` rather than transport shorthand or Pro-matrix labels, the Patrol
header description derives from the same
effective control state, and the advanced settings
drawer stays limited to model, schedule, trigger, and user-level model checks
sourced from the existing settings APIs rather than duplicating a second
control-level chooser. Runtime or provider setup
effective control state, and Settings > Pulse Intelligence > Patrol, which
replaced the retired header settings drawer, stays limited to model,
schedule, trigger, notification, and user-level model checks sourced from
the existing settings APIs rather than duplicating a second control-level
chooser. Runtime or provider setup
readiness may suppress run, schedule, model, trigger, and provider-repair
controls until Patrol can check infrastructure reliably, but it must not hide
the Patrol mode selector or replace it with setup-first proof/status chrome.
@@ -3306,7 +3307,7 @@ a new API state machine, queue contract, or verification-accounting field.
and the learned-correlation payload loaded into the shared AI intelligence store, so the Patrol intelligence page and the AI summary page consume the same governed correlation slice instead of each page fetching its own copy
and the shared dashboard-load bundle inside `frontend-modern/src/stores/aiIntelligence.ts`, so the page orchestration stays on the store-owned bundle instead of enumerating the AI fetches inline
and the Patrol page refresh lifecycle in `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts`, so slow or stalled secondary reads from that shared dashboard-load bundle may continue resolving in the background while the operator-facing Patrol refresh control remains generation-aware, timeout-bounded, and reusable once Patrol findings and status are already visible
and the Patrol header support drawer in `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx`, so API-owned Patrol status and trigger facts can feed a secondary Schedule & model surface without turning provider model, schedule, trigger tuning, or background-only runtime-policy pauses into the primary Patrol control decision
and the Patrol header in `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx`, which outside setup-only states may summarize noteworthy API-owned `status.trigger_status` facts in one compact line and, unless a provider setup block shows `Fix setup` instead, links to Settings > Pulse Intelligence > Patrol, where the Patrol model, schedule, and trigger tuning are saved through `PUT /api/settings/ai/update`, so none of those settings or background-only runtime-policy pauses become the primary Patrol control decision and the header carries no schedule and model drawer
and any frontend presentation of the AI summary's `policy_posture` counts, which must render that canonical snapshot through one shared component instead of page-local posture loops; no frontend surface renders those counts today, and the resource drawer stays on per-resource policy lines
and the dedicated `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts` owner, so recent-change, learned-correlation, and policy-coverage summary text stays derived from the canonical AI payload in one place instead of as hook-local count and pluralization logic
and the Watch-only forward-path handoffs owned by `frontend-modern/src/features/patrol/patrolControlPresentation.ts` together with the Actions inbox empty-state read of `GET /api/ai/patrol/autonomy`, so finding-level and inbox-level mode guidance consumes the canonical Patrol autonomy read/save contract through the Patrol state hook (`handleAutonomyChange`) instead of introducing a second mode mutation path or a page-local autonomy dialect
@@ -3358,8 +3359,8 @@ a new API state machine, queue contract, or verification-accounting field.
and the Patrol runtime-remediation destination shared with the AI settings endpoint, so summary actions, run-history runtime-failure actions, and runtime-finding actions may reuse the governed provider-settings route while still presenting that destination in Patrol as provider configuration instead of generic `AI Settings` copy
and the Patrol route-shell destination itself, so the thin page shell at `frontend-modern/src/pages/AIIntelligence.tsx` may continue to bridge the shared AI-runtime payload boundary while exposing `/patrol` as the canonical product route and keeping retired `/ai` browser entry points unregistered
and the Patrol route-shell accessibility boundary, so brand icons in `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx` stay decorative when the same heading already exposes visible Patrol text, preventing duplicate accessible names such as `Pulse Patrol Patrol`
and the Patrol mode selector boundary, so the default header and the
Patrol mode dialog compose the shared
and the Patrol mode selector boundary, so the header's `Mode and automation`
disclosure composes the shared
`frontend-modern/src/components/shared/FilterButtonGroup.tsx` primitive for
the visible `Watch only` / `Ask first` / `Safe auto-fix` /
`Autopilot` presentation while the API contract remains the sole owner of
@@ -1203,7 +1203,7 @@ Compact Coverage age cells use the existing metadata-size text and reclaim numer
59. `frontend-modern/src/components/shared/FilterBar/index.ts`
59c. `frontend-modern/src/components/shared/FilterBar/filterOptionPresentation.tsx`
60. `frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts`
61. `frontend-modern/src/features/` (including Patrol presentation, where Patrol control starter counts are context only even when mirrored through `patrolAutonomy*` compatibility fields, successful direct Patrol control saves may record the content-free `patrol_control` starter only when paid control is available and the effective control posture changes and must then refresh Patrol status, findings, approvals, and run history before the operator waits for polling, legacy `proActivation*` starter aliases must not render a separate proof strip by themselves, Patrol control completed/resolved counts may only project backend-owned terminal proof, current active findings and pending approvals outrank historical completion proof in the primary operator state, selected run history must read as a Patrol run record rather than a findings filter or snapshot workflow, terminal verified/rejected outcomes with no active finding or pending approval must stay history detail without rendering a no-op proof strip, resolved-only issue history must not be promoted into current-work copy or actions, compact recurrence/trust counters must read as historical evidence rather than current issue state, Patrol-owned status/history evidence must keep the assessment visible when the broader intelligence summary is missing, Patrol work-group chips may group current approvals, failed actions, failed checks, recurring active issues, and stale scheduled protection but must not become a separate status/trust/proof strip, the Patrol route and page title must lead with Patrol while the default workspace underneath may use Open work and run history stays a deliberate secondary review surface, setup-only Patrol runtime failures must instead use `Fix Patrol setup` framing with a dedicated setup task and direct provider-settings action while suppressing generic issue-row chips and filter chrome, Patrol must not expose a generic Details/supporting-context panel for nearby activity, related patterns, or policy limits, locked-control copy must state the watch-only boundary in positive capability language by saying Patrol checks infrastructure and shows current issues, avoid repeating the same sentence across the header and control, and avoid repeatedly restating infrastructure-unchanged caveats or relying on disabled controls, compact Pro badges, Limits controls, or manual-review framing, `patrolControlValueState` decides whether a terminal decision is partial review context or verified value proof while `patrolAutonomyValueState` remains a compatibility mirror, legacy `proActivation*` fields are compatibility fallback only, native Patrol state must not load the operations-loop status projection to decide current work, local Patrol state must expose issue-backed `patrolWork*` evidence rather than legacy proof naming, Patrol mode labels remain domain copy that must describe backend-owned risk policy without creating page-local safety thresholds, the selected Patrol mode sentence must state the approval and policy boundary without adding a second limits panel or proof strip, the Patrol schedule and model drawer must stay separate from the always-visible Patrol mode selector instead of duplicating the four control choices or reintroducing save/apply configuration framing, and Patrol header refresh controls must call an explicit operator-refresh handler whose spinning/disabled state is separate from background polling and initial data loads)
61. `frontend-modern/src/features/` (including Patrol presentation, where Patrol control starter counts are context only even when mirrored through `patrolAutonomy*` compatibility fields, successful direct Patrol control saves may record the content-free `patrol_control` starter only when paid control is available and the effective control posture changes and must then refresh Patrol status, findings, approvals, and run history before the operator waits for polling, legacy `proActivation*` starter aliases must not render a separate proof strip by themselves, Patrol control completed/resolved counts may only project backend-owned terminal proof, current active findings and pending approvals outrank historical completion proof in the primary operator state, selected run history must read as a Patrol run record rather than a findings filter or snapshot workflow, terminal verified/rejected outcomes with no active finding or pending approval must stay history detail without rendering a no-op proof strip, resolved-only issue history must not be promoted into current-work copy or actions, compact recurrence/trust counters must read as historical evidence rather than current issue state, Patrol-owned status/history evidence must keep the assessment visible when the broader intelligence summary is missing, Patrol work-group chips may group current approvals, failed actions, failed checks, recurring active issues, and stale scheduled protection but must not become a separate status/trust/proof strip, the Patrol route and page title must lead with Patrol while the default workspace underneath may use Open work and run history stays a deliberate secondary review surface, setup-only Patrol runtime failures must instead use `Fix Patrol setup` framing with a dedicated setup task and direct provider-settings action while suppressing generic issue-row chips and filter chrome, Patrol must not expose a generic Details/supporting-context panel for nearby activity, related patterns, or policy limits, locked-control copy must state the watch-only boundary in positive capability language by saying Patrol checks infrastructure and shows current issues, avoid repeating the same sentence across the header and control, and avoid repeatedly restating infrastructure-unchanged caveats or relying on disabled controls, compact Pro badges, Limits controls, or manual-review framing, `patrolControlValueState` decides whether a terminal decision is partial review context or verified value proof while `patrolAutonomyValueState` remains a compatibility mirror, legacy `proActivation*` fields are compatibility fallback only, native Patrol state must not load the operations-loop status projection to decide current work, local Patrol state must expose issue-backed `patrolWork*` evidence rather than legacy proof naming, Patrol mode labels remain domain copy that must describe backend-owned risk policy without creating page-local safety thresholds, the selected Patrol mode sentence must state the approval and policy boundary without adding a second limits panel or proof strip, Patrol schedule, trigger, and model settings must stay on Settings > Pulse Intelligence > Patrol, apart from the Patrol mode selector, instead of returning as a Patrol page drawer, that settings page must not duplicate the four control choices, and the Patrol page mode selector must not reintroduce save/apply configuration framing, and Patrol header refresh controls must call an explicit operator-refresh handler whose spinning/disabled state is separate from background polling and initial data loads)
62. `frontend-modern/src/components/SetupWizard/SetupWizard.tsx`
63. `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`
64. `frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx`
@@ -4379,21 +4379,26 @@ Assistant and settings surfaces can expose provider repair without forking the
model-list shell; callers own the destination, while the shared picker owns the
button placement, labeling, close behavior, and keyboard-safe dropdown state.
The Patrol alert-trigger severity selector under
`frontend-modern/src/features/patrol/` is built on the shared `FormSelect`
primitive (label-for/id wiring, `selectBaseClass` styling hook) rather than a
hand-rolled `<select>`, so its labeling and disabled-state affordances stay
consistent with the rest of the AI settings surface. That selector belongs in the
advanced Patrol settings disclosure, below the control policy that defines what
Patrol may do.
The Patrol alert-trigger severity selector on Settings > Pulse Intelligence >
Patrol (`frontend-modern/src/components/Settings/AISettings.tsx`) is built on
the shared `FormSelect` primitive (label-for/id wiring, `selectBaseClass`
styling hook) rather than a hand-rolled `<select>`, so its labeling and
disabled-state affordances stay consistent with the rest of the AI settings
surface. It sits in that page's Triggers section under the `Alert-triggered
Patrols` toggle, renders only while that toggle is on, and is saved with the
rest of the page by `Save Patrol settings`. The Patrol page carries no trigger
settings; its header's `Settings` link opens this page when setup is not
blocking.
The advanced Patrol control toggles in the same surface bind each `Toggle`
primitive's accessible name through `ariaLabelledBy` pointing at the row's
heading span, so renaming a control's visible label (for example
"Alert-Triggered Analysis" to "Container Update Risk") updates the accessible
name automatically without a parallel `aria-label` string. New Patrol toggle
copy must keep using the shared `Toggle` `ariaLabelledBy` wiring rather than
hard-coding a divergent accessible name.
The Patrol trigger and notification toggles on that page (`Alert-triggered
Patrols`, `Anomaly-triggered Patrols`, `Container update risk`, `Finding
notifications`) name each `Toggle` primitive through an explicit `ariaLabel`
such as `Enable alert-triggered Patrols`, not `ariaLabelledBy`. Each
accessible name must contain its row's visible label, so a change to a row's
visible copy (for example "Alert-Triggered Analysis" to "Container update
risk") must update its `ariaLabel` in the same change rather than leave a
divergent accessible name. `AISettings.test.tsx` finds these toggles by name
and checks that each name contains its visible label.
Kubernetes RBAC inventory (Roles, ClusterRoles, RoleBindings,
ClusterRoleBindings) is part of the existing Kubernetes platform-page
@@ -366,8 +366,8 @@ or historical proof/counting for resolved-only work.
summary.
Runtime/setup findings are still active Patrol work, but setup-only runtime
failures must read as one setup task rather than an infrastructure issue
queue. The header may suppress run and schedule/model controls while setup is
the only active work, but it must keep the Patrol mode selector visible
queue. The header may suppress its run control and `Settings` link while setup
is the only active work, but it must keep the Patrol mode selector available
because the operator's autonomy boundary remains a primary product choice
even before Patrol can run. The workspace must replace the generic findings
row with a dedicated provider/runtime setup task, one current issue label,
@@ -698,15 +698,19 @@ attention`, `approval needed`, `outcome verified`, `no active work`) instead
Effective runtime blocks on event-triggered Patrol must pass through the same
`status.trigger_status` presentation helper, but the default header and
activity strip may surface them only when they explain an actionable manual
Patrol block; background-only trigger pauses belong in secondary schedule and
model diagnostics when manual Patrol still works.
Patrol mode selection belongs to the always-visible header control;
the header drawer is the secondary Schedule & model surface and may expose
provider model, schedule, trigger tuning, and readiness errors, but it must
not duplicate the four Patrol mode choices or reintroduce a save button
for already auto-saving secondary fields. The provider model setting must lead with the effective
Patrol/default model summary; the full model catalog is power-user detail
behind an explicit change action, not the default content of the drawer.
Patrol block; while manual Patrol still works, a background-only trigger
pause stays off the header, and no other surface renders it today.
Patrol mode selection belongs to the Patrol page header, which has no
schedule and model drawer. The Patrol model, schedule, and trigger tuning
live on Settings > Pulse Intelligence > Patrol, which the header's
`Settings` link opens when setup is not blocking, and save together through
that page's `Save Patrol settings` button. The same page runs the model
readiness check as its own `Check Patrol model` action, separate from that
save. That settings page must not duplicate the
four Patrol mode choices; it points back to the Patrol page for them. The
Patrol model field must lead with the current selection or `Use shared
default`; the full model catalog is power-user detail that opens only from
the picker, not the default content of the page.
When Patrol mode is available, the Patrol mode selector must keep the
default view to the selected mode and one short sentence. It must not render a
secondary `Limits` disclosure, hard-limit matrix, or policy explainer on the
@@ -1458,19 +1462,30 @@ surface for Patrol intelligence. This contract now owns that orchestration and
presentation boundary while leaving shared transport and payload-shape
ownership in the governed AI runtime and API contract surfaces.
The Patrol control panel (`PatrolIntelligenceHeader.tsx`,
`usePatrolIntelligenceState.ts`) exposes the per-rule alert-trigger policy
directly under the Alert-Triggered Patrols toggle. A minimum-severity selector
("Investigate alerts at or above": Critical only / Warning and critical) renders
only while alert triggers are enabled and persists through
`AIAPI.updateSettings({ patrol_alert_trigger_min_severity })` with optimistic
state and revert-on-error, mirroring the existing trigger-toggle handlers. The
selector reads `patrol_alert_trigger_min_severity` from the settings response,
defaulting to critical-only, and must keep using the shared AI settings shape
rather than forking a patrol-local form.
The per-rule alert-trigger policy lives on Settings > Pulse Intelligence >
Patrol (`PatrolSettingsContent` in
`frontend-modern/src/components/Settings/AISettings.tsx`, form state and save
in `useAISettingsState.ts`), not on the Patrol page. The Patrol page header
has no trigger controls. Outside setup-only states it may show a one-line
`status.trigger_status` summary when there is something to note (queued
triggers, busy mode, alert or anomaly triggers off) and links to that settings
page, unless a provider setup block puts a `Fix setup` action in place of the
link. In the settings page's Triggers section, a
minimum-severity selector ("Investigate alerts at or above": Critical only /
Warning and critical) sits under the `Alert-triggered Patrols` toggle and
renders only while that toggle is on. The selector and the trigger toggles
edit the settings form only, and nothing is written until `Save Patrol
settings`, which sends `patrol_alert_trigger_min_severity` in the page's single
`AIAPI.updateSettings` payload whenever it differs from the loaded value.
There is no optimistic write and no revert: a rejected save raises an error
toast and leaves the edit in the form to retry, and a successful save reloads
the form from the response. The selector reads
`patrol_alert_trigger_min_severity` from the settings response, defaulting to
critical-only, and must keep using the shared AI settings form rather than
forking a Patrol-local form or save path.
The Patrol page now keeps the Patrol mode policy inline on the main surface,
not inside the secondary Schedule & model drawer: the first configurable decision remains
The Patrol page keeps the Patrol mode policy on its own header, not on
Settings > Pulse Intelligence > Patrol: the first configurable decision remains
what Patrol may handle automatically (`Watch only`, `Ask first`,
`Safe auto-fix`, `Autopilot`), and there must be only one visible chooser for
that decision.
@@ -1493,13 +1508,15 @@ Commercial, runtime, and documentation copy that describes this same decision
must also use those visible labels and the umbrella name `Patrol mode`, not
the retired `Only watch`, `Fix safe issues`, `Full control`, or generic
`Patrol control level` vocabulary.
Provider model, run schedule, trigger tuning, readiness checks, and saved
readiness issues belong to the secondary Schedule & model drawer. Within that
secondary drawer, the panel disambiguates the two alert-driven AI toggles by
scope rather than by near-identical names. The genuinely general path is
Alert-Triggered Patrols, which runs a focused Patrol investigation of the
alert's own issue. The Pro-gated `AlertTriggeredAnalysis` toggle presents as
"Container Update Risk" with copy scoped to container-update alerts, because the
Provider model, run schedule, trigger tuning, readiness checks, and the saved
readiness result belong on Settings > Pulse Intelligence > Patrol; a save whose
response reports Patrol not ready or degraded raises a warning toast instead of
a success toast. In that page's Triggers section, the two alert-driven AI
toggles are told apart by scope rather than by near-identical names. The
genuinely general path is `Alert-triggered Patrols`, which runs a focused
Patrol investigation of the alert's own issue. The Pro-gated
`AlertTriggeredAnalysis` toggle presents as
"Container update risk" with copy scoped to container-update alerts, because the
enterprise `AlertTriggeredAnalyzer` only assesses `docker-container-update`
alerts and returns nil for every other alert type. New copy must keep the
container-update toggle scoped to its real Docker-update-risk capability instead
@@ -1538,11 +1555,13 @@ owns local toggle optimism, run-status orchestration, and Patrol-only copy,
including runtime-availability messaging that stays Patrol-first in
operator-facing shells and uses provider/API-key wording only for the actual
provider settings boundary, but the underlying AI runtime catalog must stay shared
with chat and AI settings. The advanced Patrol model selector must remain
with chat and AI settings. The Patrol model selector on Settings > Pulse
Intelligence > Patrol (`AIModelOverrideField` with `kind="patrol"`) reads that
same shared catalog and must remain
state-driven across async settings/catalog loading: a saved direct-provider
model such as `deepseek:deepseek-v4-flash` must render as that provider model
once the shared catalog supplies it, not fall back visually to the default
model or an unrelated OpenRouter entry because the popover mounted after the
model or an unrelated OpenRouter entry because the field rendered before the
catalog request completed.
The Patrol page now also treats Patrol runtime availability as a first-class
render contract: the header chip, primary summary card, and status bar must
@@ -1926,10 +1945,10 @@ plain-text renders: the latest-run segment must keep an explicit textual
separator between run kind and result, so degraded entries read as
`Scoped run · error` rather than collapsing into concatenated strings like
`Scoped runerror`.
Those runtime facts must stay aligned with Patrol mode copy. The
header settings surface should expose alert-triggered and anomaly-triggered
scoped patrols as separate controls, with the legacy aggregate event-trigger
toggle treated as compatibility-only transport rather than the primary product
Those runtime facts must stay aligned with Patrol mode copy. Settings > Pulse
Intelligence > Patrol, not the Patrol header, exposes alert-triggered and
anomaly-triggered scoped Patrols as separate controls, with the legacy aggregate
event-trigger toggle treated as compatibility-only transport rather than the primary product
model.
The findings empty state must also stay subordinate to the Patrol header and
assessment shell rather than mirroring their timing metadata. In the primary
@@ -525,6 +525,146 @@ describe('AISettings model loading error states', () => {
});
});
it('shows the alert severity selector only while alert triggers are on and writes it on Save', async () => {
getSettingsMock.mockResolvedValue({
...baseSettings(),
enabled: true,
configured: true,
patrol_interval_minutes: 180,
patrol_alert_triggers_enabled: false,
patrol_alert_trigger_min_severity: 'critical',
});
updateSettingsMock.mockImplementation(async (payload: Record<string, unknown>) => ({
...baseSettings(),
enabled: true,
configured: true,
...payload,
}));
renderComponent('patrol');
await waitFor(() => {
expect(screen.getByLabelText('Schedule')).toHaveValue('180');
});
const alertToggle = screen.getByLabelText('Enable alert-triggered Patrols');
expect(alertToggle).toHaveAttribute('aria-pressed', 'false');
expect(screen.queryByLabelText('Investigate alerts at or above')).toBeNull();
fireEvent.click(alertToggle);
fireEvent.change(screen.getByLabelText('Investigate alerts at or above'), {
target: { value: 'warning' },
});
fireEvent.click(alertToggle);
expect(screen.queryByLabelText('Investigate alerts at or above')).toBeNull();
fireEvent.click(alertToggle);
expect(screen.getByLabelText('Investigate alerts at or above')).toHaveValue('warning');
await new Promise((resolve) => setTimeout(resolve, 0));
expect(updateSettingsMock).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: /Save Patrol settings/i }));
await waitFor(() => {
expect(updateSettingsMock).toHaveBeenCalledTimes(1);
});
expect(updateSettingsMock).toHaveBeenCalledWith(
expect.objectContaining({
patrol_alert_triggers_enabled: true,
patrol_alert_trigger_min_severity: 'warning',
}),
);
});
it('keeps an unsaved alert severity edit in the form when the Patrol save is rejected', async () => {
getSettingsMock.mockResolvedValue({
...baseSettings(),
enabled: true,
configured: true,
patrol_interval_minutes: 180,
patrol_alert_triggers_enabled: true,
patrol_alert_trigger_min_severity: 'critical',
});
updateSettingsMock
.mockRejectedValueOnce(new Error('Settings store unavailable'))
.mockImplementation(async (payload: Record<string, unknown>) => ({
...baseSettings(),
enabled: true,
configured: true,
patrol_interval_minutes: 360,
patrol_alert_triggers_enabled: true,
patrol_alert_trigger_min_severity: 'critical',
...payload,
}));
renderComponent('patrol');
await waitFor(() => {
expect(screen.getByLabelText('Schedule')).toHaveValue('180');
});
fireEvent.change(screen.getByLabelText('Investigate alerts at or above'), {
target: { value: 'warning' },
});
fireEvent.click(screen.getByRole('button', { name: /Save Patrol settings/i }));
await waitFor(() => {
expect(notificationErrorMock).toHaveBeenCalledWith(
expect.stringContaining('Settings store unavailable'),
);
});
expect(notificationSuccessMock).not.toHaveBeenCalled();
expect(screen.getByLabelText('Investigate alerts at or above')).toHaveValue('warning');
fireEvent.click(screen.getByRole('button', { name: /Save Patrol settings/i }));
await waitFor(() => {
expect(updateSettingsMock).toHaveBeenCalledTimes(2);
});
expect(updateSettingsMock).toHaveBeenLastCalledWith(
expect.objectContaining({ patrol_alert_trigger_min_severity: 'warning' }),
);
await waitFor(() => {
expect(notificationSuccessMock).toHaveBeenCalledWith('Patrol settings saved');
});
// The form reloads from the saved response, so an unchanged severity is
// not sent again.
expect(screen.getByLabelText('Schedule')).toHaveValue('360');
fireEvent.click(screen.getByRole('button', { name: /Save Patrol settings/i }));
await waitFor(() => {
expect(updateSettingsMock).toHaveBeenCalledTimes(3);
});
expect(updateSettingsMock.mock.calls[2][0]).not.toHaveProperty(
'patrol_alert_trigger_min_severity',
);
});
it('names each Patrol trigger toggle with an accessible name containing its visible label', async () => {
getSettingsMock.mockResolvedValue({
...baseSettings(),
enabled: true,
configured: true,
patrol_alert_triggers_enabled: true,
patrol_finding_notifications_enabled: true,
});
renderComponent('patrol');
await screen.findByLabelText('Enable alert-triggered Patrols');
for (const visibleLabel of [
'Alert-triggered Patrols',
'Anomaly-triggered Patrols',
'Container update risk',
'Finding notifications',
]) {
const row = screen.getByText(visibleLabel).parentElement?.parentElement;
expect(row, visibleLabel).toBeTruthy();
const escaped = visibleLabel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
expect(
within(row as HTMLElement).getByRole('button', { name: new RegExp(escaped, 'i') }),
).toHaveAttribute('aria-pressed');
}
});
it('shows inline warning when getModels throws a network error', async () => {
getSettingsMock.mockResolvedValue({
...baseSettings(),