From ad43352b09a072151bab2e75231abb54729dc97f Mon Sep 17 00:00:00 2001 From: catborise Date: Sun, 25 Oct 2026 21:24:56 +0300 Subject: [PATCH] fix(instances): the address search shows host names as they are The dialog filled its texts with String.replace, which reads $& and $' in the replacement as patterns: a host named c$&1 showed as c%(hosts)s1. The values now go in through a function, which inserts them as they are. --- instances/tests/test_find_address_js.py | 50 +++++++++++++++++++++++++ static/js/find_address.js | 17 ++++++--- 2 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 instances/tests/test_find_address_js.py diff --git a/instances/tests/test_find_address_js.py b/instances/tests/test_find_address_js.py new file mode 100644 index 00000000..e903b981 --- /dev/null +++ b/instances/tests/test_find_address_js.py @@ -0,0 +1,50 @@ +"""The address search dialog's script (static/js/find_address.js) fills its +texts with what the search answered, taken as plain text.""" + +import json +import shutil +import subprocess +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parents[2] / "static" / "js" / "find_address.js" + +RUN = """ +const vm = require("vm"); +const text = () => ({textContent: "", classList: {toggle() {}}}); +const handlers = {}; +const ids = { + "find-address": {dataset: DATA, addEventListener() {}}, + "find-address-form": {elements: {q: {value: "192.0.2.10"}}, querySelector: () => ({}), + addEventListener: (name, fn) => { handlers[name] = fn; }}, + "find-address-error": text(), + "find-address-results": {tBodies: [{replaceChildren() {}}], classList: {toggle() {}}}, + "find-address-coverage": text(), +}; +const answer = ANSWER; +vm.runInNewContext(SCRIPT, vm.createContext({ + document: {getElementById: (id) => ids[id]}, URLSearchParams, Promise, Date, location: {}, + fetch: () => Promise.resolve({redirected: false, json: () => Promise.resolve(answer)}), +})); +handlers.submit({preventDefault() {}}); +setTimeout(() => console.log(JSON.stringify(ids["find-address-coverage"].textContent)), 50); +""" + +DATA = {"url": "/find", "failed": "failed", "none": "None found.", "noneMac": "No MAC.", "unregistered": "(unregistered)", + "conflict": "conflict", "checked": "Checked: %(n)s.", "noAgent": "No agent: %(n)s.", + "unread": "Unread: %(n)s.", "hostsDown": "Not reached: %(hosts)s.", + "hostsUnfinished": "Not finished: %(hosts)s.", "readAt": "Read.", "someUnchecked": "Some unchecked."} + + +@unittest.skipUnless(shutil.which("node"), "node is not installed") +class FindAddressScriptTests(unittest.TestCase): + def test_host_names_are_put_in_as_they_are(self): + # String.replace reads $& and $' in a replacement as patterns + answer = {"matches": [], "read_at": "2026-10-25T10:00:00Z", + "coverage": {"checked": 2, "hosts_down": ["c$&1"], "hosts_unfinished": ["d$'2"]}} + js = (RUN.replace("SCRIPT", json.dumps(SCRIPT.read_text())).replace("DATA", json.dumps(DATA)) + .replace("ANSWER", json.dumps(answer))) + out = subprocess.run(["node", "-e", js], capture_output=True, text=True, timeout=30) + self.assertEqual(out.returncode, 0, out.stderr) + self.assertEqual(json.loads(out.stdout), + "None found. Checked: 2. Not reached: c$&1. Not finished: d$'2. Read.") diff --git a/static/js/find_address.js b/static/js/find_address.js index cf01bb50..2b15c3f3 100644 --- a/static/js/find_address.js +++ b/static/js/find_address.js @@ -11,6 +11,11 @@ var text = dialog.dataset; var MAC = /^[0-9a-f]{2}([:-]?)[0-9a-f]{2}(\1[0-9a-f]{2}){4}$/i; // as instances/addresses.py reads one + // a text with its placeholder filled; a function keeps $& and $' in the value as they are + function fill(template, placeholder, value) { + return template.replace(placeholder, function () { return value; }); + } + function cell(row, content) { var td = document.createElement("td"); if (content instanceof Node) { @@ -33,18 +38,18 @@ } function coverageText(c) { - var lines = [text.checked.replace("%(n)s", c.checked)]; + var lines = [fill(text.checked, "%(n)s", c.checked)]; if (c.no_agent) { - lines.push(text.noAgent.replace("%(n)s", c.no_agent)); + lines.push(fill(text.noAgent, "%(n)s", c.no_agent)); } if (c.unread) { - lines.push(text.unread.replace("%(n)s", c.unread)); + lines.push(fill(text.unread, "%(n)s", c.unread)); } if (c.hosts_down && c.hosts_down.length) { - lines.push(text.hostsDown.replace("%(hosts)s", c.hosts_down.join(", "))); + lines.push(fill(text.hostsDown, "%(hosts)s", c.hosts_down.join(", "))); } if (c.hosts_unfinished && c.hosts_unfinished.length) { - lines.push(text.hostsUnfinished.replace("%(hosts)s", c.hosts_unfinished.join(", "))); + lines.push(fill(text.hostsUnfinished, "%(hosts)s", c.hosts_unfinished.join(", "))); } if (c.some_unchecked) { lines.push(text.someUnchecked); @@ -76,7 +81,7 @@ }); table.classList.toggle("d-none", matches.length === 0); if (answer.coverage) { - var readAt = text.readAt.replace("%(time)s", new Date(answer.read_at).toLocaleTimeString()); + var readAt = fill(text.readAt, "%(time)s", new Date(answer.read_at).toLocaleTimeString()); var none = MAC.test(form.elements.q.value.trim()) ? text.noneMac : text.none; coverage.textContent = (matches.length ? "" : none + " ") + coverageText(answer.coverage) + " " + readAt; } else {