diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index c213daa7bf..5e2e9760bf 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -7699,3 +7699,17 @@ selection changes and empty data clear stale readings. No readings are invented for missing data. Tooltips reserve room for a wrapped local timestamp and reading. The mounted HistoryChart regression and history-keyboard browser fixture cover navigation, bounds, focus exit, refresh and replacement. Synthetic browser proof does not establish native collector or assistive-device acceptance. + +### Shared History request-state honesty + +An empty successful response states only that the selected time range has no +samples; it does not claim collection is running or promise future readings. +A failed matching background refresh keeps the last successful data and source, +with a visible polite status also included in the chart's accessible description. +The warning persists while retrying and clears only on success or selection +replacement. This applies to previously empty results too. Initial failures +remain separate errors. No polling frequency, request or access policy changes. +`useHistoryChartState.test.tsx` covers failure, pending recovery, empty results +and selection changes; `browser-tests/history-status.cjs` checks actual chart +states in desktop Chromium and phone WebKit with synthetic transport failures. +These checks do not establish native collection or released availability. diff --git a/frontend-modern/browser-tests/history-status.cjs b/frontend-modern/browser-tests/history-status.cjs new file mode 100644 index 0000000000..8fe339bdcc --- /dev/null +++ b/frontend-modern/browser-tests/history-status.cjs @@ -0,0 +1,93 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium, webkit } = require('playwright'); +(async () => { + const root = '/workspace/frontend-modern'; + process.chdir(root); + const artifacts = path.join(root, 'node_modules/history-status-proof'); + fs.mkdirSync(artifacts, { recursive: true }); + 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: fs.mkdtempSync(path.join(artifacts, 'vite-')), server: { host: '127.0.0.1', port: 5224, strictPort: true } }); + let browser; + const observations = []; + const parent = process.argv.includes('--parent'); + try { + await server.listen(); + for (const [name, engine, width, height] of [['chromium', chromium, 1365, 900], ['webkit', webkit, 390, 844]]) { + browser = await engine.launch(name === 'chromium' ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } : { headless: true }); + const page = await browser.newPage({ viewport: { width, height }, hasTouch: name === 'webkit', isMobile: name === 'webkit' }); + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + let mode = 'empty', calls = 0; + const pending = []; + await page.route('**/*', route => { + const url = new URL(route.request().url()); + if (url.origin !== 'http://127.0.0.1:5224') return route.abort(); + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (url.pathname.includes('/metrics-store/history')) { + calls++; + if (mode === 'pending') { pending.push(route); return; } + if (mode === 'failed') return route.fulfill({status: 503, json: {error: 'Synthetic unavailable'}}); + return route.fulfill({json: {points: mode === 'empty' ? [] : [0,1,2].map(i => ({timestamp: 1790942400000 + i*60000, value: 20+i*10, min: 20+i*10, max: 20+i*10})), source: 'store'}}); + } + return route.fulfill({json: url.pathname === '/api/license/runtime-capabilities' ? {capabilities: [], limits: [], max_history_days: 7, hosted_mode: false, runtime: {build:'community'}, blocked_capabilities: []} : {data: [], enabled: false}}); + }); + await page.clock.install(); + await page.goto('http://127.0.0.1:5224/browser-tests/history-status.html'); + if (name === 'webkit') await page.evaluate(() => document.documentElement.classList.add('dark')); + const chart = page.getByRole('img', {name:'Usage chart', exact:true}); + await chart.waitFor(); + const description = page.locator('#' + await chart.getAttribute('aria-describedby')); + async function state(phase, expected) { + await page.waitForFunction(({id, expected}) => document.getElementById(id).textContent.includes(expected), {id: await chart.getAttribute('aria-describedby'), expected}); + const actual = await description.textContent(); + const dimensions = await page.evaluate(() => ({scroll:document.documentElement.scrollWidth, width:innerWidth})); + assert.ok(dimensions.scroll <= dimensions.width + 1); + await page.screenshot({path:path.join(artifacts, `${parent?'parent-':''}${name}-${phase}.png`),fullPage:true}); + observations.push({name, version:browser.version(),phase,actual,dimensions,calls}); + } + await state('empty', 'No 1-hour'); + assert.equal(await page.getByText(parent ? 'Collecting data... History will appear here.' : 'No history samples in this time range.', {exact:true}).count(),1); + mode = 'populated'; + await page.clock.runFor(10_000); + await state('loaded','40.0%'); + mode = 'failed'; + await page.clock.runFor(10_000); + if (parent) { + await page.waitForTimeout(100); + assert.ok(!(await description.textContent()).includes('Could not refresh')); + assert.equal(await page.getByText('Could not refresh history. Showing the last successful result.',{exact:true}).count(),0); + await state('silent-failure','40.0%'); + await browser.close(); browser = null; break; + } + await state('refresh-failed','Could not refresh history'); + assert.ok((await description.textContent()).includes('40.0%')); + assert.ok((await page.getByRole('status', {name:'History refresh status'}).textContent()).includes('last successful result')); + mode = 'pending'; + await page.clock.runFor(10_000); + await state('retry-pending','Could not refresh history'); + for (let i=0; !pending.length && i<100; i++) await new Promise(r=>setTimeout(r,20)); + assert.ok(pending.length > 0, 'pending refresh observed'); + for (const route of pending.splice(0)) await route.fulfill({json:{points:[],source:'store'}}); + await state('recovered-empty','No 1-hour'); + assert.equal(await page.getByRole('status', {name:'History refresh status'}).textContent(),''); + mode = 'failed'; + await page.clock.runFor(10_000); + await state('empty-refresh-failed','Could not refresh history'); + mode = 'pending'; + await page.getByRole('button',{name:'Select pool b'}).click(); + await state('target-loading','Loading'); + assert.equal(await page.getByRole('status', {name:'History refresh status'}).textContent(),''); + for (let i=0; !pending.length && i<100; i++) await new Promise(r=>setTimeout(r,20)); + assert.ok(pending.length > 0, 'pending refresh observed'); + for (const route of pending.splice(0)) await route.fulfill({status:503,json:{error:'Synthetic unavailable'}}); + await state('initial-failed','could not be loaded'); + assert.equal(await page.getByRole('status', {name:'History refresh status'}).textContent(),''); + assert.deepEqual(errors,[]); + await browser.close(); browser=null; + } + fs.writeFileSync(path.join(artifacts,parent?'parent.json':'result.json'),JSON.stringify({playwright:require('playwright/package.json').version,observations},null,2)); + console.log(JSON.stringify({result:'passed',states:observations.length,parent})); + } finally { if(browser) await browser.close(); await server.close(); } +})().catch(e=>{console.error(e);process.exitCode=1}); diff --git a/frontend-modern/browser-tests/history-status.html b/frontend-modern/browser-tests/history-status.html new file mode 100644 index 0000000000..ce78449b9a --- /dev/null +++ b/frontend-modern/browser-tests/history-status.html @@ -0,0 +1 @@ +History request states verification
diff --git a/frontend-modern/browser-tests/history-status.tsx b/frontend-modern/browser-tests/history-status.tsx new file mode 100644 index 0000000000..19099f7933 --- /dev/null +++ b/frontend-modern/browser-tests/history-status.tsx @@ -0,0 +1,30 @@ +import { createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; +import { HistoryChart } from '../src/components/shared/HistoryChart'; +import '../src/index.css'; +const Fixture = () => { + const [target, setTarget] = createSignal('a'); + return ( +
+

Storage History request states

+

Synthetic transport; production History chart. Selected pool: {target()}

+ +
+ +
+
+ ); +}; +render(() => , document.getElementById('root')!); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index d0574b1b31..bd379e2ebb 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,20 +1,20 @@ { "version": 1, - "base_sha": "cf5d6cdf066539b6e29f589e42d408edb1ceb73a", - "verified_at": "2026-10-02T15:02:34.899181Z", + "base_sha": "c6edeee869a11f9206390e855fc63da744fb394a", + "verified_at": "2026-10-02T15:53:54.725571Z", "result": "passed", "changed_paths": [ "frontend-modern/src/components/shared/HistoryChart.tsx", - "frontend-modern/src/components/shared/useHistoryChartState.ts", - "frontend-modern/src/components/shared/historyChartModel.ts" + "frontend-modern/src/components/shared/HistoryChartOverlay.tsx", + "frontend-modern/src/components/shared/useHistoryChartState.ts" ], "content_sha256": { - "frontend-modern/src/components/shared/HistoryChart.tsx": "24cd863fb6288bc2c13ef2d26e02aad95d3e4f54278f3078b1703fabf8785f8c", - "frontend-modern/src/components/shared/useHistoryChartState.ts": "b1ebd93d5fee328b78f8f88f1837514a30d12be069d852d08f5d7f2b6ce999ec", - "frontend-modern/src/components/shared/historyChartModel.ts": "bd9e8954ef7e274a1eb026752d304556df303f612c4485369f8c90a7183cc751" + "frontend-modern/src/components/shared/HistoryChart.tsx": "a2ab0198a705e66ce9bea222066078709c8334fc342415916b8c179987b139e2", + "frontend-modern/src/components/shared/HistoryChartOverlay.tsx": "4192731461a19ba7c33185c2e616c5023dfef58d93e3893cd3c4fceb58598391", + "frontend-modern/src/components/shared/useHistoryChartState.ts": "8bebaa331f1e2dfc57587f517628bed1b0c4f7d9c9466e21597e7f9f7a4d7137" }, "routes": [ - "/browser-tests/history-keyboard.html" + "/browser-tests/history-status.html" ], "viewports": [ { @@ -27,22 +27,25 @@ } ], "states": [ - "Populated, empty and linked charts in Chromium light and WebKit phone dark; synthetic data, not native PBS acceptance", - "Visible keyboard focus, wrapped timestamp and reading contained within tooltip; no horizontal overflow or page errors" + "Empty successful result, loaded readings, failed refresh, pending recovery, recovered empty result, failed refresh of empty result, changed-target loading and initial failure", + "Chromium desktop light and WebKit phone dark; screenshots inspected, no horizontal overflow or page errors; synthetic transport, not native collection" ], "interactions": [ - "Tab into and out of both charts; arrows, Home/End, endpoint clamping, Escape; only focused chart announces keyboard readings", - "Parent Tab skips both charts; final empty chart invents no reading" + "Advance polling clock without altering production timers; fail and recover HTTP responses; switch pool target", + "Parent control reproduces misleading empty promise and silent failed refresh; final warning retains data and clears only on recovery or selection replacement" ], - "command": "pulse-worker-browser frontend-modern/browser-tests/history-keyboard.cjs", + "command": "pulse-worker-browser frontend-modern/browser-tests/history-status.cjs", "browser_versions": { "playwright": "1.56.1", "chromium": "141.0.7390.37", "webkit": "26.0" }, "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-tuqfk84d/browser/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-tuqfk84d/browser/webkit-keyboard.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-tuqfk84d/browser/chromium-keyboard.png" + "/var/lib/pulse-maintainer/worker-outputs/web-product-d89v1n1d/browser/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-d89v1n1d/browser/parent.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-d89v1n1d/browser/chromium-refresh-failed.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-d89v1n1d/browser/webkit-refresh-failed.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-d89v1n1d/browser/webkit-empty.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-d89v1n1d/browser/webkit-initial-failed.png" ] } diff --git a/frontend-modern/src/components/shared/HistoryChart.tsx b/frontend-modern/src/components/shared/HistoryChart.tsx index 8a37f456bc..ac36f7f4e7 100644 --- a/frontend-modern/src/components/shared/HistoryChart.tsx +++ b/frontend-modern/src/components/shared/HistoryChart.tsx @@ -28,6 +28,9 @@ export const HistoryChart: Component = (props) => { }, hoverGroup, ); + const refreshStatus = createMemo(() => + chart.refreshFailed() ? 'Could not refresh history. Showing the last successful result.' : '', + ); const accessibleDescription = createMemo(() => getHistoryChartAccessibleDescription({ data: chart.data(), @@ -51,6 +54,14 @@ export const HistoryChart: Component = (props) => { unit={props.unit} /> +

+ {refreshStatus()} +

+
= (props) => { onMouseLeave={chart.handleMouseLeave} />

- {accessibleDescription()} Use Left and Right arrow keys to inspect readings, Home and End - for the first and last reading, and Escape to clear inspection. + {refreshStatus()} {accessibleDescription()} Use Left and Right arrow keys to inspect + readings, Home and End for the first and last reading, and Escape to clear inspection.

{chart.keyboardInspecting() && chart.hoveredPoint() diff --git a/frontend-modern/src/components/shared/HistoryChartOverlay.tsx b/frontend-modern/src/components/shared/HistoryChartOverlay.tsx index e5d9f01866..2244e8d0db 100644 --- a/frontend-modern/src/components/shared/HistoryChartOverlay.tsx +++ b/frontend-modern/src/components/shared/HistoryChartOverlay.tsx @@ -35,7 +35,7 @@ export const HistoryChartOverlay: Component = (props)

-

Collecting data... History will appear here.

+

No history samples in this time range.

diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 1325a4260f..95c9485247 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -5283,7 +5283,8 @@ describe('shared primitive guardrails', () => { expect(historyChartHeaderSource).not.toContain('ChartsAPI.getMetricsHistory'); expect(historyChartHeaderSource).not.toContain('setupCanvasDPR'); - expect(historyChartOverlaySource).toContain('Collecting data... History will appear here.'); + expect(historyChartOverlaySource).toContain('No history samples in this time range.'); + expect(historyChartOverlaySource).not.toContain('History will appear here.'); expect(historyChartOverlaySource).toContain( 'Historical data beyond {props.chart.lockDays()} days requires a higher license plan.', ); diff --git a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx index 19aa1eb53c..7bfe9b99c5 100644 --- a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx @@ -104,7 +104,8 @@ describe('HistoryChart', () => { expect(historyChartHeaderSource).not.toContain('ChartsAPI.getMetricsHistory'); expect(historyChartHeaderSource).not.toContain('setupCanvasDPR'); - expect(historyChartOverlaySource).toContain('Collecting data... History will appear here.'); + expect(historyChartOverlaySource).toContain('No history samples in this time range.'); + expect(historyChartOverlaySource).not.toContain('History will appear here.'); expect(historyChartOverlaySource).toContain( 'Historical data beyond {props.chart.lockDays()} days requires a higher license plan.', ); diff --git a/frontend-modern/src/components/shared/__tests__/useHistoryChartState.test.tsx b/frontend-modern/src/components/shared/__tests__/useHistoryChartState.test.tsx index 0942f115ee..872920316b 100644 --- a/frontend-modern/src/components/shared/__tests__/useHistoryChartState.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/useHistoryChartState.test.tsx @@ -117,25 +117,56 @@ describe('History request ownership', () => { }, ); - it('does not overlap polling and preserves matching samples on refresh failure', async () => { - const initial = deferred(); - request.mockReturnValueOnce(initial.promise); - const { state } = mount(); - vi.advanceTimersByTime(120_000); - expect(request).toHaveBeenCalledTimes(1); - initial.resolve({ points: points(10), source: 'store' } as never); - await settle(); - const refresh = deferred(); - request.mockReturnValueOnce(refresh.promise); - vi.advanceTimersByTime(120_000); - expect(request).toHaveBeenCalledTimes(2); - vi.spyOn(console, 'error').mockImplementation(() => {}); - refresh.reject(new Error('refresh failed')); - await settle(); - expect(state.data()).toEqual(points(10)); - expect(state.error()).toBeNull(); - expect(state.loading()).toBe(false); - }); + it.each([{ samples: points(10) }, { samples: [] }])( + 'retains the last result %j and exposes refresh failure until recovery', + async ({ samples }) => { + const initial = deferred(); + request.mockReturnValueOnce(initial.promise); + const { state } = mount(); + vi.advanceTimersByTime(120_000); + expect(request).toHaveBeenCalledTimes(1); + initial.resolve({ points: samples, source: 'store' } as never); + await settle(); + const refresh = deferred(); + request.mockReturnValueOnce(refresh.promise); + vi.advanceTimersByTime(120_000); + expect(request).toHaveBeenCalledTimes(2); + vi.spyOn(console, 'error').mockImplementation(() => {}); + refresh.reject(new Error('refresh failed')); + await settle(); + expect(state.data()).toEqual(samples); + expect(state.error()).toBeNull(); + expect(state.refreshFailed()).toBe(true); + expect(state.source()).toBe('store'); + expect(state.loading()).toBe(false); + const recovery = deferred(); + request.mockReturnValueOnce(recovery.promise); + vi.advanceTimersByTime(10_000); + expect(state.refreshFailed()).toBe(true); + recovery.resolve({ points: points(30), source: 'memory' } as never); + await settle(); + expect(state.refreshFailed()).toBe(false); + expect(state.data()).toEqual(points(30)); + expect(state.source()).toBe('memory'); + }, + ); + + it.each([{ resourceId: 'b' }, { range: '6h' }, { data: [] }, { range: '90d' }])( + 'clears refresh failure on selection replacement %j', + async (next) => { + request.mockResolvedValueOnce({ points: points(10), source: 'store' } as never); + const { state, change } = mount(); + await settle(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + request.mockRejectedValueOnce(new Error('refresh failed')); + await vi.advanceTimersByTimeAsync(10_000); + expect(state.refreshFailed()).toBe(true); + request.mockReturnValueOnce(deferred().promise); + change(next as Partial); + expect(state.refreshFailed()).toBe(false); + expect(state.data()).toEqual([]); + }, + ); it('cancels fetched data when supplied data takes ownership, including empty samples', async () => { const old = deferred(); diff --git a/frontend-modern/src/components/shared/useHistoryChartState.ts b/frontend-modern/src/components/shared/useHistoryChartState.ts index 0ad5c2033c..a63b01d353 100644 --- a/frontend-modern/src/components/shared/useHistoryChartState.ts +++ b/frontend-modern/src/components/shared/useHistoryChartState.ts @@ -36,6 +36,7 @@ export function useHistoryChartState( const [data, setData] = createSignal(props.data ?? []); const [keyboardInspecting, setKeyboardInspecting] = createSignal(false); const [loading, setLoading] = createSignal(false); + const [refreshFailed, setRefreshFailed] = createSignal(false); const [error, setError] = createSignal(null); const [source, setSource] = createSignal<'store' | 'memory' | 'live' | 'mock_synthetic' | null>( null, @@ -119,6 +120,7 @@ export function useHistoryChartState( setData(suppliedData ?? []); setSource(suppliedData !== undefined ? 'live' : null); setError(null); + setRefreshFailed(false); setLoading(false); const selection = JSON.stringify([ resourceType, @@ -154,12 +156,13 @@ export function useHistoryChartState( if (!active) return; setData('points' in result ? (result.points ?? []) : []); setSource(result.source ?? 'store'); + setRefreshFailed(false); hasLoaded = true; } catch (err) { if (!active) return; console.error('Failed to fetch metrics history:', err); - if (!hasLoaded) setError('Failed to load history data'); - setSource(null); + if (hasLoaded) setRefreshFailed(true); + else setError('Failed to load history data'); } finally { if (active) { pending = false; @@ -416,6 +419,7 @@ export function useHistoryChartState( dataMax, dataMin, error, + refreshFailed, handleFocus, handleBlur, handleKeyDown, diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 35e2debb92..9dfa485452 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -2452,7 +2452,8 @@ describe('frontend resource type boundaries', () => { expect(historyChartModelSource).toContain('HISTORY_CHART_RANGES'); expect(historyChartModelSource).toContain('getHistoryChartScale'); expect(historyChartHeaderSource).toContain('formatHistoryChartTooltipValue'); - expect(historyChartOverlaySource).toContain('Collecting data... History will appear here.'); + expect(historyChartOverlaySource).toContain('No history samples in this time range.'); + expect(historyChartOverlaySource).not.toContain('History will appear here.'); expect(historyChartOverlaySource).not.toContain('presentationPolicyHidesUpgradePrompts'); expect(historyChartOverlaySource).not.toContain( 'Unlock {props.chart.lockTierLabel()} Features',