Storage History request states
+Synthetic transport; production History chart. Selected pool: {target()}
+ +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