mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-27 00:42:51 +08:00
410 lines
14 KiB
JavaScript
410 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
/*
|
|
* SPDX-License-Identifier: GPL-2.0-only
|
|
*
|
|
* Three frontend call sites whose *wiring* - not their body - decides whether
|
|
* they work, driven the way LuCI drives them:
|
|
*
|
|
* 1. client/nodes.js `urltest_nodes.validate` must not dereference a null
|
|
* value. It used to re-read the value through `this.section.formvalue()`
|
|
* (which answers null when the widget is not in the DOM) and then read
|
|
* `.length` on it, so the save aborted with a TypeError instead of the
|
|
* "non-empty value" message. The framework already hands the value in:
|
|
* getValidator() is L.bind(this.validate, this, section_id), so the widget
|
|
* calls validate(value).
|
|
*
|
|
* 2. client/access.js `_proxy_domain_list.remove` / `_direct_domain_list.remove`
|
|
* must read routing_mode from the 'config' section. `this.section` is the
|
|
* nested 'control' NamedSection, whose children do not include
|
|
* routing_mode, so `formvalue()` returned null and the test was always
|
|
* true: a reset wiped the domain list even in custom mode.
|
|
*
|
|
* 3. view/homeproxy-pro/status.js `getRuntimeLog` must receive each log's
|
|
* basename. It used to derive it with `o.option.split('_')[1]`; it is now
|
|
* the third bound argument, ahead of the framework's
|
|
* (option_index, section_id, in_table). Shift that by one and the switch
|
|
* stops matching, so the log-level select disappears and the wrong
|
|
* basename reaches fs.read_direct and log_clean.
|
|
*
|
|
* The mocks cover only the surface each module touches; the plumbing under
|
|
* test is the value/argument wiring.
|
|
*
|
|
* NOTE: tests/run.sh enumerates its node suites explicitly and is owned by
|
|
* another change, so this file is not in that list yet. Run it directly:
|
|
*
|
|
* node tests/frontend-view-wiring.js <repo-root>
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const path = require('path');
|
|
const { loadLuciModule } = require('./lib/luci-module.js');
|
|
|
|
const root = path.resolve(process.argv[2] || '.');
|
|
|
|
/* LuCI extends String with format() (luci.js); status.js builds the log path
|
|
* with it. The shared loader does not inject it, so provide it here exactly as
|
|
* tests/luci-form-snapshot.js does. */
|
|
if (typeof String.prototype.format !== 'function') {
|
|
String.prototype.format = function (...args) {
|
|
const values = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
|
|
let i = 0;
|
|
return this.replace(/%[sdj%]/g, (m) => (m === '%%' ? '%' : (i < values.length ? String(values[i++]) : m)));
|
|
};
|
|
}
|
|
|
|
/* cbi.js defines the static form as `''.format.apply(fmt, args)`; status.js
|
|
* builds the log path with it. */
|
|
if (typeof String.format !== 'function')
|
|
String.format = function (fmt, ...args) { return String(fmt).format(...args); };
|
|
|
|
let checks = 0, failures = 0;
|
|
function check(what, ok, detail) {
|
|
checks++;
|
|
if (ok) return;
|
|
failures++;
|
|
console.error(`FAIL ${what}${detail ? ': ' + detail : ''}`);
|
|
}
|
|
|
|
/* --- a form mock that records what the views build --------------------- */
|
|
class MockOption {
|
|
constructor(kind, name, title, description) {
|
|
this.__kind = kind && kind.__name__ ? kind.__name__ : String(kind);
|
|
this.__name = name;
|
|
this.option = name;
|
|
this.title = title;
|
|
this.description = description;
|
|
if (this.__kind === 'SectionValue')
|
|
this.subsection = new MockSection('NamedSection', name);
|
|
}
|
|
depends() {}
|
|
value() {}
|
|
cbid(section_id) { return `cbid.homeproxy-pro.${section_id}.${this.__name}`; }
|
|
}
|
|
|
|
class MockSection {
|
|
constructor(kind, name) {
|
|
this.__kind = kind;
|
|
this.__name = name;
|
|
this.options = [];
|
|
}
|
|
tab() {}
|
|
taboption(_tab, ...args) { return this.option(...args); }
|
|
option(kind, name, title, description) {
|
|
const o = new MockOption(kind, name, title, description);
|
|
o.section = this;
|
|
this.options.push(o);
|
|
return o;
|
|
}
|
|
/* form.js AbstractSection.formvalue() walks the *children* of this
|
|
* section and answers null for anything else - which is why
|
|
* `this.section.formvalue('config', 'routing_mode')` from inside the
|
|
* nested 'control' section never found routing_mode. */
|
|
formvalue(_section_id, option) {
|
|
for (const child of this.options)
|
|
if (child.__name === option)
|
|
return child.value;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function makeForm() {
|
|
const form = {};
|
|
for (const name of [ 'Value', 'ListValue', 'Flag', 'DynamicList', 'MultiValue', 'TextValue',
|
|
'DummyValue', 'Button', 'TypedSection', 'NamedSection', 'GridSection', 'SectionValue' ]) {
|
|
form[name] = class {};
|
|
/* form.js names each CBI class on `__name__`; the views use it to
|
|
* tell a SectionValue (which owns a sub-section) from the rest. */
|
|
form[name].__name__ = name;
|
|
}
|
|
|
|
form.Map = class {
|
|
constructor(title) { this.title = title; this.sections = []; }
|
|
section(kind, name) {
|
|
const section = new MockSection(kind && kind.__name__ ? kind.__name__ : String(kind), name);
|
|
this.sections.push(section);
|
|
return section;
|
|
}
|
|
render() { return this; }
|
|
};
|
|
|
|
return form;
|
|
}
|
|
|
|
function findOption(section, name) {
|
|
for (const o of section.options)
|
|
if (o.__name === name)
|
|
return o;
|
|
return null;
|
|
}
|
|
|
|
const L = { bind: (fn, self, ...bound) => fn.bind(self, ...bound) };
|
|
|
|
/* ====================================================================== *
|
|
* 1. nodes.js: the urltest_nodes validator is null-safe
|
|
* ====================================================================== */
|
|
|
|
function testNodesValidator() {
|
|
const form = makeForm();
|
|
const nodes = loadLuciModule(
|
|
path.join(root, 'htdocs/luci-static/resources/view/homeproxy-pro/client/nodes.js'),
|
|
{
|
|
baseclass: { extend: (o) => o },
|
|
form,
|
|
uci: { sections: () => {} },
|
|
homeproxy-pro: {
|
|
CBIStaticList: class {},
|
|
loadModalTitle: () => '',
|
|
loadDefaultLabel: () => '',
|
|
validateUniqueValue: () => true,
|
|
renderSectionAdd: () => null
|
|
},
|
|
'tools.widgets': { DeviceSelect: class {} }
|
|
},
|
|
{ L, E: () => null });
|
|
|
|
const ctx = { s: new MockSection('TypedSection', undefined), proxy_nodes: {} };
|
|
nodes.renderRoutingNodes(ctx);
|
|
|
|
const section = ctx.s.options[0].subsection;
|
|
const validate = findOption(section, 'urltest_nodes')?.validate;
|
|
check('nodes.js: urltest_nodes has a validate callback', typeof validate === 'function', typeof validate);
|
|
if (typeof validate !== 'function')
|
|
return;
|
|
|
|
/* Before the fix this re-read `this.section.formvalue()` - null here, since
|
|
* the widget is not in the DOM - and threw on `.length`. */
|
|
let threw = null;
|
|
try {
|
|
check('nodes.js: a missing value passes without throwing',
|
|
validate.call({ section: { formvalue: () => null } }, 'sec1', null) === true);
|
|
}
|
|
catch (e) {
|
|
threw = e;
|
|
}
|
|
check('nodes.js: a null value does not throw', threw === null, threw && threw.message);
|
|
|
|
check('nodes.js: an empty list is rejected',
|
|
validate.call({}, 'sec1', []) !== true && String(validate.call({}, 'sec1', [])).length > 0);
|
|
check('nodes.js: a non-empty list passes',
|
|
validate.call({}, 'sec1', [ 'node1' ]) === true);
|
|
check('nodes.js: no section means no validation',
|
|
validate.call({}, null, []) === true);
|
|
}
|
|
|
|
/* ====================================================================== *
|
|
* 2. access.js: the domain-list resets read routing_mode from 'config'
|
|
* ====================================================================== */
|
|
|
|
function testAccessDomainLists() {
|
|
const form = makeForm();
|
|
const writes = [];
|
|
/* The module closes over its own `uci` dependency, so the per-case values
|
|
* go into a mutable stub rather than a captured snapshot. */
|
|
const uciStub = { get: () => undefined };
|
|
|
|
const access = loadLuciModule(
|
|
path.join(root, 'htdocs/luci-static/resources/view/homeproxy-pro/client/access.js'),
|
|
{
|
|
baseclass: { extend: (o) => o },
|
|
form,
|
|
uci: uciStub,
|
|
homeproxy-pro: {
|
|
rpcCall: (method, args) => {
|
|
writes.push({ method, args });
|
|
return Promise.resolve({ result: true });
|
|
}
|
|
},
|
|
'tools.widgets': { DeviceSelect: class {} },
|
|
'tools.firewall': {
|
|
addIPOption: (ss, _tab, name) => ss.option(form.Value, name),
|
|
addMACOption: (ss, _tab, name) => ss.option(form.Value, name)
|
|
}
|
|
},
|
|
{ L, E: () => null });
|
|
|
|
const run = (routing_mode, option_name) => {
|
|
writes.length = 0;
|
|
uciStub.get = (_cfg, _section, option) => (option === 'routing_mode' ? routing_mode : undefined);
|
|
|
|
const ctx = {
|
|
s: new MockSection('NamedSection', 'config'),
|
|
hosts: {},
|
|
stubValidator: { apply: () => true }
|
|
};
|
|
access.render(ctx);
|
|
|
|
const control = ctx.s.options.find((o) => o.__name === '_control').subsection;
|
|
const so = findOption(control, option_name);
|
|
check(`access.js: ${option_name} exists`, so !== null, String(so));
|
|
if (!so)
|
|
return Promise.resolve([]);
|
|
|
|
/* rpcCall() runs synchronously inside remove(), so the snapshot has
|
|
* to be taken here: the four cases are started in the same tick and a
|
|
* shared array read from .then() would see all of them. */
|
|
const ret = so.remove.call(so);
|
|
const snapshot = writes.slice();
|
|
|
|
return Promise.resolve(ret).then(() => snapshot);
|
|
};
|
|
|
|
const cleared = (w, list) =>
|
|
w.length === 1 && w[0].method === 'acllist_write' && w[0].args[0] === list && w[0].args[1] === '';
|
|
|
|
return Promise.all([
|
|
run('custom', '_proxy_domain_list').then((w) =>
|
|
check('access.js: custom mode keeps the proxy list', w.length === 0, JSON.stringify(w))),
|
|
run('global', '_proxy_domain_list').then((w) =>
|
|
check('access.js: non-custom mode clears the proxy list', cleared(w, 'proxy_list'), JSON.stringify(w))),
|
|
run('custom', '_direct_domain_list').then((w) =>
|
|
check('access.js: custom mode keeps the direct list', w.length === 0, JSON.stringify(w))),
|
|
run('global', '_direct_domain_list').then((w) =>
|
|
check('access.js: non-custom mode clears the direct list', cleared(w, 'direct_list'), JSON.stringify(w)))
|
|
]);
|
|
}
|
|
|
|
/* ====================================================================== *
|
|
* 3. status.js: each log view keeps its own basename
|
|
* ====================================================================== */
|
|
|
|
function E(tag, attrs, children) {
|
|
if (Array.isArray(tag) && attrs === undefined)
|
|
return { tag: null, attrs: {}, children: tag, appendChild() {} };
|
|
|
|
return {
|
|
tag,
|
|
attrs: attrs || {},
|
|
children: children === undefined ? [] : (Array.isArray(children) ? children : [ children ]),
|
|
appendChild() {}
|
|
};
|
|
}
|
|
|
|
function findClickable(node) {
|
|
if (!node || typeof node !== 'object')
|
|
return null;
|
|
if (node.attrs && typeof node.attrs.click === 'function')
|
|
return node;
|
|
for (const child of node.children || []) {
|
|
const hit = findClickable(child);
|
|
if (hit) return hit;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function findTag(node, tag) {
|
|
if (!node || typeof node !== 'object')
|
|
return null;
|
|
if (node.tag === tag)
|
|
return node;
|
|
for (const child of node.children || []) {
|
|
const hit = findTag(child, tag);
|
|
if (hit) return hit;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function testLogViewWiring() {
|
|
const form = makeForm();
|
|
const reads = [];
|
|
const rpcCalls = [];
|
|
let pollHandlers = null;
|
|
|
|
const status = loadLuciModule(
|
|
path.join(root, 'htdocs/luci-static/resources/view/homeproxy-pro/status.js'),
|
|
{
|
|
baseclass: {},
|
|
dom: { content: () => {} },
|
|
form,
|
|
fs: { read_direct: (p) => { reads.push(p); return Promise.resolve('log line'); } },
|
|
poll: { add: () => {} },
|
|
uci: { get: () => 'warn' },
|
|
ui: {
|
|
createHandlerFn: (_self, fn) => fn,
|
|
addNotification: () => {},
|
|
changes: { apply: () => {} },
|
|
showModal: () => {},
|
|
hideModal: () => {}
|
|
},
|
|
view: { extend: (o) => o },
|
|
homeproxy-pro: {
|
|
statusPoller: (handlers) => { pollHandlers = handlers; return () => {}; },
|
|
rpcCall: (method, args) => { rpcCalls.push({ method, args }); return Promise.resolve({}); }
|
|
}
|
|
},
|
|
{ L: Object.assign({}, L, { resource: () => 'icons/loading.svg', env: { pollinterval: 5 } }), E });
|
|
|
|
const map = status.render();
|
|
const section = map.sections[map.sections.length - 1];
|
|
|
|
const wanted = [
|
|
[ '_homeproxy_logview', 'homeproxy-pro' ],
|
|
[ '_sing-box-c_logview', 'sing-box-c' ],
|
|
[ '_sing-box-s_logview', 'sing-box-s' ]
|
|
];
|
|
|
|
let rendered = 0;
|
|
for (const [ option_name, basename ] of wanted) {
|
|
const o = findOption(section, option_name);
|
|
check(`status.js: ${option_name} exists`, o !== null, String(o));
|
|
if (!o)
|
|
continue;
|
|
|
|
rpcCalls.length = 0;
|
|
/* form.js calls render(option_index, section_id, in_table). */
|
|
const tree = o.render(0, 'cfg01', false);
|
|
rendered++;
|
|
|
|
/* log_clean must target the same basename this view reads. */
|
|
const button = findClickable(tree);
|
|
check(`status.js: ${option_name} has a clean button`, button !== null, 'no click handler in the tree');
|
|
if (button) {
|
|
Promise.resolve(button.attrs.click());
|
|
check(`status.js: ${option_name} cleans '${basename}'`,
|
|
rpcCalls.length === 1 && rpcCalls[0].method === 'log_clean' && rpcCalls[0].args[0] === basename,
|
|
JSON.stringify(rpcCalls));
|
|
}
|
|
|
|
/* The log-level select is built inside a switch on the basename: a
|
|
* shifted argument turns it into a label, no case matches, and the
|
|
* select disappears. */
|
|
const select = findTag(tree, 'select');
|
|
const want_select = basename !== 'homeproxy-pro';
|
|
check(`status.js: ${option_name} log-level select ${want_select ? 'present' : 'absent'}`,
|
|
(select !== null) === want_select, String(select));
|
|
if (select)
|
|
check(`status.js: ${option_name} select carries section_id`,
|
|
String(select.attrs.id || '').includes('cfg01'), String(select.attrs.id));
|
|
}
|
|
|
|
check('status.js: all three log views rendered', rendered === 3, String(rendered));
|
|
check('status.js: the log poll was registered', pollHandlers !== null && typeof pollHandlers.read === 'function');
|
|
|
|
if (!pollHandlers)
|
|
return Promise.resolve();
|
|
|
|
/* One tick reads one file per registered target, keyed by basename. */
|
|
return Promise.resolve(pollHandlers.read()).then(() => {
|
|
check('status.js: the poll reads each log by basename',
|
|
JSON.stringify(reads.slice().sort()) === JSON.stringify([
|
|
'/var/run/homeproxy-pro/homeproxy-pro.log',
|
|
'/var/run/homeproxy-pro/sing-box-c.log',
|
|
'/var/run/homeproxy-pro/sing-box-s.log'
|
|
].sort()),
|
|
JSON.stringify(reads));
|
|
});
|
|
}
|
|
|
|
testNodesValidator();
|
|
|
|
testAccessDomainLists()
|
|
.then(testLogViewWiring)
|
|
.then(() => {
|
|
console.log(`frontend view wiring: ${checks} checks, ${failures} failures`);
|
|
process.exit(failures ? 1 : 0);
|
|
})
|
|
.catch((e) => {
|
|
console.error('FAIL frontend view wiring threw:', (e && e.stack) || e);
|
|
process.exit(1);
|
|
});
|