mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-26 22:12:51 +08:00
271 lines
13 KiB
JavaScript
271 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
/*
|
|
* SPDX-License-Identifier: GPL-2.0-only
|
|
*
|
|
* What the UI does when an RPC does not answer. Two paths, both of which
|
|
* used to state something false rather than admit ignorance.
|
|
*
|
|
* getBuiltinFeatures() goes through rpcCall(), which resolves to `{}` when the
|
|
* call does not come back. The protocol list used to be filtered against that
|
|
* map unconditionally, so an empty map hid every feature-gated protocol
|
|
* (hysteria, hysteria2, naive, tuic, wireguard). A node already saved as one
|
|
* of them then rendered with no matching Select option, and LuCI's
|
|
* AbstractValue.parse() writes whenever formvalue != cfgvalue - so the next
|
|
* Save & Apply silently rewrote the node's `type` to whichever protocol came
|
|
* first. Silent protocol change, from a failed status RPC.
|
|
*
|
|
* No test could see it: luci-form-snapshot.js always passes the fully
|
|
* populated feature map (and its rpc stub always resolves), so the empty-map
|
|
* path was never taken.
|
|
*
|
|
* Usage: node tests/frontend-feature-fallback.js <repo-root>
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const path = require('path');
|
|
|
|
const { loadLuciModule } = require('./lib/luci-module.js');
|
|
|
|
const root = path.resolve(process.argv[2] || '.');
|
|
|
|
let checks = 0, failures = 0;
|
|
function check(what, ok, detail) {
|
|
checks++;
|
|
if (ok) return;
|
|
failures++;
|
|
console.error(`FAIL ${what}${detail ? ': ' + detail : ''}`);
|
|
}
|
|
|
|
const hp = loadLuciModule(
|
|
path.join(root, 'htdocs/luci-static/resources/homeproxy-pro.js'),
|
|
{ baseclass: { extend: (o) => o }, form: { DynamicList: { extend: (o) => o } },
|
|
fs: {}, rpc: {}, uci: {}, ui: {} });
|
|
|
|
function types(side, features) {
|
|
return hp.protocolChoices(side, features).map((p) => p.type);
|
|
}
|
|
|
|
const FULL = { with_quic: true, with_wireguard: true, with_gvisor: true };
|
|
const GATED_CLIENT = ['hysteria', 'hysteria2', 'tuic', 'wireguard'];
|
|
const GATED_SERVER = ['hysteria', 'hysteria2', 'naive', 'tuic'];
|
|
|
|
/* --- the failure path: an empty map means "unknown", not "none" ---------- */
|
|
|
|
for (const [side, gated] of [['client', GATED_CLIENT], ['server', GATED_SERVER]]) {
|
|
const unknown = types(side, {});
|
|
const full = types(side, FULL);
|
|
|
|
for (const g of gated) {
|
|
check(`${side}: ${g} is still offered when the feature call failed`,
|
|
unknown.indexOf(g) !== -1,
|
|
`offered: ${unknown.join(',')}`);
|
|
}
|
|
|
|
/* The property that actually matters: an unanswered call must not offer
|
|
* FEWER protocols than a successful one. */
|
|
check(`${side}: an unknown feature map offers at least as many as a full one`,
|
|
unknown.length >= full.length,
|
|
`unknown=${unknown.length} full=${full.length}`);
|
|
|
|
check(`${side}: the unknown list contains everything the full list does`,
|
|
full.every((t) => unknown.indexOf(t) !== -1),
|
|
`missing: ${full.filter((t) => unknown.indexOf(t) === -1).join(',')}`);
|
|
}
|
|
|
|
/* --- and the filter still works when the features ARE known -------------- */
|
|
|
|
const noQuic = types('client', { with_quic: false, with_wireguard: true, with_gvisor: true });
|
|
for (const g of ['hysteria', 'hysteria2', 'tuic'])
|
|
check(`client: ${g} is filtered out when with_quic is false`, noQuic.indexOf(g) === -1,
|
|
`offered: ${noQuic.join(',')}`);
|
|
check('client: wireguard survives with_quic=false', noQuic.indexOf('wireguard') !== -1);
|
|
|
|
/* wireguard needs BOTH of its features; one missing must be enough to hide it. */
|
|
const partialWg = types('client', { with_quic: true, with_wireguard: true, with_gvisor: false });
|
|
check('client: wireguard is hidden when only one of its features is present',
|
|
partialWg.indexOf('wireguard') === -1, `offered: ${partialWg.join(',')}`);
|
|
|
|
/* --- renderProtocolOptions must use the same rule ------------------------ */
|
|
|
|
function driveRenderProtocolOptions(features, side) {
|
|
const offered = [];
|
|
const fakeOption = {
|
|
value: (type) => { offered.push(type); },
|
|
rmempty: true
|
|
};
|
|
const fakeSection = { option: () => fakeOption };
|
|
|
|
hp.renderProtocolOptions(fakeSection, { features: features, side: side });
|
|
return offered;
|
|
}
|
|
|
|
for (const [side, gated] of [['client', GATED_CLIENT], ['server', GATED_SERVER]]) {
|
|
const offered = driveRenderProtocolOptions({}, side);
|
|
for (const g of gated)
|
|
check(`renderProtocolOptions(${side}, {}) still offers ${g}`,
|
|
offered.indexOf(g) !== -1, `offered: ${offered.join(',')}`);
|
|
}
|
|
|
|
const renderedFull = driveRenderProtocolOptions(FULL, 'client');
|
|
check('renderProtocolOptions offers every client protocol with full features',
|
|
renderedFull.length === types('client', FULL).length,
|
|
`${renderedFull.length} vs ${types('client', FULL).length}`);
|
|
|
|
/* --- the status bar: "the query failed" is not "the service is down" ----- */
|
|
|
|
/* getServiceStatus() asks rpcCall() for a `null` fallback so it can tell the
|
|
* two apart; the default `{}` made the try/catch report a failed query as
|
|
* NOT RUNNING, which reads as "your proxy is down". */
|
|
const running = hp.statusLabel(true);
|
|
const stopped = hp.statusLabel(false);
|
|
const unknown = hp.statusLabel(null);
|
|
|
|
check('a running service is green', String(running.color) === 'green', String(running.color));
|
|
check('a stopped service is red', String(stopped.color) === 'red', String(stopped.color));
|
|
check('an unanswered query is neither green nor red',
|
|
String(unknown.color) !== 'green' && String(unknown.color) !== 'red',
|
|
String(unknown.color));
|
|
|
|
check('an unanswered query does not say NOT RUNNING',
|
|
String(unknown.text) !== String(stopped.text),
|
|
`unknown=${String(unknown.text)} stopped=${String(stopped.text)}`);
|
|
check('an unanswered query does not say RUNNING either',
|
|
String(unknown.text) !== String(running.text),
|
|
`unknown=${String(unknown.text)} running=${String(running.text)}`);
|
|
check('a missing status argument is treated as unknown',
|
|
String(hp.statusLabel(undefined).text) === String(unknown.text));
|
|
check('the three states are three distinct texts',
|
|
new Set([String(running.text), String(stopped.text), String(unknown.text)]).size === 3);
|
|
|
|
/* --- the status poll registers exactly once ----------------------------- */
|
|
|
|
/* The flag that makes this true used to live in client.js and server.js as two
|
|
* separate booleans. Every re-render that got past it added another poll
|
|
* handler, and LuCI keeps them all. The form snapshots never reach this code,
|
|
* so nothing could fail until the state moved somewhere testable. */
|
|
function makePoll() {
|
|
const added = [];
|
|
return {
|
|
added: added,
|
|
poll: { add: (fn) => { added.push(fn); return added.length; } }
|
|
};
|
|
}
|
|
|
|
{
|
|
const h = makePoll();
|
|
let reads = 0;
|
|
const register = hp.statusPoller({
|
|
poll: h.poll,
|
|
read: () => { reads++; return Promise.resolve('v'); },
|
|
paint: () => {}
|
|
});
|
|
|
|
check('the first registration reports that it registered', register() === true);
|
|
check('a second registration reports that it did not', register() === false);
|
|
check('a third registration reports that it did not', register() === false);
|
|
check('exactly one poll handler was added', h.added.length === 1, `${h.added.length} added`);
|
|
|
|
/* The handler has to actually do something - a registrar that registered an
|
|
* empty function would satisfy the count above. read() is called
|
|
* synchronously inside the handler, so this needs no awaiting (and a
|
|
* top-level `return` is not allowed in a CommonJS module anyway - the first
|
|
* version of this used one and the whole file failed to load, printing
|
|
* nothing at all). */
|
|
h.added[0]();
|
|
check('the registered handler calls read() when invoked', reads === 1, `${reads} reads`);
|
|
}
|
|
|
|
/* --- the rule-set degradation report ------------------------------------
|
|
*
|
|
* A remote rule-set is fetched before the service starts, so a failure there
|
|
* is a failed start and the running instance has nothing to show for it. The
|
|
* reason is only ever written to a log, and a start that failed has no status
|
|
* page - so this parse is the only place it becomes legible without ssh.
|
|
*
|
|
* It parses a file sing-box is concurrently writing and that clean_log.sh
|
|
* truncates at 50 KB, so the cases below are about tolerance and about not
|
|
* lying: a missing timestamp must read as "no timestamp", never as a guess.
|
|
*/
|
|
const FAILURES = hp.parseRuleSetFetchFailures;
|
|
|
|
/* The first two lines are VERBATIM from a real sing-box 1.14.2 run on the
|
|
* target (an isolated instance with the rule-set URLs pointed at an
|
|
* unreachable port), including the `+0800` UTC offset that precedes the date.
|
|
*
|
|
* That offset is the reason this case exists. The first version of the
|
|
* timestamp pattern was anchored at the start of the line and matched a bare
|
|
* date, which parses fine against every hand-written sample and returns null
|
|
* for every line the device actually writes - so the panel would have shown
|
|
* the right tag and the right reason and no time on every real failure. Only
|
|
* a real line catches that, and it is pinned here so it cannot come back. */
|
|
const SAMPLE = [
|
|
'+0800 2026-10-03 09:29:34 INFO sing-box started (0.05s)',
|
|
'+0800 2026-10-03 09:29:34 ERROR router: fetch rule-set geoip-cn: Get "http://127.0.0.1:9/geoip-cn.srs": remote error: open connection to 127.0.0.1:9 using outbound/direct[direct]: dial tcp 127.0.0.1:9: connect: connection refused',
|
|
'+0800 2026-10-03 09:29:34 ERROR router: fetch rule-set geosite-cn: Get "http://127.0.0.1:9/geosite-cn.srs": remote error: open connection to 127.0.0.1:9 using outbound/direct[direct]: dial tcp 127.0.0.1:9: connect: connection refused',
|
|
''
|
|
].join('\n');
|
|
|
|
check('an empty log yields no failures', FAILURES('').length === 0);
|
|
check('a log of blank lines yields no failures', FAILURES('\n\n').length === 0);
|
|
check('a log with no fetch lines yields no failures',
|
|
FAILURES('2026-10-03 01:15:42 INFO router: loaded rule-set geoip-cn').length === 0);
|
|
|
|
const sample = FAILURES(SAMPLE);
|
|
check('both failing tags are reported', sample.length === 2, JSON.stringify(sample));
|
|
check('the tag is captured without the surrounding prose',
|
|
sample.map((f) => f.tag).join(',') === 'geoip-cn,geosite-cn',
|
|
JSON.stringify(sample.map((f) => f.tag)));
|
|
check('the reason is captured',
|
|
sample[0].reason.startsWith('Get "http://'), JSON.stringify(sample[0].reason));
|
|
check('the timestamp is captured despite the +0800 offset before it',
|
|
sample[0].at === '2026-10-03 09:29:34', String(sample[0].at));
|
|
check('the reason survives the full real line',
|
|
sample[1].reason.startsWith('Get "http://127.0.0.1:9/'), JSON.stringify(sample[1].reason));
|
|
check('the reason keeps the nested colons and quotes',
|
|
sample[1].reason.includes('dial tcp 127.0.0.1:9: connect: connection refused'),
|
|
JSON.stringify(sample[1].reason));
|
|
|
|
/* A negative UTC offset, and a build that omits the offset entirely, both have
|
|
* to keep working - the offset is optional in the pattern on purpose. */
|
|
const westOfUTC = FAILURES('-0500 2026-12-31 20:00:00 ERROR router: fetch rule-set geoip-cn: timeout');
|
|
check('a negative UTC offset still yields the time',
|
|
westOfUTC[0].at === '2026-12-31 20:00:00', String(westOfUTC[0].at));
|
|
const noOffset = FAILURES('2026-12-31 20:00:00 ERROR router: fetch rule-set geoip-cn: timeout');
|
|
check('a line with no offset at all still yields the time',
|
|
noOffset[0].at === '2026-12-31 20:00:00', String(noOffset[0].at));
|
|
|
|
/* A rule-set that fails on every 24-hour refresh is in the log many times, and
|
|
* the file is truncated - so the most recent line has to win, or the panel
|
|
* shows a reason from yesterday as if it were current. */
|
|
const REPEATED = [
|
|
'+0800 2026-10-01 01:00:00 ERROR router: fetch rule-set geoip-cn: old failure',
|
|
'+0800 2026-10-03 01:00:00 ERROR router: fetch rule-set geoip-cn: new failure'
|
|
].join('\n');
|
|
const repeated = FAILURES(REPEATED);
|
|
check('a repeated tag is reported once', repeated.length === 1, JSON.stringify(repeated));
|
|
check('the most recent failure wins for a repeated tag',
|
|
repeated[0].reason === 'new failure', JSON.stringify(repeated[0].reason));
|
|
|
|
/* clean_log.sh can cut mid-line, and a truncated line must not become a
|
|
* failure with a nonsense reason - but it must still name its tag, or the
|
|
* panel would hide a rule-set that really is failing. */
|
|
const truncated = FAILURES('+0800 2026-10-03 01:00:00 ERROR router: fetch rule-set geoip-cn: Get "https://exa');
|
|
check('a truncated line still names its tag', truncated.length === 1, JSON.stringify(truncated));
|
|
check('a truncated line keeps the timestamp it has',
|
|
truncated[0].at === '2026-10-03 01:00:00', String(truncated[0].at));
|
|
|
|
/* The format is not assumed. A line with no timestamp is still reported, with
|
|
* the time simply absent - the panel omits it rather than inventing one. */
|
|
const noTime = FAILURES('ERROR router: fetch rule-set geosite-cn: dial tcp: i/o timeout');
|
|
check('a line without a timestamp is still reported', noTime.length === 1, JSON.stringify(noTime));
|
|
check('a line without a timestamp reports no time', noTime[0].at === null, String(noTime[0].at));
|
|
check('a line without a timestamp still yields the reason',
|
|
noTime[0].reason === 'dial tcp: i/o timeout', JSON.stringify(noTime[0].reason));
|
|
|
|
check('the parser tolerates null', FAILURES(null).length === 0);
|
|
check('the parser tolerates undefined', FAILURES(undefined).length === 0);
|
|
|
|
console.log(`frontend rpc fallbacks: ${checks} checks, ${failures} failures`);
|
|
process.exit(failures ? 1 : 0);
|