mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-05 01:44:16 +08:00
💋 Sync 2026-10-04 04:16:09
This commit is contained in:
1 parent
f6b2df73ec
commit
f62992fd80
745 files changed
+212416
-1173
No files matched your search
@@ -0,0 +1,252 @@
|
||||
// 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 };
|
||||
Reference in new issue
Block a user