Files
technitium-console/dev/check-parity-controls.mjs
T
DenozordecandClaude Sonnet 5.5 f3531c64f9 Move the console onto ReUI PRO
Install ReUI (MCP, skill, @reui registry, Tailwind v4, shadcn base-nova) and move
the shared UI onto it: Button, Tag, Check, Input/Textarea, Segmented, Alert, Panel,
Dialog, Tooltip, Menu and Select (Popover), Toast (sonner), the Table frame
(DataGrid), the Dashboard KPI strip, the shnt Ops palette, Geist and the type scale.

Design only: behaviour, texts and roles are unchanged. Tests that asserted the old
DOM were updated; the tests now format numbers in English wherever they run.
docs/reui-migration-plan.md records the plan and its decisions.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 00:06:06 +07:00

430 lines
20 KiB
JavaScript

/*
Have we dropped any of upstream's destinations?
The review plan said "open the same screen in `technitium-ui-ref` and compare by
eye". Things slip past the eye, and they did, big ones: the About panel had lost
eight of its nine links, and the whole `body` footer, with the author's support
and donation addresses inside it.
This does it by list, and without a browser: upstream's console is a single
`index.html` with ALL its panels in the markup, so its destinations are read from
the HTML; ours are read from the application code. No new dependencies (putting
Playwright in `package.json` would show up in the pull request diff), and
therefore runnable anywhere with the reference console up.
node dev/check-parity-controls.mjs
REF=http://other:5381 node dev/check-parity-controls.mjs
What it does NOT answer, worth having in front of you before believing a green:
- **It searches ALL the code, not the screen in question.** If an explanation
appears in two dialogs and falls out of one, this stays green. Verified: the
DNSKEY TTL text was deliberately removed from "Sign Zone" and it said nothing,
because the same text lives in "DNSSEC Properties". It bites on what exists in
only one place, which is how things actually get lost: the About panel lost
eight links and none of them was anywhere else.
- Whether a destination present in both points to the same thing from the
equivalent screen.
- Whether a button that exists in both does the same thing. For behaviour there
is `check-parity-actions.sh`, which compares server state.
*/
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
const REF = process.env.REF ?? 'http://127.0.0.1:5381'
const SOURCE = new URL('../src/', import.meta.url).pathname
/* Destinations upstream has and that must NOT be here, with their reason. Every
line is a decision, not an oversight: if it is not justified, it is a finding. */
const EXCUSED = new Map([
// Today this list is deliberately empty: it is left written so that whoever
// adds an excuse has to reason it out here.
])
function files(dir) {
const out = []
for (const n of readdirSync(dir)) {
const p = join(dir, n)
if (statSync(p).isDirectory()) out.push(...files(p))
else if (/\.(tsx?|css)$/.test(n)) out.push(p)
}
return out
}
const normalise = (u) => u.replace(/\/$/, '').replace(/^http:/, 'https:')
/** The four entities that appear in upstream's texts. */
const decode = (t) =>
t
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
/*
The source WITHOUT comments.
A comment is not interface: nobody reads it on screen. While they were in Spanish
it made no difference, because they could not match an English sentence from
upstream; once translated to English it stopped making no difference: a comment
quoting the help text it explains would make a REALLY missing help text match,
and this check would go green for the opposite reason to the one it exists for.
They are stripped before comparing. String literals in the code stay, since that
is exactly where the text being searched for lives.
*/
const withoutComments = (t) =>
t.replaceAll(/\/\*[\s\S]*?\*\//g, ' ').replaceAll(/^\s*\/\/[^\n]*/gm, ' ')
const ours = files(SOURCE)
.filter((f) => !/\.test\./.test(f))
.map((f) => withoutComments(readFileSync(f, 'utf8')))
.join('\n')
const html = await fetch(`${REF}/`).then((r) => {
if (!r.ok) throw new Error(`the reference console answers ${r.status} at ${REF}`)
return r.text()
})
/* Upstream's destinations, with the screen they appear on. */
const theirs = new Map()
for (const m of html.matchAll(/href="((?:https?:|mailto:)[^"]+)"/g)) {
const url = normalise(m[1])
if (!theirs.has(url)) theirs.set(url, [])
/* The nearest container with an id, searching backwards. It is a HINT, not a
truth: upstream's markup does not close a modal before the next one starts,
so it serves to go and find it, not to cite it. */
const before = html.slice(0, m.index)
const near = [...before.matchAll(/id="(modal\w+|\w*TabPane\w+|footer)"/g)].pop()
theirs.get(url).push(near ? near[1] : '?')
}
/*
The application's text, reduced to words.
Comparing markup does not work here. JSX splits sentences (`Add{' '}<code>!</code>
character`) and many explanations travel as an ATTRIBUTE (`help="The duration for
which…"`), so stripping the tags to clean up takes with it the very text you came
looking for. Both produced false positives: the first accused the ACL text of
being lost, the second three more that had been in place for months.
Reducing both sides to bare words (no punctuation, no case, no symbols) removes
all of that difference. It adds noise (class names, attributes), but noise can
only produce a false NEGATIVE across eight consecutive words, and that does not
happen.
*/
const asWords = (t) =>
' ' +
t
.toLowerCase()
.replace(/&quot;|&#39;|&apos;/g, ' ')
.replace(/[^a-z0-9]+/g, ' ')
.trim() +
' '
/* JSX writes upstream's inline markup as markup too (`bind to <code>[::]</code>
local`), and the tag names would sit between the words as `code code`. Only
the attribute-free inline tags are dropped: a tag WITH attributes may be
carrying the very `help="…"` being looked for. Links are the exception: their
attributes are addresses, never explanations. */
const prose = asWords(
ours
.replace(/<\/?(?:code|b|i|em|strong|kbd|br\s*\/?)>/g, ' ')
.replace(/<\/?(?:Link|External|a)\b[^>]*>/g, ' ')
.replace(/\{' '\}/g, ' ')
// A bare JSX expression (`{hosts.doh}`) is a value filled at runtime, not
// prose; its name would otherwise sit between the words. Upstream's side
// drops its runtime labels the same way (see NOTES).
.replace(/\{[A-Za-z_$][\w.$]*\}/g, ' ')
// So is a template literal (`{`proxy_set_header ${realIpHeader} $remote_addr;`}`),
// where upstream has a `<code id="lbl…">` its script fills.
.replace(/\{`[^`]*`\}/g, ' '),
)
const missing = []
for (const [url] of theirs) {
if (EXCUSED.has(url)) continue
// the destination is searched for as-is and without the trailing slash: in the
// code it may be wrapped by the formatter, but a URL is never split.
const raw = url.replace(/^https:/, 'http:')
if (ours.includes(url) || ours.includes(raw) || ours.includes(`${url}/`)) continue
missing.push(url)
}
const total = theirs.size
for (const url of missing) {
console.log(` MISSING ${url} (upstream, near: ${[...new Set(theirs.get(url))].join(', ')})`)
}
console.log(
missing.length === 0
? `DESTINATION PARITY: all ${total} of upstream's are in the new console.`
: `DESTINATION PARITY: ${missing.length} of ${total} missing.`,
)
/*
And the help texts. Upstream does not mark them with a class of its own: they are
`div`s carrying the Bootstrap grid offset (`col-sm-offset-N col-sm-M`), which
turns out to be a reliable signature because nothing else goes there.
They are compared by a run of words from the middle, not by the whole sentence:
the beginning and the end are what gets reworded most while laying out, and what
matters is whether the explanation is there or not.
*/
const HELP = [...html.matchAll(/<div class="col-sm-offset-\d+ col-sm-\d+"[^>]*>([\s\S]*?)<\/div>/g)]
/* A `<span id="lbl…">` or `<code id="lbl…">` inside a note is a placeholder
`loadDnsSettings` overwrites on every load (main.js:1369-1372: the DoH, DoT,
DoQ and DoH(S) addresses). Its initial text is not contract, so it is not
compared. */
.map((m) => m[1].replace(/<(span|code) id="lbl\w+">[\s\S]*?<\/\1>/g, ' '))
.map((t) => t.replace(/<[^>]*>/g, ' ').replace(/&nbsp;/g, ' '))
.map((t) => decode(t).replace(/\s+/g, ' ').trim())
.filter((t) => t.length > 40)
const missingHelp = HELP.filter((t) => {
const words = asWords(t).trim().split(' ')
if (words.length < 12) return false
// three runs spread out: if NONE is there, the explanation is not there
const runs = [words.slice(2, 10), words.slice(6, 14), words.slice(-8)]
return !runs.some((p) => prose.includes(` ${p.join(' ')} `))
})
console.log('')
for (const t of missingHelp) console.log(` MISSING help: ${t.slice(0, 120)}…`)
console.log(
missingHelp.length === 0
? `HELP PARITY: all ${HELP.length} of upstream's texts are present.`
: `HELP PARITY: ${missingHelp.length} of ${HELP.length} missing.`,
)
/*
And each field's EXAMPLE, which is what upstream puts in `placeholder`.
Added after finding thirty-four of them lost, and some were not decoration: the
one on "Add Zone" read `example.com or 192.168.0.0/24 or 2001:db8::/64`, which is
how you discover that field takes a reverse zone in CIDR form, and the one on
"Certificate Association Data" carried the example hex and the PEM alternative.
Without them the field does not explain what it expects.
It compares by VALUE against the whole source, with the same limitation as the
other two checks and for the same reason: working out which component each field
ends up in would require understanding the JSX. That lets through the case where
the example is present but on another field (it happened with "confirm password",
which existed in "Add User" and was missing from "Change Password"), so a value
repeated across several fields has to be looked at by eye. What it does catch,
and what was needed, is the wholesale loss.
*/
const EXAMPLES = new Map()
for (const m of html.matchAll(/<(?:input|textarea)[^>]*>/g)) {
const ph = /placeholder="([^"]*)"/.exec(m[0])
if (!ph) continue
const id = /id="([^"]*)"/.exec(m[0])
/* Upstream's image was built on Windows and its multi-line examples carry
CRLF; our tree is LF. It is the same normalisation `dev/check-parity.sh`
does, and without it the four multi-line examples came out as lost over a
carriage return. */
const value = decode(ph[1]).replace(/\r\n/g, '\n')
if (value.trim().length < 1) continue
if (!EXAMPLES.has(value)) EXAMPLES.set(value, id ? id[1] : '?')
}
/* The forwarder example is rewritten by `zone.js:139-152` depending on the
protocol, and the same is done here in `forwarderExample`: the one in the HTML
is only the initial value. */
const DYNAMIC = new Set(['8.8.8.8'])
const missingExample = [...EXAMPLES].filter(
([value]) => !DYNAMIC.has(value) && !ours.includes(value.split('\n')[0]),
)
console.log('')
for (const [value, id] of missingExample) {
console.log(` MISSING example for ${id}: ${value.split('\n')[0].slice(0, 60)}`)
}
console.log(
missingExample.length === 0
? `EXAMPLE PARITY: all ${EXAMPLES.size} of upstream's are present.`
: `EXAMPLE PARITY: ${missingExample.length} of ${EXAMPLES.size} missing.`,
)
/*
The notes and the explanations that are NOT on the grid offset.
HELP above only reads `col-sm-offset-*` divs, and on 2026-09-30 it said "all 110
of upstream's texts are present" against a v15.5.1 whose new LDAP pane was not
here at all: its eleven "Note!"/"Warning!" paragraphs are `<p>`, and its field
explanations are `<div style="padding-top: 5px;">`. Neither shape was read. Both
are now, with the same three-runs comparison.
*/
const NOTES = [
...html.matchAll(/<p>([\s\S]*?)<\/p>/g),
...html.matchAll(/<div style="padding-top: 5px;[^"]*">([\s\S]*?)<\/div>/g),
// and the third shape, the Zone Options tabs': `<div><b>Note!</b> …</div>`
...html.matchAll(/<div>\s*(<b>(?:Note|Warning)!<\/b>[\s\S]*?)<\/div>/g),
]
/* A `<span id="lbl…">` or `<code id="lbl…">` inside a note is a placeholder
`loadDnsSettings` overwrites on every load (main.js:1369-1372: the DoH, DoT,
DoQ and DoH(S) addresses). Its initial text is not contract, so it is not
compared. */
.map((m) => m[1].replace(/<(span|code) id="lbl\w+">[\s\S]*?<\/\1>/g, ' '))
.map((t) => t.replace(/<[^>]*>/g, ' ').replace(/&nbsp;/g, ' '))
.map((t) => decode(t).replace(/\s+/g, ' ').trim())
.filter((t, i, all) => t.length > 40 && all.indexOf(t) === i)
/* Texts upstream has and this console deliberately does not, with the reason.
Each entry is a run of words, matched whole. */
const EXCUSED_NOTES = [
// deviation 1 in CONVENTIONS.md: Amber is not offered. No note names it today;
// the entry stays so that one which does is excused for that reason and no other.
'amber theme',
]
/* A run only proves its own note when no other upstream note shares it. v15.6.1
split the LDAP server note into "On Windows, the domain name used for LDAP
Server above ..." and "On Linux, the domain name used for LDAP Server above
...", and the Linux one passed on the run it shares with the Windows one while
it was not here at all. */
const noteWords = NOTES.map((t) => asWords(t))
const distinctive = (run) => noteWords.filter((w) => w.includes(` ${run} `)).length === 1
const missingNotes = NOTES.filter((t) => {
const words = asWords(t).trim().split(' ')
if (words.length < 12) return false
if (EXCUSED_NOTES.some((e) => asWords(t).includes(` ${e} `) && words.length < 40)) return false
const all = [words.slice(2, 10), words.slice(6, 14), words.slice(-8)].map((p) => p.join(' '))
const runs = all.filter(distinctive)
/* And its last eight words, always. A run proves the note is there, not that it
is whole: v15.6.2 added "and require a manual resync" to the end of the SOA
serial warning, and every run of the old text still matched. */
const end = words.slice(-8).join(' ')
return !(runs.length ? runs : all).some((p) => prose.includes(` ${p} `)) || !prose.includes(` ${end} `)
})
console.log('')
for (const t of missingNotes) console.log(` MISSING note: ${t.slice(0, 120)}…`)
console.log(
missingNotes.length === 0
? `NOTE PARITY: all ${NOTES.length} of upstream's notes and explanations are present.`
: `NOTE PARITY: ${missingNotes.length} of ${NOTES.length} missing.`,
)
/*
And the short texts of a control: a radio's option, a modal's title, an entry of
the account menu.
None of the passes above reads them (they are neither links, nor help, nor
examples, nor notes), so a whole dialog could go missing and every one of them
stay green. It is exactly what happened to `Change Theme`: absent by decision
until 2026-10-01, and nothing here could have told whether it came back whole.
They are compared as whole runs of words, against our source without comments.
*/
const accountMenu = html.slice(html.indexOf('<ul class="dropdown-menu">'))
const CONTROL_TEXTS = [
...[...html.matchAll(/<input type="radio"[^>]*>\s*([^<]+?)\s*<\/label>/g)].map((m) => ['radio', m[1]]),
...[...html.matchAll(/<h4 class="modal-title"[^>]*>([^<]+)<\/h4>/g)].map((m) => ['modal title', m[1]]),
...[...accountMenu.slice(0, accountMenu.indexOf('</ul>')).matchAll(/>([^<>]+)<\/a>/g)].map((m) => ['account menu', m[1]]),
]
.map(([kind, t]) => [kind, decode(t).replace(/\s+/g, ' ').trim()])
.filter(([, t], i, all) => t.length > 0 && all.findIndex(([, u]) => u === t) === i)
/* Upstream's control texts this console deliberately does not have, with the reason. */
const EXCUSED_TEXTS = new Map([
['Amber Theme', 'deviation 1 in CONVENTIONS.md: Amber is not offered in Change Theme'],
])
const missingText = CONTROL_TEXTS.filter(([, t]) => !EXCUSED_TEXTS.has(t) && !prose.includes(asWords(t)))
console.log('')
for (const [kind, t] of missingText) console.log(` MISSING ${kind}: ${t}`)
for (const [t, why] of EXCUSED_TEXTS) console.log(` EXCUSED ${t} (${why})`)
console.log(
missingText.length === 0
? `CONTROL TEXT PARITY: all ${CONTROL_TEXTS.length - EXCUSED_TEXTS.size} of upstream's radio options, modal titles and account-menu entries are present; ${EXCUSED_TEXTS.size} excused (EXCUSED_TEXTS).`
: `CONTROL TEXT PARITY: ${missingText.length} of ${CONTROL_TEXTS.length} missing.`,
)
/*
And the other direction, which none of the checks above can see: a field this
console still draws after upstream REMOVED it. v15.5 dropped Auto Prefetch; its
two fields stayed here, still required, and Settings could not be saved at all
against the new server, with every parity check green, because every check
asked "is upstream's text here?" and never "is our text still upstream's?".
Every `label="…"` in the source must exist, word for word, somewhere in
upstream's page.
*/
/* Upstream builds part of its interface from JavaScript (row menus, the Apps
buttons, the DHCP and Logs tab strips), so its scripts are read too. */
const scripts = await Promise.all(
[...html.matchAll(/<script src="(js\/[^"?]+)/g)].map((m) =>
fetch(`${REF}/${m[1]}`).then((r) => (r.ok ? r.text() : '')),
),
)
const theirWords = asWords([html, decode(html), ...scripts].join(' '))
const OUR_LABELS = new Set()
/* Where each label is written, for the Blocking exception below. */
const LABEL_FILES = new Map()
for (const f of files(SOURCE).filter((f) => !/\.test\.|fixture/.test(f))) {
for (const m of withoutComments(readFileSync(f, 'utf8')).matchAll(/(?<![-\w])label="([^"{}]{3,})"/g)) {
OUR_LABELS.add(m[1])
LABEL_FILES.set(m[1], [...new Set([...(LABEL_FILES.get(m[1]) ?? []), f])])
}
}
/* Accessible names this console gives to controls upstream leaves unnamed: a
menu button's `aria-label` and a tab strip's. They are read by a screen
reader, never drawn, so they are not interface text upstream could own. */
const ACCESSIBLE_NAMES = new Set([
'Blocking options',
'Blocking sections',
'Zone actions',
'DNSSEC actions',
'DHCP sections',
'Logs sections',
'Settings sections',
])
/*
The Blocking section is the deliberate exception that adds a screen
(CONVENTIONS.md, "Deliberate deviations"), so it has words upstream does not.
They are named here one by one, each with where it is written and where its file
declares it as ours, and each passes ONLY while every file that writes it is
under `screens/blocking/`. Anywhere else in the console it is still a stray, and
a label that is not in this list is a stray even inside the section.
*/
const BLOCKING = join(SOURCE, 'screens/blocking/')
const BLOCKING_OWN_LABELS = new Map([
['Block List Domains', 'Overview figure, blocking/Overview.tsx:361; declared ours at Overview.tsx:35'],
['Your Rules', 'Overview figure, blocking/Overview.tsx:365; declared ours at Overview.tsx:35'],
['Tree', "the Tree view's Blocked/Allowed selector, blocking/Rules.tsx:329; declared ours at Rules.tsx:37-38"],
['More lists', "Quick Add's group of the console's own catalogue, blocking/BlockLists.tsx:508; declared ours at BlockLists.tsx:48-49"],
['Listed as', 'Check a domain, the list entry that matched, blocking/DomainCheck.tsx:107; declared ours at DomainCheck.tsx:15'],
['Rule', 'Check a domain, the rule that matched, blocking/DomainCheck.tsx:117; declared ours at DomainCheck.tsx:15'],
['Add to group', 'the group a new list goes into, blocking/BlockLists.tsx:666; declared ours at BlockLists.tsx:48'],
])
const onlyInBlocking = (label) => (LABEL_FILES.get(label) ?? []).every((f) => f.startsWith(BLOCKING))
const labels = [...OUR_LABELS]
const upstreamLabels = labels.filter((l) => theirWords.includes(asWords(l)))
const accessible = labels.filter((l) => !upstreamLabels.includes(l) && ACCESSIBLE_NAMES.has(l))
const blockingOwn = labels.filter(
(l) => !upstreamLabels.includes(l) && !accessible.includes(l) && BLOCKING_OWN_LABELS.has(l) && onlyInBlocking(l),
)
const strayLabels = labels.filter((l) => !upstreamLabels.includes(l) && !accessible.includes(l) && !blockingOwn.includes(l))
console.log('')
for (const l of blockingOwn) console.log(` BLOCKING OWN label: ${l} (${BLOCKING_OWN_LABELS.get(l)})`)
for (const l of strayLabels) console.log(` NOT UPSTREAM label: ${l}`)
console.log(
strayLabels.length === 0
? `LABEL PARITY: ${upstreamLabels.length} of our ${labels.length} labels exist upstream; ` +
`${accessible.length} are accessible names upstream leaves unnamed (ACCESSIBLE_NAMES) and ` +
`${blockingOwn.length} are the Blocking section's own (BLOCKING_OWN_LABELS).`
: `LABEL PARITY: ${strayLabels.length} of ${labels.length} labels are not upstream's.`,
)
process.exit(
missing.length + missingHelp.length + missingExample.length + missingNotes.length + missingText.length + strayLabels.length === 0
? 0
: 1,
)