Storage History request states
+Synthetic transport; production History chart. Selected pool: {target()}
+ +From c6edeee869a11f9206390e855fc63da744fb394a Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:52:26 +0100 Subject: [PATCH 1/2] Distinguish empty History from failed background refreshes Keep the last successful readings and source visible with an accessible warning until recovery or selection replacement. Stop promising future collection for empty history. Cover failure and recovery with request-state and two-engine browser controls. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 14 +++ .../browser-tests/history-status.cjs | 93 +++++++++++++++++++ .../browser-tests/history-status.html | 1 + .../browser-tests/history-status.tsx | 30 ++++++ frontend-modern/browser-verification.json | 35 +++---- .../src/components/shared/HistoryChart.tsx | 15 ++- .../components/shared/HistoryChartOverlay.tsx | 2 +- .../shared/__tests__/HistoryChart.test.tsx | 3 +- .../__tests__/useHistoryChartState.test.tsx | 69 ++++++++++---- .../components/shared/useHistoryChartState.ts | 8 +- 10 files changed, 229 insertions(+), 41 deletions(-) create mode 100644 frontend-modern/browser-tests/history-status.cjs create mode 100644 frontend-modern/browser-tests/history-status.html create mode 100644 frontend-modern/browser-tests/history-status.tsx 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 @@ +
Synthetic transport; production History chart. Selected pool: {target()}
+ ++ {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. + {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
Collecting data... History will appear here.
+No history samples in this time range.
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