From f81b6594977c5ca51441bc7bf0bb4746c094f8df Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:14:49 +0100 Subject: [PATCH] fix(web): preserve Space activation before type-to-search The populated Ceph thresholds browser test reproduced Space on the disclosure moving focus to search instead of collapsing it. Leave Space to buttons, summaries and role buttons, while preserving ordinary type-to-search input. Protect the keyboard journey with a full collapsed focus cycle and reopened action focus, plus focused hook coverage. Chromium failed before this repair and passes after it; 59 focused unit tests pass. Change-source: pulse-maintainer --- frontend-modern/browser-verification.json | 48 +++++++----------- .../hooks/__tests__/useTypeToSearch.test.ts | 50 +++++++++++++++++++ frontend-modern/src/hooks/useTypeToSearch.ts | 7 +++ .../tests/66-ceph-alert-thresholds.spec.ts | 44 +++++++++++++++- 4 files changed, 118 insertions(+), 31 deletions(-) create mode 100644 frontend-modern/src/hooks/__tests__/useTypeToSearch.test.ts diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index f98fc3690..13fb6d2e5 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,50 +1,38 @@ { "version": 1, - "base_sha": "09ab5c2d0ae6e02fcc5280853782a8142646e40f", - "verified_at": "2026-09-07T10:45:50.469666Z", + "base_sha": "1dcaee8ae870092f08f1692d9c81935d13229bc7", + "verified_at": "2026-09-07T14:17:11.679627Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/features/actions/ActionDecisionPacket.tsx" + "frontend-modern/src/hooks/useTypeToSearch.ts" ], "content_sha256": { - "frontend-modern/src/features/actions/ActionDecisionPacket.tsx": "ea0301d5ee7bee7b064f8e7fd8db43a54a74bc4488c0a87285d7c52151a5e07b" + "frontend-modern/src/hooks/useTypeToSearch.ts": "6ca47c567549b1dd3b38b84e5deaf1261652c58c79c6d8a43a7d25572a1f8f0a" }, "routes": [ - "/actions?action=act_9ec8d575b777a5f8fc70561376a52381", - "/actions?action=act_3384576233bf55aa3182537ad02d81d4", - "/actions?action=act_7687850d214e9c5f7038a436c6cc2ff7", - "/actions?action=act_e5e1f06da3dc42215a97360731d7e36b", - "/actions?action=act_185eebf5dfc24652e6332881dbe94f6c" + "/alerts/thresholds/infrastructure" ], "viewports": [ { - "width": 1440, - "height": 1000 + "width": 1280, + "height": 720 }, { - "width": 900, - "height": 1000 - }, - { - "width": 390, - "height": 1000 + "width": 393, + "height": 727 } ], "states": [ - "VM110 start and stop awaiting exact-plan approval", - "Completed VM110 start and stop with independent Proxmox verification", - "Expanded independent observer evidence with original observation and Pulse receipt labels", - "Expanded durable delivery record with agent-specific observation label", - "Retained completed Docker restart with independent evidence", - "Retained rejected Docker restart without execution controls", - "Retained expired Docker restart with refresh offered and no execution controls" + "Populated Ceph storage thresholds with retained custom overrides", + "Storage Devices expanded, collapsed with inert mounted content, and reopened", + "Search empty, ordinary typed character, and trailing space" ], "interactions": [ - "Inspect exact target identity, approval controls and planned state before API-authorized execution", - "Open and close policy evidence, evidence details and delivery identifiers using keyboard focus and Enter", - "Scroll nested action review and inspect readable evidence, wrapping, stacking and reachable footer at all widths", - "Close with Escape and explicit close control, reopen exact action link and reload persisted state", - "Inspect final pixels and verify no document horizontal overflow" + "Enter opens Storage Devices; Space collapses without search focus theft", + "Full Tab focus cycle excludes collapsed descendants and returns to disclosure", + "Enter reopens and Ceph edit action receives focus", + "Ordinary typing from focused edit button focuses search; Space in search remains input; clear restores resource rows", + "Inspect reopened desktop and narrow screenshots with visible disclosure and focused edit action" ], - "notes": "Final bundled development binary SHA256 c53f2e54e7ea510fc3abfbfbe164e74e363d3b1560aaa36a62df9fb1aaaf2c7d. Scripts, screenshots and raw dialog text are in workspace tmp/patrol-runner-readiness/browser and verify-action.mjs/verify-history.mjs. Browser requests were GET plus login only. Exact VM110 approvals and execution were performed separately through the canonical API, with independent Proxmox and SSH state checks and retained native receipts. Both temporary services, credentials and tunnel were removed and read-only control restored. Deep links have no initiating button for focus return. This receipt qualifies the named action-review change and adjacent rendered outcomes, not full Patrol/Assistant readiness." + "notes": "Current embedded frontend and isolated core backend exercised via pulse-heavy-run; final tests/66-ceph-alert-thresholds.spec.ts passed in chromium and mobile-chrome (2 tests). Screenshots inspected in tests/integration/test-results/66-ceph-alert-thresholds-C-51ff4-the-live-thresholds-surface-{chromium,mobile-chrome}/keyboard-reopened.png. Resource APIs mocked; no installed customer, assistive-technology, WebKit, Firefox, or notification-delivery qualification. Native summary, semantic role button and nested target ownership also covered by focused unit tests; browser matrix covers the reproduced threshold/search interaction, not every hook consumer. Runtimes stopped by harness." } diff --git a/frontend-modern/src/hooks/__tests__/useTypeToSearch.test.ts b/frontend-modern/src/hooks/__tests__/useTypeToSearch.test.ts new file mode 100644 index 000000000..05d4e0a3c --- /dev/null +++ b/frontend-modern/src/hooks/__tests__/useTypeToSearch.test.ts @@ -0,0 +1,50 @@ +import { createRoot } from 'solid-js'; +import { afterEach, describe, expect, it } from 'vitest'; +import { useTypeToSearch } from '../useTypeToSearch'; + +describe('type-to-search keyboard ownership', () => { + let dispose: (() => void) | undefined; + afterEach(() => { + dispose?.(); + document.body.replaceChildren(); + }); + + const setup = () => { + const input = document.createElement('input'); + document.body.append(input); + createRoot((cleanup) => { + dispose = cleanup; + useTypeToSearch({ getInput: () => input }); + }); + return input; + }; + + it.each(['button', 'summary', 'role-button', 'button-child'])('leaves Space to %s', async (kind) => { + const input = setup(); + const control = document.createElement(kind === 'summary' ? 'summary' : kind === 'role-button' ? 'div' : 'button'); + control.tabIndex = 0; + if (kind === 'role-button') control.setAttribute('role', 'button'); + document.body.append(control); + const target = kind === 'button-child' ? control.appendChild(document.createElement('span')) : control; + control.focus(); + const event = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }); + target.dispatchEvent(event); + await Promise.resolve(); + expect(event.defaultPrevented).toBe(false); + expect(document.activeElement).toBe(control); + expect(input.value).toBe(''); + }); + + it('still captures ordinary typing and spaces away from controls', async () => { + const input = setup(); + for (const key of ['a', ' ']) { + input.blur(); + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }); + document.body.dispatchEvent(event); + await Promise.resolve(); + expect(event.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(input); + } + expect(input.value).toBe('a '); + }); +}); diff --git a/frontend-modern/src/hooks/useTypeToSearch.ts b/frontend-modern/src/hooks/useTypeToSearch.ts index 1e19dcc1b..3c6c992e5 100644 --- a/frontend-modern/src/hooks/useTypeToSearch.ts +++ b/frontend-modern/src/hooks/useTypeToSearch.ts @@ -196,6 +196,13 @@ const handleTypeToSearchKeyDown = (event: KeyboardEvent) => { return; } + // Space activates focused buttons/disclosures on keyup. Capturing its + // keydown for search prevents that native action and steals focus. + if (event.key === ' ' && [event.target, document.activeElement].some( + (target) => target instanceof HTMLElement && + Boolean(target.closest('button, summary, [role="button"]')), + )) return; + if (!isPrintableSearchKey(event)) return; const match = getActiveEntry(() => true, { allowPrepared: true }); diff --git a/tests/integration/tests/66-ceph-alert-thresholds.spec.ts b/tests/integration/tests/66-ceph-alert-thresholds.spec.ts index c0490355d..441c0ba64 100644 --- a/tests/integration/tests/66-ceph-alert-thresholds.spec.ts +++ b/tests/integration/tests/66-ceph-alert-thresholds.spec.ts @@ -278,7 +278,8 @@ test.describe('Ceph alert thresholds', () => { .getByRole('heading', { name: 'Storage Devices' }) .getByRole('button'); if ((await storageToggle.getAttribute('aria-expanded')) === 'false') { - await storageToggle.click(); + await storageToggle.focus(); + await page.keyboard.press('Enter'); } await expect(storageToggle).toHaveAttribute('aria-expanded', 'true'); await expect(page.getByText('ceph-pool', { exact: true })).toBeVisible(); @@ -287,5 +288,46 @@ test.describe('Ceph alert thresholds', () => { await expect(page.getByRole('button', { name: 'Edit thresholds for ceph-pool' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Revert to defaults for data_replication' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Edit thresholds for data_replication' })).toBeVisible(); + + // Exercise native keyboard activation and focus exclusion in a real + // browser: DOM-only tests cannot establish inert tab-order behaviour. + const contentId = await storageToggle.getAttribute('aria-controls'); + expect(contentId).toBeTruthy(); + const content = page.locator(`[id="${contentId}"]`); + const editPool = page.getByRole('button', { name: 'Edit thresholds for ceph-pool' }); + await editPool.focus(); + await expect(editPool).toBeFocused(); + await storageToggle.focus(); + await page.keyboard.press('Space'); + await expect(storageToggle).toHaveAttribute('aria-expanded', 'false'); + await expect(content).toHaveAttribute('inert', ''); + await expect(content).toHaveAttribute('aria-hidden', 'true'); + + // Walk the complete focus cycle, rather than checking only the first + // header action after the toggle. Hidden descendants remain mounted. + const focusableCount = await page.locator('button, a[href], input, select, textarea, [tabindex]').count(); + for (let step = 0; step <= focusableCount; step += 1) { + await page.keyboard.press('Tab'); + expect(await content.evaluate((element) => element.contains(document.activeElement))).toBe(false); + if (await storageToggle.evaluate((element) => element === document.activeElement)) break; + } + await expect(storageToggle).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(storageToggle).toHaveAttribute('aria-expanded', 'true'); + await expect(content).not.toHaveAttribute('inert'); + await editPool.focus(); + await expect(editPool).toBeFocused(); + await storageToggle.scrollIntoViewIfNeeded(); + await page.screenshot({ path: test.info().outputPath('keyboard-reopened.png') }); + + const search = page.getByPlaceholder('Search resources...'); + await expect(search).toHaveValue(''); + await page.keyboard.press('c'); + await expect(search).toBeFocused(); + await expect(search).toHaveValue('c'); + await page.keyboard.press('Space'); + await expect(search).toHaveValue('c '); + await search.fill(''); + await expect(page.getByText('ceph-pool', { exact: true })).toBeVisible(); }); });