update 2026-09-20 01:22:33

This commit is contained in:
action committed 2026-09-20 01:22:33 +08:00
1 parent 42e0cfbec4
commit deb27aee9d
864 files changed
+180566 -42

No files matched your search

+310
View File
@@ -0,0 +1,310 @@
/* `_sanitizeSvg` (fs-assets.js), issue 0157: the uploaded-pattern path now CLEANS an SVG instead of
* refusing it — the elements and attributes `_svgObjection` used to refuse the whole file over are
* now removed one at a time, and only four things stay an outright refusal: not an SVG, a parser
* error, the wrong namespace, or nothing left once the unsafe parts are gone. This is NOT the
* security boundary (root/www/cgi-bin/luci-theme-footstrap-pattern's CSP header is); it exists so an
* admin whose export carries an editor artefact gets a working tile and a note, not a refusal.
*
* `_sanitizeSvg` needs a `DOMParser` and an `XMLSerializer`, which Node ships neither of. What
* follows is NOT a browser's XML engine: it is a small tree built from a regex-flat tokenizer, wide
* enough to answer the questions `_sanitizeSvg` actually asks — tag/attribute names and values,
* nesting one level deep or many, element removal, attribute removal, and a serializer that can
* write the tree back out. Entities and text nodes are still not modelled, but element namespaces
* ARE (security review finding, LOW): every `xmlns`/`xmlns:prefix` an element carries is tracked and
* inherited down the tree the way Namespaces in XML §5.3 resolves one, `xml:` bound to its reserved
* URI with no declaration needed (§4), so a fixture can put an element in a namespace its own tag
* text does not name — the only way to prove `localName`-based removal is genuinely
* namespace-oblivious, and the only way to write a root whose SPELLING is `svg` but whose NAMESPACE
* is not. So this proves the FUNCTION's decisions — which tag or attribute is removed, which
* declaration inside a `style` survives, when the original bytes come back untouched, which
* namespace an element resolves to — and not that a real engine tokenises `url(`/CSS escapes the
* same way; that half is the comment's own citations in fs-assets.js (measured executing on …), not
* this file's. */
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadModule } from './lib/luci-module.mjs';
const SVG_NS = 'http://www.w3.org/2000/svg';
const XML_NS = 'http://www.w3.org/XML/1998/namespace';
/* ---- the fake tree: just enough of Element/ProcessingInstruction/Document to sanitize ---- */
const TAG_TOKEN_RE = /<\?([^>]*)\?>|<!--[\s\S]*?-->|<\/([A-Za-z_:][-\w:.]*)\s*>|<([A-Za-z_:][-\w:.]*)([^<>]*?)\/>|<([A-Za-z_:][-\w:.]*)([^<>]*)>/g;
const ATTR_RE = /([A-Za-z_:][-\w:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
function parseAttrs(blob) {
const attrs = [];
ATTR_RE.lastIndex = 0;
let am;
while ((am = ATTR_RE.exec(blob)) !== null)
attrs.push({ name: am[1], value: am[2] !== undefined ? am[2] : am[3] });
return attrs;
}
class FakeElement {
constructor(tagName, attrs) {
this.tagName = tagName;
this.nodeName = tagName;
this.localName = tagName.includes(':') ? tagName.split(':').pop() : tagName;
this.namespaceURI = null; /* resolved by parseXml once the in-scope declarations are known */
this.nodeType = 1;
this._attrs = attrs;
this.childNodes = [];
this.parentNode = null;
this._removed = false;
}
get attributes() { return this._attrs.slice(); }
get children() { return this.childNodes.filter((n) => n.nodeType === 1); }
get isConnected() { return !this._removed && (!this.parentNode || this.parentNode.isConnected); }
setAttribute(name, value) {
const a = this._attrs.find((x) => x.name === name);
if (a) a.value = value; else this._attrs.push({ name, value });
}
removeAttribute(name) { this._attrs = this._attrs.filter((x) => x.name !== name); }
appendChild(node) { node.parentNode = this; this.childNodes.push(node); return node; }
remove() {
this._removed = true;
if (this.parentNode) this.parentNode.childNodes = this.parentNode.childNodes.filter((n) => n !== this);
this.parentNode = null;
}
querySelectorAll() {
const out = [];
const walk = (n) => { for (const c of n.childNodes) if (c.nodeType === 1) { out.push(c); walk(c); } };
walk(this);
return out;
}
}
class FakePI {
constructor(data) { this.nodeType = 7; this.data = data; this.parentNode = null; this._removed = false; }
remove() {
this._removed = true;
if (this.parentNode) this.parentNode.childNodes = this.parentNode.childNodes.filter((n) => n !== this);
this.parentNode = null;
}
}
/* Real DOMParser never exposes the XML DECLARATION (`<?xml version="1.0"?>`) as a node at all — it
* is consumed by the parser, and "xml" is a reserved PI target no document may actually use for one
* (XML 1.0 §2.6). An ordinary PI (`<?xml-stylesheet …?>`) IS a node. Skipping the declaration here
* is what lets an Inkscape-shaped fixture that opens with one round-trip untouched, the way it does
* in a real browser. */
function isXmlDeclaration(data) { return (/^xml\s/i).test(data) || (/^xml$/i).test(data); }
/* The top-level container itself: a real `document.childNodes` is live, and `n.remove()` on a
* top-level PROCESSING_INSTRUCTION removes it from THAT list, not from a plain array snapshot —
* `doc` needs to be something a node's `remove()` can reach back into, the same as an element's
* `parentNode` does for a nested one. */
class FakeDocument {
constructor() { this.childNodes = []; }
get isConnected() { return true; }
appendChild(n) { n.parentNode = this; this.childNodes.push(n); return n; }
querySelector() { return null; }
}
/* The bindings in scope AT an element: `xmlns` sets the no-prefix default, `xmlns:x` sets prefix
* `x` — both ordinary attributes as well as declarations, so they stay in `attrs` untouched; this
* only reads them back out. */
function nsDeclarations(attrs) {
const decls = new Map();
for (const a of attrs) {
if (a.name === 'xmlns') decls.set('', a.value);
else if (a.name.slice(0, 6) === 'xmlns:') decls.set(a.name.slice(6), a.value);
}
return decls;
}
/* `xml` resolves to its fixed URI even with no declaration in scope at all (Namespaces in XML §4);
* every other prefix, including the no-prefix default (`''`), is whatever the nearest declaration
* up the tree set it to, or `null` if nothing ever did. */
function resolveNs(prefix, scope) {
if (prefix === 'xml') return XML_NS;
return scope.has(prefix) ? scope.get(prefix) : null;
}
function parseXml(text) {
const doc = new FakeDocument();
doc._nsScope = new Map();
let root = null;
const stack = [];
TAG_TOKEN_RE.lastIndex = 0;
let m;
while ((m = TAG_TOKEN_RE.exec(text)) !== null) {
if (m[1] !== undefined) {
if (isXmlDeclaration(m[1])) continue;
const pi = new FakePI(m[1]);
(stack.length ? stack[stack.length - 1] : doc).appendChild(pi);
continue;
}
if (m[2] !== undefined) { stack.pop(); continue; }
const selfClose = m[3] !== undefined;
const tag = selfClose ? m[3] : m[5];
if (tag === undefined) continue; /* a comment: no capture group matched */
const attrsBlob = selfClose ? m[4] : m[6];
const attrs = parseAttrs(attrsBlob);
const el = new FakeElement(tag, attrs);
/* An element's own `xmlns`/`xmlns:*` apply to ITSELF too, not only its descendants — so they
* are folded into a copy of the parent's scope before this element's own prefix is looked up
* in it, the same order a real namespace-aware parser resolves in. */
const parentScope = stack.length ? stack[stack.length - 1]._nsScope : doc._nsScope;
const scope = new Map(parentScope);
for (const [ prefix, uri ] of nsDeclarations(attrs)) scope.set(prefix, uri);
el._nsScope = scope;
el.namespaceURI = resolveNs(tag.includes(':') ? tag.split(':')[0] : '', scope);
(stack.length ? stack[stack.length - 1] : doc).appendChild(el);
if (!root && !stack.length) root = el;
if (!selfClose) stack.push(el);
}
doc.documentElement = root;
return doc;
}
class FakeDOMParser {
parseFromString(text) { return parseXml(text); }
}
function serializeAttrs(el) {
return el.attributes.map((a) => ' ' + a.name + '="' + String(a.value).replace(/"/g, '&quot;') + '"').join('');
}
function serializeNode(n) {
if (n.nodeType === 7) return '<?' + n.data + '?>';
const attrs = serializeAttrs(n);
if (!n.childNodes.length) return '<' + n.tagName + attrs + '/>';
return '<' + n.tagName + attrs + '>' + n.childNodes.map(serializeNode).join('') + '</' + n.tagName + '>';
}
class FakeXMLSerializer {
serializeToString(doc) { return doc.childNodes.map(serializeNode).join(''); }
}
function svg(inner) {
return '<svg xmlns="' + SVG_NS + '">' + inner + '</svg>';
}
function sanitize(text) {
globalThis.DOMParser = FakeDOMParser;
globalThis.XMLSerializer = FakeXMLSerializer;
/* bare globals, the same way `installBrowserGlobals()` supplies MutationObserver etc for other
* suites: a `new Function(...)`-built factory runs in the GLOBAL scope, so `window._`/`window.E`
* in luci-module.mjs's fakes never reach a bare `_(...)`/`Node.…` reference at module top level. */
globalThis.Node = globalThis.Node || { PROCESSING_INSTRUCTION_NODE: 7 };
globalThis._ = globalThis._ || ((s) => s);
const mod = loadModule('fs-assets', { stubs: { rpc: { declare: () => () => {} }, ui: { addNotification: () => {} } } });
return mod._sanitizeSvg(text);
}
/* Walks the OUTPUT text back through the same fake parser, so a "still parses as SVG" assertion
* checks the serializer's own output rather than trusting the counters alone. */
function reparse(text) {
return new FakeDOMParser().parseFromString(text);
}
test('a clean Inkscape-shaped file round-trips byte-identical', () => {
const original = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n' +
svg('<path d="M0 0" style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none"/>');
const result = sanitize(original);
assert.equal(result.error, undefined);
assert.equal(result.text, original);
assert.equal(result.elements, undefined);
assert.equal(result.refs, undefined);
});
test('a style attribute referencing a same-document fragment survives untouched', () => {
const original = svg('<path style="fill:url(#grad)"/>');
const result = sanitize(original);
assert.equal(result.error, undefined);
assert.equal(result.text, original);
});
test('a script element is removed and the result still parses as SVG', () => {
const result = sanitize(svg('<script>alert(1)</script><path d="M0 0"/>'));
assert.equal(result.error, undefined);
assert.equal(result.elements, 1);
assert.equal(result.refs, 0);
assert.ok(!(/script/i).test(result.text));
const doc = reparse(result.text);
assert.equal(doc.documentElement.localName, 'svg');
assert.ok(!doc.documentElement.querySelectorAll().some((el) => el.localName === 'script'));
});
test('an onload handler and a javascript: xlink:href are removed', () => {
const original = svg('<path onload="alert(1)" d="M0 0"/><a xlink:href="javascript:alert(1)"><path d="M1 1"/></a>');
const result = sanitize(original);
assert.equal(result.error, undefined);
assert.equal(result.elements, 0);
assert.equal(result.refs, 2);
assert.ok(!(/onload/i).test(result.text));
assert.ok(!(/javascript:/i).test(result.text));
});
test('style="background:url(//evil)" loses that declaration and keeps the rest', () => {
const result = sanitize(svg('<rect style="fill:#fff;background:url(//evil.example/x.png)"/>'));
assert.equal(result.error, undefined);
assert.equal(result.refs, 1);
assert.ok(!(/evil\.example/).test(result.text));
assert.ok((/fill:#fff/).test(result.text));
});
test('a CSS hex-escape hiding url() in a style attribute is stripped — closes the LOW finding', () => {
/* `\72` is 'r': a real tokenizer resolves `u\72l(` to `url(` (CSS Syntax Level 3 §4.3.7). The
* finding this closes: the old literal `url\s*\(` match never saw it. */
const result = sanitize(svg('<rect style="background:u\\72l(//evil.example/x)"/>'));
assert.equal(result.error, undefined);
assert.equal(result.refs, 1);
assert.ok(!(/evil\.example/).test(result.text));
});
test('a file empty after cleaning is refused, not silently emptied', () => {
const result = sanitize(svg('<script>alert(1)</script>'));
assert.ok(result.error);
assert.equal(result.text, undefined);
});
test('a non-SVG file is refused', () => {
const result = sanitize('not xml at all');
assert.ok(result.error);
assert.equal(result.text, undefined);
});
test('a processing instruction outside the root is removed', () => {
const result = sanitize('<?xml-stylesheet href="evil.xsl" type="text/xsl"?>' + svg('<path d="M0 0" style="fill:red"/>'));
assert.equal(result.error, undefined);
assert.equal(result.elements, 1);
assert.ok(!(/xml-stylesheet/i).test(result.text));
});
test('a root spelled svg but namespaced xhtml is refused — the namespace check, not the spelling', () => {
/* Only provable once the fake resolves namespaces for real: with a hardcoded SVG_NS every root
* looked clean regardless of its own xmlns, so this fixture could not previously fail the way
* production's check means it to. */
const result = sanitize('<svg xmlns="http://www.w3.org/1999/xhtml"><path d="M0 0"/></svg>');
assert.ok(result.error);
assert.equal(result.text, undefined);
});
test('a prefixed s:script in the SVG namespace is removed by localName, not by its qualified name', () => {
const result = sanitize(svg('<s:script xmlns:s="' + SVG_NS + '">alert(1)</s:script><path d="M0 0"/>'));
assert.equal(result.error, undefined);
assert.equal(result.elements, 1);
assert.ok(!(/alert/i).test(result.text));
});
test('an unprefixed script re-namespaced to xhtml is still removed — the check is namespace-oblivious', () => {
/* The child's own `xmlns` overrides the root's default, so this element genuinely resolves to the
* xhtml namespace rather than inheriting SVG_NS — proving `_sanitizeSvg` removes it on `localName`
* alone, the same as it would in a real engine, and not because the fake happened to agree. */
const result = sanitize(svg('<script xmlns="http://www.w3.org/1999/xhtml">alert(1)</script><path d="M0 0"/>'));
assert.equal(result.error, undefined);
assert.equal(result.elements, 1);
assert.ok(!(/alert/i).test(result.text));
});
test('xml:base on a non-root element is removed, closing the relative href it would redirect off-router — LOW finding', () => {
/* No scheme, no leading `//` — the href-off-router check below has nothing to catch here, because
* the base moved instead of the reference. Nested under a non-root element on purpose: the check
* has to walk the whole tree, not just the root, to close this. */
const result = sanitize(svg('<g xml:base="http://evil.example/"><image href="x.png"/></g>'));
assert.equal(result.error, undefined);
assert.equal(result.refs, 1);
assert.ok(!(/evil\.example/).test(result.text));
assert.ok((/href="x\.png"/).test(result.text));
});
@@ -0,0 +1,131 @@
/* The chrome's cut, asked once and answered the same way twice.
*
* "How wide is the content column" is asked in two places for two reasons: `fitShell()` decides
* whether the sidebar still fits beside it, and `contentWidth()` answers a table's mid-scroll
* question, where reading layout is forbidden. They are the same arithmetic — the window less the
* sidebar (or the rail, or nothing) less the shell's gutter — and written twice they drifted:
* `--fs-content-pad` is one side's padding, already doubled by shellGeometry(), and contentWidth
* subtracted it a second time: 56 CSS px of column that does not exist. And `data-narrow` is not the
* only chrome with no sidebar beside it — fitShell returns early on the top-BAR layout and removes
* the attribute on the way out, so contentWidth, which read only that attribute, went on
* subtracting a 224px sidebar from a window that has none.
*
* Neither is visible in a screenshot: the wrong answer only matters when it crosses fs-select's
* CRAMPED threshold, and then it cards a table that had room and un-cards it a moment later. What
* this test may NOT check — that the inputs describe the real page — is checked on a stand, where
* tools/live-audit.mjs compares contentWidth() against the live `.fs-content` box. */
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadModule, fakeDocument, installBrowserGlobals } from './lib/luci-module.mjs';
installBrowserGlobals();
const chrome = () => loadModule('fs-chrome');
/* what shellGeometry() reads back from 02-tokens.css at the default density — contentPad is the
* gutter of BOTH sides, exactly as that function returns it */
const G = { contentMin: 500, sidebarW: 224, railW: 68, contentPad: 56, contentMax: 1280 };
function width(state) {
return chrome().columnWidth(G, Object.assign({ narrow: false, top: false, rail: false }, state));
}
test('the sidebar and ONE gutter come off the window', () => {
assert.equal(width({ outerW: 1280 }), 1000);
/* the number fs-select's comment quotes: an 800px window is a 520px column */
assert.equal(width({ outerW: 800 }), 520);
});
test('the rail is a narrower sidebar, not a missing one', () => {
assert.equal(width({ outerW: 800, rail: true }), 800 - G.railW - G.contentPad);
});
test('a narrow shell has the chrome above the content, so nothing is cut', () => {
assert.equal(width({ outerW: 800, narrow: true }), 800 - G.contentPad);
assert.equal(width({ outerW: 800, narrow: true, rail: true }), 800 - G.contentPad,
'the rail preference is meaningless once the sidebar is a bar');
});
test('the top-bar layout has no sidebar either, and it carries no data-narrow', () => {
/* fitShell() returns early on this layout AND removes data-narrow, so `narrow` is false here —
* which is exactly how 224px went on being subtracted from a window with no sidebar in it */
assert.equal(width({ outerW: 900, top: true }), 900 - G.contentPad);
assert.equal(width({ outerW: 900, top: true, rail: true }), 900 - G.contentPad);
});
test('the gutter is whatever the sheet gave the column, not what the token says', () => {
/* `theme/20-shell.css` re-paddings `.fs-content` to `--fs-space-4` below 767px, so the gutter
* there is 16px a side against the token's 28 — a 24px error the model carried on every phone
* width until `live-audit` reported it at 320, 390 and 568 on every page. Hence `g.contentPad`
* is MEASURED off the element (see contentGutter in fs-chrome.js) and this function only ever
* subtracts what it is handed. The breakpoint itself never enters the JS. */
const phone = Object.assign({}, G, { contentPad: 32 });
assert.equal(chrome().columnWidth(phone, { outerW: 390, narrow: true, top: false, rail: false }), 358);
assert.equal(chrome().columnWidth(phone, { outerW: 568, narrow: true, top: false, rail: false }), 536);
});
test('an omitted flag means the sidebar is there', () => {
/* fitShell() calls this with `{ outerW, rail }` and nothing else, deliberately: it is the pass
* that DECIDES `data-narrow`, so it may not read it, and the top layout returned before it got
* here. Anything that made an absent flag mean something other than "sidebar present" would
* move the fold threshold without touching a line of fitShell. */
assert.equal(chrome().columnWidth(G, { outerW: 800, rail: false }), 520);
assert.equal(chrome().columnWidth(G, { outerW: 800, rail: true }), 800 - G.railW - G.contentPad);
});
test('the column stops growing at --fs-content-max', () => {
/* `.fs-content` is `max-width: var(--fs-content-max); margin: 0 auto`, so past that width the
* surplus is margin, not column. Without the cap a 2560px sidebar layout answered 2280 for a
* column that is 1224 wide — no caller can reach that region (both ask a lower bound), which is
* exactly why it needs a test rather than a reader's trust. */
assert.equal(width({ outerW: 2560 }), G.contentMax - G.contentPad);
assert.equal(width({ outerW: 2560, top: true }), G.contentMax - G.contentPad);
/* and the cap does not bind before it should: at the width where window-minus-sidebar first
* reaches the cap, the two agree */
assert.equal(width({ outerW: G.contentMax + G.sidebarW }), G.contentMax - G.contentPad);
assert.equal(width({ outerW: G.contentMax + G.sidebarW - 100 }), G.contentMax - 100 - G.contentPad);
});
test('a column is never negative', () => {
assert.equal(width({ outerW: 10 }), 0);
});
/* issue #44: the Content width axis (fs-axes.js CWIDTH) raises --fs-content-max past the 1280px
* default by overriding the token, not by changing columnWidth()'s arithmetic — so the cap test
* above is re-run at other caps rather than edited. The axis is a slider over 1280-3840, so the
* caps here are arbitrary points in that range and one at each end, not named steps. */
test('the column stops growing at a raised cap, wherever the slider puts it', () => {
const cap = (n) => Object.assign({}, G, { contentMax: n });
const cw = (g, outerW) => chrome().columnWidth(g, { outerW, narrow: false, top: false, rail: false });
for (const n of [ 1280, 1440, 1600, 2200, 3840 ]) {
/* below the cap it is the window that binds, exactly as at the default */
assert.equal(cw(cap(n), n), n - G.sidebarW - G.contentPad);
/* past it the cap holds the column rather than letting it follow the window */
assert.equal(cw(cap(n), 5000), n - G.contentPad);
}
});
test('the fold threshold is the same arithmetic fitShell folds on', () => {
/* fitShell(): data-narrow iff the column is under --fs-content-min. At exactly the floor the
* sidebar stays — the comparison is `<`, and this is the boundary a token change moves. */
const floor = G.sidebarW + G.contentPad + G.contentMin;
assert.equal(width({ outerW: floor }), G.contentMin);
assert.equal(width({ outerW: floor - 1 }) < G.contentMin, true);
});
/* contentWidth() answers off the width fitChrome() last measured, and fitChrome() stands aside for
* the whole SCROLL_IDLE window when a resize lands mid-flick — so the one read it makes itself is the
* window's width, compared against the cached one. Measured before this: 568px reported for a 390px
* viewport for up to 220ms after the rotate, and fs-select judged a table to have room it did not. */
test('contentWidth() notices a window that changed width with no fitter having run', () => {
const root = { clientWidth: 1280, getAttribute: () => null, hasAttribute: () => false, setAttribute() {},
dataset: {}, classList: { add() {}, remove() {}, contains: () => false } };
const doc = fakeDocument({ documentElement: root, body: { appendChild() {}, getAttribute: () => null },
querySelector: () => ({}),
createElement: () => ({ style: { setProperty() {} }, setAttribute() {} }) });
const c = loadModule('fs-chrome', { document: doc,
stubs: { 'fs-prefs': { isTopLayout: () => false, currentRail: () => false } } });
assert.equal(c.contentWidth(), 1280 - G.sidebarW - G.contentPad);
root.clientWidth = 800;
assert.equal(c.contentWidth(), 800 - G.sidebarW - G.contentPad, 'the rotate is seen on the next call');
});
@@ -0,0 +1,33 @@
/* The surface the live gate measures through.
*
* tools/scroll-anchor.mjs parks the reader and then has to wait until fs-fit has taken a reference
* at that offset — anything measured before that measures the guard, not the anchor. Nothing in the
* page can answer it: `scrolling()` says "no" both before the motion sampler starts and after it
* finishes, 1.5 seconds apart in WebKit. So the module exports `restAt()` and the gate waits on it.
*
* A rename or a drop here fails nothing: the gate's `try` swallows it, falls back to a flat wait and
* goes green while measuring the wrong moment — which is exactly how a 60px-per-tick drift on Safari
* stayed invisible for a release. This test is what makes that a red gate instead. */
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadModule, installBrowserGlobals } from './lib/luci-module.mjs';
installBrowserGlobals();
const fit = () => loadModule('fs-fit', { stubs: { baseclass: { extend: (o) => o } } });
test('fs-fit exports the two the live gate waits on', () => {
const f = fit();
assert.equal(typeof f.scrolling, 'function', 'scrolling() is what says the page is moving');
assert.equal(typeof f.restAt, 'function', 'restAt() is what says a reference has been taken');
});
test('restAt() is null until a still moment has been measured', () => {
const f = fit();
assert.equal(f.restAt(), null, 'a module that has never settled must not claim an offset');
});
test('scrolling() is false on a page nobody has touched', () => {
const f = fit();
assert.equal(f.scrolling(), false);
});
@@ -0,0 +1,35 @@
/* fs-fit's registration run: a fitter that throws must not take the ones registered after it.
*
* The five passes in fs-select.js are registered separately so each fails alone, and every later run
* goes through runAll()'s try/catch — but a bare call in `add()` lets a throw on the FIRST run
* propagate out of init(), so the registrations behind it are never made. With the "an unanswered
* table takes no room" gate already raised, that is a page whose data tables are `display: none`
* permanently.
*
* A stand cannot show this: no shipped fitter throws, and one that did would be a bug fixed rather
* than a case to keep. Driving `add()` directly is the only way to hold the guarantee. */
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadModule, installBrowserGlobals } from './lib/luci-module.mjs';
installBrowserGlobals();
const fit = () => loadModule('fs-fit', { stubs: { baseclass: { extend: (o) => o } } });
test('a fitter that throws on registration does not escape add()', () => {
const f = fit();
assert.doesNotThrow(() => f.add(() => { throw new Error('third-party markup surprised me'); }));
});
test('the registrations after a throwing one still happen, and still run', () => {
const f = fit();
let ran = 0;
f.add(() => { throw new Error('boom'); });
f.add(() => { ran++; });
assert.equal(ran, 1, 'a fitter registered after a throwing one must run on registration');
});
test('add() ignores anything that is not a function', () => {
const f = fit();
assert.doesNotThrow(() => { f.add(null); f.add('fitTables'); f.add(undefined); });
});
@@ -0,0 +1,145 @@
/* fs-fit's correction state machine, driven by numbers rather than by a page.
*
* tools/scroll-anchor.mjs proves the corrections hold on a real engine; this proves the decisions
* between them — which exit a correction takes, when the engine stops being trusted and when trust
* comes back, whose scroll event the motion sampler ignores — because a stand shows only the offset
* at the end, and every one of these decisions has a twin that ends at the same offset for the wrong
* reason. The sweep reads the same exports this file asserts on. */
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { page, el, settle } from './lib/fit-page.mjs';
const GROW = 120;
/* a poll refill: the floored box is `by` taller than the floor it wears */
function refill(p, box, by) {
box.rect.height = parseFloat(box.style.minHeight) + by;
p.observe().content.cb([ p.record(box) ]);
}
test('the sweep can read every export it waits on', () => {
const { fit } = page();
for (const name of [ 'scrolling', 'restAt', 'engineTrusted', 'lateWhy', 'lateTrail', 'anchorWhy', 'anchorTrail' ])
assert.equal(typeof fit[name], 'function', name);
assert.equal(fit.engineTrusted(), true, 'an engine that knows overflow-anchor is trusted at load');
assert.equal(fit.lateWhy(), null);
assert.deepEqual(fit.lateTrail(), []);
});
test('a refill the trusted engine did not anchor is written back, and two of them cost the trust', async () => {
const p = page({ boxes: [ { minHeight: 500, height: 500 } ] });
const [ box ] = p.boxes;
p.fit.schedule();
await settle();
assert.equal(p.fit.restAt(), 1000, 'a still page has a reference');
refill(p, box, GROW);
await settle();
assert.equal(p.fit.lateWhy(), 'wrote-' + GROW, 'the growth witness carries the whole correction');
assert.equal(p.win.scrollY, 1000 + GROW);
assert.equal(box.style.minHeight, '620px', 'the floor follows the content');
assert.equal(p.fit.engineTrusted(), true, 'one miss is headroom');
refill(p, box, GROW);
await settle();
assert.equal(p.fit.lateWhy(), 'wrote-' + GROW);
assert.equal(p.win.scrollY, 1000 + 2 * GROW);
assert.equal(p.fit.engineTrusted(), false, 'the second miss moves the fork');
assert.match(p.fit.lateTrail().at(-1), /^wrote-120@\d+$/, 'the trail carries the clock');
});
test('trust comes back after two refills the engine handled on its own', async () => {
const p = page({ boxes: [ { minHeight: 500, height: 500 } ] });
const [ box ] = p.boxes;
p.fit.schedule();
await settle();
refill(p, box, GROW); await settle();
refill(p, box, GROW); await settle();
assert.equal(p.fit.engineTrusted(), false);
/* the reference holds its screen position across the refill: the engine's own work */
refill(p, box, GROW); await settle();
assert.equal(p.fit.engineTrusted(), false, 'one hit is not yet recovery');
assert.equal(p.fit.anchorWhy(), 'no-drift', 'the fast path had nothing to write');
refill(p, box, GROW); await settle();
assert.equal(p.fit.engineTrusted(), true);
assert.equal(p.win.scrollY, 1000 + 2 * GROW, 'recovery wrote nothing');
});
test('the theme reads its own write as settling, not as the reader moving', async () => {
const p = page({ engine: 'off', boxes: [ { minHeight: 500, height: 500 } ] });
p.fit.schedule();
await settle();
assert.equal(p.fit.restAt(), 1000);
/* content grew above the reference: its top moved down by GROW, the offset did not follow */
p.ref.rect.top += GROW;
p.observe().content.cb([ p.record(p.boxes[0]) ]);
await settle();
assert.equal(p.fit.anchorWhy(), 'wrote-' + GROW);
assert.equal(p.win.scrollY, 1000 + GROW);
p.scroll();
assert.equal(p.fit.scrolling(), false, 'the scroll event of the write itself');
p.scroll();
assert.equal(p.fit.scrolling(), false, 'asked twice of the same pixel, answered the same');
p.win.scrollY += 50;
p.scroll();
assert.equal(p.fit.scrolling(), true, 'a different pixel is the reader');
});
test('a batch that only removes nodes is not a page to correct against', async () => {
const p = page({ boxes: [ { minHeight: 500, height: 500 } ] });
const [ box ] = p.boxes;
p.fit.schedule();
await settle();
p.observe().content.cb([ p.record(box, { added: 0, removed: 3 }) ]);
assert.equal(p.fit.lateWhy(), 'emptying');
await settle();
assert.equal(p.win.scrollY, 1000, 'nothing written');
});
test('#view emptied and refilled is a page swap: the reference is dropped', async () => {
const p = page({ boxes: [ { minHeight: 500, height: 500 } ] });
p.fit.schedule();
await settle();
assert.equal(p.fit.restAt(), 1000);
p.observe().content.cb([ p.record(p.host, { added: 4, removed: 4 }) ]);
assert.equal(p.fit.restAt(), null);
assert.equal(p.fit.lateWhy(), null, 'no correction was armed');
});
test('a floor is re-measured only where the mutation reached', async () => {
const p = page({ boxes: [ { minHeight: 100, height: 150 }, { minHeight: 200, height: 260 } ] });
const [ a, b ] = p.boxes;
const inB = el({ parent: b });
p.fit.schedule();
await settle();
/* schedule()'s own run() sweeps everything: reset the untouched one to a stale value */
a.style.minHeight = '100px';
p.observe().content.cb([ p.record(inB) ]);
assert.equal(b.style.minHeight, '260px');
assert.equal(a.style.minHeight, '100px', 'a box nothing touched keeps the floor it had');
p.observe().flag.cb([ {} ]);
assert.equal(a.style.minHeight, '150px', 'the class observer sweeps every box');
});
test('an attribute write that changed nothing does not run the fitters', async () => {
const p = page();
let runs = 0;
p.fit.add(() => { runs++; });
assert.equal(runs, 1, 'registration runs the fitter once');
const { tabs } = p.observe();
const rec = (attributeName, oldValue, now, dataset = {}) =>
({ attributeName, oldValue, target: { getAttribute: () => now, dataset } });
tabs.cb([ rec('class', 'a', 'a', { field: 'x' }) ]);
assert.equal(runs, 1, 'same value');
tabs.cb([ rec('class', 'a', 'a b') ]);
assert.equal(runs, 1, 'a class change off a [data-field] element is the poll rewriting rows');
tabs.cb([ rec('class', 'a', 'a hidden', { field: 'x' }) ]);
assert.equal(runs, 2, 'depends() hiding a row');
tabs.cb([ rec('hidden', null, '') ]);
assert.equal(runs, 3, 'a disclosure closing');
tabs.cb([ rec('data-tab-active', 'false', 'false') ]);
assert.equal(runs, 3);
});
@@ -0,0 +1,117 @@
/* A view's timer must survive a hidden tab as ITSELF — same handle, and still the router's to sweep.
*
* fs-router hooks `setInterval` so a client navigation can clear what the outgoing page left
* running, and the hidden-tab pause rides that same registry. Both are right; the way it was done
* was not, and neither half can be seen from a stand without winning a race:
*
* * the pause CLEARED each timer and re-armed it on show, which hands back a NEW id, so a view
* holding its own handle to stop its poller was left with a dead number;
* * a paused timer carried outside the registry is one a navigation sweep cannot see, so a tab
* hidden across a click brings the previous page's timers back.
*
* So a paused timer STAYS in the registry (armed on nothing) instead of leaving it, and the handle
* the view holds keeps naming it. Driven here rather than on a stand because both faults need a
* visibilitychange to land inside a specific window; the harness dispatches it exactly. */
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadModule, installBrowserGlobals, fakeWindow, fakeDocument, fakeL } from './lib/luci-module.mjs';
installBrowserGlobals();
/* A window whose timer ids are MONOTONE, like a browser's and unlike the harness's default (which
* numbers by map size and would hand the same id back after a clear — the very confusion under
* test). `live` is what is actually armed right now. */
function timerWindow() {
const live = new Map();
let next = 100;
const win = fakeWindow({
setInterval(fn, ms, ...rest) { const id = next++; live.set(id, { fn, ms, rest }); return id; },
clearInterval(id) { live.delete(id); }
});
win.live = live;
return win;
}
function boot(extra = {}) {
const window = extra.window || timerWindow();
const document = extra.document || fakeDocument();
const L = extra.L || fakeL();
window.L = L;
const mod = loadModule('fs-router', { window, document, L, stubs: extra.stubs });
return { window, document, L, mod };
}
function visibility(document, hidden) {
document.hidden = hidden;
document.listeners.filter((l) => l.type === 'visibilitychange').forEach((l) => l.fn());
}
test('a hidden tab stops a view\'s timer', () => {
const { window, document } = boot();
const id = window.setInterval(() => {}, 3000);
assert.equal(window.live.has(id), true);
visibility(document, true);
assert.equal(window.live.size, 0, 'nothing may still be armed while the tab is hidden');
});
test('coming back re-arms the same callback at the same period, once', () => {
const { window, document } = boot();
const fn = () => {};
window.setInterval(fn, 3000, 'arg');
visibility(document, true);
visibility(document, false);
assert.equal(window.live.size, 1, 'armed again, and armed once');
const armed = [ ...window.live.values() ][0];
assert.equal(armed.fn, fn);
assert.equal(armed.ms, 3000);
assert.deepEqual(armed.rest, [ 'arg' ], 'the extra arguments a view passed are passed again');
});
test('the view can still stop its own timer after a hide/show', () => {
const { window, document } = boot();
const id = window.setInterval(() => {}, 3000);
visibility(document, true);
visibility(document, false);
window.clearInterval(id);
assert.equal(window.live.size, 0, 'clearInterval(id) must stop the timer it was given for');
});
test('a navigation while the tab is hidden does not bring the old page\'s timers back', () => {
const { window, document, mod } = boot();
window.setInterval(() => {}, 3000);
visibility(document, true);
/* what navigate() runs on every client navigation, once the incoming page has been staged */
mod.clearViewIntervals();
visibility(document, false);
assert.equal(window.live.size, 0,
'a timer swept by a navigation may not be re-armed when the tab comes back');
});
test('LuCI\'s own 1 s tick is not paused with the view timers', () => {
const L = fakeL();
const { window, document } = boot({ L });
const tick = window.setInterval(() => {}, 1000);
L.Poll.timer = tick; /* what L.Poll.start() leaves behind, through our hook */
const mine = window.setInterval(() => {}, 3000);
visibility(document, true);
assert.equal(window.live.has(tick), true, 'L.Poll owns this one and wireVisibility stops it');
assert.equal(window.live.has(mine), false);
});
test('a luci-base whose tick cannot be told apart pauses NOTHING', () => {
const L = fakeL({ Poll: undefined });
const { window, document } = boot({ L });
const id = window.setInterval(() => {}, 3000);
visibility(document, true);
assert.equal(window.live.has(id), true,
'a wasted RPC in a background tab beats re-arming LuCI\'s tick behind its back');
});
test('a timer started while the tab is hidden is left alone', () => {
const { window, document } = boot();
visibility(document, true);
const id = window.setInterval(() => {}, 3000);
visibility(document, false);
assert.equal(window.live.has(id), true, 'it was never paused, so it is not re-armed either');
assert.equal(window.live.size, 1, 'and it is not armed twice');
});
+125
View File
@@ -0,0 +1,125 @@
/* A page fs-fit can run its state machine against, with no layout engine behind it.
*
* fs-fit decides everything from a handful of reads — `scrollTop()`, a box's `min-height` against
* its measured height, a reference element's `top` — and writes back through `scrollTo`. Here every
* one of those reads is a number the test sets, so a scenario is "the box grew 120px, the reference
* did not move, the offset did not move" stated directly, and the assertion is which exit the
* correction took (`lateWhy()`, `anchorWhy()`) and where the offset ended up.
*
* What it does NOT do: dispatch events or produce frames. `requestAnimationFrame` is a macrotask,
* the MutationObserver never fires on its own — a test hands the observer its records. Anything that
* depends on real layout is tools/scroll-anchor.mjs's, on a stand. */
import { loadModule, fakeWindow, fakeDocument, installBrowserGlobals } from './luci-module.mjs';
const observers = [];
globalThis.MutationObserver = class {
constructor(cb) { this.cb = cb; observers.push(this); }
observe() {}
disconnect() {}
takeRecords() { return []; }
};
globalThis.requestAnimationFrame = (fn) => globalThis.setTimeout(() => fn(performance.now()), 0);
globalThis.cancelAnimationFrame = (id) => globalThis.clearTimeout(id);
/* the module reads these bare, inside try/catch; a key set here is what the dev switches read */
const store = {};
globalThis.localStorage = { getItem: (k) => (k in store ? store[k] : null) };
installBrowserGlobals();
/* one element: enough surface for holdFloor(), anchorRef() and the observer callbacks */
export function el({ classes = [], attrs = {}, top = 0, height = 0, parent = null } = {}) {
const e = {
nodeType: 1, isConnected: true, parentElement: null, kids: [],
style: {}, dataset: {},
attrs: new Map(Object.entries(attrs)),
classList: {
contains: (c) => classes.includes(c),
add: (c) => { if (!classes.includes(c)) classes.push(c); },
remove: (c) => { const i = classes.indexOf(c); if (i >= 0) classes.splice(i, 1); }
},
rect: { top, left: 0, width: 800, height },
get offsetHeight() { return this.rect.height; },
getBoundingClientRect() {
return { top: this.rect.top, left: this.rect.left, width: this.rect.width,
height: this.rect.height, bottom: this.rect.top + this.rect.height };
},
getClientRects() { return [ this.getBoundingClientRect() ]; },
hasAttribute(n) { return this.attrs.has(n); },
getAttribute(n) { return this.attrs.has(n) ? this.attrs.get(n) : null; },
setAttribute(n, v) { this.attrs.set(n, String(v)); },
removeAttribute(n) { this.attrs.delete(n); },
contains(n) { for (let p = n; p; p = p.parentElement) if (p === this) return true; return false; },
matches(sel) { return matchOne(this, sel); },
closest(sel) { for (let p = this; p; p = p.parentElement) if (matchOne(p, sel)) return p; return null; },
querySelectorAll(sel) {
const out = [];
const walk = (n) => { for (const k of n.kids) { if (matchOne(k, sel)) out.push(k); walk(k); } };
walk(this);
return out;
},
querySelector(sel) { return this.querySelectorAll(sel)[0] || null; }
};
if (parent) { e.parentElement = parent; parent.kids.push(e); }
return e;
}
/* the selectors fs-fit actually asks of a page; anything else matches nothing */
function matchOne(e, sel) {
switch (sel) {
case '[data-fs-floor]': return e.attrs.has('data-fs-floor');
case '[data-fs-chrome]': return e.attrs.has('data-fs-chrome');
case '.table.fs-dt': return e.classList.contains('table') && e.classList.contains('fs-dt');
default: return false;
}
}
/* A page: `#view` holding `boxes` (each already floored at `minHeight`, measuring `height`) and one
* reference element the hit test lands on. `engine: 'off'` takes the non-anchoring path. */
export function page({ scrollY = 1000, docHeight = 5000, boxes = [], refTop = 300, engine = 'on',
pageName = 'admin-status-overview' } = {}) {
for (const k of Object.keys(store)) delete store[k];
if (engine === 'off') store.fsEngineAnchor = 'off';
const host = el({ attrs: { id: 'view' }, top: 0, height: docHeight });
const made = boxes.map((b) => {
const box = el({ attrs: { 'data-fs-floor': '' }, top: b.top ?? 100, height: b.height, parent: host });
box.style.minHeight = b.minHeight + 'px';
return box;
});
const ref = el({ top: refTop, height: 40, parent: host });
const win = fakeWindow({
innerWidth: 800, innerHeight: 900, scrollY,
scrollTo(x, y) { this.scrollY = Math.max(0, Math.min(y, docHeight - 900)); },
getComputedStyle: () => ({ display: 'block', overflowY: 'visible', getPropertyValue: () => '' })
});
const doc = fakeDocument({
body: { getAttribute: (n) => (n === 'data-page' ? pageName : null), appendChild() {} },
documentElement: { scrollHeight: docHeight, clientHeight: 900, dataset: {},
getAttribute: () => null, hasAttribute: () => false, setAttribute() {},
classList: { add() {}, remove() {}, contains: () => false } },
getElementById: (id) => (id === 'view' ? host : null),
elementFromPoint: () => ref
});
const before = observers.length;
const fit = loadModule('fs-fit', { window: win, document: doc });
/* the observers exist only once a fitter is registered; this one measures nothing */
fit.add(() => {});
const mo = observers.slice(before);
/* observeContent() registers three observers, in this order */
const observe = () => ({ content: mo[0], flag: mo[1], tabs: mo[2] });
return {
win, doc, host, ref, fit, boxes: made, observe,
/* fire the `scroll` listener the module put on window */
scroll() { win.listeners.find((l) => l.type === 'scroll').fn({ target: doc }); },
/* a childList record the way the observer would deliver it */
record(target, { added = 1, removed = 0 } = {}) {
return { type: 'childList', target,
addedNodes: Array.from({ length: added }, () => ({})),
removedNodes: Array.from({ length: removed }, () => ({})) };
}
};
}
export const settle = (ms = 25) => new Promise((r) => setTimeout(r, ms));
@@ -0,0 +1,184 @@
/* Load one shipped `fs-*.js` the way luci.js loads it, so a unit test can call its exports.
*
* A LuCI resource file is NOT an ES module and cannot be `import`ed: it has no exports, it ends in a
* bare `return`, and its dependencies arrive as PARAMETERS named by the `'require x as y'` pragmas
* in its directive prologue. luci.js evaluates it as the body of
*
* function (window, document, L, <one param per pragma>) { … }
*
* with `E` and `_` reached through `window`. This reproduces that call, which is the only honest way
* to test the file that ships — a rewritten copy in ESM syntax would be a second source of truth,
* and the interesting bugs live in the seam this wrapper IS (see tools/minify-js.mjs, where the same
* derivation stops terser handing `L` to one of its own variables).
*
* The alias is derived the way luci.js derives it: the `as` name, else the dependency with every
* non-word character replaced. A dependency with no stub is handed an empty object rather than
* `undefined`, so a module that only reaches into an unrelated dependency at call time still loads.
*
* What this is NOT: a browser. There is no layout, no CSS and no event dispatch — `window` and
* `document` here record what a module ASKS FOR (listeners, timers) and answer the few reads a
* module makes at eval. Anything that needs a real box on a real page belongs on a stand
* (docs/development.md). */
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
export const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..');
const RESOURCES = join(ROOT, 'luci-theme-footstrap/htdocs/luci-static/resources');
/* The leading run of string-literal statements. Scanned line-wise rather than parsed: the prologue
* is by definition the head of the file, one directive per line in this tree (eslint keeps it so),
* and a test harness that needed a JS parser to start would be a dependency for nothing. */
export function pragmas(src) {
const out = [];
for (const line of src.split('\n')) {
const m = (/^\s*'([^']*)'\s*;\s*$/).exec(line);
if (!m) break;
if (m[1] !== 'use strict') out.push(m[1]);
}
return out;
}
export function aliasFor(pragma) {
const m = (/^require\s+(\S+)(?:\s+as\s+([A-Za-z_]\S*))?$/).exec(pragma);
if (!m) return null;
return { dep: m[1], alias: m[2] || m[1].replace(/[^A-Za-z0-9_]/g, '_') };
}
/* A listener/timer recorder with the handful of reads a module performs while it evaluates. Both
* fakes are returned so a test can assert on what the module registered — "the router wired no click
* listener" is the only observable difference between an active router and one that declined. */
export function fakeDocument(extra = {}) {
const doc = {
listeners: [],
hidden: false,
documentElement: { dataset: {}, setAttribute() {}, getAttribute: () => null,
hasAttribute: () => false, classList: { add() {}, remove() {}, contains: () => false } },
body: null,
addEventListener(type, fn, opts) { this.listeners.push({ type, fn, opts }); },
removeEventListener(type, fn) {
this.listeners = this.listeners.filter((l) => !(l.type === type && l.fn === fn));
},
querySelector: () => null,
querySelectorAll: () => [],
getElementById: () => null,
createElement: () => ({ style: {}, setAttribute() {}, appendChild() {}, classList: { add() {} } }),
...extra
};
doc.body = doc.body ?? null;
return doc;
}
export function fakeWindow(extra = {}) {
const win = {
listeners: [],
intervals: new Map(),
innerWidth: 1280,
innerHeight: 900,
location: { pathname: '/cgi-bin/luci/admin/status/overview', search: '', hash: '',
href: 'http://router/cgi-bin/luci/admin/status/overview', reload() {} },
history: { pushState() {}, replaceState() {}, state: null },
addEventListener(type, fn, opts) { this.listeners.push({ type, fn, opts }); },
removeEventListener(type, fn) {
this.listeners = this.listeners.filter((l) => !(l.type === type && l.fn === fn));
},
setInterval(fn, ms) { const id = win.intervals.size + 1; win.intervals.set(id, { fn, ms }); return id; },
clearInterval(id) { win.intervals.delete(id); },
setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms),
clearTimeout: (id) => globalThis.clearTimeout(id),
requestAnimationFrame: (fn) => globalThis.setTimeout(() => fn(0), 0),
cancelAnimationFrame: (id) => globalThis.clearTimeout(id),
getComputedStyle: () => ({ getPropertyValue: () => '' }),
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
localStorage: {
_v: new Map(),
getItem(k) { return this._v.has(k) ? this._v.get(k) : null; },
setItem(k, v) { this._v.set(k, String(v)); },
removeItem(k) { this._v.delete(k); }
},
...extra
};
return win;
}
/* A luci-base that answers every existence check the theme makes. Tests deep-copy nothing: each
* caller gets a fresh object it is free to BREAK, which is the point (tests/router-contract). */
export function fakeL(extra = {}) {
const L = {
env: {
scriptname: '/cgi-bin/luci', base_url: '/luci-static/resources', resource: '/luci-static/resources',
media: '/luci-static/footstrap', resource_version: '1',
dispatchpath: [ 'admin', 'status', 'overview' ], requestpath: [ 'admin', 'status', 'overview' ],
pathinfo: '/admin/status/overview', nodespec: { readonly: false }
},
Class: function Class() {},
require: () => Promise.resolve({}),
dom: { content() {}, parse: () => null, append() {} },
Poll: {
queue: [], timer: null,
active() { return this.timer != null; },
start() { this.timer = 1; }, stop() { this.timer = null; }, add() {}, remove() {}
},
Request: { addInterceptor() {}, get: () => Promise.resolve({ status: 200 }) },
url: () => '/cgi-bin/luci',
get: () => Promise.resolve({}),
hasSystemFeature: () => false,
...extra
};
return L;
}
/* the `ui` a module reaches through its pragma — every method the theme calls, doing nothing */
export function fakeUi(extra = {}) {
return {
instantiateView: () => Promise.resolve({}),
hideModal() {}, hideIndicator() {}, showIndicator() {}, addNotification() {},
menu: { load: () => Promise.resolve({}) },
...extra
};
}
/* The browser objects a resource file reaches as BARE globals rather than through `window` —
* observers above all. Node has none of them, and a module that constructs one at eval or on its
* first call would die on a ReferenceError before a single assertion ran. Installed once, on
* globalThis, doing nothing: a test that needs an observer to actually fire belongs on a stand,
* where the layout it observes exists. */
export function installBrowserGlobals() {
const noop = class { observe() {} unobserve() {} disconnect() {} takeRecords() { return []; } };
if (typeof globalThis.MutationObserver === 'undefined') globalThis.MutationObserver = noop;
if (typeof globalThis.ResizeObserver === 'undefined') globalThis.ResizeObserver = noop;
if (typeof globalThis.IntersectionObserver === 'undefined') globalThis.IntersectionObserver = noop;
if (typeof globalThis.matchMedia === 'undefined')
globalThis.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} });
if (typeof globalThis.getComputedStyle === 'undefined')
globalThis.getComputedStyle = () => ({ getPropertyValue: () => '', overflowY: 'visible' });
}
/* Evaluate `<name>.js` from the shipped resource directory and return what it exports.
*
* `stubs` is keyed by the pragma's dependency name (`ui`, `rpc`, `fs-menutree`), not by the alias —
* the alias is a local detail of the file under test and renaming one must not break a caller here.
*/
export function loadModule(name, { L, window, document, stubs = {} } = {}) {
const src = readFileSync(join(RESOURCES, name + '.js'), 'utf8');
const win = window || fakeWindow();
const doc = document || fakeDocument();
const luci = L || fakeL();
win.L = win.L || luci;
win.document = doc;
win.E = win.E || ((tag) => ({ tag, appendChild() {}, setAttribute() {} }));
win._ = win._ || ((s) => s);
const deps = pragmas(src).map(aliasFor).filter(Boolean);
const args = deps.map((d) => {
if (Object.prototype.hasOwnProperty.call(stubs, d.dep)) return stubs[d.dep];
/* baseclass is the one dependency every module has and none of them can do without: LuCI's
* is a class whose extend() returns the prototype, and the tests read that object. */
if (d.dep === 'baseclass') return { extend: (o) => o };
return {};
});
const factory = new Function('window', 'document', 'L', ...deps.map((d) => d.alias), src);
return factory.call(win, win, doc, luci, ...args);
}
@@ -0,0 +1,137 @@
/* fs-menutree is a PORT of dispatcher.uc's resolution, and a port is only worth what it agrees
* with: resolve a `firstchild` differently from the server and a click opens one page while F5 opens
* another, on the same URL. The stands prove the agreement for the trees they carry; these cases pin
* the rules those trees happen not to exercise — a tie broken by key order, a login node sorted
* last, a firstchild whose only children are themselves unresolvable, an alias loop planted by a
* foreign menu.d — none of which any shipped menu contains, and all of which the next installed app
* is free to introduce.
*
* `readonlyForSegs` gets the same treatment for the opposite reason: the AND-down-the-path rule was
* measured wrong once in each direction (module comment), and both directions are cheap to pin. */
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadModule, fakeL } from './lib/luci-module.mjs';
const view = (path, extra = {}) => ({ title: path, satisfied: true, action: { type: 'view', path }, ...extra });
function menu(children) {
return { title: 'root', satisfied: true, children };
}
function tree(children, env = {}) {
const L = fakeL();
Object.assign(L.env, env);
const m = loadModule('fs-menutree', { L });
m.setTree(menu(children));
return m;
}
test('a plain view resolves to itself and to its class name', () => {
const m = tree({ admin: { title: 'a', satisfied: true, action: { type: 'firstchild' },
children: { status: view('status/index') } } });
const r = m.resolveSegs([ 'admin', 'status' ]);
assert.deepEqual(r.segs, [ 'admin', 'status' ]);
assert.equal(m.viewClassFor(r.node), 'view.status.index');
});
test('an alias is followed from the ROOT, not from where it sits', () => {
const m = tree({
admin: { title: 'admin', satisfied: true, action: { type: 'firstchild' }, children: {
logs: { title: 'logs', satisfied: true, action: { type: 'alias', path: 'admin/status/syslog' } },
status: { title: 'status', satisfied: true, action: { type: 'firstchild' }, children: {
syslog: view('status/syslog')
} }
} }
});
const r = m.resolveSegs([ 'admin', 'logs' ]);
assert.deepEqual(r.segs, [ 'admin', 'status', 'syslog' ]);
assert.equal(m.viewClassFor(r.node), 'view.status.syslog');
});
test('firstchild takes the lowest order, and a tie keeps tree order', () => {
const m = tree({ admin: { title: 'admin', satisfied: true, action: { type: 'firstchild' }, children: {
second: view('b', { order: 10 }),
first: view('a', { order: 1 }),
tie_a: view('t1', { order: 1 })
} } });
assert.deepEqual(m.resolveSegs([ 'admin' ]).segs, [ 'admin', 'first' ],
'order 1 must beat order 10');
const ties = tree({ admin: { title: 'admin', satisfied: true, action: { type: 'firstchild' }, children: {
alpha: view('a', { order: 5 }),
beta: view('b', { order: 5 })
} } });
assert.deepEqual(ties.resolveSegs([ 'admin' ]).segs, [ 'admin', 'alpha' ],
'a tie goes to the first key, the way a strict > comparison over an ordered object does');
});
test('a login node sorts last even with the lowest order', () => {
const m = tree({ admin: { title: 'admin', satisfied: true, action: { type: 'firstchild' }, children: {
login: view('login', { order: 1, auth: { login: true } }),
overview: view('status/overview', { order: 900 })
} } });
assert.deepEqual(m.resolveSegs([ 'admin' ]).segs, [ 'admin', 'overview' ]);
});
test('an unsatisfied, titleless or ineligible child is not a candidate', () => {
const m = tree({ admin: { title: 'admin', satisfied: true, action: { type: 'firstchild' }, children: {
hidden: view('a', { order: 1, satisfied: false }),
untitled: { satisfied: true, order: 2, action: { type: 'view', path: 'b' } },
skipped: view('c', { order: 3, firstchild_ineligible: true }),
real: view('d', { order: 4 })
} } });
assert.deepEqual(m.resolveSegs([ 'admin' ]).segs, [ 'admin', 'real' ]);
});
test('a firstchild whose children resolve to nothing is itself not a candidate', () => {
const m = tree({ admin: { title: 'admin', satisfied: true, action: { type: 'firstchild' }, children: {
empty: { title: 'empty', satisfied: true, order: 1, action: { type: 'firstchild' }, children: {} },
real: view('d', { order: 2 })
} } });
assert.deepEqual(m.resolveSegs([ 'admin' ]).segs, [ 'admin', 'real' ]);
});
test('an alias loop from a foreign menu.d ends in null, not in a hang', () => {
const m = tree({ a: { title: 'a', satisfied: true, action: { type: 'alias', path: 'b' } },
b: { title: 'b', satisfied: true, action: { type: 'alias', path: 'a' } } });
assert.equal(m.resolveSegs([ 'a' ]), null);
});
test('a path outside the menu resolves to null, and one outside scriptname yields no segments', () => {
const m = tree({ admin: { title: 'admin', satisfied: true, action: { type: 'firstchild' },
children: { status: view('status/index') } } });
assert.equal(m.resolveSegs([ 'admin', 'nope' ]), null);
assert.equal(m.segsFromPath('/some/other/app'), null);
assert.deepEqual(m.segsFromPath('/cgi-bin/luci/'), [],
'the bare base is the root node, which is itself a firstchild — not an unroutable path');
assert.deepEqual(m.segsFromPath('/cgi-bin/luci/admin/status'), [ 'admin', 'status' ]);
});
test('only view nodes and the Overview template carry a class', () => {
const m = tree({});
assert.equal(m.viewClassFor({ satisfied: true, action: { type: 'cbi', path: 'x' } }), null);
assert.equal(m.viewClassFor({ satisfied: true, action: { type: 'template', path: 'foo/bar' } }), null);
assert.equal(m.viewClassFor({ satisfied: true, action: { type: 'template', path: 'admin_status/index' } }),
'view.status.index');
assert.equal(m.viewClassFor({ satisfied: false, action: { type: 'view', path: 'a' } }), null,
'an unsatisfied node is the server\'s to refuse, never ours to open');
});
test('readonly is AND down the path: one writable acl re-opens it', () => {
const acl = (names, readonly) => ({ title: 'n', satisfied: true, readonly,
depends: { acl: names }, action: { type: 'firstchild' } });
const m = tree({ admin: { title: 'admin', satisfied: true, action: { type: 'firstchild' }, children: {
locked: { ...acl([ 'luci-mod-status-logs' ], true), children: {
all: { ...acl([ 'luci-mod-status-logs' ], true), action: { type: 'view', path: 'a' } },
mine: { ...acl([ 'luci-app-mine' ], false), action: { type: 'view', path: 'b' } },
ungated: view('c')
} }
} } });
assert.equal(m.readonlyForSegs([ 'admin', 'locked', 'all' ]), true);
assert.equal(m.readonlyForSegs([ 'admin', 'locked', 'mine' ]), false,
'a leaf with a writable acl of its own re-opens the whole path');
assert.equal(m.readonlyForSegs([ 'admin', 'locked', 'ungated' ]), true,
'a node carrying no acl is not evidence either way and must not clear the ancestor');
assert.equal(m.readonlyForSegs([ 'admin' ]), false,
'nothing on the path is acl-gated, so the dispatcher grants write');
});
+177
View File
@@ -0,0 +1,177 @@
/* The meter's threshold logic, exercised without a screen reader or a stand.
*
* `annotateMeter` (menu-footstrap-common.js) has no test of its own before this file: `smoke` only
* proves the module loads, and `computed-diff`/`a11y` read a rendered `.cbi-progressbar` rather than
* the function that built its attributes. What is worth pinning down without either — the percentage
* parse out of `title` in both shapes it is written in, the 0..100 clamp on a value `%d` never
* bounds, and the warn/80 - danger/92 split, including the attribute coming back OFF a bar that
* cools back down — is exactly what a poll tick re-runs on every unchanged and every improving
* reading alike. */
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadModule } from './lib/luci-module.mjs';
/* bare global, the same way `installBrowserGlobals()` supplies MutationObserver etc for other
* suites (tests/assets.test.mjs carries the identical line): a `new Function(...)`-built factory
* runs in the GLOBAL scope, so `window._` in luci-module.mjs's fakes never reaches the bare
* `_('Total Available')` this module now calls at top level. Identity is enough here — the module
* under test is asked to match its OWN output, never a translated string. */
globalThis._ = globalThis._ || ((s) => s);
const common = () => loadModule('menu-footstrap-common');
/* A `.cbi-progressbar` node is never asked for a label in these cases (closest() answers null, as it
* does for the bare Software/package-manager bar the module's own comment names), so every case here
* is about the value alone. */
function fakeMeter(title) {
const attrs = { title };
return {
getAttribute(name) { return Object.prototype.hasOwnProperty.call(attrs, name) ? attrs[name] : null; },
hasAttribute(name) { return Object.prototype.hasOwnProperty.call(attrs, name); },
setAttribute(name, value) { attrs[name] = value; },
removeAttribute(name) { delete attrs[name]; },
closest() { return null; }
};
}
/* The key/value-table shape `findProgressbarLabel` reads a row's name off of (Memory, Storage —
* menu-footstrap-common.js's own comment on that function): `.cbi-value` answers null, `.tr`
* answers a stub row whose first cell is the label, and the bar's OWN `.td, td` differs from that
* cell so the lookup does not read its own wrapper back as its name. */
function fakeMeterWithLabel(title, label) {
const attrs = { title };
const own = {};
const cell = { textContent: label };
const tr = { querySelector: () => cell };
return {
getAttribute(name) { return Object.prototype.hasOwnProperty.call(attrs, name) ? attrs[name] : null; },
hasAttribute(name) { return Object.prototype.hasOwnProperty.call(attrs, name); },
setAttribute(name, value) { attrs[name] = value; },
removeAttribute(name) { delete attrs[name]; },
closest(sel) {
if (sel === '.tr') return tr;
if (sel === '.td, td') return own;
return null; /* '.cbi-value' */
}
};
}
test('parseMeterPercent reads the parenthesised form window.progressbar writes', () => {
assert.equal(common().parseMeterPercent('303.37 MiB / 484.11 MiB (62%)'), 62);
});
test('parseMeterPercent reads a bare percent with nothing around it', () => {
assert.equal(common().parseMeterPercent('97%'), 97);
});
test('parseMeterPercent reads the localised reading the same way', () => {
assert.equal(common().parseMeterPercent('3,5 МиБ / 10 МиБ (35%)'), 35);
});
test('parseMeterPercent finds nothing in an empty title', () => {
assert.equal(common().parseMeterPercent(''), null);
});
test('parseMeterPercent finds nothing when there is no percent at all', () => {
assert.equal(common().parseMeterPercent('-55.9 dBm'), null);
});
test('parseMeterPercent finds nothing when there is no title', () => {
assert.equal(common().parseMeterPercent(null), null);
});
test('annotateMeter clamps a reading past 100 down to 100', () => {
const pg = fakeMeter('150%');
common().annotateMeter(pg);
assert.equal(pg.getAttribute('aria-valuenow'), '100');
assert.equal(pg.getAttribute('data-fs-level'), 'danger');
});
test('annotateMeter clamps a reading under 0 up to 0', () => {
const pg = fakeMeter('-5%');
common().annotateMeter(pg);
assert.equal(pg.getAttribute('aria-valuenow'), '0');
assert.equal(pg.hasAttribute('data-fs-level'), false);
});
test('the warn/danger split sits at 80 and 92, not one reading either side', () => {
const cases = [ [ 79, null ], [ 80, 'warn' ], [ 91, 'warn' ], [ 92, 'danger' ], [ 100, 'danger' ] ];
for (const [ pc, level ] of cases) {
const pg = fakeMeter(pc + '%');
common().annotateMeter(pg);
assert.equal(pg.getAttribute('data-fs-level'), level, pc + '% should read data-fs-level=' + level);
}
});
test('data-fs-level comes back off a bar that cools back down under warn', () => {
const pg = fakeMeter('85%');
common().annotateMeter(pg);
assert.equal(pg.getAttribute('data-fs-level'), 'warn');
pg.setAttribute('title', '50%');
common().annotateMeter(pg);
assert.equal(pg.hasAttribute('data-fs-level'), false);
});
/* forum #134: a fill that reads "how much is left" must warn on a LOW value, not a high one — a
* memory row reading 91% "Total Available" is healthy, not a warning, and a "Swap free" bar that
* reads 100% is the best possible outcome, not permanent danger. */
test('an inverted metric at a healthy (high) reading gets no warning', () => {
const pg = fakeMeterWithLabel('14.15 GiB / 15.50 GiB (91%)', 'Total Available');
common().annotateMeter(pg);
assert.equal(pg.hasAttribute('data-fs-level'), false);
});
test('an inverted metric at a full reading gets no warning either', () => {
const pg = fakeMeterWithLabel('4.00 GiB / 4.00 GiB (100%)', 'Swap free');
common().annotateMeter(pg);
assert.equal(pg.hasAttribute('data-fs-level'), false);
});
test('an inverted metric at an unhealthy (low) reading gets a warning', () => {
const warn = fakeMeterWithLabel('2.30 GiB / 15.50 GiB (15%)', 'Total Available');
common().annotateMeter(warn);
assert.equal(warn.getAttribute('data-fs-level'), 'warn');
const danger = fakeMeterWithLabel('0.40 GiB / 15.50 GiB (3%)', 'Swap free');
common().annotateMeter(danger);
assert.equal(danger.getAttribute('data-fs-level'), 'danger');
});
/* Buffered/Cached are the kernel doing its job, not a resource running out — no colour, at any
* reading, not even one that would read "danger" under the plain fill-based rule. */
test('a cache/buffer bar gets no colour at any reading', () => {
for (const label of [ 'Buffered', 'Cached' ]) {
for (const pc of [ 0, 50, 92, 100 ]) {
const pg = fakeMeterWithLabel(pc + '%', label);
common().annotateMeter(pg);
assert.equal(pg.hasAttribute('data-fs-level'), false, label + ' at ' + pc + '% should stay uncoloured');
}
}
});
/* A bar this theme does not recognise — a third-party app's own meter included — keeps the plain
* fill-based rule rather than going dark: most such meters are used-based, and a wrong colour on
* the rare exception is the accepted cost of not losing the common case. */
test('an unrecognised bar keeps the plain fill-based rule', () => {
const pg = fakeMeterWithLabel('95%', 'Some Other App Metric');
common().annotateMeter(pg);
assert.equal(pg.getAttribute('data-fs-level'), 'danger');
});
/* The package-manager's own disk bar (view/system/packages.js, not this theme's) leads with the
* percentage instead of trailing it, and used to parse to null — no role, no aria-*, no colour —
* because parseMeterPercent only matched a percentage at the END of the string. */
test('parseMeterPercent reads the package-manager title, which LEADS with the percentage', () => {
assert.equal(
common().parseMeterPercent('6% used (62.91 GiB used of 1006.85 GiB, 943.95 GiB free)'), 6);
});
test('parseMeterPercent reads the same leading form localised', () => {
assert.equal(
common().parseMeterPercent('6% использовано (62.91 GiB использовано из 1006.85 GiB, 943.95 GiB свободно)'),
6);
});
test('parseMeterPercent does not pick a decimal quantity out of a title with no percent at all', () => {
assert.equal(common().parseMeterPercent('62.91 GiB used of 1006.85 GiB'), null);
});
@@ -0,0 +1,531 @@
/* tools/playground/lib.mjs — the pure half of the record-and-replay pipeline. capture.mjs and
* verify.mjs both need a live owlab stand (T2); this is what proves the deterministic half without
* one: the recording key a request and a reply agree on, the rewrites build.mjs applies to a
* captured document, and the overlay/menu edits it makes on top of the recorded JSON. */
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
stableStringify, ubusKey, requestKey, splitBatch,
rewriteBase, rewriteEnv, scrubTokens, scrubHost, rewriteHostname, rewriteHostnameInData,
rewriteLiteral, stripBase, jsonForScript, applyOverlay, pruneMenu, parseLsLines, TOKEN_STUB,
scrubDataDeep, findSecrets, guardNoSecrets, resolveUnderRoot, isSafeReturn,
missingOverlayKeys, waitForQuiet, drainReads, createActivityTracker, createGenerationGate,
describePendingRequest, describePendingRequests,
} from '../tools/playground/lib.mjs';
test('stableStringify sorts object keys but keeps array order', () => {
assert.equal(stableStringify({ b: 1, a: 2 }), '{"a":2,"b":1}');
assert.equal(stableStringify(['b', 'a']), '["b","a"]');
assert.equal(stableStringify({ b: [1, { d: 1, c: 2 }], a: 1 }), '{"a":1,"b":[1,{"c":2,"d":1}]}');
});
test('ubusKey is stable under argument key order, distinct under value', () => {
assert.equal(ubusKey('system', 'board', { x: 1, y: 2 }), ubusKey('system', 'board', { y: 2, x: 1 }));
assert.notEqual(ubusKey('system', 'board', { x: 1 }), ubusKey('system', 'board', { x: 2 }));
assert.equal(ubusKey('system', 'board'), 'system.board({})');
});
test('requestKey: a `call` entry keys on object.method(args), sid and id play no part', () => {
const a = { jsonrpc: '2.0', id: 1, method: 'call', params: ['sid-one', 'uci', 'get', { config: 'system' }] };
const b = { jsonrpc: '2.0', id: 99, method: 'call', params: ['sid-two', 'uci', 'get', { config: 'system' }] };
assert.equal(requestKey(a), requestKey(b));
assert.equal(requestKey(a), 'uci.get({"config":"system"})');
});
test('requestKey: the boot `list` probe has no object.method pair', () => {
assert.equal(requestKey({ jsonrpc: '2.0', id: 'init', method: 'list', params: undefined }), 'list()');
});
test('splitBatch: a single request and a batched array both split by position, in order', () => {
const single = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'call', params: ['s', 'system', 'board', {}] });
const one = splitBatch(single);
assert.equal(one.length, 1);
assert.equal(one[0].key, 'system.board({})');
const batch = JSON.stringify([
{ jsonrpc: '2.0', id: 1, method: 'call', params: ['s', 'system', 'board', {}] },
{ jsonrpc: '2.0', id: 2, method: 'call', params: ['s', 'uci', 'get', { config: 'network' }] },
]);
const two = splitBatch(batch);
assert.equal(two.length, 2);
assert.deepEqual(two.map((e) => e.key), ['system.board({})', 'uci.get({"config":"network"})']);
});
test('rewriteBase: only a quoted /cgi-bin/ or /luci-static/ prefix gets BASE spliced in', () => {
const html = `<a href="/cgi-bin/luci/admin/status/overview">x</a><link href='/luci-static/resources/luci.js'>`;
const out = rewriteBase(html, '/luci-theme-footstrap/playground');
assert.equal(out, `<a href="/luci-theme-footstrap/playground/cgi-bin/luci/admin/status/overview">x</a>`
+ `<link href='/luci-theme-footstrap/playground/luci-static/resources/luci.js'>`);
});
test('rewriteBase leaves the escaped \\/cgi-bin\\/ form (the env block) untouched', () => {
const html = `L = new LuCI({"scriptname":"\\/cgi-bin\\/luci"});`;
assert.equal(rewriteBase(html, '/base'), html);
});
test('rewriteEnv rewrites scriptname/resource/media and points ubuspath at BASE/ubus/', () => {
const env = {
media: '/luci-static/bootstrap/footstrap-media.css',
resource: '/luci-static/resources',
scriptname: '/cgi-bin/luci',
pathinfo: '',
ubuspath: '/ubus/',
sessionid: 'a'.repeat(32),
};
const escaped = JSON.stringify(env).replace(/\//g, '\\/');
const html = `<script>L = new LuCI(${escaped});</script>`;
const out = rewriteEnv(html, '/luci-theme-footstrap/playground');
const m = out.match(/new LuCI\((\{.*?\})\);/s);
const rewritten = JSON.parse(m[1].replace(/\\\//g, '/'));
assert.equal(rewritten.scriptname, '/luci-theme-footstrap/playground/cgi-bin/luci');
assert.equal(rewritten.resource, '/luci-theme-footstrap/playground/luci-static/resources');
assert.equal(rewritten.media, '/luci-theme-footstrap/playground/luci-static/bootstrap/footstrap-media.css');
assert.equal(rewritten.ubuspath, '/luci-theme-footstrap/playground/ubus/');
/* untouched fields survive the round trip */
assert.equal(rewritten.pathinfo, '');
});
test('scrubTokens replaces every 32-hex literal, wherever it sits', () => {
const sid = 'deadbeef'.repeat(4);
const html = `L=new LuCI({"sessionid":"${sid}","token":"${sid}"}); confirm(true,0,"${sid}");`;
const out = scrubTokens(html);
assert.ok(!out.includes(sid));
assert.equal((out.match(new RegExp(TOKEN_STUB, 'g')) || []).length, 3);
});
test('scrubHost drops the stand host:port, leaves everything else', () => {
assert.equal(scrubHost('http://localhost:8341/cgi-bin/luci and localhost:8341 again', 'localhost:8341'),
'http://playground.invalid/cgi-bin/luci and playground.invalid again');
assert.equal(scrubHost('unchanged', ''), 'unchanged');
});
test('rewriteLiteral swaps every occurrence, deletes on an empty `to`, no-ops on an empty `from` or an unset/unchanged `to`', () => {
const html = '<div>keep this</div><p>No password set!</p><p>No password set!</p>';
assert.equal(rewriteLiteral(html, '<p>No password set!</p>', ''), '<div>keep this</div>');
assert.equal(rewriteLiteral(html, 'No password set!', 'root has a password'),
'<div>keep this</div><p>root has a password</p><p>root has a password</p>');
assert.equal(rewriteLiteral(html, '', 'x'), html);
assert.equal(rewriteLiteral(html, 'No password set!', undefined), html);
assert.equal(rewriteLiteral(html, 'No password set!', 'No password set!'), html);
});
test('rewriteHostname swaps every literal occurrence, no-ops on an empty or unchanged pair', () => {
const html = `<title>owrt2512 | Overview</title><h1>owrt2512</h1>`;
assert.equal(rewriteHostname(html, 'owrt2512', 'footstrap-playground'),
`<title>footstrap-playground | Overview</title><h1>footstrap-playground</h1>`);
assert.equal(rewriteHostname(html, '', 'x'), html);
assert.equal(rewriteHostname(html, 'owrt2512', 'owrt2512'), html);
});
test('rewriteHostnameInData swaps every occurrence recursively, no-ops on an empty or unchanged pair', () => {
const rpc = {
'system.board({})': { result: [ 0, { hostname: 'owrt2512', model: 'x' } ] },
'uci.get({"config":"system"})': { result: [ 0, { values: { '@system[0]': { hostname: 'owrt2512' } } } ] },
'network.getHostHints({})': { result: [ 0, { '00:11:22': { name: 'owrt2512' } } ] },
list: [ 'owrt2512', 1 ],
};
const out = rewriteHostnameInData(rpc, 'owrt2512', 'footstrap-playground');
assert.equal(out['system.board({})'].result[1].hostname, 'footstrap-playground');
assert.equal(out['uci.get({"config":"system"})'].result[1].values['@system[0]'].hostname, 'footstrap-playground');
assert.equal(out['network.getHostHints({})'].result[1]['00:11:22'].name, 'footstrap-playground');
assert.deepEqual(out.list, [ 'footstrap-playground', 1 ]);
/* the recording itself is never mutated */
assert.equal(rpc['system.board({})'].result[1].hostname, 'owrt2512');
assert.equal(rewriteHostnameInData(rpc, '', 'x'), rpc);
assert.equal(rewriteHostnameInData(rpc, 'owrt2512', 'owrt2512'), rpc);
});
test('parseLsLines splits `ls -1` stdout into filenames, drops the trailing blank line and blank input', () => {
assert.deepEqual(parseLsLines('wifi.svg\nwifi_disabled.svg\nsignal-000-000.svg\n'),
[ 'wifi.svg', 'wifi_disabled.svg', 'signal-000-000.svg' ]);
assert.deepEqual(parseLsLines(''), []);
assert.deepEqual(parseLsLines('\n\n'), []);
});
test('stripBase removes BASE from a string anywhere it sits, recurses into arrays/objects, no-ops without one', () => {
const base = '/luci-theme-footstrap/playground';
assert.equal(stripBase(`/www${base}/luci-static/resources/preload`, base), '/www/luci-static/resources/preload');
assert.deepEqual(stripBase({ path: `${base}/x`, list: [ `${base}/y`, 1 ] }, base), { path: '/x', list: [ '/y', 1 ] });
assert.equal(stripBase('unchanged', ''), 'unchanged');
});
test('jsonForScript escapes every `<` so an inlined value cannot close the enclosing <script> early', () => {
const out = jsonForScript({ evil: '</script><script>alert(1)</script><!--' });
assert.ok(!out.includes('</script'));
assert.ok(!out.includes('<!--'));
assert.deepEqual(JSON.parse(out), { evil: '</script><script>alert(1)</script><!--' });
});
test('applyOverlay: a nested merge patches one key deep without dropping its siblings', () => {
const rpc = {
'luci-rpc.getWirelessDevices({})': { jsonrpc: '2.0', id: 1, result: [0, {
radio0: { up: false, retry_setup_failed: true, config: { band: '2g' }, interfaces: [ 'x' ] },
}] },
};
const out = applyOverlay(rpc, {
'luci-rpc.getWirelessDevices({})': { merge: { radio0: { up: true, retry_setup_failed: false } } },
});
const radio0 = out['luci-rpc.getWirelessDevices({})'].result[1].radio0;
assert.equal(radio0.up, true);
assert.equal(radio0.retry_setup_failed, false);
assert.deepEqual(radio0.config, { band: '2g' });
assert.deepEqual(radio0.interfaces, [ 'x' ]);
});
test('pruneMenu keeps a leaf on a tree rooted at admin (action_menu\'s real shape), admin included', () => {
const menu = { children: { admin: { children: {
status: { title: 'Status', children: { overview: { title: 'Overview' } } },
network: { title: 'Network', children: { network: { title: 'Interfaces' } } },
} } } };
const out = pruneMenu(menu, [ 'admin/status/overview' ]);
assert.deepEqual(Object.keys(out.children), [ 'admin' ]);
assert.deepEqual(Object.keys(out.children.admin.children), [ 'status' ]);
assert.deepEqual(Object.keys(out.children.admin.children.status.children), [ 'overview' ]);
});
test('applyOverlay: replace swaps the payload, merge patches it, both keep the ubus envelope', () => {
const rpc = {
'system.board({})': { jsonrpc: '2.0', id: 1, result: [0, { hostname: 'stand-host', model: 'x86' }] },
'network.device.status({"name":"eth0"})': { jsonrpc: '2.0', id: 2, result: [0, { up: false }] },
};
const out = applyOverlay(rpc, {
'system.board({})': { merge: { hostname: 'footstrap' } },
'network.device.status({"name":"eth0"})': { replace: { up: true, speed: '1000baseT/Full' } },
});
assert.deepEqual(out['system.board({})'].result, [0, { hostname: 'footstrap', model: 'x86' }]);
assert.deepEqual(out['network.device.status({"name":"eth0"})'].result, [0, { up: true, speed: '1000baseT/Full' }]);
/* the recording itself is never mutated */
assert.deepEqual(rpc['system.board({})'].result, [0, { hostname: 'stand-host', model: 'x86' }]);
});
test('applyOverlay throws on a key the recording never saw', () => {
assert.throws(() => applyOverlay({}, { 'system.board({})': { merge: {} } }),
/no entry for "system\.board\(\{\}\)"/);
});
test('applyOverlay throws when an entry names neither replace nor merge', () => {
assert.throws(() => applyOverlay({ 'system.board({})': { result: [0, {}] } }, { 'system.board({})': {} }),
/needs "replace" or "merge"/);
});
test('pruneMenu keeps only recorded leaves and their ancestors', () => {
const menu = {
children: {
status: { title: 'Status', children: {
overview: { title: 'Overview' },
processes: { title: 'Processes' },
} },
network: { title: 'Network', children: {
network: { title: 'Interfaces' },
} },
},
};
const out = pruneMenu(menu, ['status/overview']);
assert.deepEqual(Object.keys(out.children), ['status']);
assert.deepEqual(Object.keys(out.children.status.children), ['overview']);
assert.equal(out.children.status.title, 'Status');
assert.equal(out.children.status.children.overview.title, 'Overview');
});
test('pruneMenu keeps two sibling leaves and drops everything else', () => {
const menu = {
children: {
status: { children: { overview: { title: 'Overview' }, processes: { title: 'Processes' } } },
network: { children: { network: { title: 'Interfaces' } } },
},
};
const out = pruneMenu(menu, ['status/overview', 'status/processes']);
assert.deepEqual(Object.keys(out.children), ['status']);
assert.deepEqual(Object.keys(out.children.status.children).sort(), ['overview', 'processes']);
});
test('scrubDataDeep scrubs a token and the stand host buried in nested rpc data, keys included', () => {
const sid = 'deadbeef'.repeat(4);
const rpc = {
'uci.get({"config":"system"})': {
result: [ 0, { values: { '@system[0]': { hostname: 'x', note: `see http://localhost:8341/${sid}` } } } ],
},
[`session-${sid}`]: { result: [ 0, [ 'localhost:8341' ] ] },
};
const out = scrubDataDeep(rpc, 'localhost:8341');
const flat = JSON.stringify(out);
assert.ok(!flat.includes(sid), 'token survived scrubDataDeep');
assert.ok(!flat.includes('localhost:8341'), 'stand host survived scrubDataDeep');
assert.ok(flat.includes(TOKEN_STUB));
});
test('findSecrets flags a real token and the stand host, not the TOKEN_STUB placeholder', () => {
const sid = 'deadbeef'.repeat(4);
assert.deepEqual(findSecrets(`sid=${TOKEN_STUB}`, 'localhost:8341'), []);
assert.deepEqual(findSecrets(`sid=${sid}`, ''), [ 'a session-token-shaped 32-hex literal' ]);
assert.deepEqual(findSecrets('reachable at localhost:8341', 'localhost:8341'), [ 'the stand host "localhost:8341"' ]);
});
test('guardNoSecrets throws naming the file a planted token sits in, passes a clean set', () => {
const sid = 'deadbeef'.repeat(4);
assert.throws(() => guardNoSecrets([ [ 'cgi-bin/luci/admin/status/overview/index.html', `x=${sid}` ] ], ''),
/cgi-bin\/luci\/admin\/status\/overview\/index\.html/);
assert.doesNotThrow(() => guardNoSecrets([ [ 'a.html', `x=${TOKEN_STUB}` ] ], 'localhost:8341'));
});
test('isSafeReturn accepts only an admin page under base, rejects traversal, backslash, another host and a non-string', () => {
const base = '/luci-theme-footstrap/playground';
assert.equal(isSafeReturn(`${base}/cgi-bin/luci/admin/system/footstrap`, base), true);
assert.equal(isSafeReturn(`${base}/cgi-bin/luci/`, base), false, 'the login page itself is not an admin page');
assert.equal(isSafeReturn('/cgi-bin/luci/admin/system/footstrap', base), false, 'missing base prefix');
assert.equal(isSafeReturn(`${base}/cgi-bin/luci/admin/../../../etc/passwd`, base), false);
assert.equal(isSafeReturn(`${base}/cgi-bin/luci/admin/\\evil`, base), false);
assert.equal(isSafeReturn(`${base}/cgi-bin/luci/admin//evil.example`, base), false);
assert.equal(isSafeReturn(`https://evil.example${base}/cgi-bin/luci/admin/x`, base), false);
assert.equal(isSafeReturn(null, base), false);
assert.equal(isSafeReturn(undefined, base), false);
});
test('resolveUnderRoot keeps an ordinary request inside root, rejects a traversal and a NUL byte', () => {
const root = '/out';
assert.equal(resolveUnderRoot(root, '/cgi-bin/luci/admin/status/overview/index.html'),
'/out/cgi-bin/luci/admin/status/overview/index.html');
assert.equal(resolveUnderRoot(root, '/../../etc/passwd'), null);
assert.equal(resolveUnderRoot(root, '/foo/../../etc/passwd'), null);
assert.equal(resolveUnderRoot(root, '/foo\0bar'), null);
});
test('missingOverlayKeys: every overlay key not present in rpc, none when rpc has them all', () => {
const overlay = { 'system.board({})': {}, 'system.info({})': {} };
assert.deepEqual(missingOverlayKeys({}, overlay), [ 'system.board({})', 'system.info({})' ]);
assert.deepEqual(missingOverlayKeys({ 'system.board({})': {} }, overlay), [ 'system.info({})' ]);
assert.deepEqual(missingOverlayKeys({ 'system.board({})': {}, 'system.info({})': {} }, overlay), []);
assert.deepEqual(missingOverlayKeys({}, {}), []);
});
test('createActivityTracker: quiet only once the set is empty AND quietMs has passed since the last start/finish', () => {
let t = 0;
const now = () => t;
const tracker = createActivityTracker(now);
assert.equal(tracker.quiet(0), true, 'nothing ever started, 0ms is always long enough');
tracker.start('a');
assert.equal(tracker.pendingCount(), 1);
assert.equal(tracker.quiet(0), false, 'still pending');
t = 50;
tracker.finish('a');
assert.equal(tracker.pendingCount(), 0);
assert.equal(tracker.quiet(100), false, 'idle, but not for 100ms yet');
t = 150;
assert.equal(tracker.quiet(100), true);
});
test('createActivityTracker: a slow finish re-opens the clock at FINISH, not at its own start', () => {
let t = 0;
const now = () => t;
const tracker = createActivityTracker(now);
tracker.start('slow-ubus-post');
t = 800; /* a reply slower than a 750ms quiet window would allow if clocked at issue time */
tracker.finish('slow-ubus-post');
assert.equal(tracker.quiet(750), false, 'the clock just reset at finish(), 0ms elapsed since');
t = 800 + 750;
assert.equal(tracker.quiet(750), true);
});
test('createActivityTracker: a second start after the first finishes keeps the window open until IT finishes too', () => {
let t = 0;
const now = () => t;
const tracker = createActivityTracker(now);
tracker.start('a'); t = 10; tracker.finish('a');
t = 20; tracker.start('b'); /* a lazy include firing after the first request already settled */
assert.equal(tracker.quiet(5), false);
t = 30; tracker.finish('b');
assert.equal(tracker.quiet(5), false, 'only 0ms idle since b finished');
t = 36;
assert.equal(tracker.quiet(5), true);
});
test('createActivityTracker: a stale request from an earlier generation never blocks quiet() for the current one', () => {
let t = 0;
const now = () => t;
const tracker = createActivityTracker(now);
tracker.start('gen0-abandoned', 0); /* Playwright drops requestfinished/requestfailed for a
request its own page abandoned on navigation — this one never calls finish() */
t = 10;
tracker.start('gen1-doc', 1);
t = 20;
tracker.finish('gen1-doc');
assert.equal(tracker.quiet(50, 1), false, 'not idle for 50ms yet');
t = 70;
assert.equal(tracker.quiet(50, 1), true, 'gen1 is quiet even though the gen0 request never finished');
assert.equal(tracker.pendingCount(0), 1, 'the abandoned gen0 request is still tracked until pruned');
});
test('createActivityTracker: prune(gen) drops every entry not tagged that generation', () => {
const tracker = createActivityTracker(() => 0);
tracker.start('gen0-a', 0);
tracker.start('gen0-b', 0);
tracker.start('gen1-c', 1);
tracker.prune(1);
assert.equal(tracker.pendingCount(0), 0);
assert.equal(tracker.pendingCount(1), 1);
assert.deepEqual(tracker.pendingEntries(1), [ 'gen1-c' ]);
});
test('describePendingRequest: method + path, no suffix for a plain GET', () => {
assert.equal(
describePendingRequest('GET', 'http://192.168.1.1/luci-static/resources/luci.js', null),
'GET /luci-static/resources/luci.js',
);
});
test('describePendingRequest: appends the ubus object.method a POST body calls', () => {
const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'call', params: [ 'sid', 'system', 'board', {} ] });
assert.equal(describePendingRequest('POST', 'http://192.168.1.1/ubus/', body), 'POST /ubus/ [system.board]');
});
test('describePendingRequest: a batched POST names every call in the batch', () => {
const body = JSON.stringify([
{ jsonrpc: '2.0', id: 1, method: 'call', params: [ 'sid', 'system', 'board', {} ] },
{ jsonrpc: '2.0', id: 2, method: 'call', params: [ 'sid', 'uci', 'get', { config: 'system' } ] },
]);
assert.equal(describePendingRequest('POST', 'http://192.168.1.1/ubus/', body), 'POST /ubus/ [system.board, uci.get]');
});
test('describePendingRequest: a POST body that is not a ubus call gets no suffix', () => {
assert.equal(describePendingRequest('POST', 'http://192.168.1.1/cgi-bin/luci/', 'not json'), 'POST /cgi-bin/luci/');
assert.equal(
describePendingRequest('POST', 'http://192.168.1.1/cgi-bin/luci/', JSON.stringify({ method: 'list' })),
'POST /cgi-bin/luci/',
);
});
test('describePendingRequests: joins up to the limit and counts the rest', () => {
const reqs = Array.from({ length: 12 }, (_, i) => ({ method: 'GET', url: `http://h/luci-static/${i}.js`, postData: null }));
const out = describePendingRequests(reqs, 10);
assert.equal(out, `${Array.from({ length: 10 }, (_, i) => `GET /luci-static/${i}.js`).join(', ')}, +2 more`);
});
test('waitForQuiet: the timeout message names the still-pending requests (method + path, ubus call)', async () => {
let t = 0;
const now = () => t;
const tracker = createActivityTracker(now);
const stuck = {
method: 'POST', url: 'http://192.168.1.1/ubus/',
postData: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'call', params: [ 'sid', 'system', 'board', {} ] }),
};
tracker.start(stuck, 1); /* current generation is 1: a page-2 request never settling must not be
blamed on page 1's own, already-pruned, generation-0 entries */
const sleep = async (ms) => { t += ms; };
await assert.rejects(
() => waitForQuiet({
isQuiet: () => tracker.quiet(50, 1),
quietMs: 50, timeoutMs: 100, now, sleep, pollMs: 50,
describe: () => {
const pending = tracker.pendingEntries(1);
return `${pending.length} request(s) still in flight: ${describePendingRequests(pending)}`;
},
}),
/1 request\(s\) still in flight: POST \/ubus\/ \[system\.board\]/,
);
});
test('createGenerationGate: a response tagged before the latest bump() reads as stale, one tagged after as current', () => {
const gate = createGenerationGate();
gate.tag('login-landing-req'); /* generation 0, before any bump — e.g. login()'s own navigation */
assert.equal(gate.isCurrent('login-landing-req'), true, 'still generation 0, nothing has bumped yet');
gate.bump(); /* the capture loop's first page */
assert.equal(gate.isCurrent('login-landing-req'), false, 'a later generation is now current');
gate.tag('page1-req');
assert.equal(gate.isCurrent('page1-req'), true);
gate.bump(); /* the second page */
assert.equal(gate.isCurrent('page1-req'), false);
assert.equal(gate.isCurrent('page1-req'), false, 'reading it again does not change the answer');
});
test('createGenerationGate: an id that was never tagged is never current', () => {
const gate = createGenerationGate();
assert.equal(gate.isCurrent('never-tagged'), false);
gate.bump();
assert.equal(gate.isCurrent('never-tagged'), false);
});
test('waitForQuiet resolves once isQuiet() starts returning true', async () => {
let t = 0;
let quiet = false;
const now = () => t;
const sleep = async (ms) => { t += ms; if (t >= 200) quiet = true; };
const idleFor = await waitForQuiet({
isQuiet: () => quiet, quietMs: 200, timeoutMs: 5000, now, sleep, pollMs: 50,
});
assert.ok(idleFor >= 150, `expected idleFor >= 150, got ${idleFor}`);
});
test('waitForQuiet throws naming the deadline when isQuiet() never turns true', async () => {
let t = 0;
const now = () => t;
const sleep = async (ms) => { t += ms; };
await assert.rejects(
() => waitForQuiet({ isQuiet: () => false, quietMs: 200, timeoutMs: 1000, now, sleep, pollMs: 100 }),
/no 200ms quiet window reached within 1000ms$/,
);
});
test('waitForQuiet appends describe() to the timeout error when given', async () => {
let t = 0;
const now = () => t;
const sleep = async (ms) => { t += ms; };
await assert.rejects(
() => waitForQuiet({
isQuiet: () => false, quietMs: 200, timeoutMs: 1000, now, sleep, pollMs: 100,
describe: () => '2 request(s) still in flight',
}),
/no 200ms quiet window reached within 1000ms \(2 request\(s\) still in flight\)/,
);
});
test('waitForQuiet + createActivityTracker together: quiet only once every in-flight request has finished and settled for quietMs', async () => {
let t = 0;
const now = () => t;
const tracker = createActivityTracker(now);
tracker.start('doc');
const sleep = async (ms) => {
t += ms;
if (t === 50) { tracker.finish('doc'); tracker.start('lazy-include'); } /* a late-firing request */
if (t === 100) tracker.finish('lazy-include');
};
const idleFor = await waitForQuiet({
isQuiet: () => tracker.quiet(50), quietMs: 50, timeoutMs: 5000, now, sleep, pollMs: 25,
});
assert.ok(idleFor >= 100, `expected idleFor >= 100 (idle only starts once "lazy-include" finishes at t=100), got ${idleFor}`);
});
test('drainReads: every read succeeded — settled carries both, nothing abandoned', async () => {
const result = await drainReads([
[ 'a', Promise.resolve('ok') ],
[ 'b', Promise.resolve('ok') ],
]);
assert.deepEqual(result.abandoned, []);
assert.deepEqual(result.settled.map((s) => [ s.label, s.status ]), [ [ 'a', 'fulfilled' ], [ 'b', 'fulfilled' ] ]);
});
test('drainReads: never throws — a rejected read comes back in settled, not as an exception', async () => {
const result = await drainReads([
[ 'good', Promise.resolve('ok') ],
[ 'ubus POST /ubus/', Promise.reject(new Error('closed')) ],
[ 'GET /luci-static/x.js', Promise.reject(new Error('navigated')) ],
]);
assert.deepEqual(result.abandoned, []);
const byLabel = Object.fromEntries(result.settled.map((s) => [ s.label, s ]));
assert.equal(byLabel.good.status, 'fulfilled');
assert.equal(byLabel['ubus POST /ubus/'].status, 'rejected');
assert.equal(byLabel['ubus POST /ubus/'].reason.message, 'closed');
assert.equal(byLabel['GET /luci-static/x.js'].status, 'rejected');
assert.equal(byLabel['GET /luci-static/x.js'].reason.message, 'navigated');
});
test('drainReads: a read that never settles is named in `abandoned`, never thrown', async () => {
let releaseHung;
const hung = new Promise((resolve) => { releaseHung = resolve; });
const result = await drainReads([
[ 'GET /luci-static/x.js', Promise.resolve('ok') ],
[ 'ubus POST /ubus/', hung ],
], 30);
assert.deepEqual(result.abandoned, [ 'ubus POST /ubus/' ]);
assert.deepEqual(result.settled.map((s) => s.label), [ 'GET /luci-static/x.js' ]);
releaseHung('ok'); /* let the dangling promise settle so it cannot leak into a later test */
});
@@ -0,0 +1,275 @@
/* The poll-status pill's click handler is bound only ONCE, when ui.showIndicator (ui.js) first
* builds its <span> — updating an EXISTING span never rebinds it. So any teardown that leaves a
* stale, handler-less span in place for the next `poll-start` to reuse produces a pill that is
* visible and permanently inert.
*
* Two different callers empty the poll queue and reach `L.Poll.stop()`, which is what needs the
* pill hidden: fs-router's own navigate() teardown, and a VIEW emptying its own queue with no
* navigation at all (`L.Poll.remove()` — `luci-mod-status`'s Realtime Graphs on unload,
* `luci-app-banip`'s log template). A version of this fix that called the hide only from
* navigate()'s teardown covered the first and silently missed the second — reproduced live,
* owrt2410b/owrt2512b, add-then-remove on Statistics with no navigation:
* `{"active":false,"q":0,"pill":{"text":"Paused","clickable":true}}`, unchanged by a click.
*
* One `poll-stop` LISTENER covers both callers, since `stop()` dispatches the same event either
* way — but a listener racing luci.js's own (`showIndicator('poll-status', 'Paused', null,
* 'inactive')`, registered from `setupDOM()` after an async chain, against this module's own eval,
* from the inline `L.require('menu-footstrap')` in partials/footer.ut) is exactly the original bug:
* network/cache timing decided which one ran first, and a hide that ran BEFORE luci.js's own
* "Paused" show left a fresh, handler-less span for the next `poll-start` to reuse forever. The fix
* defers the actual hide by one microtask: `stop()` dispatches synchronously to every listener
* registered at that moment, and a microtask runs only once that whole synchronous turn — every
* `poll-stop` listener included, in whichever order they ran — has unwound. So the check always
* sees the state stock's listener actually left, never races it, and covers whichever caller emptied
* the queue without needing to know which one it was.
*
* This drives the real module: `hidePollIndicatorIfEmpty` directly for the plain cases, and through
* an actual dispatched `poll-stop` (awaiting a microtask, the way the shipped listener defers) for
* the ones that must prove the deferral itself — against a faithful port of ui.js's
* `showIndicator`/`hideIndicator` and luci.js's `Poll` (queue/tick/timer, `add()`'s auto-start,
* `remove()`'s auto-stop, and the events dispatched synchronously to `document`) — with the stock
* indicator listener wired both BEFORE and AFTER fs-router's own module eval, to show neither order
* has anything left to hold on to. */
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadModule, fakeWindow, fakeDocument, fakeL } from './lib/luci-module.mjs';
/* ui.showIndicator/hideIndicator (ui.js ~4501-4557): a span's click handler is fixed at creation and
* an update to an existing span never touches it — the fact the historical bug turned on. */
function realishUi() {
const spans = new Map();
return {
spans,
showIndicator(id, label, handler, style) {
const handlerFn = typeof handler === 'function' ? handler : null;
let span = spans.get(id);
if (!span) {
span = { label: null, style: null, handler: handlerFn };
spans.set(id, span);
}
span.label = label;
span.style = (style === 'inactive') ? 'inactive' : 'active';
return true;
},
hideIndicator(id) {
return spans.delete(id);
}
};
}
/* LuCI.poll (luci.js ~1091-1192), ported the same way: `add()`'s auto-start when a fresh `tick` is
* already set from a prior start()-on-empty-queue, `remove()`'s own auto-stop when the queue it
* leaves is empty (the exact call the missed finding turns on), and `start()`/`stop()` dispatching
* their events to `document` synchronously and ONLY on an actual state change. */
function realishPoll(doc) {
function dispatch(type) {
doc.listeners.filter((l) => l.type === type).forEach((l) => l.fn());
}
return {
queue: [], tick: undefined, timer: undefined,
active() { return this.timer != null; },
start() {
if (this.active()) return false;
this.tick = 0;
if (this.queue.length) { this.timer = 1; dispatch('poll-start'); }
return true;
},
stop() {
if (!this.active()) return false;
dispatch('poll-stop');
this.timer = undefined;
this.tick = undefined;
return true;
},
add(fn, interval) {
this.queue.push({ fn, interval });
if (this.tick != null && !this.active()) this.start();
return true;
},
remove(fn) {
const len = this.queue.length;
this.queue = this.queue.filter((e) => e.fn !== fn);
if (!this.queue.length && this.stop()) this.tick = 0;
return this.queue.length !== len;
}
};
}
/* luci.js:2738-2746, ported: the click handler toggles on whatever `poll` is active right now. */
function wireStockIndicator(doc, ui, poll) {
doc.addEventListener('poll-start', () => {
ui.showIndicator('poll-status', 'Refreshing', () => (poll.active() ? poll.stop() : poll.start()));
});
doc.addEventListener('poll-stop', () => {
ui.showIndicator('poll-status', 'Paused', null, 'inactive');
});
}
/* `order` is 'stock-first' (what happened to work before the fix) or 'stock-last' (the reported
* break) — the exact race this fix makes irrelevant. */
function boot(order) {
const doc = fakeDocument();
const win = fakeWindow();
const L = fakeL();
const ui = realishUi();
const poll = realishPoll(doc);
L.Poll = poll;
win.L = L;
if (order === 'stock-first') wireStockIndicator(doc, ui, poll);
const mod = loadModule('fs-router', { window: win, document: doc, L, stubs: { ui } });
if (order === 'stock-last') wireStockIndicator(doc, ui, poll);
return { doc, win, L, ui, poll, mod };
}
/* the shipped listener defers its hide by exactly one microtask (queueMicrotask); one empty turn is
* enough to let it run, and a second costs nothing if it is not */
async function flushMicrotasks() {
await Promise.resolve();
await Promise.resolve();
}
/* what navigate() does around the require()/staging chain this harness has no DOM for: flush, stop
* (which dispatches `poll-stop` to whatever is registered, including fs-router's own deferred
* listener), let the deferral run, then start on the now-empty queue */
async function teardown(poll) {
poll.queue.length = 0;
poll.stop();
await flushMicrotasks();
poll.start();
}
for (const order of [ 'stock-first', 'stock-last' ]) {
test(`${order}: the hide is deferred past the synchronous poll-stop dispatch`, async () => {
const { ui, poll } = boot(order);
poll.tick = 0;
poll.add(() => {}, 1000);
poll.queue.length = 0;
poll.stop();
/* right after the synchronous dispatch returns, stock's listener has already run (whichever
* order it registered in) and the span still stands — the hide has not fired yet */
assert.ok(ui.spans.has('poll-status'), 'the hide must not run inside the synchronous dispatch');
await flushMicrotasks();
assert.equal(ui.spans.has('poll-status'), false, 'and must have run by the next microtask');
});
test(`${order}: the pill is clickable after navigating polled page -> polled page`, async () => {
const { ui, poll } = boot(order);
/* the outgoing page is already polling, exactly as if a previous navigate() had start()ed an
* empty queue and this page's own view had just added its poller */
poll.tick = 0;
poll.add(() => {}, 1000);
assert.equal(typeof ui.spans.get('poll-status').handler, 'function', 'sanity: pill has a handler');
await teardown(poll);
/* the incoming page polls too — its own poll.add() runs well after the deferred hide above,
* the same way a real navigate()'s require()/render() chain always lands after a microtask
* queued during the synchronous stop() that preceded it */
poll.add(() => {}, 1000);
const span = ui.spans.get('poll-status');
assert.ok(span, 'the pill must exist on a page that polls');
assert.equal(typeof span.handler, 'function',
'the pill must be clickable after the navigation, regardless of listener order');
assert.equal(span.label, 'Refreshing');
/* and it stays a real toggle: clicking it now must pause, clicking again must resume */
span.handler();
assert.equal(poll.active(), false, 'the pill\'s own handler must still stop the poll');
assert.equal(ui.spans.get('poll-status').handler, span.handler, 'a manual pause must not rebind it');
span.handler();
assert.equal(poll.active(), true, 'and resume it again');
});
test(`${order}: the pill hides on a page with no polls`, async () => {
const { ui, poll } = boot(order);
poll.tick = 0;
poll.add(() => {}, 1000);
assert.ok(ui.spans.has('poll-status'));
await teardown(poll);
/* the incoming page never calls poll.add() */
assert.equal(ui.spans.has('poll-status'), false,
'a page with nothing to poll must not report "Paused" about a poll that does not exist');
});
test(`${order}: a page that empties its own queue with Poll.remove(), no navigation, hides the pill`, async () => {
/* the finding this round is for: luci-mod-status's Realtime Graphs and luci-app-banip's log
* template call L.Poll.remove() on unload, never navigate() — reproduced live on
* owrt2410b/owrt2512b, add-then-remove on Statistics, pill left reading "Paused" and clickable
* with nothing left to poll */
const { ui, poll } = boot(order);
const fn = () => {};
poll.tick = 0;
poll.add(fn, 1000);
assert.ok(ui.spans.has('poll-status'), 'sanity: the page was polling');
poll.remove(fn);
await flushMicrotasks();
assert.equal(ui.spans.has('poll-status'), false,
'a page that emptied its own queue must not leave "Paused" up with nothing to poll');
});
test(`${order}: Poll.remove() down to a non-empty queue leaves the pill alone`, async () => {
const { ui, poll } = boot(order);
const a = () => {}, b = () => {};
poll.tick = 0;
poll.add(a, 1000);
poll.add(b, 1000);
const span = ui.spans.get('poll-status');
poll.remove(a);
await flushMicrotasks();
assert.ok(ui.spans.has('poll-status'), 'one poller is still queued — the pill must stay up');
assert.equal(ui.spans.get('poll-status').handler, span.handler, 'and keep its handler');
});
test(`${order}: a stale handler-less span left over is still cleared by the deferred check`, () => {
/* the exact shape the historical bug left behind — a span shown for "Paused" with no
* previous element to reuse, so ui.showIndicator bound it with handler: null. Reproduced here
* directly (calling the probe rather than dispatching) to show the check recovers even from
* the worst state a leftover span could be in. */
const { ui, poll, mod } = boot(order);
ui.spans.set('poll-status', { label: 'Paused', style: 'inactive', handler: null });
poll.tick = 0;
mod.hidePollIndicatorIfEmpty();
poll.add(() => {}, 1000);
const span = ui.spans.get('poll-status');
assert.ok(span, 'a page that polls gets a pill');
assert.equal(typeof span.handler, 'function', 'the stale, handler-less span must not survive the check');
});
}
test('a manual pause is left alone by the deferred check (queue non-empty)', () => {
const { ui, poll, mod } = boot('stock-first');
poll.tick = 0;
poll.add(() => {}, 1000);
/* the user pauses by hand, mid-page — the queue still has the entry, nothing was navigated */
const span = ui.spans.get('poll-status');
span.handler();
assert.equal(poll.active(), false);
assert.equal(ui.spans.get('poll-status').label, 'Paused');
/* hidePollIndicatorIfEmpty() reads the same queue the listener would, and declines: the queue is
* not empty, whatever emptied it into this state */
mod.hidePollIndicatorIfEmpty();
assert.ok(ui.spans.has('poll-status'), 'a manual pause must survive being read by the check');
assert.equal(typeof ui.spans.get('poll-status').handler, 'function', 'and stay clickable');
/* resume, repeatedly, across what a manual pause actually is: no navigation at all */
span.handler();
assert.equal(poll.active(), true);
span.handler();
assert.equal(poll.active(), false);
assert.equal(typeof ui.spans.get('poll-status').handler, 'function', 'still the same clickable span');
});
@@ -0,0 +1,93 @@
/* fs-router's boot contract: the router must turn itself off on a luci-base that moved.
*
* A unit test rather than a stand run, because the fault it guards is a luci-base this repo does not
* have: both stands ship the surfaces, so a live check only ever sees the healthy branch. The only
* way to exercise the OFF branch is to hand the module an `L` with a hole in it, which is what a
* hostile fork or a trimmed distribution does at someone else's house.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadModule, fakeL, fakeUi, fakeWindow, fakeDocument } from './lib/luci-module.mjs';
function router({ L, window, document, ui } = {}) {
const win = window || fakeWindow();
const doc = document || fakeDocument();
const luci = L || fakeL();
win.L = luci;
return loadModule('fs-router', {
L: luci, window: win, document: doc,
stubs: {
ui: ui || fakeUi(),
rpc: { addInterceptor() {} },
/* the tree answers "this page is not in the menu", the branch wire() treats as a
* wildcard page — i.e. the one that goes on to wire the listeners */
'fs-menutree': { currentNode: () => null, segsFromPath: () => null, resolveSegs: () => null,
viewClassFor: () => null, tree: () => null },
'fs-chrome': { syncActive() {}, renderTabMenu() {} },
'fs-sheets': { watchViewSheets() {}, pageIsPoisoned: () => false }
}
});
}
test('a complete luci-base breaks no contract entry', () => {
assert.deepEqual(router().contractBreaks(), []);
});
test('each missing surface is named, and only that one', () => {
const cases = [
[ 'L.require', (L) => { delete L.require; } ],
[ 'L.Class', (L) => { delete L.Class; } ],
[ 'L.dom.content', (L) => { L.dom = {}; } ],
[ 'L.Poll.queue', (L) => { L.Poll.queue = null; } ],
[ 'L.Poll.start/stop', (L) => { delete L.Poll.stop; } ],
[ 'L.Request.addInterceptor', (L) => { L.Request = {}; } ]
];
for (const [ name, breakIt ] of cases) {
const L = fakeL();
breakIt(L);
assert.deepEqual(router({ L }).contractBreaks(), [ name ],
'breaking ' + name + ' must report exactly that name');
}
});
test('a missing L.env key is reported even though L.env itself is there', () => {
const L = fakeL();
delete L.env.nodespec;
const broken = router({ L }).contractBreaks();
assert.equal(broken.length, 1);
assert.match(broken[0], /L\.env/);
});
test('a gone ui method is reported', () => {
const ui = fakeUi();
delete ui.instantiateView;
assert.deepEqual(router({ ui }).contractBreaks(), [ 'ui.instantiateView' ]);
});
test('an L that throws on a property read counts as missing, not as an exception', () => {
const L = fakeL();
Object.defineProperty(L, 'Poll', { get() { throw new Error('this luci-base is a proxy'); } });
const broken = router({ L }).contractBreaks();
assert.ok(broken.includes('L.Poll.queue'), 'a throwing probe must read as a break: ' + broken);
});
test('a broken contract leaves the document un-intercepted', () => {
const L = fakeL();
delete L.Poll.stop;
const win = fakeWindow(), doc = fakeDocument();
const r = router({ L, window: win, document: doc });
const before = doc.listeners.length;
r.wire();
assert.equal(doc.listeners.filter((l) => l.type === 'click').length, 0,
'no click interception may be installed when the contract is broken');
assert.equal(doc.listeners.length, before, 'nothing at all may be wired');
assert.equal(win.listeners.filter((l) => l.type === 'popstate').length, 0);
});
test('a healthy contract wires the click and popstate paths', () => {
const win = fakeWindow(), doc = fakeDocument();
const r = router({ window: win, document: doc });
r.wire();
assert.equal(doc.listeners.filter((l) => l.type === 'click').length, 1);
assert.equal(win.listeners.filter((l) => l.type === 'popstate').length, 1);
});
@@ -0,0 +1,117 @@
/* When the router decides the session is gone — and, just as important, when it takes that back.
*
* A client navigation cannot survive an expired session: the swap succeeds, the view's first RPC
* comes back 403 and the reader is left on a page that will never fill. So the router listens on
* both interception points luci-base offers — `L.Request` for the HTTP 403 that carries
* `X-LuCI-Login-Required`, `rpc` for the `session/access` reply — and once it hears one it hands
* every later click back to the browser as a full load, which lands on the login page.
*
* The verdict is not a LATCH: that is the wrong shape for a signal this broad. An interceptor sees
* `msg` only after the transport succeeded and the body parsed (rpc.js: a rejected request never
* reaches one), so a missing frame is not a network flap — but it is a captive portal, a proxy's
* error page, a body truncated once.
*
* So the verdict follows the evidence in BOTH directions: a clean `session/access` says the session
* is there, because that is the same call the failing one was. If the session really is gone, no
* clean one arrives and the router stays off. A stand cannot show either half without expiring a
* real session mid-run, which is a fixture, not a test. */
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadModule, installBrowserGlobals, fakeWindow, fakeDocument, fakeL } from './lib/luci-module.mjs';
installBrowserGlobals();
function boot() {
const rpcFns = [], reqFns = [];
const L = fakeL({ Request: { addInterceptor(fn) { reqFns.push(fn); }, get: () => Promise.resolve({}) } });
const window = fakeWindow();
window.L = L;
const mod = loadModule('fs-router', {
window, document: fakeDocument(), L,
stubs: { rpc: { addInterceptor(fn) { rpcFns.push(fn); } } }
});
return {
mod,
/* every registered interceptor, driven the way luci-base drives it */
rpc: (msg, req) => rpcFns.forEach((fn) => fn(msg, req)),
http: (res) => reqFns.forEach((fn) => fn(res))
};
}
const ACCESS = { object: 'session', method: 'access' };
/* the three replies measured on the stands (owrt2512, 25.12.4): a live sid answers `access: true`,
* a dead one answers `access: false` with HTTP 200 and NO error frame, and the `-32002` error is
* what the ordinary call that triggered the probe came back with */
const OK = { jsonrpc: '2.0', id: 1, result: [ 0, { access: true } ] };
const NO_ACCESS = { jsonrpc: '2.0', id: 1, result: [ 0, { access: false } ] };
const DENIED = { jsonrpc: '2.0', id: 1, error: { code: -32002, message: 'Access denied' } };
function response(status, header) {
return { status, headers: { get: (k) => (k === 'X-LuCI-Login-Required' ? header : null) } };
}
test('a clean session/access reply leaves the router on', () => {
const t = boot();
t.rpc(OK, ACCESS);
assert.equal(t.mod.sessionExpired(), false);
});
test('an error on session/access takes the router off', () => {
const t = boot();
t.rpc(DENIED, ACCESS);
assert.equal(t.mod.sessionExpired(), true);
});
test('a later clean session/access takes the verdict BACK', () => {
const t = boot();
t.rpc(DENIED, ACCESS);
t.rpc(OK, ACCESS);
assert.equal(t.mod.sessionExpired(), false,
'the same call that said the session was gone is what can say it is there');
});
test('`access: false` says nothing in either direction', () => {
/* it is what a DEAD sid answers — and what an ACL denial answers for a live one, which is why
* it may neither expire the session nor revive it. The trap it closes: a 403 has just turned
* the router off, luci-base fires this probe, and reading "the frame parsed" as "the session is
* back" would clear the verdict with the very reply that confirms it. */
const t = boot();
t.rpc(NO_ACCESS, ACCESS);
assert.equal(t.mod.sessionExpired(), false, 'a restricted user keeps client navigation');
const u = boot();
u.http(response(403, 'yes'));
u.rpc(NO_ACCESS, ACCESS);
assert.equal(u.mod.sessionExpired(), true, 'and it cannot take back a verdict already reached');
});
test('a reply with no frame is not proof of an expired session on its own', () => {
const t = boot();
t.rpc(null, ACCESS);
t.rpc(OK, ACCESS);
assert.equal(t.mod.sessionExpired(), false);
});
test('an error on somebody else\'s call is not ours to read', () => {
const t = boot();
t.rpc(DENIED, { object: 'uci', method: 'get' });
assert.equal(t.mod.sessionExpired(), false);
});
test('a 403 carrying the login header takes the router off', () => {
const t = boot();
t.http(response(403, 'yes'));
assert.equal(t.mod.sessionExpired(), true);
});
test('a 403 without the header is an ordinary denial', () => {
const t = boot();
t.http(response(403, null));
assert.equal(t.mod.sessionExpired(), false);
});
test('an interceptor handed junk neither throws nor decides', () => {
const t = boot();
assert.doesNotThrow(() => { t.rpc(undefined, undefined); t.http(undefined); });
assert.equal(t.mod.sessionExpired(), false);
});
@@ -0,0 +1,46 @@
/* The one colour literal the theme ships twice, and the wiring that keeps it from mattering.
*
* `manifest.json` and the `<meta name="theme-color">` in partials/head.ut state the same fact — the
* default palette's page colour — because neither file is rendered per request. A router cannot
* catch them drifting: the manifest paints the installed app's splash and the meta paints the
* address bar, so a mismatch looks like two slightly different browsers rather than a bug.
*
* The meta is only a starting value; fs-prefs.js repaints it from the live body background. That
* repaint is what makes the literal cosmetic, so the last check here is that the wiring still
* exists — without it the tag freezes at the default palette in light mode and nothing says so. */
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { read } from '../tools/lib/root.mjs';
const HEAD = read('luci-theme-footstrap/ucode/template/themes/footstrap/partials/head.ut');
const MANIFEST = read('luci-theme-footstrap/htdocs/luci-static/footstrap/manifest.json');
const PREFS = read('luci-theme-footstrap/htdocs/luci-static/resources/fs-prefs.js');
const COMMON = read('luci-theme-footstrap/htdocs/luci-static/resources/menu-footstrap-common.js');
test('head.ut ships exactly one theme-color meta, with a literal colour', () => {
const tags = [ ...HEAD.matchAll(/<meta\s+name="theme-color"[^>]*>/g) ];
assert.equal(tags.length, 1,
'a second, media-qualified tag would outrank this one for a viewer whose OS disagrees with '
+ 'the choice made in Appearance');
assert.match(tags[0][0], /content="#[0-9a-f]{6}"/,
'the server cannot compute the palette colour, so the tag carries a literal');
});
test('the meta literal is the manifest theme_color', () => {
const meta = HEAD.match(/<meta\s+name="theme-color"\s+content="(#[0-9a-f]{6})"/)[1];
const manifest = JSON.parse(MANIFEST);
assert.equal(meta, manifest.theme_color,
'both state the default palette page colour; change one and change the other');
assert.equal(manifest.theme_color, manifest.background_color,
'the splash and the window chrome are the same surface');
});
test('the tag is repainted from the live page colour', () => {
assert.match(PREFS, /function watchThemeColor\(/,
'fs-prefs owns the repaint; without it the tag freezes at the default palette');
assert.match(PREFS, /getComputedStyle\(document\.body\)\.backgroundColor/,
'read the computed background, never the --fs-bg token: a custom property hands back its '
+ 'token stream, so a mixed palette colour would land in the attribute verbatim');
assert.match(COMMON, /prefs\.watchThemeColor\(\)/,
'the watcher is started from init(), beside the dark-stamp guard');
});