mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
Integrate reviewed keyboard and touch History inspection
Preserve exact reviewed candidate d38e2ec61a and retain both History and update-progress browser evidence. Only the shared receipt file needed resolution; frontend runtime content matches the candidate receipt.
Change-source: pulse-maintainer
This commit is contained in:
commit
0dc858cb36
5 changed files with 866 additions and 49 deletions
|
|
@ -7556,3 +7556,24 @@ catalog, the header metadata and every locale catalog; the product name is not
|
|||
translated. Its route id, feature gate and read capability are unchanged.
|
||||
Relay never provided remote access to the web UI, so no settings chrome may
|
||||
label it Remote Access.
|
||||
|
||||
### Drawer History is inspectable without pointer hover
|
||||
|
||||
`GuestDrawerHistory` exposes a labelled native range input for groups with
|
||||
multiple stored observation times. Native arrow keys, Home/End and touch input
|
||||
select real stored timestamps; the control's value text includes the full local
|
||||
date/time and separately formatted series values. A series without a sample at
|
||||
the selected time remains missing rather than borrowing a neighbouring or live
|
||||
reading. The SVG has a linked textual description, including lone observations
|
||||
and the absence of stored data; a lone observation does not fabricate a trend.
|
||||
|
||||
Selection follows its timestamp across same-source refreshes, not an ordinal
|
||||
index that shifts when samples arrive. An expired selection snaps to an actual
|
||||
remaining observation. Changing resource type, id or range clears pointer and
|
||||
keyboard selection even when matching cached data arrives immediately. Empty,
|
||||
failed initial, absent-target and licence-locked views expose no inspection
|
||||
control. Existing matching-point retention and scoped retry remain unchanged.
|
||||
Mounted inspection regressions cover these boundaries. The direct-renderer
|
||||
browser fixture verifies native keys, focus, pointer coexistence and touch at
|
||||
desktop/phone widths across Chromium, Firefox and WebKit; it is not installed
|
||||
PBS collection or a screen-reader announcement-quality claim.
|
||||
|
|
|
|||
345
frontend-modern/browser-tests/history-inspection.cjs
Normal file
345
frontend-modern/browser-tests/history-inspection.cjs
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
// Direct production History/query + CSS with synthetic API data. Not installed PBS acceptance.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { chromium, firefox, webkit } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const root = '/workspace/frontend-modern';
|
||||
process.chdir(root);
|
||||
const engine =
|
||||
process.argv.find((arg) => arg.startsWith('--engine='))?.split('=')[1] || 'chromium';
|
||||
assert.ok(['chromium', 'firefox', 'webkit'].includes(engine));
|
||||
const width = process.argv.includes('--phone') ? 390 : 1365;
|
||||
const artifacts = path.join(root, 'node_modules', `history-inspection-${engine}-${width}`);
|
||||
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-cache-')),
|
||||
server: { host: '127.0.0.1', port: 5223, strictPort: true },
|
||||
});
|
||||
let browser;
|
||||
const observations = [];
|
||||
try {
|
||||
await server.listen();
|
||||
browser = await { chromium, firefox, webkit }[engine].launch(
|
||||
engine === 'chromium'
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true },
|
||||
);
|
||||
for (const theme of ['light', 'dark']) {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width, height: width === 390 ? 844 : 900 },
|
||||
locale: 'en-GB',
|
||||
timezoneId: 'UTC',
|
||||
...(width === 390 ? { isMobile: true, hasTouch: true } : {}),
|
||||
});
|
||||
page.setDefaultTimeout(20_000);
|
||||
const requests = [],
|
||||
errors = [];
|
||||
let action = 'original',
|
||||
held;
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const time = 1_700_000_000_000;
|
||||
const point = (offset, value) => ({
|
||||
timestamp: time + offset,
|
||||
value,
|
||||
min: value,
|
||||
max: value,
|
||||
});
|
||||
const metrics = (kind) => {
|
||||
if (kind === 'single') return { cpu: [point(0, 11)] };
|
||||
if (kind === 'empty') return {};
|
||||
if (kind === 'service')
|
||||
return {
|
||||
cpu: [point(0, 77), point(120_000, 88)],
|
||||
memory: [point(0, 33), point(120_000, 44)],
|
||||
};
|
||||
return {
|
||||
cpu:
|
||||
kind === 'updated'
|
||||
? [
|
||||
point(-60_000, 8),
|
||||
point(0, 11),
|
||||
point(60_000, 22),
|
||||
point(120_000, 43),
|
||||
point(180_000, 66),
|
||||
]
|
||||
: [point(120_000, 43), point(0, 11), point(60_000, 22)],
|
||||
memory: [point(30_000, 30), point(120_000, 55)],
|
||||
// Zero throughput is an observation, not missing history.
|
||||
netin: [point(0, 0), point(120_000, 2048)],
|
||||
netout: [point(0, 1024), point(120_000, 4096)],
|
||||
diskread: [point(0, 2048), point(120_000, 8192)],
|
||||
diskwrite: [point(0, 0), point(120_000, 0)],
|
||||
};
|
||||
};
|
||||
const response = (id, range, kind) => ({
|
||||
resourceType: 'agent',
|
||||
resourceId: id,
|
||||
range,
|
||||
start: time,
|
||||
end: time + 180_000,
|
||||
source: 'store',
|
||||
metrics: metrics(kind),
|
||||
});
|
||||
await page.route('**/*', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== 'http://127.0.0.1:5223') return route.abort();
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
if (url.pathname === '/api/metrics-store/history') {
|
||||
const id = url.searchParams.get('resourceId'),
|
||||
range = url.searchParams.get('range');
|
||||
assert.ok(['agent-three', 'pbs-three'].includes(id));
|
||||
const kind = action;
|
||||
action = id === 'pbs-three' ? 'service' : 'original';
|
||||
requests.push({ id, range, kind, method: route.request().method() });
|
||||
if (kind === 'hold') {
|
||||
held = route;
|
||||
return;
|
||||
}
|
||||
if (kind === 'failure')
|
||||
return route.fulfill({ status: 503, json: { error: 'Private fixture detail' } });
|
||||
return route.fulfill({ json: response(id, range, kind) });
|
||||
}
|
||||
if (url.pathname === '/api/license/runtime-capabilities')
|
||||
return route.fulfill({
|
||||
json: {
|
||||
capabilities: [],
|
||||
limits: [],
|
||||
max_history_days: 7,
|
||||
hosted_mode: false,
|
||||
runtime: { build: 'community', label: 'Pulse Community runtime' },
|
||||
blocked_capabilities: [],
|
||||
},
|
||||
});
|
||||
return route.fulfill({ json: { data: [], enabled: false } });
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5223/browser-tests/pbs-history-refresh.html', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.evaluate(
|
||||
(dark) => document.documentElement.classList.toggle('dark', dark),
|
||||
theme === 'dark',
|
||||
);
|
||||
const fixture = page.getByTestId('history-refresh-fixture');
|
||||
const utilization = fixture.locator('[data-history-group="utilization"]');
|
||||
const slider = fixture.getByRole('slider', { name: 'Inspect Utilization history' });
|
||||
await slider.waitFor();
|
||||
const countBeforeInspection = requests.length;
|
||||
const value = () => slider.getAttribute('aria-valuetext');
|
||||
const screenshot = (state) =>
|
||||
fixture.screenshot({ path: path.join(artifacts, `${state}-${theme}.png`) });
|
||||
const assertValue = async (pattern) => assert.match(await value(), pattern);
|
||||
const layout = async () => {
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
inner: innerWidth,
|
||||
}));
|
||||
assert.ok(dimensions.scroll <= dimensions.inner + 1, JSON.stringify(dimensions));
|
||||
const box = await slider.boundingBox();
|
||||
assert.ok(box && box.x >= 0 && box.x + box.width <= width + 1);
|
||||
if (width === 390) assert.ok(box.height >= 44, JSON.stringify(box));
|
||||
return dimensions;
|
||||
};
|
||||
// Tab reaches a native slider after the existing range/refresh controls.
|
||||
await fixture.getByTestId('guest-history-range-control').focus();
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(
|
||||
await fixture
|
||||
.getByRole('button', { name: 'Refresh history' })
|
||||
.evaluate((el) => document.activeElement === el),
|
||||
true,
|
||||
);
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await slider.evaluate((el) => document.activeElement === el), true);
|
||||
const focusStyle = await slider.evaluate((el) => {
|
||||
const style = getComputedStyle(el);
|
||||
return {
|
||||
visible: el.matches(':focus-visible'),
|
||||
colour: style.outlineColor,
|
||||
width: style.outlineWidth,
|
||||
style: style.outlineStyle,
|
||||
background: getComputedStyle(el.closest('section')).backgroundColor,
|
||||
};
|
||||
});
|
||||
assert.equal(focusStyle.visible, true);
|
||||
assert.equal(focusStyle.colour, 'rgb(59, 130, 246)');
|
||||
assert.equal(focusStyle.style, 'solid');
|
||||
assert.ok(parseFloat(focusStyle.width) >= 2);
|
||||
const luminance = (colour) => {
|
||||
const channels = colour
|
||||
.match(/\d+/g)
|
||||
.slice(0, 3)
|
||||
.map((n) => {
|
||||
const s = Number(n) / 255;
|
||||
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
|
||||
};
|
||||
const a = luminance(focusStyle.colour),
|
||||
b = luminance(focusStyle.background);
|
||||
focusStyle.contrast = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
assert.ok(focusStyle.contrast >= 3, JSON.stringify(focusStyle));
|
||||
await assertValue(/CPU 43\.0%\. Memory 55\.0%\. Disk no observation\./);
|
||||
await slider.press('Home');
|
||||
assert.equal(await slider.inputValue(), '0');
|
||||
await assertValue(/CPU 11\.0%\. Memory no observation\. Disk no observation\./);
|
||||
await slider.press('ArrowRight');
|
||||
assert.equal(await slider.inputValue(), '1');
|
||||
await assertValue(/CPU no observation\. Memory 30\.0%\. Disk no observation\./);
|
||||
assert.match(await utilization.innerText(), /CPU\s*-/);
|
||||
assert.doesNotMatch(await utilization.innerText(), /53\.0%/);
|
||||
await layout();
|
||||
await screenshot('selected-sparse-time');
|
||||
await slider.press('End');
|
||||
assert.equal(await slider.inputValue(), '3');
|
||||
await slider.press('ArrowLeft');
|
||||
await assertValue(/CPU 22\.0%\. Memory no observation/);
|
||||
assert.equal(
|
||||
requests.length,
|
||||
countBeforeInspection,
|
||||
'inspection must not start another API read',
|
||||
);
|
||||
const selectedBeforePoll = await value();
|
||||
// Exercise an actual same-source refresh without moving focus, as a background poll would.
|
||||
action = 'updated';
|
||||
await fixture.getByRole('button', { name: 'Refresh history' }).evaluate((el) => el.click());
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelector('input[aria-label="Inspect Utilization history"]')?.max === '5',
|
||||
);
|
||||
assert.equal(await slider.inputValue(), '3');
|
||||
assert.equal(await value(), selectedBeforePoll);
|
||||
assert.equal(await slider.evaluate((el) => document.activeElement === el), true);
|
||||
const plot = utilization.getByTestId('guest-history-plot');
|
||||
const box = await plot.boundingBox();
|
||||
await page.mouse.move(box.x + box.width - 4, box.y + box.height / 2);
|
||||
await page.mouse.move(1, 1);
|
||||
assert.equal(await value(), selectedBeforePoll);
|
||||
assert.match(await utilization.innerText(), /CPU\s*22\.0%/);
|
||||
// Native blur restores the latest legend; mouse inspection still works.
|
||||
await slider.press('Tab');
|
||||
assert.match(await utilization.innerText(), /CPU\s*66\.0%/);
|
||||
await page.mouse.move(box.x + (34 / 360) * box.width, box.y + box.height / 2);
|
||||
assert.match(await utilization.innerText(), /CPU\s*8\.0%/);
|
||||
await page.mouse.move(1, 1);
|
||||
|
||||
if (width === 390) {
|
||||
await slider.scrollIntoViewIfNeeded();
|
||||
const touchBox = await slider.boundingBox();
|
||||
await page.touchscreen.tap(touchBox.x + 3, touchBox.y + touchBox.height / 2);
|
||||
await assertValue(/CPU 8\.0%\. Memory no observation/);
|
||||
assert.match(await utilization.innerText(), /CPU\s*8\.0%/);
|
||||
await page.touchscreen.tap(
|
||||
touchBox.x + touchBox.width - 3,
|
||||
touchBox.y + touchBox.height / 2,
|
||||
);
|
||||
await assertValue(/CPU 66\.0%\. Memory no observation/);
|
||||
assert.match(await utilization.innerText(), /CPU\s*66\.0%/);
|
||||
}
|
||||
action = 'failure';
|
||||
await fixture.getByRole('button', { name: 'Refresh history' }).click();
|
||||
await fixture
|
||||
.getByRole('status', { name: 'History refresh status' })
|
||||
.getByText('History refresh failed. Showing previously loaded history.', { exact: true })
|
||||
.waitFor();
|
||||
await slider.focus();
|
||||
await slider.press('Home');
|
||||
await assertValue(/CPU 8\.0%\. Memory no observation/);
|
||||
assert.doesNotMatch(await fixture.innerText(), /Private fixture detail/);
|
||||
await layout();
|
||||
await screenshot('failed-refresh-inspection');
|
||||
const semantics = await slider.ariaSnapshot();
|
||||
action = 'single';
|
||||
await fixture.getByRole('button', { name: 'Retry history' }).click();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('input[type="range"]').length === 0,
|
||||
);
|
||||
const description = await utilization
|
||||
.getByRole('img')
|
||||
.evaluate((el) => document.getElementById(el.getAttribute('aria-describedby')).textContent);
|
||||
assert.match(description, /1 stored observation time\. .*CPU 11\.0%/);
|
||||
assert.equal(await utilization.locator('path').count(), 0);
|
||||
action = 'empty';
|
||||
await fixture.getByRole('button', { name: 'Refresh history' }).click();
|
||||
await page.waitForFunction(() => {
|
||||
const image = document.querySelector('[data-history-group="utilization"] svg[role="img"]');
|
||||
return (
|
||||
document.getElementById(image.getAttribute('aria-describedby')).textContent ===
|
||||
'No stored history observations.'
|
||||
);
|
||||
});
|
||||
assert.equal(await fixture.getByRole('slider').count(), 0);
|
||||
// Changing resource cannot carry an inspected former-host point into the new target.
|
||||
action = 'original';
|
||||
await fixture.getByRole('button', { name: 'Refresh history' }).click();
|
||||
await slider.waitFor();
|
||||
await slider.focus();
|
||||
await slider.press('Home');
|
||||
action = 'hold';
|
||||
await page.getByRole('button', { name: 'Switch to service target' }).click();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('input[type="range"]').length === 0,
|
||||
);
|
||||
assert.equal(await fixture.locator('svg path').count(), 0);
|
||||
assert.equal(await fixture.getByTestId('guest-history-hover-time').count(), 0);
|
||||
assert.ok(held, 'new target read must be held');
|
||||
await held.fulfill({ json: response('pbs-three', '24h', 'service') });
|
||||
await slider.waitFor();
|
||||
assert.equal(await slider.inputValue(), '1');
|
||||
await assertValue(/CPU 88\.0%\. Memory 44\.0%\. Disk no observation/);
|
||||
assert.equal(await fixture.getByTestId('guest-history-hover-time').count(), 0);
|
||||
const readsBeforeLock = requests.length;
|
||||
await fixture.getByTestId('guest-history-range-control').selectOption('14d');
|
||||
await fixture.getByText(/14 days history requires a higher license plan/).waitFor();
|
||||
assert.equal(await fixture.getByRole('slider').count(), 0);
|
||||
assert.equal(requests.length, readsBeforeLock);
|
||||
assert.deepEqual(errors, []);
|
||||
observations.push({
|
||||
engine,
|
||||
version: browser.version(),
|
||||
width,
|
||||
theme,
|
||||
requests,
|
||||
errors,
|
||||
semantics,
|
||||
layout: { width },
|
||||
singleDescription: description,
|
||||
focusStyle,
|
||||
nativeKeys: ['Tab', 'Home', 'ArrowRight', 'End', 'ArrowLeft'],
|
||||
touch: width === 390,
|
||||
});
|
||||
console.log(JSON.stringify({ engine, width, theme, result: 'passed' }));
|
||||
await page.close();
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(artifacts, 'result.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
result: 'passed',
|
||||
engine,
|
||||
version: browser.version(),
|
||||
playwright: require('playwright/package.json').version,
|
||||
runtime_sha256: createHash('sha256')
|
||||
.update(
|
||||
fs.readFileSync(path.join(root, 'src/components/Workloads/GuestDrawerHistory.tsx')),
|
||||
)
|
||||
.digest('hex'),
|
||||
observations,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
|
@ -1,18 +1,16 @@
|
|||
{
|
||||
"version": 1,
|
||||
"base_sha": "f3c92a242ebf9b7062dfc9f6ff711a56639f536c",
|
||||
"verified_at": "2026-09-30T22:39:35Z",
|
||||
"base_sha": "0d3e5a801ab8fd1c1d224ef561cbf99e169c589f",
|
||||
"verified_at": "2026-09-30T23:28:26Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/UpdateProgressModal.tsx",
|
||||
"frontend-modern/src/components/updateReadinessModel.ts"
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/UpdateProgressModal.tsx": "d51df7a98a379e313f306b68529c8db0687ff91176af23869411c4b46feb2599",
|
||||
"frontend-modern/src/components/updateReadinessModel.ts": "b25bf82441fc6572b95891af48a1ed2a0e8ea1f4e583c171ea9352a1e67d76ce"
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "327e75cabb99edffc712302c00645f06d92dc44dbebdd00aef08074e7fa53eb2"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/update-progress-modal.html (production UpdateProgressModal with a scripted update stream and scripted /api/updates/status and /api/version)"
|
||||
"/browser-tests/pbs-history-refresh.html (direct production GuestDrawerHistory/query with production CSS and synthetic metrics API data; not a complete PBS drawer or installed acceptance)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
|
|
@ -25,45 +23,123 @@
|
|||
}
|
||||
],
|
||||
"states": [
|
||||
"stream delivers one downloading 10% event then stays open and silent; fallback polling advances the modal through 35, 70, 80 and 90% (applying)",
|
||||
"stream closed during download with a single unreachable status poll between two successful ones; download progress continues and the restart phase is never entered",
|
||||
"restarting status from polling, then /api/version unreachable twice, then the new version reported; Pulse is restarting shown, then exactly one page reload",
|
||||
"pre-update version unavailable (503), late polls failing and a new version plus idle answering without a restarting or completed status; modal stays on Pulse is restarting with no reload and no completion claim",
|
||||
"stream delivers downloading, verifying, extracting, backing-up, applying and completed; old version answers once, becomes unreachable, then the new version; exactly one reload and no fallback polls"
|
||||
"light/dark ordered and sparse stored series",
|
||||
"native focus with a 2px blue outline, measured contrast 3.68:1 light and 3.98:1 dark against the chart surface",
|
||||
"missing series at a selected timestamp stay missing, not nearby or live values",
|
||||
"same-source refresh adds samples without changing the selected timestamp or keyboard focus",
|
||||
"failed refresh retains inspectable matching observations and honest warning",
|
||||
"single stored observation has a dated description but no fabricated trend or pointless slider",
|
||||
"empty store/current readings expose no inspection control",
|
||||
"held replacement target has no former-host points or selection; new target starts at its own latest observation",
|
||||
"licence-locked range has no reads or inspection action"
|
||||
],
|
||||
"interactions": [
|
||||
"emit scripted SSE statuses and hold the stream silent past the 6s silence window; assert the first fallback poll lands after it and the stream stays open",
|
||||
"fail the stream and abort one status poll mid-download; assert no restart text ever rendered (MutationObserver) and the do-not-close warning remains",
|
||||
"count document loads of the harness to assert a single reload after real restart evidence, and none without a baseline or backend confirmation",
|
||||
"inspect screenshots at desktop and phone widths; assert no page errors, no horizontal overflow and the dialog inside the viewport"
|
||||
"Tab from History range through Refresh to the native observation slider; Home, ArrowRight, End and ArrowLeft select actual stored times with no extra API reads",
|
||||
"refresh through the existing real query while the input stays focused; new sample indexes do not move the selected timestamp",
|
||||
"move and leave the plot while the slider is focused; then blur and confirm existing mouse hover still works",
|
||||
"Chromium/WebKit phone touch taps at both ends of the 44px-high native control update the selected stored values",
|
||||
"fail a refresh, inspect retained observations; replace with a single sample and then no history; switch resource while its GET is held; load the service response; select locked 14d"
|
||||
],
|
||||
"command": "NODE_PATH=<playwright 1.56.1 node_modules> PULSE_BROWSER_ARTIFACTS=<proof dir> PULSE_CHROMIUM_PATH=<cached chrome-headless-shell 151.0.7922.34> node frontend-modern/browser-tests/update-progress-modal.cjs from the worktree root; Vite dev server on 127.0.0.1:5208, Playwright 1.56.1, Chromium headless shell 151.0.7922.34",
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/history-inspection.cjs --engine=chromium; the same command with --phone; --engine=firefox; --engine=webkit --phone. All from the assigned workspace root, Vite 6.4.3 / Playwright 1.56.1 (integration-lock parity verified).",
|
||||
"browser_versions": {
|
||||
"chromium": "141.0.7390.37",
|
||||
"firefox": "142.0.1",
|
||||
"webkit": "26.0"
|
||||
},
|
||||
"artifacts": [
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/harness-rebased-3.log",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-polled-90pct-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-polled-90pct-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-stream-10pct-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-stream-10pct-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/b-after-failed-poll-80pct-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/b-after-failed-poll-80pct-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-reloaded-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-reloaded-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-restarting-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-restarting-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/d-unconfirmed-waiting-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/d-unconfirmed-waiting-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-reloaded-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-reloaded-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-restarting-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-restarting-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-stream-applying-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-stream-applying-390.png"
|
||||
],
|
||||
"notes": [
|
||||
"Mock-backed UI proof of the production modal; all five scenarios passed at 1365x900 and 390x844 and the screenshots were inspected. The same harness run against the pre-fix UpdateProgressModal and updateReadinessModel (fa1d9eb36) fails scenario (a), staying on 10% (pre-fix/run.log). This does not exercise the backend SSE endpoint, a real proxy, or an installed update. Re-run after rebasing onto 8d5969fb3 (lockfile-only brace-expansion/DOMPurify floors); both full runs passed all five scenarios at both viewports with unchanged component content hashes.",
|
||||
"Re-run after rebasing onto f3c92a242; all five scenarios passed at both viewports with unchanged component content hashes."
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/selected-sparse-time-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/failed-refresh-inspection-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/selected-sparse-time-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/failed-refresh-inspection-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/selected-sparse-time-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/failed-refresh-inspection-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/selected-sparse-time-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/failed-refresh-inspection-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/selected-sparse-time-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/failed-refresh-inspection-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/selected-sparse-time-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/failed-refresh-inspection-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/selected-sparse-time-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/failed-refresh-inspection-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/selected-sparse-time-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/failed-refresh-inspection-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-chromium-desktop.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-chromium-phone.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-firefox-desktop.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-webkit-phone.log"
|
||||
],
|
||||
"notes": "All 16 final screenshots inspected. Eight engine/viewport/theme cases pass native keyboard, touch, state and layout assertions with no page errors. Semantic snapshots exercise native slider names/value text, not actual screen-reader speech quality. Initial dependency-copy/install failures, unsupported helper invocation, preliminary browser sequence exit 143 with unknown cause and the corrected dark-focus-colour defect remain in output receipts. No real PBS telemetry, full drawer, in-browser update, installed upgrade/rollback, containing-line or release availability claim. Copy-lint failure source-zhg72ioz at unshared 7c625696... is retained. Its semicolon-separated accessible value text is replaced by sentences; that unshared candidate was abandoned at the supplied base, not amended or integrated. All four browser commands and all 16 inspected screenshots now bind the corrected runtime. No failed earlier pass or shared history is rewritten.",
|
||||
"prior_verifications": [
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "f3c92a242ebf9b7062dfc9f6ff711a56639f536c",
|
||||
"verified_at": "2026-09-30T22:39:35Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/UpdateProgressModal.tsx",
|
||||
"frontend-modern/src/components/updateReadinessModel.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/UpdateProgressModal.tsx": "d51df7a98a379e313f306b68529c8db0687ff91176af23869411c4b46feb2599",
|
||||
"frontend-modern/src/components/updateReadinessModel.ts": "b25bf82441fc6572b95891af48a1ed2a0e8ea1f4e583c171ea9352a1e67d76ce"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/update-progress-modal.html (production UpdateProgressModal with a scripted update stream and scripted /api/updates/status and /api/version)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"stream delivers one downloading 10% event then stays open and silent; fallback polling advances the modal through 35, 70, 80 and 90% (applying)",
|
||||
"stream closed during download with a single unreachable status poll between two successful ones; download progress continues and the restart phase is never entered",
|
||||
"restarting status from polling, then /api/version unreachable twice, then the new version reported; Pulse is restarting shown, then exactly one page reload",
|
||||
"pre-update version unavailable (503), late polls failing and a new version plus idle answering without a restarting or completed status; modal stays on Pulse is restarting with no reload and no completion claim",
|
||||
"stream delivers downloading, verifying, extracting, backing-up, applying and completed; old version answers once, becomes unreachable, then the new version; exactly one reload and no fallback polls"
|
||||
],
|
||||
"interactions": [
|
||||
"emit scripted SSE statuses and hold the stream silent past the 6s silence window; assert the first fallback poll lands after it and the stream stays open",
|
||||
"fail the stream and abort one status poll mid-download; assert no restart text ever rendered (MutationObserver) and the do-not-close warning remains",
|
||||
"count document loads of the harness to assert a single reload after real restart evidence, and none without a baseline or backend confirmation",
|
||||
"inspect screenshots at desktop and phone widths; assert no page errors, no horizontal overflow and the dialog inside the viewport"
|
||||
],
|
||||
"command": "NODE_PATH=<playwright 1.56.1 node_modules> PULSE_BROWSER_ARTIFACTS=<proof dir> PULSE_CHROMIUM_PATH=<cached chrome-headless-shell 151.0.7922.34> node frontend-modern/browser-tests/update-progress-modal.cjs from the worktree root; Vite dev server on 127.0.0.1:5208, Playwright 1.56.1, Chromium headless shell 151.0.7922.34",
|
||||
"artifacts": [
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/harness-rebased-3.log",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-polled-90pct-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-polled-90pct-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-stream-10pct-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-stream-10pct-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/b-after-failed-poll-80pct-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/b-after-failed-poll-80pct-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-reloaded-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-reloaded-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-restarting-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-restarting-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/d-unconfirmed-waiting-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/d-unconfirmed-waiting-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-reloaded-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-reloaded-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-restarting-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-restarting-390.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-stream-applying-1365.png",
|
||||
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-stream-applying-390.png"
|
||||
],
|
||||
"notes": [
|
||||
"Mock-backed UI proof of the production modal; all five scenarios passed at 1365x900 and 390x844 and the screenshots were inspected. The same harness run against the pre-fix UpdateProgressModal and updateReadinessModel (fa1d9eb36) fails scenario (a), staying on 10% (pre-fix/run.log). This does not exercise the backend SSE endpoint, a real proxy, or an installed update. Re-run after rebasing onto 8d5969fb3 (lockfile-only brace-expansion/DOMPurify floors); both full runs passed all five scenarios at both viewports with unchanged component content hashes.",
|
||||
"Re-run after rebasing onto f3c92a242; all five scenarios passed at both viewports with unchanged component content hashes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "21a35494aecb335755de773dd099e1364e65b3e0",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,14 @@
|
|||
import { For, Show, createMemo, createSignal, onMount, type Component } from 'solid-js';
|
||||
import {
|
||||
For,
|
||||
Show,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
createUniqueId,
|
||||
on,
|
||||
onMount,
|
||||
type Component,
|
||||
} from 'solid-js';
|
||||
|
||||
import {
|
||||
ChartsAPI,
|
||||
|
|
@ -57,6 +67,7 @@ interface GuestDrawerHistoryGroupChartProps {
|
|||
loading: boolean;
|
||||
metrics: Record<string, AggregatedMetricPoint[] | undefined>;
|
||||
range: HistoryTimeRange;
|
||||
sourceKey: string;
|
||||
}
|
||||
|
||||
const GUEST_DRAWER_HISTORY_MAX_POINTS = 240;
|
||||
|
|
@ -196,24 +207,73 @@ const findClosestGuestDrawerHistoryPoint = (
|
|||
|
||||
const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps> = (props) => {
|
||||
const [hoverTimestamp, setHoverTimestamp] = createSignal<number | null>(null);
|
||||
const [selectedTimestamp, setSelectedTimestamp] = createSignal<number | null>(null);
|
||||
const inspectionId = `history-inspection-${createUniqueId()}`;
|
||||
createEffect(
|
||||
on(
|
||||
() => props.sourceKey,
|
||||
() => {
|
||||
setHoverTimestamp(null);
|
||||
setSelectedTimestamp(null);
|
||||
},
|
||||
),
|
||||
);
|
||||
const series = createMemo(() =>
|
||||
props.group.series.map((config) => ({
|
||||
...config,
|
||||
points: normalizeGuestDrawerHistoryPoints(props.metrics[config.metric], config.unit),
|
||||
})),
|
||||
);
|
||||
// Step through actual stored times, not synthetic points between samples.
|
||||
// Keep the selected time across polls even if its ordinal index changes.
|
||||
const observationTimes = createMemo(() =>
|
||||
[...new Set(series().flatMap((item) => item.points.map((point) => point.timestamp)))].sort(
|
||||
(a, b) => a - b,
|
||||
),
|
||||
);
|
||||
const selectedIndex = createMemo(() => {
|
||||
const times = observationTimes();
|
||||
const selected = selectedTimestamp();
|
||||
if (selected === null) return Math.max(0, times.length - 1);
|
||||
let index = 0;
|
||||
for (let i = 1; i < times.length; i++) {
|
||||
if (Math.abs(times[i] - selected) < Math.abs(times[index] - selected)) index = i;
|
||||
}
|
||||
return index;
|
||||
});
|
||||
const inspectionTimestamp = createMemo(() =>
|
||||
selectedTimestamp() === null ? null : (observationTimes()[selectedIndex()] ?? null),
|
||||
);
|
||||
const inspectionValueText = createMemo(() => {
|
||||
const timestamp = observationTimes()[selectedIndex()];
|
||||
if (timestamp === undefined) return 'No stored history observations.';
|
||||
const values = series().map((item) => {
|
||||
const point = item.points.find((point) => point.timestamp === timestamp);
|
||||
return `${item.label} ${point ? formatHistoryChartTooltipValue(point.value, item.unit) : 'no observation'}`;
|
||||
});
|
||||
return `${new Date(timestamp).toLocaleString()}. ${values.join('. ')}.`;
|
||||
});
|
||||
const chartDescription = createMemo(() => {
|
||||
if (props.loading) return 'Loading history.';
|
||||
const count = observationTimes().length;
|
||||
if (count === 0) return inspectionValueText();
|
||||
return `${count} stored observation ${count === 1 ? 'time' : 'times'}. ${inspectionValueText()}`;
|
||||
});
|
||||
const drawableSeries = createMemo(() => series().filter((item) => item.points.length >= 2));
|
||||
const scale = createMemo(() => getGuestDrawerHistoryScale(series(), props.group.unit));
|
||||
const bounds = createMemo(() => getGuestDrawerHistoryRangeBounds(series()));
|
||||
const hasDrawableData = createMemo(() => drawableSeries().length > 0 && bounds() !== null);
|
||||
const hoveredSeries = createMemo(() => {
|
||||
const timestamp = hoverTimestamp();
|
||||
const timestamp = inspectionTimestamp() ?? hoverTimestamp();
|
||||
const rangeBounds = bounds();
|
||||
if (timestamp === null || !rangeBounds) return [];
|
||||
|
||||
return drawableSeries()
|
||||
return (inspectionTimestamp() === null ? drawableSeries() : series())
|
||||
.map((item) => {
|
||||
const point = findClosestGuestDrawerHistoryPoint(item.points, timestamp);
|
||||
const point =
|
||||
inspectionTimestamp() === null
|
||||
? findClosestGuestDrawerHistoryPoint(item.points, timestamp)
|
||||
: item.points.find((point) => point.timestamp === timestamp);
|
||||
if (!point) return null;
|
||||
return {
|
||||
...item,
|
||||
|
|
@ -240,11 +300,13 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
|
|||
...item,
|
||||
valueLabel: hovered
|
||||
? getGuestDrawerHistoryValueLabel([hovered.point], item.unit)
|
||||
: item.points.length > 0
|
||||
? getGuestDrawerHistoryValueLabel(item.points, item.unit)
|
||||
: typeof currentValue === 'number' && Number.isFinite(currentValue)
|
||||
? formatHistoryChartTooltipValue(currentValue, item.unit)
|
||||
: '-',
|
||||
: inspectionTimestamp() !== null
|
||||
? '-'
|
||||
: item.points.length > 0
|
||||
? getGuestDrawerHistoryValueLabel(item.points, item.unit)
|
||||
: typeof currentValue === 'number' && Number.isFinite(currentValue)
|
||||
? formatHistoryChartTooltipValue(currentValue, item.unit)
|
||||
: '-',
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
|
@ -254,6 +316,7 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
|
|||
});
|
||||
|
||||
const handleHoverMove = (event: MouseEvent & { currentTarget: SVGSVGElement }) => {
|
||||
if (inspectionTimestamp() !== null) return;
|
||||
const rangeBounds = bounds();
|
||||
if (!rangeBounds) return;
|
||||
|
||||
|
|
@ -321,6 +384,7 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
|
|||
</For>
|
||||
<svg
|
||||
aria-label={`${props.group.label} history`}
|
||||
aria-describedby={`${inspectionId}-description`}
|
||||
class="absolute inset-0 h-full w-full cursor-crosshair"
|
||||
data-testid="guest-history-plot"
|
||||
onMouseMove={handleHoverMove}
|
||||
|
|
@ -407,6 +471,43 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
|
|||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<p id={`${inspectionId}-description`} class="sr-only">
|
||||
{chartDescription()}
|
||||
</p>
|
||||
<Show when={observationTimes().length > 1}>
|
||||
<div class="mt-2">
|
||||
<label for={inspectionId} class="flex justify-between gap-2 text-[10px] text-muted">
|
||||
<span>Inspect history</span>
|
||||
<span aria-hidden="true">
|
||||
{selectedIndex() + 1} / {observationTimes().length}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id={inspectionId}
|
||||
type="range"
|
||||
aria-label={`Inspect ${props.group.label} history`}
|
||||
aria-valuetext={inspectionValueText()}
|
||||
aria-describedby={`${inspectionId}-help`}
|
||||
min="0"
|
||||
max={observationTimes().length - 1}
|
||||
step="1"
|
||||
value={selectedIndex()}
|
||||
class="h-11 w-full cursor-pointer rounded-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 sm:h-6"
|
||||
onFocus={() => {
|
||||
setHoverTimestamp(null);
|
||||
setSelectedTimestamp(observationTimes()[selectedIndex()] ?? null);
|
||||
}}
|
||||
onBlur={() => setSelectedTimestamp(null)}
|
||||
onInput={(event) => {
|
||||
const index = event.currentTarget.valueAsNumber;
|
||||
if (Number.isFinite(index)) setSelectedTimestamp(observationTimes()[index] ?? null);
|
||||
}}
|
||||
/>
|
||||
<p id={`${inspectionId}-help`} class="sr-only">
|
||||
Use arrow keys, Home or End to inspect stored observations.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
|
@ -517,6 +618,7 @@ export const GuestDrawerHistory: Component<GuestDrawerHistoryProps> = (props) =>
|
|||
metrics={metrics()}
|
||||
currentMetrics={props.currentMetrics}
|
||||
range={props.range}
|
||||
sourceKey={`${props.target?.resourceType}:${props.target?.resourceId}:${props.range}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,273 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChartsAPI, type AllMetricsHistoryResponse, type HistoryTimeRange } from '@/api/charts';
|
||||
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
|
||||
import { GuestDrawerHistory } from '../GuestDrawerHistory';
|
||||
import { HOST_METRICS_HISTORY_GROUPS } from '@/components/shared/hostMetricsHistoryModel';
|
||||
import type { GuestDrawerHistoryTarget } from '../guestDrawerModel';
|
||||
|
||||
vi.mock('@/stores/license', () => ({
|
||||
loadRuntimeCapabilities: vi.fn(async () => undefined),
|
||||
maxHistoryDays: () => 7,
|
||||
isRangeLocked: (range: HistoryTimeRange) => ['14d', '30d', '90d'].includes(range),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetCreateNonSuspendingQueryCacheForTest();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const time = 1_700_000_000_000;
|
||||
const target: GuestDrawerHistoryTarget = { resourceType: 'agent', resourceId: 'pbs-host' };
|
||||
const point = (offset: number, value: number) => ({
|
||||
timestamp: time + offset,
|
||||
value,
|
||||
min: value,
|
||||
max: value,
|
||||
});
|
||||
const response = (
|
||||
metrics: AllMetricsHistoryResponse['metrics'] = {
|
||||
cpu: [point(120_000, 43), point(0, 11), point(60_000, 22)],
|
||||
memory: [point(30_000, 30), point(120_000, 55)],
|
||||
},
|
||||
): AllMetricsHistoryResponse => ({
|
||||
...target,
|
||||
range: '24h',
|
||||
start: time,
|
||||
end: time + 120_000,
|
||||
source: 'store',
|
||||
metrics,
|
||||
});
|
||||
const inspect = () => screen.getByRole('slider', { name: 'Inspect Utilization history' });
|
||||
const utilization = () => screen.getAllByTestId('guest-history-group-chart')[0];
|
||||
const choose = (slider: HTMLElement, index: number) =>
|
||||
fireEvent.input(slider, { target: { value: String(index) } });
|
||||
|
||||
// Native keyboard/touch stepping is exercised in the offline browser; jsdom
|
||||
// tests the renderer's input/focus contract rather than simulating browser defaults.
|
||||
describe('GuestDrawerHistory stored-observation inspection', () => {
|
||||
it('exposes an ordered native control, dated values and chart description without another read', async () => {
|
||||
const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" currentMetrics={{ disk: 88 }} />);
|
||||
await waitFor(() => expect(inspect()).toHaveAttribute('max', '3'));
|
||||
const slider = inspect();
|
||||
expect(slider).toHaveAttribute('type', 'range');
|
||||
expect(slider).toHaveAttribute('min', '0');
|
||||
expect(slider).toHaveAttribute('step', '1');
|
||||
expect(slider).toHaveValue('3');
|
||||
expect(slider).toHaveAttribute(
|
||||
'aria-valuetext',
|
||||
`${new Date(time + 120_000).toLocaleString()}. CPU 43.0%. Memory 55.0%. Disk no observation.`,
|
||||
);
|
||||
expect(
|
||||
within(utilization()).getByRole('img', { name: 'Utilization history' }),
|
||||
).toHaveAccessibleDescription(/4 stored observation times/);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('inspects exact stored times and does not borrow nearby or current values for missing series', async () => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
|
||||
render(() => (
|
||||
<GuestDrawerHistory
|
||||
target={target}
|
||||
range="24h"
|
||||
currentMetrics={{ cpu: 99, memory: 98, disk: 88 }}
|
||||
/>
|
||||
));
|
||||
await waitFor(() => expect(inspect()).toHaveValue('3'));
|
||||
const slider = inspect();
|
||||
slider.focus();
|
||||
choose(slider, 0);
|
||||
expect(slider).toHaveAttribute(
|
||||
'aria-valuetext',
|
||||
`${new Date(time).toLocaleString()}. CPU 11.0%. Memory no observation. Disk no observation.`,
|
||||
);
|
||||
expect(utilization()).toHaveTextContent('CPU11.0%');
|
||||
expect(utilization()).toHaveTextContent('Memory-');
|
||||
expect(utilization()).not.toHaveTextContent('88.0%');
|
||||
choose(slider, 1);
|
||||
expect(slider).toHaveAttribute(
|
||||
'aria-valuetext',
|
||||
`${new Date(time + 30_000).toLocaleString()}. CPU no observation. Memory 30.0%. Disk no observation.`,
|
||||
);
|
||||
expect(utilization()).toHaveTextContent('CPU-');
|
||||
expect(utilization()).toHaveTextContent('Memory30.0%');
|
||||
expect(slider).toHaveFocus();
|
||||
});
|
||||
|
||||
it('keeps focused selection independent of plot pointer movement, then restores normal hover on blur', async () => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
await waitFor(() => expect(inspect()).toHaveValue('3'));
|
||||
const slider = inspect();
|
||||
const plot = within(utilization()).getByTestId('guest-history-plot');
|
||||
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ left: 0, width: 360 } as DOMRect);
|
||||
slider.focus();
|
||||
choose(slider, 0);
|
||||
fireEvent.pointerMove(plot, { clientX: 352 });
|
||||
fireEvent.pointerLeave(plot);
|
||||
expect(utilization()).toHaveTextContent('CPU11.0%');
|
||||
fireEvent.blur(slider);
|
||||
expect(utilization()).toHaveTextContent('CPU43.0%');
|
||||
fireEvent.mouseMove(plot, { clientX: 34 });
|
||||
expect(utilization()).toHaveTextContent('CPU11.0%');
|
||||
});
|
||||
|
||||
it('preserves the selected stored timestamp, not its index, when a poll adds observations', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory')
|
||||
.mockResolvedValueOnce(response())
|
||||
.mockResolvedValueOnce(
|
||||
response({
|
||||
cpu: [
|
||||
point(-60_000, 8),
|
||||
point(0, 11),
|
||||
point(60_000, 22),
|
||||
point(120_000, 43),
|
||||
point(180_000, 66),
|
||||
],
|
||||
memory: [point(30_000, 30), point(120_000, 55)],
|
||||
}),
|
||||
);
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const slider = inspect();
|
||||
slider.focus();
|
||||
choose(slider, 2);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(inspect()).toBe(slider);
|
||||
expect(slider).toHaveFocus();
|
||||
expect(slider).toHaveValue('3');
|
||||
expect(slider).toHaveAttribute(
|
||||
'aria-valuetext',
|
||||
`${new Date(time + 60_000).toLocaleString()}. CPU 22.0%. Memory no observation. Disk no observation.`,
|
||||
);
|
||||
expect(utilization()).toHaveTextContent('CPU22.0%');
|
||||
});
|
||||
|
||||
it('snaps an expired inspected timestamp to an actual remaining observation on refresh', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory')
|
||||
.mockResolvedValueOnce(response())
|
||||
.mockResolvedValueOnce(response({ cpu: [point(120_000, 43), point(180_000, 66)] }));
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const slider = inspect();
|
||||
slider.focus();
|
||||
choose(slider, 0);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(slider).toHaveValue('0');
|
||||
expect(slider.getAttribute('aria-valuetext')).toContain(
|
||||
new Date(time + 120_000).toLocaleString(),
|
||||
);
|
||||
expect(utilization()).toHaveTextContent('CPU43.0%');
|
||||
});
|
||||
|
||||
it.each(['target', 'resource type', 'range'] as const)(
|
||||
'clears selection on a cached %s replacement even when its timestamps coincide',
|
||||
async (change) => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory')
|
||||
.mockResolvedValueOnce(response())
|
||||
.mockResolvedValueOnce(response({ cpu: [point(0, 77), point(120_000, 88)] }))
|
||||
.mockImplementation(() => new Promise(() => {}));
|
||||
const [current, setTarget] = createSignal(target);
|
||||
const [range, setRange] = createSignal<HistoryTimeRange>('24h');
|
||||
render(() => <GuestDrawerHistory target={current()} range={range()} />);
|
||||
await waitFor(() => expect(inspect()).toHaveValue('3'));
|
||||
if (change === 'target') setTarget({ ...target, resourceId: 'other-pbs' });
|
||||
else if (change === 'resource type') setTarget({ ...target, resourceType: 'vm' });
|
||||
else setRange('6h');
|
||||
await waitFor(() => expect(inspect()).toHaveValue('1'));
|
||||
inspect().focus();
|
||||
choose(inspect(), 0);
|
||||
expect(utilization()).toHaveTextContent('CPU77.0%');
|
||||
if (change !== 'range') setTarget(target);
|
||||
else setRange('24h');
|
||||
await waitFor(() => expect(inspect()).toHaveValue('3'));
|
||||
expect(utilization()).toHaveTextContent('CPU43.0%');
|
||||
expect(
|
||||
within(utilization()).queryByTestId('guest-history-hover-time'),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it('inspects zero throughput and temperature using the existing host metric units', async () => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
|
||||
response({
|
||||
netin: [point(0, 0), point(60_000, 2048)],
|
||||
temperature: [point(0, 42), point(60_000, 57)],
|
||||
}),
|
||||
);
|
||||
render(() => (
|
||||
<GuestDrawerHistory target={target} range="24h" groups={HOST_METRICS_HISTORY_GROUPS} />
|
||||
));
|
||||
const network = await screen.findByRole('slider', { name: 'Inspect Network I/O history' });
|
||||
network.focus();
|
||||
choose(network, 0);
|
||||
expect(network.getAttribute('aria-valuetext')).toContain('In 0 B/s. Out no observation.');
|
||||
const thermal = screen.getByRole('slider', { name: 'Inspect Thermals history' });
|
||||
thermal.focus();
|
||||
choose(thermal, 1);
|
||||
expect(thermal.getAttribute('aria-valuetext')).toContain('CPU 57°C.');
|
||||
});
|
||||
|
||||
it('keeps stored observations inspectable after a failed poll without exposing the error', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory')
|
||||
.mockResolvedValueOnce(response())
|
||||
.mockRejectedValueOnce(new Error('private backend detail'));
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(screen.getByRole('status', { name: 'History refresh status' })).toHaveTextContent(
|
||||
'Showing previously loaded history.',
|
||||
);
|
||||
inspect().focus();
|
||||
choose(inspect(), 0);
|
||||
expect(utilization()).toHaveTextContent('CPU11.0%');
|
||||
expect(utilization()).not.toHaveTextContent('private backend detail');
|
||||
});
|
||||
|
||||
it('describes a lone stored observation without manufacturing a trend or pointless slider', async () => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response({ cpu: [point(0, 11)] }));
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
await waitFor(() =>
|
||||
expect(within(utilization()).getByRole('img')).toHaveAccessibleDescription(
|
||||
/1 stored observation time/,
|
||||
),
|
||||
);
|
||||
expect(within(utilization()).getByRole('img')).toHaveAccessibleDescription(
|
||||
`1 stored observation time. ${new Date(time).toLocaleString()}. CPU 11.0%. Memory no observation. Disk no observation.`,
|
||||
);
|
||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
|
||||
expect(utilization().querySelector('path')).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['empty', 'invalid'] as const)(
|
||||
'does not offer inspection for %s stored history or live readings',
|
||||
async (kind) => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
|
||||
response(kind === 'empty' ? {} : { cpu: [point(0, NaN)] }),
|
||||
);
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" currentMetrics={{ cpu: 42 }} />);
|
||||
await waitFor(() => expect(screen.queryByText('Loading history')).not.toBeInTheDocument());
|
||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
|
||||
expect(within(utilization()).getByRole('img')).toHaveAccessibleDescription(
|
||||
'No stored history observations.',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('offers no inspection or reads for an absent target or locked range', async () => {
|
||||
const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory');
|
||||
const [current, setTarget] = createSignal<GuestDrawerHistoryTarget | null>(null);
|
||||
render(() => <GuestDrawerHistory target={current()} range="14d" />);
|
||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
|
||||
setTarget(target);
|
||||
await screen.findByText(/14 days history requires/);
|
||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue