From d856689f5b3b200c942db4151a0b657cfdacf1c4 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:31:00 +0100 Subject: [PATCH 1/2] Restore first-tap guest details on touch browsers Reuse the shared non-activating native row marker without moving actions out of delegated event ordering. Preserve nested controls, keyboard disclosure, row identity and conditional action removal. Cover guest/full-drawer touch input and safe backup guidance with exact browser proof and source regressions. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 14 + .../subsystems/performance-and-scalability.md | 9 + .../browser-tests/guest-row-touch.cjs | 299 ++++++++++++++++++ .../browser-tests/guest-row-touch.html | 12 + .../browser-tests/guest-row-touch.tsx | 123 +++++++ .../7139f2120612aedc.json | 45 +++ .../src/components/Workloads/GuestRow.tsx | 2 + .../Workloads/__tests__/GuestRow.test.tsx | 65 ++++ .../src/components/shared/Table.tsx | 3 +- .../shared/__tests__/PulseDataGrid.test.tsx | 13 +- 10 files changed, 581 insertions(+), 4 deletions(-) create mode 100644 frontend-modern/browser-tests/guest-row-touch.cjs create mode 100644 frontend-modern/browser-tests/guest-row-touch.html create mode 100644 frontend-modern/browser-tests/guest-row-touch.tsx create mode 100644 frontend-modern/browser-verification/7139f2120612aedc.json diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 0bf42b1a70..7760e00f05 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -623,6 +623,20 @@ scrolling. Explicit caller-owned native listeners remain caller-owned. isolation and bound handlers; `PulseDataGrid.test.tsx` checks inheritance. The production PBS table/drawer browser fixture exercises first-tap disclosure and History in WebKit phone emulation, not installed collection or devices. +The custom Workloads `GuestRow` shell reuses `Table`'s exported +`nativeRowClickTarget` marker, conditional on its own action, without inheriting +shared row styling or changing alert/highlight treatment. Its name and row body +must open the full guest drawer on the first touch even when the compact-layout +disclosure button is visually hidden. Keep the action document-delegated; +nested disclosure SVGs, external links and keyboard controls must still perform +only their own action. Removing the action removes the native marker, and +re-enabling it must work without replacing the row. Pointer-up and touch-end +alone never perform the action. `GuestRow.test.tsx` covers native binding, +non-activation, dynamic removal and child ordering; +`browser-tests/guest-row-touch.cjs` covers trusted row/name taps, the production +full drawer's backup/read guidance, nested controls and shared-row compatibility +on Chromium desktop/phone and WebKit phone VM/CT fixtures. This is emulated +input/presentation proof, not physical-device, native thaw or released acceptance. `PlatformResourceDetailToggleButton` is the desktop disclosure affordance and is visually removed on phone layouts where the complete row is the touch target; provider tables must not add a second mobile chevron. When row activation diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 1aa8041768..db65ab3598 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -198,6 +198,15 @@ poller, per-agent goroutine, inventory scan, or action-session lookup to normal request routing; local assets are streamed once and published fallbacks retain the existing bounded HTTP client and artifact-size checks. +The custom Workloads guest row shares `Table`'s singleton non-activating native +click marker for WebKit touch eligibility. Bind it only while a row action +exists, without changing row identity, alert/highlight classes, metric derivation +or delegated child-control ordering. The marker must not perform actions, read +telemetry, start History work, add polling/timers or activate on pointer-up or +touch-end. `GuestRow.test.tsx` verifies its conditional lifecycle and +non-activation; the production row/full-drawer touch fixture verifies first +activation and unchanged identity independently of native collector acceptance. + ## Canonical Files 1. `pkg/metrics/store.go` diff --git a/frontend-modern/browser-tests/guest-row-touch.cjs b/frontend-modern/browser-tests/guest-row-touch.cjs new file mode 100644 index 0000000000..b75faf4c31 --- /dev/null +++ b/frontend-modern/browser-tests/guest-row-touch.cjs @@ -0,0 +1,299 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); + +const root = '/workspace/frontend-modern'; +const output = '/workspace/tmp/guest-row-touch'; +const origin = 'http://127.0.0.1:5313'; +const runtime = [ + 'frontend-modern/src/components/Workloads/GuestRow.tsx', + 'frontend-modern/src/components/shared/Table.tsx', +]; +const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); + +(async () => { + fs.mkdirSync(output, { recursive: true }); + const result = { + playwright: require('playwright/package.json').version, + runtime_hashes: Object.fromEntries(runtime.map((file) => [file, sha256('/workspace/' + file)])), + results: [], + screenshots: [], + result: 'incomplete', + }; + assert.equal( + result.playwright, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: output + '/cache', + server: { host: '127.0.0.1', port: 5313, strictPort: true }, + }); + let browser; + try { + await server.listen(); + for (const [name, engine, width, dark, kinds] of [ + ['webkit-phone', webkit, 390, true, ['qemu', 'lxc']], + ['chromium-desktop', chromium, 1365, false, ['qemu', 'lxc']], + ['chromium-phone', chromium, 390, false, ['qemu']], + ['webkit-narrow', webkit, 360, false, ['lxc']], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const context = await browser.newContext({ + viewport: { width, height: width <= 768 ? 844 : 900 }, + isMobile: width <= 768, + hasTouch: width <= 768, + locale: 'en-GB', + timezoneId: 'UTC', + }); + for (const kind of kinds) { + const page = await context.newPage(); + page.setDefaultTimeout(15000); + page.setDefaultNavigationTimeout(60000); + const record = { + name, + kind, + browser: browser.version(), + viewport: { width, height: width <= 768 ? 844 : 900 }, + dark, + errors: [], + requests: [], + events: [], + checks: [], + }; + result.results.push(record); + page.on('pageerror', (error) => record.errors.push(error.message)); + await page.route('**/*', (route) => { + const request = route.request(); + const url = new URL(request.url()); + record.requests.push({ + method: request.method(), + origin: url.origin, + path: url.pathname, + }); + if (url.origin !== origin) return route.abort(); + if (url.pathname.startsWith('/api/')) + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + data: [], + alerts: [], + anomalies: [], + config: null, + }), + }); + return route.continue(); + }); + await page.addInitScript((isDark) => { + window.__inputEvents = []; + for (const type of ['pointerdown', 'pointerup', 'touchstart', 'touchend', 'click']) + document.addEventListener( + type, + (event) => + window.__inputEvents.push({ + type, + tag: event.target.tagName, + trusted: event.isTrusted, + pointerType: event.pointerType, + }), + true, + ); + document.addEventListener('DOMContentLoaded', () => { + if (isDark) document.documentElement.classList.add('dark'); + }); + }, dark); + await page.goto(`${origin}/browser-tests/guest-row-touch.html?kind=${kind}`); + await page.waitForFunction(() => window.__guestRowTouch); + assert.equal(await page.evaluate(() => innerWidth), width); + const row = page.locator('[data-guest-id]'); + const nameTarget = row.getByText('backup-guest', { exact: true }); + const drawer = page.getByRole('region', { name: 'Guest details' }); + const disclosure = row.locator('[data-row-action]'); + await page.evaluate(() => { + window.__originalGuestRow = document.querySelector('[data-guest-id]'); + }); + const state = () => + page.evaluate(() => ({ + count: window.__guestRowTouch.actionCount(), + expanded: window.__guestRowTouch.expanded(), + closeCount: window.__guestRowTouch.closeCount(), + disclosureExpanded: document + .querySelector('[data-row-action]') + ?.getAttribute('aria-expanded'), + sameRow: document.querySelector('[data-guest-id]') === window.__originalGuestRow, + })); + const activate = async (target) => { + if (width <= 768) await target.tap(); + else await target.click(); + await page.waitForTimeout(650); + }; + const expectState = async (count, expanded) => { + const value = await state(); + record.state = value; + record.events = await page.evaluate(() => window.__inputEvents); + assert.equal(value.count, count, `${name}/${kind}: single row activation`); + assert.equal(value.expanded, expanded); + assert.equal(value.disclosureExpanded, String(expanded)); + assert.equal(value.sameRow, true); + }; + try { + await activate(width === 360 ? row : nameTarget); + await expectState(1, true); + assert(record.events.some((event) => event.type === 'click' && event.trusted)); + if (width <= 768) { + assert(record.events.some((event) => event.type === 'touchend' && event.trusted)); + assert.equal(await row.getAttribute('data-history-lens-active'), null); + } + await drawer.getByText('No completed backup found', { exact: true }).waitFor(); + await drawer.getByText('Running ยท not completed yet', { exact: true }).waitFor(); + if (kind === 'qemu') + await drawer.getByText(/Using last known disk stats\. Guest reads paused/).waitFor(); + assert.equal( + await disclosure.getAttribute('aria-controls'), + await drawer.getAttribute('id'), + ); + assert.equal(await row.getAttribute('tabindex'), null); + assert.equal(await row.getAttribute('aria-expanded'), null); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), + true, + ); + record.checks.push( + `first trusted ${width === 360 ? 'row-body' : 'name'} activation opens production full drawer with safe backup/read context`, + ); + const screenshot = `${output}/${name}-${kind}-open.png`; + await page.screenshot({ path: screenshot, fullPage: true }); + result.screenshots.push({ + path: path.relative('/workspace', screenshot), + sha256: sha256(screenshot), + }); + await activate(width === 360 ? row : nameTarget); + await expectState(2, false); + await activate(row); + await expectState(3, true); + await activate(page.getByRole('button', { name: 'Collapse backup-guest details' })); + await expectState(3, false); + assert.equal((await state()).closeCount, 1); + record.checks.push( + 'repeated name and row-centre activation toggle once; nested full-drawer collapse stays independent', + ); + + await disclosure.focus(); + await page.keyboard.press('Enter'); + await expectState(4, true); + await page.keyboard.press('Space'); + await expectState(5, false); + await disclosure.locator('svg').click(); + await expectState(6, true); + // Keyboard focus makes the compact disclosure button visible and + // can consume the name's space; the row body stays the touch target. + await activate(row); + await expectState(7, false); + record.checks.push( + 'native disclosure keyboard Enter/Space and nested SVG click do not double-activate', + ); + + if (width >= 1024) { + // The adjacent link is intentionally hidden in condensed phone + // identity. Exercise its real wide-layout action, preventing only + // external navigation, not production propagation handling. + const link = row.getByRole('link', { name: 'Open web interface for backup-guest' }); + await link.evaluate((anchor) => + anchor.addEventListener('click', (event) => event.preventDefault()), + ); + await activate(link.locator('svg')); + await expectState(7, false); + record.checks.push( + 'real external-link SVG retains its separate action; navigation prevented in fixture only', + ); + } + + await page.evaluate(() => window.__guestRowTouch.enabled(false)); + assert.equal(await disclosure.count(), 0); + await activate(row); + assert.equal((await state()).count, 7); + assert.equal((await state()).expanded, false); + await page.evaluate(() => window.__guestRowTouch.enabled(true)); + await activate(row); + await expectState(8, true); + record.checks.push( + 'disabling/re-enabling the row action preserves node identity and first-tap activation', + ); + assert.deepEqual(record.errors, []); + assert( + record.requests.every( + (request) => request.origin === origin && request.method === 'GET', + ), + ); + record.result = 'passed'; + } catch (error) { + record.result = 'failed'; + record.error = error.message; + const screenshot = `${output}/${name}-${kind}-failed.png`; + await page.screenshot({ path: screenshot, fullPage: true }); + result.screenshots.push({ + path: path.relative('/workspace', screenshot), + sha256: sha256(screenshot), + }); + throw error; + } finally { + await page.close(); + } + } + // Exporting the compatibility marker must preserve the original shared + // TableRow's own single-action and embedded-control ordering. + const page = await context.newPage(); + await page.goto(`${origin}/browser-tests/table-row-touch.html`); + const sharedState = () => page.getByRole('status', { name: 'Action state' }).textContent(); + const shared = page.locator('[data-testid="control-row"]'); + if (width <= 768) await shared.getByText('Shared resource name', { exact: true }).tap(); + else await shared.getByText('Shared resource name', { exact: true }).click(); + const sharedValue = JSON.parse(await sharedState()); + assert.equal(sharedValue.rowActions, 1); + await shared.getByRole('button', { name: 'Child action' }).click(); + const childValue = JSON.parse(await sharedState()); + assert.equal(childValue.rowActions, 1); + assert.equal(childValue.childActions, 1); + result.results.push({ + name, + control: 'shared-TableRow', + single: sharedValue, + child: childValue, + result: 'passed', + }); + await page.close(); + await context.close(); + await browser.close(); + browser = null; + } + result.result = 'passed'; + result.verified_at = new Date().toISOString(); + console.log( + 'PASS: trusted guest row/disclosure activation, full drawer safety context and child isolation', + ); + } catch (error) { + result.result = 'failed'; + result.error = error.message; + throw error; + } finally { + fs.writeFileSync(output + '/result.json', JSON.stringify(result, null, 2) + '\n'); + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { + console.error(error.stack); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/guest-row-touch.html b/frontend-modern/browser-tests/guest-row-touch.html new file mode 100644 index 0000000000..c3c2c52ed3 --- /dev/null +++ b/frontend-modern/browser-tests/guest-row-touch.html @@ -0,0 +1,12 @@ + + + + + + Guest row touch verification + + +
+ + + diff --git a/frontend-modern/browser-tests/guest-row-touch.tsx b/frontend-modern/browser-tests/guest-row-touch.tsx new file mode 100644 index 0000000000..539499c43e --- /dev/null +++ b/frontend-modern/browser-tests/guest-row-touch.tsx @@ -0,0 +1,123 @@ +// Production guest row and full drawer with synthetic observations. +// No collector, backup command, QGA/thaw, installed device or release proof. +import { createSignal, Show } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { GuestRow } from '../src/components/Workloads/GuestRow'; +import { buildSummaryDisclosureControlsId } from '../src/components/shared/summaryInteractionA11y'; +import type { WorkloadTableLayoutMode } from '../src/components/Workloads/guestRowModel'; +import { GuestDrawer } from '../src/components/Workloads/GuestDrawer'; +import { DarkModeContext } from '../src/contexts/appRuntime'; +import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState'; +import type { WorkloadGuest } from '../src/types/workloads'; +import '../src/index.css'; + +syncAIRuntimeSettings({ discovery_enabled: false }); + +function Fixture() { + const [observation, setObservation] = createSignal({ + lastBackup: 0, + running: true, + reason: 'prev-vm-locked', + }); + const [closeCount, setCloseCount] = createSignal(0); + const [actionCount, setActionCount] = createSignal(0); + const [expanded, setExpanded] = createSignal(false); + const [enabled, setEnabled] = createSignal(true); + const [layout, setLayout] = createSignal( + window.innerWidth <= 768 ? 'phone' : 'wide', + ); + const toggle = () => { + setActionCount((count) => count + 1); + setExpanded((value) => !value); + }; + const type = new URLSearchParams(window.location.search).get('kind') === 'lxc' ? 'lxc' : 'qemu'; + const guest = (): WorkloadGuest => ({ + id: 'fixture-pve:pve-a:101', + vmid: 101, + name: 'backup-guest', + node: 'pve-a', + instance: 'fixture-pve', + status: 'running', + type, + cpu: 0.1, + cpus: 2, + memory: { total: 1024 ** 3, used: 256 * 1024 ** 2, free: 768 * 1024 ** 2, usage: 25 }, + disk: { total: 10 * 1024 ** 3, used: 5 * 1024 ** 3, usage: 50 }, + disks: [ + { mountpoint: '/data', type: 'ext4', total: 10 * 1024 ** 3, used: 5 * 1024 ** 3, usage: 50 }, + ], + diskStatusReason: observation().reason, + networkIn: 0, + networkOut: 0, + diskRead: 0, + diskWrite: 0, + uptime: 3600, + template: false, + lastBackup: observation().lastBackup, + backupInProgress: observation().running, + tags: [], + lock: observation().running ? 'backup' : '', + lastSeen: '2026-10-03T12:00:00Z', + }); + (window as any).__guestRowTouch = { + update: setObservation, + closeCount, + actionCount, + expanded, + enabled: setEnabled, + layout: setLayout, + identity: () => guest().id, + }; + return ( +
+

