mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-11-01 01:25:13 +08:00
update 2026-09-20 01:22:33
This commit is contained in:
1 parent
42e0cfbec4
commit
deb27aee9d
864 files changed
+180566
-42
No files matched your search
@@ -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, '"') + '"').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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user