diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 368d192fb4..74bf4d914e 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -8439,6 +8439,23 @@ for missing data. Tooltips reserve room for a wrapped local timestamp and readin fixture cover navigation, bounds, focus exit, refresh and replacement. Synthetic browser proof does not establish native collector or assistive-device acceptance. +### Shared History window control access + +Each visible shared chart window selector is a named group for its metric (or +"History window" when no label is supplied). Exactly the current period carries +`aria-pressed=true`; switching one chart must not change another chart's selected +state. Period buttons retain native keyboard/touch behaviour and explicit +`type=button`, so changing the window cannot submit an enclosing form. A hidden +selector exposes neither a group nor controls. + +The header, summary and existing period choices wrap within their chart on +narrow screens instead of forcing horizontal overflow. This changes neither +available periods, subscriptions, History ownership nor sample interpretation. +Mounted `HistoryChart.test.tsx` and the production-component +`browser-tests/history-window-controls.cjs` cover selection, scope, native input, +form non-submission and phone layout. Synthetic observations are not native +PBS/NAS, physical device or assistive-device acceptance. + ### Shared History request-state honesty An empty successful response states only that the selected time range has no diff --git a/frontend-modern/browser-tests/history-window-controls.cjs b/frontend-modern/browser-tests/history-window-controls.cjs new file mode 100644 index 0000000000..7344049eac --- /dev/null +++ b/frontend-modern/browser-tests/history-window-controls.cjs @@ -0,0 +1,126 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const http = require('node:http'); +const crypto = require('node:crypto'); +const assert = require('node:assert/strict'); +const { chromium, webkit } = require('playwright'); +const root = '/workspace/frontend-modern'; +const parent = Boolean(global.__historyWindowParentControl); +const output = '/workspace/tmp/history-window-controls/' + (parent ? 'parent' : 'final'); +const preview = output + '/preview'; +const origin = 'http://127.0.0.1:5332'; +const runtime = 'frontend-modern/src/components/shared/HistoryChartHeader.tsx'; +const parentFile = '/workspace/tmp/history-window-controls/parent.tsx'; +const hash = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const load = name => import(require.resolve(name, { paths: [root] })); +(async () => { + fs.mkdirSync(output, {recursive:true}); + const result = { result:'incomplete', control:parent ? 'exact parent expected defects' : 'final', + playwright:require('playwright/package.json').version, + runtime_hash:hash(parent ? parentFile : '/workspace/' + runtime), + script_hash:hash(root + '/browser-tests/history-window-controls.cjs'), + cases:[], captures:[], cleanup:{browser:false,server:false}, + limits:'Production header and CSS with synthetic state. No native PBS/NAS, collector, physical device, assistive-device, installed or release acceptance.' }; + let server, browser; + try { + assert.equal(result.playwright,JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages['node_modules/@playwright/test'].version); + const {build} = await load('vite'); + const solidModule = await load('vite-plugin-solid'); + const solid = solidModule.default.default || solidModule.default; + const {default:tailwind} = await load('@tailwindcss/vite'); + const plugins = []; + if(parent) plugins.push({name:'exact-parent-header-control',enforce:'pre',load(id) { + if(id.split('?')[0] === '/workspace/' + runtime) return fs.readFileSync(parentFile,'utf8'); + }}); + plugins.push(solid(),tailwind()); + await build({root,configFile:false,logLevel:'warn',plugins,resolve:{alias:{'@':root + '/src'}}, + build:{target:'esnext',outDir:preview,emptyOutDir:true,minify:false,rollupOptions:{input:root + '/browser-tests/history-window-controls.html'}}}); + server = http.createServer((req,res) => { + const url=new URL(req.url,origin); + let file=path.resolve(preview,'.' + decodeURIComponent(url.pathname)); + if(file!==preview && !file.startsWith(preview + '/')) {res.writeHead(403);return res.end();} + if(!fs.existsSync(file)||fs.statSync(file).isDirectory()) file=preview+'/browser-tests/history-window-controls.html'; + res.setHeader('Content-Type',({'.html':'text/html','.js':'text/javascript','.css':'text/css'})[path.extname(file)]||'application/octet-stream'); + res.end(fs.readFileSync(file)); + }); + await new Promise(resolve=>server.listen(5332,'127.0.0.1',resolve)); + for(const [name,engine,width,dark] of [['chromium-desktop',chromium,1280,false],['webkit-phone',webkit,390,true]]) { + browser=await engine.launch(engine===chromium ? {headless:true,channel:'chromium',args:['--no-sandbox']} : {headless:true}); + const context=await browser.newContext({viewport:{width,height:760},isMobile:width<=768,hasTouch:width<=768,locale:'en-GB'}); + const page=await context.newPage(); page.setDefaultTimeout(10000); + const record={name,browser:browser.version(),width,errors:[],blocked:[]}; result.cases.push(record); + page.on('pageerror',error=>record.errors.push(error.message)); + await page.route('**/*',route=> { + if(new URL(route.request().url()).origin!==origin) {record.blocked.push(route.request().url());return route.abort();} + return route.continue(); + }); + await page.addInitScript(dark=> { + window.__trustedHistoryClicks=[]; + document.addEventListener('click',event=>window.__trustedHistoryClicks.push(event.isTrusted),true); + document.addEventListener('DOMContentLoaded',()=>{if(dark)document.documentElement.classList.add('dark');}); + },dark); + await page.goto(origin,{waitUntil:'domcontentloaded'}); + await page.waitForFunction(()=>window.__historyWindows); + const cpu=page.locator('[data-chart="CPU"]'); + const memory=page.locator('[data-chart="Memory"]'); + record.initialLayout=await cpu.evaluate(el=>({width:el.clientWidth,scroll:el.scrollWidth,document:document.documentElement.clientWidth,documentScroll:document.documentElement.scrollWidth})); + if(parent) { + assert.equal(await cpu.getByRole('group').count(),0); + assert.equal(await cpu.locator('[aria-pressed]').count(),0); + await cpu.getByRole('button',{name:'24h',exact:true}).click(); + assert.equal(await page.evaluate(()=>window.__historyWindows.submissions()),1); + if(width<=768) assert.ok(record.initialLayout.scroll>record.initialLayout.width+1,JSON.stringify(record.initialLayout)); + record.expectedDefects={noNamedGroup:true,noSelectionState:true,nativeFormSubmitted:true,phoneOverflow:width<=768}; + } else { + const cpuGroup=cpu.getByRole('group',{name:'CPU history window'}); + const memoryGroup=memory.getByRole('group',{name:'Memory history window'}); + assert.equal(await cpuGroup.getByRole('button',{pressed:true}).count(),1); + assert.equal(await cpuGroup.getByRole('button',{name:'1h',exact:true}).getAttribute('aria-pressed'),'true'); + if(width<=768) { + await cpuGroup.getByRole('button',{name:'7d',exact:true}).tap(); + assert.equal(await cpuGroup.getByRole('button',{name:'7d',exact:true}).getAttribute('aria-pressed'),'true'); + await memoryGroup.getByRole('button',{name:'6h',exact:true}).tap(); + await page.waitForFunction(()=>Array.from(document.querySelectorAll('[data-chart="Memory"] button')).some(button=>button.textContent.trim()==='6h' && button.getAttribute('aria-pressed')==='true')); + assert.equal(await memoryGroup.getByRole('button',{name:'6h',exact:true}).getAttribute('aria-pressed'),'true'); + assert.equal(await cpuGroup.getByRole('button',{name:'7d',exact:true}).getAttribute('aria-pressed'),'true'); + } else { + const button=cpuGroup.getByRole('button',{name:'24h',exact:true}); + await button.focus();await page.keyboard.press('Enter'); + assert.equal(await button.getAttribute('aria-pressed'),'true'); + assert.equal(await button.evaluate(el=>el===document.activeElement),true); + await page.keyboard.press('Tab'); + assert.equal(await cpuGroup.getByRole('button',{name:'7d',exact:true}).evaluate(el=>el===document.activeElement),true); + await page.keyboard.press('Space'); + assert.equal(await cpuGroup.getByRole('button',{name:'7d',exact:true}).getAttribute('aria-pressed'),'true'); + assert.equal(await memoryGroup.getByRole('button',{name:'1h',exact:true}).getAttribute('aria-pressed'),'true'); + } + assert.equal(await cpuGroup.getByRole('button',{pressed:true}).count(),1); + assert.equal(await memoryGroup.getByRole('button',{pressed:true}).count(),1); + assert.equal(await page.evaluate(()=>window.__historyWindows.submissions()),0); + assert.equal(await page.locator('[data-chart="hidden"]').getByRole('group').count(),0); + assert.ok(record.initialLayout.scroll<=record.initialLayout.width+1,JSON.stringify(record.initialLayout)); + assert.ok(record.initialLayout.documentScroll<=record.initialLayout.document+1,JSON.stringify(record.initialLayout)); + record.selection=await page.locator('[data-chart] button').evaluateAll(buttons=>buttons.map(button=>({text:button.textContent.trim(),pressed:button.getAttribute('aria-pressed'),class:button.className}))); + for(const button of record.selection) assert.equal(button.class.includes('bg-surface text-base-content'),button.pressed==='true'); + record.calls=await page.evaluate(()=>window.__historyWindows.calls); + record.trusted=await page.evaluate(()=>window.__trustedHistoryClicks); + assert.ok(record.trusted.length>=2 && record.trusted.every(Boolean)); + } + assert.deepEqual(record.errors,[]);assert.deepEqual(record.blocked,[]); + await page.evaluate(async()=> { + // Observe completed finite colour transitions, not a transient tap frame. + await Promise.all(document.getAnimations().filter(animation=>Number.isFinite(animation.effect?.getComputedTiming().endTime)).map(animation=>animation.finished.catch(()=>{}))); + await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))); + }); + const capture=name+'.png';await page.screenshot({path:output+'/'+capture,fullPage:true}); + result.captures.push({file:capture,sha256:hash(output+'/'+capture)}); + await browser.close();browser=undefined; + } + result.result='passed'; + } finally { + if(browser)await browser.close(); result.cleanup.browser=true; + if(server)await new Promise(resolve=>server.close(resolve)); result.cleanup.server=true; + fs.writeFileSync(output+'/result.json',JSON.stringify(result,null,2)+'\n'); + } + console.log(JSON.stringify(result)); +})().catch(error=>{console.error(error);process.exitCode=1;}); diff --git a/frontend-modern/browser-tests/history-window-controls.html b/frontend-modern/browser-tests/history-window-controls.html new file mode 100644 index 0000000000..85f89d728f --- /dev/null +++ b/frontend-modern/browser-tests/history-window-controls.html @@ -0,0 +1,12 @@ + + + + + + History window control verification + + +
+ + + diff --git a/frontend-modern/browser-tests/history-window-controls.tsx b/frontend-modern/browser-tests/history-window-controls.tsx new file mode 100644 index 0000000000..88ded03587 --- /dev/null +++ b/frontend-modern/browser-tests/history-window-controls.tsx @@ -0,0 +1,56 @@ +import { createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; +import { HistoryChartHeader } from '../src/components/shared/HistoryChartHeader'; +import { HISTORY_CHART_RANGES } from '../src/components/shared/historyChartModel'; +import type { HistoryChartState } from '../src/components/shared/useHistoryChartState'; +import type { HistoryTimeRange } from '../src/api/charts'; +import '../src/index.css'; + +const calls: Array<{ chart: string; range: HistoryTimeRange }> = []; +let submissions = 0; +const makeChart = (name: string) => { + const [range, setRange] = createSignal('1h'); + return { + ranges: HISTORY_CHART_RANGES, + range, + updateRange: (next: HistoryTimeRange) => { + calls.push({ chart: name, range: next }); + setRange(next); + }, + dataMin: () => 0, + dataMax: () => 75, + source: () => 'store', + } as unknown as HistoryChartState; +}; +const cpu = makeChart('CPU'); +const memory = makeChart('Memory'); + +Object.assign(window, { __historyWindows: { calls, submissions: () => submissions } }); +render( + () => ( +
+

History window controls

+

Production header and styles; synthetic readings. No collector or API is contacted.

+
{ + event.preventDefault(); + submissions += 1; + }} + > +
+ +
+
+ +
+
+ +
+
+ +
+ ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification/a4f6b6ce8e48c1ab.json b/frontend-modern/browser-verification/a4f6b6ce8e48c1ab.json new file mode 100644 index 0000000000..64c952a3b5 --- /dev/null +++ b/frontend-modern/browser-verification/a4f6b6ce8e48c1ab.json @@ -0,0 +1,64 @@ +{ + "version": 1, + "base_sha": "e78de344ea5e2d3211149c2a845cdff90432adf2", + "verified_at": "2026-10-06T04:42:13.612566Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/shared/HistoryChartHeader.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/shared/HistoryChartHeader.tsx": "e74f8058a9f26bdd6d906152f2af68139e3b0966f8b6c860e33d3915ad15999e" + }, + "routes": [ + "Shared History header used by Storage/PBS/NAS metric panels (production-component preview, synthetic state)" + ], + "viewports": [ + { + "width": 1280, + "height": 760 + }, + { + "width": 390, + "height": 760 + } + ], + "states": [ + "Two independently scoped named History window groups, exactly one pressed period in each", + "Eight existing periods, zero/max summaries and hidden selector", + "Phone dark layout wraps without horizontal overflow", + "ARIA selection and active styling agree after finite colour transitions" + ], + "interactions": [ + "Trusted desktop Enter, Tab and Space; focus retained and second chart unchanged", + "Trusted phone CPU 7d and Memory 6h taps; independent selection", + "Native form owns zero submissions when period buttons activate" + ], + "evidence": { + "script": "frontend-modern/browser-tests/history-window-controls.cjs", + "script_sha256": "c8fe327e9f0d4377008283a76723f14712532af3b8a34af37150794d8ae2b8b8", + "playwright": "1.56.1", + "browsers": [ + { + "name": "chromium-desktop", + "browser": "141.0.7390.37", + "width": 1280 + }, + { + "name": "webkit-phone", + "browser": "26.0", + "width": 390 + } + ], + "captures": [ + { + "file": "chromium-desktop.png", + "sha256": "4ddf8126d67b69eec339d13f2701b08fbb722e70baf8bb92b37dc1aed2b6e8b4" + }, + { + "file": "webkit-phone.png", + "sha256": "06f5c326641853f78153e9404f227f669ab925d10c0e73f181795118a105eaea" + } + ], + "limits": "Production header and CSS with synthetic state. No native PBS/NAS, collector, physical device, assistive-device, installed or release acceptance." + } +} diff --git a/frontend-modern/src/components/shared/HistoryChartHeader.tsx b/frontend-modern/src/components/shared/HistoryChartHeader.tsx index d6527f5049..c54404b195 100644 --- a/frontend-modern/src/components/shared/HistoryChartHeader.tsx +++ b/frontend-modern/src/components/shared/HistoryChartHeader.tsx @@ -12,8 +12,10 @@ interface HistoryChartHeaderProps { export const HistoryChartHeader: Component = (props) => { return ( -
-
+
+
{props.label || 'History'} ({props.unit}) @@ -36,7 +38,7 @@ export const HistoryChartHeader: Component = (props) =>
-
+
@@ -54,9 +56,15 @@ export const HistoryChartHeader: Component = (props) =>
-
+
{props.chart.ranges.map((range) => (