Files
small-packages/luci-theme-footstrap/tools/lib/stands.mjs
T
2026-09-20 01:22:33 +08:00

193 lines
11 KiB
JavaScript

/* The live half of the gates: one place that knows how to reach a running router, log into LuCI and
* enumerate its pages.
*
* Every static gate here measures a FILE. The bugs that reached users measured a PAGE — a column
* shredded to one character per line (#11), a submenu title clipped (#22), a doubled scrollbar in
* Firefox (#12), a third-party app's tabs laid out wrong (#36, #33, #8), a client navigation that
* painted less than a full load (upstream review). None can be seen in a stylesheet; all are one
* query away on a live page.
*
* The routers are owlab's — `owlab status -json` names each one and the port it answers on, so
* nothing here hard-codes a port or a container name. Nothing here boots anything either: a gate
* that starts and stops containers by itself is a gate nobody runs locally. */
import { execFileSync } from 'node:child_process';
/* The routers a gate runs on by default: the three OpenWrt lines the theme supports. What changes
* what the theme is measured against is the package manager (apk on 25.12+, opkg on 24.10) and the
* luci-base the router carries; the snapshot box tracks luci-base's master, so an upstream change
* fails here before it reaches a user. `--all` widens to every running OpenWrt router (the -b/-c/-d
* twins); a gate that takes `--only` must honour `--all` too.
*
* ImmortalWrt is not a gate target at all, since 0.14.13: same luci-base, different brand and app
* set, never the leg that caught something first, and its two legs produced 1685 `noname` findings
* of their own app set on the 0.14.13 release run with nothing to read in them. A running `imm*`
* router is ignored, and `--only imm2512` is refused by name rather than measured. */
export const CORE = [ 'owrt2512', 'owrt2410', 'owrtsnap' ];
/* Every RUNNING owlab router, newest release first, or an empty array when owlab is absent — the
* caller decides whether that is a failure (a gate) or a reason to skip (a local convenience).
* With no `only` and no `all`, the CORE pair above; if none of it is running, everything that is,
* with a line saying so — a gate that silently measured a different set than it claims is worse
* than a slow one. */
export function stands(only, { all = false } = {}) {
let out;
try {
out = execFileSync('owlab', [ 'status', '-json' ], { encoding: 'utf8', stdio: [ 'ignore', 'pipe', 'ignore' ] });
} catch (e) {
return [];
}
let parsed;
try { parsed = JSON.parse(out); } catch (e) { return []; }
const wanted = (only || '').split(',').map((s) => s.trim()).filter(Boolean);
const foreign = (parsed.routers || []).filter((r) => r.distro !== 'openwrt' && wanted.includes(r.id));
if (foreign.length) {
console.error(`${foreign.map((r) => r.id).join(', ')}: not a gate target — the gates measure OpenWrt `
+ 'routers only (tools/lib/stands.mjs).');
process.exit(2);
}
const running = (parsed.routers || [])
.filter((r) => r.state === 'running' && r.http_port && r.distro === 'openwrt')
.map((r) => ({
id: r.id,
base: `http://localhost:${r.http_port}/cgi-bin/luci`,
release: r.release,
distro: r.distro,
pkg: r.package_manager,
}));
if (wanted.length) return running.filter((r) => wanted.includes(r.id));
if (all) return running;
const core = running.filter((r) => CORE.includes(r.id));
if (core.length) return core;
if (running.length)
process.stderr.write(`(none of ${CORE.join(', ')} is running — measuring `
+ `${running.map((r) => r.id).join(', ')} instead)\n`);
return running;
}
/* Cut the browser off from everything except the router under test.
*
* A live gate measures what THIS theme renders. Anything a page fetches from the open internet is
* somebody else's code reaching somebody else's host, and it can only add noise: `luci-app-ssclash`
* asks GitHub for the latest mihomo release on two of its pages, and on a CI runner — whose egress
* IP is shared and rate-limited — that answers 403. Nine console findings across three routers, on
* a release that had nothing to do with any of them, none of it reproducible off the runner.
*
* Blocking is better than filtering the message afterwards, and better than dropping the app from
* the stands: the app stays (its lazily-inserted Ace stylesheets are the one adversary that can
* invert the cascade layer order — owlab.yaml), the request simply never leaves. The gates stop
* depending on the network, on a rate limit, and on how fast github.com answers today.
*
* `abort`, not a fake 200: an invented response is a lie the page then renders. The abort still
* writes `net::ERR_FAILED` to the console, which live-audit's own network-noise filter drops.
*
* The router's own origin passes, and so do data:/blob: — a stylesheet or an icon the page builds
* for itself is not a fetch. */
export async function sealToRouter(ctx, base) {
const host = new URL(base).host;
/* The predicate form, never a catch-all glob: a glob route hands EVERY request to a JS callback,
* including the hundreds the router itself serves, and each round trip through the client costs
* time and disables the browser's own handling. A predicate is evaluated by Playwright, so a request to
* the router is never intercepted at all — only the handful going elsewhere reach the handler.
* The glob version pushed CI's live job from 17 minutes past its 45-minute limit. */
await ctx.route(
(url) => url.host !== host && url.protocol !== 'data:' && url.protocol !== 'blob:',
(route) => route.abort());
}
/* LuCI answers an unauthenticated request with the login form, not a 403 page — so every live gate
* has to log in before it can measure anything. owlab's routers are root with an empty password
* (`owlab status` prints it); a hardware router is not what these gates run against. */
export async function login(page, base) {
await page.goto(base, { waitUntil: 'domcontentloaded' });
if (await page.$('input[name="luci_password"]')) {
await page.fill('input[name="luci_username"]', 'root');
await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
page.press('input[name="luci_password"]', 'Enter'),
]);
}
}
/* Every LEAF of the router's own menu tree, as LuCI itself resolves it — not a list of paths kept
* in the repo, which would go stale the moment an app is installed or a release moves a page.
*
* `L.ui.menu.load()` returns the tree ALREADY rooted at `admin`, so the walk starts with an empty
* path: seeding it with the root's name produces `/admin/admin/...` and a sweep of 404s that looks
* like a clean run. */
export async function menuPaths(page, opts = {}) {
/* `L.ui` is only there once ui.js has been required by something on the page, and how soon that
* happens differs between release lines — reading it straight after the login redirect crashed
* the 24.10 leg with "Cannot read properties of undefined (reading 'menu')" while 25.12 was fine.
* Wait for the runtime, then ask for the module by name rather than hoping somebody else did. */
await page.waitForFunction(() => window.L && typeof window.L.require === 'function', null, { timeout: 20000 });
/* Only the leaves that are PAGES. A dispatcher tree carries far more than the menu shows: on a
* router with openclash and justclash installed, 105 of its 169 leaves are `call` nodes — RPC
* endpoints an app registers for its own JS — plus `function` nodes and the untitled plumbing.
* None renders a page, so a gate that measured layout on one measured an empty `#view` and spent
* two thirds of its wall clock proving that.
*
* `view` and `template` are the two that paint (plus `cbi` on an old enough app), and a node the
* menu does not title is not a page a user can reach. `{ all: true }` returns the raw walk, for a
* caller that wants the dispatcher rather than the interface. */
return page.evaluate(async (all) => {
const ui = await L.require('ui');
const tree = await ui.menu.load();
const RENDERS = { view: 1, template: 1, cbi: 1 };
const out = [];
const walk = (node, path) => {
for (const name of Object.keys(node.children || {})) {
const child = node.children[name];
const p = path.concat(name);
if (child.children && Object.keys(child.children).length) { walk(child, p); continue; }
const type = (child.action && child.action.type) || '';
if (all || (RENDERS[type] && child.title)) out.push('/' + p.join('/'));
}
};
walk(tree, []);
return out;
}, !!opts.all);
}
/* Pages a sweep must not open twice: they end the session or the router, and the second visit
* measures a login form or a dead container. Named by path fragment, because the menu labels are
* translated and the paths are not. */
export const DESTRUCTIVE = /\/(logout|reboot|flash|backup|shutdown)(\/|$)/;
/* The literal clone owlab boots beside `owrt2512`, `owrt2410` and `owrtsnap` (`owlab.yaml`, task
* sweepspeed) — same distro, release and package manager, its own container answering its own
* ubus — for exactly this: a sweep can treat the pair as one logical stand with twice the
* concurrency, without changing which release lines it covers. Auto-paired only when the caller did
* not already name the twin explicitly: `--only owrt2512,owrt2512b` means "measure both,
* separately", and this must not silently halve that into "measure the union once." Returns a Map
* keyed by the BASE stand's id; empty when owlab has no `-b` set running, so a caller with the old
* three-stand lab degrades to exactly today's behaviour. */
export function pairStands(list) {
let out;
try {
out = execFileSync('owlab', [ 'status', '-json' ], { encoding: 'utf8', stdio: [ 'ignore', 'pipe', 'ignore' ] });
} catch (e) { return new Map(); }
let parsed;
try { parsed = JSON.parse(out); } catch (e) { return new Map(); }
const running = (parsed.routers || []).filter((r) => r.state === 'running' && r.http_port);
const byId = new Map(running.map((r) => [ r.id, r ]));
const explicit = new Set(list.map((s) => s.id));
const pairs = new Map();
for (const s of list) {
const twinId = s.id + 'b';
if (explicit.has(twinId)) continue;
const t = byId.get(twinId);
if (t) pairs.set(s.id, { id: t.id, base: `http://localhost:${t.http_port}/cgi-bin/luci`,
release: t.release, distro: t.distro, pkg: t.package_manager });
}
return pairs;
}
/* No stand, no verdict — and a gate that quietly reports success on zero routers is worse than one
* that fails, because it looks the same as a clean run in a log. */
export function requireStands(list, name) {
if (list.length) return list;
console.error(`${name}: no owlab router is running, so nothing was checked.`);
console.error('Start one with `owlab up` (see docs/development.md) and run this again.');
process.exit(2);
}