Files
op-packages/luci-app-appflow/tests/frontend-suite.js
T

654 lines
32 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);