mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-10-28 03:52:53 +08:00
582 lines
35 KiB
JavaScript
582 lines
35 KiB
JavaScript
#!/usr/bin/env node
|
|
/* The page gate: every page the router's menu offers, at every width that matters, measured for the
|
|
* faults that have actually reached users.
|
|
*
|
|
* The static gates read the stylesheet; every field report so far was about a PAGE — a column
|
|
* shredded to one character per line (#11), a submenu title clipped (#22), an indicator that did
|
|
* not fit the sidebar (#14), a hidden tab pane leaving phantom scroll (#10), a doubled scrollbar
|
|
* in Firefox (#12), a widget or a whole app laid out wrong (#5, #15, #32, #33, #36). Not one is
|
|
* visible in a file, and every one is one DOM query away on a live page.
|
|
*
|
|
* WHAT IT LOOKS FOR: `doc-scroll` (the document scrolls sideways — the symptom users report as
|
|
* "вёрстка плывёт", #1), `overflow` (past the content column with nothing to scroll it back),
|
|
* `clipped` (a non-scroller whose content is wider than itself), `target` (a hit target under
|
|
* 24x24 CSS px with a neighbour inside 24px of its centre — WCAG 2.2 SC 2.5.8 with its spacing
|
|
* exception), `noname` (an operable element with no accessible name — SC 4.1.2, the one a11y
|
|
* failure a stylesheet CAN cause), `ungated-table`/`unanswered-table` (see below),
|
|
* `nested-scroll` (two scrollports stacked on the shell), `dead-floor` (see below), `geometry` (the chrome's model against
|
|
* the real box) and `console` (a view that threw while rendering). The width sweep starts at 320,
|
|
* which is the narrowest reflow WCAG 1.4.10 requires.
|
|
*
|
|
* A BASELINE, NOT A CLEAN SHEET. Some findings belong to the app, not the theme, and a gate that
|
|
* fails on them is a gate that gets disabled. So every finding is signed `path|width|kind|element`
|
|
* and the known set lives in tools/baselines/live-audit.json: a NEW signature fails the run, a
|
|
* signature that stopped appearing is printed so the baseline can shrink. A ratchet, exactly like
|
|
* css-metrics.mjs. The file is a UNION ACROSS PLATFORMS, not a photograph of one machine — text
|
|
* metrics differ between a maintainer's containers and CI's runner, and a few findings sit within
|
|
* a pixel of their threshold.
|
|
*
|
|
* node tools/live-audit.mjs [--only owrt2512,owrt2410] [--widths 320,390,768,1440]
|
|
* [--pages /admin/status] [--pages-all] [--all] [--arrive 768]
|
|
* [--update] [--prune] [--engine chromium|firefox|webkit] [--lang ru]
|
|
* [--settle 460]
|
|
*
|
|
* Needs a running owlab router (docs/development.md). `--update` rewrites the baseline: read the
|
|
* diff before you do that — it is the whole value of the file.
|
|
*
|
|
* `--settle` (task livegate): the resize loop below used to sample EVERYTHING 220ms after each
|
|
* `setViewportSize`, which is 180ms INSIDE `fs-fit.js`'s own `SCROLL_IDLE` (400ms) — the window the
|
|
* theme deliberately defers every layout-reading pass behind while it judges the page to still be
|
|
* moving, and which a fast back-to-back sweep across WIDTHS keeps re-arming on every step. Sampling
|
|
* `doc-scroll`/`overflow`/`clipped`/`target`/the table and floor checks there reads the state the
|
|
* theme is holding off on and reports it as a layout break — measured directly as CI's
|
|
* `geometry|fs-content` finding on `/admin/status/vnstat2/config`, whose offset was always exactly
|
|
* the gap between two adjacent WIDTHS entries, never a real number.
|
|
*
|
|
* TWO DIFFERENT FIXES FOR TWO DIFFERENT PROMISES, not one. `1cd2af1` fixed `fs-chrome.js`'s
|
|
* `contentWidth()` so it re-reads the window's width on every call regardless of the defer window —
|
|
* GEOMETRY's own promise is "the model matches reality NO MATTER WHEN you ask" (fs-select reads it
|
|
* mid-scroll too), so GEOMETRY stays sampled IMMEDIATELY, before `SETTLE_MS`, same as before —
|
|
* `docs/development.md` says outright not to "fix" a recurrence of this shape by sampling later
|
|
* instead, and reproduced directly (task livegate, `1cd2af1` reverted in a scratch copy): sampled at
|
|
* 220ms it reads the stale model (`off=-70` at 320->390 through `off=-936` at 1440, matching the
|
|
* vnstat2 shape exactly), sampled ONLY past `SCROLL_IDLE` it reads clean — the SAME bug, hidden,
|
|
* because by then the deferred pass has simply had time to run and paper over a model that is still
|
|
* broken. So GEOMETRY is sampled BOTH ways (see below) and stays exactly as exposed to this class of
|
|
* regression as it always was. Every OTHER check's promise is "the page IS laid out correctly", which
|
|
* needs the deferred pass to have actually RUN — those depend on `SETTLE_MS`, past `SCROLL_IDLE`, not
|
|
* on a self-correcting read, and moving only them fixes the 220ms false report without reintroducing
|
|
* the one `1cd2af1` closed. 460ms clears the 400ms floor with the same margin `SCROLL_IDLE`'s own
|
|
* comment gives it.
|
|
*
|
|
* `--lang` (task 0162): the checks that read TEXT LENGTH — `overflow`, `clipped`, `doc-scroll` — are
|
|
* pinned to whatever language the router happens to answer in, and the default is English (Playwright's
|
|
* browser locale is en-US, and `luci.main.lang=auto` resolves against it). Switching a router to
|
|
* Russian by hand and running the unmodified gate produced 77 findings on one stand and 70 on the
|
|
* other — not regressions, RU text is measurably longer (docs/gallery.html's pseudo-loc gate uses
|
|
* 1.3-1.6x for the same reason) — and every one would have failed the run, because the baseline held
|
|
* only what English measured. `--lang` sets `luci.main.lang` on each router before the sweep (`en`
|
|
* maps to `auto`, same convention as `.claude/tooling/lang.mjs`) and restores `auto` after, and the
|
|
* baseline key gets the language appended exactly like `--engine` already appends the engine — a
|
|
* Russian-only overflow is a REAL, distinct signature, not the English baseline's problem, so it earns
|
|
* its own entry (`owrt2512@ru`) rather than either failing every English run or being silently
|
|
* swallowed into the language-agnostic checks (`noname`, `target`, `console`, …) which do not need
|
|
* this at all. The alternative — recording only language-independent findings — would have hidden the
|
|
* ssclash label/section bug this task started from, which WAS a length fault. */
|
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { execFileSync } from 'node:child_process';
|
|
import * as pw from 'playwright';
|
|
import { stands, login, menuPaths, DESTRUCTIVE, requireStands, sealToRouter } from './lib/stands.mjs';
|
|
import { classify, representatives, reportReduction, reportFrozen, PINNED } from './lib/page-shapes.mjs';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const BASELINE = resolve(HERE, 'baselines/live-audit.json');
|
|
|
|
const arg = (name, dflt) => {
|
|
const i = process.argv.indexOf('--' + name);
|
|
return i === -1 ? dflt : process.argv[i + 1];
|
|
};
|
|
const UPDATE = process.argv.includes('--update');
|
|
/* with --update: replace each measured router's set instead of unioning into it. Removes findings
|
|
* that belong to apps this machine simply does not install, so it is the flag you reach for after
|
|
* reading the "no longer reproduce" list, not the one you run by habit. */
|
|
const PRUNE = process.argv.includes('--prune');
|
|
const ENGINE = arg('engine', 'chromium');
|
|
/* the language the router is measured in — see the file header. `en` is the default and maps to
|
|
* `auto`, which is what every existing baseline entry was collected against. */
|
|
const LANG = arg('lang', 'en');
|
|
if (!/^[a-z]{2,3}(-[a-zA-Z0-9]+)?$/.test(LANG)) {
|
|
console.error(`live-audit: --lang wants a language code (or "en"), got "${LANG}"`);
|
|
process.exit(1);
|
|
}
|
|
/* uci wants `auto`, never the literal `en` — LuCI ships no catalogue for English, it's the source
|
|
* strings, so `en` names in this file are the AUDIT's language, not a `uci luci.main.lang` value. */
|
|
const setRouterLang = (id, code) => {
|
|
try {
|
|
execFileSync('owlab', [ 'exec', id, '--', 'uci', 'set', `luci.main.lang=${code === 'en' ? 'auto' : code}` ], { stdio: 'ignore' });
|
|
execFileSync('owlab', [ 'exec', id, '--', 'uci', 'commit', 'luci' ], { stdio: 'ignore' });
|
|
} catch (e) {
|
|
console.error(`live-audit: could not set luci.main.lang on ${id} (${e.message})`);
|
|
process.exit(2);
|
|
}
|
|
};
|
|
/* 320 is the narrowest width WCAG 1.4.10 requires content to reflow to; 390 is the modal phone;
|
|
* 568 is where the theme's own card decision sits; 768 and 1024 bracket the sidebar's fit; 1440 is
|
|
* the desktop the reports come from. */
|
|
const WIDTHS = arg('widths', '320,390,568,768,1024,1440').split(',').map(Number);
|
|
const ONLY_PAGES = arg('pages', '');
|
|
/* Measure one page per SHAPE instead of every leaf of the menu — see lib/page-shapes.mjs for what a
|
|
* shape is and for the three sets that are never sampled away. `--pages-all` takes them all. */
|
|
const ALL_PAGES = process.argv.includes('--pages-all');
|
|
/* the four routers rather than the OpenWrt pair (lib/stands.mjs) */
|
|
const ALL_STANDS = process.argv.includes('--all');
|
|
/* Entering a page at a width is not the same as RESIZING into it. A sweep that loads each page once
|
|
* and then walks the widths takes every measurement after the first on a page that has already been
|
|
* laid out, fitted and corrected — and the fitters re-run on the resize, which is exactly the event
|
|
* that hides an arrival bug: a first fit pass judges a table its own gate is still hiding, caches
|
|
* "no remedy", and on a page that renders once and stands still no later mutation corrects it —
|
|
* the table then sits 65px past its column.
|
|
*
|
|
* So ONE width is also entered, with a load of its own: the fault is in the arrival, not in the
|
|
* width, so any width whose layout needs a remedy will do, and 768 is where the sidebar has just
|
|
* folded and a data table still has to break a column to fit. Its findings are signed `<width>a`
|
|
* so an arrival-only fault cannot hide behind the identical resize signature. */
|
|
const ARRIVE = Number(arg('arrive', '768'));
|
|
if (!Number.isFinite(ARRIVE) || ARRIVE < 0) {
|
|
/* a typo may not turn a check off in silence — that is how a gate stops holding anything */
|
|
console.error(`live-audit: --arrive wants a width in px (or 0 to skip it), got "${arg('arrive', '')}"`);
|
|
process.exit(1);
|
|
}
|
|
/* See the file header. Past fs-fit.js's SCROLL_IDLE (400ms), not "a frame or two" — sampling inside
|
|
* that window reads the theme's own deferred state, not a break. */
|
|
const SETTLE_MS = Number(arg('settle', '460'));
|
|
if (!Number.isFinite(SETTLE_MS) || SETTLE_MS < 0) {
|
|
console.error(`live-audit: --settle wants a wait in ms, got "${arg('settle', '')}"`);
|
|
process.exit(1);
|
|
}
|
|
|
|
/* Does the chrome's arithmetic still describe the page it is about?
|
|
*
|
|
* `fs-chrome.contentWidth()` answers "how wide is the content column" WITHOUT reading layout, for
|
|
* the one pass that may not read it. It gets there by subtracting the sidebar and the shell's
|
|
* gutter from the window, every term a token read out of the stylesheet — so it is a model of the
|
|
* page, and a model drifts silently. It has: the gutter was once subtracted twice, and the top-bar
|
|
* layout kept a sidebar in the sum, both worth enough to cross fs-select's CRAMPED threshold and
|
|
* card a table that had room. */
|
|
const GEOMETRY = function () {
|
|
const RT = window.L;
|
|
if (!RT || typeof RT.require !== 'function') return [];
|
|
return RT.require('fs-chrome').then((chrome) => {
|
|
const col = document.querySelector('.fs-content');
|
|
if (!col || typeof chrome.contentWidth !== 'function') return [];
|
|
const cs = getComputedStyle(col);
|
|
/* clientWidth is the padding box; the model answers the width the CONTENT gets */
|
|
const real = col.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
|
const off = Math.round(chrome.contentWidth() - real);
|
|
/* 2px: a fractional layout edge and a subpixel scrollbar are not a drift */
|
|
return Math.abs(off) > 2 ? [ { kind: 'geometry', el: 'fs-content', by: off } ] : [];
|
|
}).catch(() => []);
|
|
};
|
|
|
|
/* Runs INSIDE the page. Kept as one function so what CI measures and what a developer measures
|
|
* cannot be two different definitions of "overflows". */
|
|
const CHECK = function () {
|
|
const out = [];
|
|
const vis = (el) => {
|
|
const cs = getComputedStyle(el);
|
|
return cs.display !== 'none' && cs.visibility !== 'hidden' && el.getClientRects().length > 0;
|
|
};
|
|
const label = (el) => {
|
|
const cls = (typeof el.className === 'string' ? el.className : '').split(' ').filter(Boolean)[0];
|
|
return el.tagName.toLowerCase() + (el.id ? '#' + el.id : cls ? '.' + cls : '');
|
|
};
|
|
const scrolls = (el) => /(auto|scroll)/.test(getComputedStyle(el).overflowX + getComputedStyle(el).overflowY);
|
|
const inScroller = (el) => {
|
|
for (let p = el.parentElement; p && p !== document.documentElement; p = p.parentElement)
|
|
if (scrolls(p)) return true;
|
|
return false;
|
|
};
|
|
|
|
const host = document.getElementById('view') || document.body;
|
|
const hostRect = host.getBoundingClientRect();
|
|
const hostRight = hostRect.right;
|
|
/* A vertical scrollbar takes width from the content column but not from its border box, so a
|
|
* page long enough to scroll measures differently from the same page that is not — which is why
|
|
* a finding seen in CI need not reproduce on a stand. Reported with every overflow. */
|
|
const scrollbar = Math.round(window.innerWidth - document.documentElement.clientWidth);
|
|
|
|
/* 1. the document itself */
|
|
const docScroll = Math.round(document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
|
if (docScroll > 1) out.push({ kind: 'doc-scroll', el: 'document', by: docScroll });
|
|
|
|
/* 2. reach: past the content column with nothing to scroll it back. 1.5px because a border-box edge
|
|
* lands on a fraction at some zoom levels.
|
|
*
|
|
* Inside an <svg> nothing is laid out by the CSS box model — the stock realtime views draw a
|
|
* polyline wider than the viewport on purpose and slide it leftwards — so every one of its points
|
|
* reports as "past the column" while the drawing is correct. The <svg> element itself is still
|
|
* measured, that being the box the theme sizes. */
|
|
for (const el of host.querySelectorAll('*')) {
|
|
if (!vis(el) || inScroller(el) || el.ownerSVGElement) continue;
|
|
const r = el.getBoundingClientRect();
|
|
if (!r.width || r.right <= hostRight + 1.5) continue;
|
|
/* THE NUMBER ALONE IS NOT ACTIONABLE. `table#packages (2)` says two pixels and nothing about
|
|
* WHICH box is two pixels too wide — the element itself, or a parent it fills at 100%. That
|
|
* is a day of guessing on a finding that does not reproduce off the runner, so the boxes are
|
|
* printed with it: the element, the column it overflows, its parent, and whether a scrollbar
|
|
* was taking width at the time. Only `by` carries this; the signature the baseline is keyed
|
|
* on stays `path|width|kind|element`. */
|
|
const p = el.parentElement;
|
|
const pr = p ? p.getBoundingClientRect() : null;
|
|
const px = (n) => Math.round(n);
|
|
out.push({
|
|
kind: 'overflow',
|
|
el: label(el),
|
|
by: `${px(r.right - hostRight)}px el ${px(r.width)}`
|
|
+ ` · host ${px(hostRect.width)}`
|
|
+ (pr ? ` · parent ${label(p)} ${px(pr.width)}` : '')
|
|
+ ` · ${getComputedStyle(el).boxSizing}`
|
|
+ (scrollbar ? ` · scrollbar ${scrollbar}` : ''),
|
|
});
|
|
}
|
|
|
|
/* 3. clipped: a non-scrolling box holding content wider than itself. Only the containers the
|
|
* theme owns the geometry of — every element would report the browser's own rounding. */
|
|
for (const el of host.querySelectorAll('.cbi-section, .table, .alert-message, .cbi-value, .fs-card')) {
|
|
if (!vis(el) || scrolls(el)) continue;
|
|
const inner = el.scrollWidth - el.clientWidth;
|
|
if (inner > 1) out.push({ kind: 'clipped', el: label(el), by: inner });
|
|
}
|
|
|
|
/* 4. hit targets, SC 2.5.8 with the spacing exception.
|
|
*
|
|
* Per LINE BOX, not per element: an inline link that wraps has one rect per line and
|
|
* getBoundingClientRect() returns their union, whose centre lies on neither — in the footer that
|
|
* phantom centre sat 15px from a real one and the gate reported a violation no pointer can
|
|
* reach. getClientRects() is what the criterion is about anyway. */
|
|
const targets = [];
|
|
for (const el of document.querySelectorAll('button, a[href], .cbi-button, input[type="checkbox"], input[type="radio"], select, [role="button"]')) {
|
|
if (!vis(el)) continue;
|
|
for (const r of el.getClientRects()) targets.push({ el, r });
|
|
}
|
|
const centre = (r) => ({ x: r.left + r.width / 2, y: r.top + r.height / 2 });
|
|
for (const t of targets) {
|
|
if (t.r.width >= 23.5 && t.r.height >= 23.5) continue;
|
|
const a = centre(t.r);
|
|
if (targets.some((o) => o.el !== t.el && Math.hypot(centre(o.r).x - a.x, centre(o.r).y - a.y) < 24))
|
|
out.push({ kind: 'target', el: label(t.el), by: Math.round(t.r.width) + 'x' + Math.round(t.r.height) });
|
|
}
|
|
|
|
/* 5. names, SC 4.1.2. The multi-select checkbox is ui.js's own presentational shape — excluded
|
|
* by markup, not by rule, exactly as in tools/a11y-gallery.mjs. */
|
|
for (const el of document.querySelectorAll('button, a[href], input:not([type="hidden"]), select, textarea, [role="button"]')) {
|
|
if (!vis(el) || el.matches('.cbi-dropdown[multiple] li > form > input[type="checkbox"]')) continue;
|
|
const name = (el.textContent || '').trim() || el.getAttribute('aria-label') || el.getAttribute('title')
|
|
|| (el.labels && el.labels.length ? 'label' : '') || el.getAttribute('placeholder') || el.getAttribute('value');
|
|
if (!name) out.push({ kind: 'noname', el: label(el) });
|
|
}
|
|
|
|
/* 6. The gate that keeps a fresh table out of the layout, asked of the page rather than of the
|
|
* stylesheet. theme/30-tables.css holds a `.fs-dt` out of the flow until fs-select stamps it
|
|
* `.fs-fitted`, because a poll tick REPLACES these tables and an unstamped one is laid out
|
|
* full-width for an instant, which the engine re-anchors on. Two ways that protection can be
|
|
* absent without anything else looking wrong: a data table outside every root the rule names,
|
|
* and a table the fitter never stamped. */
|
|
if (document.documentElement.hasAttribute('data-fs-fit')) {
|
|
for (const t of document.querySelectorAll('.table.fs-dt')) {
|
|
if (!t.closest('#view, #modal_overlay')) out.push({ kind: 'ungated-table', el: label(t) });
|
|
else if (!t.classList.contains('fs-fitted') && t.getBoundingClientRect().height > 0)
|
|
out.push({ kind: 'unanswered-table', el: label(t), by: Math.round(t.getBoundingClientRect().height) });
|
|
}
|
|
}
|
|
|
|
/* 7. A poll floor that HOLDS PAGE the reader cannot see. `min-height` beats the `height: 0` an
|
|
* inactive tab pane is collapsed with, so a floor written on the PANE pins the collapse open and
|
|
* the page carries the pane's whole height as blank: 1265px on Network -> Interfaces, twice
|
|
* shipped (issue #41, 0.14.4 and again 0.14.5) and never visible in a file.
|
|
*
|
|
* THE PANE, or a hidden box that nothing clips — not every floor under a collapsed pane.
|
|
* `visibility` inherits, so a naive test names every box inside one, and those hold nothing: the
|
|
* pane is `height: 0; overflow: hidden`, so a floor on a child is clipped away. Measured on the
|
|
* 0.14.3 floor shape, which writes one on every container it sweeps: 25 such floors on
|
|
* /admin/network/dhcp and 7 on /admin/services/nlbw/display at 320px, and stripping all 32
|
|
* changed the document by 0px. Flagging them cost 408 findings that named nothing — a gate that
|
|
* cries at a shape rather than at a symptom is a gate that gets baselined into silence. */
|
|
if (document.documentElement.hasAttribute('data-fs-fit')) {
|
|
for (const el of document.querySelectorAll('#view [style*="min-height"]')) {
|
|
if (getComputedStyle(el).visibility !== 'hidden') continue;
|
|
const pane = el.closest('[data-tab-title]:not([data-tab-active="true"])');
|
|
/* the pane itself wearing one, or a hidden floor with no collapsed pane above it */
|
|
if (pane === el || !pane)
|
|
out.push({ kind: 'dead-floor', el: label(el), by: el.style.minHeight });
|
|
}
|
|
}
|
|
|
|
/* 8. two stacked scrollports on the shell — the doubled scrollbar of #12 */
|
|
const shellScrollers = [ document.documentElement, document.body, ...document.querySelectorAll('.fs-shell, .fs-main, .fs-content, #maincontent') ]
|
|
.filter((el) => el && /(auto|scroll)/.test(getComputedStyle(el).overflowY) && el.scrollHeight > el.clientHeight + 1);
|
|
if (shellScrollers.length > 1)
|
|
out.push({ kind: 'nested-scroll', el: shellScrollers.map(label).join('+') });
|
|
|
|
return out;
|
|
};
|
|
|
|
const baseline = (() => {
|
|
try { return JSON.parse(readFileSync(BASELINE, 'utf8')); }
|
|
catch (e) { return {}; }
|
|
})();
|
|
|
|
const list = requireStands(stands(arg('only', ''), { all: ALL_STANDS }), 'live-audit');
|
|
const browser = await pw[ENGINE].launch();
|
|
const seen = {}, fresh = [];
|
|
/* pages that froze while their shape was being read — their own kind of finding, and fatal on
|
|
* their own: reportFrozen() below (lib/page-shapes.mjs) */
|
|
const frozen = [];
|
|
let checked = 0, armed = 0;
|
|
|
|
/* SAY WHERE THE SWEEP IS, ON EVERY PAGE AND WITH THE CLOCK.
|
|
*
|
|
* This gate printed nothing between its first page load and the per-router total at the end, which
|
|
* is the whole of a router's sweep. A slice cancelled inside that window says nothing about where
|
|
* it was — task liveslice: `/admin/system/filemanager` pinned the browser's main thread under
|
|
* `13e9864` and `page.evaluate()` has no deadline of its own, so this gate and `spa-parity` both sat
|
|
* at the 45-minute cap for a day of runs with an empty log each time.
|
|
*
|
|
* A line per page, newline-terminated so a runner flushes it as it happens, carrying seconds since
|
|
* the gate started: what each page costs is then readable off the log, and a run that dies names
|
|
* the page it died on. */
|
|
const T0 = Date.now();
|
|
const at = () => `${String(Math.round((Date.now() - T0) / 1000)).padStart(4)}s`;
|
|
const say = (line) => process.stdout.write(`${at()} ${line}\n`);
|
|
|
|
/* THE ROUTERS RUN AT THE SAME TIME. Nothing here is a timing measurement — every finding is a
|
|
* geometry or a name read out of a settled page — so two containers answering at once cannot change
|
|
* an answer, and the wall clock is halved. (`scroll-jank` is the one gate that stays sequential,
|
|
* because frame pacing IS its subject.) */
|
|
await Promise.all(list.map(async (stand) => {
|
|
let here = 0;
|
|
/* Set BEFORE the sweep and restored to `auto` in the `finally` below regardless of outcome — a
|
|
* run may never leave a router parked in a language a later, unflagged run silently inherits,
|
|
* which is exactly the incident this flag exists to stop happening again. */
|
|
setRouterLang(stand.id, LANG);
|
|
try {
|
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
|
await sealToRouter(ctx, stand.base);
|
|
const page = await ctx.newPage();
|
|
const errs = [];
|
|
/* A FETCH THAT FAILED IS NOT THIS THEME'S. The theme reaches no third-party host at run time —
|
|
* that is a rule of the package, not an accident (CLAUDE.md; the one convenience tool that ever
|
|
* wanted `curl` was refused over it) — so a console line about a resource that would not load,
|
|
* or an app's own updater giving up on an HTTP status, can only belong to somebody else's code.
|
|
*
|
|
* They are dropped rather than baselined because the text carries the stand's own port
|
|
* (`http://localhost:8024/…`), so a baseline entry would be keyed to a port and go stale the
|
|
* moment owlab hands out a different one.
|
|
*
|
|
* Measured: `luci-app-ssclash` asks GitHub for the latest mihomo release on two of its pages,
|
|
* and on a GitHub Actions runner — whose egress IP is shared and rate-limited — that answers
|
|
* 403. Nine findings across three routers, none of them reproducible here, all of them the
|
|
* runner's network rather than the page's markup. Anything that is not a network failure is
|
|
* still recorded: a TypeError out of a view, an unhandled rejection, our own broken require. */
|
|
/* Every spelling a blocked or failed request reaches the console in. `Failed to fetch` is
|
|
* Chromium's rejection of a fetch(), `Load failed` is WebKit's and `NetworkError` Firefox's;
|
|
* `net::ERR_` is what stands.mjs's own seal produces when it aborts the request. An app may
|
|
* wrap any of them in its own sentence, in its own language — ssclash says
|
|
* `Не удалось получить последний релиз: TypeError: Failed to fetch` — so the match is on the
|
|
* engine's words inside the line, never on the app's. */
|
|
const NETWORK_NOISE =
|
|
/Failed to load resource|Failed to fetch|Load failed|NetworkError|net::ERR_|ERR_INTERNET_DISCONNECTED|HTTP \d{3}\b/i;
|
|
const note = (text) => {
|
|
const line = text.replace(/\s+/g, ' ').slice(0, 120);
|
|
if (!NETWORK_NOISE.test(line)) errs.push(line);
|
|
};
|
|
page.on('pageerror', (e) => note(String(e)));
|
|
page.on('console', (m) => { if (m.type() === 'error') note(m.text()); });
|
|
await login(page, stand.base);
|
|
|
|
/* Baselines are per ENGINE and per LANGUAGE, as well as per router: a second engine finds different
|
|
* things (the doubled scrollbar of #12 was Firefox-only), a second language finds different things
|
|
* for the reason the file header gives, and mixing either pair of sets would let one run bless a
|
|
* finding it never saw. Suffixes compose (`owrt2512@ru@firefox`) rather than picking one. */
|
|
const keySuffixes = [];
|
|
if (LANG !== 'en') keySuffixes.push(LANG);
|
|
if (ENGINE !== 'chromium') keySuffixes.push(ENGINE);
|
|
const key = keySuffixes.length ? `${stand.id}@${keySuffixes.join('@')}` : stand.id;
|
|
const known = baseline[key] || [];
|
|
const kset = new Set(known);
|
|
seen[key] = new Set();
|
|
|
|
let paths = (await menuPaths(page)).filter((p) => !DESTRUCTIVE.test(p));
|
|
if (ONLY_PAGES) paths = paths.filter((p) => p.startsWith(ONLY_PAGES));
|
|
|
|
if (!ALL_PAGES && !ONLY_PAGES) {
|
|
/* one load per page to read its shape, then one representative per shape — plus every path
|
|
* the baseline names and every pinned page, which may never be sampled away */
|
|
say(`${key}: reading the shape of ${paths.length} page(s)`);
|
|
const shapes = await classify(page, stand.base, paths, { frozen, id: key });
|
|
const { picked, dropped } = representatives(shapes, [ ...known.map((sig) => sig.split('|')[0]), ...PINNED ]);
|
|
reportReduction(stand.id, picked, dropped, shapes);
|
|
paths = picked;
|
|
}
|
|
|
|
let n = 0;
|
|
for (const path of paths) {
|
|
say(`${key} ${++n}/${paths.length} ${path}`);
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
errs.length = 0;
|
|
try { await page.goto(stand.base + path, { waitUntil: 'domcontentloaded', timeout: 20000 }); }
|
|
catch (e) { continue; }
|
|
/* a view renders behind an RPC; give it the time a user would wait before judging it */
|
|
await page.waitForTimeout(1800);
|
|
/* a page the router refuses (an app in the menu whose ACL says no) is not a layout finding */
|
|
if (!(await page.evaluate(() => !!document.getElementById('view')))) continue;
|
|
checked++; here++;
|
|
/* whether the theme actually ran on this page — a stand rebuilt without `owlab sync`
|
|
* (docs/development.md) serves every page LuCI-bare, `data-fs-fit` never appears, and
|
|
* ungated-table/unanswered-table/dead-floor (all three gated on it) check nothing on any
|
|
* page for the whole run without a single line saying so */
|
|
if (await page.evaluate(() => document.documentElement.hasAttribute('data-fs-fit'))) armed++;
|
|
|
|
/* what the RESIZE pass saw at the arrival width, so the arrival pass can report only what is
|
|
* new about arriving. See the arrival block below. */
|
|
const atArrive = new Set();
|
|
const record = (width, f) => {
|
|
const sig = `${path}|${width}|${f.kind}|${f.el}`;
|
|
if (width === ARRIVE) atArrive.add(`${f.kind}|${f.el}`);
|
|
seen[key].add(sig);
|
|
if (!kset.has(sig)) fresh.push({ stand: key, sig, by: f.by });
|
|
};
|
|
for (const e of errs.slice(0, 3)) record(0, { kind: 'console', el: e });
|
|
|
|
for (const w of WIDTHS) {
|
|
await page.setViewportSize({ width: w, height: 900 });
|
|
/* GEOMETRY sampled IMMEDIATELY, deliberately inside the window the theme may still be
|
|
* deferring behind — its promise is "the model matches reality NO MATTER WHEN you ask",
|
|
* because fs-select reads it mid-scroll too (fs-chrome.js's own contentWidth()). Waiting
|
|
* here would let a future regression of the exact 1cd2af1 shape — contentWidth()
|
|
* answering for the previous width — hide behind the deferred fitter's own, unrelated
|
|
* correctness once SETTLE_MS has passed: reproduced directly (task livegate, 1cd2af1
|
|
* reverted in a scratch copy) — sampled at 220ms it reads the stale model, sampled once
|
|
* more past SCROLL_IDLE it reads clean, same bug, because by then the deferred pass has
|
|
* simply had time to run and paper over it. docs/development.md says the same in the
|
|
* other direction: do not "fix" a recurrence of this shape by sampling later instead.
|
|
* Sampled again after the settle wait below, so a model that is ALSO wrong once the page
|
|
* has stopped moving is caught too — same signature either way, one entry. */
|
|
try { for (const f of await page.evaluate(GEOMETRY)) record(w, f); } catch (e) { /* see there */ }
|
|
/* Everything else here — doc-scroll, overflow, clipped, target, the table and floor
|
|
* checks — depends on the deferred pass having actually RUN, unlike GEOMETRY above, so
|
|
* it is sampled past SCROLL_IDLE (see SETTLE_MS in the file header) rather than inside
|
|
* the window the theme is deliberately still deferring in. */
|
|
await page.waitForTimeout(SETTLE_MS);
|
|
let found = [];
|
|
try { found = await page.evaluate(CHECK); } catch (e) { continue; }
|
|
for (const f of found) record(w, f);
|
|
try { for (const f of await page.evaluate(GEOMETRY)) record(w, f); } catch (e) { /* see there */ }
|
|
}
|
|
|
|
/* The arrival (see ARRIVE above): the page reached AT this width rather than resized into it.
|
|
*
|
|
* Only what the resize at this width did not already say. A fault the same width produces
|
|
* either way is one fault, and recording it under a second signature doubles the baseline with
|
|
* copies that carry no information — and those copies are machine-specific, since the baseline
|
|
* is a union across platforms and a machine only sees the apps it installs, so a duplicate
|
|
* recorded where those apps exist is a red gate everywhere they do not. */
|
|
if (ARRIVE > 0) {
|
|
await page.setViewportSize({ width: ARRIVE, height: 900 });
|
|
let arrived = true;
|
|
try { await page.goto(stand.base + path, { waitUntil: 'domcontentloaded', timeout: 20000 }); }
|
|
catch (e) { arrived = false; }
|
|
if (arrived) {
|
|
await page.waitForTimeout(1800);
|
|
let found = [];
|
|
try { found = await page.evaluate(CHECK); } catch (e) { found = []; }
|
|
try { found = found.concat(await page.evaluate(GEOMETRY)); } catch (e) { /* see there */ }
|
|
for (const f of found) {
|
|
if (atArrive.has(`${f.kind}|${f.el}`)) continue;
|
|
record(ARRIVE + 'a', f);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
await ctx.close();
|
|
say(`${key}: ${seen[key].size} finding(s) over ${here} page(s)`);
|
|
} finally {
|
|
/* Never leave a router parked off `auto` — the next unflagged run must not silently inherit
|
|
* this one's language, which is the exact shape of the incident this flag exists to close. */
|
|
setRouterLang(stand.id, 'en');
|
|
}
|
|
}));
|
|
await browser.close();
|
|
|
|
/* A page that froze is measured by nobody, and it is never a baseline entry: a signature can be
|
|
* blessed, a stopped main thread cannot. First of the three, because it names the actual cause —
|
|
* enough of them and `checked === 0` below would blame the login instead — and before --update,
|
|
* because a run that lost pages to a freeze may not rewrite the baseline from what it did see. */
|
|
if (reportFrozen(frozen)) {
|
|
console.error('live-audit: those pages were not audited at any width. Fix the freeze first.\n');
|
|
process.exit(1);
|
|
}
|
|
|
|
/* Zero of either is not a clean sweep. `checked === 0` is every router unauthenticated or every
|
|
* page in the menu lacking #view — the shape `login()` never checks for (lib/stands.mjs). `armed
|
|
* === 0` with `checked > 0` is pages rendering LuCI-bare: a stand rebuilt with `owlab up --rebuild`
|
|
* carries no theme until `owlab sync` (docs/development.md), every check gated on data-fs-fit
|
|
* (ungated-table, unanswered-table, dead-floor) then checks nothing for the whole run, and nothing
|
|
* before this printed that it had not. */
|
|
if (checked === 0) {
|
|
console.error('\nlive-audit: 0 page render(s) across ' + list.length + ' router(s) — every router '
|
|
+ 'was unauthenticated, or every page in its menu lacked #view. Nothing was measured.\n');
|
|
process.exit(1);
|
|
}
|
|
if (armed === 0) {
|
|
console.error(`\nlive-audit: ${checked} page(s) rendered but data-fs-fit never appeared on any of `
|
|
+ 'them — the theme is not installed on the stand(s) measured (owlab sync after a rebuild, '
|
|
+ 'docs/development.md). ungated-table, unanswered-table and dead-floor were not checked.\n');
|
|
process.exit(1);
|
|
}
|
|
|
|
/* A run narrowed by --pages or --widths visited only part of the baseline, so it may neither rewrite
|
|
* it nor report the rest as fixed. */
|
|
/* …and a run that measured one page per shape is narrowed like any other: it cannot tell a finding
|
|
* that stopped happening from a page it did not open. */
|
|
const fullSweep = !ONLY_PAGES && arg('widths', null) === null && arg('arrive', null) === null && ALL_PAGES;
|
|
|
|
if (UPDATE) {
|
|
if (!fullSweep) {
|
|
console.error('live-audit: --update needs a full sweep — a run narrowed by --pages or --widths');
|
|
console.error('would drop every signature it did not visit. Drop the narrowing flags.');
|
|
process.exit(2);
|
|
}
|
|
/* Adding is safe, removing is a decision — and this file is a UNION ACROSS PLATFORMS, which is what
|
|
* makes the difference matter: a run sees the apps THIS machine has. Rewriting a router's set
|
|
* from what one machine saw deletes every finding belonging to an app it does not have, and the
|
|
* next CI run reports them as new — a red gate produced by a green one. So `--update` unions and
|
|
* prints what did not reproduce, while `--prune` is the deliberate act of dropping those. */
|
|
const next = Object.assign({}, baseline);
|
|
for (const id of Object.keys(seen)) {
|
|
const now = [ ...seen[id] ];
|
|
next[id] = PRUNE ? now.sort() : [ ...new Set([ ...(baseline[id] || []), ...now ]) ].sort();
|
|
}
|
|
const untouched = Object.keys(baseline).filter((id) => !(id in seen));
|
|
mkdirSync(dirname(BASELINE), { recursive: true });
|
|
writeFileSync(BASELINE, JSON.stringify(next, null, '\t') + '\n');
|
|
console.log(PRUNE ? 'baseline rewritten (pruned to this run):' : 'baseline updated (union):', BASELINE);
|
|
if (untouched.length)
|
|
console.log(` kept as they were: ${untouched.join(', ')} (not measured by this run)`);
|
|
process.exit(0);
|
|
}
|
|
|
|
/* A signature that stopped appearing is not a failure — it is a fix, and the baseline should shrink
|
|
* to match. Printed, never gating: a page an app no longer installs would otherwise fail the run.
|
|
*
|
|
* Only a FULL sweep may say that: a run narrowed by --pages or --widths did not visit most of the
|
|
* baseline, and reporting the rest as "no longer reproduces" is how a baseline gets emptied by
|
|
* someone debugging one page. */
|
|
let stale = 0;
|
|
for (const id of Object.keys(seen))
|
|
for (const sig of baseline[id] || [])
|
|
if (!seen[id].has(sig)) stale++;
|
|
if (stale && fullSweep) console.log(`${stale} baseline entr(ies) no longer reproduce — re-run with --update to drop them.`);
|
|
|
|
if (fresh.length) {
|
|
console.error(`\nlive-audit: ${fresh.length} NEW finding(s):\n`);
|
|
for (const f of fresh.slice(0, 60)) console.error(` ${f.stand} ${f.sig}${f.by != null ? ' (' + f.by + ')' : ''}`);
|
|
if (fresh.length > 60) console.error(` … and ${fresh.length - 60} more`);
|
|
console.error('\nEach line is path|width|kind|element. Fix it, or — if it belongs to the app and');
|
|
console.error('not to the theme — say so in the commit and re-run with --update.');
|
|
process.exit(1);
|
|
}
|
|
console.log(`live-audit: ${checked} page render(s) across ${list.length} router(s), no new findings.`);
|