Files
pulse/frontend-modern/browser-tests/history-pointer.cjs
T
rcourtman 3a69ad0906 Keep Retry focus handoff working in Chromium; repair seven proofs
The focus handoff from #2925 tracked Retry focus with focus/blur
handlers. Chromium blurs a focused element when it leaves the DOM, and
the Show branch removes the button before the error effect runs, so the
flag was already false and focus fell to the page. jsdom never blurs on
removal, which is why the unit test passed. Read document.activeElement
when the Retry branch is disposed instead, and make the refresh test
model the removal blur so it fails against the old code.

The worker-browser proofs for history response, pointer, inspection,
window and the three PBS fixtures still clicked the removed History tab
and Refresh button. They now read the charts on Overview, drive reads
through the 30-second poll, select series paths rather than every path
(bands and dots add paths), and mount fixtures in a shared app runtime
(fixtureRuntime.tsx) because drawers read the WebSocket and theme
contexts. The PBS refresh proof is what caught the focus regression.
2026-10-14 11:09:55 +01:00

309 lines
13 KiB
JavaScript

// 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-pointer-${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 = [];
const sourceFile = path.join(root, 'src/components/Workloads/GuestDrawerHistory.tsx');
const sourceHash = createHash('sha256').update(fs.readFileSync(sourceFile)).digest('hex');
const playwrightVersion = require('playwright/package.json').version;
const integrationVersion = JSON.parse(
fs.readFileSync('/workspace/tests/integration/package-lock.json'),
).packages['node_modules/@playwright/test'].version;
assert.equal(playwrightVersion, integrationVersion);
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 kind = '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 = (state) => {
if (state === 'empty') return {};
if (state === 'service') return { cpu: [point(0, 77), point(120_000, 88)] };
return {
cpu: [point(120_000, 43), point(0, 11), point(60_000, 22)],
memory: [point(30_000, state === 'updated' ? 31 : 30), point(120_000, 55)],
disk: [point(45_000, 0)],
netin: [point(0, 0), point(120_000, 2048)],
netout: [point(30_000, 4096)],
diskread: [point(0, 2048), point(120_000, 8192)],
diskwrite: [point(0, 0), point(120_000, 0)],
temperature: [point(0, 42), point(120_000, 57)],
};
};
const response = (id, range, state) => ({
resourceType: 'agent',
resourceId: id,
range,
start: time,
end: time + 120_000,
source: 'store',
metrics: metrics(state),
});
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 state = kind;
requests.push({ id, range, state, method: route.request().method() });
if (state === 'hold') {
held = route;
return;
}
if (state === 'failure')
return route.fulfill({ status: 503, json: { error: 'Private fixture detail' } });
return route.fulfill({ json: response(id, range, state) });
}
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 = utilization.getByRole('slider', { name: 'Inspect Utilization history' });
await slider.waitFor();
const readsBefore = requests.length;
const plot = utilization.getByTestId('guest-history-plot');
const hover = async (chart, offset) => {
await chart.scrollIntoViewIfNeeded();
const box = await chart.getByTestId('guest-history-plot').boundingBox();
assert.ok(box);
await page.mouse.move(
box.x + ((34 + (offset / 120_000) * 318) / 360) * box.width,
box.y + box.height / 2,
);
};
const text = async () => (await utilization.innerText()).replace(/\s+/g, ' ');
const sparseTime = async (offset, expected, absent) => {
await hover(utilization, offset);
assert.match(await text(), expected);
assert.match(await text(), absent);
const markers = await plot
.locator('[data-history-hover-marker]')
.evaluateAll((nodes) =>
nodes.map((node) => ({
x: node.getAttribute('data-x'),
y: node.getAttribute('data-y'),
})),
);
assert.equal(new Set(markers.map((marker) => marker.x)).size, 1);
return markers;
};
// Capture the exact base misattribution too, before assertions fail.
await hover(utilization, 30_000);
const observed = { theme, sourceHash, text: await text(), browser: browser.version() };
console.log(JSON.stringify({ observedSparsePointer: observed }));
// Element screenshots scroll a tall phone fixture and end its hover.
// Capture the current viewport without disturbing the active pointer.
await page.screenshot({ path: path.join(artifacts, `sparse-pointer-${theme}.png`) });
assert.match(await text(), /CPU\s*no reading Memory\s*30\.0% Disk\s*no reading/);
assert.equal(await plot.locator('[data-history-hover-marker]').count(), 1);
assert.equal(
await utilization.getByTestId('guest-history-hover-time').innerText(),
'Stored · 22:13',
);
// The plot names its description first, then any missing-reading note.
const descriptionId = (await plot.getAttribute('aria-describedby')).split(' ')[0];
assert.match(
await page.locator(`[id="${descriptionId}"]`).innerText(),
/14\/11\/2023, 22:13:50\. CPU no observation\. Memory 30\.0%\. Disk no observation\./,
);
await sparseTime(0, /CPU\s*11\.0%/, /Memory\s*no reading Disk\s*no reading/);
await sparseTime(45_000, /Disk\s*0\.0%/, /CPU\s*no reading Memory\s*no reading/);
assert.equal(
await plot.locator('path[data-history-series="disk"]').count(),
0,
'lone disk sample must not become a path',
);
await hover(utilization, 120_000);
assert.match(await text(), /CPU\s*43\.0% Memory\s*55\.0% Disk\s*no reading/);
assert.equal(await plot.locator('[data-history-hover-marker]').count(), 2);
assert.equal(
await utilization.getByTestId('guest-history-hover-time').innerText(),
'Stored · 22:15',
);
const network = fixture.locator('[data-history-group="network"]');
await hover(network, 0);
assert.match(
(await network.innerText()).replace(/\s+/g, ' '),
/In\s*0 B\/s Out\s*no reading/,
);
await hover(network, 30_000);
assert.match(
(await network.innerText()).replace(/\s+/g, ' '),
/In\s*no reading Out\s*4\.00 KB\/s/,
);
await page.mouse.move(1, 1);
// The default readout is one stored time; disk has no point at the latest.
assert.match(await text(), /CPU\s*43\.0% Memory\s*55\.0% Disk\s*no reading/);
// Not inspecting: any readout names the latest stored time, never "Stored ·".
assert.deepEqual(
await fixture
.getByTestId('guest-history-hover-time')
.evaluateAll(
(nodes) => nodes.filter((node) => node.textContent.startsWith('Stored ·')).length,
),
0,
);
assert.equal(requests.length, readsBefore, 'pointer inspection must not request data');
// Native keyboard selection remains authoritative even with the pointer on another time.
await slider.focus();
await slider.press('Home');
await hover(utilization, 30_000);
assert.match(await text(), /CPU\s*11\.0% Memory\s*no reading Disk\s*no reading/);
await slider.press('ArrowRight');
assert.match(
await slider.getAttribute('aria-valuetext'),
/CPU no observation\. Memory 30\.0%\. Disk no observation/,
);
await slider.press('Tab');
await hover(utilization, 45_000);
assert.match(await text(), /CPU\s*no reading Memory\s*no reading Disk\s*0\.0%/);
if (width === 390) {
const box = await slider.boundingBox();
assert.ok(box.height >= 44);
await page.touchscreen.tap(box.x + 3, box.y + box.height / 2);
assert.match(
await slider.getAttribute('aria-valuetext'),
/CPU 11\.0%\. Memory no observation/,
);
await page.touchscreen.tap(box.x + box.width - 3, box.y + box.height / 2);
assert.match(await slider.getAttribute('aria-valuetext'), /CPU 43\.0%\. Memory 55\.0%/);
await slider.press('Tab');
}
await page.mouse.move(1, 1);
kind = 'failure';
// No manual refresh: the next 30-second poll reads the failing body.
await page.waitForTimeout(31_000);
await fixture
.getByText('History refresh failed. Showing previously loaded history.')
.waitFor();
await sparseTime(30_000, /Memory\s*30\.0%/, /CPU\s*no reading/);
await page.screenshot({
path: path.join(artifacts, `retained-sparse-pointer-${theme}.png`),
});
assert.equal((await fixture.innerText()).includes('Private fixture detail'), false);
kind = 'updated';
await fixture.getByRole('button', { name: 'Retry history' }).evaluate((el) => el.click());
await page.waitForFunction(() =>
[...document.querySelectorAll('[data-history-group="utilization"] span')].some(
(el) => el.textContent === '31.0%',
),
);
assert.match(await text(), /CPU\s*no reading Memory\s*31\.0% Disk\s*no reading/);
kind = 'hold';
await page
.getByRole('button', { name: 'Switch to service target' })
.evaluate((el) => el.click());
await page.waitForFunction(
() =>
[...document.querySelectorAll('[data-history-group="utilization"] path')].length === 0,
);
// Not inspecting: any readout names the latest stored time, never "Stored ·".
assert.deepEqual(
await fixture
.getByTestId('guest-history-hover-time')
.evaluateAll(
(nodes) => nodes.filter((node) => node.textContent.startsWith('Stored ·')).length,
),
0,
);
assert.equal(await plot.locator('[data-history-hover-marker]').count(), 0);
// The new target's read is held, so the legend shows qualified live
// readings and a plain dash for disk, which has none.
assert.match(await text(), /CPU\s*42\.0%\s*current Memory\s*53\.0%\s*current Disk\s*-/);
assert.ok(held);
kind = 'service';
await held.fulfill({ json: response('pbs-three', '24h', 'service') });
await slider.waitFor();
await hover(utilization, 0);
assert.match(await text(), /CPU\s*77\.0% Memory\s*no reading Disk\s*no reading/);
const dimensions = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
inner: innerWidth,
}));
assert.ok(dimensions.scroll <= dimensions.inner + 1, JSON.stringify(dimensions));
assert.deepEqual(errors, []);
observations.push({ theme, requests, dimensions, sourceHash });
await page.close();
}
const result = {
engine,
width,
browserVersion: browser.version(),
playwrightVersion,
sourceHash,
observations,
result: 'passed',
};
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2));
console.log(JSON.stringify(result));
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});