diff --git a/front/css/app.css b/front/css/app.css index fdc2afbfc..b4fe82bc7 100755 --- a/front/css/app.css +++ b/front/css/app.css @@ -1924,6 +1924,8 @@ textarea[readonly], #networkTree .box { + /* Positioned HTML inside SVG foreignObject is misplaced by WebKit. */ + position:static; border-width:1px; border-top-color:grey; padding:0px; @@ -1946,7 +1948,7 @@ textarea[readonly], } #networkTree .netNodeText { - position: absolute; + position: static; } #networkTree .netPort { @@ -1957,7 +1959,8 @@ textarea[readonly], #networkTree .portBckgIcon { - opacity: 0.3; + color: var(--color-gray); + color: color-mix(in srgb, currentColor 30%, transparent); display: initial; float: left; width: 1em; @@ -2034,8 +2037,8 @@ textarea[readonly], #networkTree .netCollapse { - position: absolute; - right: 0; + position: static; + float: right; margin-right: -3px; } @@ -2058,9 +2061,12 @@ textarea[readonly], #networkTree .network-hw-icon { - position: absolute; + position: static; + display: inline-block; margin-left: -0.4em; - opacity: 0.3; + /* Alpha in the color avoids WebKit's foreignObject opacity layer bug. */ + color: var(--color-gray); + color: color-mix(in srgb, currentColor 30%, transparent); margin-top: 0.1em; } diff --git a/front/js/network-tree.js b/front/js/network-tree.js index 26ebcce59..7dc6fcc83 100644 --- a/front/js/network-tree.js +++ b/front/js/network-tree.js @@ -281,7 +281,7 @@ function initTree(myHierarchy) // generate +/- icon if node has children nodes collapseExpandHtml = nodeData.data.hasChildren ? `
diff --git a/test/ui/TESTING_GUIDE.md b/test/ui/TESTING_GUIDE.md index 6afef9b1f..66632ab09 100644 --- a/test/ui/TESTING_GUIDE.md +++ b/test/ui/TESTING_GUIDE.md @@ -413,4 +413,24 @@ Tests that the settings form submits correctly to `php/server/util.php` with `fu Verifies that all settings are preserved when saved (no data loss during save operation). **Key Coverage**: Form submission flow → PHP `saveSettings()` → Config file generation with Python-compatible formatting +# Network topology rendering in WebKit, Chromium and Firefox + +The standalone regression uses the real Treeviz bundle, network renderer and styles +with four fixture devices. No running NetAlertX backend or database is required. +It checks painted pixels, node clicks, collapse/expand, wheel zoom and drag pan. +Pixel checks are necessary because WebKit may return correct DOM bounds while +painting positioned or translucent HTML at the SVG origin instead. + +Run from the repository root (Node.js required): + +```sh +npm install --prefix /tmp/netalertx-browser-tests playwright pngjs +/tmp/netalertx-browser-tests/node_modules/.bin/playwright install --with-deps webkit chromium firefox +NODE_PATH=/tmp/netalertx-browser-tests/node_modules node test/ui/test_network_topology.cjs +``` + +Screenshots are written to `/tmp/netalertx-topology`, or to the directory specified +by `NETALERTX_TOPOLOGY_ARTIFACTS`. This regression runs separately from pytest. +Also verify the fixture in native Safari when a Mac is available; Playwright +WebKit is useful regression coverage but is not the Safari application. diff --git a/test/ui/fixtures/network_topology.html b/test/ui/fixtures/network_topology.html new file mode 100644 index 000000000..fe7a4bf90 --- /dev/null +++ b/test/ui/fixtures/network_topology.html @@ -0,0 +1,41 @@ + + + +NetAlertX network topology regression fixture + + + + + + +

Network topology — discussion #1379

+
+ + + + + diff --git a/test/ui/test_network_topology.cjs b/test/ui/test_network_topology.cjs new file mode 100644 index 000000000..6ecc5053a --- /dev/null +++ b/test/ui/test_network_topology.cjs @@ -0,0 +1,119 @@ +// Standalone browser regression for SVG foreignObject rendering; see TESTING_GUIDE.md. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const os = require('node:os'); +const path = require('node:path'); +const {chromium, firefox, webkit} = require('playwright'); +const {PNG} = require('pngjs'); + +/** Verify actual painted pixels, since WebKit can report correct bounds for misplaced HTML. */ +async function assertPainted(page, selector) { + const items = await page.locator(selector).all(); + assert(items.length > 0, selector + ' is missing'); + for (const item of items) { + const rect = await item.boundingBox(); + assert(rect && rect.width > 0 && rect.height > 0, selector + ' has no bounds'); + const png = PNG.sync.read(await page.screenshot({clip: { + x: Math.ceil(rect.x + 1), y: Math.ceil(rect.y + 2), + width: Math.max(1, Math.floor(rect.width - 2)), height: Math.max(1, Math.floor(rect.height - 4)) + }})); + let pixels = 0; + for (let i = 0; i < png.data.length; i += 4) { + if (png.data[i] < 240 && png.data[i + 1] < 240 && png.data[i + 2] < 240) pixels++; + } + assert(pixels >= 3, selector + ' is not painted at its SVG coordinates'); + } +} + +/** Serve the fixture and repository assets on an ephemeral loopback port. */ +function createServer() { + const root = path.resolve(__dirname, '../..'); + return http.createServer((request, response) => { + const pathname = new URL(request.url, 'http://localhost').pathname; + const file = path.resolve(root, '.' + pathname); + if (!file.startsWith(root + path.sep)) { + response.writeHead(403).end(); + return; + } + fs.readFile(file, (error, data) => { + if (error) { + response.writeHead(404).end(); + return; + } + const types = {'.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript'}; + response.writeHead(200, {'Content-Type': types[path.extname(file)] || 'application/octet-stream'}); + response.end(data); + }); + }); +} + +/** Check painting and real pointer interactions before and after tree updates and transforms. */ +async function checkBrowser(name, engine, url, artifacts) { + const browser = await engine.launch(); + try { + const page = await browser.newPage({viewport: {width: 1200, height: 800}}); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto(url); + await page.waitForFunction(() => document.querySelectorAll('#networkTree g.node').length === 4); + // Treeviz transitions take 600 ms; wait for both them and icon fonts to settle. + await page.evaluate(() => document.fonts.ready); + await page.waitForTimeout(900); + await page.screenshot({path: path.join(artifacts, name + '-initial.png')}); + for (const selector of ['.spanNetworkTree', '.netIcon', '.portBckgIcon', '.network-hw-icon', '.netCollapse']) { + await assertPainted(page, '#networkTree ' + selector); + } + const laptop = page.locator('.node-inner[data-mac="aa:bb:cc:dd:ee:02"] .spanNetworkTree'); + await laptop.click(); + assert.deepEqual(await page.evaluate(() => clicks), ['aa:bb:cc:dd:ee:02']); + const collapse = page.locator('.netCollapse[data-mytreemac="aa:bb:cc:dd:ee:01"]'); + await collapse.click(); + await page.waitForFunction(() => document.querySelectorAll('#networkTree g.node').length === 2); + await page.waitForTimeout(700); + await collapse.click(); + await page.waitForFunction(() => document.querySelectorAll('#networkTree g.node').length === 4); + await page.waitForTimeout(700); + await assertPainted(page, '#networkTree .spanNetworkTree'); + // Exercise Treeviz/D3's actual wheel zoom and drag pan handlers. + const canvas = page.locator('#networkTree svg > g'); + const beforeZoom = await canvas.getAttribute('transform'); + await page.mouse.move(600, 400); + await page.mouse.wheel(0, 300); + await page.waitForTimeout(500); + const afterZoom = await canvas.getAttribute('transform'); + assert.notEqual(afterZoom, beforeZoom, 'Wheel zoom did not change the SVG transform'); + await page.mouse.move(600, 400); + await page.mouse.down(); + await page.mouse.move(650, 440, {steps: 5}); + await page.mouse.up(); + assert.notEqual(await canvas.getAttribute('transform'), afterZoom, 'Drag pan did not change the SVG transform'); + await assertPainted(page, '#networkTree .spanNetworkTree'); + await laptop.click(); + assert.equal((await page.evaluate(() => clicks)).length, 2); + await page.screenshot({path: path.join(artifacts, name + '-zoom-pan.png')}); + // The bundled Treeviz demo expects #tree, which network.php also does not provide. + assert.deepEqual(errors.filter(error => error !== 'Cannot find dom element with id:tree'), []); + console.log(name + ': PASS — painting, clicks, collapse/expand, zoom and pan'); + } finally { + await browser.close(); + } +} + +/** Run the same rendering checks in WebKit, Chromium and Firefox without an app backend. */ +async function main() { + const artifacts = process.env.NETALERTX_TOPOLOGY_ARTIFACTS || path.join(os.tmpdir(), 'netalertx-topology'); + fs.mkdirSync(artifacts, {recursive: true}); + const server = createServer(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const url = `http://127.0.0.1:${server.address().port}/test/ui/fixtures/network_topology.html`; + try { + for (const [name, engine] of Object.entries({webkit, chromium, firefox})) { + await checkBrowser(name, engine, url, artifacts); + } + } finally { + await new Promise(resolve => server.close(resolve)); + } +} + +main().catch(error => { console.error(error); process.exitCode = 1; });