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

253 lines
10 KiB
JavaScript

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 VolanticSystems
//
// Load a real LuCI view module under Node, unmodified, so its exported pure
// functions can be tested without a browser.
//
// WHY THIS EXISTS. Every browser-side defect this package has had was found by
// a human or a review panel reading the code. Not one was found by a test,
// because no test could run any of it: LuCI view files are not CommonJS or ES
// modules, they are evaluated by LuCI's own loader with the `'require x';`
// directives intercepted. So the JavaScript half of a package whose entire
// safety promise lives in `audit()` had zero automated coverage.
//
// WHAT IS AND IS NOT STUBBED, because this is where a harness like this goes
// wrong. The stubs supply DATA and inert plumbing only:
//
// baseclass.extend(o) -> returns o. LuCI returns a class whose singleton
// exposes the same properties; callers use it as
// `gen.audit()`, so a plain object is behaviourally
// identical for these tests.
// uci.sections(pkg) -> returns the fixture array the test supplied.
// rpc / request -> throw if called. Nothing under test calls them,
// and a stub that silently returned a plausible
// value would be the reimplementation trap: the
// test would pass against a product that had
// stopped asking.
//
// NO LOGIC FROM THE MODULE IS COPIED HERE. The classification, generation and
// validation code that runs is the shipped code, byte for byte.
'use strict';
const fs = require('fs');
const vm = require('vm');
const path = require('path');
// Strip the LuCI loader directives. They are bare string-expression statements
// at the top of the file, exactly `'require name';` or `'require name as x';`,
// and they are the only thing preventing the file from evaluating as ordinary
// JavaScript. Replaced with a blank line each so every reported line number
// still matches the real file.
// The same String.prototype.format, as SOURCE TEXT, so it can be evaluated
// inside the vm realm where the module actually runs.
const FORMAT_SRC = `
Object.defineProperty(String.prototype, 'format', {
value: function () {
var args = arguments, i = 0;
return this.replace(/%(?:(\d+)\$)?([sdifj%])/g, function (m, pos, kind) {
if (kind === '%') return '%';
var v = args[pos ? (parseInt(pos, 10) - 1) : i++];
if (kind === 'd' || kind === 'i') return String(parseInt(v, 10));
if (kind === 'f') return String(parseFloat(v));
if (kind === 'j') return JSON.stringify(v);
return (v == null) ? '' : String(v);
});
}
});
`;
function stripRequires(src) {
return src.replace(/^\s*'require [^']*';\s*$/gm, '');
}
// load(file, stubs, opts) -> the object the module returns.
//
// opts.translate replaces the _() implementation. Default is identity, which
// is LuCI's real behaviour with no catalogue loaded.
//
// WHY A NON-IDENTITY TRANSLATOR IS WORTH HAVING. With identity, a label that
// went through _() and a label a runtime title-caser produced are the same
// string, so no assertion on the OUTPUT can tell them apart. That is not
// hypothetical: three label paths shipped untranslatable underneath a green
// run of a suite that asserted exactly those outputs. Loading the module a
// second time with a marking translator makes the two observationally
// different, and equality assertions become sound again rather than
// coincidentally true. Raised by a review panel, 2026-08-30.
function load(file, stubs, opts) {
const translate = (opts && opts.translate) || ((s) => s);
const src = fs.readFileSync(file, 'utf8');
// Every string passed to _() while the module evaluates, in order. This is
// not decoration: it lets a test assert that the shipped catalogue
// (po/templates/*.pot) actually covers the strings the code asks for, which
// is otherwise a claim nobody checks until a translator files a bug.
const translated = [];
const sandbox = Object.assign({
baseclass: { extend: (o) => o },
// LuCI's _() returns its msgid unchanged when no catalogue is loaded,
// which is the default English case, so identity is the real
// behaviour rather than a convenient stand-in.
_: (s) => { translated.push(s); return translate(s); },
N_: (n, s, p) => {
translated.push(s); translated.push(p);
return translate(n === 1 ? s : p);
},
L: {
// L.resource() builds a static asset URL. It is a pure string
// join in LuCI and is used at module top level, so it has to
// return something or the file will not evaluate. This is the one
// stub that returns a value rather than throwing, and it returns
// an obviously-fake path so a test that accidentally depends on it
// fails visibly rather than plausibly.
resource: (...p) => '/TEST-RESOURCE/' + p.join('/'),
// L.toArray is a LuCI RUNTIME helper, not product logic, and this
// is a faithful copy of it from luci-base's luci.js rather than an
// approximation. Getting it wrong would make normalize() behave
// differently here than in a browser, which is the one way a
// harness like this lies convincingly: the product code would be
// real and the answer would still be wrong.
//
// null/undefined -> []
// array -> itself
// object -> [ it ]
// '' -> []
// otherwise -> String(val).trim().split(/\s+/)
toArray: (val) => {
if (val == null) return [];
if (Array.isArray(val)) return val;
if (typeof val === 'object') return [ val ];
const s = String(val).trim();
return s === '' ? [] : s.split(/\s+/);
},
// Everything else throws rather than returning something
// plausible. A stub that silently answers is how a test starts
// passing against a product that has stopped asking.
error: (e) => { throw new Error('L.error: ' + e); },
raise: (e) => { throw new Error('L.raise: ' + e); }
},
// LuCI's DOM element factory. The real one returns a live DOM node;
// this returns an inspectable plain tree of the same shape.
//
// BE HONEST ABOUT WHAT THIS CAN AND CANNOT TEST. It shows which
// element, class and text the code CHOSE to emit, which is exactly
// where this project's browser defects have lived: a status pill that
// rendered "healthy" for an unknown state, a tile whose colour came
// back undefined. It says nothing about layout, CSS cascade, or what
// a browser finally paints. Those still need a browser.
E: function (tag, attrs, children) {
if (attrs != null && (typeof attrs !== 'object' || Array.isArray(attrs))) {
children = attrs;
attrs = {};
}
return {
tag: tag,
attrs: attrs || {},
children: children == null ? [] : [].concat(children)
};
},
console: console
}, stubs || {});
// The module body ends in a top-level `return`, which is only legal inside
// a function, so wrap it in one.
const fn = new vm.Script(
'(function(){\n' + stripRequires(src) + '\n})()',
{ filename: path.basename(file) }
);
// vm gives the module its own REALM, with its own String.prototype, so a
// polyfill installed in Node's realm does not reach it. Build the context
// first, install the LuCI runtime extensions INSIDE it, then evaluate.
// Without this, product code calling '%s'.format() throws here while
// working perfectly in a browser.
const ctx = vm.createContext(sandbox);
new vm.Script(FORMAT_SRC, { filename: 'luci-runtime-shim.js' }).runInContext(ctx);
const mod = fn.runInContext(ctx);
// Non-enumerable so it cannot be mistaken for part of the module's own
// export surface by a test that iterates the keys.
Object.defineProperty(mod, '__translated', {
value: translated, enumerable: false
});
return mod;
}
// Every msgid in a .pot file. Used to assert the catalogue covers the strings
// the code actually asks for.
function potMsgids(file) {
const out = new Set();
const re = /^msgid "((?:[^"\\]|\\.)*)"/gm;
const src = fs.readFileSync(file, 'utf8');
let m;
while ((m = re.exec(src)) !== null) {
const s = m[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\')
.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
if (s) out.add(s);
}
return out;
}
// A uci stub whose sections() returns fixture data in LuCI's own shape:
// each section is a flat object of its options plus '.name' and '.type'.
function uciStub(sectionsByPackage) {
return {
sections: (pkg) => (sectionsByPackage[pkg] || []),
get: (pkg, sec, opt) => {
const list = sectionsByPackage[pkg] || [];
const s = list.find((x) => x['.name'] === sec);
return s ? s[opt] : undefined;
}
};
}
// Parse the output of `uci show <pkg>` into LuCI section objects.
//
// This is here so a fixture can be written in the SAME text the init script
// parses, rather than as hand-built objects that have quietly drifted from
// what uci actually emits. The two halves of the ownership rule are supposed
// to see the same configuration; letting them be described twice, in two
// notations, is how they stopped agreeing in the first place.
function parseUciShow(text) {
const sections = [];
const byName = {};
for (const line of text.split('\n')) {
const t = line.trim();
if (!t) continue;
let m = t.match(/^([a-z0-9_-]+)\.([^.=]+)=(\S+)$/i);
if (m) {
const sec = { '.name': m[2], '.type': m[3] };
byName[m[2]] = sec;
sections.push(sec);
continue;
}
m = t.match(/^([a-z0-9_-]+)\.([^.=]+)\.([^=]+)='(.*)'$/i);
if (m && byName[m[2]]) byName[m[2]][m[3]] = m[4];
}
return sections;
}
// Flatten an E()-tree to its visible text, and to the set of class names used.
// Both are what a test wants to assert on: "which pill did it choose", not
// "what did the browser paint".
function textOf(node) {
if (node == null || node === false) return '';
if (typeof node === 'string' || typeof node === 'number') return String(node);
if (Array.isArray(node)) return node.map(textOf).join('');
if (node.children) return node.children.map(textOf).join('');
return '';
}
function classesOf(node, out) {
out = out || new Set();
if (node == null || typeof node !== 'object') return out;
if (Array.isArray(node)) { node.forEach((n) => classesOf(n, out)); return out; }
const c = node.attrs && (node.attrs['class'] || node.attrs.class);
if (typeof c === 'string') c.trim().split(/\s+/).forEach((x) => x && out.add(x));
(node.children || []).forEach((n) => classesOf(n, out));
return out;
}
module.exports = { load, uciStub, parseUciShow, stripRequires, potMsgids, textOf, classesOf };