Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 6 additions & 1 deletion frontend/src/state/nodeDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions frontend/tests/fixtures/nodeDetails.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<!doctype html>
<!-- Copyright (c) Microsoft Corporation.
SPDX-License-Identifier: Apache-2.0 -->
<html lang="en">
<head><meta charset="utf-8"><title>Node details lifecycle test</title></head>
<body>
<div id="root"></div>
<button id="unmount">Unmount</button>
<script type="module">
import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
import useNodeDetails from '/src/hooks/useNodeDetails';
import NodeDetailDialog from '/src/components/nodes/NodeDetailDialog';

function Harness() {
const [selected, select] = useState(null);
const { detail, load } = useNodeDetails(selected);
return React.createElement(React.Fragment, null,
React.createElement('button', { onClick: () => select('node-a') }, 'Open node'),
React.createElement(NodeDetailDialog, {
nodeName: selected, detail, onLoad: load,
allNodeNames: ['node-a'], gatewayByNode: new Map(),
theme: 'light', detailTab: 'peerings',
onDetailTabChange: () => {}, onSelectNode: select,
onClose: () => select(null),
}),
);
}
const root = createRoot(document.getElementById('root'));
root.render(React.createElement(React.StrictMode, null, React.createElement(Harness)));
document.getElementById('unmount').onclick = () => root.unmount();
</script>
</body>
</html>
130 changes: 130 additions & 0 deletions frontend/tests/nodeDetails.browser.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
});