From ed0dc134b92ac71d264c64742af1e8749e4c5b50 Mon Sep 17 00:00:00 2001 From: soundgoof Date: Tue, 28 Jul 2026 08:43:33 +0200 Subject: [PATCH 1/5] Add core test suite and repair the dev server and examples Introduces the first tests this repo has had. The JS suites load the browser scripts into a vm sandbox with jQuery and Raphael stubbed, so dhmap.init genuinely runs and its private registries become observable without a browser. The Python suite drives ipplan2dhmap.py as a subprocess against a database built from a topology fixture, giving a topology -> database -> generator -> topology round trip. Tests characterize current behaviour rather than desired behaviour, so they pass on a clean checkout. Four known defects are pinned as such: the doubled renderSwitch offsets, the unguarded alert_hosts dereference, the crash on a Grid hall with no objects, and the AttributeError on a hall-less name without digits. localserver.py imported SimpleHTTPServer/SocketServer and so could not run on Python 3 at all despite its shebang; ported to http.server with the /analytics/ redirect semantics unchanged, plus --port and --analytics-port so tests can bind free ports. src/examples/ had not been touched since the initial commit and crashed against the current dhmap.init: the data was a flat array predating the hall/Grid rewrite, example.js passed booleans where status strings are expected, and index.html was missing the elements init looks up. Repaired and now covered by tests so it cannot rot again silently. A real ipplan database can be dropped into local/ to run the same assertions against real data; those tests skip when none is present, which is how CI runs. --- .gitignore | 18 ++- README.md | 27 +++- local/.gitkeep | 0 local/README.md | 33 ++++ localserver.py | 64 ++++++-- src/examples/data.json | 182 +++++++++++++-------- src/examples/example.js | 28 ++-- src/examples/index.html | 22 +++ test/fixtures/build_ipplan.py | 80 ++++++++++ test/fixtures/topology.js | 82 ++++++++++ test/fixtures/topology.json | 94 +++++++++++ test/fixtures/topology.py | 56 +++++++ test/helpers/load.js | 140 ++++++++++++++++ test/helpers/raphael-stub.js | 93 +++++++++++ test/python/test_ipplan2dhmap.py | 204 ++++++++++++++++++++++++ test/unit/dhmap.test.js | 203 ++++++++++++++++++++++++ test/unit/dhmon.test.js | 264 +++++++++++++++++++++++++++++++ 17 files changed, 1499 insertions(+), 91 deletions(-) create mode 100644 local/.gitkeep create mode 100644 local/README.md create mode 100644 test/fixtures/build_ipplan.py create mode 100644 test/fixtures/topology.js create mode 100644 test/fixtures/topology.json create mode 100644 test/fixtures/topology.py create mode 100644 test/helpers/load.js create mode 100644 test/helpers/raphael-stub.js create mode 100644 test/python/test_ipplan2dhmap.py create mode 100644 test/unit/dhmap.test.js create mode 100644 test/unit/dhmon.test.js diff --git a/.gitignore b/.gitignore index fe107c2..2fde25d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,17 @@ -/data.json \ No newline at end of file +/data.json + +# Test outputs - never commit anything a test run produces +node_modules/ +test-results/ +playwright-report/ +.playwright/ +__pycache__/ +*.pyc + +# Real ipplan databases must never be committed (they contain host +# addressing and network plans). Drop them in local/ for local testing. +*.db +*.db.xz +/local/* +!/local/.gitkeep +!/local/README.md diff --git a/README.md b/README.md index 4da7057..e557b62 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,28 @@ HTML5/JS library for drawing and updating network layouts # Try it - cd src/ - python -m SimpleHTTPServer + make serve -Go to `http://localhost:8000/examples/` +Go to `http://localhost:8000/dhmon.html` for the map, or +`http://localhost:8000/src/examples/` for a minimal usage example. + +`make serve` runs the local web server together with a fake analytics backend, +so the map is populated without needing a real dhmon behind it. To render a +real event instead, drop an ipplan database into `local/` first — see +[local/README.md](local/README.md). + +# Tests + + make test + +Runs everything that has its dependencies available. The core suites need only +`python3` and `node`: + + make test-unit # JS logic + make test-py # ipplan2dhmap.py, localserver, fake backend + +The browser suites additionally need `npm ci` and +`npx playwright install chromium`: + + make test-dom # menu rendering under jsdom + make test-e2e # full app in headless chromium diff --git a/local/.gitkeep b/local/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/local/README.md b/local/README.md new file mode 100644 index 0000000..a589208 --- /dev/null +++ b/local/README.md @@ -0,0 +1,33 @@ +# local/ + +Scratch directory for local development. **Everything here is gitignored.** + +## Running against real event data + +Drop a real ipplan database here and every command picks it up automatically: + + cp ~/Downloads/ipplan.db.xz local/ + +`.xz` files are decompressed to a temp directory transparently, so you can drop +the file in exactly as it arrives. Then: + + make serve # serves the real event map on localhost:8000 + make test-py # additionally runs the assertions against the real database + +Resolution order, used consistently everywhere: + +1. `$IPPLAN_DB` +2. `local/ipplan.db` +3. `local/ipplan.db.xz` +4. a generated fixture topology (the default, and what CI uses) + +With no database present everything still works — the suite falls back to a +fixture topology and the real-data tests report as skipped. + +## Do not commit real data + +A real `ipplan.db` contains host records with IPv4/IPv6 addressing and network +definitions with VLANs, netmasks and gateways. This repository is public *and* +is deployed verbatim to `/var/www/html/dhmap` by puppet, so anything committed +here becomes web-served. `.gitignore` covers `local/`, `*.db` and `*.db.xz` to +make that mistake hard to make. diff --git a/localserver.py b/localserver.py index 0bdfa8c..e02f09d 100755 --- a/localserver.py +++ b/localserver.py @@ -1,24 +1,68 @@ #!/usr/bin/env python3 -# Used to run dhmap + analytics on the same machine for dev purposes +"""Serve dhmap locally, proxying /analytics/ to the analytics backend. -import SimpleHTTPServer -import SocketServer +Used to run dhmap + analytics on the same machine for dev purposes. The +analytics backend is normally dhmon's; for local work without one, see +test/fake/backend.py, which serves the same endpoints from a scenario. +""" +import argparse +import functools +import http.server +import os +import socketserver -PORT = 8000 +DEFAULT_PORT = 8000 +DEFAULT_ANALYTICS_PORT = 5000 + + +class RevHandler(http.server.SimpleHTTPRequestHandler): + """Static file handler that redirects /analytics/ to the backend.""" + + analytics_port = DEFAULT_ANALYTICS_PORT -class RevHandler(SimpleHTTPServer.SimpleHTTPRequestHandler): def do_GET(self): if self.path.startswith('/analytics/'): path = self.path[len('/analytics/'):] self.send_response(302) - self.send_header("Location", "http://localhost:5000/" + path) + self.send_header( + 'Location', 'http://localhost:%d/%s' % (self.analytics_port, path)) self.end_headers() return None else: - SimpleHTTPServer.SimpleHTTPRequestHandler.do_GET(self) + return http.server.SimpleHTTPRequestHandler.do_GET(self) + + +def serve(port=DEFAULT_PORT, analytics_port=DEFAULT_ANALYTICS_PORT, + directory=None): + """Create a server. Returns it without serving, so tests can drive it. + + Pass port=0 to bind an arbitrary free port; read it back from + httpd.server_address[1]. + """ + # Subclass per server so concurrent servers can target different backends; + # setting the attribute on a functools.partial would not reach the class. + handler = type('BoundRevHandler', (RevHandler,), + {'analytics_port': analytics_port}) + handler = functools.partial( + handler, + directory=directory or os.path.dirname(os.path.abspath(__file__))) + # Without this a restart within TIME_WAIT fails to bind. + socketserver.TCPServer.allow_reuse_address = True + return socketserver.TCPServer(('', port), handler) -httpd = SocketServer.TCPServer(("", PORT), RevHandler) -print("serving at port {}".format(PORT)) -httpd.serve_forever() +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--port', type=int, default=DEFAULT_PORT, + help='port to serve on (default: %(default)s)') + parser.add_argument('--analytics-port', type=int, + default=DEFAULT_ANALYTICS_PORT, + help='analytics backend port (default: %(default)s)') + args = parser.parse_args() + httpd = serve(args.port, args.analytics_port) + print('serving at port {}'.format(args.port)) + try: + httpd.serve_forever() + except KeyboardInterrupt: + httpd.shutdown() diff --git a/src/examples/data.json b/src/examples/data.json index 9e52659..95aecff 100644 --- a/src/examples/data.json +++ b/src/examples/data.json @@ -1,68 +1,118 @@ -[ { - "name": "D73", - "horizontal": 0, - "class": "table", - "x1": 67, - "y1": 0, - "x2": 59, - "y2": 37, - "width": 38, - "height": 8 -}, -{ - "name": "D74", - "horizontal": 0, - "class": "table", - "x1": 50, - "y1": 0, - "x2": 42, - "y2": 37, - "width": 38, - "height": 8 -}, -{ - "name": "D75", - "horizontal": 0, - "class": "table", - "x1": 33, - "y1": 0, - "x2": 25, - "y2": 37, - "width": 38, - "height": 8 -}, -{ - "name": "d73-a.event.dreamhack.local", - "horizontal": 0, - "class": "switch", - "x1": 68, - "y1": 22, - "x2": 68, - "y2": 22, - "width": 5, - "height": 5 -}, -{ - "name": "d74-a.event.dreamhack.local", - "horizontal": 0, - "class": "switch", - "x1": 51, - "y1": 22, - "x2": 51, - "y2": 22, - "width": 5, - "height": 5 -}, -{ - "name": "d75-a.event.dreamhack.local", - "horizontal": 0, - "class": "switch", - "x1": 34, - "y1": 22, - "x2": 34, - "y2": 22, - "width": 5, - "height": 5 + "Hall 1": [ + { + "name": "D73", + "horizontal": 0, + "class": "table", + "hall": "Hall 1", + "x1": 67, + "y1": 0, + "x2": 59, + "y2": 37, + "width": 38, + "height": 8 + }, + { + "name": "D74", + "horizontal": 0, + "class": "table", + "hall": "Hall 1", + "x1": 50, + "y1": 0, + "x2": 42, + "y2": 37, + "width": 38, + "height": 8 + }, + { + "name": "D75", + "horizontal": 0, + "class": "table", + "hall": "Hall 1", + "x1": 33, + "y1": 0, + "x2": 25, + "y2": 37, + "width": 38, + "height": 8 + }, + { + "name": "d73-a.event.dreamhack.local", + "horizontal": 0, + "class": "switch", + "hall": "Hall 1", + "x1": 72, + "y1": 23, + "x2": 72, + "y2": 23, + "width": 5, + "height": 5 + }, + { + "name": "d74-a.event.dreamhack.local", + "horizontal": 0, + "class": "switch", + "hall": "Hall 1", + "x1": 55, + "y1": 23, + "x2": 55, + "y2": 23, + "width": 5, + "height": 5 + }, + { + "name": "d75-a.event.dreamhack.local", + "horizontal": 0, + "class": "switch", + "hall": "Hall 1", + "x1": 38, + "y1": 23, + "x2": 38, + "y2": 23, + "width": 5, + "height": 5 + } + ], + "Dist": [ + { + "name": "dist-core.event.dreamhack.local", + "horizontal": 0, + "class": "switch", + "hall": "Dist", + "x1": 0, + "y1": 0, + "x2": 0, + "y2": 0, + "width": 0, + "height": 0 + } + ], + "Prod": [ + { + "name": "prod-sw1.event.dreamhack.se", + "horizontal": 0, + "class": "switch", + "hall": "Prod", + "x1": 0, + "y1": 0, + "x2": 0, + "y2": 0, + "width": 0, + "height": 0 + } + ], + "Grid": [ + { + "name": "Hall 1", + "horizontal": 0, + "class": "hall", + "hall": "Grid", + "x1": 0, + "y1": 0, + "x2": 0, + "y2": 0, + "width": 0, + "height": 0 + } + ] } -] diff --git a/src/examples/example.js b/src/examples/example.js index 5ec3c34..0c61bd1 100644 --- a/src/examples/example.js +++ b/src/examples/example.js @@ -1,13 +1,19 @@ -var map = null; - +// Minimal dhmap usage: load a map, draw it, then push switch statuses. +// +// The status values are the keys of dhmap.colour ('OK', 'CRITICAL', +// 'WARNING', 'SPEED', 'STP', 'ERRORS', 'ALERT'); anything unrecognised - or a +// switch you omit - renders as UNKNOWN. $.getJSON('./data.json', function(objects) { - dhmap.init(objects); -}); - -setTimeout(function() { - dhmap.updateSwitches({ - "d73-a.event.dreamhack.local": true, - "d74-a.event.dreamhack.local": false, - "d75-a.event.dreamhack.local": true + dhmap.init(objects, function(object) { + console.log('clicked', object.name); }); -}, 2000); + + // Statuses normally arrive from /analytics/; here we just fake one update. + setTimeout(function() { + dhmap.updateSwitches({ + 'd73-a.event.dreamhack.local': 'OK', + 'd74-a.event.dreamhack.local': 'CRITICAL', + 'd75-a.event.dreamhack.local': 'WARNING' + }); + }, 2000); +}); diff --git a/src/examples/index.html b/src/examples/index.html index f7e600b..d83bb62 100644 --- a/src/examples/index.html +++ b/src/examples/index.html @@ -3,9 +3,31 @@ Example Usage - dhmap + + + + + +
diff --git a/test/fixtures/build_ipplan.py b/test/fixtures/build_ipplan.py new file mode 100644 index 0000000..ad5369c --- /dev/null +++ b/test/fixtures/build_ipplan.py @@ -0,0 +1,80 @@ +"""Build a throwaway ipplan SQLite database from a topology. + +This inverts what src/ipplan2dhmap.py does, so a topology can be turned into a +database, fed back through the generator, and compared with the original. The +database is always written to a temp directory - never into the repository. + +Only the five tables the generator reads are created; a real ipplan has many +more, but none of them affect the map. +""" +import os +import sqlite3 +import tempfile + +SWITCH_OFFSET = 5 + +SCHEMA = """ +CREATE TABLE table_coordinates ( + name TEXT, horizontal INT, hall TEXT, + x1 INT, y1 INT, x2 INT, y2 INT, width INT, height INT); +CREATE TABLE switch_coordinates (name TEXT, table_name TEXT, x INT, y INT); +CREATE TABLE host (node_id INT, name TEXT); +CREATE TABLE option (node_id INT, name TEXT, value TEXT); +CREATE TABLE hall_positions (name TEXT, x INT, y INT); +""" + + +def table_for_switch(switch_name): + """d73-a.event.dreamhack.local -> D73, matching how the map pairs them.""" + short = switch_name.split('.')[0] + return short.split('-')[0].upper() + + +def build(topology, path=None, hall_position_duplicates=1): + """Write `topology` to a SQLite database and return its path. + + hall_position_duplicates reproduces a real-world quirk: the production + database holds several identical rows per hall, which the generator's UNION + collapses back to one. + """ + if path is None: + path = os.path.join(tempfile.mkdtemp(prefix='dhmap-fixture-'), 'ipplan.db') + + conn = sqlite3.connect(path) + conn.executescript(SCHEMA) + node_id = 0 + + for hall, objects in topology.items(): + for obj in objects: + if obj['class'] == 'table': + conn.execute( + 'INSERT INTO table_coordinates VALUES (?,?,?,?,?,?,?,?,?)', + (obj['name'], obj['horizontal'], obj['hall'], obj['x1'], + obj['y1'], obj['x2'], obj['y2'], obj['width'], obj['height'])) + + elif obj['class'] == 'hall': + for _ in range(hall_position_duplicates): + conn.execute('INSERT INTO hall_positions VALUES (?,?,?)', + (obj['name'], obj['x1'], obj['y1'])) + + elif obj['class'] == 'switch': + node_id += 1 + if hall == 'Dist': + conn.execute('INSERT INTO host VALUES (?,?)', (node_id, obj['name'])) + conn.execute('INSERT INTO option VALUES (?,?,?)', + (node_id, 'layer', 'dist')) + elif hall == 'Prod': + # Deliberately no switch_coordinates row: the generator's Prod branch + # selects exactly those access switches that have no coordinates. + conn.execute('INSERT INTO host VALUES (?,?)', (node_id, obj['name'])) + conn.execute('INSERT INTO option VALUES (?,?,?)', + (node_id, 'layer', 'access')) + else: + conn.execute( + 'INSERT INTO switch_coordinates VALUES (?,?,?,?)', + (obj['name'], table_for_switch(obj['name']), + obj['x1'] - SWITCH_OFFSET, obj['y1'] - SWITCH_OFFSET)) + + conn.commit() + conn.close() + return path diff --git a/test/fixtures/topology.js b/test/fixtures/topology.js new file mode 100644 index 0000000..1e07169 --- /dev/null +++ b/test/fixtures/topology.js @@ -0,0 +1,82 @@ +/** + * Builds map topologies in the shape ipplan2dhmap.py emits and dhmap.init + * consumes: an object keyed by hall name, plus the synthetic Dist, Prod and + * Grid halls. + * + * Built rather than stored so tests can ask for the specific shape they need + * (an empty hall, a name without digits, a second hall) without a fixture file + * per case. + */ +'use strict'; + +const KEYS = ['name', 'horizontal', 'class', 'hall', + 'x1', 'y1', 'x2', 'y2', 'width', 'height']; + +function object(overrides) { + return { + name: '', horizontal: 0, class: 'table', hall: '', + x1: 0, y1: 0, x2: 0, y2: 0, width: 0, height: 0, + ...overrides, + }; +} + +function table(name, hall, x1, y1, overrides = {}) { + return object({ + name, hall, class: 'table', x1, y1, + x2: x1 - 8, y2: y1 + 37, width: 38, height: 8, + ...overrides, + }); +} + +/** A switch as the generator emits it: coordinates already offset by +5. */ +function accessSwitch(name, hall, x1, y1, overrides = {}) { + return object({ + name, hall, class: 'switch', + x1: x1 + 5, y1: y1 + 5, x2: x1 + 5, y2: y1 + 5, + width: 5, height: 5, + ...overrides, + }); +} + +function hallMarker(name, x1, y1) { + return object({ name, hall: 'Grid', class: 'hall', x1, y1 }); +} + +/** + * A small but complete topology: one hall with two tables and their switches, + * one dist switch, one prod switch, and the Grid placement. + * + * Loaded from topology.json so the Python tier builds its SQLite fixture from + * exactly the same data. Returned as a fresh deep copy every call, because + * dhmap.init mutates the objects it is handed. + */ +function basic() { + return structuredClone(require('./topology.json')); +} + +/** Two halls side by side, to exercise the grid layout maths. */ +function twoHalls() { + const topology = basic(); + topology['Hall 2'] = [ + table('E10', 'Hall 2', 20, 0), + accessSwitch('e10-a.event.dreamhack.local', 'Hall 2', 5, 5), + ]; + topology['Grid'].push(hallMarker('Hall 2', 1, 0)); + return topology; +} + +/** Every switch name in a topology, in the order dhmap would draw them. */ +function switchNames(topology) { + const names = []; + for (const hall of Object.keys(topology)) { + for (const object of topology[hall]) { + if (object.class === 'switch') names.push(object.name); + } + } + return names; +} + +module.exports = { + KEYS, object, table, accessSwitch, hallMarker, + basic, twoHalls, switchNames, +}; diff --git a/test/fixtures/topology.json b/test/fixtures/topology.json new file mode 100644 index 0000000..18671e0 --- /dev/null +++ b/test/fixtures/topology.json @@ -0,0 +1,94 @@ +{ + "Hall 1": [ + { + "name": "D73", + "horizontal": 0, + "class": "table", + "hall": "Hall 1", + "x1": 67, + "y1": 0, + "x2": 59, + "y2": 37, + "width": 38, + "height": 8 + }, + { + "name": "D74", + "horizontal": 0, + "class": "table", + "hall": "Hall 1", + "x1": 50, + "y1": 0, + "x2": 42, + "y2": 37, + "width": 38, + "height": 8 + }, + { + "name": "d73-a.event.dreamhack.local", + "horizontal": 0, + "class": "switch", + "hall": "Hall 1", + "x1": 15, + "y1": 25, + "x2": 15, + "y2": 25, + "width": 5, + "height": 5 + }, + { + "name": "d74-a.event.dreamhack.local", + "horizontal": 0, + "class": "switch", + "hall": "Hall 1", + "x1": 17, + "y1": 27, + "x2": 17, + "y2": 27, + "width": 5, + "height": 5 + } + ], + "Dist": [ + { + "name": "dist-core.event.dreamhack.local", + "horizontal": 0, + "class": "switch", + "hall": "Dist", + "x1": 0, + "y1": 0, + "x2": 0, + "y2": 0, + "width": 0, + "height": 0 + } + ], + "Prod": [ + { + "name": "prod-sw1.event.dreamhack.se", + "horizontal": 0, + "class": "switch", + "hall": "Prod", + "x1": 0, + "y1": 0, + "x2": 0, + "y2": 0, + "width": 0, + "height": 0 + } + ], + "Grid": [ + { + "name": "Hall 1", + "horizontal": 0, + "class": "hall", + "hall": "Grid", + "x1": 0, + "y1": 0, + "x2": 0, + "y2": 0, + "width": 0, + "height": 0 + } + ] +} diff --git a/test/fixtures/topology.py b/test/fixtures/topology.py new file mode 100644 index 0000000..65c72b2 --- /dev/null +++ b/test/fixtures/topology.py @@ -0,0 +1,56 @@ +"""Map topologies in the shape ipplan2dhmap.py emits and dhmap.init consumes. + +Loaded from topology.json, the same fixture the JavaScript tier uses, so both +languages test against identical data. +""" +import copy +import json +import os + +FIXTURE_DIR = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.path.dirname(os.path.dirname(FIXTURE_DIR)) + +# Every key ipplan2dhmap.py puts on an object. +KEYS = ['name', 'horizontal', 'class', 'hall', + 'x1', 'y1', 'x2', 'y2', 'width', 'height'] + +# The generator offsets switch coordinates by this much; build_ipplan reverses +# it so a round trip lands back on the original values. +SWITCH_OFFSET = 5 + + +def basic(): + """One hall with two tables and switches, plus Dist, Prod and Grid.""" + with open(os.path.join(FIXTURE_DIR, 'topology.json')) as fp: + return json.load(fp) + + +def with_hall(topology, name, x, y, objects=None): + """Add a hall, its Grid placement, and optionally its objects.""" + topology = copy.deepcopy(topology) + topology.setdefault(name, list(objects or [])) + topology['Grid'].append({ + 'name': name, 'horizontal': 0, 'class': 'hall', 'hall': 'Grid', + 'x1': x, 'y1': y, 'x2': 0, 'y2': 0, 'width': 0, 'height': 0, + }) + return topology + + +def table(name, hall, x1, y1): + return {'name': name, 'horizontal': 0, 'class': 'table', 'hall': hall, + 'x1': x1, 'y1': y1, 'x2': x1 - 8, 'y2': y1 + 37, + 'width': 38, 'height': 8} + + +def access_switch(name, hall, x1, y1): + """A switch as the generator emits it, coordinates already offset.""" + return {'name': name, 'horizontal': 0, 'class': 'switch', 'hall': hall, + 'x1': x1 + SWITCH_OFFSET, 'y1': y1 + SWITCH_OFFSET, + 'x2': x1 + SWITCH_OFFSET, 'y2': y1 + SWITCH_OFFSET, + 'width': SWITCH_OFFSET, 'height': SWITCH_OFFSET} + + +def switch_names(topology): + """Every switch name in a topology.""" + return [o['name'] for objects in topology.values() for o in objects + if o['class'] == 'switch'] diff --git a/test/helpers/load.js b/test/helpers/load.js new file mode 100644 index 0000000..67875e0 --- /dev/null +++ b/test/helpers/load.js @@ -0,0 +1,140 @@ +/** + * Loads dhmap's browser scripts into a vm sandbox so they can be unit tested. + * + * None of the sources are modules - they are classic scripts that assign to + * globals and, in dhmon.js's case, run side effects at load time ($.getJSON + * for data.json, and a $.widget call to patch the jQuery UI dialog). So rather + * than requiring them, we evaluate them in a prepared context whose $ and + * document are inert stubs, then read the globals back off the sandbox. + */ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const REPO_ROOT = path.resolve(__dirname, '..', '..'); + +/** A chainable no-op standing in for jQuery at load time. */ +function makeJqueryStub() { + const $ = function () { return $; }; + const chain = () => $; + Object.assign($, { + getJSON: chain, + when: chain, + then: chain, + done: chain, + each: chain, + append: chain, + html: chain, + css: chain, + attr: chain, + text: chain, + hide: chain, + show: chain, + extend: Object.assign, + widget: () => {}, + ui: { dialog: { prototype: {} } }, + }); + return $; +} + +/** + * Minimal document stub. dhmon.js reads #consumer_mode's checked state inside + * checkIfaceErrors/checkIfaceStp, which is the only DOM dependency in the + * logic we unit test; the returned object is mutable so tests can toggle it. + */ +function makeDocumentStub() { + const consumerMode = { checked: false }; + const elements = { consumer_mode: consumerMode }; + return { + consumerMode, + document: { + getElementById: (id) => elements[id] || null, + body: { style: {}, classList: { add() {}, remove() {} } }, + }, + }; +} + +/** + * Evaluate `files` (repo-relative) in a fresh sandbox and return it. + * + * The sandbox doubles as the global object, so `sandbox.computeStatus` is the + * real function and `sandbox.ping = {...}` sets the global the function reads. + */ +function loadScripts(files, extras = {}) { + const { consumerMode, document } = makeDocumentStub(); + const $ = makeJqueryStub(); + + const sandbox = { + console: { log() {}, warn() {}, error() {} }, + Date, + Math, + JSON, + parseInt, + parseFloat, + setTimeout: () => 0, + setInterval: () => 0, + clearInterval: () => {}, + $, + jQuery: $, + document, + Raphael: undefined, + ...extras, + }; + sandbox.window = sandbox; + // Exposed for convenience: tests flip this to enable consumer-port checks. + sandbox.consumerMode = consumerMode; + + vm.createContext(sandbox); + for (const file of files) { + const full = path.resolve(REPO_ROOT, file); + vm.runInContext(fs.readFileSync(full, 'utf8'), sandbox, { filename: full }); + } + return sandbox; +} + +/** Load dhmon.js with dhmap/dhmenu stubbed out as recording sinks. */ +function loadDhmon(extras = {}) { + const updates = { dhmap: [], dhmenu: [] }; + const sandbox = loadScripts(['dhmon.js'], { + dhmap: { + colour: {}, + updateSwitches: (s) => updates.dhmap.push(s), + }, + dhmenu: { updateSwitches: (s) => updates.dhmenu.push(s) }, + ...extras, + }); + sandbox.updates = updates; + return sandbox; +} + +/** + * Load src/dhmap.js with Raphael stubbed, so dhmap.init can actually run. + * + * init() is the only thing that populates the closure-private canvasObjects + * and switches registries, which filter() and updateSwitches() then read - so + * letting it draw against a recording stub is what makes those testable. + */ +function loadDhmap(extras = {}) { + const { makeRaphael, RaphaelZPD, makeDom } = require('./raphael-stub.js'); + const dom = makeDom(); + const Raphael = makeRaphael(); + + const sandbox = loadScripts(['src/dhmap.js'], { + Raphael, + RaphaelZPD, + document: dom.document, + innerWidth: dom.innerWidth, + innerHeight: dom.innerHeight, + ...extras, + }); + sandbox.dom = dom; + // Shapes drawn by the most recent init(), in creation order. + Object.defineProperty(sandbox, 'shapes', { + get: () => (Raphael.lastPaper ? Raphael.lastPaper.shapes : []), + }); + return sandbox; +} + +module.exports = { loadScripts, loadDhmon, loadDhmap, REPO_ROOT }; diff --git a/test/helpers/raphael-stub.js b/test/helpers/raphael-stub.js new file mode 100644 index 0000000..cbdf588 --- /dev/null +++ b/test/helpers/raphael-stub.js @@ -0,0 +1,93 @@ +/** + * A minimal stand-in for Raphael and RaphaelZPD. + * + * dhmap.js keeps canvasObjects and switches in closure scope, so the only way + * to observe what init() actually drew is to let it draw. This records every + * shape instead of producing SVG, which is enough to assert on geometry, + * fills and the object registry without a browser. + */ +'use strict'; + +/** One drawn shape. Mirrors just enough of a Raphael element. */ +function makeShape(type, attrs, paper) { + const shape = { + type, + // Raphael exposes the raw attribute bag as .attrs; setSwitchColor reads it + // directly rather than going through .attr(), so it must stay in sync. + attrs: { ...attrs }, + visible: true, + handlers: {}, + rotation: 0, + }; + + shape.attr = function (nameOrObject) { + if (typeof nameOrObject === 'string') { + return shape.attrs[nameOrObject]; + } + Object.assign(shape.attrs, nameOrObject); + return shape; + }; + shape.rotate = (deg) => { shape.rotation = deg; return shape; }; + shape.hide = () => { shape.visible = false; return shape; }; + shape.show = () => { shape.visible = true; return shape; }; + shape.toFront = () => shape; + for (const event of ['mouseover', 'mouseout', 'click']) { + shape[event] = (fn) => { shape.handlers[event] = fn; return shape; }; + } + + paper.shapes.push(shape); + return shape; +} + +/** Raphael(canvas) -> paper. Called without `new` in dhmap.js. */ +function makeRaphael() { + function Raphael() { + const paper = { shapes: [] }; + paper.rect = (x, y, width, height) => + makeShape('rect', { x, y, width, height }, paper); + paper.text = (x, y, text) => + makeShape('text', { x, y, text }, paper); + Raphael.lastPaper = paper; + return paper; + } + return Raphael; +} + +/** RaphaelZPD(paper, opts) - only its gelem transform is touched. */ +function RaphaelZPD() { + this.gelem = { + attributes: {}, + setAttribute(name, value) { this.attributes[name] = value; }, + }; +} + +/** + * DOM stub providing the three elements dhmap.init looks up, plus the + * window dimensions it reads. + */ +function makeDom() { + const element = (extra = {}) => ({ + innerHTML: '', + style: {}, + clientWidth: 0, + clientHeight: 0, + ...extra, + }); + const elements = { + canvas: element(), + menu_container: element({ clientWidth: 100 }), + header: element({ clientHeight: 36 }), + consumer_mode: { checked: false }, + }; + return { + elements, + document: { + getElementById: (id) => elements[id] || null, + body: { style: {}, classList: { add() {}, remove() {} } }, + }, + innerWidth: 1920, + innerHeight: 1080, + }; +} + +module.exports = { makeRaphael, RaphaelZPD, makeDom, makeShape }; diff --git a/test/python/test_ipplan2dhmap.py b/test/python/test_ipplan2dhmap.py new file mode 100644 index 0000000..31010d4 --- /dev/null +++ b/test/python/test_ipplan2dhmap.py @@ -0,0 +1,204 @@ +"""Characterization tests for src/ipplan2dhmap.py. + +The generator is a script with no importable API, so it is driven the way +production drives it: as a subprocess against a database, asserting on the +JSON it prints. Fixtures are built into a temp directory and never committed. +""" +import json +import os +import sqlite3 +import subprocess +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), + '..', 'fixtures')) +import build_ipplan # noqa: E402 +import topology # noqa: E402 + +REPO_ROOT = topology.REPO_ROOT +GENERATOR = os.path.join(REPO_ROOT, 'src', 'ipplan2dhmap.py') + + +def generate(db_path): + """Run the generator, returning parsed JSON. Raises on failure.""" + result = subprocess.run([sys.executable, GENERATOR, db_path], + capture_output=True, text=True) + if result.returncode != 0: + raise AssertionError('generator failed: %s' % result.stderr) + return json.loads(result.stdout) + + +class RoundTripTest(unittest.TestCase): + """topology -> database -> generator -> topology.""" + + def test_round_trip_reproduces_the_topology(self): + source = topology.basic() + self.assertEqual(generate(build_ipplan.build(source)), source) + + def test_every_object_carries_the_expected_keys(self): + result = generate(build_ipplan.build(topology.basic())) + for hall, objects in result.items(): + for obj in objects: + self.assertCountEqual(obj.keys(), topology.KEYS, + 'unexpected keys in %s' % hall) + + def test_output_is_keyed_by_hall(self): + result = generate(build_ipplan.build(topology.basic())) + self.assertIn('Hall 1', result) + for hall, objects in result.items(): + for obj in objects: + self.assertEqual(obj['hall'], hall) + + +class BranchTest(unittest.TestCase): + """Each arm of the generator's five-way UNION.""" + + def setUp(self): + self.result = generate(build_ipplan.build(topology.basic())) + + def test_tables_come_through_with_their_coordinates(self): + tables = [o for o in self.result['Hall 1'] if o['class'] == 'table'] + self.assertEqual(sorted(t['name'] for t in tables), ['D73', 'D74']) + + def test_switch_coordinates_are_offset_by_five(self): + switch = next(o for o in self.result['Hall 1'] + if o['name'].startswith('d73-a')) + # build_ipplan stored x=10,y=20; the generator adds 5 to each. + self.assertEqual((switch['x1'], switch['y1']), (15, 25)) + self.assertEqual((switch['x2'], switch['y2']), (15, 25)) + self.assertEqual((switch['width'], switch['height']), (5, 5)) + + def test_dist_switches_land_in_their_own_hall(self): + self.assertEqual([o['name'] for o in self.result['Dist']], + ['dist-core.event.dreamhack.local']) + + def test_prod_only_covers_access_switches_without_coordinates(self): + self.assertEqual([o['name'] for o in self.result['Prod']], + ['prod-sw1.event.dreamhack.se']) + + def test_grid_describes_hall_placement(self): + grid = self.result['Grid'] + self.assertEqual([g['name'] for g in grid], ['Hall 1']) + self.assertEqual(grid[0]['class'], 'hall') + + +class RealWorldQuirksTest(unittest.TestCase): + """Behaviours only a production database reveals.""" + + def test_duplicate_hall_positions_collapse_to_one(self): + # Production holds several identical rows per hall. UNION (not UNION ALL) + # dedupes them; switching to UNION ALL would draw overlapping halls. + db = build_ipplan.build(topology.basic(), hall_position_duplicates=7) + self.assertEqual(len(generate(db)['Grid']), 1) + + def test_hall_names_may_contain_spaces(self): + result = generate(build_ipplan.build(topology.basic())) + self.assertIn('Hall 1', result) + + def test_halls_without_a_name_are_derived_from_the_object_name(self): + # A table with no hall falls back to the letters leading its name. + db = build_ipplan.build(topology.basic()) + conn = sqlite3.connect(db) + conn.execute('INSERT INTO table_coordinates VALUES ' + '("F12", 0, NULL, 0, 0, 0, 0, 0, 0)') + conn.commit() + conn.close() + self.assertIn('F', generate(db)) + + def test_hall_less_name_without_digits_crashes(self): + # KNOWN DEFECT (ipplan2dhmap.py:44): re.search(...).group(1) is called + # without checking for a match, so a name like VIP raises AttributeError. + db = build_ipplan.build(topology.basic()) + conn = sqlite3.connect(db) + conn.execute('INSERT INTO table_coordinates VALUES ' + '("VIP", 0, NULL, 0, 0, 0, 0, 0, 0)') + conn.commit() + conn.close() + + result = subprocess.run([sys.executable, GENERATOR, db], + capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn('AttributeError', result.stderr) + + +class ExampleTest(unittest.TestCase): + """src/examples/data.json is documentation, so it must stay accurate.""" + + def setUp(self): + with open(os.path.join(REPO_ROOT, 'src', 'examples', 'data.json')) as fp: + self.example = json.load(fp) + + def test_example_is_keyed_by_hall_with_the_expected_keys(self): + self.assertIsInstance(self.example, dict, + 'the example must be hall-keyed, not a flat list') + for hall, objects in self.example.items(): + for obj in objects: + self.assertCountEqual(obj.keys(), topology.KEYS) + + def test_example_has_a_grid_so_dhmap_init_can_lay_it_out(self): + self.assertIn('Grid', self.example) + for marker in self.example['Grid']: + self.assertIn(marker['name'], self.example, + 'every hall in Grid needs objects, or dhmap.init throws') + + def test_example_round_trips_through_the_generator(self): + self.assertEqual(generate(build_ipplan.build(self.example)), self.example) + + +def _real_database(): + """Resolve an optional real ipplan database, decompressing .xz if needed.""" + candidates = [os.environ.get('IPPLAN_DB'), + os.path.join(REPO_ROOT, 'local', 'ipplan.db'), + os.path.join(REPO_ROOT, 'local', 'ipplan.db.xz')] + for path in candidates: + if path and os.path.exists(path): + if not path.endswith('.xz'): + return path + import lzma + target = os.path.join(tempfile.mkdtemp(prefix='dhmap-real-'), 'ipplan.db') + with lzma.open(path) as src, open(target, 'wb') as dst: + dst.write(src.read()) + return target + return None + + +@unittest.skipUnless(_real_database(), + 'no real ipplan database - set IPPLAN_DB or drop one in ' + 'local/ (optional; CI never has one)') +class RealDataTest(unittest.TestCase): + """Structural checks against a real database, when one is available. + + Deliberately asserts nothing about specific device names, so real event data + never ends up encoded in test expectations. + """ + + @classmethod + def setUpClass(cls): + cls.result = generate(_real_database()) + + def test_produces_halls_with_well_formed_objects(self): + self.assertGreater(len(self.result), 0) + for hall, objects in self.result.items(): + for obj in objects: + self.assertCountEqual(obj.keys(), topology.KEYS) + self.assertEqual(obj['hall'], hall) + + def test_every_grid_hall_has_objects(self): + # dhmap.init looks up hallsizes[hall] for each Grid entry and would throw + # on a hall that has none, so this catches a map-breaking ipplan edit. + for marker in self.result.get('Grid', []): + self.assertIn(marker['name'], self.result) + + def test_object_classes_are_ones_dhmap_can_draw(self): + drawn = {'table', 'switch'} + for hall, objects in self.result.items(): + if hall == 'Grid': + continue + for obj in objects: + self.assertIn(obj['class'], drawn) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/unit/dhmap.test.js b/test/unit/dhmap.test.js new file mode 100644 index 0000000..3f70a60 --- /dev/null +++ b/test/unit/dhmap.test.js @@ -0,0 +1,203 @@ +/** + * Characterization tests for src/dhmap.js - the rendering library. + * + * Raphael is stubbed (test/helpers/raphael-stub.js) so init() genuinely runs + * and populates its private registries; filter() and updateSwitches() are only + * observable once it has. + */ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const { loadDhmap } = require('../helpers/load.js'); +const topo = require('../fixtures/topology.js'); + +/** Load dhmap and draw a topology, returning the sandbox. */ +function drawn(topology = topo.basic(), onclick = () => {}) { + const sandbox = loadDhmap(); + sandbox.dhmap.init(topology, onclick); + return sandbox; +} + +test('dhmap.colour', async (t) => { + const { dhmap } = loadDhmap(); + + await t.test('defines a colour for every status computeStatus emits', () => { + for (const status of ['OK', 'CRITICAL', 'WARNING', 'SPEED', 'STP', + 'ERRORS', 'ALERT', 'UNKNOWN']) { + assert.ok(dhmap.colour[status], `missing colour for ${status}`); + } + }); + + // dhmenu.js injects alpha with colour.replace(')', ',0.7)'), so these must + // stay in rgb()/rgba() form - a hex value there would produce nonsense. + await t.test('uses rgb()/rgba() form, which dhmenu depends on', () => { + for (const [status, colour] of Object.entries(dhmap.colour)) { + assert.match(colour, /^rgba?\(/, `${status} must be rgb()/rgba()`); + } + }); + + await t.test('alpha injection produces a usable colour', () => { + assert.equal(dhmap.colour.OK.replace(')', ',0.7)'), 'rgb(137,245,108,0.7)'); + }); +}); + +test('dhmap.init', async (t) => { + await t.test('draws a rectangle per table and switch, plus hall boxes', () => { + const sandbox = drawn(); + const rects = sandbox.shapes.filter((s) => s.type === 'rect'); + // 2 tables + 2 switches + 2 switch label boxes + 1 hall box + assert.equal(rects.length, 7); + assert.ok(sandbox.shapes.some((s) => s.attrs.text === 'Hall 1'), + 'hall label should be drawn'); + }); + + await t.test('fills tables and switches with their status colours', () => { + const sandbox = drawn(); + const fills = sandbox.shapes.map((s) => s.attrs.fill); + assert.ok(fills.includes(sandbox.dhmap.colour.TABLE), 'tables use TABLE'); + assert.ok(fills.includes(sandbox.dhmap.colour.UNKNOWN), + 'switches start UNKNOWN until statuses arrive'); + }); + + await t.test('skips Dist, Prod and Grid when sizing halls', () => { + // Those halls have no coordinates; only Hall 1 gets a bounding box drawn. + const sandbox = drawn(); + const hallBoxes = sandbox.shapes.filter( + (s) => typeof s.attrs.fill === 'string' && s.attrs.fill.startsWith('hsla(')); + assert.equal(hallBoxes.length, 1); + }); + + await t.test('lays two halls out side by side', () => { + const sandbox = drawn(topo.twoHalls()); + const labels = sandbox.shapes + .filter((s) => s.type === 'text' && /^Hall /.test(s.attrs.text)) + .map((s) => s.attrs.text); + assert.deepEqual(labels.sort(), ['Hall 1', 'Hall 2']); + }); + + await t.test('wires the click callback through to switches', () => { + const clicked = []; + const sandbox = drawn(topo.basic(), (object) => clicked.push(object.name)); + const withClick = sandbox.shapes.find((s) => s.handlers.click); + assert.ok(withClick, 'switches should have a click handler'); + withClick.handlers.click(); + assert.equal(clicked.length, 1); + }); + + // KNOWN DEFECT (dhmap.js:269 and :398): init renders every object twice, a + // dry pass to measure and a wet pass to draw, but renderSwitch mutates the + // object it is given. So the centering offsets are applied twice: width goes + // 5 -> 6.7 -> 8.4 rather than stopping at 6.7. + await t.test('applies switch centering offsets twice (known defect)', () => { + const topology = topo.basic(); + const target = topology['Hall 1'].find((o) => o.class === 'switch'); + assert.deepEqual( + { x1: target.x1, y1: target.y1, width: target.width }, + { x1: 15, y1: 25, width: 5 }); + + loadDhmap().dhmap.init(topology, () => {}); + + assert.equal(target.width, 8.4, '1.7 added twice, not once'); + assert.equal(target.height, 8.4); + assert.equal(target.y1, 16.05); + }); + + // KNOWN DEFECT (dhmap.js:358): halls are sized only from halls that have + // objects, but the third phase looks up every hall named in Grid. Adding a + // hall to hall_positions before its tables exist takes the map down. + await t.test('throws for a Grid hall with no objects (known defect)', () => { + const topology = topo.basic(); + topology['Grid'].push(topo.hallMarker('Hall 9', 1, 0)); + assert.throws(() => loadDhmap().dhmap.init(topology, () => {}), (err) => { + assert.equal(err.name, 'TypeError'); + return true; + }); + }); +}); + +test('dhmap.updateSwitches', async (t) => { + const switchName = 'd73-a.event.dreamhack.local'; + + /** The rectangle drawn for a switch (its label box is drawn separately). */ + function switchRect(sandbox) { + return sandbox.shapes.find( + (s) => s.type === 'rect' && s.handlers.click); + } + + await t.test('paints each switch with the colour for its status', () => { + const sandbox = drawn(); + sandbox.dhmap.updateSwitches({ [switchName]: 'CRITICAL' }); + assert.equal(switchRect(sandbox).attrs.fill, sandbox.dhmap.colour.CRITICAL); + }); + + await t.test('falls back to UNKNOWN for a switch with no status', () => { + const sandbox = drawn(); + sandbox.dhmap.updateSwitches({}); + assert.equal(switchRect(sandbox).attrs.fill, sandbox.dhmap.colour.UNKNOWN); + }); + + await t.test('repaints when the status changes', () => { + const sandbox = drawn(); + sandbox.dhmap.updateSwitches({ [switchName]: 'CRITICAL' }); + sandbox.dhmap.updateSwitches({ [switchName]: 'OK' }); + assert.equal(switchRect(sandbox).attrs.fill, sandbox.dhmap.colour.OK); + }); + + await t.test('leaves a search-highlighted switch alone', () => { + const sandbox = drawn(); + sandbox.dhmap.filter('d73-a'); + assert.equal(switchRect(sandbox).attrs.fill, '#0000ff'); + + sandbox.dhmap.updateSwitches({ [switchName]: 'CRITICAL' }); + assert.equal(switchRect(sandbox).attrs.fill, '#0000ff', + 'status updates must not clobber the highlight'); + }); +}); + +test('dhmap.filter', async (t) => { + await t.test('highlights a table by name, case insensitively', () => { + const sandbox = drawn(); + sandbox.dhmap.filter('d73'); + assert.equal(sandbox.dhmap.oldTableObject.attrs.fill, '#0000ff'); + }); + + await t.test('highlights a switch and its table together', () => { + const sandbox = drawn(); + sandbox.dhmap.filter('d73-a'); + assert.equal(sandbox.dhmap.oldSwitchObject.attrs.fill, '#0000ff'); + assert.equal(sandbox.dhmap.oldTableObject.attrs.fill, '#0000ff', + 'the switch\'s table is highlighted too'); + }); + + await t.test('restores the previous colour on the next search', () => { + const sandbox = drawn(); + sandbox.dhmap.filter('d73'); + sandbox.dhmap.filter('d74'); + const d73 = sandbox.shapes.find((s) => s.attrs.text === 'D73'); + assert.ok(d73, 'D73 label should exist'); + assert.equal(sandbox.dhmap.oldTableObject.attrs.fill, '#0000ff'); + }); + + await t.test('clears the highlight for an unknown name', () => { + const sandbox = drawn(); + sandbox.dhmap.filter('d73'); + sandbox.dhmap.filter('nosuchtable'); + assert.equal(sandbox.dhmap.oldTableObject, undefined); + assert.equal(sandbox.dhmap.oldSwitchObject, undefined); + }); + + // A switch name with no '-' makes indexOf return -1, and substr(0, -1) + // yields '', so the table lookup silently misses instead of erroring. + await t.test('finds no table for a switch name without a dash', () => { + const topology = topo.basic(); + topology['Hall 1'].push( + topo.accessSwitch('lonely.event.dreamhack.local', 'Hall 1', 30, 30)); + const sandbox = drawn(topology); + + sandbox.dhmap.filter('lonely'); + assert.ok(sandbox.dhmap.oldSwitchObject, 'the switch itself is found'); + assert.equal(sandbox.dhmap.oldTableObject, undefined, + 'substr(0, -1) gives an empty name, so no table matches'); + }); +}); diff --git a/test/unit/dhmon.test.js b/test/unit/dhmon.test.js new file mode 100644 index 0000000..914865f --- /dev/null +++ b/test/unit/dhmon.test.js @@ -0,0 +1,264 @@ +/** + * Characterization tests for dhmon.js - the status logic behind the map. + * + * These pin current behaviour, including known bugs, so they pass on a clean + * checkout and fail when behaviour changes. Where a test documents a defect + * rather than desired behaviour it says so explicitly. + */ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const { loadDhmon } = require('../helpers/load.js'); + +/** An interface entry as /analytics/switch.interfaces reports it. */ +function iface(overrides = {}) { + return { + trunk: 1, + status: 'up', + admin: 'up', + speed: '1000', + errors_in: 0, + errors_out: 0, + stp: 'ok', + lastoid: 1, + rx_10min: 0, + tx_10min: 0, + ...overrides, + }; +} + +test('checkIfaceSpeed', async (t) => { + const { checkIfaceSpeed } = loadDhmon(); + + await t.test('passes a healthy gigabit trunk', () => { + assert.equal(checkIfaceSpeed(null, {}, { 'ge-0/0/1': iface() }), true); + }); + + await t.test('fails any trunk linked at 10M', () => { + assert.equal( + checkIfaceSpeed(null, {}, { 'ge-0/0/1': iface({ speed: '10' }) }), false); + }); + + await t.test('allows 100M on ge- and GigabitEthernet ports', () => { + for (const name of ['ge-0/0/1', 'GigabitEthernet0/1']) { + assert.equal( + checkIfaceSpeed(null, {}, { [name]: iface({ speed: '100' }) }), true, + `${name} at 100M should pass`); + } + }); + + await t.test('fails 100M on ports expected to do more (xe-, fe-)', () => { + for (const name of ['xe-0/0/1', 'fe-0/0/1']) { + assert.equal( + checkIfaceSpeed(null, {}, { [name]: iface({ speed: '100' }) }), false, + `${name} at 100M should fail`); + } + }); + + await t.test('ignores access ports - sleeping clients rarely link full', () => { + assert.equal( + checkIfaceSpeed(null, {}, { 'ge-0/0/1': iface({ trunk: 0, speed: '10' }) }), + true); + }); + + await t.test('ignores non-ethernet interfaces', () => { + assert.equal( + checkIfaceSpeed(null, {}, { 'vlan.100': iface({ speed: '10' }) }), true); + }); + + await t.test('ignores ports that are down', () => { + assert.equal( + checkIfaceSpeed(null, {}, { 'ge-0/0/1': iface({ status: 'down', speed: '10' }) }), + true); + }); + + await t.test('passes when the switch has no data yet', () => { + assert.equal(checkIfaceSpeed(null, undefined, undefined), true); + assert.equal(checkIfaceSpeed(null, {}, undefined), true); + }); + + // Speeds arrive from SNMP as strings and are compared with ==, not ===, so + // a numeric speed compares equal too. Worth pinning: switching to === would + // silently stop flagging any backend that reports speed as a number. + await t.test('compares speed loosely, so 10 and "10" both match', () => { + assert.equal( + checkIfaceSpeed(null, {}, { 'ge-0/0/1': iface({ speed: 10 }) }), false); + assert.equal( + checkIfaceSpeed(null, {}, { 'ge-0/0/1': iface({ speed: '10' }) }), false); + }); +}); + +test('checkIfaceErrors', async (t) => { + await t.test('fails an up trunk with input or output errors', () => { + const { checkIfaceErrors } = loadDhmon(); + assert.equal( + checkIfaceErrors(null, {}, { 'ge-0/0/1': iface({ errors_in: 5 }) }), false); + assert.equal( + checkIfaceErrors(null, {}, { 'ge-0/0/1': iface({ errors_out: 5 }) }), false); + }); + + await t.test('passes a clean trunk', () => { + const { checkIfaceErrors } = loadDhmon(); + assert.equal(checkIfaceErrors(null, {}, { 'ge-0/0/1': iface() }), true); + }); + + await t.test('skips access ports until consumer mode is enabled', () => { + const sandbox = loadDhmon(); + const ifaces = { 'ge-0/0/1': iface({ trunk: 0, errors_in: 5 }) }; + + assert.equal(sandbox.checkIfaceErrors(null, {}, ifaces), true, + 'access port errors are hidden by default'); + + sandbox.consumerMode.checked = true; + assert.equal(sandbox.checkIfaceErrors(null, {}, ifaces), false, + 'consumer mode surfaces access port errors'); + }); +}); + +test('checkIfaceStp', async (t) => { + await t.test('fails an up trunk blocked by spanning tree', () => { + const { checkIfaceStp } = loadDhmon(); + assert.equal( + checkIfaceStp(null, {}, { 'ge-0/0/1': iface({ stp: 'error' }) }), false); + }); + + await t.test('passes when stp is healthy', () => { + const { checkIfaceStp } = loadDhmon(); + assert.equal(checkIfaceStp(null, {}, { 'ge-0/0/1': iface() }), true); + }); + + await t.test('skips access ports until consumer mode is enabled', () => { + const sandbox = loadDhmon(); + const ifaces = { 'ge-0/0/1': iface({ trunk: 0, stp: 'error' }) }; + + assert.equal(sandbox.checkIfaceStp(null, {}, ifaces), true); + sandbox.consumerMode.checked = true; + assert.equal(sandbox.checkIfaceStp(null, {}, ifaces), false); + }); +}); + +test('interface name regexes', async (t) => { + const { ifre, gere } = loadDhmon(); + + await t.test('ifre matches ethernet interfaces dhmap cares about', () => { + for (const name of ['ge-0/0/1', 'xe-1/2/3', 'fe-0/0/0', + 'GigabitEthernet0/1', 'TenGigabitEthernet1/1']) { + assert.ok(ifre.exec(name), `${name} should match ifre`); + } + }); + + await t.test('ifre ignores logical and management interfaces', () => { + for (const name of ['vlan.100', 'lo0', 'irb', 'me0']) { + assert.equal(ifre.exec(name), null, `${name} should not match ifre`); + } + }); + + await t.test('gere matches only gigabit-capable names', () => { + assert.ok(gere.exec('ge-0/0/1')); + assert.ok(gere.exec('GigabitEthernet0/1')); + assert.equal(gere.exec('xe-0/0/1'), null); + assert.equal(gere.exec('fe-0/0/1'), null); + }); +}); + +/** Drive computeStatus for a single switch under given conditions. */ +function statusFor({ ping = 0, snmpSince = 0, snmpMissing = false, + ifaces = {}, alert = false } = {}) { + const sw = 'sw.event.dreamhack.local'; + const sandbox = loadDhmon(); + sandbox.ping = { [sw]: ping }; + sandbox.snmp = snmpMissing ? {} : { [sw]: { since: snmpSince } }; + sandbox.model = { [sw]: {} }; + sandbox.iface = { [sw]: ifaces }; + sandbox.alert_hosts = alert ? { [sw]: true } : {}; + sandbox.start_fetch = new Date(); + sandbox.computeStatus(); + return { status: sandbox.switch_status[sw], sandbox }; +} + +test('computeStatus', async (t) => { + await t.test('reports OK for a healthy switch', () => { + assert.equal(statusFor({ ifaces: { 'ge-0/0/1': iface() } }).status, 'OK'); + }); + + await t.test('reports CRITICAL when ICMP has been silent 60s', () => { + assert.equal(statusFor({ ping: 60 }).status, 'CRITICAL'); + assert.equal(statusFor({ ping: 59 }).status, 'OK', '59s is still not critical'); + }); + + await t.test('reports STP, SPEED and ERRORS from the iface checks', () => { + assert.equal( + statusFor({ ifaces: { 'ge-0/0/1': iface({ stp: 'error' }) } }).status, 'STP'); + assert.equal( + statusFor({ ifaces: { 'ge-0/0/1': iface({ speed: '10' }) } }).status, 'SPEED'); + assert.equal( + statusFor({ ifaces: { 'ge-0/0/1': iface({ errors_in: 1 }) } }).status, 'ERRORS'); + }); + + await t.test('reports WARNING when snmp data is missing or stale', () => { + assert.equal(statusFor({ snmpMissing: true }).status, 'WARNING'); + assert.equal(statusFor({ snmpSince: 361 }).status, 'WARNING'); + assert.equal(statusFor({ snmpSince: 360 }).status, 'OK', '360 is the boundary'); + }); + + await t.test('reports ALERT when the host has a monitoring alert', () => { + assert.equal(statusFor({ alert: true }).status, 'ALERT'); + }); + + await t.test('applies branches in priority order', () => { + // A switch failing everything at once reports only the highest priority. + const everything = iface({ speed: '10', stp: 'error', errors_in: 5 }); + assert.equal(statusFor({ ping: 60, ifaces: { 'ge-0/0/1': everything } }).status, + 'CRITICAL', 'ping outranks all iface checks'); + assert.equal(statusFor({ ifaces: { 'ge-0/0/1': everything } }).status, + 'STP', 'stp outranks speed and errors'); + + const speedAndErrors = iface({ speed: '10', errors_in: 5 }); + assert.equal(statusFor({ ifaces: { 'ge-0/0/1': speedAndErrors } }).status, + 'SPEED', 'speed outranks errors'); + + assert.equal(statusFor({ snmpMissing: true, alert: true }).status, + 'WARNING', 'stale snmp outranks alerts'); + }); + + await t.test('does nothing until every data source has loaded', () => { + const sandbox = loadDhmon(); + sandbox.ping = { 'sw': 0 }; + sandbox.snmp = null; // still in flight + sandbox.model = {}; + sandbox.iface = {}; + sandbox.computeStatus(); + assert.deepEqual(sandbox.switch_status, {}, + 'partial data must not produce statuses'); + }); + + await t.test('pushes results to both the map and the menu', () => { + const { sandbox } = statusFor({ ifaces: { 'ge-0/0/1': iface() } }); + assert.equal(sandbox.updates.dhmap.length, 1); + assert.equal(sandbox.updates.dhmenu.length, 1); + assert.deepEqual(sandbox.updates.dhmap[0], sandbox.switch_status); + }); + + // KNOWN DEFECT (dhmon.js:137): alert_hosts is dereferenced but, unlike + // iface/model/snmp/ping, is not covered by the guard at :116. If the + // alerts.hosts fetch is slow or fails, computeStatus throws. Pinned here so + // the fix is a deliberate change rather than an accident. + await t.test('throws when alert_hosts has not loaded (known defect)', () => { + const sandbox = loadDhmon(); + const sw = 'sw.event.dreamhack.local'; + sandbox.ping = { [sw]: 0 }; + sandbox.snmp = { [sw]: { since: 0 } }; + sandbox.model = { [sw]: {} }; + sandbox.iface = { [sw]: {} }; + sandbox.alert_hosts = null; // fetch still in flight + sandbox.start_fetch = new Date(); + // Matched by name rather than constructor: the error originates inside the + // vm realm, whose TypeError is a different object to this one. + assert.throws(() => sandbox.computeStatus(), (err) => { + assert.equal(err.name, 'TypeError'); + assert.match(err.message, /null/); + return true; + }); + }); +}); From ecadf8816f95d4680d1ab8e6af35d156dd6dea90 Mon Sep 17 00:00:00 2001 From: soundgoof Date: Tue, 28 Jul 2026 09:10:49 +0200 Subject: [PATCH 2/5] Add browser test tiers, a fake monitoring backend and CI Completes the suite with the two tiers that need real browser machinery, plus the backend they talk to. The fake backend presents two faces over a single scenario: the Prometheus HTTP API, using the metric names the real rules in scripts/prometheus already use, and the seven analytics endpoints dhmon.js fetches. Because both are rendered from the same data they cannot disagree, and a test holds them to that. It also means `make serve` gives a populated map with no monitoring system present, which is the first time this repo has been runnable end to end locally. The browser tiers run in a container rather than installing anything on the host: jsdom and Playwright live in a podman volume and the browsers come preinstalled in the image. CI uses the same image, so the two environments match. The E2E tier drives the real dev server and follows the /analytics redirect rather than stubbing at the network layer. That redirect is a development-only artifact - production reverse-proxies /analytics same-origin - so it makes the fetches cross-origin and preflighted, and the fake backend answers OPTIONS accordingly. Testing with realistic hall names surfaced a defect worth noting: hall element ids are built by string concatenation, so a hall named "Hall 1" yields id="menu_hall_Hall 1", which jQuery parses as a descendant selector and never matches. Hall colour roll-up therefore does nothing for every hall in production. Pinned as a known defect rather than fixed here. The suite was checked for teeth: inverting the CRITICAL ping threshold, pointing the page at a missing vendor file, and changing a colour out of rgb() form each fail the relevant tier. --- .github/workflows/test.yml | 55 +++ Makefile | 76 +++ localserver.py | 10 +- package-lock.json | 619 +++++++++++++++++++++++++ package.json | 17 + playwright.config.js | 47 ++ test/data_source.py | 100 ++++ test/dom/dhmenu.test.js | 252 ++++++++++ test/e2e/dhmon.spec.js | 186 ++++++++ test/e2e/examples.spec.js | 48 ++ test/e2e/global-setup.js | 17 + test/fake/backend.py | 249 ++++++++++ test/fake/scenarios.py | 71 +++ test/fixtures/scenarios/degraded.json | 86 ++++ test/fixtures/scenarios/healthy.json | 86 ++++ test/fixtures/scenarios/saturated.json | 86 ++++ test/podman.sh | 38 ++ test/python/test_fake_backend.py | 232 +++++++++ test/python/test_localserver.py | 72 +++ 19 files changed, 2343 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 Makefile create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.js create mode 100644 test/data_source.py create mode 100644 test/dom/dhmenu.test.js create mode 100644 test/e2e/dhmon.spec.js create mode 100644 test/e2e/examples.spec.js create mode 100644 test/e2e/global-setup.js create mode 100644 test/fake/backend.py create mode 100644 test/fake/scenarios.py create mode 100644 test/fixtures/scenarios/degraded.json create mode 100644 test/fixtures/scenarios/healthy.json create mode 100644 test/fixtures/scenarios/saturated.json create mode 100755 test/podman.sh create mode 100644 test/python/test_fake_backend.py create mode 100644 test/python/test_localserver.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..80c415f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,55 @@ +name: test + +on: + push: + pull_request: + +jobs: + # The core tiers install nothing, so this job is fast and cannot be broken + # by a registry outage. It is also the job that proves a bare checkout works: + # there is no ipplan database here, so the real-data tests skip. + core: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: JS logic + run: make test-unit + + - name: Python, servers and fake backend + run: make test-py + + # jsdom, Playwright and a browser. Uses the same image as test/podman.sh so + # local and CI runs are the same environment. + browser: + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.62.0-noble + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: npm ci --no-audit --no-fund + + - name: Menu under jsdom + run: node --test "test/dom/*.test.js" + + - name: Full app in chromium + run: npx playwright test + + - if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + retention-days: 7 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..df41431 --- /dev/null +++ b/Makefile @@ -0,0 +1,76 @@ +# dhmap - development and test entry points. +# +# The core tiers need nothing but python3 and node, which is why they are kept +# dependency-free. The browser tiers need jsdom, Playwright and a browser, so +# they run in a container and install nothing on the host. + +PYTHON ?= python3 +NODE ?= node +PORT ?= 8000 +BACKEND_PORT ?= 5000 +SCENARIO ?= degraded + +PODMAN_RUN := ./test/podman.sh + +.PHONY: test test-unit test-py test-dom test-e2e serve data clean help + +help: + @echo 'make test run every tier whose dependencies are available' + @echo 'make test-unit JS logic (no install)' + @echo 'make test-py python + servers (no install)' + @echo 'make test-dom menu under jsdom (podman)' + @echo 'make test-e2e full app in chromium (podman)' + @echo 'make serve run the app locally with a fake backend' + @echo 'make clean remove generated outputs' + @echo + @echo 'Drop a real ipplan database in local/ to work against real data;' + @echo 'see local/README.md.' + +# Tier 1 - no dependencies beyond node and python3. +test-unit: + $(NODE) --test "test/unit/*.test.js" + +test-py: + $(PYTHON) -m unittest discover -s test/python -t test/python + +# Tiers 2 and 3 - containerised, so nothing is installed on the host. +test-dom: + $(PODMAN_RUN) '$(NODE) --test "test/dom/*.test.js"' + +test-e2e: + $(PODMAN_RUN) 'playwright test' + +# Runs what it can: the core tiers always, the browser tiers when podman is +# available. Keeps a bare checkout useful without failing on missing tools. +test: test-unit test-py + @if command -v podman >/dev/null 2>&1; then \ + $(MAKE) test-dom test-e2e; \ + else \ + echo; \ + echo 'podman not found - skipping the browser tiers (test-dom, test-e2e).'; \ + fi + +# data.json is a build output: generated from a real ipplan database when one +# is present, otherwise from the example. +data: + @$(PYTHON) test/data_source.py + +serve: data + @echo "map http://localhost:$(PORT)/dhmon.html" + @echo "example http://localhost:$(PORT)/src/examples/" + @echo "scenario $(SCENARIO)" + @echo + @$(PYTHON) test/fake/backend.py --port $(BACKEND_PORT) \ + --scenario $(SCENARIO) \ + --devices `$(PYTHON) test/data_source.py --devices` & \ + trap 'kill %1 2>/dev/null' EXIT INT TERM; \ + $(PYTHON) localserver.py --port $(PORT) \ + --analytics-port $(BACKEND_PORT) + +clean: + rm -f data.json + rm -rf test-results playwright-report + find . -name __pycache__ -type d -not -path './src/vendor/*' \ + -exec rm -rf {} + 2>/dev/null || true + @echo 'Removed generated outputs. The container dependency volume is' + @echo 'kept; remove it with: podman volume rm dhmap-deps' diff --git a/localserver.py b/localserver.py index e02f09d..7c4730e 100755 --- a/localserver.py +++ b/localserver.py @@ -33,16 +33,18 @@ def do_GET(self): def serve(port=DEFAULT_PORT, analytics_port=DEFAULT_ANALYTICS_PORT, - directory=None): + directory=None, quiet=False): """Create a server. Returns it without serving, so tests can drive it. Pass port=0 to bind an arbitrary free port; read it back from - httpd.server_address[1]. + httpd.server_address[1]. Pass quiet=True to suppress request logging. """ # Subclass per server so concurrent servers can target different backends; # setting the attribute on a functools.partial would not reach the class. - handler = type('BoundRevHandler', (RevHandler,), - {'analytics_port': analytics_port}) + overrides = {'analytics_port': analytics_port} + if quiet: + overrides['log_message'] = lambda self, *args: None + handler = type('BoundRevHandler', (RevHandler,), overrides) handler = functools.partial( handler, directory=directory or os.path.dirname(os.path.abspath(__file__))) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..daefee4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,619 @@ +{ + "name": "dhmap", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dhmap", + "version": "0.0.0", + "license": "BSD-3-Clause", + "devDependencies": { + "@playwright/test": "^1.62.0", + "jsdom": "^30.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz", + "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.2.1", + "@csstools/css-color-parser": "^4.1.9", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.0.tgz", + "integrity": "sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@playwright/test": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz", + "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.0.tgz", + "integrity": "sha512-JQHfRGmmKmaZoUAvIgff5jjG/0SzTQlGz8c7t72KzBzo8ZULEjAjnYE0sNwBOUA4QtWwYE2xoYitg8NFsmiYxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.2.5", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.6", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.7.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/playwright": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/undici": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1f854c4 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "dhmap", + "version": "0.0.0", + "private": true, + "description": "HTML5/JS library for drawing and updating network layouts", + "license": "BSD-3-Clause", + "scripts": { + "test": "make test", + "test:unit": "node --test \"test/unit/*.test.js\"", + "test:dom": "node --test \"test/dom/*.test.js\"", + "test:e2e": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.62.0", + "jsdom": "^30.0.0" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..a6040b8 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,47 @@ +// Browser tier configuration. +// +// Runs the real development server plus the fake monitoring backend, so the +// whole chain - page, /analytics redirect, backend - is exercised rather than +// stubbed at the network layer. +const { defineConfig, devices } = require('@playwright/test'); + +const APP_PORT = 8000; +const BACKEND_PORT = 5000; + +module.exports = defineConfig({ + testDir: './test/e2e', + // Always render the example, never whatever ipplan database happens to be + // sitting in local/ - these assertions name specific switches. + globalSetup: require.resolve('./test/e2e/global-setup.js'), + fullyParallel: false, // both specs share one fake backend + workers: 1, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', + + use: { + baseURL: `http://127.0.0.1:${APP_PORT}`, + trace: 'retain-on-failure', + }, + + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], + + webServer: [ + { + command: `python3 test/fake/backend.py --port ${BACKEND_PORT}` + + ' --scenario degraded', + url: `http://127.0.0.1:${BACKEND_PORT}/-/healthy`, + reuseExistingServer: !process.env.CI, + stdout: 'ignore', + }, + { + command: `python3 localserver.py --port ${APP_PORT}` + + ` --analytics-port ${BACKEND_PORT}`, + url: `http://127.0.0.1:${APP_PORT}/dhmon.html`, + reuseExistingServer: !process.env.CI, + stdout: 'ignore', + }, + ], +}); diff --git a/test/data_source.py b/test/data_source.py new file mode 100644 index 0000000..6335414 --- /dev/null +++ b/test/data_source.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Produce the data.json that dhmon.html fetches. + +Resolution order, used consistently by `make serve` and the browser tier: + + 1. $IPPLAN_DB + 2. local/ipplan.db + 3. local/ipplan.db.xz (decompressed to a temp directory) + 4. src/examples/data.json (the default, and what CI uses) + +The first three run the real generator, so local mode renders the real event +map. data.json is a build output: gitignored, and removed by `make clean`. +""" +import argparse +import json +import lzma +import os +import shutil +import subprocess +import sys +import tempfile + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +GENERATOR = os.path.join(REPO_ROOT, 'src', 'ipplan2dhmap.py') +EXAMPLE = os.path.join(REPO_ROOT, 'src', 'examples', 'data.json') + + +def find_database(allow_real=True): + """Return a usable ipplan database path, or None.""" + if not allow_real: + return None + candidates = [ + os.environ.get('IPPLAN_DB'), + os.path.join(REPO_ROOT, 'local', 'ipplan.db'), + os.path.join(REPO_ROOT, 'local', 'ipplan.db.xz'), + ] + for path in candidates: + if not path or not os.path.exists(path): + continue + if not path.endswith('.xz'): + return path + target = os.path.join(tempfile.mkdtemp(prefix='dhmap-ipplan-'), + 'ipplan.db') + with lzma.open(path) as src, open(target, 'wb') as dst: + shutil.copyfileobj(src, dst) + return target + return None + + +def build(output, allow_real=True): + """Write data.json to `output`. Returns a description of the source used.""" + database = find_database(allow_real) + if database: + result = subprocess.run([sys.executable, GENERATOR, database], + capture_output=True, text=True) + if result.returncode != 0: + raise SystemExit('generator failed:\n%s' % result.stderr) + with open(output, 'w') as fp: + fp.write(result.stdout) + return 'real ipplan database' + + shutil.copyfile(EXAMPLE, output) + return 'src/examples/data.json' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', default=os.path.join(REPO_ROOT, 'data.json')) + parser.add_argument('--devices', action='store_true', + help='print the switch names instead of writing a file') + parser.add_argument('--source', choices=['auto', 'example'], default='auto', + help='auto uses a real database when one is present; ' + 'example always uses src/examples/data.json, which ' + 'is what the browser tier needs so its assertions ' + 'stay deterministic (default: %(default)s)') + args = parser.parse_args() + allow_real = args.source == 'auto' + + if args.devices: + database = find_database(allow_real) + if database: + result = subprocess.run([sys.executable, GENERATOR, database], + capture_output=True, text=True) + objects = json.loads(result.stdout) + else: + with open(EXAMPLE) as fp: + objects = json.load(fp) + for hall in objects.values(): + for obj in hall: + if obj['class'] == 'switch': + print(obj['name']) + return + + source = build(args.output, allow_real) + print('wrote %s from %s' + % (os.path.relpath(args.output, REPO_ROOT), source)) + + +if __name__ == '__main__': + main() diff --git a/test/dom/dhmenu.test.js b/test/dom/dhmenu.test.js new file mode 100644 index 0000000..087653d --- /dev/null +++ b/test/dom/dhmenu.test.js @@ -0,0 +1,252 @@ +/** + * Characterization tests for src/dhmenu.js, the switch list beside the map. + * + * dhmenu is pure jQuery over a static DOM - no Raphael - so jsdom is enough. + * The vendored jQuery is loaded rather than a stub, which makes this suite a + * genuine check when the jQuery version is bumped. + * + * dhmenu cannot load standalone: it reads dhmap.colour and errors_to_human, + * the latter defined in dhmon.js, so both are injected here. + */ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const { JSDOM } = require('jsdom'); + +const REPO_ROOT = path.resolve(__dirname, '..', '..'); +const topo = require('../fixtures/topology.js'); + +const JQUERY = path.join(REPO_ROOT, + 'src/vendor/jquery-3.7.1/jquery-3.7.1.min.js'); +const DHMENU = path.join(REPO_ROOT, 'src/dhmenu.js'); +const DHMAP = path.join(REPO_ROOT, 'src/dhmap.js'); +const DHMON = path.join(REPO_ROOT, 'dhmon.js'); + +/** Pull dhmap.colour and errors_to_human out of the sources, as dhmenu sees them. */ +function crossFileGlobals() { + const vm = require('node:vm'); + const context = { window: {}, document: undefined }; + context.window = context; + vm.createContext(context); + // dhmap.js is an IIFE that only needs a global object to attach to. + vm.runInContext(fs.readFileSync(DHMAP, 'utf8').replace( + /dhmap\.init\s*=[\s\S]*$/, '})();'), context, { filename: DHMAP }); + const errorsSource = fs.readFileSync(DHMON, 'utf8') + .match(/var errors_to_human = \{[\s\S]*?\};/)[0]; + vm.runInContext(errorsSource, context); + return { colour: context.dhmap.colour, errors: context.errors_to_human }; +} + +const GLOBALS = crossFileGlobals(); + +/** Build a DOM with jQuery and dhmenu loaded, and the menu written. */ +function menu(topology = topo.basic()) { + const dom = new JSDOM( + ` + + `, + { runScripts: 'dangerously' }); + + const { window } = dom; + window.eval(fs.readFileSync(JQUERY, 'utf8')); + // Injected because dhmenu reads them from other files at call time. + window.dhmap = { colour: GLOBALS.colour, filter() {} }; + window.errors_to_human = GLOBALS.errors; + window.eval(fs.readFileSync(DHMENU, 'utf8')); + + window.dhmenu.init(topology, () => {}); + return window; +} + +const $of = (window) => window.$; + +/** basic(), with its hall renamed - used to isolate the space-in-id defect. */ +function renamedHall(name) { + const topology = topo.basic(); + topology[name] = topology['Hall 1'].map((o) => ({ ...o, hall: name })); + delete topology['Hall 1']; + topology['Grid'] = topology['Grid'].map((g) => ({ ...g, name })); + return topology; +} + +test('dhmenu.write', async (t) => { + await t.test('lists every hall that has switches', () => { + const window = menu(); + const halls = [...window.document.querySelectorAll('li[id^=menu_hall_]')] + .map((li) => li.id); + assert.ok(halls.includes('menu_hall_Hall 1')); + assert.ok(halls.includes('menu_hall_Dist')); + assert.ok(halls.includes('menu_hall_Prod')); + }); + + await t.test('lists switches by their short upper-case name', () => { + const window = menu(); + const ids = [...window.document.querySelectorAll('li[id^=menu_switch_]')] + .map((li) => li.id); + assert.ok(ids.includes('menu_switch_D73-A'), + 'd73-a.event.dreamhack.local should become D73-A'); + }); + + await t.test('tags each switch with its hall, table and status', () => { + const window = menu(); + const li = window.document.getElementById('menu_switch_D73-A'); + assert.equal(li.getAttribute('data-hall'), 'Hall 1'); + assert.equal(li.getAttribute('data-table'), 'D73'); + assert.equal(li.getAttribute('data-status'), 'UNKNOWN'); + }); + + await t.test('does not list tables, only switches', () => { + const window = menu(); + assert.equal(window.document.getElementById('menu_switch_D73'), null); + }); + + await t.test('drops the Grid hall, which never holds switches', () => { + const window = menu(); + assert.equal(window.document.getElementById('menu_hall_Grid'), null); + }); + + // dhmenu.write reorders and deletes keys on the object it is handed, so + // dhmon.js:322-323 must call dhmap.init first. Reversing that order would + // leave dhmap.init without Grid, which it dereferences at :284. + await t.test('mutates the caller\'s objects, removing Grid', () => { + const topology = topo.basic(); + assert.ok(topology['Grid'], 'Grid present before'); + menu(topology); + assert.equal(topology['Grid'], undefined, + 'Grid is deleted, so dhmap.init must run first'); + }); + + await t.test('moves Dist and Prod to the end of the list', () => { + const window = menu(); + const halls = [...window.document.querySelectorAll('li[id^=menu_hall_]')] + .map((li) => li.id); + assert.deepEqual(halls.slice(-2), ['menu_hall_Dist', 'menu_hall_Prod']); + }); +}); + +test('dhmenu.updateSwitches', async (t) => { + await t.test('records the status on the switch element', () => { + const window = menu(); + window.dhmenu.updateSwitches( + { 'd73-a.event.dreamhack.local': 'CRITICAL' }); + assert.equal( + window.document.getElementById('menu_switch_D73-A') + .getAttribute('data-status'), 'CRITICAL'); + }); + + await t.test('accepts both fqdn and short names', () => { + const window = menu(); + window.dhmenu.updateSwitches({ 'D73-A': 'OK' }); + assert.equal( + window.document.getElementById('menu_switch_D73-A') + .getAttribute('data-status'), 'OK'); + }); + + await t.test('colours the switch with dhmap.colour plus alpha', () => { + const window = menu(); + window.dhmenu.updateSwitches( + { 'd73-a.event.dreamhack.local': 'CRITICAL' }); + const style = $of(window)('#menu_switch_D73-A').css('background-color'); + // rgb(255,0,0) -> rgb(255,0,0,0.7); this is why dhmap.colour must stay + // in rgb() form rather than hex. + assert.match(style.replace(/\s/g, ''), /255,0,0/); + }); + + await t.test('sets a human readable title from errors_to_human', () => { + const window = menu(); + window.dhmenu.updateSwitches( + { 'd73-a.event.dreamhack.local': 'CRITICAL' }); + assert.equal( + window.document.getElementById('menu_switch_D73-A').getAttribute('title'), + GLOBALS.errors.CRITICAL); + }); + + await t.test('defaults a switch with no status to UNKNOWN', () => { + const window = menu(); + const statuses = { 'd73-a.event.dreamhack.local': undefined }; + window.dhmenu.updateSwitches(statuses); + assert.equal(statuses['d73-a.event.dreamhack.local'], 'UNKNOWN', + 'the caller\'s object is filled in, not just the DOM'); + }); + + await t.test('rolls a CRITICAL switch up to its hall', () => { + const window = menu(renamedHall('HallA')); + window.dhmenu.updateSwitches( + { 'd73-a.event.dreamhack.local': 'CRITICAL' }); + assert.equal( + window.document.getElementById('menu_hall_HallA') + .getAttribute('data-status'), 'CRITICAL'); + }); + + await t.test('does not downgrade a hall from CRITICAL to OK', () => { + const window = menu(renamedHall('HallA')); + window.dhmenu.updateSwitches({ + 'd73-a.event.dreamhack.local': 'CRITICAL', + 'd74-a.event.dreamhack.local': 'OK', + }); + assert.equal( + window.document.getElementById('menu_hall_HallA') + .getAttribute('data-status'), 'CRITICAL', + 'the worst status in a hall should win'); + }); + + // KNOWN DEFECT (dhmenu.js:107-115): the hall element id is built by + // concatenation, so a hall named "Hall 1" produces id="menu_hall_Hall 1". + // jQuery parses "#menu_hall_Hall 1" as a descendant selector and matches + // nothing, so the roll-up silently does nothing - and every production hall + // is named this way. The switch itself still colours correctly. + await t.test('never rolls up to a hall whose name has a space (known defect)', + () => { + const window = menu(); // basic() uses "Hall 1" + window.dhmenu.updateSwitches( + { 'd73-a.event.dreamhack.local': 'CRITICAL' }); + + const hall = window.document.getElementById('menu_hall_Hall 1'); + assert.ok(hall, 'the element exists'); + assert.equal(hall.getAttribute('data-status'), null, + 'no status is ever recorded on a hall with a space in its name'); + assert.equal($of(window)('#menu_hall_Hall 1').length, 0, + 'because the selector matches nothing'); + }); +}); + +test('dhmenu.filter', async (t) => { + // filter() hides switches both directly and by folding the hall's