From 357b1a505e80bb1cafbfc9cb02f36f96182c7b57 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:16:21 +0100 Subject: [PATCH 1/2] Explain why the Container update risk switch is locked Patrol settings showed the Container update risk switch dimmed on installs without ai_alerts and gave no reason, so a free install could not tell a plan limit from a broken setting. The card now renders the canonical Patrol availability presentation. patrolAutonomyAvailability.ts keeps one precedence (runtime lock, then hidden commercial surfaces, then plan lock) and the only action labels, while Patrol mode and container update risk each supply their lock state and copy. Links appear only when the session allows upgrade prompts, and installs that hide commercial surfaces get a neutral line with no plan or Pro wording. The explanation is tied to the switch with aria-describedby, because the shared Toggle does not render its locked/lockedMessage props. --- .../v6/internal/subsystems/api-contracts.md | 5 + .../subsystems/frontend-primitives.md | 5 + .../subsystems/patrol-intelligence.md | 10 ++ .../b254486b940deb2e.json | 42 ++++++ .../src/components/Settings/AISettings.tsx | 46 ++++++- .../Settings/__tests__/AISettings.test.tsx | 110 +++++++++++++++- .../__tests__/settingsArchitecture.test.ts | 29 +++++ .../PatrolIntelligenceHeader.test.ts | 38 ++++++ .../patrolAutonomyAvailability.test.ts | 120 ++++++++++++++++++ .../patrol/patrolAutonomyAvailability.ts | 97 +++++++++++--- 10 files changed, 481 insertions(+), 21 deletions(-) create mode 100644 frontend-modern/browser-verification/b254486b940deb2e.json diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 12452c53b1..0fd38347b5 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -3187,6 +3187,11 @@ a new API state machine, queue contract, or verification-accounting field. proof. The default API-backed presentation must stay on the selected control level and its plain summary; it must not render a separate Limits disclosure, hard-limit matrix, or always-on explanatory matrix beside the mode picker. + and the Patrol container update risk lock, so `/api/settings/ai` + `alert_triggered_analysis` stays the licence-masked API value while the + runtime-capabilities `ai_alerts` grant or `paid_runtime_required` block + decides which lock explanation the Patrol settings card renders, without a + new API field and the Patrol run-record copy, so `finding_ids` remains the API-owned fail-closed scoping input while the frontend presents selected history as a Patrol run record instead of a generic findings filter or snapshot workflow diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index e077067776..b2eb84d4c2 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -4738,6 +4738,11 @@ stable accessible label and route unconfigured installs into the canonical provider setup modal instead of falling back to generic "first pressed toggle" selectors, provider-model-load heuristics, legacy hosted-model enablement, or in-app trial acquisition. +A settings toggle disabled by a plan or runtime lock must explain the lock in +the card that owns it, not through the shared `Toggle` `locked` and +`lockedMessage` props, which the primitive does not render. `AISettings.tsx` +renders the Container update risk lock from the canonical Patrol availability +presentation and links that explanation to the switch with `ariaDescribedBy`. That same route-owned presentation rule also governs Patrol findings empty states: shared section shells under `frontend-modern/src/features/patrol/` must not render a green healthy empty state from `0 active findings` alone diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index dfbf50ed95..e04084427b 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -725,6 +725,16 @@ attention`, `approval needed`, `outcome verified`, `no active work`) instead sentence across the header and mode control, repeatedly restate infrastructure-unchanged caveats in the default view, or imply Patrol's main value is a manual review chore. + Plan-gated Patrol capabilities share that one availability presentation: + `patrolAutonomyAvailability.ts` owns the precedence (runtime lock, then + hidden commercial surfaces, then plan lock) and the only action labels + (`Open Pro downloads`, `Plans & Billing`), while each capability supplies + its lock state and copy. The Patrol settings Container update risk trigger + (`ai_alerts`) uses `getPatrolAlertAnalysisAvailabilityPresentation`, so a + locked install sees why the trigger is unavailable beside the disabled + switch: a higher license plan, a missing Pulse Pro runtime, or, when + commercial surfaces are hidden, a neutral not-available line with no plan + or Pro wording. Actions appear only when upgrade prompts are allowed. 5. Add or change Patrol header schedule and runtime presentation through `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx`, `frontend-modern/src/utils/aiPatrolSchedulePresentation.ts`, and `frontend-modern/src/utils/patrolRuntimePresentation.ts`. Patrol must not surface retired hosted-model credit badges or trial-like activation prompts in the normal self-hosted GA app, even when legacy transport fields are still present. 6. Keep Patrol and chat identifier-label presentation aligned through the shared `frontend-modern/src/utils/textPresentation.ts` diff --git a/frontend-modern/browser-verification/b254486b940deb2e.json b/frontend-modern/browser-verification/b254486b940deb2e.json new file mode 100644 index 0000000000..f4f8ebe47f --- /dev/null +++ b/frontend-modern/browser-verification/b254486b940deb2e.json @@ -0,0 +1,42 @@ +{ + "version": 1, + "base_sha": "1dcf9418aae87f9427dbfc6bcae67dbde114bf8a", + "verified_at": "2026-10-05T21:16:11Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Settings/AISettings.tsx", + "frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Settings/AISettings.tsx": "d3966224e497b9f8f3cd701bea1661212c5f32c9cc15ff795c4001f851fae6ca", + "frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts": "240fe0f1988d881da4344811aa3fdcc8e452182da24414ca6c941ea72e8e545f" + }, + "routes": [ + "/settings/pulse-intelligence/patrol", + "/settings/pulse-intelligence/billing/plan" + ], + "viewports": [ + { + "width": 1280, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private stacks built from this tree: community runtime (7782/5282), community DEMO_MODE runtime (7783/5283) and a Pro runtime built from pulse-enterprise against this tree (7784/5284), mock data, dev-signed Pro and Enterprise licences. Playwright Chromium at 1280x900 dark and 390x844 light, plus the in-app browser pane.", + "Free community install (no licence, hideUpgrade true, hideCommercial false, ai_alerts absent, /api/settings/ai alert_triggered_analysis false via a real persisted save): switch disabled and off, card reads 'Higher license plan required. This install's plan does not include container update risk.' with no link; the switch's aria-describedby points at that line.", + "Unlicensed Pro runtime (prompts allowed, ai_alerts not granted, backend licence checker masks the setting to false): same plan-lock line plus a Plans & Billing link to /settings/pulse-intelligence/billing/plan.", + "Pro licence on the community runtime (ai_alerts blocked as paid_runtime_required): 'Pulse Pro runtime required. This install is running Pulse Community runtime. Install the Pulse Pro runtime to use container update risk.' plus Open Pro downloads (https://pulserelay.pro/download.html, new tab, noopener).", + "Hidden commercial surfaces via DEMO_MODE (hideCommercial and hideUpgrade true; read-only demo, so GET /api/settings/ai intercepted to alert_triggered_analysis false): 'Not available. This install does not include container update risk.' with no plan or Pro wording and no link.", + "Enterprise licence on the community runtime (white-label, hideCommercial true, ai_alerts runtime-blocked): runtime lock copy without the download link, which is the canonical runtime-before-commercial precedence shared with Patrol mode.", + "Pro licence on the Pro runtime: switch enabled, no lock line, no aria-describedby. No horizontal overflow and no page errors in any state; the disabled track styling is the pre-#2535 shared Toggle and is unchanged here." + ], + "interactions": [ + "Clicked the Plans & Billing link from the locked card in the browser pane; it routed in-app to /settings/pulse-intelligence/billing/plan.", + "On the licensed Pro runtime, turned the container update risk switch on and saved Patrol settings; GET /api/settings/ai then returned alert_triggered_analysis true.", + "Switched licence state live (activate Pro, activate Enterprise, clear) against the same backend and reloaded the page for each state." + ] +} diff --git a/frontend-modern/src/components/Settings/AISettings.tsx b/frontend-modern/src/components/Settings/AISettings.tsx index 931653fdec..a6f5c75a1e 100644 --- a/frontend-modern/src/components/Settings/AISettings.tsx +++ b/frontend-modern/src/components/Settings/AISettings.tsx @@ -1,4 +1,4 @@ -import { Component, For, createMemo, Show } from 'solid-js'; +import { Component, For, createMemo, createUniqueId, Show } from 'solid-js'; import { useNavigate } from '@solidjs/router'; import { AIChatMaintenanceSection } from '@/components/Settings/AIChatMaintenanceSection'; import { AISettingsDialogs } from '@/components/Settings/AISettingsDialogs'; @@ -18,8 +18,13 @@ import { FormSelect } from '@/components/shared/FormSelect'; import { LoadingSpinner } from '@/components/shared/LoadingSpinner'; import SettingsPanel from '@/components/shared/SettingsPanel'; import { Toggle } from '@/components/shared/Toggle'; +import { UpgradeLink } from '@/components/shared/UpgradeLink'; import { PATROL_PATH } from '@/routing/resourceLinks'; -import { getPatrolAutonomyAvailabilityPresentation } from '@/features/patrol/patrolAutonomyAvailability'; +import { + PATROL_ALERT_ANALYSIS_FEATURE_KEY, + getPatrolAlertAnalysisAvailabilityPresentation, + getPatrolAutonomyAvailabilityPresentation, +} from '@/features/patrol/patrolAutonomyAvailability'; import { getRuntimeCapabilityBlock, hasFeature, runtimeCapabilities } from '@/stores/license'; import { getUpgradeActionDestination } from '@/stores/licenseCommercial'; import { @@ -115,6 +120,17 @@ const PatrolSettingsContent: Component<{ state: ReturnType patrolModeAvailability().kind === 'plan_locked'); + const alertAnalysisAvailability = createMemo(() => + getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: props.state.alertAnalysisLocked(), + commercialSurfacesHidden: presentationPolicyHidesCommercialSurfaces(), + upgradePromptsHidden: presentationPolicyHidesUpgradePrompts(), + runtimeCapabilityBlock: getRuntimeCapabilityBlock(PATROL_ALERT_ANALYSIS_FEATURE_KEY), + runtime: runtimeCapabilities()?.runtime, + planUpgradeDestination: getUpgradeActionDestination(PATROL_ALERT_ANALYSIS_FEATURE_KEY), + }), + ); + const alertAnalysisLockId = createUniqueId(); const intervalOptions = [ { value: 0, label: 'Manual only' }, { value: 60, label: 'Every hour' }, @@ -270,14 +286,38 @@ const PatrolSettingsContent: Component<{ state: ReturnType Assess risk when container-update alerts fire.

+ +

+ + {alertAnalysisAvailability().title}. + {' '} + {alertAnalysisAvailability().body} + + {(destination) => ( + <> + {' '} + + {alertAnalysisAvailability().actionLabel} + + + )} + +

+
props.state.setForm('alertTriggeredAnalysis', event.currentTarget.checked) } - disabled={props.state.saving() || props.state.alertAnalysisLocked()} + disabled={props.state.saving() || alertAnalysisAvailability().locked} ariaLabel="Enable container update risk analysis" + ariaDescribedBy={ + alertAnalysisAvailability().locked ? alertAnalysisLockId : undefined + } /> diff --git a/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx b/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx index 9625418385..55ce073d9f 100644 --- a/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx @@ -37,6 +37,8 @@ const loadCommercialPostureMock = vi.fn(); const commercialPostureMock = vi.fn(); const entitlementsMock = vi.fn(); const presentationPolicyHidesUpgradePromptsMock = vi.fn(); +const presentationPolicyHidesCommercialSurfacesMock = vi.fn(); +const getRuntimeCapabilityBlockMock = vi.fn(); vi.mock('@/api/ai', () => ({ AIAPI: { @@ -95,7 +97,7 @@ vi.mock('@/utils/logger', () => ({ vi.mock('@/stores/license', () => ({ hasFeature: (...args: unknown[]) => hasFeatureMock(...args), - getRuntimeCapabilityBlock: () => undefined, + getRuntimeCapabilityBlock: (...args: unknown[]) => getRuntimeCapabilityBlockMock(...args), loadRuntimeCapabilities: (...args: unknown[]) => loadLicenseStatusMock(...args), runtimeCapabilities: () => ({ capabilities: [], runtime: undefined }), })); @@ -110,7 +112,7 @@ vi.mock('@/stores/licenseCommercial', () => ({ })); vi.mock('@/stores/sessionPresentationPolicy', () => ({ - presentationPolicyHidesCommercialSurfaces: () => false, + presentationPolicyHidesCommercialSurfaces: () => presentationPolicyHidesCommercialSurfacesMock(), presentationPolicyHidesUpgradePrompts: () => presentationPolicyHidesUpgradePromptsMock(), })); @@ -181,6 +183,8 @@ const resetAllMocks = () => { commercialPostureMock.mockReset(); entitlementsMock.mockReset(); presentationPolicyHidesUpgradePromptsMock.mockReset(); + presentationPolicyHidesCommercialSurfacesMock.mockReset(); + getRuntimeCapabilityBlockMock.mockReset(); }; const setupDefaultMocks = () => { @@ -212,6 +216,8 @@ const setupDefaultMocks = () => { listSessionsMock.mockResolvedValue([]); summarizeSessionMock.mockResolvedValue(undefined); presentationPolicyHidesUpgradePromptsMock.mockReturnValue(false); + presentationPolicyHidesCommercialSurfacesMock.mockReturnValue(false); + getRuntimeCapabilityBlockMock.mockReturnValue(undefined); }; describe('AISettings model loading error states', () => { @@ -350,6 +356,106 @@ describe('AISettings model loading error states', () => { expect(screen.getByRole('button', { name: /Open Patrol/i })).toBeInTheDocument(); }); + describe('container update risk lock', () => { + const lockedPatrolSettings = () => ({ + ...baseSettings(), + enabled: true, + configured: true, + // The backend masks this to false when the install lacks ai_alerts. + alert_triggered_analysis: false, + }); + const containerRiskToggle = () => + screen.getByLabelText('Enable container update risk analysis'); + const lockExplanation = () => { + const describedBy = containerRiskToggle().getAttribute('aria-describedby'); + expect(describedBy).toBeTruthy(); + return document.getElementById(describedBy!)!; + }; + + it('explains a plan lock without an upgrade link on a free install', async () => { + hasFeatureMock.mockImplementation((feature: string) => feature !== 'ai_alerts'); + presentationPolicyHidesUpgradePromptsMock.mockReturnValue(true); + getSettingsMock.mockResolvedValue(lockedPatrolSettings()); + + renderComponent('patrol'); + + await screen.findByLabelText('Enable container update risk analysis'); + expect(containerRiskToggle()).toBeDisabled(); + expect(lockExplanation()).toHaveTextContent( + "Higher license plan required. This install's plan does not include container update risk.", + ); + expect(within(lockExplanation()).queryByRole('link')).toBeNull(); + expect(screen.queryByRole('link', { name: 'Plans & Billing' })).toBeNull(); + }); + + it('links Plans & Billing when upgrade prompts are allowed', async () => { + hasFeatureMock.mockImplementation((feature: string) => feature !== 'ai_alerts'); + getSettingsMock.mockResolvedValue(lockedPatrolSettings()); + + renderComponent('patrol'); + + await screen.findByLabelText('Enable container update risk analysis'); + expect(containerRiskToggle()).toBeDisabled(); + const link = within(lockExplanation()).getByRole('link', { name: 'Plans & Billing' }); + expect(link).toHaveAttribute('href', 'https://example.com/upgrade'); + }); + + it('drops plan and Pro wording when commercial surfaces are hidden', async () => { + hasFeatureMock.mockImplementation((feature: string) => feature !== 'ai_alerts'); + presentationPolicyHidesCommercialSurfacesMock.mockReturnValue(true); + presentationPolicyHidesUpgradePromptsMock.mockReturnValue(true); + getSettingsMock.mockResolvedValue(lockedPatrolSettings()); + + renderComponent('patrol'); + + await screen.findByLabelText('Enable container update risk analysis'); + expect(containerRiskToggle()).toBeDisabled(); + expect(lockExplanation()).toHaveTextContent( + 'Not available. This install does not include container update risk.', + ); + expect(lockExplanation().textContent).not.toMatch(/plan|Pro\b/i); + expect(within(lockExplanation()).queryByRole('link')).toBeNull(); + }); + + it('points a licensed community runtime at the Pro runtime download', async () => { + hasFeatureMock.mockImplementation((feature: string) => feature !== 'ai_alerts'); + getRuntimeCapabilityBlockMock.mockImplementation((feature: string) => + feature === 'ai_alerts' + ? { + key: 'ai_alerts', + reason: 'paid_runtime_required', + action_url: 'https://pulserelay.pro/download.html', + } + : undefined, + ); + getSettingsMock.mockResolvedValue(lockedPatrolSettings()); + + renderComponent('patrol'); + + await screen.findByLabelText('Enable container update risk analysis'); + expect(lockExplanation()).toHaveTextContent( + 'Pulse Pro runtime required. This install is running this runtime. Install the Pulse Pro runtime to use container update risk.', + ); + expect( + within(lockExplanation()).getByRole('link', { name: 'Open Pro downloads' }), + ).toHaveAttribute('href', 'https://pulserelay.pro/download.html'); + }); + + it('shows no lock explanation when the install includes the feature', async () => { + getSettingsMock.mockResolvedValue({ + ...lockedPatrolSettings(), + alert_triggered_analysis: true, + }); + + renderComponent('patrol'); + + await screen.findByLabelText('Enable container update risk analysis'); + expect(containerRiskToggle()).toBeEnabled(); + expect(containerRiskToggle()).not.toHaveAttribute('aria-describedby'); + expect(screen.queryByText(/Higher license plan required/)).toBeNull(); + }); + }); + it('saves Patrol trigger settings from Pulse Intelligence Patrol settings', async () => { getSettingsMock.mockResolvedValue({ ...baseSettings(), diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 768cf83f60..226f053d19 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -1459,6 +1459,35 @@ describe('settings architecture guardrails', () => { ); }); + it('explains the container update risk lock through the canonical Patrol availability policy', () => { + expect(aiSettingsSource).toContain('getPatrolAlertAnalysisAvailabilityPresentation({'); + expect(aiSettingsSource).toContain( + 'commercialSurfacesHidden: presentationPolicyHidesCommercialSurfaces(),', + ); + expect(aiSettingsSource).toContain( + 'upgradePromptsHidden: presentationPolicyHidesUpgradePrompts(),', + ); + expect(aiSettingsSource).toContain( + 'runtimeCapabilityBlock: getRuntimeCapabilityBlock(PATROL_ALERT_ANALYSIS_FEATURE_KEY),', + ); + expect(aiSettingsSource).toContain( + 'planUpgradeDestination: getUpgradeActionDestination(PATROL_ALERT_ANALYSIS_FEATURE_KEY),', + ); + expect(aiSettingsSource).toContain( + 'disabled={props.state.saving() || alertAnalysisAvailability().locked}', + ); + expect(aiSettingsSource).toContain( + '

', + ); + expect(aiSettingsSource).toContain( + 'alertAnalysisAvailability().locked ? alertAnalysisLockId : undefined', + ); + // The shared Toggle declares locked/lockedMessage but never renders them, + // so the card must carry the explanation itself. + expect(aiSettingsSource).not.toContain('lockedMessage='); + expect(aiSettingsSource).not.toContain('Requires Pulse Pro'); + }); + it('keeps system AI save feedback tied to provider and Patrol readiness context', () => { expect(aiSettingsSource).toContain('Choose a Patrol mode on the Patrol page.'); expect(aiSettingsSource).toContain('Open Patrol'); diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts index d3e17b1700..c46e349e8f 100644 --- a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts @@ -8,6 +8,7 @@ import { getPatrolConfigurationFailureInlineDetails, } from '../PatrolIntelligenceHeader'; import { + getPatrolAlertAnalysisAvailabilityPresentation, getPatrolAutonomyAvailabilityPresentation, PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON, } from '../patrolAutonomyAvailability'; @@ -312,6 +313,43 @@ describe('PatrolIntelligenceHeader', () => { }); }); + it('explains the container update risk lock through the same presentation policy', () => { + const planUpgradeDestination = { + href: '/settings/pulse-intelligence/billing/plan', + external: false, + }; + expect( + getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: true, + planUpgradeDestination, + }), + ).toMatchObject({ + kind: 'plan_locked', + title: 'Higher license plan required', + actionLabel: 'Plans & Billing', + destination: planUpgradeDestination, + }); + expect( + getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: true, + upgradePromptsHidden: true, + planUpgradeDestination, + }), + ).not.toHaveProperty('actionLabel'); + expect( + getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: true, + commercialSurfacesHidden: true, + planUpgradeDestination, + }), + ).toEqual({ + kind: 'plan_locked', + locked: true, + title: 'Not available', + body: 'This install does not include container update risk.', + }); + }); + it('keeps trust counters out of the page header chrome', () => { // Needs Attention and deliberate history review own trust // state. The header should stay focused on title, recency, and controls. diff --git a/frontend-modern/src/features/patrol/__tests__/patrolAutonomyAvailability.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolAutonomyAvailability.test.ts index 100d81e519..c8a6f090ed 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolAutonomyAvailability.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolAutonomyAvailability.test.ts @@ -3,8 +3,10 @@ import { describe, expect, it } from 'vitest'; import type { LicenseRuntimeCapabilityBlock, LicenseRuntimeIdentity } from '@/api/license'; import { + PATROL_ALERT_ANALYSIS_FEATURE_KEY, PATROL_AUTONOMY_FEATURE_KEY, PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON, + getPatrolAlertAnalysisAvailabilityPresentation, getPatrolAutonomyAvailabilityPresentation, } from '../patrolAutonomyAvailability'; import type { UpgradeDestination } from '@/utils/upgradeNavigation'; @@ -53,6 +55,10 @@ describe('patrolAutonomyAvailability', () => { expect(PATROL_AUTONOMY_FEATURE_KEY).toBe('ai_autofix'); }); + it('exposes the ai_alerts feature key', () => { + expect(PATROL_ALERT_ANALYSIS_FEATURE_KEY).toBe('ai_alerts'); + }); + it('exposes the paid_runtime_required reason', () => { expect(PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON).toBe('paid_runtime_required'); }); @@ -319,4 +325,118 @@ describe('patrolAutonomyAvailability', () => { }); }); }); + + describe('getPatrolAlertAnalysisAvailabilityPresentation', () => { + const alertRuntimeBlock = runtimeBlock({ key: PATROL_ALERT_ANALYSIS_FEATURE_KEY }); + + it('is available when alert analysis is not locked, ignoring every other field', () => { + expect( + getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: false, + runtimeCapabilityBlock: alertRuntimeBlock, + commercialSurfacesHidden: true, + planUpgradeDestination: planDestination, + }), + ).toEqual({ + kind: 'available', + locked: false, + title: 'Container update risk available', + body: 'Assess risk when container-update alerts fire.', + }); + }); + + it('explains a plan lock and offers Plans & Billing when surfaces are visible', () => { + expect( + getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: true, + planUpgradeDestination: planDestination, + }), + ).toEqual({ + kind: 'plan_locked', + locked: true, + title: 'Higher license plan required', + body: "This install's plan does not include container update risk.", + actionLabel: 'Plans & Billing', + destination: planDestination, + }); + }); + + it('keeps the plan explanation but drops the action when upgrade prompts are hidden', () => { + const result = getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: true, + upgradePromptsHidden: true, + planUpgradeDestination: planDestination, + }); + + expect(result).toEqual({ + kind: 'plan_locked', + locked: true, + title: 'Higher license plan required', + body: "This install's plan does not include container update risk.", + }); + expect(result).not.toHaveProperty('actionLabel'); + expect(result).not.toHaveProperty('destination'); + }); + + it('uses neutral copy with no action when commercial surfaces are hidden', () => { + const result = getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: true, + commercialSurfacesHidden: true, + planUpgradeDestination: planDestination, + }); + + expect(result).toEqual({ + kind: 'plan_locked', + locked: true, + title: 'Not available', + body: 'This install does not include container update risk.', + }); + expect(`${result.title} ${result.body}`).not.toMatch(/plan|Pro\b/i); + }); + + it('shares the canonical runtime lock with Patrol mode', () => { + const input = { + runtimeCapabilityBlock: runtimeBlock({ + key: PATROL_ALERT_ANALYSIS_FEATURE_KEY, + action_url: 'https://pro.example/downloads', + }), + runtime: runtime({ label: 'Pulse Community runtime' }), + planUpgradeDestination: planDestination, + }; + const alertAnalysis = getPatrolAlertAnalysisAvailabilityPresentation({ + ...input, + alertAnalysisLocked: true, + }); + const patrolMode = getPatrolAutonomyAvailabilityPresentation({ + ...input, + autoFixLocked: true, + }); + + expect(alertAnalysis).toEqual({ + kind: 'runtime_locked', + locked: true, + title: 'Pulse Pro runtime required', + body: 'This install is running Pulse Community runtime. Install the Pulse Pro runtime to use container update risk.', + actionLabel: 'Open Pro downloads', + destination: externalDestination('https://pro.example/downloads'), + }); + expect(alertAnalysis.title).toBe(patrolMode.title); + expect(alertAnalysis.actionLabel).toBe(patrolMode.actionLabel); + expect(alertAnalysis.destination).toEqual(patrolMode.destination); + }); + + it('omits the runtime download action when upgrade prompts are hidden', () => { + const result = getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: true, + upgradePromptsHidden: true, + runtimeCapabilityBlock: alertRuntimeBlock, + runtime: runtime(), + planUpgradeDestination: planDestination, + }); + + expect(result.kind).toBe('runtime_locked'); + expect(result).not.toHaveProperty('actionLabel'); + expect(result).not.toHaveProperty('destination'); + }); + }); }); diff --git a/frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts b/frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts index f23791bd33..c683fc9720 100644 --- a/frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts +++ b/frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts @@ -3,12 +3,16 @@ import type { UpgradeDestination } from '@/utils/upgradeNavigation'; import { resolveUpgradeDestination } from '@/utils/upgradeNavigation'; export const PATROL_AUTONOMY_FEATURE_KEY = 'ai_autofix'; +export const PATROL_ALERT_ANALYSIS_FEATURE_KEY = 'ai_alerts'; export const PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON = 'paid_runtime_required'; -export type PatrolAutonomyAvailabilityKind = 'available' | 'plan_locked' | 'runtime_locked'; +export type PatrolFeatureAvailabilityKind = 'available' | 'plan_locked' | 'runtime_locked'; -export interface PatrolAutonomyAvailabilityInput { - autoFixLocked: boolean; +// Presentation policy shared by every plan-gated Patrol capability. Each +// capability supplies only its lock state and its own copy; the precedence +// (runtime lock, then hidden commercial surfaces, then plan lock) and the +// action labels stay canonical here. +interface PatrolFeatureAvailabilityPolicy { upgradePromptsHidden?: boolean; commercialSurfacesHidden?: boolean; runtimeCapabilityBlock?: LicenseRuntimeCapabilityBlock; @@ -16,8 +20,16 @@ export interface PatrolAutonomyAvailabilityInput { planUpgradeDestination: UpgradeDestination; } -export interface PatrolAutonomyAvailabilityPresentation { - kind: PatrolAutonomyAvailabilityKind; +export interface PatrolAutonomyAvailabilityInput extends PatrolFeatureAvailabilityPolicy { + autoFixLocked: boolean; +} + +export interface PatrolAlertAnalysisAvailabilityInput extends PatrolFeatureAvailabilityPolicy { + alertAnalysisLocked: boolean; +} + +export interface PatrolFeatureAvailabilityPresentation { + kind: PatrolFeatureAvailabilityKind; locked: boolean; title: string; body: string; @@ -25,6 +37,44 @@ export interface PatrolAutonomyAvailabilityPresentation { destination?: UpgradeDestination; } +interface PatrolFeatureAvailabilityCopy { + available: { title: string; body: string }; + planLocked: { title: string; body: string }; + // Plan-locked copy for sessions that hide commercial surfaces. Defaults to + // planLocked, so it is only needed when that copy mentions plans. + planLockedCommercialHidden?: { title: string; body: string }; + // Completes "Install the Pulse Pro runtime to use ...". + runtimeFeatureLabel: string; +} + +const PATROL_AUTONOMY_COPY: PatrolFeatureAvailabilityCopy = { + available: { + title: 'Patrol mode available', + body: 'Choose the mode for this install.', + }, + planLocked: { + title: 'Watch only', + body: 'This install watches infrastructure and shows issues.', + }, + runtimeFeatureLabel: 'Patrol modes', +}; + +const PATROL_ALERT_ANALYSIS_COPY: PatrolFeatureAvailabilityCopy = { + available: { + title: 'Container update risk available', + body: 'Assess risk when container-update alerts fire.', + }, + planLocked: { + title: 'Higher license plan required', + body: "This install's plan does not include container update risk.", + }, + planLockedCommercialHidden: { + title: 'Not available', + body: 'This install does not include container update risk.', + }, + runtimeFeatureLabel: 'container update risk', +}; + function getRuntimeDownloadDestination( block: LicenseRuntimeCapabilityBlock | undefined, runtime: LicenseRuntimeIdentity | undefined, @@ -37,15 +87,16 @@ function getRuntimeLabel(runtime: LicenseRuntimeIdentity | undefined): string { return runtime?.label?.trim() || 'this runtime'; } -export function getPatrolAutonomyAvailabilityPresentation( - input: PatrolAutonomyAvailabilityInput, -): PatrolAutonomyAvailabilityPresentation { - if (!input.autoFixLocked) { +function getPatrolFeatureAvailabilityPresentation( + locked: boolean, + input: PatrolFeatureAvailabilityPolicy, + copy: PatrolFeatureAvailabilityCopy, +): PatrolFeatureAvailabilityPresentation { + if (!locked) { return { kind: 'available', locked: false, - title: 'Patrol mode available', - body: 'Choose the mode for this install.', + ...copy.available, }; } @@ -54,7 +105,7 @@ export function getPatrolAutonomyAvailabilityPresentation( kind: 'runtime_locked', locked: true, title: 'Pulse Pro runtime required', - body: `This install is running ${getRuntimeLabel(input.runtime)}. Install the Pulse Pro runtime to use Patrol modes.`, + body: `This install is running ${getRuntimeLabel(input.runtime)}. Install the Pulse Pro runtime to use ${copy.runtimeFeatureLabel}.`, ...(input.upgradePromptsHidden ? {} : { @@ -70,16 +121,14 @@ export function getPatrolAutonomyAvailabilityPresentation( return { kind: 'plan_locked', locked: true, - title: 'Watch only', - body: 'This install watches infrastructure and shows issues.', + ...(copy.planLockedCommercialHidden ?? copy.planLocked), }; } return { kind: 'plan_locked', locked: true, - title: 'Watch only', - body: 'This install watches infrastructure and shows issues.', + ...copy.planLocked, ...(input.upgradePromptsHidden ? {} : { @@ -88,3 +137,19 @@ export function getPatrolAutonomyAvailabilityPresentation( }), }; } + +export function getPatrolAutonomyAvailabilityPresentation( + input: PatrolAutonomyAvailabilityInput, +): PatrolFeatureAvailabilityPresentation { + return getPatrolFeatureAvailabilityPresentation(input.autoFixLocked, input, PATROL_AUTONOMY_COPY); +} + +export function getPatrolAlertAnalysisAvailabilityPresentation( + input: PatrolAlertAnalysisAvailabilityInput, +): PatrolFeatureAvailabilityPresentation { + return getPatrolFeatureAvailabilityPresentation( + input.alertAnalysisLocked, + input, + PATROL_ALERT_ANALYSIS_COPY, + ); +} From 4ce78685e850821924ae94a8eb73b7464ba4739e Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:38:49 +0100 Subject: [PATCH 2/2] Keep Pro runtime wording out of hidden-commercial sessions Demo and white-label sessions hide commercial surfaces, but a licensed install on the community runtime still saw "Pulse Pro runtime required" on the Container update risk card and, inside the Patrol header's mode disclosure, "Install the Pulse Pro runtime" beside a disabled paid-mode picker. The shared availability presentation checked the runtime lock before the hidden-commercial policy, and the Patrol header passed a constant true for that policy, so the order could not be fixed without breaking the header's runtime prompt for everyone else. Hidden commercial surfaces now win over the runtime lock, and the Patrol header and banners pass the session's real policy. Sessions that show commercial surfaces keep the runtime prompt and download link unchanged. --- .../v6/internal/subsystems/api-contracts.md | 5 +- .../subsystems/frontend-primitives.md | 5 ++ .../subsystems/patrol-intelligence.md | 21 ++++--- .../abaabdb1cd2cfda1.json | 42 ++++++++++++++ .../Settings/__tests__/AISettings.test.tsx | 26 +++++++++ .../patrol/PatrolIntelligenceBanners.tsx | 7 ++- .../patrol/PatrolIntelligenceHeader.tsx | 7 ++- .../PatrolIntelligenceHeader.test.ts | 5 +- .../patrolAutonomyAvailability.test.ts | 47 ++++++++++++++- .../patrolCommercialBoundary.test.ts | 8 ++- .../patrol/patrolAutonomyAvailability.ts | 21 +++---- .../pages/__tests__/AIIntelligence.test.tsx | 58 +++++++++++++++++++ .../frontendResourceTypeBoundaries.test.ts | 5 +- 13 files changed, 231 insertions(+), 26 deletions(-) create mode 100644 frontend-modern/browser-verification/abaabdb1cd2cfda1.json diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 0fd38347b5..206cb6030e 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -3191,7 +3191,10 @@ a new API state machine, queue contract, or verification-accounting field. `alert_triggered_analysis` stays the licence-masked API value while the runtime-capabilities `ai_alerts` grant or `paid_runtime_required` block decides which lock explanation the Patrol settings card renders, without a - new API field + new API field. A `paid_runtime_required` block is presentation input only: + when the security-status presentation policy hides commercial surfaces, + Patrol mode and container update risk both render their neutral lock and + the browser must not surface the block's Pro download URL and the Patrol run-record copy, so `finding_ids` remains the API-owned fail-closed scoping input while the frontend presents selected history as a Patrol run record instead of a generic findings filter or snapshot workflow diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index b2eb84d4c2..5d373942b3 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -4743,6 +4743,11 @@ the card that owns it, not through the shared `Toggle` `locked` and `lockedMessage` props, which the primitive does not render. `AISettings.tsx` renders the Container update risk lock from the canonical Patrol availability presentation and links that explanation to the switch with `ariaDescribedBy`. +The shared Patrol feature shells (`PatrolIntelligenceHeader.tsx` and +`PatrolIntelligenceBanners.tsx`) and that card pass the session's real +commercial and upgrade presentation policy into the presentation, never a +constant, so a session that hides commercial surfaces gets the neutral lock +line and never Pulse Pro runtime wording or a download link. That same route-owned presentation rule also governs Patrol findings empty states: shared section shells under `frontend-modern/src/features/patrol/` must not render a green healthy empty state from `0 active findings` alone diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index e04084427b..383727f107 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -726,15 +726,20 @@ attention`, `approval needed`, `outcome verified`, `no active work`) instead infrastructure-unchanged caveats in the default view, or imply Patrol's main value is a manual review chore. Plan-gated Patrol capabilities share that one availability presentation: - `patrolAutonomyAvailability.ts` owns the precedence (runtime lock, then - hidden commercial surfaces, then plan lock) and the only action labels + `patrolAutonomyAvailability.ts` owns the precedence (hidden commercial + surfaces, then runtime lock, then plan lock) and the only action labels (`Open Pro downloads`, `Plans & Billing`), while each capability supplies - its lock state and copy. The Patrol settings Container update risk trigger - (`ai_alerts`) uses `getPatrolAlertAnalysisAvailabilityPresentation`, so a - locked install sees why the trigger is unavailable beside the disabled - switch: a higher license plan, a missing Pulse Pro runtime, or, when - commercial surfaces are hidden, a neutral not-available line with no plan - or Pro wording. Actions appear only when upgrade prompts are allowed. + its lock state and copy. Hidden commercial surfaces win over the runtime + lock, so demo and white-label sessions never see Pulse Pro runtime wording, + and every caller (the Patrol header, the Patrol banners and Patrol + settings) passes the session's real `presentationPolicyHidesCommercialSurfaces()` + and `presentationPolicyHidesUpgradePrompts()` values instead of a constant. + The Patrol settings Container update risk trigger (`ai_alerts`) uses + `getPatrolAlertAnalysisAvailabilityPresentation`, so a locked install sees + why the trigger is unavailable beside the disabled switch: a higher + license plan, a missing Pulse Pro runtime, or, when commercial surfaces are + hidden, a neutral not-available line with no plan or Pro wording. Actions + appear only when upgrade prompts are allowed. 5. Add or change Patrol header schedule and runtime presentation through `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx`, `frontend-modern/src/utils/aiPatrolSchedulePresentation.ts`, and `frontend-modern/src/utils/patrolRuntimePresentation.ts`. Patrol must not surface retired hosted-model credit badges or trial-like activation prompts in the normal self-hosted GA app, even when legacy transport fields are still present. 6. Keep Patrol and chat identifier-label presentation aligned through the shared `frontend-modern/src/utils/textPresentation.ts` diff --git a/frontend-modern/browser-verification/abaabdb1cd2cfda1.json b/frontend-modern/browser-verification/abaabdb1cd2cfda1.json new file mode 100644 index 0000000000..41be908ee6 --- /dev/null +++ b/frontend-modern/browser-verification/abaabdb1cd2cfda1.json @@ -0,0 +1,42 @@ +{ + "version": 1, + "base_sha": "357b1a505e80bb1cafbfc9cb02f36f96182c7b57", + "verified_at": "2026-10-05T21:37:58Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx", + "frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx", + "frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts" + ], + "content_sha256": { + "frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx": "89bce4990f82104e8724e4e099a3ae06a275cf1a2d5ab875670f9bc3b4cefcff", + "frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx": "6b899aaebeba642fd5bb792146f27302d29542a4f4ced548f71168398101faf3", + "frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts": "3d334beb4135c9c53027b79debd869c403db627f207bfb54ab004d5d191f99b0" + }, + "routes": [ + "/settings/pulse-intelligence/patrol", + "/patrol" + ], + "viewports": [ + { + "width": 1280, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private community-runtime stack built from this tree (backend 7782, Vite 5282 serving this checkout), mock data, dev-signed licences switched live on the same backend. Playwright Chromium at 1280x900 dark and 390x844 light.", + "Enterprise licence on the community runtime (white-label: hideCommercial and hideUpgrade true; ai_alerts and ai_autofix blocked as paid_runtime_required): the Container update risk card reads 'Not available. This install does not include container update risk.' with no link, and the Patrol header shows the plain Watch only line with no Mode and automation disclosure, no paid-mode picker, no Pulse Pro runtime wording and no download link.", + "Same stack and licence with the previous patrolAutonomyAvailability.ts, PatrolIntelligenceHeader.tsx and PatrolIntelligenceBanners.tsx swapped back in under the running Vite: the card showed 'Pulse Pro runtime required...' and the expanded Mode and automation disclosure showed 'Install the Pulse Pro runtime to use Patrol modes' beside a disabled paid-mode picker. Restored afterwards.", + "Pro licence on the community runtime (commercial and upgrade prompts visible): unchanged. Card shows the runtime lock with Open Pro downloads; the Patrol page still shows the runtime prompt and the https://pulserelay.pro/download.html link with the mode picker.", + "Free community install (licence cleared; hideUpgrade true, hideCommercial false): unchanged. Card shows the higher-license-plan line with no link; Patrol header shows plain Watch only.", + "No horizontal overflow and no page errors in any state." + ], + "interactions": [ + "Expanded the Patrol header 'Mode and automation' disclosure where present to read the runtime note and mode picker.", + "Activated an Enterprise licence, a Pro licence and cleared the licence on the same backend, reloading both routes for each state." + ] +} diff --git a/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx b/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx index 55ce073d9f..759d16ace6 100644 --- a/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx @@ -441,6 +441,32 @@ describe('AISettings model loading error states', () => { ).toHaveAttribute('href', 'https://pulserelay.pro/download.html'); }); + it('keeps Pro runtime wording out when commercial surfaces are hidden', async () => { + hasFeatureMock.mockImplementation((feature: string) => feature !== 'ai_alerts'); + presentationPolicyHidesCommercialSurfacesMock.mockReturnValue(true); + presentationPolicyHidesUpgradePromptsMock.mockReturnValue(true); + getRuntimeCapabilityBlockMock.mockImplementation((feature: string) => + feature === 'ai_alerts' + ? { + key: 'ai_alerts', + reason: 'paid_runtime_required', + action_url: 'https://pulserelay.pro/download.html', + } + : undefined, + ); + getSettingsMock.mockResolvedValue(lockedPatrolSettings()); + + renderComponent('patrol'); + + await screen.findByLabelText('Enable container update risk analysis'); + expect(containerRiskToggle()).toBeDisabled(); + expect(lockExplanation()).toHaveTextContent( + 'Not available. This install does not include container update risk.', + ); + expect(lockExplanation().textContent).not.toMatch(/\bPro\b|runtime|plan/i); + expect(within(lockExplanation()).queryByRole('link')).toBeNull(); + }); + it('shows no lock explanation when the install includes the feature', async () => { getSettingsMock.mockResolvedValue({ ...lockedPatrolSettings(), diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx index cacd6f23c0..059d311f05 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx @@ -6,7 +6,10 @@ import SettingsIcon from 'lucide-solid/icons/settings'; import { AnimatedNumber } from '@/components/shared/AnimatedNumber'; import { UpgradeButtonLink } from '@/components/shared/UpgradeLink'; import { getUpgradeActionDestination } from '@/stores/licenseCommercial'; -import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy'; +import { + presentationPolicyHidesCommercialSurfaces, + presentationPolicyHidesUpgradePrompts, +} from '@/stores/sessionPresentationPolicy'; import { formatRelativeTime } from '@/utils/format'; import { getAIProviderDisplayName } from '@/utils/aiProviderPresentation'; import { getPatrolSetupAction } from '@/utils/patrolRuntimeActions'; @@ -35,6 +38,8 @@ export function PatrolIntelligenceBanners(props: { state: PatrolIntelligenceStat const autonomyAvailability = createMemo(() => getPatrolAutonomyAvailabilityPresentation({ autoFixLocked: state.autoFixLocked(), + commercialSurfacesHidden: presentationPolicyHidesCommercialSurfaces(), + upgradePromptsHidden: presentationPolicyHidesUpgradePrompts(), runtimeCapabilityBlock: state.autoFixCapabilityBlock(), runtime: state.licenseRuntimeIdentity(), planUpgradeDestination: getUpgradeActionDestination('ai_autofix'), diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx index 7fc77c485e..490cdf1ab6 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx @@ -13,7 +13,10 @@ import { UpgradeButtonLink } from '@/components/shared/UpgradeLink'; import type { PatrolAutonomyLevel } from '@/api/patrol'; import { settingsTabPath } from '@/components/Settings/settingsNavigationModel'; import { getUpgradeActionDestination } from '@/stores/licenseCommercial'; -import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy'; +import { + presentationPolicyHidesCommercialSurfaces, + presentationPolicyHidesUpgradePrompts, +} from '@/stores/sessionPresentationPolicy'; import { formatRelativeTime } from '@/utils/format'; import { getPatrolPageHeaderMeta } from '@/utils/patrolPagePresentation'; import { getPatrolTriggerStatusSummary } from '@/utils/patrolRunPresentation'; @@ -146,7 +149,7 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState getPatrolAutonomyAvailabilityPresentation({ autoFixLocked: state.autoFixLocked(), upgradePromptsHidden: upgradePromptsHidden(), - commercialSurfacesHidden: true, + commercialSurfacesHidden: presentationPolicyHidesCommercialSurfaces(), runtimeCapabilityBlock: state.autoFixCapabilityBlock(), runtime: state.licenseRuntimeIdentity(), planUpgradeDestination: getUpgradeActionDestination('ai_autofix'), diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts index c46e349e8f..81e50c2cc1 100644 --- a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts @@ -251,7 +251,10 @@ describe('PatrolIntelligenceHeader', () => { external: false, }, }); - expect(headerSource).toContain('commercialSurfacesHidden: true'); + expect(headerSource).toContain( + 'commercialSurfacesHidden: presentationPolicyHidesCommercialSurfaces(),', + ); + expect(headerSource).not.toContain('commercialSurfacesHidden: true'); expect(headerSource).not.toContain('showAutonomyPlanBillingAction'); expect(headerSource).not.toContain('CreditCardIcon'); expect(headerSource).not.toContain('Plans & Billing'); diff --git a/frontend-modern/src/features/patrol/__tests__/patrolAutonomyAvailability.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolAutonomyAvailability.test.ts index c8a6f090ed..7d7d99ddc9 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolAutonomyAvailability.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolAutonomyAvailability.test.ts @@ -93,7 +93,7 @@ describe('patrolAutonomyAvailability', () => { it.each([ [ - 'runtime_locked beats commercialSurfacesHidden', + 'commercialSurfacesHidden beats runtime_locked', { autoFixLocked: true, runtimeCapabilityBlock: runtimeBlock(), @@ -102,6 +102,17 @@ describe('patrolAutonomyAvailability', () => { upgradePromptsHidden: true, planUpgradeDestination: planDestination, }, + 'plan_locked', + ], + [ + 'runtime_locked when commercial surfaces are visible', + { + autoFixLocked: true, + runtimeCapabilityBlock: runtimeBlock(), + runtime: runtime(), + upgradePromptsHidden: true, + planUpgradeDestination: planDestination, + }, 'runtime_locked', ], [ @@ -425,6 +436,40 @@ describe('patrolAutonomyAvailability', () => { expect(alertAnalysis.destination).toEqual(patrolMode.destination); }); + it('keeps Pro runtime wording out of hidden-commercial sessions for both capabilities', () => { + const input = { + commercialSurfacesHidden: true, + upgradePromptsHidden: true, + runtimeCapabilityBlock: runtimeBlock({ action_url: 'https://pro.example/downloads' }), + runtime: runtime({ label: 'Pulse Community runtime' }), + planUpgradeDestination: planDestination, + }; + const alertAnalysis = getPatrolAlertAnalysisAvailabilityPresentation({ + ...input, + alertAnalysisLocked: true, + }); + const patrolMode = getPatrolAutonomyAvailabilityPresentation({ + ...input, + autoFixLocked: true, + }); + + expect(alertAnalysis).toEqual({ + kind: 'plan_locked', + locked: true, + title: 'Not available', + body: 'This install does not include container update risk.', + }); + expect(patrolMode).toEqual({ + kind: 'plan_locked', + locked: true, + title: 'Watch only', + body: 'This install watches infrastructure and shows issues.', + }); + for (const result of [alertAnalysis, patrolMode]) { + expect(`${result.title} ${result.body}`).not.toMatch(/\bPro\b|runtime|plan/i); + } + }); + it('omits the runtime download action when upgrade prompts are hidden', () => { const result = getPatrolAlertAnalysisAvailabilityPresentation({ alertAnalysisLocked: true, diff --git a/frontend-modern/src/features/patrol/__tests__/patrolCommercialBoundary.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolCommercialBoundary.test.ts index 7e56c8fcd9..3bf59b5561 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolCommercialBoundary.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolCommercialBoundary.test.ts @@ -16,7 +16,13 @@ describe('patrol commercial boundary', () => { expect(patrolIntelligenceHeaderSource).toContain( "autonomyAvailability().kind === 'runtime_locked'", ); - expect(patrolIntelligenceHeaderSource).toContain('commercialSurfacesHidden: true'); + expect(patrolIntelligenceHeaderSource).toContain( + 'commercialSurfacesHidden: presentationPolicyHidesCommercialSurfaces(),', + ); + expect(patrolIntelligenceHeaderSource).not.toContain('commercialSurfacesHidden: true'); + expect(patrolIntelligenceBannersSource).toContain( + 'commercialSurfacesHidden: presentationPolicyHidesCommercialSurfaces(),', + ); expect(patrolIntelligenceHeaderSource).not.toContain('showAutonomyPlanBillingAction'); expect(patrolAutonomyAvailabilitySource).toContain('Plans & Billing'); expect(patrolAutonomyAvailabilitySource).toContain('input.upgradePromptsHidden'); diff --git a/frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts b/frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts index c683fc9720..36aa0ce346 100644 --- a/frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts +++ b/frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts @@ -10,8 +10,9 @@ export type PatrolFeatureAvailabilityKind = 'available' | 'plan_locked' | 'runti // Presentation policy shared by every plan-gated Patrol capability. Each // capability supplies only its lock state and its own copy; the precedence -// (runtime lock, then hidden commercial surfaces, then plan lock) and the -// action labels stay canonical here. +// (hidden commercial surfaces, then runtime lock, then plan lock) and the +// action labels stay canonical here. Hidden commercial surfaces win over the +// runtime lock so demo and white-label sessions never see Pro wording. interface PatrolFeatureAvailabilityPolicy { upgradePromptsHidden?: boolean; commercialSurfacesHidden?: boolean; @@ -100,6 +101,14 @@ function getPatrolFeatureAvailabilityPresentation( }; } + if (input.commercialSurfacesHidden) { + return { + kind: 'plan_locked', + locked: true, + ...(copy.planLockedCommercialHidden ?? copy.planLocked), + }; + } + if (input.runtimeCapabilityBlock?.reason === PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON) { return { kind: 'runtime_locked', @@ -117,14 +126,6 @@ function getPatrolFeatureAvailabilityPresentation( }; } - if (input.commercialSurfacesHidden) { - return { - kind: 'plan_locked', - locked: true, - ...(copy.planLockedCommercialHidden ?? copy.planLocked), - }; - } - return { kind: 'plan_locked', locked: true, diff --git a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx index 41c9269c4b..e98f75d56a 100644 --- a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx +++ b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx @@ -1259,6 +1259,64 @@ describe('AIIntelligence entitlement gating', () => { expect(screen.queryByRole('button', { name: /start free trial/i })).not.toBeInTheDocument(); }); + describe('Patrol mode runtime lock', () => { + const useLicensedCommunityRuntime = () => { + getRuntimeCapabilityBlockMock.mockImplementation((feature: string) => + feature === 'ai_autofix' + ? { + key: 'ai_autofix', + reason: 'paid_runtime_required', + action_url: 'https://pulserelay.pro/download.html', + } + : undefined, + ); + runtimeCapabilitiesMock.mockReturnValue({ + runtime: { build: 'community', label: 'Pulse Community runtime' }, + capabilities: ['ai_intelligence'], + blocked_capabilities: [ + { key: 'ai_autofix', reason: 'paid_runtime_required' }, + { key: 'ai_alerts', reason: 'paid_runtime_required' }, + ], + }); + getPatrolStatusMock.mockResolvedValue(defaultPatrolStatus({ license_required: true })); + }; + + it('points a licensed community runtime at the Pro runtime download', async () => { + useLicensedCommunityRuntime(); + + render(() => ); + + await waitFor(() => { + expect(screen.getAllByText(/Pulse Pro runtime required/).length).toBeGreaterThan(0); + }); + const downloads = screen.getAllByRole('link', { name: 'Open Pro downloads' }); + expect(downloads.length).toBeGreaterThan(0); + for (const link of downloads) { + expect(link).toHaveAttribute('href', 'https://pulserelay.pro/download.html'); + } + }); + + it('keeps Pro runtime wording off the page when commercial surfaces are hidden', async () => { + useLicensedCommunityRuntime(); + presentationPolicyHidesCommercialSurfacesMock.mockReturnValue(true); + presentationPolicyHidesUpgradePromptsMock.mockReturnValue(true); + + render(() => ); + + await waitFor(() => { + expect(getPatrolStatusMock).toHaveBeenCalled(); + }); + const patrolControlAnchor = document.getElementById(PATROL_CONTROL_ANCHOR); + expect(patrolControlAnchor).not.toBeNull(); + const patrolControl = within(patrolControlAnchor!); + expect(patrolControl.getAllByText('Watch only').length).toBeGreaterThan(0); + expect(patrolControl.queryByRole('group', { name: 'Patrol mode' })).not.toBeInTheDocument(); + expect(screen.queryByText(/Pulse Pro runtime/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Install the Pulse Pro runtime/)).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Open Pro downloads' })).not.toBeInTheDocument(); + }); + }); + it('keeps the Patrol model catalog out of the operator page', async () => { apiFetchJSONMock.mockImplementation(async (path: string) => { if (path === '/api/ai/models') { diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index d7f19e6bae..dd056e05e3 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -3596,7 +3596,10 @@ describe('frontend resource type boundaries', () => { expect(patrolIntelligenceBannersSource).toContain('state.licenseRequired()'); expect(patrolIntelligenceHeaderSource).toContain('presentationPolicyHidesUpgradePrompts'); expect(patrolIntelligenceHeaderSource).toContain('!presentationPolicyHidesUpgradePrompts()'); - expect(patrolIntelligenceHeaderSource).toContain('commercialSurfacesHidden: true'); + expect(patrolIntelligenceHeaderSource).toContain( + 'commercialSurfacesHidden: presentationPolicyHidesCommercialSurfaces(),', + ); + expect(patrolIntelligenceHeaderSource).not.toContain('commercialSurfacesHidden: true'); expect(patrolIntelligenceHeaderSource).not.toContain('Plans & Billing'); expect(patrolIntelligenceHeaderSource).toContain( "from '@/components/shared/FilterButtonGroup'",