Guest row touch verification

+

Synthetic observations; no backup command is sent.

+ + + + +
+ +
+ { + setCloseCount((count) => count + 1); + setExpanded(false); + }} + /> +
+
+
+ ); +} + +render( + () => ( + + ( + document.documentElement.classList.contains('dark')} + > + + + )} + /> + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification/7139f2120612aedc.json b/frontend-modern/browser-verification/7139f2120612aedc.json new file mode 100644 index 0000000000..604657fe02 --- /dev/null +++ b/frontend-modern/browser-verification/7139f2120612aedc.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "base_sha": "888d918ba1a0a4bf448542630bc0f47af72e4572", + "verified_at": "2026-10-03T23:27:41.013Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestRow.tsx", + "frontend-modern/src/components/shared/Table.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestRow.tsx": "1096ad3669baea8bec5c6a80b016b64f2c428d2e5a0b7c85746ff6bf21e103fa", + "frontend-modern/src/components/shared/Table.tsx": "014d9c4382d4de03a547f171e410fb9207686bf00dd76695b50c98249d6eacbd" + }, + "routes": [ + "/browser-tests/guest-row-touch.html?kind=qemu", + "/browser-tests/guest-row-touch.html?kind=lxc", + "/browser-tests/table-row-touch.html" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + }, + { + "width": 360, + "height": 844 + } + ], + "states": [ + "Production GuestRow and full GuestDrawer, production CSS and delegated actions, synthetic VM/CT observations. Playwright 1.56.1 matches integration lock; Chromium 141.0.7390.37 desktop/phone and WebKit 26.0 phone. Six screenshots opened and inspected.", + "Trusted first name/row-body activation reveals missing completed backup, separately cautionary running activity and retained VM-lock disk guidance. The 360px CT control uses the row body. Full drawer safety copy wraps without page overflow; the two-column fixture is not full Workloads table layout acceptance.", + "Row remains a table row, not an extra tab stop or duplicate expanded control. Native disclosure retains matching aria-expanded and aria-controls, conditional action removal/re-enable preserves the same row node.", + "Emulated input and presentation only: no physical phone, real guest collector, QGA/thaw, installed recovery or released-availability assertion. Earlier failed parent activation and two adverse harness runs remain retained separately." + ], + "interactions": [ + "Trusted Playwright touch taps emit click and toggle the same row exactly once; repeated name/row taps and production drawer collapse preserve independent action counts. Touch entry does not activate a hover history lens.", + "Focused native disclosure Enter/Space and nested SVG click each toggle once. The row body remains usable while keyboard focus makes compact disclosure visible. No touch-end or pointer-up action is added.", + "Real adjacent external-link SVG is exercised on the actual wide layout with only navigation prevented; production propagation isolation keeps the row closed. Phone identity intentionally hides that link; it is not forced into a non-condensed phone layout.", + "Removing the row action prevents activation and removes disclosure; re-enabling restores first-tap activation without remount. Exported compatibility marker preserves shared TableRow single action and delegated child-button isolation. Guest fixtures issue only local GET requests and report no page errors." + ] +} diff --git a/frontend-modern/src/components/Workloads/GuestRow.tsx b/frontend-modern/src/components/Workloads/GuestRow.tsx index 2622930599..8254fed215 100644 --- a/frontend-modern/src/components/Workloads/GuestRow.tsx +++ b/frontend-modern/src/components/Workloads/GuestRow.tsx @@ -27,6 +27,7 @@ import { createSummaryInteractiveRowPreviewHandlers, } from '@/components/shared/summaryInteractionA11y'; import { SummaryRowActionButton } from '@/components/shared/SummaryRowActionButton'; +import { nativeRowClickTarget } from '@/components/shared/Table'; import { DiscoveryReadinessBadge } from '@/components/shared/DiscoveryReadinessBadge'; import { getWorkloadGuestDiskStatusMessage } from '@/utils/workloadGuestPresentation'; import { ResourceNameWithWebInterfaceLink } from '@/components/shared/WebInterfaceLink'; @@ -241,6 +242,7 @@ export function GuestRow(props: GuestRowProps) { : undefined } data-summary-row-active={props.isSummaryHighlighted && !props.isExpanded ? 'true' : 'false'} + on:click={props.onClick ? nativeRowClickTarget : undefined} onClick={props.onClick} {...interactiveRowHandlers} > diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx index 42b1032ce3..16dd981390 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx @@ -212,6 +212,7 @@ afterEach(() => { cleanup(); vi.useRealTimers(); vi.clearAllMocks(); + vi.restoreAllMocks(); isMobileMock.mockReturnValue(false); }); @@ -835,6 +836,70 @@ describe('GuestRow', () => { expect(onClick).toHaveBeenCalledOnce(); }); + it('marks only actionable guest rows as native touch click targets', () => { + const addListener = vi.spyOn(HTMLTableRowElement.prototype, 'addEventListener'); + const removeListener = vi.spyOn(HTMLTableRowElement.prototype, 'removeEventListener'); + const onClick = vi.fn(); + const [enabled, setEnabled] = createSignal(false); + const { container } = render(() => ( + + + + +
+ )); + const row = container.querySelector('tr')!; + const nativeClickCalls = () => addListener.mock.calls.filter(([type]) => type === 'click'); + + expect(nativeClickCalls()).toHaveLength(0); + setEnabled(true); + expect(nativeClickCalls()).toHaveLength(1); + expect(row).not.toHaveAttribute('tabindex'); + expect(row).not.toHaveAttribute('role', 'button'); + expect(row).not.toHaveAttribute('aria-expanded'); + + // The native compatibility target must never perform the action itself. + const nativeClick = nativeClickCalls()[0][1] as EventListener; + nativeClick.call(row, new MouseEvent('click')); + fireEvent.touchEnd(row); + fireEvent.pointerUp(row, { pointerType: 'touch' }); + expect(onClick).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText('test-vm')); + expect(onClick).toHaveBeenCalledTimes(1); + + setEnabled(false); + expect(removeListener.mock.calls.some(([type]) => type === 'click')).toBe(true); + fireEvent.click(row); + expect(onClick).toHaveBeenCalledTimes(1); + setEnabled(true); + fireEvent.click(screen.getByText('test-vm')); + expect(onClick).toHaveBeenCalledTimes(2); + }); + + it('keeps child disclosure and external-link actions ahead of guest row activation', () => { + const onClick = vi.fn(); + const { container } = renderGuestRow({ + guest: makeGuest(), + customUrl: 'https://guest.example.invalid/', + onClick, + }); + fireEvent.click(screen.getByRole('link', { name: 'Open web interface for test-vm' })); + expect(onClick).not.toHaveBeenCalled(); + const button = screen.getByRole('button', { name: 'Expand test-vm' }); + fireEvent.click(button.querySelector('svg')!); + expect(onClick).toHaveBeenCalledTimes(1); + fireEvent.keyDown(button, { key: 'Enter' }); + expect(onClick).toHaveBeenCalledTimes(2); + fireEvent.keyDown(button, { key: ' ', code: 'Space' }); + expect(onClick).toHaveBeenCalledTimes(3); + fireEvent.keyDown(container.querySelector('tr')!, { key: 'Enter' }); + expect(onClick).toHaveBeenCalledTimes(3); + }); + it('calls onHoverChange with canonical guestId on fine-pointer preview', () => { const onHoverChange = vi.fn(); const { container } = renderGuestRow({ diff --git a/frontend-modern/src/components/shared/Table.tsx b/frontend-modern/src/components/shared/Table.tsx index a39c4ebabc..7b87a7d588 100644 --- a/frontend-modern/src/components/shared/Table.tsx +++ b/frontend-modern/src/components/shared/Table.tsx @@ -66,7 +66,8 @@ export type TableRowProps = JSX.HTMLAttributes; // WebKit touch needs a native click target on otherwise-static table rows. // Keep actions document-delegated: nested controls must run first and retain // their existing stopPropagation behaviour instead of opening the row too. -const nativeRowClickTarget = () => undefined; +// Custom row shells reuse this marker without inheriting TableRow's styling. +export const nativeRowClickTarget = () => undefined; export function TableRow(props: TableRowProps) { const [local, rest] = splitProps(props, ['class', 'children']); diff --git a/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx b/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx index b42076dff2..b3da84eeb9 100644 --- a/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx @@ -5,7 +5,7 @@ import pulseDataGridSource from '@/components/shared/PulseDataGrid.tsx?raw'; import pulseDataGridModelSource from '@/components/shared/pulseDataGridModel.ts?raw'; import pulseDataGridStateSource from '@/components/shared/usePulseDataGridState.ts?raw'; import { PulseDataGrid } from '@/components/shared/PulseDataGrid'; -import { TableCell } from '@/components/shared/Table'; +import { TableCell, nativeRowClickTarget } from '@/components/shared/Table'; import tableSource from '@/components/shared/Table.tsx?raw'; type TestRow = { @@ -105,16 +105,23 @@ describe('PulseDataGrid', () => { it('inherits the native touch click target from the shared TableRow', () => { const nativeListener = vi.spyOn(HTMLTableRowElement.prototype, 'addEventListener'); + const onRowClick = vi.fn(); try { render(() => ( data={[{ id: '1', name: 'Touch tower' }]} columns={[{ key: 'name', label: 'Name' }]} keyExtractor={(row) => row.id} - onRowClick={vi.fn()} + onRowClick={onRowClick} /> )); - expect(nativeListener.mock.calls.some(([type]) => type === 'click')).toBe(true); + expect(nativeListener.mock.calls.find(([type]) => type === 'click')?.[1]).toBe( + nativeRowClickTarget, + ); + nativeRowClickTarget(); + expect(onRowClick).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText('Touch tower')); + expect(onRowClick).toHaveBeenCalledTimes(1); } finally { nativeListener.mockRestore(); } From 826eaf2a1275b59dfc22f372958665223a0ebbe8 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:47:28 +0100 Subject: [PATCH 2/2] Forward native and delegated guest row handlers through typed props Match the existing shared TableRow splitProps pattern so the JSX lint check recognises distinct native eligibility and delegated action handlers. Preserve the same callback ordering, conditional removal, identity and keyboard controls; repeat the production guest/full-drawer browser pass for the final binding. Change-source: pulse-maintainer Contract-Neutral: Typed handler forwarding preserves the already-contracted touch eligibility, actions, child isolation, accessibility, row identity and state; no contract delta. --- .../9596a94267f10b6d.json | 44 +++++++++++++++++++ .../src/components/Workloads/GuestRow.tsx | 7 +-- 2 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 frontend-modern/browser-verification/9596a94267f10b6d.json diff --git a/frontend-modern/browser-verification/9596a94267f10b6d.json b/frontend-modern/browser-verification/9596a94267f10b6d.json new file mode 100644 index 0000000000..b18aa6a289 --- /dev/null +++ b/frontend-modern/browser-verification/9596a94267f10b6d.json @@ -0,0 +1,44 @@ +{ + "version": 1, + "base_sha": "d856689f5b3b200c942db4151a0b657cfdacf1c4", + "verified_at": "2026-10-03T23:44:57.288Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestRow.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestRow.tsx": "e531b8c4584a8a8f4c76a3205b472ae6cc41c15780cf70a0205b06f7bdf553ed" + }, + "routes": [ + "/browser-tests/guest-row-touch.html?kind=qemu", + "/browser-tests/guest-row-touch.html?kind=lxc", + "/browser-tests/table-row-touch.html" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + }, + { + "width": 360, + "height": 844 + } + ], + "states": [ + "Production GuestRow and full GuestDrawer, production CSS and delegated actions, synthetic VM/CT observations. Playwright 1.56.1 matches integration lock; Chromium 141.0.7390.37 desktop/phone and WebKit 26.0 phone. Six screenshots opened and inspected.", + "Trusted first name/row-body activation reveals missing completed backup, separately cautionary running activity and retained VM-lock disk guidance. The 360px CT control uses the row body. Full drawer safety copy wraps without page overflow; the two-column fixture is not full Workloads table layout acceptance.", + "Row remains a table row, not an extra tab stop or duplicate expanded control. Native disclosure retains matching aria-expanded and aria-controls, conditional action removal/re-enable preserves the same row node.", + "Emulated input and presentation only: no physical phone, real guest collector, QGA/thaw, installed recovery or released-availability assertion. Earlier failed parent activation and two adverse harness runs remain retained separately.", + "Final GuestRow uses typed splitProps forwarding, matching the shared TableRow native/delegated pattern after the initial JSX lint failure. No lint rule was disabled; all browser interaction checks rerun on these exact final bytes." + ], + "interactions": [ + "Trusted Playwright touch taps emit click and toggle the same row exactly once; repeated name/row taps and production drawer collapse preserve independent action counts. Touch entry does not activate a hover history lens.", + "Focused native disclosure Enter/Space and nested SVG click each toggle once. The row body remains usable while keyboard focus makes compact disclosure visible. No touch-end or pointer-up action is added.", + "Real adjacent external-link SVG is exercised on the actual wide layout with only navigation prevented; production propagation isolation keeps the row closed. Phone identity intentionally hides that link; it is not forced into a non-condensed phone layout.", + "Removing the row action prevents activation and removes disclosure; re-enabling restores first-tap activation without remount. Exported compatibility marker preserves shared TableRow single action and delegated child-button isolation. Guest fixtures issue only local GET requests and report no page errors." + ] +} diff --git a/frontend-modern/src/components/Workloads/GuestRow.tsx b/frontend-modern/src/components/Workloads/GuestRow.tsx index 8254fed215..784665c66b 100644 --- a/frontend-modern/src/components/Workloads/GuestRow.tsx +++ b/frontend-modern/src/components/Workloads/GuestRow.tsx @@ -1,4 +1,4 @@ -import { createMemo, createSignal, Show } from 'solid-js'; +import { createMemo, createSignal, Show, splitProps } from 'solid-js'; import BoxIcon from 'lucide-solid/icons/box'; import type { VM } from '@/types/api'; import type { WorkloadGuest } from '@/types/workloads'; @@ -50,6 +50,7 @@ export type { GuestRowProps, WorkloadIOEmphasis } from './guestRowModel'; import { getGuestColumnStyle } from './guestRowModel'; export function GuestRow(props: GuestRowProps) { + const [rowActionProps] = splitProps(props, ['onClick']); const { agentVersion, appContainerRuntimeBadge, @@ -242,8 +243,8 @@ export function GuestRow(props: GuestRowProps) { : undefined } data-summary-row-active={props.isSummaryHighlighted && !props.isExpanded ? 'true' : 'false'} - on:click={props.onClick ? nativeRowClickTarget : undefined} - onClick={props.onClick} + on:click={rowActionProps.onClick ? nativeRowClickTarget : undefined} + {...rowActionProps} {...interactiveRowHandlers} > {/* Name - always visible */}