💋 Sync 2026-10-04 04:16:09

This commit is contained in:
github-actions[bot] committed 2026-10-04 04:16:09 +08:00
1 parent f6b2df73ec
commit f62992fd80
745 files changed
+212416 -1173

No files matched your search

+653
View File
@@ -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);