diff --git a/frontend/README.md b/frontend/README.md index 763fa4253..34a582a39 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -50,3 +50,20 @@ The tests require Node's built-in TypeScript stripping; no frontend test framework is required. Tests cover summary compatibility, observed count semantics, API mapping, explicit loads, cache reuse, refresh failures, cancellation, fixed deadlines and expiry. + +The optional Chromium regression test mounts the real detail dialog and hook +under React StrictMode, using native browser timers and same-origin HTTP +requests. It covers button events, correlated polling, cache reuse, failed +refresh, expiry, close/reopen and unmount cancellation. Run it with an existing +Playwright installation and its Chromium browser (no production dependency): + +```sh +PLAYWRIGHT_MODULE=file:///absolute/path/to/playwright/index.mjs \ +PLAYWRIGHT_BROWSERS_PATH=/absolute/path/to/playwright-browsers \ +node --test frontend/tests/nodeDetails.browser.test.mjs +``` + +Both paths can point to project-local tooling. If Playwright is already +resolvable from `frontend`, omit `PLAYWRIGHT_MODULE`. Keep native browser timers +in this test: fake timers do not detect the `Illegal invocation` caused by +calling Window timer functions with a custom clock object as their receiver. diff --git a/frontend/src/state/nodeDetails.ts b/frontend/src/state/nodeDetails.ts index 432412ebe..b0371cf27 100644 --- a/frontend/src/state/nodeDetails.ts +++ b/frontend/src/state/nodeDetails.ts @@ -20,7 +20,12 @@ type Clock = { setTimeout: (callback: () => void, delay: number) => Timer; clearTimeout: (timer: Timer) => void; }; -const browserClock: Clock = { now: Date.now, setTimeout, clearTimeout }; +const browserClock: Clock = { + now: Date.now, + // Browser timers require their global receiver, not the injected clock object. + setTimeout: (callback, delay) => globalThis.setTimeout(callback, delay), + clearTimeout: (timer) => globalThis.clearTimeout(timer), +}; const notLoaded: DetailView = { state: 'not-loaded' }; // Only this store owns retained snapshots. React subscribes to a version, not a diff --git a/frontend/tests/fixtures/nodeDetails.html b/frontend/tests/fixtures/nodeDetails.html new file mode 100644 index 000000000..61390bda6 --- /dev/null +++ b/frontend/tests/fixtures/nodeDetails.html @@ -0,0 +1,34 @@ + + + + Node details lifecycle test + +
+ + + + diff --git a/frontend/tests/nodeDetails.browser.test.mjs b/frontend/tests/nodeDetails.browser.test.mjs new file mode 100644 index 000000000..c5d28bf95 --- /dev/null +++ b/frontend/tests/nodeDetails.browser.test.mjs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. +// SPDX-License-Identifier: Apache-2.0 + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { createServer } from 'vite'; + +// Browser tooling is optional; see README.md for the explicit browser test command. +const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright'); + +test('native browser Load data, polling, refresh, expiry and cleanup lifecycle', async (t) => { + const requests = []; + let mode = 'complete'; + let requestId = 0; + const server = await createServer({ + configFile: false, + root: fileURLToPath(new URL('..', import.meta.url)), + server: { host: '127.0.0.1', port: 0 }, + plugins: [{ + name: 'details-http-fixture', + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + const url = new URL(req.url, 'http://localhost'); + if (url.pathname !== '/status/node/node-a/details') return next(); + let body = ''; + for await (const chunk of req) body += chunk; + requests.push({ + method: req.method, requestId: url.searchParams.get('requestId'), + body: body ? JSON.parse(body) : undefined, cookie: req.headers.cookie, + }); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-store'); + const result = { nodeName: 'node-a', requestId: `request-${requestId}` }; + if (mode === 'error') { + res.statusCode = 503; + res.end(JSON.stringify({ ...result, state: 'retryable', error: 'fixture unavailable' })); + } else if (req.method === 'POST' || mode === 'pending') { + if (req.method === 'POST') result.requestId = `request-${++requestId}`; + res.statusCode = 202; + res.end(JSON.stringify({ + ...result, state: 'pending', + deadline: new Date(Date.now() + (mode === 'deadline' ? -1 : 30000)).toISOString(), + })); + } else { + const now = new Date().toISOString(); + res.end(JSON.stringify({ + ...result, state: 'complete', + details: { + ...result, collectedAt: now, receivedAt: now, + expiresAt: new Date(Date.now() + (mode === 'expire' ? 2000 : 60000)).toISOString(), + status: { nodeInfo: { name: 'node-a' }, peers: [], routes: [], bpfEntries: [] }, + }, + })); + } + }); + }, + }], + }); + t.after(() => server.close()); + await server.listen(); + const origin = `http://127.0.0.1:${server.httpServer.address().port}`; + const browser = await chromium.launch(); + t.after(() => browser.close()); + const context = await browser.newContext(); + await context.addCookies([{ name: 'viewer', value: 'test-session', url: origin }]); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.setDefaultTimeout(10000); + await page.goto(`${origin}/tests/fixtures/nodeDetails.html`); + const open = () => page.getByRole('button', { name: 'Open node', exact: true }).click(); + const load = () => page.getByRole('button', { name: 'Load data', exact: true }).click(); + const refresh = () => page.getByRole('button', { name: 'Refresh', exact: true }).click(); + const loaded = () => page.getByText('Detailed data loaded.', { exact: true }).waitFor(); + const deadline = () => page.getByText('Request deadline:').waitFor(); + const fullJSON = page.getByLabel('Full node JSON'); + + await open(); + assert.equal(requests.length, 0, 'selection does not collect details'); + await load(); + await loaded(); + assert.deepEqual(requests.map(({ method }) => method), ['POST', 'GET']); + assert.deepEqual(requests[0].body, { forceRefresh: false }); + assert.equal(requests[1].requestId, 'request-1'); + assert.ok(requests.every(({ cookie }) => cookie === 'viewer=test-session')); + await page.getByRole('button', { name: 'Show full node JSON' }).click(); + assert.equal(JSON.parse(await fullJSON.innerText()).nodeInfo.name, 'node-a'); + await load(); + assert.equal(requests.length, 2, 'Load data reuses the still-valid snapshot'); + + mode = 'error'; + await refresh(); + await page.getByRole('alert').waitFor(); + assert.deepEqual(requests.at(-1).body, { forceRefresh: true }); + assert.match(await page.getByRole('alert').innerText(), /fixture unavailable/); + assert.equal(JSON.parse(await fullJSON.innerText()).nodeInfo.name, 'node-a'); + await page.getByText('Showing previous still-valid snapshot.', { exact: false }).waitFor(); + + mode = 'expire'; + await refresh(); + await loaded(); + await page.getByRole('button', { name: 'Show full node JSON' }).click(); + await fullJSON.waitFor(); + await page.getByText('Detailed data expired and was removed.', { exact: false }).waitFor(); + assert.equal(await fullJSON.count(), 0, 'expiry unmounts the retained full payload'); + + mode = 'deadline'; + await load(); + await page.getByRole('alert').waitFor(); + assert.match(await page.getByRole('alert').innerText(), /deadline expired/); + assert.equal(requests.at(-1).method, 'POST', 'expired requests must not start polling'); + + mode = 'pending'; + await load(); + await deadline(); + await page.getByRole('button', { name: 'Close', exact: true }).click(); + const afterCancel = requests.length; + await open(); + await page.getByText('Detailed data is not loaded.', { exact: false }).waitFor(); + await page.waitForTimeout(1100); + assert.equal(requests.length, afterCancel, 'close cancels polling; reopening never loads'); + await load(); + await deadline(); + await page.getByRole('button', { name: 'Unmount', exact: true }).click(); + const afterUnmount = requests.length; + await page.waitForTimeout(1100); + assert.equal(requests.length, afterUnmount, 'unmount cancels pending timers'); + assert.deepEqual(errors, [], 'native timer invocation and StrictMode cleanup must not throw'); +});