mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-29 09:35:13 +08:00
💋 Sync 2026-10-04 04:16:09
This commit is contained in:
1 parent
f6b2df73ec
commit
f62992fd80
745 files changed
+212416
-1173
No files matched your search
Executable
+653
@@ -0,0 +1,653 @@
|
||||
#!/usr/bin/env node
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 VolanticSystems
|
||||
//
|
||||
// appflow browser-half suite.
|
||||
//
|
||||
// WHY THIS FILE EXISTS. Every defect this package's frontend has had was found
|
||||
// by a human or a review panel READING the code. Not one was found by a test,
|
||||
// because until now no test could run any of it. The daemon has 54 checks
|
||||
// against it and the JavaScript had none, which is backwards: the daemon
|
||||
// mostly counts bytes, while the frontend is what decides whether an operator
|
||||
// is told the collector is down.
|
||||
//
|
||||
// usage: node tests/frontend-suite.js
|
||||
//
|
||||
// Runs anywhere Node runs. No router, no browser, no network. The real
|
||||
// common.js is loaded and executed by tests/luci-module.js; nothing in it is
|
||||
// reimplemented here.
|
||||
//
|
||||
// EVERY CHECK IN THE FIRST THREE GROUPS IS A REGRESSION TEST FOR A DEFECT THAT
|
||||
// WAS REAL, shipped, and fixed. They are not hypotheticals: each one names the
|
||||
// date it was found and what it did to the screen.
|
||||
//
|
||||
// SABOTAGE COMMENTS: every check names the smallest edit to the PRODUCT that
|
||||
// turns it red, written before the assertion.
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const { load, potMsgids, textOf, classesOf } = require('./luci-module.js');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const COMMON = path.join(ROOT, 'htdocs/luci-static/resources/view/appflow/common.js');
|
||||
const POT = path.join(ROOT, 'po/templates/appflow.pot');
|
||||
const DAEMON = path.join(ROOT, 'root/usr/sbin/appflowd');
|
||||
|
||||
// The category column of the daemon's AI_HOSTS table, read out of the daemon
|
||||
// source itself. This is the only cross-file contract the browser half has: if
|
||||
// the daemon emits a category the frontend has no key for, the label is
|
||||
// untranslatable and nothing else in either suite notices.
|
||||
//
|
||||
// Deliberately a text scan rather than a fixture. A fixture would record what
|
||||
// the daemon USED to send, which is exactly the failure being guarded against.
|
||||
function daemonAiCategories() {
|
||||
const src = require('fs').readFileSync(DAEMON, 'utf8');
|
||||
const out = new Set();
|
||||
const re = /\[\s*"[^"]*"\s*,\s*"(ai-[a-z-]+|AI [a-z]+)"\s*,/g;
|
||||
let m;
|
||||
while ((m = re.exec(src)) !== null) out.add(m[1]);
|
||||
return [...out].sort();
|
||||
}
|
||||
|
||||
let PASS = 0, FAIL = 0;
|
||||
|
||||
function ok(m) { PASS++; console.log(' PASS ' + m); }
|
||||
function bad(m) { FAIL++; console.log(' FAIL ' + m); }
|
||||
function chk(desc, expected, actual) {
|
||||
const e = JSON.stringify(expected), a = JSON.stringify(actual);
|
||||
if (e === a) ok(desc); else bad(`${desc} (expected ${e}, got ${a})`);
|
||||
}
|
||||
function head(m) { console.log('\n=== ' + m + ' ==='); }
|
||||
|
||||
// rpc and request throw if touched. Nothing under test calls them, and a stub
|
||||
// that answered plausibly would let a test pass against a product that had
|
||||
// stopped asking.
|
||||
const c = load(COMMON, {
|
||||
rpc: { declare: (o) => () => { throw new Error('rpc called: ' + (o && o.method)); } },
|
||||
request: { get: () => { throw new Error('request called'); } }
|
||||
});
|
||||
|
||||
// --------------------------------------------------- the status strip
|
||||
|
||||
head('STATUS STRIP: absent evidence is not positive evidence');
|
||||
|
||||
// THE DEFECT, found 2026-08-25. The test was
|
||||
// connected = (s.agent_connected !== false) && (sock.connected !== false)
|
||||
// and `undefined !== false` is true, so a payload carrying neither field
|
||||
// rendered a single green "Netify agent connected" pill. Overview passes
|
||||
// `status || summary`, so this fired exactly when the status call FAILED: the
|
||||
// one indicator whose job is to say the collector is down failed GREEN, on the
|
||||
// most common reason for the page being empty.
|
||||
//
|
||||
// SABOTAGE for this whole group: in statusStrip(), drop the `known &&` from
|
||||
// the `connected` expression, or replace `known` with `true`. The three
|
||||
// unknown rows go red and the two positive rows stay green, which is the
|
||||
// asymmetry that matters.
|
||||
const STATES = [
|
||||
['no status at all', undefined, 'af-unknown'],
|
||||
['an empty object', {}, 'af-unknown'],
|
||||
// The payload that actually triggered it: a summary, passed in because the
|
||||
// status call failed. It has neither field.
|
||||
['a summary payload (the real case)', { bytes_total: 12345, top_apps: [] }, 'af-unknown'],
|
||||
['agent_connected true', { agent_connected: true }, 'af-ok'],
|
||||
['agent_connected false', { agent_connected: false }, 'af-warn'],
|
||||
// socket.connected alone IS positive evidence: it says the daemon has the
|
||||
// export socket open. `known` requires only that ONE of the two fields be
|
||||
// present, which is deliberate.
|
||||
['socket.connected true only', { socket: { connected: true } }, 'af-ok'],
|
||||
['socket.connected false only', { socket: { connected: false } }, 'af-warn']
|
||||
];
|
||||
|
||||
for (const [label, status, wantClass] of STATES) {
|
||||
const cls = classesOf(c.statusStrip(status));
|
||||
if (cls.has(wantClass)) ok(`${label} -> ${wantClass}`);
|
||||
else bad(`${label} -> expected ${wantClass}, got [${[...cls].join(' ')}]`);
|
||||
}
|
||||
|
||||
// And the text, because a neutral class with reassuring words is still a lie.
|
||||
//
|
||||
// SABOTAGE: change the unknown-state string back to "Netify agent connected".
|
||||
// The class check above stays green and only this goes red.
|
||||
const unknownText = textOf(c.statusStrip({ bytes_total: 1 }));
|
||||
if (/unknown/i.test(unknownText)) ok('and the unknown state SAYS unknown');
|
||||
else bad(`the unknown state reads "${unknownText}"`);
|
||||
|
||||
// -------------------------------------------- prototype-chain lookups
|
||||
|
||||
head('HOSTILE KEYS: a name from the wire must not reach Object.prototype');
|
||||
|
||||
// THE DEFECT, found 2026-08-25. CATEGORY_SLOT is an object literal, so it
|
||||
// inherits Object.prototype, and a bare `CATEGORY_SLOT[s] != null` test passed
|
||||
// for 'constructor', '__proto__', 'toString' and friends, indexing PALETTE
|
||||
// with a function and yielding undefined. A device named `constructor`
|
||||
// rendered style="background:undefined", which the browser drops: white text
|
||||
// on a transparent tile, and in the doughnut the slice silently did not draw
|
||||
// while its legend still claimed a percentage.
|
||||
//
|
||||
// `s` is attacker-influenced. It comes from a category tag, an application
|
||||
// label, or a DHCP hostname, so a LAN device can choose it.
|
||||
//
|
||||
// SABOTAGE: in color(), replace
|
||||
// Object.prototype.hasOwnProperty.call(CATEGORY_SLOT, s)
|
||||
// with a bare `CATEGORY_SLOT[s] != null`. Every row below goes red.
|
||||
const HOSTILE = ['constructor', '__proto__', 'toString', 'hasOwnProperty',
|
||||
'valueOf', 'isPrototypeOf'];
|
||||
|
||||
for (const key of HOSTILE) {
|
||||
const col = c.color(key);
|
||||
if (typeof col === 'string' && /^hsl\(/.test(col))
|
||||
ok(`color(${JSON.stringify(key)}) is a real colour`);
|
||||
else
|
||||
bad(`color(${JSON.stringify(key)}) returned ${JSON.stringify(col)}`);
|
||||
}
|
||||
|
||||
// The mirror: a function that returned a fixed colour for everything would
|
||||
// pass all six rows above. A known category must still get its ASSIGNED slot,
|
||||
// not the hash fallback.
|
||||
//
|
||||
// SABOTAGE: delete the hasOwnProperty branch entirely so everything falls
|
||||
// through to the hash. This goes red while the six rows above stay green.
|
||||
const known = c.color('games'), other = c.color('games');
|
||||
chk('a known category is stable across calls', known, other);
|
||||
if (c.color('games') !== c.color('shopping'))
|
||||
ok('and different categories get different colours');
|
||||
else
|
||||
bad('two different categories returned the same colour');
|
||||
|
||||
// -------------------------------------------------------- normalisers
|
||||
|
||||
head('LABELS: netifyd slugs are cased, deliberate casing is left alone');
|
||||
|
||||
// catLabel() used to lowercase its input before title-casing it. That is
|
||||
// harmless for netifyd's own categories, which are lowercase slugs, and it
|
||||
// destroyed appflow's AI categories: "AI assistants" came back as
|
||||
// "Ai assistants", because the title-case split is on [-_] and a space is
|
||||
// neither. It rendered that way on a live router before anyone noticed.
|
||||
//
|
||||
// Since 1.1.1 the daemon sends those four as slugs, so no category the daemon
|
||||
// actually emits reaches the pass-through any more. It is kept because it is
|
||||
// the guard for anything NOT in the table, and these two inputs exercise it
|
||||
// directly.
|
||||
//
|
||||
// SABOTAGE: remove the /[A-Z]/ pass-through in catLabel(). The first two go
|
||||
// red and the netifyd cases stay green, which is the distinction that matters.
|
||||
chk('a category with deliberate capitals is left alone',
|
||||
'AI assistants', c.catLabel('AI assistants'));
|
||||
chk('and so is a second one', 'AI infrastructure', c.catLabel('AI infrastructure'));
|
||||
|
||||
// The netifyd side must be unchanged by that pass-through.
|
||||
chk('a lowercase netifyd slug is still title-cased', 'Networking', c.catLabel('networking'));
|
||||
chk('and a hyphenated one still splits', 'Social Network', c.catLabel('social-network'));
|
||||
chk('an empty category is still Unclassified', 'Unclassified', c.catLabel(''));
|
||||
|
||||
head('TILE LETTERS: a name in any script must produce a letter from that script');
|
||||
|
||||
// THE DEFECT, found 2026-08-31 by rendering the real component and looking at
|
||||
// it. tile() stripped leading characters with /^[^0-9A-Za-z]+/, an ASCII class,
|
||||
// so a name in any non-Latin script was consumed entirely and fell through to
|
||||
// the '?' fallback. Every Chinese, Japanese, Korean, Russian, Greek, Arabic and
|
||||
// Hebrew name rendered an identical question-mark tile.
|
||||
//
|
||||
// It was never a translation bug. Any LAN client with a non-Latin DHCP hostname
|
||||
// hit it on every release shipped so far; accepting a zh_Hans catalogue would
|
||||
// have turned an occasional case into a universal one.
|
||||
//
|
||||
// SABOTAGE: put the ASCII class back. The three non-Latin rows go red and every
|
||||
// Latin row stays green, which is the asymmetry that matters.
|
||||
chk('a Latin name is unchanged', 'R', textOf(c.tile('Router', 'Router')));
|
||||
chk('and a hyphenated one', 'K', textOf(c.tile('kitchen-pi', 'kitchen-pi')));
|
||||
chk('Chinese yields a Chinese character', '路',
|
||||
textOf(c.tile('路由器', '路由器')));
|
||||
chk('Japanese yields a Japanese character', '日',
|
||||
textOf(c.tile('日本語', '日本語')));
|
||||
chk('Cyrillic yields a Cyrillic character, upper-cased', 'Р',
|
||||
textOf(c.tile('Русский',
|
||||
'Русский')));
|
||||
|
||||
// The strip must still do its job, and the fallback must still exist. A fix
|
||||
// that simply removed the strip would pass every row above.
|
||||
//
|
||||
// SABOTAGE: delete the .replace() entirely. This row goes red.
|
||||
chk('leading punctuation is still stripped', 'W', textOf(c.tile('...weird', 'x')));
|
||||
// SABOTAGE: replace the '?' fallback with ''. This row goes red.
|
||||
chk('a nameless tile still falls back', '?', textOf(c.tile('', 'x')));
|
||||
|
||||
head('LABELS ARE TRANSLATABLE, WHICH IS NOT WHAT THE CHECKS ABOVE PROVE');
|
||||
|
||||
// THE DEFECT, found 2026-08-30, reported by @ntbowen against 1.1.0.
|
||||
//
|
||||
// READ THIS BEFORE ADDING A CHECK ABOVE INSTEAD OF HERE. Every assertion in
|
||||
// the group above is satisfied identically by two implementations, one of
|
||||
// which is broken:
|
||||
//
|
||||
// catLabel('networking') -> _('Networking') translatable
|
||||
// catLabel('networking') -> 'networking'.replace(…) English for ever
|
||||
//
|
||||
// Both return the string 'Networking', so `chk(…, 'Networking', …)` passes on
|
||||
// either. The difference IS the feature, and an equality assertion cannot see
|
||||
// it. Three separate label paths shipped broken underneath a green run of this
|
||||
// file, including its own catalogue check, which asserts that every string
|
||||
// reaching _() is in the .pot and is therefore blind by construction to a
|
||||
// string that never reaches _() at all.
|
||||
//
|
||||
// The observable that distinguishes them is not the output, it is WHETHER _()
|
||||
// WAS CALLED. tests/luci-module.js records every string the module passes to
|
||||
// _() as it loads, so the assertion is membership in that set.
|
||||
//
|
||||
// NETIFYD_TAGS is a deliberate second copy, read off the bench from
|
||||
// /etc/netify.d/netify-categories.json on netifyd 4.4.7 (32 application tags
|
||||
// + 18 protocol tags, 43 unique). It must NOT be imported from the product,
|
||||
// or this check becomes a tautology that compares the table with itself.
|
||||
const NETIFYD_TAGS = [
|
||||
'adult', 'advertiser', 'authentication', 'business', 'cdn', 'cybersecurity',
|
||||
'database', 'device-iot', 'education', 'entertainment', 'file-server',
|
||||
'file-sharing', 'financial', 'gambling', 'games', 'government', 'hosting',
|
||||
'infrastructure', 'mail', 'malware', 'media', 'media-provider', 'messaging',
|
||||
'networking', 'news', 'os-software-updates', 'portal', 'printing', 'proxy',
|
||||
'recreation', 'reference', 'remote-desktop', 'shopping', 'social-media',
|
||||
'sports', 'streaming-media', 'technology', 'telco', 'unclassified', 'voip',
|
||||
'vpn', 'vpn-and-proxy', 'web'
|
||||
];
|
||||
|
||||
// THE MODULE IS LOADED A SECOND TIME WITH A MARKING TRANSLATOR, and every
|
||||
// check below runs against that copy rather than against `c`.
|
||||
//
|
||||
// The first version of this group asked "is the returned string in the set of
|
||||
// strings passed to _()". That is weaker than it looks, and a panel named the
|
||||
// general shape after an executed sabotage had already found one instance:
|
||||
// with the default identity translator, `_('Web')` and a runtime title-caser
|
||||
// both produce exactly `'Web'`, so a membership test can be satisfied by a
|
||||
// COINCIDENCE between an untranslated label and some other string that did go
|
||||
// through _(). It happened for real with the AI categories, where the table
|
||||
// entry _('AI media') put that exact string in the set, so a daemon that had
|
||||
// reverted to sending 'AI media' still passed.
|
||||
//
|
||||
// With `_()` returning `[[msgid]]`, a translated label and a generated one are
|
||||
// different strings, and plain equality becomes a sound test again. `c` keeps
|
||||
// the identity translator because every other check in this file asserts on
|
||||
// real English output.
|
||||
const T = (s) => `[[${s}]]`;
|
||||
const t9n = load(COMMON, {
|
||||
rpc: { declare: (o) => () => { throw new Error('rpc called: ' + (o && o.method)); } },
|
||||
request: { get: () => { throw new Error('request called'); } }
|
||||
}, { translate: T });
|
||||
|
||||
// __translated still records the ARGUMENT to _(), not its return, so the
|
||||
// catalogue check further down is unaffected by the marking translator.
|
||||
const translated = new Set(c.__translated.filter(Boolean));
|
||||
|
||||
// A check over an empty set passes vacuously. Refuse to report on one, the
|
||||
// same way the catalogue check below does.
|
||||
if (translated.size === 0) {
|
||||
bad('the run reached NO _() strings, so nothing below proves anything');
|
||||
} else if (t9n.normDevice({}).name !== T('Unknown device')) {
|
||||
// If the marking translator is not actually reaching the product, every
|
||||
// equality check below would compare English with English and pass while
|
||||
// proving nothing. Prove the instrument works before trusting it.
|
||||
//
|
||||
// ANCHOR THIS ON A STRING THAT IS NOT IN EITHER TABLE UNDER TEST. The
|
||||
// first version probed catLabel('web'), which is one of the very entries
|
||||
// these checks exist to police: unwrapping that entry to `'web': 'Web'`
|
||||
// made the probe fail, so a real product defect reported itself as a
|
||||
// broken instrument and skipped the nine checks that would have named it.
|
||||
// normDevice's fallback is translated on a path no check below asserts on.
|
||||
bad('the marking translator had no effect, so nothing below proves anything');
|
||||
} else {
|
||||
// SABOTAGE: delete any one entry from CATEGORY_LABELS in common.js, or
|
||||
// write it unwrapped as `'web': 'Web'`. The title-caser still returns the
|
||||
// same English text and every equality check above stays green; this goes
|
||||
// red and names the tag.
|
||||
const untranslatable = NETIFYD_TAGS.filter((t) => t9n.catLabel(t) !== T(c.catLabel(t)));
|
||||
if (untranslatable.length === 0)
|
||||
ok(`all ${NETIFYD_TAGS.length} netifyd category tags return the translator's output`);
|
||||
else
|
||||
bad(`${untranslatable.length} category tag(s) produce a label _() never saw, `
|
||||
+ `so they are English in every language: ${JSON.stringify(untranslatable)}`);
|
||||
|
||||
// Every one of those labels must also be in the shipped catalogue. The
|
||||
// check above proves _() was called; this proves the extractor saw it.
|
||||
//
|
||||
// SABOTAGE: add a category entry to common.js without regenerating the
|
||||
// .pot. This goes red; the check above stays green.
|
||||
const catalogue = potMsgids(POT);
|
||||
const uncatalogued = NETIFYD_TAGS.map((t) => c.catLabel(t))
|
||||
.filter((s) => !catalogue.has(s));
|
||||
if (uncatalogued.length === 0)
|
||||
ok('and every one of those labels is in appflow.pot');
|
||||
else
|
||||
bad(`${uncatalogued.length} label(s) reach _() but are not in the catalogue: `
|
||||
+ JSON.stringify(uncatalogued.slice(0, 5)));
|
||||
|
||||
// The daemon's own AI categories, which is where WE introduced this bug
|
||||
// rather than inheriting it. Until 1.1.1 appflowd sent 'AI assistants' and
|
||||
// three siblings as rendered English, and catLabel returned them untouched.
|
||||
//
|
||||
// TWO ASSERTIONS, AND THE FIRST ONE IS NOT REDUNDANT. The membership test
|
||||
// alone cannot catch this: catLabel('AI media') falls through the
|
||||
// pass-through and returns 'AI media' unchanged, and that exact string IS
|
||||
// in the translated set, because the table entry 'ai-media': _('AI media')
|
||||
// put it there. So a daemon that reverted to display strings would satisfy
|
||||
// a membership check while being exactly as broken as before. That was the
|
||||
// first version of this check and an executed sabotage caught it, which is
|
||||
// the only reason it is written this way.
|
||||
//
|
||||
// SABOTAGE A: change one AI_HOSTS category in appflowd back to a display
|
||||
// string, e.g. 'ai-media' -> 'AI media'. The shape check goes red.
|
||||
// SABOTAGE B: change one to a slug with no table entry, e.g. 'ai-video'.
|
||||
// The membership check goes red.
|
||||
const SLUG = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
||||
const daemonCats = daemonAiCategories();
|
||||
if (daemonCats.length < 4) {
|
||||
bad(`read only ${daemonCats.length} AI categories out of appflowd; expected 4`);
|
||||
} else {
|
||||
const shaped = daemonCats.filter((t) => !SLUG.test(t));
|
||||
if (shaped.length === 0)
|
||||
ok(`all ${daemonCats.length} AI categories the daemon emits are lowercase slugs`);
|
||||
else
|
||||
bad(`the daemon emits pre-rendered display string(s) ${JSON.stringify(shaped)}; `
|
||||
+ 'a category that arrives already in English has no key to look up');
|
||||
|
||||
const unknown = daemonCats.filter((t) => t9n.catLabel(t) !== T(c.catLabel(t)));
|
||||
if (unknown.length === 0)
|
||||
ok(`and the frontend has a _() label for all ${daemonCats.length} of them`);
|
||||
else
|
||||
bad(`the daemon emits ${JSON.stringify(unknown)}, which the frontend `
|
||||
+ 'has no CATEGORY_LABELS entry for');
|
||||
}
|
||||
|
||||
// The three synthetic pseudo-devices. The daemon sends their display name
|
||||
// already rendered in English because it has no idea what language the
|
||||
// browser wants, so the frontend must name them from the stable key.
|
||||
//
|
||||
// The fixture's `name` is deliberately the real English the daemon sends,
|
||||
// not a marker string: a lookup keyed on the name rather than the key is a
|
||||
// plausible implementation and would pass against a fixture that could not
|
||||
// satisfy it.
|
||||
//
|
||||
// SABOTAGE: delete an entry from DEVICE_LABELS, key the lookup on the
|
||||
// daemon's name instead of t.key, or misspell a key. This goes red.
|
||||
[ [ 'router', 'Router' ],
|
||||
[ 'unknown', 'Unknown' ],
|
||||
[ 'multicast', 'Multicast / Broadcast' ] ].forEach(function(pair) {
|
||||
const key = pair[0], english = pair[1];
|
||||
const got = t9n.normDevice({ key: key, name: english }).name;
|
||||
if (got === T(english))
|
||||
ok(`pseudo-device '${key}' is named through _(), not from the daemon's English`);
|
||||
else
|
||||
bad(`pseudo-device '${key}' rendered ${JSON.stringify(got)}, `
|
||||
+ `expected the translated ${JSON.stringify(T(english))}`);
|
||||
});
|
||||
|
||||
// A real device must NOT be captured by that table, even when its DHCP
|
||||
// hostname is exactly the English of a pseudo-device. This is the check
|
||||
// that a name-keyed implementation fails.
|
||||
//
|
||||
// SABOTAGE: index DEVICE_LABELS by the daemon's name rather than t.key.
|
||||
[ 'kitchen-pi', 'Router', 'Multicast / Broadcast' ].forEach(function(name) {
|
||||
const got = t9n.normDevice({ key: 'b4:2e:99:3a:d6:df',
|
||||
mac: 'b4:2e:99:3a:d6:df', name: name }).name;
|
||||
if (got === name)
|
||||
ok(`a real device named ${JSON.stringify(name)} keeps its own name`);
|
||||
else
|
||||
bad(`a real device named ${JSON.stringify(name)} was renamed to `
|
||||
+ `${JSON.stringify(got)} by the pseudo-device table`);
|
||||
});
|
||||
}
|
||||
|
||||
// Both label tables are plain object literals, so they inherit
|
||||
// Object.prototype and a bare lookup on an inherited key returns a truthy
|
||||
// non-string. t.name is assumed to be a string by the .toUpperCase() two lines
|
||||
// later in normDevice, and a label is assumed to be a string by every caller.
|
||||
//
|
||||
// Raised independently by two reviewers against the contributor's patch, which
|
||||
// used a bare `DEVICE_LABELS[key] ||` lookup. color() in the same file already
|
||||
// guarded its own table this way; catLabel() did not.
|
||||
//
|
||||
// THESE PROBES FEED HOSTILE INPUT, SO THEY MUST NOT BE ALLOWED TO THROW
|
||||
// UNCAUGHT. Removing the guard makes normDevice raise a TypeError rather than
|
||||
// return a bad value, and an uncaught throw here would abort the whole file:
|
||||
// node prints a stack trace, every later check never runs, and the summary
|
||||
// line that says how many passed is never printed at all. The run is still red
|
||||
// by exit code, but "red" and "red naming the defect, with the other 60 checks
|
||||
// still reported" are different things. attempt() turns a throw into a named
|
||||
// failure and lets the suite finish.
|
||||
//
|
||||
// SABOTAGE: replace either hasOwnProperty guard with a bare truthiness test.
|
||||
// Every probe below goes red, by value for catLabel and by throw for
|
||||
// normDevice, and the checks after this block still report.
|
||||
function attempt(desc, fn) {
|
||||
let v;
|
||||
try {
|
||||
v = fn();
|
||||
} catch (e) {
|
||||
bad(`${desc} threw ${e && e.name}: ${e && e.message}`);
|
||||
return;
|
||||
}
|
||||
if (typeof v === 'string') ok(desc);
|
||||
else bad(`${desc} produced a ${typeof v}, not a string`);
|
||||
}
|
||||
|
||||
[ '__proto__', 'constructor', 'toString', 'hasOwnProperty' ].forEach(function(k) {
|
||||
attempt(`catLabel(${JSON.stringify(k)}) returns a string`,
|
||||
() => c.catLabel(k));
|
||||
attempt(`normDevice key ${JSON.stringify(k)} names a string`,
|
||||
() => c.normDevice({ key: k, mac: 'aa:bb:cc:dd:ee:ff' }).name);
|
||||
});
|
||||
|
||||
head('NORMALISERS: idempotent, and never inventing a total');
|
||||
|
||||
// THE DEFECT, found 2026-08-25. normSeries() was not idempotent and one caller
|
||||
// normalised twice. The second pass read the already-renamed fields, found
|
||||
// nothing, and produced {dl:0, ul:0, total:<real>}: a total contradicting its
|
||||
// own parts. The chart drew empty while the "no data" message stayed
|
||||
// suppressed, because `total` was non-zero.
|
||||
//
|
||||
// SABOTAGE: remove 'dl' and 'ul' from the alias lists the normaliser accepts.
|
||||
// The second pass zeroes them and this goes red.
|
||||
const rawSeries = [{ time: 1, dl: 10, ul: 5 }, { time: 2, dl: 20, ul: 10 }];
|
||||
const once = c.normSeries(rawSeries);
|
||||
const twice = c.normSeries(once);
|
||||
chk('normSeries is idempotent', once, twice);
|
||||
|
||||
// SABOTAGE: change the `(tot >= 0) ? tot : dl + ul` fallback to a constant.
|
||||
chk('a point with no explicit total gets dl+ul', 15, once[0].total);
|
||||
chk('and the parts survive', [10, 5], [once[0].dl, once[0].ul]);
|
||||
|
||||
// The invariant that would have caught the original defect on its own, stated
|
||||
// as an invariant rather than as a value: parts and total must not contradict.
|
||||
const contradictions = twice.filter((p) => p.dl === 0 && p.ul === 0 && p.total > 0);
|
||||
chk('no point claims a total while both parts are zero', [], contradictions);
|
||||
|
||||
// SABOTAGE: make normSeries() return its input unchanged. The empty and
|
||||
// absent cases stop being normalised and this goes red.
|
||||
chk('normSeries(null) is an empty list', [], c.normSeries(null));
|
||||
chk('normSeries(undefined) is an empty list', [], c.normSeries(undefined));
|
||||
|
||||
head('HELPERS: the accessors every view depends on');
|
||||
|
||||
// SABOTAGE for each: change the default-value branch of num()/str()/toList()
|
||||
// so a miss returns something other than the supplied default.
|
||||
chk('num() finds a value by alias', 5, c.num({ a: 5 }, ['x', 'a'], 0));
|
||||
chk('num() returns the default on a miss', 42, c.num({}, ['nope'], 42));
|
||||
chk('num() returns the default on a non-number', 7, c.num({ a: 'xyz' }, ['a'], 7));
|
||||
chk('str() returns the default on a miss', 'dflt', c.str({}, ['nope'], 'dflt'));
|
||||
chk('toList(null) is empty', [], c.toList(null));
|
||||
chk('toList of an array is itself', [1, 2], c.toList([1, 2]));
|
||||
|
||||
// toList over an object keyed by name must carry the key through, or every
|
||||
// per-device row loses its identity.
|
||||
//
|
||||
// SABOTAGE: drop the keyField assignment in toList(). This goes red.
|
||||
const keyed = c.toList({ 'aa:bb': { bytes: 1 } }, 'mac');
|
||||
chk('toList carries the object key into the named field', 'aa:bb', keyed[0] && keyed[0].mac);
|
||||
|
||||
// ---------------------------------------------------------- catalogue
|
||||
|
||||
head('FILTER: include, exclude, and the shapes a half-typed term takes');
|
||||
|
||||
// WHY THIS GROUP EXISTS. The filter is the only way to make a busy dashboard
|
||||
// legible, and the question that produced it was concrete: one streaming
|
||||
// session buried everything else and there was no way to look past it.
|
||||
//
|
||||
// Both views share this logic precisely so they cannot drift, and it lives in
|
||||
// common.js rather than inside a render function so a test can reach it at all.
|
||||
// The classifier lesson from the sibling package: logic that decides something
|
||||
// belongs where Node can load it.
|
||||
|
||||
// A term matches the application label, its key, or its category label. Those
|
||||
// are the three strings a person could plausibly be typing at.
|
||||
const FIELDS = {
|
||||
claude: [ 'Anthropic (Claude)', 'ai:anthropic-claude', 'AI assistants' ],
|
||||
openai: [ 'OpenAI', 'ai:openai', 'AI assistants' ],
|
||||
netflix: [ 'Netflix', 'a145', 'Streaming Media' ],
|
||||
http: [ 'HTTP/S', 'p196', 'Web' ]
|
||||
};
|
||||
|
||||
const pass = (text, row) => c.matchFilter(c.parseFilter(text), FIELDS[row]);
|
||||
|
||||
// An empty filter passes everything. This is the unfiltered case and must not
|
||||
// be special-cased by any caller.
|
||||
//
|
||||
// SABOTAGE: make matchFilter return false for an empty term list. Every view
|
||||
// renders an empty table on load, which is as broken as it sounds.
|
||||
chk('an empty filter passes everything', [ true, true, true, true ],
|
||||
[ 'claude', 'openai', 'netflix', 'http' ].map((r) => pass('', r)));
|
||||
|
||||
// Matching is case-insensitive and matches any of the three fields.
|
||||
chk('a term matches the application label', true, pass('netflix', 'netflix'));
|
||||
chk('and is case-insensitive', true, pass('NeTfLiX', 'netflix'));
|
||||
chk('a term matches the CATEGORY label', true, pass('ai assistants', 'claude'));
|
||||
chk('a partial category matches', true, pass('ai', 'claude'));
|
||||
chk('a term matches the internal key', true, pass('p196', 'http'));
|
||||
chk('a term that matches nothing excludes', false, pass('netflix', 'claude'));
|
||||
|
||||
// EXCLUSION. This is the case the whole feature exists for: "show me
|
||||
// everything except the thing that is burying the view". An include-only
|
||||
// filter cannot express it, because you would have to already know what you
|
||||
// wanted to keep.
|
||||
//
|
||||
// SABOTAGE: drop the leading-minus branch in parseFilter so '-netflix' is
|
||||
// treated as an ordinary term. The two checks below invert.
|
||||
chk('a minus term removes the row it matches', false, pass('-netflix', 'netflix'));
|
||||
chk('and leaves every other row alone', true, pass('-netflix', 'claude'));
|
||||
|
||||
// Terms combine with AND, so each one narrows. Mixing include and exclude is
|
||||
// the useful case: this category, except that member of it.
|
||||
//
|
||||
// SABOTAGE: change every() to some() in matchFilter. The first goes true.
|
||||
chk('include and exclude combine', false, pass('ai -claude', 'claude'));
|
||||
chk('and the combination still includes', true, pass('ai -claude', 'openai'));
|
||||
chk('two includes both have to match', false, pass('ai netflix', 'claude'));
|
||||
|
||||
// A bare '-' is what a half-typed exclusion looks like. Treating it as
|
||||
// "exclude everything" would blank the table between two keystrokes.
|
||||
//
|
||||
// SABOTAGE: remove the final filter() in parseFilter that drops empty terms.
|
||||
chk('a bare minus is ignored, not treated as exclude-all',
|
||||
[ true, true ], [ pass('-', 'claude'), pass('-', 'netflix') ]);
|
||||
chk('and so is stray whitespace', true, pass(' ', 'claude'));
|
||||
|
||||
// Nulls and absent fields must not throw: a row can legitimately have no
|
||||
// category, and normApp fills what it can.
|
||||
//
|
||||
// SABOTAGE: drop the null guard in matchFilter's field join.
|
||||
chk('a row with missing fields does not throw', false,
|
||||
c.matchFilter(c.parseFilter('claude'), [ null, undefined, '' ]));
|
||||
chk('and still matches on the field it does have', true,
|
||||
c.matchFilter(c.parseFilter('claude'), [ null, 'Anthropic (Claude)', undefined ]));
|
||||
|
||||
head('DEVICE IDENTITY: the key the drill-down is addressed by');
|
||||
|
||||
// normDevice DROPPED the daemon's device key. Every call site that needed it
|
||||
// got undefined, so clicking a device cleared the selection instead of setting
|
||||
// it: no error, no effect, a feature that looked wired and was not. Three bugs
|
||||
// in one evening had that exact shape.
|
||||
//
|
||||
// SABOTAGE: remove `t.key = ...` from normDevice. This goes red and nothing
|
||||
// else does, which is precisely why it had to be added.
|
||||
{
|
||||
const d = c.normDevice({ key: 'b4:2e:99:3a:d6:df', mac: 'b4:2e:99:3a:d6:df',
|
||||
ip: '192.168.72.20', name: 'Stang', bytes_total: 10 });
|
||||
|
||||
chk('normDevice carries the daemon key through', 'b4:2e:99:3a:d6:df', d.key);
|
||||
chk('and still carries name, mac and ip', [ 'Stang', 'B4:2E:99:3A:D6:DF', '192.168.72.20' ],
|
||||
[ d.name, d.mac, d.ip ]);
|
||||
}
|
||||
|
||||
// The pseudo-devices have no MAC at all, so a fallback to mac/ip would leave
|
||||
// them unaddressable. The daemon's key is what they are called.
|
||||
//
|
||||
// SABOTAGE: change the key preference order so mac wins over key.
|
||||
{
|
||||
const d = c.normDevice({ key: 'router', name: 'Router', bytes_total: 5 });
|
||||
|
||||
chk('a pseudo-device is addressed by its key', 'router', d.key);
|
||||
}
|
||||
|
||||
head('CLICKABLE CELLS: a label that names a thing is a way to see it');
|
||||
|
||||
// appCell and deviceCell take an onPick callback. Without one they render
|
||||
// exactly as before, so nothing that does not want the behaviour gets it.
|
||||
//
|
||||
// SABOTAGE: make appCell add af-pick unconditionally. The first check goes red,
|
||||
// and every table that never wanted click targets grows them.
|
||||
{
|
||||
const plain = c.appCell({ label: 'Netflix', key: 'a145', cat: 'streaming-media' });
|
||||
chk('without a callback an app cell is not clickable',
|
||||
false, /af-pick/.test(JSON.stringify(plain)));
|
||||
|
||||
const picked = [];
|
||||
const live = c.appCell({ label: 'Netflix', key: 'a145', cat: 'streaming-media' },
|
||||
null, (v) => picked.push(v));
|
||||
chk('with a callback it is', true, /af-pick/.test(JSON.stringify(live)));
|
||||
}
|
||||
|
||||
// A device cell with a callback is clickable and passes the whole device
|
||||
// object, because the caller needs both the key (to query) and the name (to
|
||||
// show in the chip).
|
||||
//
|
||||
// SABOTAGE: have deviceCell pass dev.name instead of dev. The drill-down then
|
||||
// queries by name, which is not what the daemon keys on.
|
||||
{
|
||||
const plain = c.deviceCell({ name: 'Stang', mac: 'B4:2E:99:3A:D6:DF', key: 'b4:2e' });
|
||||
chk('without a callback a device cell is not clickable',
|
||||
false, /af-pick/.test(JSON.stringify(plain)));
|
||||
|
||||
const live = c.deviceCell({ name: 'Stang', mac: 'B4:2E:99:3A:D6:DF', key: 'b4:2e' },
|
||||
() => {});
|
||||
chk('with a callback it is', true, /af-pick/.test(JSON.stringify(live)));
|
||||
}
|
||||
|
||||
head('THE STRING CATALOGUE COVERS WHAT THE CODE ASKS FOR');
|
||||
|
||||
// SABOTAGE: add a `_('some new string')` anywhere in common.js without
|
||||
// regenerating po/templates/appflow.pot. This goes red. Without it the
|
||||
// catalogue rots silently the moment anyone adds a message, and nobody finds
|
||||
// out until a translator does.
|
||||
const pot = potMsgids(POT);
|
||||
const seen = new Set(c.__translated.filter(Boolean));
|
||||
|
||||
// A catalogue check over an empty set proves nothing, so refuse to report a
|
||||
// pass on one.
|
||||
if (seen.size === 0) {
|
||||
bad('the run reached NO _() strings, so this check proved nothing');
|
||||
} else {
|
||||
const missing = [...seen].filter((x) => !pot.has(x));
|
||||
if (missing.length === 0)
|
||||
ok(`all ${seen.size} strings reached by this run are in the catalogue`);
|
||||
else
|
||||
bad(`${missing.length} of ${seen.size} not in appflow.pot: `
|
||||
+ JSON.stringify(missing.slice(0, 5)));
|
||||
}
|
||||
|
||||
// The catalogue must also not be EMPTY or trivially small, which would make
|
||||
// the check above pass while covering nothing.
|
||||
//
|
||||
// SABOTAGE: truncate appflow.pot. This goes red.
|
||||
if (pot.size >= 100) ok(`the catalogue holds ${pot.size} msgids`);
|
||||
else bad(`the catalogue holds only ${pot.size} msgids; expected the full extraction`);
|
||||
|
||||
console.log('\n----------------------------------------');
|
||||
console.log(`passed ${PASS}, failed ${FAIL}`);
|
||||
process.exit(FAIL);
|
||||
Executable
+422
@@ -0,0 +1,422 @@
|
||||
#!/bin/sh
|
||||
# appflow hardware test suite.
|
||||
#
|
||||
# The behavioural claims this package makes that can be checked without a
|
||||
# browser, as one runnable script. It exists because all of them had previously
|
||||
# been verified only by ad-hoc commands typed once and thrown away, and because
|
||||
# two of this daemon's defects were silent: they produced wrong numbers while
|
||||
# every status field said things were fine.
|
||||
#
|
||||
# RUN IT ON A SANDBOX ROUTER. It restarts appflowd, resets its counters, and
|
||||
# generates traffic. It does not modify configuration.
|
||||
#
|
||||
# usage: sh hardware-suite.sh run everything
|
||||
# sh hardware-suite.sh conservation run one group
|
||||
# (health|conservation|conntrack|restart|acl)
|
||||
#
|
||||
# Exit status is the number of failed checks.
|
||||
#
|
||||
# WHAT THIS SUITE CAN AND CANNOT CATCH, established by running it against the
|
||||
# pre-fix v1.0.1 daemon on 2026-08-25, which is the only test of a test worth
|
||||
# anything:
|
||||
#
|
||||
# caught: an old daemon with no conservation counters at all
|
||||
# (status.bytes missing), and any leak once they exist
|
||||
# NOT caught: the reshadow double-count itself. The restart ratio came back
|
||||
# 1.012 on the known-broken daemon, having been 0.70 and 0.79 on
|
||||
# two runs the day before. See the note in test_restart.
|
||||
#
|
||||
# Unlike the wansentry suite, nothing here reimplements the code under test:
|
||||
# every check queries the running daemon over ubus. The weakness is coverage,
|
||||
# not construction.
|
||||
#
|
||||
# WIRE TRUTH. Where a test needs to know how many bytes really moved, it reads
|
||||
# the client veth's own counter in /sys/class/net. Nothing in appflowd can
|
||||
# influence that number, which is the entire point: comparing appflow against
|
||||
# appflow proves nothing.
|
||||
#
|
||||
# Requires: appflowd and netifyd running, root, and a network namespace client
|
||||
# (see NETNS below) for the NAT tests. Groups that need the netns skip
|
||||
# themselves with a clear message when it is absent rather than failing.
|
||||
|
||||
set -u
|
||||
|
||||
GROUP="${1:-all}"
|
||||
NETNS="${NETNS:-cli}"
|
||||
VETH="${VETH:-veth-host}"
|
||||
PASS=0; FAIL=0; SKIP=0
|
||||
|
||||
head2() { printf '\n=== %s ===\n' "$*"; }
|
||||
ok() { PASS=$((PASS+1)); printf ' PASS %s\n' "$*"; }
|
||||
bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$*"; }
|
||||
skip() { SKIP=$((SKIP+1)); printf ' SKIP %s\n' "$*"; }
|
||||
|
||||
st() { ubus call appflow status 2>/dev/null; }
|
||||
# field <group> <key> e.g. field bytes leaked
|
||||
field() {
|
||||
st | sed -n "/\"$1\"/,/}/p" | sed -n "s/.*\"$2\": *\([0-9-]*\).*/\1/p" | head -1
|
||||
}
|
||||
wire() { cat "/sys/class/net/$VETH/statistics/tx_bytes" 2>/dev/null || echo 0; }
|
||||
|
||||
have_netns() {
|
||||
ip netns list 2>/dev/null | grep -q "^$NETNS" && [ -r "/sys/class/net/$VETH/statistics/tx_bytes" ]
|
||||
}
|
||||
|
||||
# A sustained NAT-ed transfer. The WAN here moves 50 MB in about a second, so an
|
||||
# ordinary download finishes long before anything interesting happens; the
|
||||
# throttle is TCP backpressure from a consumer that reads a fixed amount per
|
||||
# second. head -c reads an exact count, dd on a pipe does short reads and
|
||||
# throttles to a trickle instead.
|
||||
slow_pull() { # slow_pull <seconds> <blocks-per-second>
|
||||
local secs="$1" bps="$2"
|
||||
ip netns exec "$NETNS" sh -c "
|
||||
{ printf 'GET /__down?bytes=40000000 HTTP/1.0\r\nHost: speed.cloudflare.com\r\nConnection: close\r\n\r\n'
|
||||
sleep $secs
|
||||
} | socat - TCP:speed.cloudflare.com:80 2>/dev/null | {
|
||||
n=0
|
||||
while [ \"\$n\" -lt $secs ]; do
|
||||
head -c \$(( $bps * 65536 )) > /dev/null 2>&1 || break
|
||||
n=\$(( n + 1 )); sleep 1
|
||||
done
|
||||
}" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
ratio() { awk -v a="$1" -v w="$2" 'BEGIN{ if (w<=0) print "0"; else printf "%.3f", a/w }'; }
|
||||
within() { # within <value> <lo> <hi>
|
||||
awk -v v="$1" -v lo="$2" -v hi="$3" 'BEGIN{ exit !(v>=lo && v<=hi) }'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- health
|
||||
|
||||
test_health() {
|
||||
head2 "DAEMON HEALTH"
|
||||
|
||||
# busybox pgrep has no -c, so count the lines rather than asking it to.
|
||||
if pgrep -f '[a]ppflowd' >/dev/null 2>&1; then
|
||||
ok "appflowd is running"
|
||||
else
|
||||
bad "appflowd is not running"; return
|
||||
fi
|
||||
|
||||
[ -n "$(st)" ] && ok "ubus object 'appflow' answers" \
|
||||
|| bad "ubus call appflow status returned nothing"
|
||||
|
||||
case "$(st | sed -n 's/.*"connected": *\(true\|false\).*/\1/p' | head -1)" in
|
||||
true) ok "connected to netifyd's export socket" ;;
|
||||
*) bad "not connected to netifyd" ;;
|
||||
esac
|
||||
|
||||
# A daemon that has never seen an event is not a healthy daemon, it is a
|
||||
# daemon whose socket happens to be open.
|
||||
# Default the value: an empty field makes [ -gt ] abort the whole script with
|
||||
# "out of range" instead of simply failing this one check.
|
||||
local seen; seen=$(field flows seen); seen=${seen:-0}
|
||||
if [ "$seen" -gt 0 ] 2>/dev/null; then
|
||||
ok "flows have been observed (seen=$seen)"
|
||||
else
|
||||
bad "no flows observed at all"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------- conservation
|
||||
|
||||
test_conservation() {
|
||||
head2 "BYTE CONSERVATION (reported == attributed + shadowed)"
|
||||
# Both silent byte-loss defects this daemon has had would have shown here in
|
||||
# the first minute. This is the check that makes them loud.
|
||||
|
||||
have_netns || { skip "needs netns '$NETNS' (conservation)"; return; }
|
||||
|
||||
ubus call appflow reset >/dev/null 2>&1; sleep 2
|
||||
slow_pull 20 6
|
||||
sleep 25
|
||||
|
||||
local rep att sh lk
|
||||
rep=$(field bytes reported); att=$(field bytes attributed)
|
||||
sh=$(field bytes shadowed); lk=$(field bytes leaked)
|
||||
|
||||
if [ -z "$rep" ] || [ -z "$lk" ]; then
|
||||
bad "status.bytes is missing (is this appflow >= 1.0.2?)"; return
|
||||
fi
|
||||
printf ' reported=%s attributed=%s shadowed=%s leaked=%s\n' "$rep" "$att" "$sh" "$lk"
|
||||
|
||||
[ "$lk" = "0" ] && ok "leaked == 0 (nothing reported went unaccounted)" \
|
||||
|| bad "leaked == $lk, so bytes were reported and never attributed"
|
||||
|
||||
[ "${rep:-0}" -gt 0 ] 2>/dev/null && ok "traffic was actually observed" \
|
||||
|| bad "no bytes reported; the transfer did not happen"
|
||||
|
||||
# purge_only_rescued counts flows that were purged having never received a
|
||||
# flow_stats. Those carry their whole byte count on the purge event, and
|
||||
# before this path existed every one of them was silently discarded.
|
||||
#
|
||||
# It is NOT expected to be zero, and an earlier version of this suite
|
||||
# asserted that it was. Measured on this hardware: 13 of 1341 flows, 0.97%.
|
||||
# A 38-flow event-socket capture had seen none, which at that rate has an
|
||||
# expected count of 0.4 -- so the sample was far too small to support the
|
||||
# "netifyd never does this" conclusion that was briefly drawn from it, and
|
||||
# the review panel that reported the byte loss was right.
|
||||
#
|
||||
# What is worth watching is the RATE. A sudden jump means netifyd's event
|
||||
# contract has shifted; zero across a busy run means this path stopped
|
||||
# firing and the rescue may have regressed.
|
||||
local po seen; po=$(field bytes purge_only_rescued); po=${po:-0}
|
||||
seen=$(field flows seen); seen=${seen:-0}
|
||||
if [ "$seen" -gt 200 ] 2>/dev/null; then
|
||||
printf ' purge_only_rescued=%s of %s flows (%s%%)\n' "$po" "$seen" \
|
||||
"$(awk -v p="$po" -v s="$seen" 'BEGIN{printf "%.2f", 100*p/s}')"
|
||||
if awk -v p="$po" -v s="$seen" 'BEGIN{exit !(100*p/s <= 10)}'; then
|
||||
ok "purge-only rescues are a small fraction of flows"
|
||||
else
|
||||
bad "purge_only_rescued is $po of $seen flows; netifyd's contract may have shifted"
|
||||
fi
|
||||
else
|
||||
printf ' purge_only_rescued=%s (only %s flows seen; rate not meaningful)\n' "$po" "$seen"
|
||||
skip "too few flows to judge the purge-only rate"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- conntrack
|
||||
|
||||
test_conntrack() {
|
||||
head2 "NAT TWIN RESOLUTION (conntrack)"
|
||||
have_netns || { skip "needs netns '$NETNS' (conntrack)"; return; }
|
||||
|
||||
case "$(st | sed -n 's/.*"available": *\(true\|false\|null\).*/\1/p' | head -1)" in
|
||||
true) ok "conntrack is readable" ;;
|
||||
*) bad "conntrack unavailable; NAT twins cannot be resolved" ;;
|
||||
esac
|
||||
|
||||
ubus call appflow reset >/dev/null 2>&1; sleep 2
|
||||
slow_pull 15 6
|
||||
sleep 20
|
||||
|
||||
local tw un
|
||||
tw=$(field conntrack twins); un=$(field conntrack unresolved)
|
||||
printf ' twins=%s router_local=%s unresolved=%s entries=%s\n' \
|
||||
"$tw" "$(field conntrack router_local)" "$un" "$(field conntrack entries)"
|
||||
|
||||
# The first version of this fix cached the snapshot for 2 s, so new flows
|
||||
# almost always missed and it silently degraded to the pre-fix behaviour
|
||||
# while reporting conntrack as available. High unresolved against zero twins
|
||||
# is exactly that signature.
|
||||
if [ "${tw:-0}" -gt 0 ] 2>/dev/null; then
|
||||
ok "NAT twins are being resolved on evidence"
|
||||
elif [ "${un:-0}" -gt 0 ] 2>/dev/null; then
|
||||
bad "unresolved=$un with twins=0: resolution is degrading to the safe default"
|
||||
else
|
||||
skip "no external captures in this window; nothing to resolve"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- restart
|
||||
|
||||
test_restart() {
|
||||
head2 "RESTART MID-TRANSFER (reshadow must not double count)"
|
||||
have_netns || { skip "needs netns '$NETNS' (restart)"; return; }
|
||||
|
||||
# reshadow() used to return early on dev_key == "router", which is exactly
|
||||
# how a WAN-side capture of a NAT-ed client flow is classified, so after a
|
||||
# restart both copies of a flow stayed counted for its whole remaining life.
|
||||
#
|
||||
# READ THIS BEFORE TRUSTING A GREEN RESULT HERE. This check is a broad
|
||||
# regression guard, not a reliable detector of that defect. Measured against
|
||||
# the pre-fix v1.0.1 daemon: 0.701 and 0.791 on two runs on 2026-08-24, then
|
||||
# 1.012 on 2026-08-25 -- the same broken code passing, because whether the
|
||||
# defect manifests depends on a NAT-ed flow actually spanning the restart
|
||||
# and being captured on both interfaces, and a sixty-second window does not
|
||||
# guarantee that.
|
||||
#
|
||||
# So: a RED result here is strong evidence of a real problem. A GREEN result
|
||||
# is weak evidence of correctness, and one green run is not proof the
|
||||
# attribution is sound. The conservation counters above are the dependable
|
||||
# signal; this one catches gross breakage.
|
||||
# PRECONDITION, CHECKED RATHER THAN ASSUMED: netifyd must actually capture
|
||||
# the path this traffic takes. `wire()` reads the test client's own veth, so
|
||||
# it counts every byte the client sent regardless of which uplink carried
|
||||
# them, while appflow can only ever count what netifyd was told to watch.
|
||||
#
|
||||
# This bit on 2026-08-27. A bench router had a second uplink and a VPN
|
||||
# tunnel added to it while netifyd still ran `-I br-lan -E wan`, and the
|
||||
# ratio dropped to 0.62. That reads as an attribution defect and is not one:
|
||||
# the traffic left by a path netifyd was never watching. The unmodified
|
||||
# shipped daemon produced the same failure, which is the only reason it was
|
||||
# not chased as a regression.
|
||||
#
|
||||
# A ratio computed over an uncaptured path is not a weak signal, it is a
|
||||
# meaningless one, so this skips loudly instead of reporting a number.
|
||||
local caps uncap
|
||||
caps=$(ps w 2>/dev/null | sed -n 's/.*netifyd.*/&/p' | head -1)
|
||||
uncap=""
|
||||
for d in $(ip -4 route show default 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1)}' | sort -u); do
|
||||
case " $caps " in
|
||||
*" $d "*) ;;
|
||||
*) uncap="$uncap $d" ;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$uncap" ]; then
|
||||
skip "netifyd does not capture default-route interface(s):$uncap -- ratio would be meaningless"
|
||||
[ "$(field bytes leaked)" = "0" ] && ok "leaked still 0" || bad "leaked != 0"
|
||||
return 0
|
||||
fi
|
||||
|
||||
ubus call appflow reset >/dev/null 2>&1; sleep 2
|
||||
local w0; w0=$(wire)
|
||||
|
||||
slow_pull 45 6 &
|
||||
local job=$!
|
||||
sleep 12
|
||||
/etc/init.d/appflowd restart >/dev/null 2>&1
|
||||
sleep 40
|
||||
wait $job 2>/dev/null
|
||||
sleep 20
|
||||
|
||||
local w1 moved tot r
|
||||
w1=$(wire); moved=$(( w1 - w0 ))
|
||||
tot=$(st | sed -n 's/.*"bytes_total": *\([0-9]*\).*/\1/p' | head -1)
|
||||
[ -z "$tot" ] && tot=$(ubus call appflow summary 2>/dev/null | sed -n 's/.*"bytes_total": *\([0-9]*\).*/\1/p' | head -1)
|
||||
r=$(ratio "${tot:-0}" "$moved")
|
||||
printf ' wire=%s appflow=%s ratio=%s\n' "$moved" "${tot:-0}" "$r"
|
||||
|
||||
if [ "$moved" -lt 100000 ]; then
|
||||
skip "not enough traffic moved to judge the ratio"
|
||||
elif within "$r" 0.80 1.25; then
|
||||
ok "total stays near wire truth across a restart (ratio $r)"
|
||||
else
|
||||
bad "ratio $r is outside 0.80-1.25; a restart is mis-attributing"
|
||||
fi
|
||||
|
||||
[ "$(field bytes leaked)" = "0" ] && ok "leaked still 0 after a restart" \
|
||||
|| bad "leaked != 0 after a restart"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- acl
|
||||
|
||||
test_categories() {
|
||||
head2 "Every category this box's netifyd can emit has a translatable label"
|
||||
|
||||
# THE DRIFT GAP, and it is the one thing the offline suite structurally
|
||||
# cannot close. frontend-suite.js checks CATEGORY_LABELS against a list of
|
||||
# 43 tags hand-copied from netifyd 4.4.7. That catches a deleted entry. It
|
||||
# cannot catch netifyd ADDING a tag, because a list written today does not
|
||||
# know about it, and the product's response to an unknown tag is to
|
||||
# title-case it into credible English that is untranslatable for ever.
|
||||
#
|
||||
# Only a real box knows what its own netifyd carries. This check reads the
|
||||
# installed category file and asserts the frontend has a label for every
|
||||
# tag in it, so a netifyd upgrade that adds a category becomes a visible
|
||||
# test failure rather than a silently English label.
|
||||
#
|
||||
# SABOTAGE: delete any entry from CATEGORY_LABELS in common.js. This goes
|
||||
# red and names the tag. To see it fire for real, add a tag to
|
||||
# /etc/netify.d/netify-categories.json that the frontend does not know.
|
||||
local CATS=/etc/netify.d/netify-categories.json
|
||||
local JS=/www/luci-static/resources/view/appflow/common.js
|
||||
|
||||
[ -f "$CATS" ] || { skip "netify category file absent ($CATS)"; return; }
|
||||
[ -f "$JS" ] || { skip "appflow frontend not installed ($JS)"; return; }
|
||||
|
||||
local tags missing n
|
||||
tags=$(jsonfilter -i "$CATS" -e '@.application_tag_index' \
|
||||
-e '@.protocol_tag_index' 2>/dev/null |
|
||||
grep -o '"[a-z][a-z0-9-]*"' | tr -d '"' | sort -u)
|
||||
|
||||
if [ -z "$tags" ]; then
|
||||
skip "could not read any category tag out of $CATS"
|
||||
return
|
||||
fi
|
||||
|
||||
n=$(printf '%s\n' "$tags" | grep -c .)
|
||||
missing=""
|
||||
for t in $tags; do
|
||||
# The label table is `'tag': _('Label'),`. Matching the quoted key
|
||||
# against the file is enough: an entry that exists but is not wrapped
|
||||
# in _() is the offline suite's job, and it checks exactly that.
|
||||
grep -q "'$t':" "$JS" || missing="$missing $t"
|
||||
done
|
||||
|
||||
if [ -z "$missing" ]; then
|
||||
ok "all $n category tags on this box have a CATEGORY_LABELS entry"
|
||||
else
|
||||
bad "netifyd carries category tags the frontend cannot translate:$missing"
|
||||
fi
|
||||
}
|
||||
|
||||
test_acl() {
|
||||
head2 "ACL grants no more than the package uses"
|
||||
A=/usr/share/rpcd/acl.d/luci-app-appflow.json
|
||||
[ -f "$A" ] || { bad "ACL file not installed"; return; }
|
||||
|
||||
# /etc/config/network holds PPPoE and 802.1x credentials and wireguard keys.
|
||||
# A sibling package had this grant removed on 2026-08-25 for that reason.
|
||||
if grep -q '"network"' "$A"; then
|
||||
bad "ACL grants uci read on 'network' (PPPoE/wireguard secrets)"
|
||||
else
|
||||
ok "ACL does not grant uci read on 'network'"
|
||||
fi
|
||||
|
||||
# appflow reads nothing but its own ubus object, so a uci grant of any kind
|
||||
# would be a grant it does not use.
|
||||
if grep -q '"uci"' "$A"; then
|
||||
bad "ACL contains a uci grant; this package only uses its own ubus object"
|
||||
else
|
||||
ok "ACL contains no uci grant at all"
|
||||
fi
|
||||
|
||||
if grep -q '"file"' "$A"; then
|
||||
bad "ACL contains a file grant; this package shells out to nothing"
|
||||
else
|
||||
ok "ACL contains no file/exec grant"
|
||||
fi
|
||||
|
||||
# There IS a legitimate write: `reset` clears the live counters and the UI
|
||||
# offers it as a button. What must not appear is a write to anything else.
|
||||
W=$(sed -n '/"write"/,$p' "$A" | grep -oE '"[a-z_]+"' | grep -vE '"write"|"ubus"|"appflow"' | tr -d '"' | tr '\n' ' ')
|
||||
if [ "$(echo "$W" | tr -d ' ')" = "reset" ]; then
|
||||
ok "the only write grant is appflow.reset"
|
||||
else
|
||||
bad "write grants beyond appflow.reset: $W"
|
||||
fi
|
||||
|
||||
# The grant list must not exceed what the frontend actually binds. Checking
|
||||
# only for absent CATEGORIES missed this: the ACL granted `apps` and `flows`
|
||||
# that no view calls, and `flows` is the live per-connection table rather
|
||||
# than an aggregate. The code comment even said the two were deliberately
|
||||
# unbound, so the evidence was sitting next to the defect.
|
||||
V=/www/luci-static/resources/view/appflow/common.js
|
||||
if [ -r "$V" ]; then
|
||||
DECL=$(grep -oE "declare\('[a-z_]+'" "$V" | sed "s/declare('//;s/'//" | sort -u)
|
||||
GRANT=$(sed -n '/"read"/,/"write"/p' "$A" | grep -oE '"[a-z_]+"' \
|
||||
| grep -vE '"read"|"write"|"ubus"|"appflow"' | tr -d '"' | sort -u)
|
||||
EXTRA=$(for g in $GRANT; do echo "$DECL" | grep -qx "$g" || echo "$g"; done | tr '\n' ' ')
|
||||
if [ -z "$(echo "$EXTRA" | tr -d ' ')" ]; then
|
||||
ok "every granted read method is one the frontend declares"
|
||||
else
|
||||
bad "granted but never called by any view: $EXTRA"
|
||||
fi
|
||||
else
|
||||
skip "frontend not installed; cannot compare grants against declares"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- run
|
||||
|
||||
printf 'appflow hardware suite\n'
|
||||
printf 'group: %s netns: %s veth: %s\n' "$GROUP" "$NETNS" "$VETH"
|
||||
have_netns || printf 'note: netns/veth absent; traffic tests will skip\n'
|
||||
|
||||
case "$GROUP" in
|
||||
health) test_health ;;
|
||||
conservation) test_conservation ;;
|
||||
conntrack) test_conntrack ;;
|
||||
restart) test_restart ;;
|
||||
acl) test_acl ;;
|
||||
categories) test_categories ;;
|
||||
all) test_health; test_conservation; test_conntrack
|
||||
test_restart; test_acl; test_categories ;;
|
||||
*) printf "unknown group '%s'\n" "$GROUP"; exit 2 ;;
|
||||
esac
|
||||
|
||||
printf '\n----------------------------------------\n'
|
||||
printf 'passed %d, failed %d, skipped %d\n' "$PASS" "$FAIL" "$SKIP"
|
||||
exit $FAIL
|
||||
@@ -0,0 +1,252 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 VolanticSystems
|
||||
//
|
||||
// Load a real LuCI view module under Node, unmodified, so its exported pure
|
||||
// functions can be tested without a browser.
|
||||
//
|
||||
// WHY THIS EXISTS. Every browser-side defect this package has had was found by
|
||||
// a human or a review panel reading the code. Not one was found by a test,
|
||||
// because no test could run any of it: LuCI view files are not CommonJS or ES
|
||||
// modules, they are evaluated by LuCI's own loader with the `'require x';`
|
||||
// directives intercepted. So the JavaScript half of a package whose entire
|
||||
// safety promise lives in `audit()` had zero automated coverage.
|
||||
//
|
||||
// WHAT IS AND IS NOT STUBBED, because this is where a harness like this goes
|
||||
// wrong. The stubs supply DATA and inert plumbing only:
|
||||
//
|
||||
// baseclass.extend(o) -> returns o. LuCI returns a class whose singleton
|
||||
// exposes the same properties; callers use it as
|
||||
// `gen.audit()`, so a plain object is behaviourally
|
||||
// identical for these tests.
|
||||
// uci.sections(pkg) -> returns the fixture array the test supplied.
|
||||
// rpc / request -> throw if called. Nothing under test calls them,
|
||||
// and a stub that silently returned a plausible
|
||||
// value would be the reimplementation trap: the
|
||||
// test would pass against a product that had
|
||||
// stopped asking.
|
||||
//
|
||||
// NO LOGIC FROM THE MODULE IS COPIED HERE. The classification, generation and
|
||||
// validation code that runs is the shipped code, byte for byte.
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const path = require('path');
|
||||
|
||||
// Strip the LuCI loader directives. They are bare string-expression statements
|
||||
// at the top of the file, exactly `'require name';` or `'require name as x';`,
|
||||
// and they are the only thing preventing the file from evaluating as ordinary
|
||||
// JavaScript. Replaced with a blank line each so every reported line number
|
||||
// still matches the real file.
|
||||
// The same String.prototype.format, as SOURCE TEXT, so it can be evaluated
|
||||
// inside the vm realm where the module actually runs.
|
||||
const FORMAT_SRC = `
|
||||
Object.defineProperty(String.prototype, 'format', {
|
||||
value: function () {
|
||||
var args = arguments, i = 0;
|
||||
return this.replace(/%(?:(\d+)\$)?([sdifj%])/g, function (m, pos, kind) {
|
||||
if (kind === '%') return '%';
|
||||
var v = args[pos ? (parseInt(pos, 10) - 1) : i++];
|
||||
if (kind === 'd' || kind === 'i') return String(parseInt(v, 10));
|
||||
if (kind === 'f') return String(parseFloat(v));
|
||||
if (kind === 'j') return JSON.stringify(v);
|
||||
return (v == null) ? '' : String(v);
|
||||
});
|
||||
}
|
||||
});
|
||||
`;
|
||||
|
||||
function stripRequires(src) {
|
||||
return src.replace(/^\s*'require [^']*';\s*$/gm, '');
|
||||
}
|
||||
|
||||
// load(file, stubs, opts) -> the object the module returns.
|
||||
//
|
||||
// opts.translate replaces the _() implementation. Default is identity, which
|
||||
// is LuCI's real behaviour with no catalogue loaded.
|
||||
//
|
||||
// WHY A NON-IDENTITY TRANSLATOR IS WORTH HAVING. With identity, a label that
|
||||
// went through _() and a label a runtime title-caser produced are the same
|
||||
// string, so no assertion on the OUTPUT can tell them apart. That is not
|
||||
// hypothetical: three label paths shipped untranslatable underneath a green
|
||||
// run of a suite that asserted exactly those outputs. Loading the module a
|
||||
// second time with a marking translator makes the two observationally
|
||||
// different, and equality assertions become sound again rather than
|
||||
// coincidentally true. Raised by a review panel, 2026-08-30.
|
||||
function load(file, stubs, opts) {
|
||||
const translate = (opts && opts.translate) || ((s) => s);
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
// Every string passed to _() while the module evaluates, in order. This is
|
||||
// not decoration: it lets a test assert that the shipped catalogue
|
||||
// (po/templates/*.pot) actually covers the strings the code asks for, which
|
||||
// is otherwise a claim nobody checks until a translator files a bug.
|
||||
const translated = [];
|
||||
|
||||
const sandbox = Object.assign({
|
||||
baseclass: { extend: (o) => o },
|
||||
// LuCI's _() returns its msgid unchanged when no catalogue is loaded,
|
||||
// which is the default English case, so identity is the real
|
||||
// behaviour rather than a convenient stand-in.
|
||||
_: (s) => { translated.push(s); return translate(s); },
|
||||
N_: (n, s, p) => {
|
||||
translated.push(s); translated.push(p);
|
||||
return translate(n === 1 ? s : p);
|
||||
},
|
||||
L: {
|
||||
// L.resource() builds a static asset URL. It is a pure string
|
||||
// join in LuCI and is used at module top level, so it has to
|
||||
// return something or the file will not evaluate. This is the one
|
||||
// stub that returns a value rather than throwing, and it returns
|
||||
// an obviously-fake path so a test that accidentally depends on it
|
||||
// fails visibly rather than plausibly.
|
||||
resource: (...p) => '/TEST-RESOURCE/' + p.join('/'),
|
||||
|
||||
// L.toArray is a LuCI RUNTIME helper, not product logic, and this
|
||||
// is a faithful copy of it from luci-base's luci.js rather than an
|
||||
// approximation. Getting it wrong would make normalize() behave
|
||||
// differently here than in a browser, which is the one way a
|
||||
// harness like this lies convincingly: the product code would be
|
||||
// real and the answer would still be wrong.
|
||||
//
|
||||
// null/undefined -> []
|
||||
// array -> itself
|
||||
// object -> [ it ]
|
||||
// '' -> []
|
||||
// otherwise -> String(val).trim().split(/\s+/)
|
||||
toArray: (val) => {
|
||||
if (val == null) return [];
|
||||
if (Array.isArray(val)) return val;
|
||||
if (typeof val === 'object') return [ val ];
|
||||
const s = String(val).trim();
|
||||
return s === '' ? [] : s.split(/\s+/);
|
||||
},
|
||||
|
||||
// Everything else throws rather than returning something
|
||||
// plausible. A stub that silently answers is how a test starts
|
||||
// passing against a product that has stopped asking.
|
||||
error: (e) => { throw new Error('L.error: ' + e); },
|
||||
raise: (e) => { throw new Error('L.raise: ' + e); }
|
||||
},
|
||||
// LuCI's DOM element factory. The real one returns a live DOM node;
|
||||
// this returns an inspectable plain tree of the same shape.
|
||||
//
|
||||
// BE HONEST ABOUT WHAT THIS CAN AND CANNOT TEST. It shows which
|
||||
// element, class and text the code CHOSE to emit, which is exactly
|
||||
// where this project's browser defects have lived: a status pill that
|
||||
// rendered "healthy" for an unknown state, a tile whose colour came
|
||||
// back undefined. It says nothing about layout, CSS cascade, or what
|
||||
// a browser finally paints. Those still need a browser.
|
||||
E: function (tag, attrs, children) {
|
||||
if (attrs != null && (typeof attrs !== 'object' || Array.isArray(attrs))) {
|
||||
children = attrs;
|
||||
attrs = {};
|
||||
}
|
||||
return {
|
||||
tag: tag,
|
||||
attrs: attrs || {},
|
||||
children: children == null ? [] : [].concat(children)
|
||||
};
|
||||
},
|
||||
console: console
|
||||
}, stubs || {});
|
||||
|
||||
// The module body ends in a top-level `return`, which is only legal inside
|
||||
// a function, so wrap it in one.
|
||||
const fn = new vm.Script(
|
||||
'(function(){\n' + stripRequires(src) + '\n})()',
|
||||
{ filename: path.basename(file) }
|
||||
);
|
||||
// vm gives the module its own REALM, with its own String.prototype, so a
|
||||
// polyfill installed in Node's realm does not reach it. Build the context
|
||||
// first, install the LuCI runtime extensions INSIDE it, then evaluate.
|
||||
// Without this, product code calling '%s'.format() throws here while
|
||||
// working perfectly in a browser.
|
||||
const ctx = vm.createContext(sandbox);
|
||||
new vm.Script(FORMAT_SRC, { filename: 'luci-runtime-shim.js' }).runInContext(ctx);
|
||||
const mod = fn.runInContext(ctx);
|
||||
|
||||
// Non-enumerable so it cannot be mistaken for part of the module's own
|
||||
// export surface by a test that iterates the keys.
|
||||
Object.defineProperty(mod, '__translated', {
|
||||
value: translated, enumerable: false
|
||||
});
|
||||
return mod;
|
||||
}
|
||||
|
||||
// Every msgid in a .pot file. Used to assert the catalogue covers the strings
|
||||
// the code actually asks for.
|
||||
function potMsgids(file) {
|
||||
const out = new Set();
|
||||
const re = /^msgid "((?:[^"\\]|\\.)*)"/gm;
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
let m;
|
||||
while ((m = re.exec(src)) !== null) {
|
||||
const s = m[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\')
|
||||
.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
||||
if (s) out.add(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A uci stub whose sections() returns fixture data in LuCI's own shape:
|
||||
// each section is a flat object of its options plus '.name' and '.type'.
|
||||
function uciStub(sectionsByPackage) {
|
||||
return {
|
||||
sections: (pkg) => (sectionsByPackage[pkg] || []),
|
||||
get: (pkg, sec, opt) => {
|
||||
const list = sectionsByPackage[pkg] || [];
|
||||
const s = list.find((x) => x['.name'] === sec);
|
||||
return s ? s[opt] : undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Parse the output of `uci show <pkg>` into LuCI section objects.
|
||||
//
|
||||
// This is here so a fixture can be written in the SAME text the init script
|
||||
// parses, rather than as hand-built objects that have quietly drifted from
|
||||
// what uci actually emits. The two halves of the ownership rule are supposed
|
||||
// to see the same configuration; letting them be described twice, in two
|
||||
// notations, is how they stopped agreeing in the first place.
|
||||
function parseUciShow(text) {
|
||||
const sections = [];
|
||||
const byName = {};
|
||||
for (const line of text.split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
let m = t.match(/^([a-z0-9_-]+)\.([^.=]+)=(\S+)$/i);
|
||||
if (m) {
|
||||
const sec = { '.name': m[2], '.type': m[3] };
|
||||
byName[m[2]] = sec;
|
||||
sections.push(sec);
|
||||
continue;
|
||||
}
|
||||
m = t.match(/^([a-z0-9_-]+)\.([^.=]+)\.([^=]+)='(.*)'$/i);
|
||||
if (m && byName[m[2]]) byName[m[2]][m[3]] = m[4];
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
// Flatten an E()-tree to its visible text, and to the set of class names used.
|
||||
// Both are what a test wants to assert on: "which pill did it choose", not
|
||||
// "what did the browser paint".
|
||||
function textOf(node) {
|
||||
if (node == null || node === false) return '';
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
||||
if (Array.isArray(node)) return node.map(textOf).join('');
|
||||
if (node.children) return node.children.map(textOf).join('');
|
||||
return '';
|
||||
}
|
||||
|
||||
function classesOf(node, out) {
|
||||
out = out || new Set();
|
||||
if (node == null || typeof node !== 'object') return out;
|
||||
if (Array.isArray(node)) { node.forEach((n) => classesOf(n, out)); return out; }
|
||||
const c = node.attrs && (node.attrs['class'] || node.attrs.class);
|
||||
if (typeof c === 'string') c.trim().split(/\s+/).forEach((x) => x && out.add(x));
|
||||
(node.children || []).forEach((n) => classesOf(n, out));
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = { load, uciStub, parseUciShow, stripRequires, potMsgids, textOf, classesOf };
|
||||
Executable
+1873
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user