mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-10-27 09:33:11 +08:00
1749 lines
125 KiB
Markdown
1749 lines
125 KiB
Markdown
# Development
|
||
|
||
How to bring up a dev router, push a change to it, and prove the change did what you meant.
|
||
|
||
Rules a patch has to follow: [conventions.md](conventions.md). Building a release: [ci.md](ci.md).
|
||
|
||
## Two modes of working
|
||
|
||
1. **The fast loop (no package build)** — edit files, push them straight to a router. The theme is
|
||
templates plus static assets; the only build step is `build-css.sh`, which concatenates `styles/`
|
||
into `cascade.css` with nothing but `cat` and `awk`. This is the normal mode.
|
||
2. **A real package** — for distribution, and for verifying a clean install.
|
||
|
||
## Install owlab first — it is not optional
|
||
|
||
**owlab is a required part of this checkout, not a convenience.** A change is not finished until it
|
||
has run on a real OpenWrt userland; see the rule in [conventions.md](conventions.md).
|
||
|
||
```sh
|
||
go install owfeed.org/owlab/cmd/owlab@latest
|
||
owlab doctor # what this machine can do (Docker, arch, emulation)
|
||
```
|
||
|
||
Docker is the only other requirement. Everything else comes out of `owlab.yaml`.
|
||
|
||
## The dev stand: four containers
|
||
|
||
Brought up by [owlab](https://github.com/owfeed/owlab) from `owlab.yaml` in the repo root. There are
|
||
**four** routers because the differences that bite are runtime ones and one box will not show them:
|
||
three axes — package manager, LuCI feed (upstream vs fork), release — covered pairwise by four
|
||
boxes.
|
||
|
||
| id | distro | release | manager | LuCI |
|
||
|---|---|---|---|---|
|
||
| `owrt2512` | OpenWrt | 25.12.4 | apk | http://localhost:8025 |
|
||
| `owrt2410` | OpenWrt | 24.10.8 | opkg | http://localhost:8024 |
|
||
| `imm2512` | ImmortalWrt | 25.12.1 | apk | http://localhost:8026 — not a gate target |
|
||
| `imm2410` | ImmortalWrt | 24.10.6 | opkg | http://localhost:8027 — not a gate target |
|
||
|
||
```sh
|
||
owlab up # build and start all four
|
||
owlab sync --watch # rebuild the CSS and push on every edit
|
||
owlab open owrt2512 # open LuCI in a browser
|
||
```
|
||
|
||
Log in as `root` with an empty password. Inside is the release's real userland (procd as PID 1,
|
||
netifd, ubus, rpcd, uhttpd) from its own rootfs tarball, not a home-made imitation.
|
||
|
||
- **Reach them only through `localhost:<port>`.** The docker bridge address routes from the host on
|
||
native Linux and inside WSL2, but not on Docker Desktop for macOS or Windows — so no command here
|
||
uses it and the stand behaves identically on any OS.
|
||
- **Rebuilding an image is a factory reset**: there are no volumes, so `owlab up --rebuild` wipes the
|
||
pushed theme and you re-run `owlab sync`. That is wanted — it exercises the install path for real,
|
||
on both package managers.
|
||
- **There is no `curl` on them**, exactly as on a stock router. Run a curl snippet from the host
|
||
against `localhost:<port>`, not through `owlab exec`.
|
||
- **owlab disables mwan3 and watchcat itself.** mwan3 decides the dummy WAN is dead and installs
|
||
`ip rule … blackhole`: LuCI answers while all outbound traffic hangs with no error.
|
||
- A hardware router is still reachable as `ssh router`, and `luci-theme-footstrap/dev-sync.sh`
|
||
pushes to it — for when the question is genuinely about hardware.
|
||
|
||
## Pushing a change
|
||
|
||
```sh
|
||
owlab sync # to every router
|
||
owlab sync owrt2512 # to one
|
||
owlab sync --watch # and thereafter on every edit
|
||
```
|
||
|
||
`sync` puts files exactly where `luci.mk` would and drops the same caches its postinst does. The
|
||
steps are spelled out in `owlab.yaml`:
|
||
|
||
- `build:` rebuilds `cascade.css` from `styles/` (`build-css.sh --dev`, comments intact) before every
|
||
push. Without it everything is copied except the file LuCI actually requests, and the router
|
||
404s on its own stylesheet;
|
||
- `install:` maps the package directories onto router paths;
|
||
- `post_sync:` registers the theme and removes legacy directories — here rather than through
|
||
`root/etc/uci-defaults/…`, because `sync` deliberately does not overwrite `/etc/config` or
|
||
`/etc/uci-defaults`: that is router state, not package content;
|
||
- `theme: footstrap` — owlab sets `luci.main.mediaurlbase` after the push. Installing the package
|
||
only registers the theme, which on a dev stand is the opposite of what you want.
|
||
|
||
Resource JS is copied by glob (all of `htdocs/`), never by a list of names. The list was a bug: a
|
||
new file made it into the package (luci.mk copies `htdocs/` wholesale) but silently never reached the
|
||
dev router, so it was first exercised after the release.
|
||
|
||
What `sync` does not do: stamp `FS_VERSION` (the Footstrap tab shows `dev`) and compile
|
||
`po/*.po` into `.lmo` (strings stay English). Both belong to a real package build, which is where
|
||
they should be verified.
|
||
|
||
## If you break it
|
||
|
||
- **A broken template does not brick the UI.** If `header.ut` does not compile, LuCI falls back to
|
||
the first working theme in `luci.themes` and shows a "Theme fallback" indicator carrying the error.
|
||
- Manual rollback at any time:
|
||
```sh
|
||
owlab exec owrt2512 -- 'uci set luci.main.mediaurlbase=/luci-static/bootstrap && uci commit luci'
|
||
```
|
||
- If everything is broken: `uci` is reachable over ssh, and LuCI is not needed to recover.
|
||
|
||
## Caches while iterating
|
||
|
||
- The menu and dispatcher are cached in `/tmp/luci-indexcache.<hash>.json`. The hash comes from
|
||
menu-file mtimes, so it updates itself — but if things look strange:
|
||
`owlab exec owrt2512 -- 'rm -f /tmp/luci-indexcache*'`.
|
||
- `.ut` templates are not cached between requests (ucode compiles on the fly) — an edit to
|
||
`header.ut` is visible on F5.
|
||
- CSS/JS are cached by the browser. `cascade.css` is served with `?v={{ pkgs_update_time }}`, so
|
||
touching the package database changes the key and an ordinary F5 picks the file up. **Which file
|
||
that is depends on the release**, so touch both:
|
||
```sh
|
||
owlab exec owrt2512 -- 'for db in /lib/apk/db/installed /usr/lib/opkg/status; do [ -f "$db" ] && touch "$db"; done'
|
||
```
|
||
Naming only the apk path means the key never changes on 24.10: the file arrives, the browser serves
|
||
the old one, and it looks exactly like an edit that did nothing.
|
||
|
||
## Verifying a change
|
||
|
||
**A template** — with the same `trycompile` LuCI uses, which is also what CI runs:
|
||
|
||
```sh
|
||
owlab exec owrt2512 -- 'ucode -T -c -o /dev/null \
|
||
/usr/share/ucode/luci/template/themes/footstrap/header.ut'
|
||
```
|
||
|
||
**CSS** — not with screenshots. Live counters (uptime, DHCP leases, wifi signal) move 0.5–1.3% of
|
||
pixels between two runs of the *same* stylesheet, while a real regression weighs 0.19%. Diff computed
|
||
styles instead: load the page once, swap the `<link>` for the second sheet, snapshot
|
||
`getComputedStyle` over every element. Method and traps: [css.md](css.md).
|
||
|
||
**Behaviour** — on a router, with `owlab test` (next section). The gates cannot see behaviour, and a
|
||
stubbed harness only proves a module loads.
|
||
|
||
**Pure logic — and only the part a router cannot show you** — with the unit suite:
|
||
|
||
```sh
|
||
npm test # node --test, ~100 ms, no browser and no stand
|
||
node --test tests/menutree.test.mjs # one file
|
||
node --test --test-name-pattern 'alias' tests/ # one case
|
||
```
|
||
|
||
`tests/lib/luci-module.mjs` evaluates a shipped `fs-*.js` inside the same wrapper luci.js uses —
|
||
`function (window, document, L, <one param per require pragma>)` — so the file under test is the file
|
||
that ships, not a rewritten copy. `window` and `document` are recorders: they answer the few reads a
|
||
module makes while it evaluates and remember the listeners and timers it registered.
|
||
|
||
**What belongs here is what a stand cannot produce.** The suite is not a second opinion on layout or
|
||
on behaviour — there is no box, no paint and no event dispatch, and a measurement faked here would be
|
||
worth less than nothing. It is for the branches the two stands can never enter: a luci-base with a
|
||
surface missing (`tests/router-contract.test.mjs`), an alias loop planted by a foreign `menu.d`, a
|
||
`firstchild` tie broken by key order, a leaf whose own ACL re-opens a read-only path
|
||
(`tests/menutree.test.mjs`). If a case can be seen on `owlab`, it belongs on `owlab`.
|
||
|
||
Three more kinds have since earned a place here, and each one is a fault the suite FOUND:
|
||
|
||
| File | The branch a stand cannot hold still |
|
||
|---|---|
|
||
| `chrome-geometry.test.mjs` | the column's width per combination of layout, rail and window — pure arithmetic over four measured numbers, and the only place every combination can be asked at once. Whether the numbers still describe the page is `live-audit`'s question, not this one |
|
||
| `interval-pause.test.mjs` | a `visibilitychange` landing inside a specific window: a hide across an in-flight navigation, a view clearing its own timer after a hide/show. Both need a race won on purpose; the harness dispatches the event exactly |
|
||
| `session-expiry.test.mjs` | the verdict the two interceptors reach on a reply, in both directions. A stand would have to expire a real session mid-run — a fixture, not a test |
|
||
|
||
The rule is unchanged: the harness may not fake a measurement. What these drive is a decision made
|
||
from numbers somebody else measured, which is a different thing.
|
||
|
||
**Everything else** — the static gates:
|
||
|
||
```sh
|
||
npm run check
|
||
```
|
||
|
||
One run covers lint, `audit.py --strict`, the CSS ratchets, orphans, duplicates, `@mirror`, the
|
||
appearance axes, the chrome fence, the export tier, the rpcd ACL, i18n and axe-core. What each gate
|
||
holds: [conventions.md](conventions.md).
|
||
|
||
`build-css.sh` additionally checks its own brace balance and refuses to write a suspiciously short
|
||
file. Two gates run in CI only: `tools/jsmin-verify.mjs`, which needs a jsmin built from
|
||
`luci-upstream.pin`, and `ucode -T -c` over every template, which the `verify` containers run against
|
||
the installed theme — the same command as above, so locally it is one `owlab exec`.
|
||
|
||
Nothing in `package.json` reaches the package: the OpenWrt buildbot has no node.
|
||
|
||
### The three cheap browser gates: `smoke`, `computed-diff` and `pseudo-loc`
|
||
|
||
Between the static gates and a stand there is a step that costs seconds and catches the regression
|
||
that is in the FILE rather than in the page. Both drive `docs/gallery.html` — every widget LuCI or a
|
||
third-party app can emit, with the real class names, and no router.
|
||
|
||
```sh
|
||
npm run smoke # ~1.4 s: the modules come up in a real DOM, the axes stamp in order
|
||
npm run computed-diff # ~4 s per viewport: worktree vs HEAD, getComputedStyle over every element
|
||
npm run computed-diff -- --control # the same sheet twice; must be 0
|
||
```
|
||
|
||
`smoke` evaluates `fs-fit`, `fs-prefs`, `fs-axes`, `fs-select`, `fs-chrome` and `fs-router` the way
|
||
luci.js does — the prologue's `'require x as y'` pragmas become the factory's parameters, one copy
|
||
of that derivation shared with `tests/lib/luci-module.mjs` — and then watches each colour axis write
|
||
`--fs-<x>-h` before `data-<x>`. What it adds over `npm test` is the box: the unit suite's window and
|
||
document RECORD calls rather than answer them, so a module that throws the first time it measures
|
||
something passes there and fails on a router. Proven to bite: inverting the two writes in
|
||
`fs-axes.js` turns five checks red.
|
||
|
||
`computed-diff` builds the stylesheet at `HEAD` with `git archive` (so an uncommitted edit cannot
|
||
leak into the BEFORE side), loads the gallery once, and swaps the `<link>`. Its floor is 0, and two
|
||
measurements were needed to get there — the reference sheet is swapped onto ITSELF before the first
|
||
snapshot, because a colour from a sheet parsed with the document serialises `oklab(…)` and the same
|
||
colour from one attached later serialises `color(srgb …)` (28 phantom differences in light, 0 in
|
||
dark); and every running animation is awaited, because a `span.cbi-tooltip` fade was caught at
|
||
`opacity 0.00245647` by one snapshot and finished by the other. It reports rather than judges unless
|
||
given `--max N`.
|
||
|
||
**It runs at two viewports, not one** (task 0145): 1280 (every page's design width) and 390x844 (a
|
||
phone point). A property that only diverges once a flex row is narrower than its own content is
|
||
invisible at 1280 by construction — the switch-knob overhang bug measured 0 differences there on
|
||
`worktree vs HEAD` with the fix reverted, and a `width` difference (26.80px vs 40px declared) only at
|
||
390. `a11y` and `export-tier` still take Playwright's 1280 default alone; the gap this closes is
|
||
`computed-diff`'s.
|
||
|
||
`pseudo-loc` catches what neither of the other two can: a box sized for the ENGLISH string it was
|
||
built against. Both `smoke` and `computed-diff` render the gallery exactly once, in English, and a
|
||
toggle knob left its pill on every Russian phone at default density (task 0145) is invisible to a
|
||
gate that never renders a longer string. `pseudo-loc` replaces every visible text node and
|
||
text-carrying attribute in the gallery with a pseudo-localised form — each word 45% longer, Latin
|
||
letters swapped for accented look-alikes, no translation catalogue or router needed — and asserts
|
||
nothing overflows its box, escapes its parent, or is clipped with no scroller to reach it, at 320
|
||
and 390 CSS px across all three densities.
|
||
|
||
```sh
|
||
npm run pseudo-loc # ~4 s: 6 width x density points, English vs pseudo-localised
|
||
npm run pseudo-loc -- --verbose # every finding, not just the first 8 per kind
|
||
npm run pseudo-loc -- --eps 4 # raise the tolerance; re-check the intact tree still passes first
|
||
```
|
||
|
||
Every shape is measured TWICE per point — once on the page as loaded (English), once after
|
||
pseudo-localisation — and a finding is only reported if it both exceeds the tolerance AND *grew* by
|
||
more than `--eps` between the two. Without that, `docs/gallery.html` reports overflow that is
|
||
already there in plain English (13 such findings at 320px measured on this tree: a synthetic
|
||
unbroken RU compound built to exercise `min-width: 0`, a breadcrumb path, an iface stat line) —
|
||
real, but not caused by translation length and not this gate's fault to raise. Three more
|
||
false-positive shapes are guarded structurally rather than by a threshold: an empty `.modal` LuCI
|
||
keeps in the DOM at all times would read as a host with a clean sheet if it were ever measured, so
|
||
every check requires `getClientRects().length > 0`, which also excludes a collapsed submenu's links
|
||
— they carry an ordinary computed `display` while their `<ul>` ancestor is `display: none`, and a
|
||
hidden ancestor gives every descendant zero client rects; and a deliberately fixed foreign width
|
||
(the filemanager's 600px case, already decided in `tools/baselines/live-audit.json`) is excused by a
|
||
plain selector ALLOW list, the same shape as `a11y-gallery.mjs`'s `.exclude()` — this gate keeps no
|
||
baseline file of its own, so an allowlist is the only place left to record the decision.
|
||
|
||
**Proven to bite**: `flex-shrink: 0` on `.cbi-checkbox > label[for]` (`styles/theme/60-inputs.css`,
|
||
task 0145) reverted in a scratch copy outside this checkout. Intact: 117 findings, none naming the
|
||
switch. Reverted: 123 findings, +6 — one per width/density point — every one `<label for="cb1">`
|
||
itself, self-overflow 15-25px: the knob (`::after`, `position: absolute`) stays anchored to the
|
||
pill's declared width while the pill shrinks under the caption pseudo-localisation just grew.
|
||
|
||
**What to do when it fires**: read the element, width, density and measured `by` in the output.
|
||
If it names something already accepted (a torture fixture, gallery chrome, a contract another gate
|
||
owns) it is an ALLOW-list candidate with the same one-line justification the existing two entries
|
||
carry — not a reason to raise `--eps` globally, which would blind the check everywhere at once
|
||
instead of at the one place that earned the exception. Otherwise it is a real finding: report it (do
|
||
not fix it in the same change unless that change's own purpose was already this element) with the
|
||
element, width, density and `by` from the output, the way any other gate's finding gets triaged.
|
||
|
||
**Tier: folded into `check:slow`** (task 0155), same cost class as `smoke`/`a11y`/`export-tier` —
|
||
one headless Chromium, a few seconds. `check:mid` was never a candidate (it is the browser-free
|
||
static half — CSS metrics, floor, duplicates, size, i18n).
|
||
|
||
The 117 findings task 0152 left untriaged (9 distinct root elements, everything else the same
|
||
element at another width/density) were the reason it stayed standalone until now: folding an
|
||
unreviewed set into `check:slow` would have turned every future `npm run check` red for pages
|
||
nobody had looked at yet. Each of the 9 was one of two things, never a third — `--eps` was not
|
||
touched:
|
||
- a REAL fault, fixed in CSS: the Port-status tile's stat line and the upload strip's Create/
|
||
Cancel row were both `white-space: nowrap` inherited from a text-only ellipsis rule
|
||
(`styles/base/95-luci.css`) with no text to ellipsis — a nested flex row, not a sentence — so a
|
||
grown label escaped or clipped with no "…" to show for it; the checkbox caption's one
|
||
unbreakable word (a plausible real RU compound, not a torture string) needed `overflow-wrap:
|
||
anywhere` it did not have. One gallery fixture was the fault instead of the CSS: three
|
||
`<input>` demo captions read as raw markup (`value="input[type=button]"`), a string no real
|
||
button ever carries, and grew past their row on that alone;
|
||
- an accepted shape, ALLOW-listed in `tools/pseudo-loc.mjs` with the reasoning inline: three were
|
||
the SAME dropdown-ellipsis precedent (task 0149) by other selectors — the breadcrumb path, the
|
||
tab strip's long RU label, the filename column all shrink-then-ellipsis correctly and merely
|
||
*measure* as self-overflow the way any truncated element does; one is the meter's reserve,
|
||
already measured and margin-tolerant, marginal even in English, where pseudo-loc's own bracket
|
||
wrapper (not a translation) tips it over; one is `#g-longlabel` itself, the gallery's own
|
||
deliberately-unbreakable torture compound, which no CSS fixes by construction.
|
||
|
||
117 -> 0 findings; `npm run pseudo-loc` is green on the intact tree and now runs on every
|
||
`npm run check`.
|
||
|
||
**Task 0161 (verification of the triage above) found the ALLOW walk itself over-scoped**: it climbed
|
||
from every matched element to `<body>`, excusing every ancestor along the way, not just the element a
|
||
selector named — `scrollWidth` is cumulative, so one `.table` or one accepted dropdown silenced its
|
||
`.cbi-section`, then its `.g-sec`, then everything else sharing that ancestor. Measured: 311 of 822
|
||
elements and 14 of 22 gallery sections read as unmeasurable, including all four buttons of the "Row
|
||
actions, plain table (Startup-style)" fixture the same triage had just added — a reverted
|
||
`flex-basis: auto` (`styles/theme/55-buttons.css`) would have gone undetected. Fixed to match only the
|
||
elements each selector names (no ancestor climb); re-running then surfaced 66 findings, every one a
|
||
duplicate of the same three already-accepted leaves (`#g-longlabel`, `.cbi-progressbar`, `.table`)
|
||
pushing their own CONTAINER's `scrollWidth` over — not a new fault, the cumulative-`scrollWidth`
|
||
mechanism working exactly as documented, just no longer hidden. Each container is now named explicitly
|
||
with `:has()` against the leaf that causes it, and `.g-wrap` — the gallery's own outermost wrapper, not
|
||
a widget any router page renders — is excused outright, since it reads as "widest content on the page"
|
||
for as long as any of the three exists anywhere on it. Separately, `.table *`'s wildcard also swallowed
|
||
every row-action BUTTON inside a `.td.cbi-section-actions` cell, unrelated to the no-JS-carding
|
||
contract that entry exists for; narrowed with `:not(.cbi-section-actions, .cbi-section-actions *)`, and
|
||
reverting `flex-basis: auto` to check reproduces exactly the expected finding (6 points, self-overflow
|
||
on "Принудительно завершить"), confirming the fixture is now actually measured, not merely present.
|
||
The SELF check's `eps + margin` tolerance had a second latent flaw, unrelated to scoping: a NEGATIVE
|
||
margin (`.zonebadge .cbi-tooltip`, `styles/base/90-widgets.css`: `margin: -1.6em 0 0 -5px`, an overlap
|
||
trick, not reserved room) drove `eps + margin` below zero, which widens the pass condition instead of
|
||
narrowing it and can report `by` as low as 0px — clamped with `Math.max(0, …)` so a negative margin now
|
||
costs nothing, the same as carrying none.
|
||
|
||
**Neither replaces a userland run, and a green one never earns a release the right to skip owlab.**
|
||
The gallery has every widget and none of the pages: no menu, no chrome, no session, no third-party
|
||
sheet, no rpc, and every dependency in `smoke` is a
|
||
stub. They are early detectors. The release matrix is unchanged — `owlab test` on both formats,
|
||
`npm run live -- --all`, `npm run check`, `/security-review` (releasing.md).
|
||
|
||
### Git hooks
|
||
|
||
The repository keeps its hooks in `.githooks/`, which is not active until you point git at it:
|
||
|
||
```sh
|
||
git config core.hooksPath .githooks
|
||
```
|
||
|
||
`commit-msg` strips Co-Authored-By, `Claude-Session:` and "Generated with" trailers from whatever
|
||
wrote them, and leaves `Signed-off-by` alone — openwrt/luci refuses a sign-off with a
|
||
`@users.noreply.github.com` address, so that line is load-bearing. `pre-push` runs `npm run check`;
|
||
`git push --no-verify` is the deliberate bypass and says so on the way past.
|
||
|
||
## The live gates: `npm run live`
|
||
|
||
The static gates read files. Every bug a user has reported was about a **page** — a shredded column,
|
||
a clipped title, a doubled scrollbar, a third-party app laid out wrong, a client navigation that
|
||
painted less than a full load. `npm run live` is the half that opens pages, and it needs stands:
|
||
|
||
```sh
|
||
owlab up # the containers these gates measure
|
||
owlab sync # your working tree onto them
|
||
npm run live # upstream-contract, spa-parity, live-audit, scroll-jank, table-tick, scroll-anchor
|
||
# two routers (the OpenWrt pair), one page per SHAPE
|
||
npm run live -- --all --pages-all # every running OpenWrt router and every page: before a tag
|
||
```
|
||
|
||
Each is also a command of its own, and each takes `--only <router ids>`:
|
||
|
||
```sh
|
||
node tools/upstream-contract.mjs --only owrtsnap --verbose # every assumption, named, one by one
|
||
node tools/spa-parity.mjs --only owrt2410 --pages /admin/network
|
||
node tools/live-audit.mjs --only owrt2512 --widths 320,1440 --pages /admin/status
|
||
node tools/scroll-jank.mjs --engines chromium,firefox,webkit # the other two need installing
|
||
```
|
||
|
||
**What a live run measures, and what it deliberately does not.** The gates used to open every leaf
|
||
of the menu on all four routers, which on a box with a couple of `luci-app-*` installed is 169 paths
|
||
per router and over an hour of wall clock — long enough that the honest description of the suite
|
||
became "the thing nobody runs before pushing". Three cuts, none of which changes what a finding
|
||
means:
|
||
|
||
- **`call` and `function` nodes are not pages.** 105 of those 169 leaves are RPC endpoints an app
|
||
registers for its own JS; opening one answers JSON. `menuPaths()` returns the leaves that render
|
||
(`view`, `template`, `cbi`) and are titled.
|
||
- **One page per SHAPE.** A page is classified by what it is MADE OF — data table, config table,
|
||
form, tabs, editor, svg, file input… (`tools/lib/page-shapes.mjs`) — and one representative of
|
||
each shape is measured. Every path the baseline names and every page a field report came from
|
||
(`PINNED`) keeps its seat regardless, every dropped page is printed with the page standing in for
|
||
it, and a narrowed run may not rewrite the baseline. `--pages-all` measures them all.
|
||
- **Three routers by default** (`lib/stands.mjs`, `CORE`): 25.12/apk, 24.10/opkg and the snapshot
|
||
box. The first two are the package managers; the third tracks luci-base master, which is where an
|
||
upstream change shows up before it reaches a release. `--all` widens to every running OpenWrt
|
||
router (the twins). ImmortalWrt is not a gate target: a running `imm*` router is ignored and
|
||
`--only imm2512` is refused. A gate that takes `--only` must honour `--all` too:
|
||
`upstream-contract` read one and ignored the other, and silently measured a subset of what the
|
||
release runbook asked for.
|
||
|
||
The structural gates run their routers CONCURRENTLY — nothing they measure is a timing — while
|
||
`scroll-jank` stays sequential, because frame pacing is its subject.
|
||
|
||
```sh
|
||
```
|
||
|
||
- **`upstream-contract`** is the registry of what this theme assumes about luci-base — private
|
||
fields, a deprecated alias, a module that loads uci once and answers out of that cache forever.
|
||
Run it against **`owrtsnap`** as well: SNAPSHOT tracks luci-base's master, so that is where an
|
||
assumption breaks first, and a failure names the module here that has to be looked at.
|
||
- **`spa-parity`** has no baseline, because a page reached by a click that differs from the same page
|
||
reached by a load is always a bug.
|
||
- **`install-check`** (`npm run install-check`, not part of `npm run live`) runs `install.sh` on the
|
||
stands twice over, because the upgrade path is where every installer report has come from. It
|
||
installs the published release and re-syncs your tree afterwards — do not run it in the middle of
|
||
debugging something else.
|
||
- **`live-audit`** is a ratchet: known findings live in `tools/baselines/live-audit.json`, a new
|
||
signature fails, and `--update` rewrites the file. Read the diff before you update — some findings
|
||
belong to a third-party app rather than to the theme, and that distinction is the file's whole
|
||
value. `--engine firefox|webkit` runs the same sweep in another engine, keyed separately in the
|
||
baseline (a headless Firefox refuses to launch on some macOS setups; the flag is there for CI and
|
||
for Linux). A new engine needs its own baseline, created by one `--update` run. `--lang ru` (task
|
||
0162, below) runs the same sweep against a Russian router, keyed `<stand>@ru` — the two suffixes
|
||
compose (`owrt2512@ru@firefox`).
|
||
- **`scroll-anchor`** (task sweepspeed) runs its requested engines concurrently rather than one after
|
||
another, and — when an `-b` twin of a stand is up (`owlab.yaml`'s matched pairs, `owrt2512`/
|
||
`owrt2512b` and the like) — splits that stand's own cell list across the two containers instead of
|
||
walking it with one. Neither changes which cells run: every combination the axes define still runs
|
||
exactly once, findings from either container print under the base stand's id. `--no-pair` turns the
|
||
splitting off (for measuring the pairing itself, or a run that wants the twin left idle); `--only
|
||
owrt2512,owrt2512b` measures both explicitly instead of pairing them. `--quick` is the fast local
|
||
loop — one stand, the Overview page alone, the default axes — and prints what it left out on every
|
||
run; it is not a substitute for a full `npm run anchor` or CI's own `--full` sweep, and is never set
|
||
by CI.
|
||
|
||
### `live-audit`'s baseline is not a clean sheet, and neither entry nor language may be assumed
|
||
|
||
A baseline that is just a list of strings has no room to say WHY a signature stays — and a signature
|
||
with no reason is, on inspection, indistinguishable from a bug nobody looked at twice. That is
|
||
exactly how `/admin/services/ssclash/settings|320|clipped|div.cbi-section` sat in this file: ordinary
|
||
in shape, present since ssclash was first added to the dev routers, never singled out. Task 0162
|
||
opened every surviving signature rather than trust the shape.
|
||
|
||
**Two unrelated causes can share one signature — the exact trap that hid this one.** The RU-only
|
||
label fix already in this changelog (`min-width: 0`, `base/30-forms.css`, "a form label escaping its
|
||
section") closes a `.cbi-value-title` clip in a NORMAL settings row. It does nothing for the entry
|
||
above, which is a SEPARATE widget on the SAME page: ssclash's own "Additional Settings" panel renders
|
||
`style="display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); …"` inline, on
|
||
markup the app owns outright — a 320px column FLOOR that cannot fit inside a 320px content column
|
||
once the section's own padding is subtracted, in ANY language. Measured live on both stands, English
|
||
and Russian alike: `div.cbi-section` clipped 50px, `label` overflowing 49px, with no scroller to
|
||
reach either — a live, real, currently-reproducing finding, not a leftover from before the label fix,
|
||
and not fixable from this theme's stylesheet without overriding one app's own inline `style`
|
||
attribute, which `docs/third-party-apps.md`'s fence exists to protect the CHROME from, not to hand
|
||
back the other way onto an app's content. Filed as an accepted, app-owned limitation — but named,
|
||
this time, instead of sitting anonymous.
|
||
|
||
**What the rest of the surviving 200 (owrt2512) / 200 (owrt2410) signatures turned out to be** (194 /
|
||
200 at the time of the audit below; task 0167 restored six live `owrt2512` signatures a later baseline
|
||
rewrite had dropped — see "Task 0167" further down), grouped
|
||
by the shape a probe against the live page actually showed (`el.outerHTML`, not just the selector):
|
||
|
||
- **`noname` (144 signatures) is core LuCI CBI or bundled third-party markup with no accessible
|
||
name** — `input.cbi-section-create-name` (mwan3, acme: the "add a new section" field every
|
||
`TypedSection` renders), a bare `textarea`/`textarea#logfile`/`textarea#syslog` (adblock, banip,
|
||
acme, dmesg: LuCI's own `form.TextValue`, whose `.cbi-value-title` label carries no `for=`),
|
||
`textarea.ace_text-input` (ssclash: the ACE editor's own hidden input proxy), filemanager's bulk
|
||
`input.select-checkbox`. None of it is markup this theme ships or a stylesheet can fix — a `title`
|
||
or `aria-label` here would need a JS template edit inside luci-base or inside the app, both outside
|
||
this package's fence.
|
||
- **`overflow`/`clipped` on `div.radio-info`, `span.ssid`/`.bssid`/`.chan`/`.rate`/`span.associations`
|
||
etc. (`/admin/dashboard`) is a third-party dashboard app's own custom widget** — verified none of
|
||
`radio-info`, `wifi-info`, `settings-info`, `router-status-wifi`, `dashboard-bg`, `box-s1` exist
|
||
anywhere in `styles/` (`grep -rl` came back empty); the theme supplies no rule for any of it.
|
||
- **`div.ace_layer`/`.ace_line`/`.ace_line_group` overflow (ssclash config/log, every width) is the
|
||
bundled ACE code editor's own internal virtualised rendering** — third-party library code, not this
|
||
theme's DOM.
|
||
- **The vnstat2 `overflow` group (`li`, `span.hide-open`, `img`, `input.create-item-input`) is a
|
||
measurement blind spot, not a live one.** `.cbi-dropdown[empty] > ul { max-width: 1px }`
|
||
(`theme/65-dropdown.css`) is deliberate — an unselected multiselect's placeholder list collapses to
|
||
a functional zero width, and the visible control shows a plain "unspecified" instead (confirmed
|
||
with a screenshot of the actual closed control). `live-audit`'s `overflow` check only excludes an
|
||
ancestor that `scrolls()` (`auto`/`scroll`); it has no notion of `overflow: hidden`, so a box that
|
||
is correctly and totally invisible still measures as one whose content escaped it. Same shape as
|
||
the already-documented empty-`.modal` trap above — accepted, not a defect, and not worth teaching
|
||
the check a new blind spot's opposite blind spot for three low-traffic signatures.
|
||
- **`div#file-list-container`/`table#file-table` overflow, `button.btn`/`a.symlink-name` sizing, and
|
||
the `Failed to load config` console line are filemanager's own inline pixel width** (`style="width:
|
||
400px; …"`, JS-set) and its own row/icon sizing — the same app and the same shape already named in
|
||
`tools/pseudo-loc.mjs`'s ALLOW list ("the filemanager's 600px case, already decided").
|
||
`ssclash/config|320|target|a` and the filemanager target entries match the precedent `af0b05d`
|
||
already set: an app's own link, under 24px, is the app's sizing choice.
|
||
- **`/admin/status/mwan3/overview` and `/admin/status/overview`'s `clipped|div.cbi-section` (2px) is
|
||
sub-pixel rounding** — the file's own header already names this class of finding ("a few findings
|
||
sit within a pixel of their threshold"); 2px against a 1px trigger is noise, not a box that lost
|
||
content.
|
||
- **`/admin/statistics/graphs`'s `img` overflow (every width up to 1024; it fits at 1440) is a
|
||
server-rendered RRD graph at its own native pixel width**, with no scroller and no theme rule for a
|
||
bare `img` — a real piece of usability debt (a phone cannot see the whole graph) but not a
|
||
regression this session caused or a fix that fits its file list; noted here rather than silently
|
||
accepted forever. **Which of those widths a single sweep actually catches is itself unreliable**:
|
||
the first `--lang ru --update` run recorded only 320/390 for `owrt2512@ru` (`owrt2410@ru` got all
|
||
five in the same run), and a plain reverify straight afterwards found 568/768 firing on
|
||
`owrt2512@ru` that the update had missed — the graph is drawn from a `blob:` URL the page builds
|
||
itself, and whether that draw has landed by the time `CHECK` reads the `<img>`'s box at a given
|
||
resize step is a race this gate does not control. A second `--update --lang ru` closed the gap;
|
||
a future sweep that reports fewer of these five than before on either `@ru` key is this same race
|
||
losing, not a fix, and needs a re-run before it is trusted as "no longer reproduces."
|
||
|
||
**58 signatures no longer reproduced anywhere on either stand and are gone** (33 on owrt2512, 25 on
|
||
owrt2410, 1 of the 58 replaced rather than dropped outright — filemanager's `a.directory-link` target
|
||
finding became `a.symlink-name`, the same under-24px row height on a different row). Three shapes
|
||
account for all of it: the two fixes below closed the nftables tooltip's whole overflowing subtree
|
||
(`li`, `small.cbi-tooltip`, `span.jump`, `ul`, `table.table`, `td`/`th`/`tr`) and wireguard's `code` +
|
||
`span`; a `768a` arrival-only signature stopped differing from its own `768` resize signature once ACE
|
||
and dmesg's timing settled (a dedupe artefact, not a fix); and ssclash's own `320|target|a` and one ACL
|
||
console line stopped reproducing on their own, the app's dev-router version having moved since
|
||
`af0b05d` baselined them. Verified with `node tools/live-audit.mjs --pages-all --update --prune`,
|
||
diffed against the git `HEAD` copy rather than trusted from the run's own "no longer reproduce" count.
|
||
|
||
**Two real, live CSS faults, found and closed in this session** (`base/20-typography.css`,
|
||
`base/90-widgets.css` — see the changelog for the measured numbers): a WireGuard public key inside an
|
||
inline `<code>` had no `overflow-wrap`, and a firewall rule-jump tooltip's `white-space: pre` refused
|
||
to wrap at all, both regardless of language. Reverting either live and re-running
|
||
`node tools/live-audit.mjs --pages /admin/status/wireguard` /
|
||
`--pages /admin/status/nftables/iptables` reproduces the exact pre-fix signature (`small.cbi-tooltip`
|
||
72px, `span.jump` 27px on owrt2512) — the ratchet fires on its own regression, not merely on faith
|
||
that the fix once worked.
|
||
|
||
### English alone was never enough, and RU is now its own ratchet, not a merge
|
||
|
||
Switching a stand to Russian by hand and running the unmodified gate produced 77 (owrt2512) / 70
|
||
(owrt2410) findings before this task, every one of which would have failed a run that only ever knew
|
||
an English baseline — not because anything regressed, but because RU text is measurably longer
|
||
(`docs/gallery.html`'s own `pseudo-loc` gate assumes 1.3-1.6x for the same reason) and the checks that
|
||
read box geometry (`overflow`, `clipped`, `doc-scroll`) are exactly the ones text length moves.
|
||
|
||
Three structural options, and why the one shipped is `--lang`'s per-language SUFFIXED key
|
||
(`owrt2512@ru`, composing with `--engine` the same way `@firefox` already does):
|
||
|
||
1. **Merge RU findings into the existing per-stand key.** Rejected: a signature's presence would no
|
||
longer say which language it needs to reproduce in, so re-verifying it later (as this task had to,
|
||
for the ssclash entry) would require re-discovering the language by hand every time.
|
||
2. **A language-agnostic baseline — record a finding only if it reproduces in BOTH languages.**
|
||
Rejected outright: this is the shape of the bug the whole task started from. It would have kept
|
||
hiding a REAL, length-only fault (the wireguard/nftables pair this session fixed) behind the
|
||
English half of the pair passing clean.
|
||
3. **Stay English-only, forever.** The status quo before this task, and the reason it exists.
|
||
|
||
`--lang`'s suffix keeps every existing invariant the file already had for `--engine` — a per-language
|
||
UNION, `--update`/`--prune` still refuse anything short of a full sweep, and a narrowed run still
|
||
cannot rewrite what it did not visit — while making a Russian-only finding exactly as loud as an
|
||
English one, never louder (it fails ITS OWN key, not the plain one) and never silent.
|
||
|
||
`owrt2512@ru` (255) and `owrt2410@ru` (254) are now baselined by `node tools/live-audit.mjs
|
||
--pages-all --update --lang ru`, restoring `luci.main.lang` to `auto` on both routers when it exits
|
||
(a `finally` block, so a crash mid-sweep cannot leave a router parked in Russian for the next
|
||
unflagged run to silently inherit — see "Two `live-audit` sweeps against the same stand fight over
|
||
its language" below, which is exactly that failure mode from a second cause). Most of it
|
||
(192 / 199) is the SAME signature English already carries — a `noname`, `target` or app-owned finding
|
||
that has nothing to do with string length reproduces in either language identically. 62 (owrt2512) /
|
||
55 (owrt2410) are RU-only, and two shapes account for nearly all of them: the dashboard widget's extra
|
||
spans (`span.associations`, `span.encryption`, `span.label` — the same app-owned widget as the EN
|
||
group above, just more of its rows long enough to clip in translation) and filemanager's fixed-width
|
||
table clipping harder with longer RU column headers (`table#file-table`) — both already-accepted
|
||
groups, reproducing worse rather than differently.
|
||
|
||
**One RU-only group was neither of those, and was a real defect, filed rather than fixed in this
|
||
task**: `/admin/network/firewall/zones`, `firewall/custom`, `services/ddns` and `services/samba4` —
|
||
four unrelated apps sharing one `TypedSection` table shape — clipped their WHOLE `table.table` (every
|
||
`th`/`tr`/`td` in it) inside `div#cbi-firewall-zone` / `#cbi-ddns-service` / `#cbi-samba4-sambashare`
|
||
at 1024px, RU only. Measured live on owrt2512: the firewall zones table's six `<th>` cells (`Зона ⇒
|
||
Перенаправления`, `Входящий трафик`, `Исходящий трафик`, `Внутризональная пересылка`, `Маскарадинг
|
||
IPv4`, plus the actions column) total 1017px inside a 968px `#view` column — 49-65px short, with no
|
||
scroller anywhere near it. Left open rather than fixed here for the same reason task 0161's
|
||
closed-dropdown case was filed instead of forced: the theme's own history with pinned/resized table
|
||
columns is exactly where past fixes went wrong twice over (`f702d15`, `c71c920`), and a `TypedSection`
|
||
reflow needed its own measured card, not a same-night patch bolted onto a baseline-triage task's file
|
||
list.
|
||
|
||
**Closed by task 0163, not as a width or a language special case.** `.cbi-section-table` already had
|
||
a card at `@container fs-content (max-width: 960px)` (theme/65-dropdown.css) — the SAME machinery a
|
||
config table with inline widgets already uses below that width, so a `TypedSection` reflow reused it
|
||
rather than inventing a scroll fallback (this table can hold `.cbi-dropdown`/`.cbi-dynlist` widgets
|
||
whose open list a scroll container would clip, the exact trap `.fs-xscroll` is deliberately kept away
|
||
from). The query itself was reading the wrong box: `fs-content` binds to `.cbi-map`, one level above
|
||
the `.cbi-section` that actually holds the table and spends `--fs-card-pad` (16px a side, 32px total
|
||
at every density but Compact) before the table ever sees the width. Firewall zones' `.cbi-map` sat at
|
||
968px — clearing the un-adjusted 960px check — while the SECTION handed the table only 936px for a
|
||
1017px header. 992 (960 + 32) is the same check asking what it always meant to; it holds for any
|
||
column count, any language, and any viewport, because it corrects a fixed offset in the query's own
|
||
reference frame rather than a pixel this one page happened to need. A `.cbi-map`-pinned specimen
|
||
reproducing the exact 968px/1017px numbers is now in `docs/gallery.html`, so `npm run computed-diff`
|
||
reads the header row's `.th`/`.td` switching `display: table-cell` -> `block` without a router.
|
||
|
||
Neither existing browser gate would have caught this on its own: `pseudo-loc` only measures 320/390px
|
||
(the phone range) and this table has a full page of room there, while `computed-diff` renders the
|
||
gallery at 1280 and 390, neither of which lands in the 936-992px band that mattered — the fixture's
|
||
own `.cbi-map` pin is what makes the boundary visible without one. `live-audit --lang ru` is the gate
|
||
that actually saw it live and is the one to re-run (`--pages /admin/network/firewall/zones
|
||
/admin/network/firewall/custom /admin/services/ddns /admin/services/samba4 --lang ru`, T2) before
|
||
pruning the `owrt2512@ru`/`owrt2410@ru` baseline entries this finding left behind
|
||
(`1024|clipped|div#cbi-*`/`1024|overflow|table.table` and its children) — not done in this task, which
|
||
had no router to verify against.
|
||
|
||
**Task 0167 is that re-run, and it is where the RU/EN stale-count asymmetry (95 vs 3) traces to.**
|
||
`live-audit --lang ru --pages-all` on both stands reported the fixed pages' whole sub-tree gone —
|
||
46 signatures on `owrt2512@ru`, 45 on `owrt2410@ru`, every one `firewall/zones`/`firewall/custom`/
|
||
`ddns`/`samba4|1024|…` — confirming the 992px fix above live rather than by inspection. Those 91 are
|
||
pruned; nothing else the sweep called stale was, and the reason is the same shape as the RRD-graph
|
||
race already on this page: **a run that fails to reproduce a signature once is not proof it is
|
||
gone.** `--prune` REPLACES a language key with exactly what one sweep saw, and a first attempt at
|
||
this task did exactly that — dropped six live `/admin/services/acme/logread|*|noname|textarea#syslog`
|
||
signatures from `owrt2512@ru` along with the 91 real ones, because that particular sweep's `noname`
|
||
check read the `<textarea id="syslog">` as named. The check treats `el.textContent.trim()` as a name
|
||
(`CHECK`'s rule 5, `tools/live-audit.mjs`), and this textarea's content IS the log line LuCI's own
|
||
view writes into it, so whether the signature fires depends on whether the page's own `logread()`
|
||
RPC has resolved by the time the 1800ms check runs — the same asynchronous-content race the RRD
|
||
`img` already gets a pass for, not a fix. A probe against the live page (`el.outerHTML`, `acme`'s own
|
||
`admin/services/acme/logread` menu entry, static, not per-certificate) confirmed the textarea and its
|
||
`.cbi-section-descr` label carry no accessible name either way; only the transient log line changes.
|
||
The six are restored to `owrt2512` (English) and `owrt2512@ru` — `noname` does not depend on
|
||
language — with the same reason the rest of the `noname` group above already carries: LuCI's own
|
||
`form.TextValue`, no `for=`, an app the theme does not own the markup of. Not restored on `owrt2410`:
|
||
`/admin/services/acme/logread` does not render there at all (see "Reset the syslog" below). Three
|
||
more signatures survived the same reasoning for the same underlying cause: `owrt2410@ru`'s
|
||
`banip/processing_log|320|doc-scroll|document` and two `geometry|fs-content` entries need enough
|
||
accumulated log content to overflow the page at all, and a freshly booted container's log is short —
|
||
exactly the state dependence "Reset the syslog BEFORE a live run" already names for this page. A
|
||
sweep against a fresh container proves nothing about the finding's shape on a long-running one, so
|
||
these stay rather than being pruned on one clean run's word.
|
||
|
||
**`--lang ru` is not part of `npm run live`'s default sweep** — doubling every router's wall clock on
|
||
every run for a class of fault that moves only when copy or CSS changes is not proportionate at T0/T1.
|
||
Run it explicitly (`node tools/live-audit.mjs --pages-all --lang ru`, T2, detached) before a release
|
||
and after any change to a string-heavy page or to `overflow`/`white-space`/flex-basis CSS — the same
|
||
occasions `pseudo-loc` already earns its keep on.
|
||
|
||
### The probe rig: `.claude/tooling/`
|
||
|
||
Ad-hoc Playwright probes against a running stand, kept for reuse and not gates: nothing there is in
|
||
`package.json`, nothing ships, nothing runs in `check`. `lib.mjs` is the shared half — `PORTS`
|
||
(stand → host port), `login`, `PAGES` (the ACL-filtered menu tree as a page list), `SNAP` (one chrome
|
||
snapshot: sidebar width, menu items, sheet counts, poll queue) and `navAndCheck` (SPA-vs-full-load
|
||
through a sentinel). Every probe takes the stand as its first argument and writes to `../tmp/`:
|
||
|
||
```sh
|
||
node .claude/tooling/<probe>.mjs owrt2512 [arg]
|
||
```
|
||
|
||
The ones worth knowing by name — `parity.mjs`, `overflow.mjs`, `resize.mjs`, `adversary.mjs`,
|
||
`traffic.mjs` — say what they measure at the top of each file. `preview-venv/` beside them is a
|
||
gitignored Python venv holding a second Playwright for `docs/screenshots/capture.py`.
|
||
|
||
## Proving it on a router: `owlab test`
|
||
|
||
`owlab test` is the local form of CI's `verify` job: build the packages, install them on a real
|
||
userland of each release, assert. Run it before pushing anything that changes behaviour.
|
||
|
||
```sh
|
||
./tools/stage.sh && owfeed build # writes dist/noarch/*.apk and dist/all/*.ipk
|
||
|
||
UT=/usr/share/ucode/luci/template/themes/footstrap
|
||
|
||
owlab test --release 25.12.4 --install 'dist/noarch/luci-theme-footstrap-*.apk' \
|
||
--assert 'package luci-theme-footstrap' \
|
||
--assert 'file /www/luci-static/footstrap/cascade.css' \
|
||
--assert 'http 200 /cgi-bin/luci/admin/status/overview' \
|
||
--assert 'http 200 /cgi-bin/luci/admin/system/system' \
|
||
--assert "exec for f in $UT/*.ut; do ucode -T -c -o /dev/null \"\$f\" || exit 1; done"
|
||
|
||
owlab test --release 24.10.8 --install 'dist/all/luci-theme-footstrap_*.ipk' \
|
||
--assert 'package luci-theme-footstrap' \
|
||
--assert 'file /www/luci-static/footstrap/cascade.css' \
|
||
--assert 'http 200 /cgi-bin/luci/admin/status/overview' \
|
||
--assert 'http 200 /cgi-bin/luci/admin/system/system' \
|
||
--assert "exec for f in $UT/*.ut; do ucode -T -c -o /dev/null \"\$f\" || exit 1; done"
|
||
```
|
||
|
||
**Two invocations, one per format — not one run with two `--release` flags.** `--install` is a glob
|
||
over the host, evaluated once per router, so `dist/*/luci-theme-footstrap*` hands the apk box an ipk
|
||
as well and the install fails on both (measured: `0 of 2 routers passed`). Name the format that
|
||
matches the release.
|
||
|
||
Those are the same five assertions the `verify` job makes (`.github/workflows/build.yml`), which
|
||
installs per format for the same reason — keep the two in step, and add an assertion here whenever
|
||
you add one there. The vocabulary is `package <name>`, `file <path>`, `http <code> <path>`,
|
||
`service <name>`, `exec <shell>`. Why the fifth one compiles the templates here rather than in
|
||
`check`: [ci.md](ci.md).
|
||
|
||
**Pin exact point releases.** `--release 25.12` or a snapshot works today and fails within days;
|
||
`owlab.yaml` pins `25.12.4` / `24.10.8` for the same reason.
|
||
|
||
For anything that is not a pass/fail assertion — a layout change, a fold, an axis — drive the running
|
||
container by hand:
|
||
|
||
```sh
|
||
owlab up && owlab sync
|
||
owlab open owrt2512 # then click the thing
|
||
owlab open owrt2410 # and again on the other package manager
|
||
```
|
||
|
||
### The routers are pre-populated, and that is what makes them useful
|
||
|
||
`owlab.yaml` sets `fixtures: [all]`, so each box comes up with seeded networks, clients, wireguard
|
||
peers, port forwards, system data and wireless config — the wireless pages render from UCI with
|
||
no radios present, and this theme has to style them.
|
||
|
||
It also adds a long `packages:` list on top of owlab's stock set, every entry prefixed with `+` so it
|
||
adds rather than replaces. That list **is the theme's test surface**: a stock router renders a
|
||
handful of menus, while the sections, tabs, tables and widgets that need styling live in the apps.
|
||
`curl` is deliberately absent — it is not in OpenWrt's default set, and installing it here would hide
|
||
the bug class `install.sh`'s `uclient-fetch` fallback exists for.
|
||
|
||
If a change needs a real kernel — not this theme's usual case — a router can be raised to
|
||
`fidelity: vm`, which runs it under QEMU instead of in a container.
|
||
|
||
### Proving it on hardware
|
||
|
||
No gate runs against hardware: `tools/lib/stands.mjs` enumerates owlab routers only. A hardware run
|
||
happens on the maintainer's explicit word for one change, never by reflex, and `dev-sync.sh` and
|
||
`ssh` are `ask` in `.claude/settings.json` so that word is given at the prompt.
|
||
|
||
1. Pre-check that the fallback exists, and record where the router is now:
|
||
`ssh <host> 'ls -d /www/luci-static/bootstrap /usr/share/ucode/luci/template/themes/bootstrap/header.ut && uci get luci.main.mediaurlbase'`.
|
||
Either path missing: stop. A broken template falls back to the first working theme in
|
||
`luci.themes`, and with no bootstrap on the box there is nothing to fall back to.
|
||
2. `luci-theme-footstrap/dev-sync.sh <host>` — registers the theme, activates nothing, reloads rpcd
|
||
(never restarts it) and busts the asset cache. It keeps no backup: the rollback is uci.
|
||
3. Sweep the templates the way LuCI will:
|
||
`ssh <host> 'for f in /usr/share/ucode/luci/template/themes/footstrap/*.ut /usr/share/ucode/luci/template/themes/footstrap/partials/*.ut; do ucode -T -c -o /dev/null "$f" || echo FAIL $f; done'`.
|
||
4. `curl -s -o /dev/null -w '%{http_code}' http://<host>/cgi-bin/luci/` — 200 on the login page.
|
||
Admin paths answer 403 unauthenticated and prove nothing; the pages are clicked by a person.
|
||
5. Rollback, if anything above fails or a page is wrong:
|
||
`ssh <host> 'uci set luci.main.mediaurlbase=/luci-static/bootstrap; uci commit luci; rm -f /tmp/luci-indexcache*'`.
|
||
|
||
### The playground: recording and replaying a real router
|
||
|
||
`tools/playground/{capture,build,verify}.mjs` turn an owlab stand into a static, offline site —
|
||
CI's own `playground` job, [ci.md](ci.md). Each step needs the one before it (`capture` needs a
|
||
booted, installed router; `build` and `verify` need `capture`'s output) and the whole chain is a
|
||
router-driving Playwright run, so it is T2: detach it with `tools/bg.sh` and pair it with
|
||
`tools/bg-wait.sh` in the same turn rather than waiting on it in the foreground.
|
||
|
||
```sh
|
||
owlab up owrt2512 && owlab install owrt2512 'dist/noarch/luci-theme-footstrap-*.apk'
|
||
tools/bg.sh sh -c '
|
||
npm run playground:capture &&
|
||
npm run playground:build -- --base /luci-theme-footstrap/playground &&
|
||
npm run playground:verify -- --base /luci-theme-footstrap/playground
|
||
'
|
||
tools/bg-wait.sh <run-id>
|
||
```
|
||
|
||
Each script also takes its own flags directly (`--recording DIR`, `--out DIR`, `--base /path`,
|
||
`--budget-kb N` on `verify`) — `--help` on any of the three prints the exact list. Output lands
|
||
under `../tmp/playground/` by default (`recording/`, `out/`, `playground.tar.gz`), never inside the
|
||
checkout. Open `../tmp/playground/out/cgi-bin/luci/admin/status/overview/index.html` in a browser
|
||
under any static server rooted at `--base` to look at what was built without re-running `verify`.
|
||
|
||
The site opens on a login page (`BASE/cgi-bin/luci/`, prefilled `root`/empty like a real router)
|
||
that accepts any credentials — a static site has no server left to check them against — and
|
||
`.fs-logout` returns to it; `build.mjs` refuses an old `--recording` made before `capture.mjs`
|
||
saved the login form, naming the fix (re-capture) rather than shipping a playground with no gate.
|
||
|
||
## Running everything CI runs, locally: `tools/ci-local.sh`
|
||
|
||
`.github/workflows/build.yml` is six jobs; nothing local ran the four beyond `check`/`lint`
|
||
(`build`, `verify`, `live`, `anchors`) until this script, so a red job there used to be the first
|
||
time anything here measured that half at all. `tools/ci-local.sh` runs the same commands, in the
|
||
same order, selectable per job or per slice:
|
||
|
||
```sh
|
||
tools/ci-local.sh --list # the job/slice map and what this cannot reproduce
|
||
tools/ci-local.sh check lint build # the three jobs that never touch a router
|
||
tools/ci-local.sh verify # owlab test, both formats — safe with stands up
|
||
tools/ci-local.sh --mode push --force live # all three live slices, THIS project's own stands
|
||
tools/ci-local.sh all --dry-run # print every command either mode would run
|
||
```
|
||
|
||
`--mode pr|push` reproduces the exact split `build.yml` makes for `live`/`anchors`
|
||
(`ROUTERS=owrt2512`, `FULL=""` on a pull request; all three routers and `--full` on a push) —
|
||
`check`/`lint`/`build`/`verify` do not vary by mode. `verify` runs `owlab test`, which
|
||
synthesizes its own ephemeral router in a scratch directory and never touches the named stands,
|
||
so it is safe to run even while another session has `owrt2512`/`owrt2410`/`owrtsnap` to itself.
|
||
`live` and `anchors` are the opposite — they boot and install onto those SAME named stands — so
|
||
the script refuses to run them for real without `--force`, a deliberate echo of "Two live-audit
|
||
sweeps against the same stand fight over its language" below: this default exists because that
|
||
failure mode is measured, not hypothetical. `--dry-run` prints every command any job/slice would
|
||
run without touching a tool or a router, and works with no prerequisite on `PATH` at all.
|
||
|
||
**Every run — `--list` included — ends by naming what it did NOT reproduce and why**: the signed
|
||
`release`/`pages` jobs (no local copy of either secret key), the artifact upload/download between
|
||
`build` and everything downstream of it (substituted by one shared `dist/` on disk), job-level
|
||
`timeout-minutes` (unenforced locally), and the apt-get fallback branches inside `check`'s
|
||
gettext block and `ci-playwright.sh` (only exercised when the tool is actually missing, which it
|
||
was not on this host). A step this script cannot cover is printed as `SKIP` with a reason, never
|
||
folded into a passing count.
|
||
|
||
**This has to run from WSL, never from Git Bash on Windows** — the same host split
|
||
`docs/development.md` documents below for every other npm-run gate — invoked the way that
|
||
section's own recipe does, PATH set by hand inside the call. Node does not need installing under
|
||
WSL's own nvm the way that recipe assumes, either: this session found `npm`/`node` already staged
|
||
under the Windows install (`node_modules/.bin/*` carries an extensionless sh wrapper beside the
|
||
`.cmd`/`.ps1` ones), and that copy runs from WSL exactly as well as a native one once its `PATH`
|
||
is set — no `EFTYPE`/loader-hook workaround needed, because a REAL Linux `node` executing
|
||
`build-css.sh` through `execFileSync` is not the Windows-side failure that trap is about. `owlab`
|
||
and `owfeed` (`~/go/bin`, both already on this host at the CI-pinned versions, 0.6.1 and 0.5.1)
|
||
are ordinary Linux ELF binaries and need no such bridging at all. What genuinely needs installing
|
||
fresh is `node` itself if WSL has none of its own — this session used NodeSource's `setup_22.x`
|
||
to match `build.yml`'s pinned `node-version: '22'` exactly, a one-time `apt-get` cost, not a
|
||
per-run one.
|
||
|
||
**A variable ASSIGNED inside an inline `wsl.exe -- bash -c '…'` string reads back empty or
|
||
truncated, independent of and in addition to the `$?` trap already on this page.** Measured this
|
||
session, the same inline call every time: `x=$(echo hello); echo "$x"` printed nothing; so did
|
||
the far simpler `x=hello; echo "$x"`, with no command substitution involved at all; a function
|
||
DEFINED inline (`f(){ …; }; f`) failed to resolve. The exact same lines, saved to a file and run
|
||
as `wsl.exe -- bash /path/to/script.sh`, read back correctly every time — command substitution,
|
||
`$?`, and everything else this script depends on, all confirmed with a dedicated probe before
|
||
writing a line of `tools/ci-local.sh` around it. Treat this as one mechanism with the `$?` trap
|
||
below rather than two: neither survives an inline `bash -c` string, both survive a file, and
|
||
"write the script to a file and call `wsl.exe -- bash <path>.sh` instead of inlining it" (already
|
||
this page's advice for the `$R`/`$T` collapse) is the same fix for both.
|
||
|
||
## The stand's own traps
|
||
|
||
- **`owlab test` (0.6.1) removes the project's RUNNING stands, not only the throwaway router it
|
||
booted.** Measured 2026-09-14: `owrt2512` and `owrt2410` were up, `owlab test --release 25.12.4
|
||
--install …` ran, and its own log carried `Container owlab-luci-theme-footstrap-owrt2512 Removing`
|
||
/ `Removed` before the `openwrt-25.12.4` router it built — a compose `down` of the whole project.
|
||
A concurrent `owlab up owrtsnap imm2512 imm2410` exited 1 with its images built and no container
|
||
left. `docs/ci.md` and `tools/ci-local.sh --list` say `verify` is "safe with stands up"; on 0.6.1
|
||
it is not. Tell the two apart with `docker ps --format '{{.Names}}' | grep owlab-luci-theme-footstrap`
|
||
before and after: 0 after is this. Run `owlab test` BEFORE `owlab up`, or `owlab up` again after
|
||
it; an issue against owfeed/owlab is drafted in `../tmp/task-release-0.14.13/owlab-issue.md`.
|
||
|
||
- **`owlab up` on a taken port fails ONE container and returns non-zero for the whole command.**
|
||
Adding three stands on 2026-09-09, `owrt2512b` could not bind ssh 2235 — something else on this
|
||
machine already listened there — so `up` printed `Bind for 0.0.0.0:2235 failed: port is already
|
||
allocated` and exited 1, while the other two had started fine. The following `owlab sync` then
|
||
synced those two and reported a failure for the third. Read the per-router lines, not the exit
|
||
status: a partial success looks like a total failure and the reverse is equally possible. Check
|
||
what is actually listening with `ss -ltn | grep :22` before choosing a port, and note that
|
||
2222 and, on this host, 2235/2240/2241 are taken by things owlab does not own.
|
||
|
||
- **A stand rebuilt with `owlab up --rebuild` carries NO theme until `owlab sync`.** The gates do
|
||
not say so: every cell prints `page.evaluate: NetworkError: HTTP error 404 while loading class
|
||
file "/luci-static/resources/fs-prefs.js"` and the run **exits 0**. A sweep that measured nothing
|
||
is indistinguishable from a clean one unless you read the lines. Always `owlab sync` after a
|
||
rebuild, and treat a 404 in a cell as "the stand is empty", not as a finding.
|
||
|
||
- **Never pipe a long run into `tail`, `sort` or a bare `grep`.** The output buffers until the
|
||
command ends, so a hang looks exactly like progress. `live-audit` over a full sweep did this on
|
||
2026-09-09: the process sat alive and idle for 73 minutes with no output, where CI takes about 90
|
||
seconds a stand. `grep` is the one that catches you by accident — filtering a `wsl.exe` call's
|
||
noise through `| grep -v …` buffers in 4KB blocks, so a gate that was printing a line a second
|
||
looked mute for minutes (2026-09-10). Write to a file and read the file as it grows (`grep
|
||
--line-buffered` if it must be a pipe), and give any local `live-audit` both `--pages` and a
|
||
`timeout`.
|
||
|
||
- **A variable assigned INSIDE an inline `wsl.exe -- bash -c '...'` string reads back empty.** This
|
||
is separate from the `$?` trap below and bites the same way: the script looks like it ran and
|
||
produced nothing. Write the script to a file and call `wsl.exe -- bash <path>.sh`.
|
||
|
||
- **`tools/bg.sh` started from inside a `wsl.exe -e bash -c …` call dies with that call, and reads
|
||
as a run that finished instantly — and `tools/bg-wait.sh` then waits out its full two-hour cap
|
||
on it.** The log file is created with `bg.sh`'s own header (~280 B) and never grows, and `.status`
|
||
never appears. Neither does `.pid`: the inner shell is killed before it gets that far, so
|
||
`bg-wait.sh` has no pid to test and cannot see the death at all — measured 2026-09-10, it returned
|
||
`WAIT-RC=2, exit: timeout (no status after 7200s)` on a run that had died at second zero. The detach
|
||
is real inside WSL; what does not survive is the WSL session itself, which the interop call tears
|
||
down the moment its own command returns, taking the whole process group with it. Tell the two apart
|
||
by the files: a run that genuinely finished has output in its log and a `.status`; one killed with
|
||
its session has a header-only log, no `.status` and no `.pid`. From a Windows host, start a long gate as a **background Bash-tool
|
||
command** (`wsl.exe -e bash -c 'cd … && npm run check'`, run in the background) and wait on that
|
||
instead — `tools/bg.sh` is for a session that outlives the command, which an interop call is not.
|
||
|
||
- **`scroll-anchor --full` with all three engines at once over three stands kills the chromium
|
||
process about two minutes in; one engine at a time completes.** It reads as a regression in the
|
||
sweep — chromium's leg dies mid-run while webkit and firefox finish — and it is the browser's
|
||
zygote going down under the load of three engine sets against three routers on one host, not
|
||
anything the theme did. Tell the two apart by re-running the same axis with a single
|
||
`--engines chromium`: a real finding reproduces there, this does not. Measured 2026-09-11 on the
|
||
three twins (`owrt2512b`, `owrt2410b`, `owrtsnapb`), 184 runs per engine green when run one at a
|
||
time. CI does not meet it — `anchors` is one job per engine, on a runner each. **The way to have
|
||
all three at once locally is one PROCESS per engine**, not one process driving three: measured
|
||
2026-09-12, nine shards (engine x stand, own browser and own router each) ran to completion with
|
||
no zygote loss at all.
|
||
|
||
- **A twin restarting mid-run reads as `install.sh` failing the network, not as the stand.** Testing
|
||
the install-feed card's fixes on `owrt2410b`/`owrt2512b`, a run reported `StartedAt`
|
||
12:39:45…12:43:42 — several restarts inside four minutes, none of them anything this session did —
|
||
and for about 10 s after each one `ip route` inside the container had no default route at all
|
||
(`wget`/`apk`/`opkg` answered "Operation not permitted", not a DNS failure or a 404): the
|
||
container's network namespace was still being reattached to `br-lan`. A network call from
|
||
`install.sh` that lands in that ~10 s window fails exactly like a real feed outage would —
|
||
`feed_refresh`'s own tolerance logic cannot tell the two apart, because from inside the container
|
||
there IS no route, full stop. Tell a restart apart from an installer or feed defect with
|
||
`docker inspect -f '{{.State.StartedAt}}' <container>` against the time the failure was logged (a
|
||
`StartedAt` within seconds of the failure is the restart, not the script) and `ip route` right
|
||
after (no `default via …` line yet, or an `owlab status` showing the router still coming up) — a
|
||
genuine feed problem leaves the route intact and fails on DNS/TLS/timeout instead. Cause not
|
||
identified from inside this session (no `owlab up`/`owlab sync` was run against these two stands
|
||
here); re-running the same scenario a few minutes later, once `StartedAt` had settled, reproduced
|
||
cleanly with no restarts.
|
||
|
||
- **A `cp`/`cat`/`mv` PATH-shim in a failure-injection harness silently injects nothing when the
|
||
harness runs under this dev box's own busybox in WSL, while the identical shim works inside a
|
||
real router container.** Testing the install-feed card's `atomic_write()` failure paths, a harness
|
||
that put a fake, always-failing `cp` first on `$PATH` and called the extracted shell functions
|
||
directly kept reporting success — the write went through, unshimmed, no matter how the temp
|
||
directory or `export PATH=` order was arranged. The cause: this busybox's `ash` resolves `cp`,
|
||
`cat` and `mv` as its own applets via a standalone-shell shortcut that bypasses `$PATH` lookup
|
||
entirely for names it already provides, so a shim script at another path on `$PATH` is never
|
||
reached — confirmed with `busybox --list | grep -wE 'cp|cat|mv'` (all three listed) against the
|
||
same shim placed and `chmod +x`'d correctly, invoked by its literal path (`/tmp/shim/cp`, which
|
||
*did* run) versus by bare name (`cp`, which did not). Inside an owlab router container the same
|
||
shim, same harness, intercepted correctly on the first try — `docker exec <container> sh
|
||
harness.sh`, not `busybox ash harness.sh` on the dev box. Tell the two apart before trusting a
|
||
"shimmed and it still passed" result: run the harness through `docker exec`/`owlab` on a stand, or
|
||
check `busybox --list` in the exact shell the harness runs under for the command being shimmed.
|
||
|
||
- **The sweep is wait-bound, not CPU-bound, and sharding it further than the tool already does buys
|
||
almost nothing.** Measured 2026-09-12 on a 20-core, 15 GB host: three engine processes over three
|
||
stands each read 22 cells/min; nine shards, one per engine and stand, read 26 — 18% for three
|
||
times the processes, because `tools/scroll-anchor.mjs` (0fa9c2b) ALREADY runs its stands
|
||
concurrently inside one process, so an external split mostly moves that same concurrency outside.
|
||
What the same run says about the machine is the useful part: `load average 1.49` of 20, 5 GB of
|
||
15 in use, every stand container at 0.03-0.14% CPU. A cell costs ~20 s and nearly all of it is
|
||
fixed waiting — SWAP's 900 ms window, three REPEAT refills, QUIET's 24 flick steps, `SCROLL_IDLE`
|
||
on each check — so the lever is more cells in flight (memory is the only ceiling), never a faster
|
||
machine. **Do not shorten those waits to speed the sweep up**: each is a measured number, and half
|
||
of this week's findings need the window as wide as it is. Full `--full` across three engines:
|
||
~75 min serial, **1860 s (31 min) at nine shards**, 828 runs.
|
||
|
||
- **Nine shards is the ceiling; running the sweep ALONGSIDE `spa-parity` and `npm run check`
|
||
manufactures late findings that are not there.** Measured 2026-09-12: nine sweep shards plus three
|
||
`spa-parity` runs plus a full `check`, thirteen processes at once, produced two
|
||
`corrected late` findings — `webkit owrt2410d` at 228 ms and `webkit owrt2512d` at 247 ms, both
|
||
`@1440 side normal engine DECLINES overview`, both just over the gate's own 200 ms `LATE_MS`. The
|
||
identical three webkit shards on an otherwise idle machine, same commit and same stands, read 276
|
||
runs and no findings. Tell the two apart by re-running the engine alone before believing a
|
||
`corrected late` within ~50 ms of the threshold; a real one reproduces on a quiet machine. Run the
|
||
sweep on its own, and `spa-parity`/`check` after it.
|
||
|
||
- **Two `tools/ci-local.sh` runs against the same stands invent findings, and `--force` does not
|
||
make it safe — it only silences the refusal that was protecting you.** Measured 2026-09-12: a
|
||
second `anchors:webkit` started 37 s after the first, both with `--force`, and the pair reported
|
||
**12 findings** — `the reader drifted 46/88/138px across real poll ticks` at every density and
|
||
both layouts, on two stands. Run alone, the same commit and the same command read 276 runs and no
|
||
findings. The script's default refusal names this exact failure; passing `--force` is a promise
|
||
that nothing else is using the stands, and the way to keep it is `pgrep -f '^node tools/(scroll-anchor|spa-parity|live-audit)'`
|
||
and `pgrep -f '^sh tools/ci-local'` both finding nothing before you start — anchored with `^`
|
||
and run from a script FILE: the unanchored `ps -eo cmd | grep '[s]croll-anchor'` this entry first
|
||
recommended matches the command line of an inline `bash -c '…'` that contains it, reports busy
|
||
every time, and aborted two A/B runs on 2026-09-13 with nothing else on the stands. A finding that appears on twelve
|
||
cells at once, evenly across an axis, is this and not the theme.
|
||
|
||
- **"no owlab router is running, so nothing was checked" while all eight are up means the gate
|
||
could not find `owlab`, not that the stands are down.** `owlab` is a Go binary in `~/go/bin`, put
|
||
on `PATH` by the login profile — and a login shell started as `wsl.exe -e bash -lc` from a Windows
|
||
host can die part-way through that profile on something unrelated (measured 2026-09-11: a stale
|
||
`deno/env` path from another project, printed as a bare `No such file or directory` with no
|
||
mention of owlab), leaving `PATH` half-built. Every live gate then reports the same sentence on
|
||
every stand at once. Tell the two apart with `owlab status`: if it lists routers as `running`, the
|
||
stands are fine and the shell is not. Run live gates as `wsl.exe -e bash -c` with
|
||
`export PATH=$HOME/go/bin:$PATH` set explicitly, which skips the profile entirely. **Six gates
|
||
saying "nothing was checked" is the hardening working** — before `21a8502` and `916d4d5` those
|
||
same runs would have exited 0 and read as six passes.
|
||
|
||
- **A page that pins the browser's main thread stops a gate DEAD, and no gate has a deadline for
|
||
it.** `page.evaluate()` is the one Playwright call with no timeout at all: it waits for the page's
|
||
own thread, and a page stuck in a loop never gives it back. Measured 2026-09-10 (task liveslice):
|
||
`/admin/system/filemanager` under `fs-fit.js` at `13e9864` left the renderer at ~105% CPU for as
|
||
long as it was allowed, so `classify()` (`tools/lib/page-shapes.mjs`, which both `spa-parity` and
|
||
`live-audit` walk the whole menu with) never returned, and both CI slices were cancelled at their
|
||
45-minute cap on every run for a day — with an empty log, because neither gate said which page it
|
||
was on. Both gates now print a line per page with the clock on it; a run that stops has the answer
|
||
as its last line.
|
||
**Tell a frozen page apart from a slow one in about a minute:**
|
||
```sh
|
||
# 1. is it the theme? serve the stock package on that stand and load the same page
|
||
owlab exec owrt2512b -- 'uci set luci.main.mediaurlbase=/luci-static/bootstrap && uci commit luci'
|
||
owlab exec owrt2512b -- 'rm -f /tmp/luci-indexcache*' # …and back to /luci-static/footstrap after
|
||
# 2. is it THIS commit? swap one file in, no worktree and no sync — the stands may be somebody
|
||
# else's right now, and `owlab sync` pushes whatever the working tree currently holds
|
||
git show <sha>:luci-theme-footstrap/htdocs/luci-static/resources/fs-fit.js > ../tmp/fs-fit.<sha>.js
|
||
docker cp ../tmp/fs-fit.<sha>.js owlab-luci-theme-footstrap-owrt2512b:/www/luci-static/resources/fs-fit.js
|
||
docker exec owlab-luci-theme-footstrap-owrt2512b chmod 644 /www/luci-static/resources/fs-fit.js
|
||
```
|
||
`chmod` is not optional: a `docker cp` lands the file 0600-ish and uhttpd answers **403**, which
|
||
reaches the page as `NetworkError: HTTP error 403 while loading class file` — the module then does
|
||
not run at all and the page looks *fixed*. A green result with 403s in it has measured nothing.
|
||
|
||
- **`live-audit` on a `-b` twin calls every finding NEW.** The baseline is keyed by stand id
|
||
(`tools/baselines/live-audit.json`: `owrt2410`, `owrt2410@ru`, …), and `owrt2410b` is not one of
|
||
those keys, so a sweep there starts from an empty known set — 30 fresh signatures over the
|
||
`/admin/network` subtree alone, none of them a regression. The twins are for the gates that carry
|
||
no baseline (`scroll-anchor`, `spa-parity`); measure `live-audit` on the base stand, or read its
|
||
twin run as a list rather than a verdict.
|
||
|
||
- **`pkill -f <pattern>` kills the shell you typed it in.** `-f` matches the full command line, and
|
||
the wrapper `bash -lc "pkill -f probe-one …; node probe-one.mjs …"` contains the pattern, so the
|
||
whole chain dies before the command after the `;` runs — and it looks exactly like a run that
|
||
produced no output. Bracket the first character (`pkill -f "[p]robe-one"`), or kill from a separate
|
||
call.
|
||
|
||
- **`${PIPESTATUS[0]}` is as unreliable as `$?` in that shell.** On 2026-09-09 a piped
|
||
`npm run check` reported `CHECK_EXIT=` and read as success while it had actually failed on the
|
||
size budget; the failure was only caught by reading the gate's printed text. Judge every gate by
|
||
what it printed, never by a status.
|
||
|
||
Every one of these cost a measurement that read as a regression in the theme. They are written down
|
||
because each was hit more than once.
|
||
|
||
**Two `live-audit` sweeps against the same stand fight over its language, and the result reads as a
|
||
theme regression that is really two processes racing.** `--lang` sets `luci.main.lang` for the whole
|
||
sweep and restores it on exit — safe against ONE sweep crashing, not against a SECOND sweep (or a
|
||
manual `uci set luci.main.lang=ru` for a side probe) touching the same router while the first is still
|
||
mid-flight: whichever write lands last wins for every page load after it, so a page fetched between
|
||
the two writes is measured in whichever language happened to be live at that instant, filed under
|
||
whichever key the confused sweep was running as. Measured directly (task 0162): a stray backgrounded
|
||
`live-audit` process from an earlier, abandoned launch attempt survived unnoticed alongside the real
|
||
one, both hammering `owrt2512`/`owrt2410` at once, and the English pass reported 19 "NEW" findings —
|
||
every one Russian dashboard text (`span.associations`, `span.encryption`) under the PLAIN `owrt2512`
|
||
key — because the stray process's own `--lang ru` write landed mid-sweep. `ps aux | grep live-audit`
|
||
before trusting a "NEW finding" that names text in the wrong language; a live-audit run is exclusive
|
||
use of whatever stand it names, the same as `--prune`'s own exclusive claim on the baseline file.
|
||
|
||
**On a Windows checkout, most npm-run gates fail before they measure anything, and the failure is
|
||
the host, not the theme.** Tell one apart from the other by re-running the SAME gate the SAME way
|
||
on a clean `HEAD` — a host fault fails there too, identically.
|
||
|
||
- `npm run lint:js`, `npm run lint:css` and other npm-script entry points fail outright: the
|
||
packages under `node_modules/.bin` have no `.cmd` shim on this host. Call the binary directly
|
||
instead of through npm: `node node_modules/eslint/bin/eslint.js <path>`,
|
||
`node node_modules/stylelint/bin/stylelint.mjs "<glob>"`.
|
||
- Any gate that builds the sheet (`css-metrics`, `css-floor`, `table-contract`, `smoke`,
|
||
`computed-diff`, `a11y`, `export-tier`) fails with `EFTYPE`: `tools/lib/css.mjs` runs
|
||
`build-css.sh` through `execFileSync`, and Windows cannot execute a shell script directly.
|
||
The workaround this session used is a `NODE_OPTIONS=--import` loader hook that intercepts the
|
||
build call and re-issues it through `sh`, run from a copy of the tree in `../tmp/` so the checkout
|
||
is never written to.
|
||
- `tools/computed-diff.mjs` additionally hands `tar -C` a `C:\…` path, which `tar` refuses — it
|
||
needs a POSIX path.
|
||
- `python3` resolves to the Microsoft Store stub and fails with "Python was not found"; the working
|
||
interpreter on this host is named `python` — matters for `python3 tools/audit.py --strict`.
|
||
- `npm test` fails one case of 154 on Windows and none in WSL: `resolveUnderRoot`
|
||
(`tests/playground.test.mjs`, code in `tools/playground/lib.mjs`, added by `cfdc7d8`) asserts
|
||
`/out/cgi-bin/luci/...` and gets `C:\out\cgi-bin\luci\...`, because `path.resolve` is
|
||
`path.win32.resolve` there. The test states the contract CI runs under, so a red case here is the
|
||
host disagreeing with it, not the tree: re-run the suite as
|
||
`wsl.exe -e bash -c 'cd /mnt/c/... && npm test'` before reading it as a defect — 154 pass there.
|
||
|
||
**None of the above means the live half is out of reach — it runs fine, just not from the Windows
|
||
side of this checkout.** `owlab` is already installed in WSL (`~/go/bin/owlab`), its containers are
|
||
already up, and the full `npm run live` gate passes there against the very same tree, mounted at
|
||
`/mnt/c/...`. The recipe this session used for every WSL call:
|
||
|
||
```sh
|
||
MSYS_NO_PATHCONV=1 wsl.exe -- bash -c 'PATH=$HOME/go/bin:$HOME/.nvm/versions/node/v24.12.0/bin:/usr/local/bin:/usr/bin:/bin; export PATH; cd /mnt/c/Users/IVAN/Documents/home/openwrt/luci-theme-footstrap; <command>'
|
||
```
|
||
|
||
- `MSYS_NO_PATHCONV=1` is load-bearing: without it Git Bash rewrites the POSIX `/mnt/c/...` argument
|
||
into a Windows-shaped path before `wsl.exe` ever sees it, and the `cd` fails with "No such file or
|
||
directory" on a line that reads correctly.
|
||
- The `PATH=` has to be assigned by hand and kept short: the inherited Windows PATH carries spaces
|
||
and parentheses (`Program Files`, `NVIDIA Corporation (x86)`), and `export PATH=<that>` inside
|
||
`bash -c '...'` fails with `syntax error near unexpected token '('`.
|
||
- Node in WSL lives under nvm (`~/.nvm/versions/node/v24.12.0/bin`), which is not on PATH by
|
||
default: leave it out and a live `node` on Windows still reads as `node: command not found`
|
||
inside the WSL shell.
|
||
- The same recipe also clears the static gates that need a built sheet: `node tools/size-budget.mjs`
|
||
run this way needs no loader-hook workaround at all, because `build-css.sh` executes normally
|
||
under WSL's own `sh`.
|
||
|
||
Two traps sit in the calling convention itself, each cost a retry:
|
||
|
||
- shell variables inside `wsl.exe -- bash -c '...'` collapse to empty before `bash` ever starts —
|
||
`$R`, `$T`, `$?` in the single-quoted string belong to Git Bash, not to WSL — so `> $T/gate-$n.log`
|
||
turns into `> /gate--.log` and `cd $R/luci-theme-footstrap` into `cd /luci-theme-footstrap`. Write
|
||
the script to a file and call `wsl.exe -- bash <path>.sh` instead of inlining it.
|
||
- **detaching through `tools/bg.sh` inside a one-shot `wsl.exe` call does not survive**: WSL tears
|
||
the session down together with the process it was running, and the log is left with only its
|
||
header — no `.status`, no `.pid`. It works only when the WSL session behind the run stays alive
|
||
for the whole duration, not merely for the `wsl.exe` invocation that started it — which a session
|
||
driven from Git Bash on Windows never gives it: every `wsl.exe` call from there is its own
|
||
subprocess that returns and tears down, so the `setsid`-detached child dies with it the moment the
|
||
call completes, before a T2 gate (`owlab`, docker, anything living in WSL) has had time to finish.
|
||
Four T2 runs were lost to exactly this before the tester stopped routing them through `tools/bg.sh`
|
||
from Git Bash and used the harness's own background runner instead — the Bash tool's
|
||
`run_in_background: true`, which keeps the process (and the WSL session under it) alive for as
|
||
long as the harness itself runs, paired with the Monitor/notification mechanism rather than
|
||
`tools/bg-wait.sh`. Tell the two apart by the log: a `tools/bg.sh` run started this way stops dead
|
||
at its header line with no `.status` file ever appearing, no matter how long `bg-wait.sh` is left
|
||
polling it.
|
||
|
||
**`owlab.yaml`'s `extra_packages` under `defaults:` reaches every router, including the snapshot
|
||
box, and a package that box cannot resolve fails `owlab up` for the whole lab — not just the
|
||
missing package.** `defaults.extra_packages` merges additively onto whatever a router adds
|
||
(`internal/config/config.go` in owlab, no subtraction syntax), so there is no way to opt a router
|
||
OUT of a list declared in `defaults:`; the only lever is to not put it there. Reproduced 2026-09-07,
|
||
`owlab up --rebuild owrtsnap`: openclash and ssclash need `kmod-tun`, which resolves through a kmods
|
||
index keyed to the box's exact kernel git hash, and `downloads.openwrt.org/snapshots` was not
|
||
currently publishing kmods for the box's baked `6.18.33` hash — `apk add` 404s that index and both
|
||
apps fail to resolve, permanently, until the box's kernel and the live kmods feed happen to agree
|
||
again. justclash carries no kernel module and installed cleanly on the same box, which is why it was
|
||
never in the failure list — same feed, different dependency shape, not a fluke. `owlab up`'s
|
||
non-zero exit here is correct by design (`reportMissingExtras`, owfeed/owlab#18's sibling): it is
|
||
the config asking every router for packages one of them cannot ever have. Fix is in `owlab.yaml`
|
||
itself — declare the three apps once on a release router and alias the list onto the others, leaving
|
||
`owrtsnap` with none, rather than routing them through `defaults:`.
|
||
|
||
**An ordinary `packages:` entry can go missing on `owrtsnap` too, and it is not the kmod-tun
|
||
mechanism above even though the symptom looks the same — and the entry that goes missing is not
|
||
fixed, so do not name one here as if it were.** Task 0177: a `scroll-anchor` finding on
|
||
`owrtsnap @1440 side compact` traced to the Overview page rendering 12 `.cbi-section` there against
|
||
`owrt2512`'s 13 — `luci-app-https-dns-proxy` absent that day. `apk info -R` shows neither it nor its
|
||
`https-dns-proxy` binary needs a kernel module (`ca-bundle libc libcares libcurl4 libev jsonfilter
|
||
resolveip` only), which rules out the kmods-index gap; `/var/log/apk.log` from that boot instead
|
||
named the KMOD-needing packages failing (`luci-app-mwan3`, `-sqm`, `-nlbwmon`, `-openvpn`,
|
||
`luci-proto-wireguard`/`-openconnect` — the same `ERROR: unable to select packages` the kmods gap
|
||
produces) while `luci-app-https-dns-proxy` installed clean in that same run. By task 0178,
|
||
`https-dns-proxy` installed clean on every stand and `luci-app-mwan3` was the one missing instead
|
||
(no `90_mwan3.js`; the same `unable to select packages` line in `apk.log`, this time for `mwan3`,
|
||
`nlbwmon`, `openvpn` and `sqm` together). Two different names in two sessions IS the finding:
|
||
`feeds/luci` and `feeds/packages` build the snapshot index independently, and a `luci-app-*`/binary
|
||
pair that agrees on version when both are fresh can briefly disagree while one side has moved and
|
||
the other has not mirrored yet — the same class of drift as the kmods index, on an ordinary package
|
||
rather than a kernel module, and not reproducible on demand for that reason. Not fixed by a version
|
||
pin: `owlab.yaml`'s `packages:` list carries no version syntax, and pinning one would go stale as
|
||
the snapshot feed prunes old builds (a release branch keeps them for the branch's life; snapshot
|
||
does not). Check what is missing TODAY rather than trust a name in this paragraph: `apk info -e
|
||
<pkg>` for a `+luci-app-*` in `owlab.yaml`, or `grep 'unable to select' /var/log/apk.log`, on
|
||
`owrtsnap` — before treating a snapshot-only section-count or DOM-count difference as a theme
|
||
finding.
|
||
|
||
**`mangle-tokens.sh` fails on a `C:\...`-shaped path with `mv: cannot stat ...tmp.NNN`, and the gate
|
||
that surfaces it never mentions the script by name.** Like `build-css.sh`, it needs a POSIX path;
|
||
`tools/size-budget.mjs` calls it and inherits the failure as its own. A failed run also leaves
|
||
droppings in the checkout ROOT — files named like `C<...>cascade.css.tmp.248` — that have to be
|
||
removed by hand: `git clean` is not safe here, because the same root holds untracked files that are
|
||
wanted.
|
||
|
||
**A stand serves http only, so nothing about the login page's https hop can be measured on it as it
|
||
comes.** `uhttpd` has the certificate already; what is missing is the listener, and owlab publishes
|
||
port 80 alone — the https side is reached on the container's own address rather than through
|
||
localhost:
|
||
|
||
```sh
|
||
docker exec owlab-luci-theme-footstrap-owrt2512 sh -c \
|
||
"uci set uhttpd.main.listen_https='0.0.0.0:443'; uci commit uhttpd; /etc/init.d/uhttpd restart"
|
||
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
|
||
owlab-luci-theme-footstrap-owrt2512 # -> curl -k https://<ip>/cgi-bin/luci
|
||
```
|
||
|
||
Put it back with `uci delete uhttpd.main.listen_https` — a stand left listening on 443 answers the
|
||
live gates on two schemes and one of them has a self-signed certificate. Comparing against stock is
|
||
`uci set luci.main.mediaurlbase='/luci-static/bootstrap'`, which needs no reinstall: the fallback
|
||
theme is already on the router (`uci-defaults`).
|
||
|
||
**A question gated on `/proc/mtd` or on the overlay mount line has no answer on a stand, and the
|
||
stand fails it silently — as a missing control, which reads as the theme having hidden one.**
|
||
luci-mod-system's flash page adds "Reset to defaults" only when `/proc/mtd` names `rootfs_data` or
|
||
`/proc/mounts` carries `overlayfs:/overlay / ` (`view/system/flash.js`), and both reads go through
|
||
`fs.trimmed()`, which is `L.resolveDefault(read(path), '')`: a missing file or a denied read
|
||
degrades to an empty string and the page renders complete, one control short, with no error. In a
|
||
container `/proc/mtd` does not exist and the root line reads `overlay / overlay …` from Docker's own
|
||
overlay2 driver, so the button is absent under footstrap AND under `/luci-static/bootstrap` — which
|
||
is the measurement that tells the two apart, and the one to run first. Feed the data rather than
|
||
trust the stand: intercept the ubus reply and re-read the DOM.
|
||
|
||
```js
|
||
// playwright, on the context: the endpoint is /ubus/, the body a batched JSON-RPC array
|
||
await ctx.route('**/ubus/**', async route => {
|
||
const body = JSON.parse(route.request().postData() || '[]');
|
||
const ids = new Set(body.filter(i => i.params?.[1] === 'file' && i.params?.[2] === 'read'
|
||
&& i.params?.[3]?.path === '/proc/mounts').map(i => i.id));
|
||
if (!ids.size) return route.continue();
|
||
const response = await route.fetch(), json = await response.json();
|
||
for (const i of json) if (ids.has(i.id) && typeof i.result?.[1]?.data === 'string')
|
||
i.result[1].data += '\noverlayfs:/overlay / overlay rw,noatime,lowerdir=/,'
|
||
+ 'upperdir=/overlay/upper,workdir=/overlay/work 0 0\n';
|
||
await route.fulfill({ response, json });
|
||
});
|
||
```
|
||
|
||
**More than about ten unthrottled ubus calls in flight at once exhaust the stand's uhttpd, and
|
||
every request on the router then reports HTTP status 0 — which reads exactly like the outage the
|
||
probe was built to catch.** `uhttpd` on a stand runs with a small worker/connection pool (`-n 50`,
|
||
and far fewer in practice); a probe that fires a call every 8 ms without waiting for the previous
|
||
one to settle self-DoSes it, and 98% of its own samples fail regardless of what it was measuring.
|
||
`/etc/init.d/uhttpd restart` clears it. Tell the two apart by the base rate: a real rpcd window is
|
||
rare (~10% of reloads) and narrow (≤55 ms), a self-inflicted one is nearly every sample and does not
|
||
stop when the event under test does. Poll serially — re-fire the moment the previous call settles,
|
||
which is a natural 42–85 ms on this stand — rather than on a timer.
|
||
|
||
**A "does this style reach the page?" probe lies in three ways, and all three say the same thing:
|
||
the rule is fine and the measurement is not.** Verifying today's CSS on a live stand produced three
|
||
false negatives in a row. The theme's controls transition (`transition: box-shadow var(--fs-dur)`,
|
||
150 ms), so a computed style read immediately after `.focus()` samples the START of the animation —
|
||
`oklab(0 0 0 / 0) 0px 0px 0px 0px`, a transparent ring — and reads as "no ring applied"; wait past
|
||
the duration and it is `color(srgb 0.035 0.41 0.85 / 0.1) 0 0 0 3px`, exactly `--fs-focus-ring`. A
|
||
page's only native `<select>` is `display: none`, because LuCI replaces every CBI select with a
|
||
`.cbi-dropdown` widget, so focusing it measures nothing — mount a raw one, which is also what a
|
||
third-party app outside the CBI form does. And a modern Chromium aliases `-webkit-mask-image` onto
|
||
the standard property in the CSSOM, so `cssText` never shows the prefix: that check is textual,
|
||
against the built sheet, and cannot be done in a browser at all.
|
||
|
||
**Both stands run `data-layout="top"`, so a crawl that does not switch layouts measures none of the
|
||
sidebar.** The whole `[data-layout="sidebar"]` family — the open-submenu rules, the rail, the
|
||
narrow fold — renders on no page of a default sweep, and a coverage run reports every one of those
|
||
selectors as unmatched. It took a deliberate `localStorage['fs-layout'] = 'sidebar'` pass, and a
|
||
reload, to exercise them. Tell the two apart before believing any "unused" verdict: if the report
|
||
has no `[data-rail="true"]` hit either, the crawl never wore the layout rather than the rules being
|
||
dead.
|
||
|
||
**Playwright's session-wide JS coverage keeps only the documents that are still alive.**
|
||
`startJSCoverage({ resetOnNavigation: false })` across a crawl of eight pages returns the scripts of
|
||
the LAST one — eleven entries where sixteen modules had been loaded, with `fs-appearance`,
|
||
`fs-overview` and `fs-search` missing outright. It made `fs-router.navigate` (16,332 B, the largest
|
||
single block in the module) look dead when the crawl had simply never clicked anything: driving one
|
||
SPA click took that module from 55.4% to 89.2% executed. Start and stop the coverage around EACH
|
||
page and merge the byte maps yourself. The tell is a module count lower than the number of modules
|
||
the page actually loads.
|
||
|
||
**Some string literals are read out of the source by a gate, so hoisting one into a `const` breaks
|
||
the gate rather than the code.** `tools/axes.mjs` extracts the attribute names from the SOURCE of
|
||
`stampDark()` in `fs-prefs.js`, and `tools/page-modules.mjs` reads the `data-page` value out of
|
||
`fs-overview.js` the same way. Both were hoisted during a byte-saving pass; both gates then reported
|
||
a fault that did not exist — "the Appearance axes have drifted" and "nothing in it tests a data-page
|
||
value". The known members of this family are the `require …` loader pragmas, `@mirror`/`@endmirror`,
|
||
`/* fs:probe */`, the Makefile's buildroot marker, and now these two. `npm run check` catches them,
|
||
but it names the symptom, not the edit that caused it — so when a gate suddenly claims a drift
|
||
nobody introduced, look for a literal that moved.
|
||
|
||
**A layout fault can be invisible on one release line because that luci-base happens to mutate the
|
||
page, not because the theme is right.** The poll floor left on an outgoing tab pane (issue #75,
|
||
`docs/anchoring.md`) reproduces on 25.12 — 2432px of blank above System → Startup's textarea, for
|
||
the life of a page that never polls — and the same v0.14.6 build on the 24.10 stand cleared it
|
||
within 200 ms of the switch, some other mutation there having woken the sweep. A stand that is green
|
||
is evidence about that stand. What tells the two apart is measuring the mechanism rather than the
|
||
symptom: click a tab and read the floor off the pane the reader left, on each stand.
|
||
|
||
```sh
|
||
# after switching tabs, on any page with a tab strip
|
||
[...document.querySelectorAll('#view [data-tab-title]')]
|
||
.filter(p => p.getAttribute('data-tab-active') !== 'true' && p.style.minHeight)
|
||
.map(p => p.getAttribute('data-tab-title') + '=' + p.style.minHeight) # must be []
|
||
```
|
||
|
||
**`Poll.start()` fires a tick synchronously, so a probe that hands the poll back poisons the probe
|
||
after it.** luci-base's `start()` sets the interval and then calls `step()` on the spot, which means
|
||
restoring the poll on the way out of one measurement drops a real tick into the beginning of the
|
||
next. `scroll-anchor`'s HOLD case did exactly that for two days: SWAP then measured the content
|
||
column's floor while a live tick was rewriting the sections under it, and CI reported `the content
|
||
column's floor is not holding the document up`, 120px, on webkit/owrt2410 @390 top compact. The
|
||
theme was not involved — the same cell is green at v0.14.2 and on every build without that change,
|
||
and the finding follows the PROBE across four runs. The stopped poll now stays stopped until QUIET,
|
||
the one case that wants ticks landing mid-flick, starts it. Tell this apart from a theme fault by
|
||
the shape: a floor finding that only CI sees, on a cell a local repeat cannot reproduce.
|
||
|
||
**LuCI keeps an EMPTY `.modal` in the DOM at all times, and a probe that trusts the class name alone
|
||
measures inside it.** `base/60-modal.css`'s host is rendered once per document and left behind,
|
||
zero-content, whenever no dialog is open; `document.querySelector('.modal')` finds THAT element on
|
||
every page that has never opened one, not the live dialog a page that HAS opened one is currently
|
||
showing. A probe built on the assumption "`.modal` exists only while a dialog is open" reports a
|
||
CLEAN SHEET everywhere — no overflow, no clipped text, no failing contrast — because it is reading a
|
||
hidden, childless box, not the modal's actual content. The second RU sweep (task 0147) hit this on
|
||
four pages in a row before the shape was named: every one of them "passed" a modal-content check that
|
||
had nothing to check. Tell the two apart before trusting a modal probe: check that the element is
|
||
actually visible (`getComputedStyle(el).display !== 'none'` and a non-zero `getBoundingClientRect()`)
|
||
AND that it has at least one child — an empty, hidden `.modal` clears neither.
|
||
|
||
**A hand-written replay of a probe is not the probe, and on the anchor sweep it was green six times
|
||
over a fault that was real.** Chasing `scroll-anchor`'s webkit finding, six standalone scripts
|
||
replayed what the gate does — the same park, the same HOLD, the same swap, the gate's own way of
|
||
picking its mark, the poll left running — and every one of them measured 0px while the gate went on
|
||
reporting -12px on that cell. What differed was never found, and looking for it cost more than the
|
||
fault did. Measure INSIDE the gate: copy `tools/scroll-anchor.mjs` into `../tmp/`, add an rAF
|
||
sampler that records `scrollY`, `fit.restAt()` and the theme's own reference, and print it from the
|
||
branch that raises the finding. That is what showed `_restAt` and `_rest.top` describing different
|
||
pages, which no replay reproduced.
|
||
|
||
**`owlab` can be absent from `PATH` while every stand is up.** The containers are started by
|
||
whatever ran `owlab up` last; the CLI lives with that runner, not with the checkout. Gates that
|
||
enumerate routers do it through `owlab status -json` (`tools/lib/stands.mjs`), so with the binary
|
||
missing they find no routers and SKIP — `install-check` says so out loud, the sweeps just measure
|
||
nothing. `docker ps --format '{{.Names}}\t{{.Ports}}'` tells them apart in one line: containers
|
||
running means the ports are there and only the CLI is gone. A shim that answers `status -json` with
|
||
those ports is enough to run any of them locally, and it belongs in `../tmp/`, never in the tree.
|
||
|
||
**Anchor findings that move between runs are the sweep measuring three routers at once.** Since the
|
||
stands were parallelised the same push has reported 8 findings, then 1, then a WebKit internal
|
||
error, with cells that skip in one run and measure in the next — the routers grow their own tables
|
||
under the probe. A finding is only a finding when the same cell repeats it: `--only <one stand>`
|
||
plus three passes, which is now a minute with `--width`/`--layout`. The ones that survived that test
|
||
were real; the ones that did not never reproduced alone.
|
||
|
||
**`RPC call to uci/get failed: Access denied` on arrival is LuCI's, not the theme's.** It is thrown
|
||
once, BEFORE the login form is submitted: `luci.js` asks for `uci get luci` with the all-zero session
|
||
id and rpcd refuses, which is what it is supposed to do. It reads as a regression because a
|
||
`pageerror` listener attached before the first navigation catches it and reports it against whatever
|
||
was being measured. Prove whose it is by splitting the capture at the login — the same error appears
|
||
on a stand carrying no local change:
|
||
|
||
```
|
||
before login: RPC call to uci/get failed with error -32002: Access denied
|
||
after login: none
|
||
```
|
||
|
||
**A page-scoped rule is invisible to `computed-diff` and `a11y`.** Both gates load
|
||
`docs/gallery.html`, which carries no `body[data-page]`, so a rule written
|
||
`body[data-page="admin-status-overview"] …` matches nothing there: the diff reports 0 differences and
|
||
axe reports no violations, and neither has looked at the change. Green on such an edit means "not
|
||
measured", not "no effect" — take the reading off the playground or a stand, and compute any contrast
|
||
the rule introduces by hand. Measured this way for the Overview card restyle: `--fs-good` on
|
||
`--fs-panel2` is 4.59:1 at its worst (footstrap/dark) across all four palettes, both modes.
|
||
|
||
**`owlab sync` does not ship what a router gets.** It copies `htdocs/` and `ucode/` straight from the
|
||
checkout and builds `cascade.css` with `--dev`: no minifier, no pre-paint minifier, no template
|
||
strip, no token mangle, no PNG repack. Anything touching the build pipeline has to be measured on a
|
||
package — `./tools/stage.sh && owfeed build`, then install. The seam mangle is visible from the
|
||
browser: on a packaged build `getComputedStyle(document.documentElement).getPropertyValue('--fs-accent')`
|
||
comes back empty, because the name is `--aX` there.
|
||
|
||
**opkg does not reinstall a package whose version already matches.** `PKG_VERSION` is git-derived and
|
||
does not move between rebuilds of one commit, so on 24.10 `owlab install` answers
|
||
`… is up to date.` and leaves the previous files in place while apk reinstalls happily. Three rounds
|
||
of an anchor fix were measured on the OLD build this way. Prove the bytes arrived:
|
||
|
||
```sh
|
||
md5sum dist/root/www/luci-static/resources/fs-fit.js
|
||
owlab exec owrt2410 -- md5sum /www/luci-static/resources/fs-fit.js
|
||
```
|
||
|
||
When they differ, force it through docker — `owlab install` has no flag for it:
|
||
|
||
```sh
|
||
docker cp dist/all/*.ipk owlab-luci-theme-footstrap-owrt2410:/tmp/theme.ipk
|
||
docker exec owlab-luci-theme-footstrap-owrt2410 opkg install --force-reinstall /tmp/theme.ipk
|
||
```
|
||
|
||
**…and a forced install leaves the stand on stock bootstrap.** It runs postrm, which hands
|
||
`luci.main.mediaurlbase` to another theme exactly as [package.md](package.md) requires. The next gate
|
||
then measures bootstrap and reports the theme as broken — 78 findings on one anchor sweep, 156 on the
|
||
next, every one of them the wrong theme. Put it back:
|
||
|
||
```sh
|
||
owlab exec owrt2410 -- uci set luci.main.mediaurlbase=/luci-static/footstrap
|
||
owlab exec owrt2410 -- uci commit luci
|
||
```
|
||
|
||
**`owlab exec` eats short flags.** Everything after `--` still goes through owlab's own flag parser,
|
||
so `sh -c '…'` fails with `--config: stat n=0; for t in …` and `ucode -T -c -o /dev/null` fails with
|
||
`--config: stat -o`. stdin is not forwarded either. Put the script in the staged tree, sync it, and
|
||
run it by path: `owlab exec <stand> -- sh /www/_probe.sh`.
|
||
|
||
**`owlab exec` never attaches stdin, so a pipe into it "succeeds" and writes nothing.** `printf
|
||
'hello-stdin\n' | owlab exec owrt2512 -- cat` prints nothing and exits 0 — `docker exec` is run
|
||
without `-i` (owfeed/owlab#21). Tell it apart from a real empty file with `printf 'x\n' | docker
|
||
exec -i owlab-luci-theme-footstrap-owrt2512 cat`, which prints `x`; the container is named
|
||
`owlab-<project>-<router>`. Push with `docker cp <file>
|
||
owlab-luci-theme-footstrap-<router>:<path>` instead, and clear `/tmp/luci-indexcache*` and
|
||
`/tmp/luci-modulecache` afterward if the file is a LuCI resource. Measured 2026-09-14, owlab 0.6.1.
|
||
|
||
**`owlab exec <stand> -- sh -c '…'` fails with `owlab: --config: stat …: no such file or directory`**:
|
||
owlab still parses its own flags after `--`, so `-c` is read as `--config` (owfeed/owlab#24); a
|
||
flag owlab does not define, like `ls -l`, passes through. Pass the command as one string:
|
||
`owlab exec <stand> -- "sh -c '…'"`. Measured 2026-09-14, owlab 0.6.1.
|
||
|
||
**`owlab test` fights the stands that are already up.** It synthesises its own router on host port
|
||
2222, and with stands running the bind fails — after it has already removed one of the existing
|
||
containers. Either take the stands down first, or assert the five `verify` things by hand on a
|
||
running stand. If you do it by hand, log IN: an unauthenticated `curl` answers 403 and proves
|
||
nothing.
|
||
|
||
**`owlab up` can fail to rebuild the full five-router set, and the failure looks like the theme's
|
||
containers are gone.** The 24.10 leg dies in the image build on opkg index drift:
|
||
|
||
```
|
||
opkg_install_pkg: Checksum or size mismatch for package bash
|
||
target owrt2410: failed to solve: … exit code: 255
|
||
```
|
||
|
||
Docker's build cancels the ImmortalWrt and snapshot legs the moment that one fails, so one stale
|
||
index entry takes down a run that was asked to rebuild all five. **Do not take the running stands
|
||
down while `up` is broken this way** — a `--rebuild` or a plain `up` that fails partway may not
|
||
hand them back, and there is no volume to recover from. The way round it, used for the 0.14.10
|
||
pre-tag matrix: `owlab test --release <version> --install <artifact> --assert …` synthesises its
|
||
own router and reads no `owlab.yaml`, so it needs no image rebuild at all. Run it from a scratch
|
||
directory rather than the checkout — the containers are named after the cwd, so this also keeps it
|
||
from colliding with (or replacing) the project's own stands:
|
||
|
||
```sh
|
||
mkdir -p ../tmp/owlab-test-pretag && cd ../tmp/owlab-test-pretag
|
||
owlab test --release 25.12.4 --install '/path/to/dist/noarch/luci-theme-footstrap-*.apk' --assert …
|
||
```
|
||
|
||
**Reset the syslog BEFORE a live run, not after it reports.**
|
||
`/admin/services/banip/processing_log` prints the system log as page content, so the page grows with
|
||
every install, sync and `rpcd reload` done while working — a long session manufactures its own
|
||
findings (309 lines gave 3, one line gave none). Treating that as something to diagnose afterwards
|
||
costs a full re-run each time; it cost three in one session here. Put it in the run instead:
|
||
|
||
```sh
|
||
for c in owrt2512 owrt2410 owrtsnap; do
|
||
docker exec owlab-luci-theme-footstrap-$c /etc/init.d/log restart
|
||
done
|
||
```
|
||
|
||
`/admin/services/acme/logread` has a `textarea` with no accessible name in 25.12's app; 24.10 does
|
||
not render it at all.
|
||
|
||
**A live gate that says "no owlab router is running" may be looking at the wrong PATH.** Every one
|
||
of them shells out to `owlab status -json` (`tools/lib/stands.mjs`) and treats a failed spawn as
|
||
an empty router list, so a shell that cannot see `owlab` — `go install` puts it in `~/go/bin`,
|
||
which a login shell exports and `tools/bg.sh` does not — reports exit 2 and measures nothing,
|
||
with four containers up. The two apart: `docker ps` lists the stands while `owlab status -json`
|
||
fails. Export the path into the detached command itself:
|
||
`tools/bg.sh sh -c 'export PATH=$HOME/go/bin:$PATH; node tools/live-audit.mjs'`.
|
||
|
||
**A live gate killed by a timeout reports a browser bug, not a finding.** `page.waitForTimeout:
|
||
Target page, context or browser has been closed` with an empty `log: []` is the gate being
|
||
SIGTERMed mid-run — `live-audit` over two routers is 113 page renders and does not fit in five
|
||
minutes. The two apart: a real finding prints `path|width|kind|element` lines and a count; a
|
||
killed run prints a Playwright stack. Never re-run it with a bigger timeout — that is what T2
|
||
means: `tools/bg.sh`, report the run-id, and pair it with a waiter (below).
|
||
|
||
**A detached run needs a waiter, or it is a run nobody reads.** `tools/bg.sh` calls `setsid`: the
|
||
process outlives the shell that started it and nothing announces its end. The log and a `.status`
|
||
file next to it are the whole interface, and `.status` appears only when the run is over — which
|
||
makes "is it done?" a question you have to keep asking, and therefore one that gets forgotten. It
|
||
was forgotten three times in a single session here, twice while somebody was waiting on the answer,
|
||
each time for 15-30 minutes after the chain had already finished.
|
||
|
||
Start the waiter in the same turn as the run, as a background command, so finishing wakes you
|
||
instead of you polling it:
|
||
|
||
```sh
|
||
tools/bg.sh sh -c '…' # prints run-id and log path
|
||
tools/bg-wait.sh <run-id> # blocks until .status exists, prints `exit: N` and the `name:` lines
|
||
```
|
||
|
||
The waiter costs nothing while the run is going and turns the result into an event. A run whose log
|
||
nobody opened is not a green gate — that rule is older than this note; the waiter is what makes it
|
||
practical to honour.
|
||
|
||
It stops on three things rather than one: the `.status` file, the run's own pid disappearing
|
||
(`<run-id>.pid`, written by the inner shell — a run killed by SIGKILL, by a reboot or by the
|
||
machine sleeping never writes a status, and waiting for that file alone waits forever), and a cap,
|
||
`tools/bg-wait.sh <run-id> [interval] [max]`, 7200 s by default. The last two print
|
||
`exit: process-gone` and `exit: timeout` and exit 2: a waiter still alive when the session ends
|
||
wakes it later with a verdict nobody asked for.
|
||
|
||
**`tools/bg.sh` refuses `ssh`, `scp`, `sftp`, `rsync`, `dev-sync.sh`, `git push`, `git commit`,
|
||
`git tag` and `gh pr|release|issue`.** The wrapper is allow-listed in `.claude/settings.json` and
|
||
the permission engine matches the first word of a command, so it cannot see an `ssh` inside
|
||
`tools/bg.sh sh -c '…'` — the form every T2 gate uses — and the wrapper would carry that command
|
||
straight past the `ask` rule guarding the hardware router, the one thing a wrong run breaks for a
|
||
person. Those commands run in the foreground, where the prompt reaches the human.
|
||
|
||
**"It is the app's markup" is a claim, and switching the stand to bootstrap is how you check it.**
|
||
A finding on a third-party page reads as the app's, and the reflex is to write it into the baseline.
|
||
Point the stand at the stock theme instead, re-measure the same page at the same width, and compare:
|
||
|
||
```sh
|
||
owlab exec owrt2512 -- uci set luci.main.mediaurlbase=/luci-static/bootstrap
|
||
owlab exec owrt2512 -- uci commit luci # …and put it back afterwards
|
||
```
|
||
|
||
Three `overflow` findings on `ssclash/config` at 320 looked like the app's split button, which is an
|
||
`inline-flex` the app styles inline — and the theme sets no width on it at all. Under bootstrap the
|
||
same page overflowed by nothing: the wrapper takes its buttons' max-content, and this theme's
|
||
buttons are 295px where stock's are 245, its face being wider than the system stack even at a
|
||
smaller size. The finding was ours. A baseline entry would have hidden it for good, which is what
|
||
makes the ratchet worth only as much as the judgement behind each line.
|
||
|
||
**A `<style>` injected into a live page is eaten by the theme's own fence.** Playwright's
|
||
`addStyleTag` appends a `<style>` to `<head>`; `fs-sheets.js` observes that, re-hosts it into
|
||
`@layer theme` and scopes its selectors to the current view. A rule aimed at the chrome then matches
|
||
nothing and its `!important` has nothing left to win, so the screenshot is byte-identical to the one
|
||
without the rule — a silent zero that reads as "the effect is invisible". Three PNGs shot this way
|
||
shared one sha256. Inject through a constructed sheet instead, which is not a DOM node and is not
|
||
observed:
|
||
|
||
```js
|
||
const s = new CSSStyleSheet(); s.replaceSync('* { box-shadow: none !important }');
|
||
document.adoptedStyleSheets = [...document.adoptedStyleSheets, s];
|
||
```
|
||
|
||
Whichever way it goes in, read the property back with `getComputedStyle` in the same evaluate and
|
||
print it beside the measurement: `blur(12px)` turning into `none` is what proves the variant is a
|
||
variant. A control that changes something obvious — a transparent bar — separates a broken injection
|
||
from an effect that genuinely does not show.
|
||
|
||
**Playwright's JS coverage does not accumulate across page loads, whatever `resetOnNavigation`
|
||
says.** `startJSCoverage({ resetOnNavigation: false })` around a fifteen-page run and one
|
||
`stopJSCoverage()` at the end returns the LAST document only — measured, 30 entries with `fs-fit.js`
|
||
appearing once after fifteen full loads, and `fs-axes`, `fs-assets`, `fs-appearance`, `fs-overview`
|
||
absent entirely because the last page does not load them. Every "never ran" number taken that way is
|
||
an artefact. Start and stop the coverage around EACH scenario and merge outside the browser, keyed
|
||
on `file|functionName|startOffset`, counting a function as live when any scenario ran it: the same
|
||
run then reports `fs-sheets` at 3 dead functions rather than 27.
|
||
|
||
Three things make a coverage sweep of this theme lie even when the merge is right, and each cost a
|
||
pass:
|
||
|
||
- **The stands run `data-layout="top"`.** `fitShell()` returns before `shellGeometry()` in that
|
||
layout, so the whole sidebar branch reads as dead. Set `fs-layout` to `sidebar` in a scenario of
|
||
its own.
|
||
- **The Appearance panel is its own page now** (`/admin/system/footstrap`) — before it had a route
|
||
it was a tab INSIDE the System page, whose own selects came first in document order, and
|
||
`page.$$('select')` picked Log level and Language while the axes went untouched. The page still
|
||
has selects of its own (Layout, Theme, Palette, …), so find the row by its label rather than
|
||
position — and the labels are translated, so match the rendered text, not the English source
|
||
string.
|
||
- **A branch can be unreachable by engine, not by code.** `fs-fit.js` takes the non-anchoring path
|
||
only where the engine does not anchor; `localStorage.fsEngineAnchor = 'off'` is the switch that
|
||
reaches it from Chromium.
|
||
|
||
**When a live gate goes red, build the PARENT COMMIT.** Package it, install it the same way, run the
|
||
same narrowed check. A finding that reproduces there did not come from the change under test. That is
|
||
how the anchor regression in 0.14.3 was pinned to one commit out of thirty-seven, and how both
|
||
findings above were shown to belong to their apps.
|
||
|
||
**`owlab up` alone leaves `luci.main.mediaurlbase` at `/luci-static/bootstrap` on both stands, so a
|
||
probe run right after boot silently measures the STOCK theme instead of this one.** `up` only starts
|
||
the containers; it is `owlab sync <stand>` that installs the package and points the router at it. A
|
||
task-0145 probe run straight after `owlab up` reproduced nothing because the pill it was reading was
|
||
`/luci-static/bootstrap`'s own, unmodified by anything in this tree. `owlab exec <stand> -- uci get
|
||
luci.main.mediaurlbase` tells the two apart: `/luci-static/bootstrap` means sync never ran (or the
|
||
value was reset for a stock-vs-theme comparison and never put back), `/luci-static/footstrap` means
|
||
the stand is actually measuring this theme.
|
||
|
||
**A mouse click parks focus and re-seats sequential navigation, so a probe that clicks before
|
||
pressing Tab reports the skip link unreachable.** Whatever the click landed on becomes
|
||
`document.activeElement`, and every Tab after it walks the sequence relative to THAT element, not
|
||
from the top of the document — a click anywhere past the skip link in tab order puts it behind
|
||
everything the click already skipped. Start the walk from the document, not from a click:
|
||
|
||
```js
|
||
document.activeElement.tagName // 'BODY' right after a fresh load — Tab from here reaches
|
||
// the skip link first; anything else means a click already moved it
|
||
```
|
||
|
||
**Collapsed submenus carry an ordinary computed `display` on their links while the parent `ul` is
|
||
`display: none`, so a probe reading `getComputedStyle(link).display` sees them as laid out.** The
|
||
`display: none` sits on the ancestor `ul`, not on each `<a>`, and `getComputedStyle` answers only for
|
||
the element it is asked about — a collapsed link can read `display: flex` and still have a zero
|
||
rect. Filter on the box, not the property: `link.getClientRects().length` is `0` for every element
|
||
inside a `display: none` ancestor regardless of what its own `display` says.
|
||
|
||
**`$?` is unreliable in this WSL bash** — `false; echo $?` prints `0`. What sits between the two
|
||
commands was not isolated this session; the workaround is not diagnosing it. Read each tool's own
|
||
printed verdict instead — PASS/FAIL text, a finding count, `bg-wait.sh`'s own `exit: N` line — never
|
||
the shell's exit code after the fact. This is the worst trap on this page because it fails
|
||
*silently*: a chain that trusts `$?` reports a red gate as green with nothing in the log to say so.
|
||
|
||
**On System → System, `button.cbi-button-apply` selects "Скопир. из браузера" (Copy from browser),
|
||
not the Apply-changes control.** The page renders more than one element carrying that class, and the
|
||
Apply control itself is a `.cbi-dropdown` widget, not a `<button>`: the selector that actually reaches
|
||
it is `.cbi-page-actions .cbi-dropdown.cbi-button-apply li[data-value="0"]`. A repro battery keyed on
|
||
the bare class name clicked the wrong element on every run and reported NOT REPRODUCED for a fault
|
||
that was real.
|
||
|
||
**`npm`, `python3` and any `*.sh` gate cannot run from the Windows side of this checkout at all** —
|
||
not merely degraded the way the `.cmd`-shim and `EFTYPE` notes above describe. `npm run smoke` dies
|
||
with `EFTYPE: spawnSync … build-css.sh`, and `sh tools/check-acl.sh` resolves `python3` mid-script to
|
||
the Microsoft Store alias. Run these under WSL, with node put on `PATH` by hand — a non-login
|
||
`bash -c` never sources nvm's shim on its own:
|
||
|
||
```sh
|
||
export PATH="$HOME/.nvm/versions/node/v24.12.0/bin:$PATH"
|
||
```
|
||
|
||
**A leftover `dist/` in the checkout can be labelled with a version it is not, and a size comparison
|
||
against it moves in the wrong direction.** A leftovers audit found one stamped `0.14.11-r1` that
|
||
built to 69,945 B / 73,649 B (CSS/JS) against the tag's true 69,475 B / 73,186 B — a stale artefact
|
||
from a run that never got a clean rebuild after later commits landed, still carrying the old
|
||
package's name. Comparing a new change against that directory would have read as the package
|
||
shrinking when it was in fact growing. The reliable baseline is a fresh build from the tag itself,
|
||
not whatever is sitting in `dist/`:
|
||
|
||
```sh
|
||
git archive <tag> | tar -x -C /clean/checkout && (cd /clean/checkout && ./tools/stage.sh)
|
||
```
|
||
|
||
**A dead feed no longer reds `owrtsnap`'s boot — owlab 0.6.1 made the refresh partial instead of
|
||
fatal, closing the trap this note used to teach around.** Through owlab 0.5.3, stage-3 opened
|
||
`apk update`/`opkg update` under a bare `set -eu`, so one unreachable feed took the whole install
|
||
down before owlab's own per-package loop — already tolerant of a package missing from a feed — got a
|
||
turn; the snapshot rootfs pins its kmods index to a kernel hash the feed's retention window keeps only
|
||
a handful of, so an image a few weeks old 404s on that one sub-index on every run with no code change
|
||
involved (owfeed/owlab#18, closed within the hour; `live`/`anchors` had carried a containment for it,
|
||
`live-snapshot`, `continue-on-error` and absent from `release`'s `needs`, task 0172/0173). `owlab`
|
||
0.6.1's `internal/pkgmgr.UpdateShell` now continues the refresh once at least one feed has answered
|
||
and only aborts when none has — reproduced locally (`owlab up owrtsnap`, `owlab install owrtsnap
|
||
dist/noarch/luci-theme-footstrap-*.apk`, task 0173) against a freshly downloaded `owlab 0.6.1`
|
||
binary, not the WSL install's drifted dev build: the identical 404 still prints
|
||
(`ERROR: wget: exited with error 8` / `unexpected end of file` on
|
||
`kmods/6.18.33-1-70e27cfe28d8cb55760256504e7c02fe/packages.adb`), but the router boots and the
|
||
package installs anyway, and the log now names the gap rather than staying silent about it —
|
||
`owlab: apk update: partial refresh, 7 feed(s) read, 10206 packages available; the feed above did
|
||
not answer and its packages will be missing`. `owrtsnap` is back in `live`/`anchors` and
|
||
`live-snapshot` is gone; what is worth knowing going forward is the shape of that line in a log, not
|
||
how to survive its absence.
|
||
|
||
**`install.sh` carried the identical trap one layer down, and "packages available > 0" turned out
|
||
not to be the signal that separates a partial refresh from a total one.** Task 0175, run 34112646188:
|
||
`install-check` red on `owrtsnap` at `apk update`, same dead kmods sub-index as above, but this time
|
||
inside the installer's own `set -e`, which had no per-package loop after it to absorb the exit —
|
||
every install on that stand died before the theme was ever fetched. The obvious fix (treat apk's
|
||
own "N unavailable, M stale; K distinct packages available" line as fine whenever K > 0) is wrong:
|
||
with every feed unreachable, apk still printed `8 unavailable, 0 stale; 136 distinct packages
|
||
available` and exited non-zero — those 136 are rows already in the **installed** database, not
|
||
anything the refresh just read, so K is nonzero on a total failure too. What actually separates the
|
||
two is the `N unavailable` count against how many feeds were **configured** to begin with (counted
|
||
from `/etc/apk/repositories` + `/etc/apk/repositories.d/*.list`): `N < configured` means at least one
|
||
feed answered, `N == configured` means none did. opkg prints no such summary line and is affected
|
||
identically (exit 1 with one bad feed of eight on 24.10.8, exit 7 with the network cut), so the same
|
||
comparison is drawn there by counting `Failed to download` lines against the configured
|
||
`distfeeds.conf`/`customfeeds.conf` entries instead. Neither manager's own exit code decides this in
|
||
either implementation — `install.sh`'s `feed_refresh()`.
|
||
|
||
**That tolerance turned out to be too even-handed: a security review (task 0176) found it let this
|
||
project's OWN feed, `repo.owfeed.org`, be the one silently skipped.** `repo.owfeed.org` is a distinct
|
||
host from every stock OpenWrt feed, so an on-path/DNS attacker can blackhole it alone while the rest
|
||
answer — `_bad < _total`, the old code returned 0, and the script went on to install whatever
|
||
owfeed-packages index apk/opkg already had cached from a prior run while printing "[+] Installed …".
|
||
Reproduced live rather than argued: on `owrt2512` (9 configured feeds) with a `127.0.0.1
|
||
repo.owfeed.org` `/etc/hosts` entry and the 8 stock feeds left open, `apk update` itself reported the
|
||
router's cached copy as merely `stale` (`0 unavailable, 1 stale; 11286 distinct packages available`,
|
||
exit 1) — a shape the OLD counter never even tolerated (`_bad` reads 0, not 1, so the pre-fix code
|
||
already fell through to the generic failure here) but a fresh-index router would read as `unavailable`
|
||
and the old code WOULD tolerate. `feed_refresh()` now checks, before the tolerance, whether `$FEED_HOST`
|
||
— the literal string this same script writes into the repository line a few lines below, not
|
||
`$FEED_NAME` or any label an admin could rename — appears in a failure line (apk: `ERROR:`/`WARNING:`;
|
||
opkg: `Failed to download`, both of which print the full failing URL in real router output, confirmed
|
||
on both managers below). If it does, the refresh fails closed with a message naming this project's own
|
||
feed specifically, regardless of how many other feeds answered. Verified on live stands, all four
|
||
shapes: `owrtsnap`'s real dead kmods sub-index (unrelated host) still tolerates and installs, unchanged
|
||
from the paragraph above; `owrt2512` (apk) and `owrt2410` (opkg) with `repo.owfeed.org` blocked and
|
||
every stock feed open now fail closed with `` `apk/opkg update` could not reach https://repo.owfeed.org
|
||
— this project's own feed`` and install nothing, where the old code's tolerance would have gone on to
|
||
`apk add`/`opkg install` against a stale cache; both routers with every feed healthy install clean, no
|
||
warning; `owrt2512` fully disconnected from its docker network still fails the way it always did,
|
||
naming the unreachable host and refusing rather than claiming success.
|
||
|
||
**A verification trap worth naming for the next session: opkg's OWN counting is looser than apk's, in
|
||
the other direction.** Blocking `downloads.openwrt.org` (the host behind all 7 of `owrt2410`'s stock
|
||
feeds, `/etc/opkg/distfeeds.conf`) while `repo.owfeed.org` stayed open made `feed_refresh()`'s pre-fix
|
||
`_bad` counter read 14 against 8 configured — opkg logs each unreachable feed on TWO lines that both
|
||
match the substring `Failed to download` (`*** Failed to download the package list from <url>` and
|
||
` * opkg_download: Failed to download <url>, wget returned N.`), so `grep -c 'Failed to download'`
|
||
double-counts every failure. `_bad >= _total` therefore reads as "none answered" even when most did,
|
||
and the refresh fails closed rather than tolerating — safe (it never installs from a worse index than
|
||
it would otherwise refuse), but it makes opkg's tolerance narrower than the comment above claims and
|
||
narrower than apk's, which reads its own reported `N unavailable` count rather than grepping its log.
|
||
Not this task's fix (`install.sh`'s boundary here is the own-feed decision alone, not the general
|
||
counting shape) — flagged for whoever next touches `feed_refresh()`'s opkg branch.
|
||
|
||
**Installing npm packages from WSL instead of from Windows leaves every gate looking broken, when
|
||
only the install is.** An install run from WSL writes `node_modules/.bin/` as POSIX symlinks
|
||
(`eslint@ -> ../eslint/bin/eslint.js`), which Windows cannot execute — every `npm run <gate>` from
|
||
Git Bash then dies with `'eslint' is not recognized as an internal or external command`, and the git
|
||
`pre-push` hook fails the same way, reading exactly like a red gate rather than a bad install.
|
||
Installed from Windows instead, npm writes three wrappers per package (`eslint`, `eslint.cmd`,
|
||
`eslint.ps1`), and the extensionless one is a sh script WSL can run too — one install serves both
|
||
sides. The install itself has to be invoked as `node "C:\Program Files\nodejs\node_modules\npm\bin\npm-cli.js"
|
||
ci --ignore-scripts` from Git Bash: `cmd.exe /c npm ci` from Git Bash silently does nothing (MSYS
|
||
rewrites `/c` into a path before `cmd.exe` ever sees it), and PowerShell refuses `npm` outright (next
|
||
trap). `--ignore-scripts` is deliberate — the browsers the gates need live in WSL, and a second
|
||
Windows-side copy is a few hundred MB nobody runs. Verified this session: `npm run lint` passes from
|
||
both Git Bash and WSL off one Windows-side install.
|
||
|
||
**A fresh Windows-side `npm ci` needs `chmod +x node_modules/.bin/*` from WSL before WSL can run any
|
||
of it, and the fix does not survive the next install.** `/mnt/c` on this machine mounts
|
||
`metadata,umask=0077,fmask=0177` (`/etc/wsl.conf`), so every file npm just wrote from Windows arrives
|
||
world-unexecutable — the sh wrappers the trap above depends on are present and correct, but
|
||
`npm run lint` inside WSL still dies, this time with `sh: 1: eslint: Permission denied`. Because
|
||
`metadata` is on, one `chmod` sticks across reboots — but not across a fresh `npm ci`, which writes
|
||
new files under the mount's default mode again. Diagnose with `ls -l node_modules/.bin/eslint` (mode
|
||
`0600` is the trap) and `mount | grep " /mnt/c "` (confirms the `fmask`).
|
||
|
||
**PowerShell is not a fallback for the symlink trap above — `npm` does not run there at all on this
|
||
machine.** `npm.ps1 cannot be loaded because running scripts is disabled on this system` is
|
||
PowerShell's own execution policy refusing the wrapper script outright, unrelated to the WSL symlink
|
||
issue and not fixed by installing from the "right" side. Git Bash and `cmd` (called directly, not
|
||
through `cmd.exe /c` from Git Bash) are unaffected.
|
||
|
||
**`${PIPESTATUS[0]}` reads back empty in this WSL bash, the same way `$?` is already unreliable here
|
||
— and it fails silently, not loudly.** A piped `npm run check | tee log.txt; echo
|
||
"CHECK_EXIT=${PIPESTATUS[0]}"` printed `CHECK_EXIT=` — empty, not a number — and the empty string
|
||
read as "not the literal failure text" to whatever was watching it, while the run had actually
|
||
failed on the size budget. The gate's own printed output was the only place the failure showed.
|
||
Judge a WSL gate by what it printed, never by a captured status of any kind — `$?`, `PIPESTATUS`, or
|
||
otherwise.
|
||
|
||
**A `geometry|fs-content` finding whose offset equals the gap between two adjacent entries in the
|
||
gate's own `WIDTHS` is a staleness race, not a layout break — re-sample at 620 ms before believing
|
||
it.** `live-audit` samples `contentWidth()` at 220 ms after each `setViewportSize`; `fitChrome()`
|
||
steps aside for the whole `SCROLL_IDLE` window (400 ms, `fs-fit.js`) whenever `fit.scrolling()`
|
||
answers yes, and a resize starts that window too, so a sample taken inside it reads the PREVIOUS
|
||
width, not the one the gate just set. Task staleouter: `owrt2410 /admin/status/vnstat2/config | 568
|
||
| geometry | fs-content (-178)` — `-178` is exactly `568 - 390`, the gap between those two widths in
|
||
`live-audit`'s own list, and the same shape held at every step (`-70` at 320→390, `-256` at
|
||
1024→1440). Confirmed with `probe2.mjs`/`probe3.mjs` (`../tmp/task-vnstat/`): `model=358 real=536
|
||
scrolling=true` at 220 ms, `off=0` once resampled past 620 ms. Fixed in `fs-chrome.js`'s
|
||
`contentWidth()`, which now re-reads the window's width on every call rather than trusting the last
|
||
fitter's cache (`docs/chrome.md`, "`data-narrow`: ..."); do not "fix" a live recurrence of this shape
|
||
by making the gate sample later instead — that would hide the same staleness from a real reader.
|
||
|
||
**`TaskStop` on a background command kills its outer shell, not a `sh script.sh` it started.** A
|
||
stopped chain kept waiting as its own process and would have raced its replacement to the same
|
||
commit. After stopping one, check `ps -ef | grep scratchpad` on the Windows side and `pgrep -af '^sh
|
||
/mnt/c/.*scratchpad/'` in WSL; kill the child by its exact command, and give a replacement a guard
|
||
that refuses to start while the old one lives.
|
||
|
||
**A process search inside `sh -c '…'` finds the shell running it.** `pgrep -f "npm run check"`, `ps |
|
||
grep build-css` and `pgrep -f "sh tools/ci-local"` all matched their own `sh -c`, whose command line
|
||
contains the pattern: two launchers waited forever and a CSS-build check reported a build that did not
|
||
exist. Anchor the pattern (`^node tools/`, `^npm run check`) and run the check from a script file, or
|
||
wait on a file's final line instead.
|
||
|
||
**Git Bash rewrites `/mnt/c/...` arguments passed to `wsl.exe` into `C:/Program Files/Git/mnt/c/...`**,
|
||
and the script "does not exist". `export MSYS_NO_PATHCONV=1` before `wsl.exe -e sh /mnt/c/...`.
|
||
|
||
**A timing finding from `scroll-anchor` with a `longest frame gap` near its `landed Nms` is the page
|
||
not producing frames, not the theme deciding late.** Read the late trail beside it: the theme's
|
||
`wrote-…+T` is when the correction happened. A `T` far below `landed` with a gap that ends at `landed`
|
||
was the runner (task painted, `docs/anchoring.md`); a `settle` that itself sits at the end of the gap
|
||
was the theme waiting for that frame (task stall).
|
||
|
||
## The test matrix
|
||
|
||
- **Pages**: Status/Overview (tables, ifacebox), Network/Interfaces (zonebadge, modals),
|
||
Network/Firewall (section table, dropdown), System/Software (progress), Realtime graphs (SVG),
|
||
login/logout, Reboot. Plus the apply/rollback confirmation sheet, which `ui.js` draws over the
|
||
theme and which custom z-indexes often break.
|
||
- **Modes**: light/dark/auto, both layouts, all three palettes, a narrow window, long hostnames and SSIDs.
|
||
- **There are no breakpoints for "does it fit" — it is a MEASUREMENT.** Drag the window with the
|
||
mouse; do not test specific widths. Why: [chrome.md](chrome.md).
|
||
|
||
## Building a package locally
|
||
|
||
```sh
|
||
./tools/stage.sh && owfeed build # both formats, seconds, no toolchain
|
||
```
|
||
|
||
That is exactly what CI does. `luci-theme-footstrap/build-apk.sh` is a different path — a build
|
||
through the OpenWrt SDK, which exists to prove the theme is still buildable by its Makefile,
|
||
`luci.mk` and jsmin for someone who has never heard of owfeed. Releases do not come out of it. Both
|
||
are described in [ci.md](ci.md).
|
||
|
||
Through owlab:
|
||
|
||
```sh
|
||
owlab build # target taken from the first router in owlab.yaml
|
||
owlab build --arch x86_64 --release 25.12.4
|
||
owlab install owrt2512 dist/luci-theme-footstrap-*.apk
|
||
owlab exec owrt2512 -- 'apk del luci-theme-footstrap'
|
||
```
|
||
|
||
An SDK build is not the same as `sync`, and the difference is measurable: a real build runs the
|
||
sources through the minifiers, so code that works unminified and breaks minified is invisible until
|
||
you build a package. Without that step, the first person to see it is a user.
|
||
|
||
On Apple Silicon this runs under emulation — every `openwrt/sdk` tag is `linux/amd64`. owlab warns
|
||
before it starts.
|