Guest row touch verification
+Synthetic observations; no backup command is sent.
+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 @@ + + +
+ + +Synthetic observations; no backup command is sent.
+