diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 76c5f97463..166de986ed 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -20,6 +20,19 @@ ## Purpose +### Visible action text remains in accessible names (24 October 2026) + +Infrastructure section shortcuts include their visible Add action and group +identity in the accessible name. Update chips include the visible singular or +counted update label, the affected system and Agent Doctor destination, at both +responsive layouts. Read-only inventories expose no add/update action. Patrol +attention rows derive their name from their visible severity, title, summary, +decision/resource context and observation time rather than a title-only override. +Native buttons, pressed state, focus and touch selection remain unchanged. +Registered InfrastructureSourceManager and PatrolAttentionWorkbench tests cover +scoped actions, counts, read-only rendering and time-varying accessible content; +the consuming WCAG scan and its rules remain enforced. + ### Agent first-report status Machines, resource detail drawers and agent connection rows use the shared diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 0ddcde083b..a2f27c2eae 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -15,6 +15,16 @@ ## Purpose +### Attention selection retains visible evidence in its name (24 October 2026) + +Attention list buttons use their visible contents as the accessible name instead +of replacing severity, summary, decision state and Last seen evidence with an +Open-title label. Resource-leading titles, flapping/handled badges, pressed state +and native keyboard/touch selection are preserved. The registered workbench +suite checks the full accessible name, including changes to relative time, +and existing review/detail/focus behaviour. No provider, action execution, +authorisation or attention-state semantics change. + ### One-page Patrol setup on the Patrol page When Patrol is blocked because no provider is connected, Pulse Intelligence diff --git a/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx b/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx index 07cd5f93bb..277a3d31a6 100644 --- a/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx @@ -940,7 +940,7 @@ export const InfrastructureSourceManager: Component handleGroupAddSource(group)} class={`${addSectionButtonClass} whitespace-nowrap`} - aria-label={group.actionLabel} + aria-label={`Add ${group.label}`} title={group.actionLabel} > @@ -1080,7 +1080,7 @@ export const InfrastructureSourceManager: Component {row.agentUpdateCount === 1 ? 'Agent update' @@ -1468,7 +1468,7 @@ export const InfrastructureSourceManager: Component handleGroupAddSource(group)} class={`${addSectionButtonClass} whitespace-nowrap`} - aria-label={group.actionLabel} + aria-label={`Add ${group.label}`} title={group.actionLabel} > @@ -1697,7 +1697,7 @@ export const InfrastructureSourceManager: Component {row.agentUpdateCount === 1 ? 'Agent update' diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureSourceManager.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureSourceManager.test.tsx index abe2f56b04..25e7b2278a 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureSourceManager.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureSourceManager.test.tsx @@ -220,7 +220,7 @@ describe('InfrastructureSourceManager setup summary', () => { 'min-h-11', 'lg:min-h-9', ); - expect(screen.getByRole('button', { name: /^Install Pulse Agent$/i })).toHaveClass( + expect(screen.getByRole('button', { name: /^Add Pulse Agent hosts$/i })).toHaveClass( 'min-h-11', 'lg:min-h-0', ); @@ -229,6 +229,64 @@ describe('InfrastructureSourceManager setup summary', () => { ); }); + it.each([1440, 390])('keeps visible Add and update labels in scoped names at %ipx', (width) => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: width }); + const onAddSource = vi.fn(); + const onOpenAgentDoctor = vi.fn(); + const outdated = connectionFixture({ id: 'agent:outdated', agentUpdateAvailable: true }); + const second = connectionFixture({ id: 'agent:second', agentUpdateAvailable: true }); + const current = connectionFixture({ id: 'agent:current', agentUpdateAvailable: false }); + const rows = [ + row({ + id: 'fleet', + name: 'Fleet alpha', + agentUpdateCount: 2, + connection: outdated, + attachedConnections: [second, current], + }), + ]; + const view = render(() => ( + rows} + discoveredNodes={() => []} + discoveryEnabled={false} + discoveryScanStatus={() => ({ scanning: false })} + readOnly={false} + onAddSource={onAddSource} + onOpenAgentDoctor={onOpenAgentDoctor} + /> + )); + const add = screen.getByRole('button', { name: 'Add Pulse Agent hosts' }); + expect(add).toHaveTextContent('Add'); + expect(add).toHaveAttribute('title', 'Install Pulse Agent'); + fireEvent.click(add); + expect(onAddSource).toHaveBeenCalledExactlyOnceWith('agent'); + const label = width < 1024 ? '2 agent updates' : '2 updates'; + const update = screen.getByRole('button', { + name: `${label}: open Agent Doctor for Fleet alpha`, + }); + expect(update).toHaveTextContent(label); + fireEvent.click(update); + expect(onOpenAgentDoctor).toHaveBeenCalledExactlyOnceWith(['agent:outdated', 'agent:second']); + view.unmount(); + render(() => ( + rows} + discoveredNodes={() => []} + discoveryEnabled={false} + discoveryScanStatus={() => ({ scanning: false })} + readOnly + onAddSource={onAddSource} + onOpenAgentDoctor={onOpenAgentDoctor} + /> + )); + expect(screen.queryByRole('button', { name: 'Add Pulse Agent hosts' })).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /open Agent Doctor for Fleet alpha/ }), + ).not.toBeInTheDocument(); + expect(screen.getByText(label)).toBeInTheDocument(); + }); + it('keeps manual discovery observable while a scan is running', () => { const onRunDiscovery = vi.fn(); @@ -437,7 +495,9 @@ describe('InfrastructureSourceManager setup summary', () => { /> )); - fireEvent.click(screen.getByRole('button', { name: 'Open Agent Doctor for behind-host' })); + fireEvent.click( + screen.getByRole('button', { name: 'Agent update: open Agent Doctor for behind-host' }), + ); expect(onOpenAgentDoctor).toHaveBeenCalledWith(['agent:behind-host']); }); @@ -478,7 +538,7 @@ describe('InfrastructureSourceManager setup summary', () => { )); expect( - screen.getByRole('button', { name: 'Open Agent Doctor for outdated-host' }), + screen.getByRole('button', { name: 'Agent update: open Agent Doctor for outdated-host' }), ).toHaveTextContent('Agent update'); expect(screen.queryByText('Version behind')).toBeNull(); expect(screen.queryByText(/needs? attention/i)).toBeNull(); diff --git a/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx b/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx index 5463aa0abb..4e211e4c44 100644 --- a/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx +++ b/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx @@ -732,7 +732,6 @@ function AttentionList(props: { : 'hover:bg-surface-hover' }`} aria-pressed={props.selectedItemId === item.id} - aria-label={`Open ${displayTitle()}`} onClick={() => props.onSelect(item.id)} >
diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx b/frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx index 3357415d91..6b60d58022 100644 --- a/frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx +++ b/frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx @@ -304,8 +304,11 @@ describe('PatrolAttentionWorkbench', () => { apiMocks.getDetail.mockResolvedValue(detail(active)); try { renderWorkbench(); - const row = await screen.findByRole('button', { name: 'Open Database VM · Disk pressure' }); - expect(row).toHaveTextContent('Last seen 1m ago'); + const row = await screen.findByRole('button', { name: /Database VM · Disk pressure/ }); + expect(row).not.toHaveAttribute('aria-label'); + expect(row).toHaveAccessibleName( + 'Critical Flapping · 11 changes in 24h Database VM · Disk pressure The database disk is nearly full. Last seen 1m ago', + ); fireEvent.click(row); const detailRegion = await screen.findByRole('complementary', { name: 'Database VM · Disk pressure', @@ -315,7 +318,9 @@ describe('PatrolAttentionWorkbench', () => { expect(detailRegion).toHaveTextContent(/first 10m ago.*latest 2m ago/); const held = vi.spyOn(patrolAttentionStore, 'load').mockResolvedValue(undefined); vi.advanceTimersByTime(60_000); - expect(row).toHaveTextContent('Last seen 2m ago'); + expect(row).toHaveAccessibleName( + 'Critical Flapping · 11 changes in 24h Database VM · Disk pressure The database disk is nearly full. Last seen 2m ago', + ); expect(detailRegion).toHaveTextContent('Last seen 2m ago'); expect(detailRegion).toHaveTextContent(/first 11m ago.*latest 3m ago/); expect(apiMocks.getList).toHaveBeenCalledOnce(); @@ -374,9 +379,7 @@ describe('PatrolAttentionWorkbench', () => { await screen.findByRole('heading', { name: '1 decision needs you' }), ).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Start review' })).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: 'Open Database VM · Disk pressure' }), - ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Database VM · Disk pressure/ })).toBeInTheDocument(); expect(screen.queryByText("Today's Patrol briefing")).not.toBeInTheDocument(); expect(screen.queryByRole('heading', { name: 'Decision inbox' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Review this decision' })).not.toBeInTheDocument(); @@ -451,7 +454,9 @@ describe('PatrolAttentionWorkbench', () => { /1 other current issue is continuing without a decision under Safe auto-fix/i, ), ).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /Open Database VM/ })).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /Database VM · Disk pressure/ }), + ).not.toBeInTheDocument(); }); it('keeps a long decision queue compact until the operator asks for all of it', async () => { @@ -464,10 +469,10 @@ describe('PatrolAttentionWorkbench', () => { renderWorkbench(); const showAll = await screen.findByRole('button', { name: 'Show all 7 decisions' }); - expect(screen.getAllByRole('button', { name: /Open Decision/ })).toHaveLength(5); + expect(screen.getAllByRole('button', { name: /Decision \d/ })).toHaveLength(5); fireEvent.click(showAll); - expect(screen.getAllByRole('button', { name: /Open Decision/ })).toHaveLength(7); + expect(screen.getAllByRole('button', { name: /Decision \d/ })).toHaveLength(7); expect(screen.getByRole('button', { name: 'Show fewer decisions' })).toBeInTheDocument(); }); @@ -526,7 +531,7 @@ describe('PatrolAttentionWorkbench', () => { renderWorkbench(); const openButton = await screen.findByRole('button', { - name: 'Open Database VM · Disk pressure', + name: /Database VM · Disk pressure/, }); fireEvent.click(openButton); @@ -586,7 +591,7 @@ describe('PatrolAttentionWorkbench', () => { apiMocks.getDetail.mockResolvedValue(detail(active)); renderWorkbench(); - fireEvent.click(await screen.findByRole('button', { name: /^Open .*Database VM/ })); + fireEvent.click(await screen.findByRole('button', { name: /Database VM · Disk pressure/ })); const link = await screen.findByRole('link', { name: 'Open resource' }); await waitFor(() => expect(link).toHaveAttribute('href', '/vmware/overview')); expect(getResource).toHaveBeenCalledWith('agent-b602', expect.anything()); @@ -609,9 +614,7 @@ describe('PatrolAttentionWorkbench', () => { try { renderWorkbench(); - fireEvent.click( - await screen.findByRole('button', { name: 'Open Database VM · Disk pressure' }), - ); + fireEvent.click(await screen.findByRole('button', { name: /Database VM · Disk pressure/ })); const detailRegion = await screen.findByRole('complementary', { name: 'Database VM · Disk pressure', }); @@ -654,9 +657,7 @@ describe('PatrolAttentionWorkbench', () => { try { renderWorkbench(); fireEvent.click(await screen.findByRole('button', { name: 'Reviewed and suppressed (1)' })); - fireEvent.click( - await screen.findByRole('button', { name: 'Open Database VM · Disk pressure' }), - ); + fireEvent.click(await screen.findByRole('button', { name: /Database VM · Disk pressure/ })); const detailRegion = await screen.findByRole('complementary', { name: 'Database VM · Disk pressure', }); @@ -707,9 +708,7 @@ describe('PatrolAttentionWorkbench', () => { apiMocks.getDetail.mockResolvedValue(detail(active)); renderWorkbench(); - fireEvent.click( - await screen.findByRole('button', { name: 'Open Database VM · Disk pressure' }), - ); + fireEvent.click(await screen.findByRole('button', { name: /Database VM · Disk pressure/ })); const detailRegion = await screen.findByRole('complementary', { name: 'Database VM · Disk pressure', }); @@ -755,7 +754,7 @@ describe('PatrolAttentionWorkbench', () => { apiMocks.getDetail.mockResolvedValue(detail(unknownTrust)); renderWorkbench(); - const row = await screen.findByRole('button', { name: 'Open Database VM · Disk pressure' }); + const row = await screen.findByRole('button', { name: /Database VM · Disk pressure/ }); expect(within(row).queryByText(/Evidence/)).not.toBeInTheDocument(); expect(within(row).queryByText(/timing/i)).not.toBeInTheDocument(); expect(within(row).queryByText('Unknown / Complete')).not.toBeInTheDocument(); @@ -791,7 +790,7 @@ describe('PatrolAttentionWorkbench', () => { fireEvent.click( await screen.findByRole('button', { - name: 'Open Database VM · Disk pressure', + name: /Database VM · Disk pressure/, }), ); @@ -858,7 +857,7 @@ describe('PatrolAttentionWorkbench', () => { fireEvent.click( await screen.findByRole('button', { - name: 'Open Database VM · Disk pressure', + name: /Database VM · Disk pressure/, }), ); expect(await screen.findByText('Decision 1 of 2')).toBeInTheDocument(); @@ -898,7 +897,7 @@ describe('PatrolAttentionWorkbench', () => { fireEvent.click( screen.getByRole('button', { - name: 'Open Database VM · Disk pressure', + name: /Database VM · Disk pressure/, }), ); fireEvent.click(await screen.findByRole('button', { name: 'Return to decision inbox' })); @@ -935,7 +934,7 @@ describe('PatrolAttentionWorkbench', () => { fireEvent.click( await screen.findByRole('button', { - name: 'Open Database VM · Disk pressure', + name: /Database VM · Disk pressure/, }), ); fireEvent.click(await screen.findByRole('button', { name: 'Suppress temporarily' })); @@ -1023,7 +1022,7 @@ describe('PatrolAttentionWorkbench', () => { fireEvent.click(await screen.findByRole('button', { name: 'Reviewed and suppressed (1)' })); fireEvent.click( await screen.findByRole('button', { - name: 'Open Database VM · Disk pressure', + name: /Database VM · Disk pressure/, }), ); fireEvent.click(await screen.findByRole('button', { name: 'Suppress temporarily' })); @@ -1100,7 +1099,7 @@ describe('PatrolAttentionWorkbench', () => { fireEvent.click(await screen.findByRole('button', { name: 'Reviewed and suppressed (1)' })); fireEvent.click( await screen.findByRole('button', { - name: 'Open Database VM · Disk pressure', + name: /Database VM · Disk pressure/, }), ); const returnToAttention = await screen.findByRole('button', { @@ -1151,7 +1150,7 @@ describe('PatrolAttentionWorkbench', () => { fireEvent.click(await screen.findByRole('button', { name: 'Reviewed and suppressed (1)' })); fireEvent.click( await screen.findByRole('button', { - name: 'Open Database VM · Disk pressure', + name: /Database VM · Disk pressure/, }), ); fireEvent.click(await screen.findByRole('button', { name: 'End suppression' })); @@ -1180,7 +1179,7 @@ describe('PatrolAttentionWorkbench', () => { fireEvent.click( await screen.findByRole('button', { - name: 'Open Database VM · Disk pressure', + name: /Database VM · Disk pressure/, }), ); @@ -1243,7 +1242,7 @@ describe('PatrolAttentionWorkbench', () => { )); fireEvent.click( await screen.findByRole('button', { - name: 'Open Database VM · Disk pressure', + name: /Database VM · Disk pressure/, }), ); const decisions = within(await screen.findByRole('list', { name: 'Lasting decisions' })); @@ -1301,7 +1300,7 @@ describe('PatrolAttentionWorkbench', () => { )); fireEvent.click( await screen.findByRole('button', { - name: 'Open Database VM · Disk pressure', + name: /Database VM · Disk pressure/, }), ); @@ -1331,7 +1330,7 @@ describe('PatrolAttentionWorkbench', () => { )); fireEvent.click( await screen.findByRole('button', { - name: 'Open Database VM · Disk pressure', + name: /Database VM · Disk pressure/, }), ); @@ -1378,7 +1377,7 @@ describe('PatrolAttentionWorkbench', () => { )); fireEvent.click( await screen.findByRole('button', { - name: 'Open Database VM · Disk pressure', + name: /Database VM · Disk pressure/, }), ); @@ -1422,7 +1421,7 @@ describe('PatrolAttentionWorkbench', () => { )); fireEvent.click( await screen.findByRole('button', { - name: 'Open Database VM · Disk pressure', + name: /Database VM · Disk pressure/, }), ); @@ -1482,7 +1481,7 @@ describe('PatrolAttentionWorkbench', () => { renderWorkbench(); const row = await screen.findByRole('button', { - name: 'Open shlink-web-clientX · Docker container state', + name: /shlink-web-clientX · Docker container state/, }); expect(within(row).getByText('Flapping · 11 changes in 24h')).toBeInTheDocument(); fireEvent.click(row); @@ -1586,7 +1585,7 @@ describe('PatrolAttentionWorkbench', () => { fireEvent.click( await screen.findByRole('button', { - name: 'Open API container is unhealthy', + name: /API container is unhealthy/, }), ); const trigger = await screen.findByRole('button', { name: 'Review and approve' });