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

1527 lines
97 KiB
JavaScript

#!/usr/bin/env node
/* The reader's place, on an engine that will not keep it.
*
* A poll tick changes the height of things above the reader and the page below moves. Chromium,
* Firefox and — since WebKit 26 — Safari all hide that with scroll anchoring of their own
* (docs/anchoring.md, `ENGINE_ANCHORS`); an older WebKit and every iPhone before it had none, and a
* current one can still get the COLLAPSE case wrong (task wkanchor, and the `tick` row below).
*
* The theme does that job where nobody else does (fs-fit.js, ENGINE_ANCHORS). This gate holds every
* half of that sentence, since each can break silently:
*
* held with the engine's anchoring suppressed AND the theme's fallback forced on, a growth
* above the fold must move the reader by no more than a pixel or two.
* floor the same refill with the correction switched off (`fsAnchor = 'off'`): the content
* column holds its height between ticks, so the document never gets short enough to be
* clamped. It can only be measured with the other half silent.
* swapped a section refilled the way `dom.content()` refills one — emptied, then filled again —
* must leave the reader where they were too, and PROMPTLY: sampled every frame for
* `SWAP_WINDOW` (900ms, well past lateDrift()'s measured 419-420ms), a drift that never
* falls back under tolerance is one finding ("never came back") and one that does but
* past `LATE_MS` (200ms — task latenet's four-way ablation put the theme's own fallback
* at 7-36ms and an engine's late residual at 419-420ms, and this sits between the two
* with margin either side) is another ("corrected late") — a single fixed-delay read
* cannot tell those apart, and used to fold them into the same `moved 0px`. The moment in
* between the empty and the refill has no height, and `held` cannot see that: a growth
* only ever inserted never collapses anything.
* not twice with the engine's anchoring left alone, the same growth must move the reader just as
* little — a fallback that also ran there would throw the page the other way.
* quiet while the reader is SCROLLING the theme must not correct at all: a correction landing
* inside a flick is itself a jump.
* tick while the reader is PARKED and nothing is inserted, a REAL poll tick — the engine's own
* anchoring included — must not move the offset either. `held` closes inside 800ms and
* `quiet` discards a still 400ms; a correction landing between those windows (WebKit's
* own, measured 421ms) passed both and is what this row exists to catch.
* repeat `swapped` again, `REPEAT_TIMES` (3) times running on the same section — the shape
* every case above is structurally blind to: each closes inside one refill, and
* `_engineTrusted` (fs-fit.js) only ever moves on the SECOND miss, so a switch that
* flips wrongly — an engine still anchoring correctly loses the theme's trust in it two
* ticks later — is invisible to anything that never performs a second one (task
* missrule). Both the reader's position after every refill and the trust flag itself
* are read; the flag going false while the reader never moved is its own finding,
* separate from the reader actually moving. It measures on the SAME section SWAP just
* measured — `window.__fsRepeatSection`, not a second search — because a second search
* run after SWAP's own two refills can land on a worse candidate than the one SWAP
* already proved reaches the scroller (274/274 cells measured nothing before this was
* fixed; SWAP's own coverage in the same run is the comparison).
*
* The growth in `held`/`swapped`/`quiet` is inserted rather than waited for: a real tick depends on
* what the router's radios are doing, and a gate that only fails when a station happens to join is
* not a gate. `tick` is the one case that measures a real one anyway, for the fault only a real tick
* can show.
*
* node tools/scroll-anchor.mjs [--only owrt2512] [--engines chromium,firefox] [--full]
* … [--width 390] [--layout top] [--bail] one cell, stopping at the first finding
*
* The default sweep crosses the axes that were measured to change its answer; `--full` adds the
* ones that were measured not to (see SCROLLERS and DENSITIES) and is what CI runs on a push.
*
* Needs a running owlab router (docs/development.md). */
import * as pw from 'playwright';
import { stands, login, requireStands, sealToRouter, pairStands } from './lib/stands.mjs';
const arg = (name, dflt) => {
const i = process.argv.indexOf('--' + name);
return i === -1 ? dflt : process.argv[i + 1];
};
const ENGINES = arg('engines', 'chromium').split(',').map((s) => s.trim()).filter(Boolean);
/* `--full` restores the axes this sweep used to cross in full: every width against every layout,
* and all three densities. Measured on 132 cells, they multiply the sweep without dividing its
* answers — see SCROLLERS and DENSITIES. CI crosses them on a push and a tag, where an hour is
* affordable and a narrowing that turns out to be wrong is caught before a release. */
const FULL = process.argv.includes('--full');
/* Stop at the first finding. A full sweep is 144 cells and tens of minutes, so an answer that only
* arrives at the end is one nobody can iterate against — a fix attempt cost a whole sweep to learn
* it had failed in the third cell. CI never passes this: how WIDE a fault is is half of what the
* sweep says. */
const BAIL = process.argv.includes('--bail');
/* A local iteration loop, not the sweep CI runs: the first stand only, the Overview page alone, the
* default width/layout/density axes (task sweepspeed). Says what it left out on every run, loudly,
* so a clean `--quick` is never mistaken for a clean sweep — the same reason a page that never
* opened is reported apart from a pass rather than folded into one. `--only`/`--page` still override
* when given explicitly; CI never sets this flag. */
const QUICK = process.argv.includes('--quick');
/* Three shapes, because the shape decides what the theme can anchor ON, and each of these breaks
* differently:
*
* overview sections whose BODIES a poll refills (`.cbi-section > div`, every 5 s). The only
* page here where a refilled block is ever entirely above the reader, so the
* `swapped` scenario is measured here and nowhere else.
* dhcp a table under a section whose ROWS a poll replaces — what `L.ui.Table` does, and
* the shape a third-party app is most likely to emit. The leases are stable (32 and
* 36 rows on the stands), which is what makes it a repeatable cell.
* processes one table, a direct child of `#view`. The climb out of it used to land on the host
* and give up, so nothing held the reader at all, on every engine, while a gate that
* only opened the first shape passed throughout. It does not poll and its single
* block is taller than the page scrolls (6397px against 6108px on owrt2512), so it
* proves the CLIMB and never the swap.
*
* `--page a,b` overrides. Two pages were measured and NOT taken, which is the cheaper half of this
* list to lose:
*
* wireless 812px of scroll at 390px wide and 0 at 1440, so every cell of it skipped.
* connections the realtime page. Its length follows the conntrack table, so the same cell
* reported 958px of scroll in one run and 400px in the next — a cell that is not
* repeatable is not a gate. And the block this sweep swaps there is not the block
* its poll replaces: emptying the section `div` moved the reader 252px against a
* 120px pad while the block itself measured the same height before and after
* (`grewOnItsOwn` 0), the same way swapping `.fs-ovl` deletes three sections no poll
* touches. What produced the other 132px was not established. */
const PAGES = arg('page', QUICK ? '/admin/status/overview'
: '/admin/status/overview,/admin/network/dhcp,/admin/status/processes')
.split(',').map((p) => p.trim()).filter(Boolean);
/* WIDTH AND LAYOUT ARE ONE AXIS FOR HOLD/SWAP/QUIET, and it has two values, not four: what those
* three measure is which element the correction has to scroll, and only one of the four
* combinations scrolls anything but the window:
*
* 1440 side → #maincontent 1440 top → window 390 side → window 390 top → window
*
* `tick` (task wkanchor) does not fit that reduction: at 390 wide both `side` and `top` collapse the
* sidebar into the same bar chrome and scroll the same window, but `data-layout` itself is still
* either value underneath that collapse, and something in what THAT leaves in the DOM is what
* decides whether WebKit's own scroll anchoring misfires — measured on owrt2512/Overview, one real
* tick with the reader parked: 0px peak-to-peak at 390 side, 41px at 390 top, same page, same tick,
* same engine. So `top` stays in the default axis for that one case rather than only under `--full`
* — a PR that reintroduces this fault would otherwise only be caught on a push or a tag. */
const SCROLLERS = (() => {
const all = FULL
? [ 1440, 390 ].flatMap((width) => [ 'side', 'top' ].map((layout) => ({ width, layout })))
: [ { width: 1440, layout: 'side' }, { width: 390, layout: 'side' }, { width: 390, layout: 'top' } ];
/* `--width 390 --layout top` narrows the sweep to the cell a finding names, which is what turns
* a fix attempt from a sweep into a minute. Neither is set in CI, where the axes above are the
* contract. */
const w = arg('width', ''), l = arg('layout', '');
return all.filter((c) => (!w || String(c.width) === w) && (!l || c.layout === l));
})();
/* One density. The axis moves the geometry — type and spacing scale, so a section is a different
* height and the fold falls elsewhere — but it does not move the ANSWER: across all 72 Overview
* cells of a full sweep, normal, compact and large reported the same two outcomes and nothing else
* (`anchor on` clamped 0-1px with the reader still; `anchor off` clamped 59-60px with the reader
* moved 60-61px). Three densities measured one thing three times. `--density a,b` widens it. */
const DENSITIES = arg('density', FULL ? 'normal,compact,large' : 'normal')
.split(',').map((d) => d.trim()).filter(Boolean);
const GROWTH = 120;
/* a rect edge lands on a fraction; two pixels is not a jump */
const TOLERANCE = 2;
/* how many REAL poll ticks TICK insists on before it will trust a 0px reading — see the note on
* TICK below for why fewer would pass a fault this gate exists to catch */
const TICK_COUNT = 3;
/* How late a correction may land before the drift it leaves is itself a finding — separate from
* SWAP simply never seeing one at all. Measured (task latenet's four-way SWAP ablation): the
* theme's own fallback (`applyAnchor` via `scheduleAnchor`) lands 7-36ms after a refill; an engine
* that anchors but did not keep the reference through THIS refill is caught by the theme's
* `lateDrift()` (fs-fit.js), which used to answer one rAF plus `SCROLL_IDLE` later — 404-422ms,
* measured on the `engine DECLINES` cell below across three engines and three stands. 200ms sat
* roughly midway between those two clusters with well over 150ms of headroom either side, so
* ordinary jitter on a loaded runner could not cross it, and it is what a maintainer reading a
* report would call the difference between "instant" and "a jump".
*
* Task late419 moved `lateDrift()` itself rather than this number: where the page was already
* still when the refill landed it now waits the next frame instead, and the same cells read
* 8-61ms. The threshold is unchanged and still separates the two shapes — what it now separates
* is "the theme corrected on the next frame" from "the theme waited out a reader who was moving",
* which is the only case left above it. */
const LATE_MS = 200;
/* How long SWAP watches a refill before it may call a correction "never came". Long enough to hold
* lateDrift()'s slow path (404-422ms, the reader in motion) with real margin for a loaded runner;
* short enough that a cell that truly never corrects does not sit idle. NOT shortened to match
* task late419's faster path: the window has to outlast the SLOWEST correction the theme can
* legitimately make, or "late" and "never" become the same reading again. */
const SWAP_WINDOW = 900;
/* How many refills REPEAT performs on the SAME section, back to back — see the note on REPEAT
* below. `LATE_MISS_LIMIT` (fs-fit.js) is 2: one is headroom for a one-off, a second is the count.
* Two refills only ever REACHES the limit on the second one; a third is what proves the flag it
* trips STAYS tripped (or, on a correctly-anchoring engine, never trips at all) rather than
* resetting itself on the very next tick — task missrule. */
const REPEAT_TIMES = 3;
/* Park the reader and wait for the THEME to notice, rather than for a stopwatch.
*
* A probe scrolls the page and then has to let the theme settle: fs-fit treats the reader as moving
* until SCROLL_IDLE (400 ms) of stillness and only then remembers where the page stands — the
* reference every correction is measured against. A flat wait is a race WebKit loses: a programmatic
* `scrollTop` write there does not fire `scroll` on the next frame, it arrives up to 1.2 seconds
* later, so the sampler had not begun settling when the probe grew the page (measured: three
* findings on WebKit, none on the other two engines, with the theme identical).
*
* So the wait is on the EVENT and then on the idle window, both bounded. */
/* Runs in the page: park the reader, grow something above them, report what they saw.
*
* The router's poll is held here too, though this case only INSERTS. It used to run underneath, on
* the reasoning that a pad the probe adds survives a tick landing beside it — and the pad does. The
* REFERENCE does not: a tick calls `dom.content()` on the section the reader happens to be over, and
* the element this probe measured the page by is gone before the second read. Measured on the tag
* build: `the reader's element was replaced mid-measurement` on webkit/Overview at 1440 side, on
* owrt2512 and owrt2410 both, and on no other engine — WebKit is slow enough here to lose that race
* repeatably. The growth is synthetic in either case, so a real tick underneath adds nothing but the
* chance of measuring nothing. */
/* BELOW — content arriving UNDER the reader must not move them. Every other case grows content above the
* reader: HOLD inserts at the top of #view, SWAP and REPEAT refill a body entirely above the viewport. So a
* theme that scrolls the reader for growth below them passed all of them — measured, 0f298ef: a 120 px refill
* inside a floored box below the viewport moved the offset +120 and the reader -120 on firefox and chromium,
* /admin/network/dhcp and Overview, engine anchoring on, the stack naming settle() in lateDrift() writing the
* whole growth as a blind-witness correction. The pad goes INSIDE the floored box, a child deep, because that
* is the shape a LuCI refill has and the shape that change mis-read. */
const BELOW = async (growth) => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const view = document.getElementById('view');
if (!view || !view.firstElementChild) return { skip: 'nothing to grow' };
const mc = document.getElementById('maincontent');
const flow = mc ? getComputedStyle(mc).overflowY : '';
const sc = (flow === 'auto' || flow === 'scroll') ? mc : null;
const pos = () => (sc ? sc.scrollTop : window.scrollY);
const vh = sc ? sc.clientHeight : window.innerHeight;
const room = (sc ? sc.scrollHeight - sc.clientHeight : document.documentElement.scrollHeight - window.innerHeight);
if (room < 600) return { skip: 'page too short to scroll' };
const poll = (window.L && window.L.Poll) || null;
if (poll && typeof poll.active === 'function' && poll.active()) poll.stop();
const at = Math.round(room * 0.25);
const target = sc || window;
const landed = new Promise((res) => {
let done = false;
const on = () => { if (!done) { done = true; target.removeEventListener('scroll', on); res(); } };
target.addEventListener('scroll', on, { passive: true });
setTimeout(() => { if (!done) { done = true; target.removeEventListener('scroll', on); res(); } }, 2500);
});
if (sc) sc.scrollTop = at; else window.scrollTo(0, at);
await landed;
const fit = await window.L.require('fs-fit').then((m) => m, () => null);
if (fit && typeof fit.restAt === 'function')
for (let i = 0; i < 160; i++) { if (fit.restAt() === pos() && !fit.scrolling()) break; await wait(25); }
await wait(600);
/* a floored box entirely below the viewport, the kind a poll refills */
const scTop = sc ? sc.getBoundingClientRect().top : 0;
const box = Array.from(view.querySelectorAll('[data-fs-floor]'))
.find((b) => b.getBoundingClientRect().top - scTop > vh + 40 && b.offsetHeight > 60);
if (!box) return { skip: 'no floored box below the reader' };
const markAt = (y, x) => {
for (const el of document.elementsFromPoint(x, y)) {
if (el === view || !view.contains(el) || box.contains(el)) continue;
let stuck = false;
for (let a = el; a && a !== view; a = a.parentElement)
if (getComputedStyle(a).position === 'sticky') { stuck = true; break; }
if (!stuck) return el;
}
return null;
};
const h = window.innerHeight || 800;
const vw = window.innerWidth || 800;
let mark = null;
for (const fy of [ 0.5, 0.4, 0.6, 0.3 ]) {
for (const fx of [ 0.5, 0.25, 0.75 ]) {
mark = markAt(Math.round(h * fy), Math.round(vw * fx));
if (mark) break;
}
if (mark) break;
}
if (!mark) return { skip: 'no content under the reader' };
const before = { pos: pos(), top: Math.round(mark.getBoundingClientRect().top) };
const w0 = (window.__fsW || []).length;
const pad = document.createElement('div');
pad.style.height = growth + 'px';
(box.firstElementChild || box).appendChild(pad);
await wait(1200);
const after = { pos: pos(), top: mark.isConnected ? Math.round(mark.getBoundingClientRect().top) : null };
const writes = (window.__fsW || []).slice(w0);
pad.remove();
return { before, after, moved: after.top === null ? null : after.top - before.top,
scrollDelta: after.pos - before.pos, boxDesc: box.id || box.className || box.tagName, writes };
};
const HOLD = async (growth) => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const view = document.getElementById('view');
/* A page that rendered at all, not one that rendered a particular way. Counting `#view`'s children
* was a stand-in for that and got it wrong: the realtime Connections page puts everything inside a
* single `.cbi-map`, so one child meant 3219px of content read as an empty page and every cell of
* that shape skipped. What "nothing to grow" has to mean is that there is nothing under `#view`;
* whether there is enough of it to scroll is the next check's question, and it asks it in pixels. */
if (!view || !view.firstElementChild) return { skip: 'nothing to grow' };
const mc = document.getElementById('maincontent');
const flow = mc ? getComputedStyle(mc).overflowY : '';
const sc = (flow === 'auto' || flow === 'scroll') ? mc : null;
const pos = () => (sc ? sc.scrollTop : window.scrollY);
const room = (sc ? sc.scrollHeight - sc.clientHeight : document.documentElement.scrollHeight - window.innerHeight);
if (room < 600) return { skip: 'page too short to scroll' };
const poll = (window.L && window.L.Poll) || null;
const polling = !!(poll && typeof poll.active === 'function' && poll.active());
if (polling) poll.stop();
try {
const at = Math.min(Math.round(room / 2), 1600);
/* park the reader and wait for the ENGINE to say the scroll happened — see the note above */
const parkAt = async (y) => {
const target = sc || window;
const landed = new Promise((res) => {
let done = false;
const on = () => { if (!done) { done = true; target.removeEventListener('scroll', on); res(); } };
target.addEventListener('scroll', on, { passive: true });
setTimeout(() => { if (!done) { done = true; target.removeEventListener('scroll', on); res(); } }, 2500);
});
if (sc) sc.scrollTop = y; else window.scrollTo(0, y);
await landed;
/* AND THEN UNTIL THE THEME SAYS IT IS STILL, which is not the same as SCROLL_IDLE elapsing.
* fs-fit starts its motion sampler on the scroll event and only remembers where the page
* stands once that sampler has been quiet for SCROLL_IDLE; in WebKit the sampler starts late
* enough that a flat wait measured the theme before it had a reference at all — the gate then
* reported a jump on every WebKit run and none on the other two engines, with the theme
* identical. Asking the theme removes the guess: `scrolling` is exported for exactly this
* kind of question. */
const fit = await window.L.require('fs-fit').then((m) => m, () => null);
/* A theme that answers but cannot say where it rested is a FAILURE, not a fallback. This
* used to be one try/catch around both, so a stripped export threw a TypeError that read as
* "no theme here" and the sweep quietly went back to the flat wait below — which is the
* WebKit flake this call exists to remove, and it measured nothing while saying nothing. */
if (fit && typeof fit.restAt !== 'function')
throw new Error('fs-fit is loaded but exports no restAt(): the sweep cannot tell when '
+ 'the theme has taken its reference, and a flat wait is not a substitute');
if (fit) {
/* until the theme has taken a reference AT THIS OFFSET. "Is it scrolling" cannot answer
* that: it says no both before the motion sampler starts and after it finishes, and in
* WebKit those are 1.5s apart — the probe grew the page in between, while the theme still
* had no reference, and the gate reported a jump on every WebKit run and none on the
* other two engines with the theme identical on all three. */
for (let i = 0; i < 160; i++) {
if (fit.restAt() === (sc ? sc.scrollTop : window.scrollY) && !fit.scrolling()) break;
await wait(25);
}
}
await wait(600); /* the still moment the theme measures from */
};
await parkAt(at);
/* The host is not a mark, and taking it as one makes this gate report a jump that is its own:
* `#view` is a `.cbi-section` gap wide enough to hit at 390px, its own top does not move when the
* pad grows INSIDE it, and a correctly compensated page then reads as -120px. Measured: on
* 25.12's Overview every point down the middle answered `#view`, so all eight runs on that
* release reported "no content under the reader" and measured nothing.
*
* So the whole STACK at the point is read rather than the topmost element — the section a grid
* gap belongs to is right underneath it — and two more rows are tried before giving up, a gap
* being a gap only at the y it was measured at. */
/* STICKY EXCLUDED FOR THE SAME REASON AS #view: a `position: sticky` element's rect.top is
* pinned to its stuck offset for as long as it stays stuck, so `after.top - before.top` stops
* being a faithful proxy for the reader's place the moment the stick state does — or does not —
* change across the measurement. Confirmed live: a sticky mark on a synthetic page reported
* moved=0 on every growth from 0 to 120px while a plain sibling at the same point moved with the
* page, so the check would silently pass however badly the theme failed there. The walk climbs
* to `view` because a plain child of a stuck header inherits the same pinned top. */
const markAt = (y, x) => {
for (const el of document.elementsFromPoint(x, y)) {
if (el === view || !view.contains(el)) continue;
let stuck = false;
for (let a = el; a && a !== view; a = a.parentElement)
if (getComputedStyle(a).position === 'sticky') { stuck = true; break; }
if (!stuck) return el;
}
return null;
};
/* THE HIT IS TRIED ACROSS THE VIEWPORT, not at three points down its middle: a mark is anything
* inside #view, and on 25.12's Overview the middle column is a grid gap for most of its height,
* so all three of those points answered `#view` and every run on that release reported "no
* content under the reader" and measured nothing — half the matrix, silently unmeasured. */
const h = window.innerHeight || 800;
const vw = window.innerWidth || 800;
let mark = null;
for (const fy of [ 0.6, 0.5, 0.7, 0.4, 0.8, 0.35 ]) {
for (const fx of [ 0.5, 0.25, 0.75 ]) {
mark = markAt(Math.round(h * fy), Math.round(vw * fx));
if (mark) break;
}
if (mark) break;
}
if (!mark) return { skip: 'no content under the reader' };
const before = { pos: pos(), top: Math.round(mark.getBoundingClientRect().top) };
/* Sliced from HERE, not from navigation — the writes `parkAt()` already made to reach this
* offset are not this measurement's business. `window.__fsW` is an init-script wrapper around
* `scrollTo`/`scrollBy`, the `scrollTop` setter and `Element.scrollTo`
* (../tmp/task-holdreg/hold-probe.mjs), read back only when a finding needs it: a -505px HOLD
* reading on firefox/owrt2512 @1440 top compact was chased a full round before before.top/
* after.top were printed at all — before.top read 503 on every local run, so after.top≈0 was
* the mark landing at the viewport top, a different event from a failed correction. */
const w0 = (window.__fsW || []).length;
const pad = document.createElement('div');
pad.style.height = growth + 'px';
view.insertBefore(pad, view.firstChild);
await wait(800);
const after = { pos: pos(), top: mark.isConnected ? Math.round(mark.getBoundingClientRect().top) : null };
const writes = (window.__fsW || []).slice(w0);
pad.remove();
return { before, after, moved: after.top === null ? null : after.top - before.top,
scrollDelta: after.pos - before.pos, scroller: sc ? 'maincontent' : 'window', writes };
} finally { /* the poll stays stopped — see the note on QUIET, which starts it again */ }
};
/* Runs in the page: a poll tick the way LuCI actually performs one — `dom.content()` empties the
* section before it refills it — and reports where that left the reader.
*
* Not the HOLD case above: HOLD inserts a pad, so the page only ever gets TALLER and the reference
* the theme keeps stays valid. A real tick passes through a moment where the section has no height
* at all, and a document that short is one the engine clamps the offset into; the section fills
* again, nobody puts the offset back, and the reader is somewhere else. */
const SWAP = async ([ growth, tol, lateMs, winMs ]) => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const view = document.getElementById('view');
if (!view) return { skip: 'no view' };
const mc = document.getElementById('maincontent');
const flow = mc ? getComputedStyle(mc).overflowY : '';
const sc = (flow === 'auto' || flow === 'scroll') ? mc : null;
const pos = () => (sc ? sc.scrollTop : window.scrollY);
const room = (sc ? sc.scrollHeight - sc.clientHeight : document.documentElement.scrollHeight - window.innerHeight);
if (room < 600) return { skip: 'page too short to scroll' };
/* The router's own poll is held for the duration, which is what makes this a measurement rather
* than a coin toss: this case performs a tick BY HAND, to control when the container is empty,
* and a real tick landing in the same window rewrites the very section being swapped. Measured
* before it was stopped, the same router and width reported 689px, 577px and 0px on three
* consecutive runs. HOLD holds it too, for the reference rather than the pad — see the note there.
* QUIET only inserts a pad of its own, so a tick underneath it is noise it survives. */
const poll = (window.L && window.L.Poll) || null;
const polling = !!(poll && typeof poll.active === 'function' && poll.active());
if (polling) poll.stop();
try {
/* as far down as the page goes: what the reader loses to a clamp is what is left below them */
const at = room - 60;
/* park the reader and wait for the ENGINE to say the scroll happened — see the note above */
const parkAt = async (y) => {
const target = sc || window;
const landed = new Promise((res) => {
let done = false;
const on = () => { if (!done) { done = true; target.removeEventListener('scroll', on); res(); } };
target.addEventListener('scroll', on, { passive: true });
setTimeout(() => { if (!done) { done = true; target.removeEventListener('scroll', on); res(); } }, 2500);
});
if (sc) sc.scrollTop = y; else window.scrollTo(0, y);
await landed;
/* AND THEN UNTIL THE THEME SAYS IT IS STILL, which is not the same as SCROLL_IDLE elapsing.
* fs-fit starts its motion sampler on the scroll event and only remembers where the page
* stands once that sampler has been quiet for SCROLL_IDLE; in WebKit the sampler starts late
* enough that a flat wait measured the theme before it had a reference at all — the gate then
* reported a jump on every WebKit run and none on the other two engines, with the theme
* identical. Asking the theme removes the guess: `scrolling` is exported for exactly this
* kind of question. */
const fit = await window.L.require('fs-fit').then((m) => m, () => null);
/* A theme that answers but cannot say where it rested is a FAILURE, not a fallback. This
* used to be one try/catch around both, so a stripped export threw a TypeError that read as
* "no theme here" and the sweep quietly went back to the flat wait below — which is the
* WebKit flake this call exists to remove, and it measured nothing while saying nothing. */
if (fit && typeof fit.restAt !== 'function')
throw new Error('fs-fit is loaded but exports no restAt(): the sweep cannot tell when '
+ 'the theme has taken its reference, and a flat wait is not a substitute');
if (fit) {
/* until the theme has taken a reference AT THIS OFFSET. "Is it scrolling" cannot answer
* that: it says no both before the motion sampler starts and after it finishes, and in
* WebKit those are 1.5s apart — the probe grew the page in between, while the theme still
* had no reference, and the gate reported a jump on every WebKit run and none on the
* other two engines with the theme identical on all three. */
for (let i = 0; i < 160; i++) {
if (fit.restAt() === (sc ? sc.scrollTop : window.scrollY) && !fit.scrolling()) break;
await wait(25);
}
}
await wait(600); /* the still moment the theme measures from */
};
await parkAt(at);
/* The tallest section body that is ENTIRELY ABOVE the viewport, and both halves matter. Tall,
* because the clamp only bites when what the swap takes away is more than the room left below the
* reader. Above, because "the reader must not move" is only true of a change above them: a
* section that straddles the fold is one the theme anchors INSIDE, and content growing below that
* anchor is supposed to move. */
/* What a poll actually replaces, not one page's idea of it. Looking for `.cbi-section > div` alone
* is the Overview's shape, so the gate measures this fault there and nowhere else: Processes,
* Routes and the realtime pages are a TABLE inside the section, and `L.ui.Table` refreshes by
* replacing its rows — the same collapse with a different parent — so every one of those runs
* reported "no section body big enough to collapse" and passed on the engine where the fault is
* real. */
let body = null;
/* What a poll replaces, and nothing wider. `:scope > div` also matches `.fs-ovl`, the theme's own
* wrapper around System/Memory/Storage, so the probe deletes three whole sections at once, which
* no poll does — the stock one refreshes a section's BODY in place. The theme's reference lives
* inside that grid, so removing all of it takes the reference and its fallback together and the
* gate reports a jump the theme could not have prevented. A section body, a table, a table's
* body: those are the three things `dom.content()` is called on.
*
* `.cbi-section-descr` is excluded for the same reason and was the same mistake one level down:
* it is a section's DESCRIPTION, appended once when the section renders (luci-base form.js) and
* never replaced by a tick. On DHCP at 1440 wide with the top bar it is the tallest `div` under a
* section that sits above the reader, so the probe emptied it and reported 121px of movement on
* firefox and webkit — a collapse of static text that no poll performs. */
for (const el of view.querySelectorAll('.cbi-section > div:not(.cbi-section-descr), .table > .tbody, .table')) {
if (el.getBoundingClientRect().bottom > 0) continue; /* must be entirely above the reader */
if (el.contains(view) || el === view) continue;
if (!body || el.offsetHeight > body.offsetHeight) body = el;
}
if (!body || body.offsetHeight < 200) return { skip: 'nothing above the reader big enough to collapse' };
/* Stashed for REPEAT as soon as `body` is settled, before the mark search below can still fail
* — see the note beside the full stash a few lines down. `mark: null` here so REPEAT can tell
* "SWAP found no candidate at all" apart from "SWAP found one but nothing survived under the
* reader", rather than reading the second as the first. */
window.__fsRepeatSection = { body, mark: null };
/* Named for the guard below: `body` living inside `.fs-ovl` (System/Memory/Storage's own grid,
* fs-overview.js) can grow by the full pad and move the DOCUMENT by nothing — the grid's row
* tracks size off the taller column, so a shorter column growing 120px never reaches the
* scroller. Measured live: `div#fs-ovl-panel-0` at 291px grew the document 0px on both
* owrtsnap@900 and owrt2410@1200 — a cell that would otherwise print "moved 0px" and read as
* the correction working. */
const bodyDesc = body.tagName.toLowerCase() + (body.id ? '#' + body.id : '')
+ (body.className ? '.' + String(body.className).trim().replace(/\s+/g, '.') : '');
/* WHAT THE THEME'S GROWTH WITNESS CAN SEE FROM HERE — task blindgrow. `observeContent()` measures
* a refill against the `min-height` on the floored box, so a refill with no floored box in its
* ancestry has no witness at all and a `lateDrift()` whose element-based drift is also blind
* writes nothing. That reads in a report exactly like a correction that ran and failed, which is
* three CI runs of guesswork; printed here so a "never came back" finding says which of the two
* it was. Read BEFORE the swap, while the floor of the settled page is still standing. */
const floorBox = body.closest && body.closest('[data-fs-floor]');
const witness = !floorBox ? 'none'
: (floorBox === body ? 'self' : floorBox.tagName.toLowerCase() + (floorBox.id ? '#' + floorBox.id : ''))
+ '@' + Math.round(parseFloat(floorBox.style.minHeight) || 0);
/* AND WHETHER THE THEME HAS A REFERENCE AT ALL — the other half of "why was nothing written".
* `anchorRef()` returns null outright while `scrolling()` is true, and `rememberRest()` then
* clears `_rest` while still setting `_restAt`, so the wait above (`restAt() === scrollTop`) is
* satisfied by a theme that has no reference. `lateDrift()`'s first line is `if (_lateFrame ||
* !ref) return`, so that reads as `writes: []` — indistinguishable, in a report, from a
* correction that ran and missed. Read here, immediately before the refill. */
const fitMod = await window.L.require('fs-fit').then((m) => m, () => null);
const themeStill = fitMod ? !fitMod.scrolling() : null;
const lateWhy = () => (fitMod && typeof fitMod.lateWhy === 'function' ? fitMod.lateWhy() : 'n/a');
const anchorWhy = () => (fitMod && typeof fitMod.anchorWhy === 'function' ? fitMod.anchorWhy() : 'n/a');
const bodyH0 = body.offsetHeight;
const docH = () => (sc ? sc.scrollHeight : document.documentElement.scrollHeight);
/* the whole STACK at the point, not just the topmost element: in a grid gap the top of the
* stack is `#view` itself — a host is not a mark, its own top does not move when something
* grows INSIDE it — and the section that gap belongs to is right underneath it. Measured: on
* 25.12's Overview every point down the middle answered `#view`, so all eight runs on that
* release reported "no content under the reader" and measured nothing. */
/* STICKY EXCLUDED FOR THE SAME REASON AS #view AND `.cbi-section-descr`: a `position: sticky`
* element's rect.top is pinned to its stuck offset for as long as it stays stuck, so
* `after.top - before.top` stops being a faithful proxy for the reader's place the moment the
* stick state does — or does not — change across the measurement. Found live on owrtsnap: the
* mark landed on `th.th`, the sidebar's own sticky table header (theme/30-tables.css), where
* owrt2512 gave `td.td` for the same page and point. It did not fire in the runs that found it
* (`headerWasPinned=true, tableTop=-166`, moved 0 throughout) — but a synthetic sticky header
* reproduces the failure directly: rect.top read 0 on every growth from 0 to 120px while a plain
* sibling at the same point moved with the page, so a mark landing there would pass however
* badly the theme failed. The walk climbs to `view` because a plain child of a stuck header
* inherits the same pinned top. */
const markAt = (y, x) => {
for (const el of document.elementsFromPoint(x, y)) {
if (el === view || !view.contains(el) || body.contains(el)) continue;
let stuck = false;
for (let a = el; a && a !== view; a = a.parentElement)
if (getComputedStyle(a).position === 'sticky') { stuck = true; break; }
if (!stuck) return el;
}
return null;
};
/* THE HIT IS TRIED ACROSS THE VIEWPORT, not at three points down its middle: a mark is anything
* inside #view, and on 25.12's Overview the middle column is a grid gap for most of its height,
* so all three of those points answered `#view` and every run on that release reported "no
* content under the reader" and measured nothing — half the matrix, silently unmeasured. */
const h = window.innerHeight || 800;
const vw = window.innerWidth || 800;
let mark = null;
for (const fy of [ 0.6, 0.5, 0.7, 0.4, 0.8, 0.35 ]) {
for (const fx of [ 0.5, 0.25, 0.75 ]) {
mark = markAt(Math.round(h * fy), Math.round(vw * fx));
if (mark) break;
}
if (mark) break;
}
if (!mark) return { skip: 'nothing under the reader that survives the swap' };
const before = { pos: pos(), top: Math.round(mark.getBoundingClientRect().top) };
/* `mark` filled in on the same stash — REPEAT reads this rather than re-searching, which runs
* next in the same page. Measured live: an independent search, run 700ms after this one, picked
* `table.table.cbi-section-table` (1145px, a legitimate-looking candidate) on a cell where THIS
* search had already found a sibling that reached the scroller; the growth landed on the wrong
* element and `grewDoc` read 0 on every one of 274 cells swept. Nothing here mutates between the
* two searches on purpose — it is SWAP's own two `swap()` passes below, each a `dom.content()`-
* shaped refill, that wake `holdFloor()` in between and let it hand a second search a different
* answer. */
window.__fsRepeatSection.mark = mark;
/* the two halves of dom.content(), with the layout the engine performs in between made explicit
* — WebKit gets there on its own, and a gate must not depend on when */
const swap = async () => {
const startDocH = docH();
const kept = Array.prototype.slice.call(body.childNodes);
for (const n of kept) body.removeChild(n);
const empty = { docH: (sc ? sc.scrollHeight : document.documentElement.scrollHeight), pos: pos() };
for (const n of kept) body.appendChild(n);
const pad = document.createElement('div');
pad.style.height = growth + 'px';
pad.dataset.fsProbe = '1';
body.appendChild(pad);
/* WHEN the mark comes back, not only WHETHER — see LATE_MS above. A single read at a fixed
* delay cannot tell "never corrected" from "corrected after the maintainer would call it a
* jump": both look identical if the delay lands after the fault has resolved and before a
* true non-correction would have. Sampled every frame for winMs instead — long enough to
* hold lateDrift()'s 419-420ms correction with margin for a loaded runner — noting the
* first frame the drift falls back under tolerance. */
const t0 = performance.now();
const w0 = (window.__fsW || []).length;
let correctedAt = null, lastTop = mark.isConnected ? Math.round(mark.getBoundingClientRect().top) : null;
/* LONGEST FRAME GAP — task gap. The interval between two of this loop's frames (the first measured
* from t0), and when it ended. `performance.now()` only: no layout read, so the measurement cannot
* stall the frames it is measuring. */
let prevFrame = t0, gapMax = 0, gapAt = 0;
/* WHAT A FRAME PAINTED, NOT WHAT IT STARTED FROM — task painted. This loop's rAF is queued before
* the theme's own (it is requested right after the refill, the theme's from the mutation record a
* microtask later), so a read at the top of the callback sees the page BEFORE the theme's correction
* in that same frame, and credits the correction to the NEXT frame. With frames 16ms apart that was a
* frame of slack; on a stalled runner it was the whole stall. Measured in CI, firefox owrt2512 @390
* top normal, engine DECLINES, /admin/network/dhcp: the late trail read `settle+21 wrote-120+21` and
* the finding read 2702ms, the longest frame gap 2681ms ending at +2702. A ResizeObserver's
* notifications are delivered in the same rendering update AFTER every rAF callback and before paint
* (HTML "update the rendering"), and `observe()` always delivers one first notification — so
* re-observing the root each frame reads the page as that frame paints it, with no DOM write and no
* size change. Credited to the frame's own timestamp. `painted` counts how many frames were read
* that way; a frame the observer did not deliver for is read at the top of the next one and credited
* to THAT frame, which can only make a correction look later, never earlier. */
let pending = null, painted = 0, frames = 0;
const read = (at) => {
lastTop = mark.isConnected ? Math.round(mark.getBoundingClientRect().top) : null;
if (correctedAt === null && lastTop !== null && Math.abs(lastTop - before.top) <= tol)
correctedAt = Math.round(at - t0);
};
const ro = new ResizeObserver(() => { if (pending !== null) { pending = null; painted++; read(prevFrame); } });
await new Promise((done) => {
const frame = () => {
const nowF = performance.now();
if (pending !== null) { pending = null; read(nowF); }
if (nowF - prevFrame > gapMax) { gapMax = nowF - prevFrame; gapAt = nowF - t0; }
prevFrame = nowF;
frames++;
if (nowF - t0 < winMs) {
pending = nowF;
ro.unobserve(document.documentElement);
ro.observe(document.documentElement);
requestAnimationFrame(frame);
} else { ro.disconnect(); read(nowF); done(); }
};
requestAnimationFrame(frame);
});
const after = { pos: pos(), top: lastTop };
const writes = (window.__fsW || []).slice(w0);
/* WHICH LINE DECIDED — see `_lateWhy` in fs-fit.js. Read after the window, so it holds the
* decision this refill produced rather than the previous cell's. */
const why = lateWhy(), awhy = anchorWhy();
/* each entry as `decision+ms`, relative to this refill's own t0 — see `_lateTrail` in fs-fit.js */
const rel = (tr) => (tr || []).map((e) => {
const i = e.lastIndexOf('@');
return e.slice(0, i) + '+' + Math.round(Number(e.slice(i + 1)) - t0);
}).join(' ');
const trail = fitMod && typeof fitMod.lateTrail === 'function' ? rel(fitMod.lateTrail()) : 'n/a';
const atrail = fitMod && typeof fitMod.anchorTrail === 'function' ? rel(fitMod.anchorTrail()) : 'n/a';
/* The offset the swap actually asked the scroller to move by — separate from `clamped`
* (what the engine took OUT of a document momentarily empty) and from `moved` (what the
* reader's mark shows). A cell that reports `clamped 0px` because the engine declined to
* anchor reads identically here to one where the mark itself misreported; this term is
* the one number both a healthy pass and a false one can be told apart by. */
const offsetDelta = after.pos - before.pos;
/* Did the pad reach the SCROLLER at all — see the note on `bodyDesc` above. A grid whose
* row tracks size off a sibling column can absorb the whole 120px inside `body` without the
* document growing by a pixel, which is a different failure from "the reader didn't move". */
const grewDoc = docH() - startDocH;
pad.remove();
await wait(700); /* let the floor come back down before the next pass measures */
return { empty, after, moved: after.top === null ? null : after.top - before.top,
clamped: before.pos - empty.pos, offsetDelta, grewDoc, correctedAt, writes, why, awhy, trail, atrail, gapMax: Math.round(gapMax), gapAt: Math.round(gapAt), painted, frames };
};
const corrected = await swap();
/* THE SAME SWAP WITH THE CORRECTION SWITCHED OFF, which is what isolates the other half. The
* content column keeps a floor between ticks (fs-fit.js, holdFloor), so a section emptying inside
* it takes nothing off the document and there is nothing for the engine to clamp into. With
* `fsAnchor = 'off'` the theme writes no offset at all, so anything that still moves the reader
* here is the floor failing rather than the correction covering for it. */
let floorOnly = { skip: 'no storage' };
try {
localStorage.setItem('fsAnchor', 'off');
floorOnly = await swap();
} catch (e) { /* no storage, no second pass */ }
finally { try { localStorage.removeItem('fsAnchor'); } catch (e) { /* … */ } }
return { before, empty: corrected.empty, after: corrected.after, moved: corrected.moved,
clamped: corrected.clamped, offsetDelta: corrected.offsetDelta, grewDoc: corrected.grewDoc,
correctedAt: corrected.correctedAt,
late: corrected.correctedAt !== null && corrected.correctedAt > lateMs,
writes: corrected.writes,
bodyDesc, bodyH: bodyH0, witness, themeStill, why: corrected.why, awhy: corrected.awhy, trail: corrected.trail, atrail: corrected.atrail, gapMax: corrected.gapMax, gapAt: corrected.gapAt, painted: corrected.painted, frames: corrected.frames,
floorMoved: floorOnly.skip ? null : floorOnly.moved,
floorClamped: floorOnly.skip ? null : floorOnly.clamped,
floorOffsetDelta: floorOnly.skip ? null : floorOnly.offsetDelta,
scroller: sc ? 'maincontent' : 'window' };
} finally { /* the poll stays stopped — see the note on QUIET, which starts it again */ }
};
/* Runs in the page: SEVERAL of dom.content()'s empty-then-refill cycles on the SAME section, back
* to back, on a page where the engine's own anchoring is left alone throughout.
*
* Why this is not SWAP again: SWAP performs ONE refill and closes inside 900ms, so `LATE_MISS_LIMIT`
* (2, fs-fit.js) — the count that decides whether the theme keeps trusting an engine that anchors
* imprecisely — is structurally unreachable from it. A switch that only flips after the SECOND miss
* is invisible to a case that never performs one (task missrule): 48 green sub-runs proving a fix
* to the miss-counting formula were all first-miss runs, and the regression the fix itself could
* introduce — the switch flipping on an engine that is doing the job correctly, two ticks (10s)
* later — had no case that could see it trip at all.
*
* `fs-fit.engineTrusted()` is read rather than inferred from timing, for the same reason `restAt()`
* already is: "is it scrolling" and a correction's own latency both answer the wrong question, and a
* flat guess here already cost a false report once (see the note on `parkAt` above).
*
* IT DOES NOT PICK ITS OWN SECTION. `window.__fsRepeatSection` — set by SWAP, which runs first in
* every cell — is read instead of running the same search again: a second search, after SWAP's own
* two refills have woken `holdFloor()`, can land on a different and sometimes worse candidate than
* the one just proven to reach the scroller. Measured before this was fixed: 274 of 274 cells in a
* full three-engine sweep reported either "nothing above the reader big enough to collapse" or a
* growth that never reached the document, on a page where SWAP — the identical selector, the
* identical park — had just measured something. See the note beside `window.__fsRepeatSection`
* above for the one concrete example chased down. */
const REPEAT = async ([ growth, tol, times ]) => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const view = document.getElementById('view');
if (!view) return { skip: 'no view' };
const mc = document.getElementById('maincontent');
const flow = mc ? getComputedStyle(mc).overflowY : '';
const sc = (flow === 'auto' || flow === 'scroll') ? mc : null;
const pos = () => (sc ? sc.scrollTop : window.scrollY);
const room = (sc ? sc.scrollHeight - sc.clientHeight : document.documentElement.scrollHeight - window.innerHeight);
if (room < 600) return { skip: 'page too short to scroll' };
/* only against the REAL engine — see the note on TICK below for why `noEngineAnchor` skips this
* case entirely rather than reaching it with the fallback forced on */
const poll = (window.L && window.L.Poll) || null;
const polling = !!(poll && typeof poll.active === 'function' && poll.active());
if (polling) poll.stop();
try {
/* THE SAME SECTION SWAP ALREADY PICKED, not a second search. SWAP's own two `dom.content()`-
* shaped passes (corrected + floor-only) wake `holdFloor()` in between, and a fresh search run
* afterwards can land on a different — and sometimes worse — candidate than the one SWAP just
* proved reaches the scroller: measured live, an independent search here picked
* `table.table.cbi-section-table` (1145px, itself a plausible-looking candidate) on a cell where
* SWAP's own search had found a sibling that DID reach the scroller, and every one of 274 swept
* cells reported "nothing above the reader big enough to collapse" or the growth landing on an
* element that never moved the document — SWAP measured 91 of those same 171 cells in the same
* run. Re-parking (below) is still needed — SWAP's own `wait(700)` settling window may have left
* the reader a pixel or two off `before.top` — but WHICH element is measured is SWAP's call, read
* off `window.__fsRepeatSection` rather than re-decided. */
const picked = window.__fsRepeatSection;
let body = picked && picked.body && picked.body.isConnected ? picked.body : null;
if (!body || body.offsetHeight < 200) return { skip: 'nothing above the reader big enough to collapse' };
let mark = picked && picked.mark && picked.mark.isConnected ? picked.mark : null;
if (!mark) return { skip: 'nothing under the reader that survives the swap' };
const bodyDesc = body.tagName.toLowerCase() + (body.id ? '#' + body.id : '')
+ (body.className ? '.' + String(body.className).trim().replace(/\s+/g, '.') : '');
/* WHAT THE THEME'S GROWTH WITNESS CAN SEE FROM HERE — task blindgrow. `observeContent()` measures
* a refill against the `min-height` on the floored box, so a refill with no floored box in its
* ancestry has no witness at all and a `lateDrift()` whose element-based drift is also blind
* writes nothing. That reads in a report exactly like a correction that ran and failed, which is
* three CI runs of guesswork; printed here so a "never came back" finding says which of the two
* it was. Read BEFORE the swap, while the floor of the settled page is still standing. */
const floorBox = body.closest && body.closest('[data-fs-floor]');
const witness = !floorBox ? 'none'
: (floorBox === body ? 'self' : floorBox.tagName.toLowerCase() + (floorBox.id ? '#' + floorBox.id : ''))
+ '@' + Math.round(parseFloat(floorBox.style.minHeight) || 0);
const bodyH0 = body.offsetHeight;
const docH = () => (sc ? sc.scrollHeight : document.documentElement.scrollHeight);
/* park/wait: the same shape as SWAP above, because the fault this measures is downstream of the
* SAME candidate section — duplicated rather than shared, matching every other case in this
* file, since each has to run standalone inside `page.evaluate()`. */
const at = room - 60;
const parkAt = async (y) => {
const target = sc || window;
const landed = new Promise((res) => {
let done = false;
const on = () => { if (!done) { done = true; target.removeEventListener('scroll', on); res(); } };
target.addEventListener('scroll', on, { passive: true });
setTimeout(() => { if (!done) { done = true; target.removeEventListener('scroll', on); res(); } }, 2500);
});
if (sc) sc.scrollTop = y; else window.scrollTo(0, y);
await landed;
const fit = await window.L.require('fs-fit').then((m) => m, () => null);
if (fit && typeof fit.restAt !== 'function')
throw new Error('fs-fit is loaded but exports no restAt(): the sweep cannot tell when '
+ 'the theme has taken its reference, and a flat wait is not a substitute');
if (fit) {
for (let i = 0; i < 160; i++) {
if (fit.restAt() === (sc ? sc.scrollTop : window.scrollY) && !fit.scrolling()) break;
await wait(25);
}
}
await wait(600);
};
await parkAt(at);
const before = { pos: pos(), top: Math.round(mark.getBoundingClientRect().top) };
const fit = await window.L.require('fs-fit').then((m) => m, () => null);
/* Absent on a checkout that stripped it, same guard as `restAt` — a case that silently measured
* nothing here would read as "trust never left true", which is not the same claim. */
if (fit && typeof fit.engineTrusted !== 'function')
throw new Error('fs-fit is loaded but exports no engineTrusted(): this case cannot tell '
+ 'whether the switch tripped, and a missing export is not the same as "it did not"');
const trusted = () => (fit ? fit.engineTrusted() : null);
const trustedBefore = trusted();
/* Grown by `body`'s OWN mutation, in dom.content()'s shape — empty, then refill, one tick at a
* time. The pad is removed and the floor allowed to settle between ticks (SWAP's own comment on
* why: the next pass reads a floor already standing, not one still clearing). */
const startDocH = docH();
const refills = [];
/* Read WHILE the first pad is still standing — see SWAP's own `grewDoc`, taken before ITS pad
* comes off. This used to be `docH() - startDocH` computed after the loop, by which point every
* refill's pad had already been removed and the document was back at its start height on
* purpose: net zero on every cell, `.fs-ovl` grid or not, and indistinguishable from the growth
* genuinely never reaching the scroller. Measured live on the 39 cells SWAP itself measured
* something on in the same run: every one still read `grewDoc` 0 under the old placement. */
let grewDoc = null;
for (let i = 0; i < times; i++) {
const kept = Array.prototype.slice.call(body.childNodes);
for (const n of kept) body.removeChild(n);
for (const n of kept) body.appendChild(n);
const pad = document.createElement('div');
pad.style.height = growth + 'px';
pad.dataset.fsProbe = '1';
body.appendChild(pad);
const t0 = performance.now();
const w0 = (window.__fsW || []).length;
/* the same diagnostics SWAP prints — a REPEAT finding used to say only "corrected never" */
const themeStill = fit ? !fit.scrolling() : null;
let correctedAt = null, lastTop = mark.isConnected ? Math.round(mark.getBoundingClientRect().top) : null;
let prevFrame = t0, gapMax = 0, gapAt = 0;
/* read as the frame paints it — see SWAP's note, task painted */
let pending = null;
const read = (at) => {
lastTop = mark.isConnected ? Math.round(mark.getBoundingClientRect().top) : null;
if (correctedAt === null && lastTop !== null && Math.abs(lastTop - before.top) <= tol)
correctedAt = Math.round(at - t0);
};
const ro = new ResizeObserver(() => { if (pending !== null) { pending = null; read(prevFrame); } });
await new Promise((done) => {
const frame = () => {
const now = performance.now();
if (pending !== null) { pending = null; read(now); }
if (now - prevFrame > gapMax) { gapMax = Math.round(now - prevFrame); gapAt = Math.round(now - t0); }
prevFrame = now;
if (now - t0 < 900) {
pending = now;
ro.unobserve(document.documentElement);
ro.observe(document.documentElement);
requestAnimationFrame(frame);
} else { ro.disconnect(); read(now); done(); }
};
requestAnimationFrame(frame);
});
const writes = (window.__fsW || []).slice(w0);
const rel = (tr) => (tr || []).map((e) => {
const k = e.lastIndexOf('@');
return e.slice(0, k) + '+' + Math.round(Number(e.slice(k + 1)) - t0);
}).join(' ');
const why = fit && typeof fit.lateWhy === 'function' ? fit.lateWhy() : 'n/a';
const awhy = fit && typeof fit.anchorWhy === 'function' ? fit.anchorWhy() : 'n/a';
const trail = fit && typeof fit.lateTrail === 'function' ? rel(fit.lateTrail()) : 'n/a';
const atrail = fit && typeof fit.anchorTrail === 'function' ? rel(fit.anchorTrail()) : 'n/a';
if (i === 0) grewDoc = docH() - startDocH;
pad.remove();
refills.push({ i, moved: lastTop === null ? null : lastTop - before.top, correctedAt, writes,
engineTrusted: trusted(), themeStill, why, awhy, trail, atrail, gapMax, gapAt });
await wait(700);
}
return { before, bodyDesc, bodyH: bodyH0, grewDoc, witness,
trustedBefore, trustedAfter: trusted(), refills,
scroller: sc ? 'maincontent' : 'window' };
} finally { /* the poll stays stopped, matching HOLD/SWAP — nothing here restarts it */ }
};
/* Runs in the page: a scripted flick up and down while ticks land, reporting any offset change the
* wheel did not ask for. */
/* THE POLL IS STARTED HERE AND NOWHERE ELSE, and the two cases before this leave it stopped rather
* than handing it back. `Poll.start()` calls `step()` SYNCHRONOUSLY (luci-base, luci.js), so a case
* that restores the poll on its way out fires a real tick into the case that runs next: measured in
* CI, HOLD returning the poll put a live tick under SWAP's floor measurement and the sweep reported
* `the content column's floor is not holding the document up`, 120px, on webkit/owrt2410 @390 top
* compact — a cell that is green at v0.14.2, green before that change and green again when it was
* reverted, on a theme those three builds share. This case WANTS ticks landing mid-flick, so it is
* the one that starts them. */
const QUIET = async (growth) => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const view = document.getElementById('view');
const mc = document.getElementById('maincontent');
const flow = mc ? getComputedStyle(mc).overflowY : '';
const sc = (flow === 'auto' || flow === 'scroll') ? mc : null;
const pos = () => (sc ? sc.scrollTop : window.scrollY);
const room = (sc ? sc.scrollHeight - sc.clientHeight : document.documentElement.scrollHeight - window.innerHeight);
if (room < 600) return { skip: 'page too short to scroll' };
/* A FLICK THAT NEVER REACHES AN EDGE. Assigning a scrollTop that is already the current one
* fires no scroll event, so a run of steps clamped at 0 or at the bottom is a page standing
* still — 400 ms of that and the theme is right to put the pending growth back, which is the
* theme's contract and not a jump. So the travel is kept inside the page: a margin at each end,
* and a step small enough that six of them fit between the two. */
/* The ticks this case measures against — see the note above. Started BEFORE the park and given
* a tick's worth of time, because `Poll.start()` performs one synchronously: started later, that
* first tick lands inside the flick this case is timing, and the sweep reports the jump it came
* to look for. Measured in CI: `the offset moved on its own mid-flick (worst 145.5px)` on
* firefox/owrt2410 @390 top compact, from starting the poll two statements further down. */
const poll = (window.L && window.L.Poll) || null;
if (poll && typeof poll.active === 'function' && !poll.active()) {
poll.start();
await wait(1500);
}
const edge = Math.max(80, Math.min(200, Math.round(room * 0.15)));
const lo = edge, hi = room - edge;
/* six steps out and six back, from the MIDDLE of that band: half the band is what one direction
* gets, so the step is a twelfth of it. Measured on the stands, where the Overview has 1582px of
* room at 1440 and 4355 at 390 — the old fixed 160px reached the bottom in three steps at 1440
* and stood there for the rest of the half-cycle, which is how a page nobody was scrolling came
* to be measured as a flick. */
const reach = Math.max(40, Math.min(160, Math.floor((hi - lo) / 12)));
let unexplained = 0, biggest = 0, expected = 0, stalls = 0;
/* WHO MOVED IT — the first surprise's step, the theme's own scroll writes and its decision trails, so
* a "moved on its own" finding says whether the theme wrote or the engine adjusted. A CI report
* (firefox owrt2410 @390 top large, engine on, dhcp, 562px) arrived without any of it. */
let surprise = null;
const fitQ = await window.L.require('fs-fit').then((m) => m, () => null);
let last = Math.round((lo + hi) / 2);
if (sc) sc.scrollTop = last; else window.scrollTo(0, last);
await wait(700);
last = pos();
let lastAt = Date.now();
let movedAt = Date.now();
for (let i = 0; i < 24; i++) {
const step = (i % 12 < 6) ? reach : -reach;
const stepT0 = performance.now();
const w0 = (window.__fsW || []).length;
const docH0 = sc ? sc.scrollHeight : document.documentElement.scrollHeight;
expected = Math.max(lo, Math.min(hi, last + step));
if (sc) sc.scrollTop = expected; else window.scrollTo(0, expected);
/* a growth lands mid-flick, which is when the theme must NOT correct */
if (i % 6 === 3) {
const pad = document.createElement('div');
pad.style.height = growth + 'px';
pad.dataset.fsProbe = '1';
view.insertBefore(pad, view.firstChild);
}
await wait(70);
const now = pos();
/* Was this step still part of a flick? The theme calls the reader "scrolling" until the offset
* has held still for SCROLL_IDLE (400 ms, fs-fit.js), and a step here is 70 ms, so on a machine
* that keeps up the whole loop is one motion. A loaded CI runner does not always keep up, and a
* step that took longer has the theme rightly deciding the reader stopped. A gap that long is
* not a flick, so the step is excluded — and counted and printed, because silence would make a
* run that measured nothing look like a run that found nothing. */
const gap = Date.now() - lastAt;
lastAt = Date.now();
if (now !== last) movedAt = Date.now();
/* …and the same question asked of the PAGE rather than of the loop: an offset that has not
* changed for SCROLL_IDLE is a page nobody is scrolling, whatever this loop asked for. */
const idle = Date.now() - movedAt;
/* the offset may differ from the request by the growth the engine compensated; what must not
* happen is a correction of the theme's own on top of it while the reader is moving */
const off = Math.abs(now - expected);
if (off > growth + 4) {
if (gap >= 400 || idle >= 400) stalls++;
else {
unexplained++; biggest = Math.max(biggest, off);
if (!surprise) {
const rel = (tr) => (tr || []).map((e) => {
const k = e.lastIndexOf('@');
return e.slice(0, k) + '+' + Math.round(Number(e.slice(k + 1)) - stepT0);
}).filter((e) => Number(e.slice(e.lastIndexOf('+') + 1)) > -2000).join(' ');
surprise = { step: i, padded: i % 6 === 3, expected, now, gap,
docDelta: (sc ? sc.scrollHeight : document.documentElement.scrollHeight) - docH0,
writes: (window.__fsW || []).slice(w0),
trail: fitQ && typeof fitQ.lateTrail === 'function' ? rel(fitQ.lateTrail()) : 'n/a',
atrail: fitQ && typeof fitQ.anchorTrail === 'function' ? rel(fitQ.anchorTrail()) : 'n/a',
trusted: fitQ && typeof fitQ.engineTrusted === 'function' ? fitQ.engineTrusted() : null };
}
}
}
last = now;
}
view.querySelectorAll('[data-fs-probe]').forEach((el) => el.remove());
return { unexplained, biggest, stalls, surprise };
};
/* Runs in the page: park the reader, LEAVE THE POLL RUNNING, and watch the offset across several
* REAL ticks — no synthetic pad, nothing inserted by the probe at all.
*
* HOLD and SWAP stop the poll (see the note on QUIET) and both close their case inside 800ms; QUIET
* starts the poll but its subject is a reader in MOTION, and it discards any step where the offset
* held still for 400ms. None of the three ever watches a PARKED reader across a tick the poll fires
* on its own — which is exactly the shape of task wkanchor's fault: WebKit's own scroll anchoring
* moved the offset +21px on a tick where nothing above the reader changed height by even a pixel
* (no `scrollTo`, no `scrollTop` setter recorded — the probe in ../tmp/task-overview12/tick-probe.mjs
* is what caught that), and lateDrift() (fs-fit.js) wrote it back one rAF + SCROLL_IDLE later,
* measured 421/421/408ms after the tick. Both HOLD/SWAP's 800ms window and QUIET's 400ms-still
* discard read that pair as "the reader stayed put" — the correction landing inside their own
* tolerance, not outside it — which is why this gate held green through it. */
const TICK = async (ticks) => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const view = document.getElementById('view');
if (!view) return { skip: 'no view' };
const mc = document.getElementById('maincontent');
const flow = mc ? getComputedStyle(mc).overflowY : '';
const sc = (flow === 'auto' || flow === 'scroll') ? mc : null;
const pos = () => (sc ? sc.scrollTop : window.scrollY);
const room = (sc ? sc.scrollHeight - sc.clientHeight : document.documentElement.scrollHeight - window.innerHeight);
if (room < 600) return { skip: 'page too short to scroll' };
const poll = (window.L && window.L.Poll) || null;
/* An empty queue polls nothing to watch. `L.Poll.queue` is not a new liberty taken with a
* private property — fs-router.js already reads it directly (`L.Poll.queue.length === 0`), so
* this asks the same question the theme itself asks. Processes registers none, and starting the
* interval anyway would just wait out HARD_TIMEOUT below for a tick that can never land. */
if (!poll || !Array.isArray(poll.queue) || poll.queue.length === 0)
return { skip: 'this page registers no poll — nothing to tick' };
if (typeof poll.active === 'function' && !poll.active()) poll.start();
/* NOT room/2 — HOLD and SWAP only need SOME room below the reader, but this case's fault is
* where the reader rests relative to the FOLD, not to the document's total length: on a page
* `room` puts room/2 thousands of pixels past where anything was misbehaving, and a run parked
* there measured 0px on 3/3 tries while ../tmp/task-overview12/tick-probe.mjs kept finding 21px
* peak-to-peak at the same page's default park, ~0.6 viewport heights down — the same fraction
* `anchorRef()` (fs-fit.js) already uses to find the fold, because a reader who just opened a
* page rests roughly one screen down into it, not in its middle. */
const at = Math.min(Math.round((window.innerHeight || 800) * 0.6), room);
/* park the reader and wait for the ENGINE to say the scroll happened — see the note on HOLD */
const parkAt = async (y) => {
const target = sc || window;
const landed = new Promise((res) => {
let done = false;
const on = () => { if (!done) { done = true; target.removeEventListener('scroll', on); res(); } };
target.addEventListener('scroll', on, { passive: true });
setTimeout(() => { if (!done) { done = true; target.removeEventListener('scroll', on); res(); } }, 2500);
});
if (sc) sc.scrollTop = y; else window.scrollTo(0, y);
await landed;
const fit = await window.L.require('fs-fit').then((m) => m, () => null);
if (fit && typeof fit.restAt !== 'function')
throw new Error('fs-fit is loaded but exports no restAt(): the sweep cannot tell when '
+ 'the theme has taken its reference, and a flat wait is not a substitute');
if (fit) {
for (let i = 0; i < 160; i++) {
if (fit.restAt() === (sc ? sc.scrollTop : window.scrollY) && !fit.scrolling()) break;
await wait(25);
}
}
await wait(600); /* the still moment the theme measures from */
};
await parkAt(at);
/* NOT counted by MutationObserver batch — tried first, and wrong: one real tick on the Overview
* refills System, Memory and Storage as three separate `dom.content()` calls, each its own
* microtask-batched callback, so 3 "mutations" landed inside a SINGLE real tick and the case
* exited after 500ms of quiet having watched about two seconds of page time — long before the
* three real, 5-SECOND-APART ticks this case exists to cross. That version measured 0px on 3/3
* tries on the exact cell ../tmp/task-overview12/tick-probe.mjs was, at the time, reporting 21px
* peak-to-peak on. So the budget is TIME, off the page's own `L.env.pollinterval` — what
* `Poll.add()` itself defaults an interval to when a caller gives none (luci-base) — rather than
* off how many mutation callbacks happened to fire. */
const interval = (window.L && window.L.env && Number(window.L.env.pollinterval)) || 5;
let mutations = 0;
const mo = new MutationObserver(() => { mutations++; });
mo.observe(view, { childList: true, subtree: true });
const samples = [];
const start = performance.now();
/* `ticks` real intervals, plus one more tick's worth of headroom for lateDrift's correction
* (measured up to 421ms after the mutation, fs-fit.js) to land inside the window instead of
* just past its close. */
const budget = (ticks + 1) * interval * 1000 + 1500;
await new Promise((done) => {
const frame = () => {
samples.push(pos());
if (performance.now() - start < budget) requestAnimationFrame(frame); else done();
};
requestAnimationFrame(frame);
});
mo.disconnect();
if (mutations === 0)
return { skip: `no tick landed in ${Math.round(performance.now() - start)}ms — the poll did not run` };
const mn = Math.min(...samples), mx = Math.max(...samples);
return { moved: Math.round((mx - mn) * 10) / 10, mutations, samples: samples.length,
scroller: sc ? 'maincontent' : 'window' };
};
let list = requireStands(stands(arg('only', ''), { all: process.argv.includes('--all') }), 'scroll-anchor');
/* `--quick`'s stand narrowing: the first stand `stands()` handed back, unless `--only` already said
* which ones — an explicit `--only` is the caller overriding the default, and `--quick` narrowing it
* again on top would make the flag's own effect depend on argument order. */
if (QUICK && !arg('only', '')) list = list.slice(0, 1);
/* Each `-b` twin owlab boots beside a release line (task sweepspeed) is a second container for the
* SAME measurement, not a fourth release line — see the note on `pairStands()`. `--no-pair` is the
* escape hatch for debugging the pairing itself, or for a run that wants the twin left idle. */
const PAIRS = process.argv.includes('--no-pair') ? new Map() : pairStands(list);
/* Printed as it is found, not held until the end: the first finding is the whole answer for someone
* iterating on a fix, and the list below is what says how many cells and which axes. */
const findings = [];
const found = (line) => {
findings.push(line);
process.stdout.write(` FINDING ${line}\n`);
};
/* Cells whose page never loaded. They are NOT findings: this gate measures where the reader ends up
* after a layout change, and a page that did not open measured nothing at all. Reported separately,
* and fatal only in bulk — see UNOPENED_TOLERANCE below. */
const unopened = [];
let runs = 0;
if (QUICK) {
console.log(`scroll-anchor --quick: ${ENGINES.join(',')} on ${list.map((s) => s.id).join(',')} `
+ `only, page ${PAGES.join(',')} only, default width/layout/density axes. This is NOT the full `
+ 'sweep: it skips every other stand, both other page shapes (dhcp, processes), and — without '
+ '--full — compact/large density and every scroller but 1440 side/390 side/390 top. Run without '
+ '--quick (`npm run anchor`, or CI\'s own `--full` sweep) before trusting a clean result.\n');
}
/* A single `page.goto` in CI is not reliable enough to fail a release on. Two runs an hour apart on
* the same commit died on webkit/owrt2410 with `page.goto: Timeout 20000ms exceeded` and
* `page.goto: WebKit encountered an internal error` — two cells out of the sweep, every other cell
* reporting `reader moved 0px`, and the same sweep green locally over 174 runs. So: three attempts
* before a cell is written off. */
async function openPage(page, url) {
let last;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
return;
} catch (e) {
last = e;
/* the WebKit process may still be dying; give it a moment rather than racing it */
if (attempt < 3) await page.waitForTimeout(1500);
}
}
throw last;
}
/* How many cells may go unopened before the sweep itself is the problem. One flaky cell is the
* runner; a stand that stopped answering is not, and a run that measured almost nothing must not
* report that the reader stayed put. 2% of a ~170-cell sweep is 3 cells. */
const UNOPENED_TOLERANCE = 0.02;
/* one line out of a Playwright error: the rest is a stack through the evaluate wrapper, and the
* first line is the sentence the browser or this file actually wrote */
const first = (e) => String((e && e.message) || e).split('\n')[0].trim();
/* Reuse a logged-in session across every cell aimed at the same physical container, instead of
* performing the POST-and-redirect login flow once per cell. Captured with a throwaway context and
* handed to every context a worker opens afterward; `login()` still runs on each of them and still
* re-authenticates for real the moment a cookie has expired (LuCI's own session timeout) — this
* removes the ROUND TRIP for the common case, it does not replace the check. */
async function captureSession(browser, base) {
const ctx = await browser.newContext();
await sealToRouter(ctx, base);
const page = await ctx.newPage();
await login(page, base);
const state = await ctx.storageState();
await ctx.close();
return state;
}
/* One cell, against ONE physical container — every case in this file, run once, findings printed
* under `reportId` rather than under `base`'s own container id, so a cell an owlab `-b` twin
* happened to draw reads identically to one the base container drew (see `pairStands()`). Pulled out
* of the sweep below so a worker can run its own slice of the cell list without duplicating what
* used to be the loop body. */
async function runCell(browser, engine, reportId, base, sessionState, { PAGE, width: w, layout, density, noEngineAnchor, declines }) {
const ctx = await browser.newContext({ viewport: { width: w, height: 844 }, storageState: sessionState });
await sealToRouter(ctx, base);
/* HOLD and SWAP's own record of every scroll write made during their measurement
* window — read back only when a finding needs it, which is what told a real failed
* correction apart from a mark that legitimately reached the top of the viewport (a
* -505px HOLD reading, firefox owrt2512 @1440 top compact, chased a full round before
* that distinction was made). Borrowed from ../tmp/task-holdreg/hold-probe.mjs. */
await ctx.addInitScript(() => {
window.__fsW = [];
const rec = (how, val) => { try { window.__fsW.push({ t: Math.round(performance.now()), how, val: Math.round(val) }); } catch (e) { /* … */ } };
const st = window.scrollTo;
window.scrollTo = function (...a) { rec('window.scrollTo', typeof a[0] === 'object' ? (a[0] && a[0].top) : a[1]); return st.apply(this, a); };
const sb = window.scrollBy;
window.scrollBy = function (...a) { rec('window.scrollBy', typeof a[0] === 'object' ? (a[0] && a[0].top) : a[1]); return sb.apply(this, a); };
const d = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop');
Object.defineProperty(Element.prototype, 'scrollTop', { configurable: true, enumerable: d.enumerable,
get() { return d.get.call(this); },
set(v) { rec('scrollTop=' + (this.id || this.className || this.tagName), v); return d.set.call(this, v); } });
const es = Element.prototype.scrollTo;
if (es) Element.prototype.scrollTo = function (...a) { rec('el.scrollTo=' + (this.id || this.className || this.tagName), typeof a[0] === 'object' ? (a[0] && a[0].top) : a[1]); return es.apply(this, a); };
});
/* the Safari path, forced: `fsEngineAnchor=off` makes fs-fit believe the platform has
* no anchoring of its own, and the stylesheet turns the engine's off for real, so the
* two agree about which of them is responsible.
*
* `declines` IS THE SAME STYLESHEET WITHOUT THE localStorage HALF — task late419, and it
* is the third state of this axis rather than a flag on top of it. An engine that HAS
* `overflow-anchor` and declines to use it on a given refill is not a hypothesis: CI
* reported it on `webkit owrtsnap @1440 side compact overview` twice in three runs of one
* commit and never once locally, because on an unloaded machine the same engine anchors
* every time. The two cells above cannot reach that state by construction — one has the
* engine working, the other has the theme knowing it is off — so the path the theme takes
* when it TRUSTS an engine that did nothing (`lateDrift()`, fs-fit.js) was measured only
* by whatever a loaded runner happened to produce. Ablating the engine alone puts the
* theme in exactly that state on every engine and every run, which is what turns a 2-in-3
* CI coin toss into a cell that either passes or does not. */
if (noEngineAnchor || declines)
await ctx.addInitScript((tellTheTheme) => {
if (tellTheTheme) try { localStorage.setItem('fsEngineAnchor', 'off'); } catch (e) { /* no storage */ }
document.addEventListener('DOMContentLoaded', () => {
const s = document.createElement('style');
s.textContent = 'html, body, #maincontent, .fs-main, #view, #view * { overflow-anchor: none !important; }';
document.head.appendChild(s);
});
}, !!noEngineAnchor);
const page = await ctx.newPage();
const mode = noEngineAnchor ? 'engine-anchoring OFF' : (declines ? 'engine DECLINES ' : 'engine-anchoring on ');
const where = `${engine} ${reportId} @${w} ${layout.padEnd(4)} ${density.padEnd(7)} ${mode} ${PAGE.replace('/admin/status/', '')}`;
await login(page, base);
try {
await page.evaluate(async ([l, d]) => {
const prefs = await window.L.require('fs-prefs');
prefs.applyLayout(l);
prefs.applyDensity(d);
}, [ layout, density ]);
await openPage(page, base + PAGE);
}
catch (e) {
/* NOT a finding: a page that never opened says nothing about where the reader
* ended up. Kept apart and counted — see UNOPENED_TOLERANCE. */
unopened.push(`${where}: ${first(e)}`);
await ctx.close();
return;
}
await page.waitForTimeout(3000);
let held, swap, quiet, tick, repeat, below;
try {
/* TICK FIRST, and on the page exactly as it loaded — HOLD's pad, SWAP's two
* removeChild/appendChild cycles and QUIET's two dozen scripted flicks all perturb
* whatever the engine had picked as its own anchor node, and TICK measures whether
* a REAL tick misbehaves on ITS OWN, not after three probes have already leaned on
* the page. Measured: run after them, on this same page, TICK read 0px on a cell
* ../tmp/task-overview12/tick-probe.mjs (a fresh navigation) was reporting 21px
* peak-to-peak on at the time — the same fix, the same page, only its position in
* the sequence differed. Only against the REAL engine: with `noEngineAnchor` the
* stylesheet above already turns anchoring off for the whole page, which is
* precisely what TICK exists to watch for misbehaving — running it there would
* measure the theme's own correction against nothing, the question HOLD/SWAP
* already answer with a synthetic pad. */
tick = (noEngineAnchor || declines)
? { skip: 'engine already forced off — HOLD/SWAP cover the theme\'s own correction' }
: await page.evaluate(TICK, TICK_COUNT);
held = await page.evaluate(HOLD, GROWTH);
swap = await page.evaluate(SWAP, [ GROWTH, TOLERANCE, LATE_MS, SWAP_WINDOW ]);
/* only against the REAL engine — same reasoning as TICK above: with `noEngineAnchor`
* the fallback already runs every refill, so `_engineTrusted` has nothing to say.
* AND NOT ON THE `declines` CELL EITHER, for the opposite reason: there the flag going
* false is the switch working exactly as built — the engine really is doing nothing —
* so REPEAT's "the flag tripped while the reader never moved" finding would fire on
* correct behaviour. SWAP above performs one trusted refill per page load, which is
* the one measurement this cell is for; a second would be taken on an already-
* distrusted theme and measure `applyAnchor()`, which the OFF cell already covers. */
repeat = (noEngineAnchor || declines)
? { skip: 'engine already forced off — HOLD/SWAP cover the theme\'s own correction' }
: await page.evaluate(REPEAT, [ GROWTH, TOLERANCE, REPEAT_TIMES ]);
below = await page.evaluate(BELOW, GROWTH);
quiet = await page.evaluate(QUIET, GROWTH);
}
/* A cell that threw proved nothing, and dropping it without a word is how a sweep comes
* back green having measured a fraction of what it was asked to. That is not a theory:
* `fs-fit.restAt()` was stripped out of the package, every measurement threw, and both
* of these catches took the whole run down to "0 run(s)" and exit 0. */
catch (e) {
/* A PAGE THAT IS NO LONGER A LUCI PAGE MEASURED NOTHING EITHER — task noluci. The throw above stays
* a finding where LuCI is still there to answer: a stripped `restAt()` throws with `window.L` in
* place. But CI also threw `page.evaluate: TypeError: undefined is not an object (evaluating
* 'window.L.require')` — webkit owrt2410 @1440 side normal, engine DECLINES, Overview — in the same
* run as `page.goto: WebKit encountered an internal error` on the same stand: the document the
* cell opened was gone, which is the unopened case arriving late. Asked of the page itself, and a
* page that cannot even answer (crashed, closed) counts as gone. Still counted against
* UNOPENED_TOLERANCE, so a stand that keeps losing its pages fails the sweep as before. */
const luci = await page.evaluate(() => !!(window.L && typeof window.L.require === 'function')).catch(() => false);
if (luci) findings.push(`${where}: the measurement threw — ${first(e)}`);
else unopened.push(`${where}: the page stopped being a LuCI page mid-measurement (${page.isClosed() ? 'closed' : page.url()}) — ${first(e)}`);
await ctx.close();
return;
}
/* A cell that grew `body` by the full pad without the DOCUMENT growing has proven
* nothing: `.fs-ovl`'s grid sizes a row off its taller column, so a shorter column
* (`div#fs-ovl-panel-0`, 291px, on owrtsnap@900 and owrt2410@1200) can absorb 120px
* and leave the scroller untouched — `swap.moved` then reads 0 for the same reason a
* correctly-anchored page would, and the two are indistinguishable without this
* check. Folded into `swap.skip` rather than a separate branch so it is excluded from
* the pass line below the same way "nothing to collapse" already is — a skip, not a
* silent pass: it says the growth never reached what it was measuring. */
if (!swap.skip && typeof swap.grewDoc === 'number' && swap.grewDoc < GROWTH - TOLERANCE
&& swap.grewDoc < GROWTH / 2)
swap.skip = `the ${GROWTH}px pad only grew the document ${swap.grewDoc}px `
+ `(${swap.bodyDesc}, ${swap.bodyH}px) — the growth never reached the scroller`;
/* the same `.fs-ovl` grid guard as SWAP's, above — REPEAT walks the same candidates */
if (!repeat.skip && typeof repeat.grewDoc === 'number' && repeat.grewDoc < GROWTH - TOLERANCE
&& repeat.grewDoc < GROWTH / 2)
repeat.skip = `the ${GROWTH}px pad only grew the document ${repeat.grewDoc}px `
+ `(${repeat.bodyDesc}, ${repeat.bodyH}px) — the growth never reached the scroller`;
if (held.skip || quiet.skip) {
process.stdout.write(` ${where}: ${held.skip || quiet.skip}\n`);
await ctx.close();
return;
}
runs++;
const geom = (h) => `before.top=${h && h.before ? h.before.top : '-'} after.top=${h && h.after ? h.after.top : '-'}`;
if (held.moved === null)
findings.push(`${where}: the reader's element was replaced mid-measurement, so nothing was proven`);
else if (Math.abs(held.moved) > TOLERANCE) {
/* ONE FLAKE IN ~216 CELLS MUST NOT FAIL A RUN ON ITS OWN. A -505px HOLD finding on
* firefox owrt2512 @1440 top compact was chased for a full round and never
* reproduced locally: before.top read 503 every run, so after.top≈0 was the mark
* landing at the viewport top — a different event from a failed correction — and
* nothing here printed the geometry that would have said so at the time. Both
* readings are printed now, and a second measurement is taken before either one
* becomes a finding. */
const held2 = await page.evaluate(HOLD, GROWTH);
if (held2.moved !== null && Math.abs(held2.moved) > TOLERANCE)
found(`${where}: ${GROWTH}px grew above the reader and the page moved ${held.moved}px `
+ `under them (${geom(held)}); re-measured: ${held2.moved}px (${geom(held2)})`
+ ((held.writes && held.writes.length) ? ` — writes: ${JSON.stringify(held.writes)}` : ''));
else
process.stdout.write(` ${where}: HOLD read ${held.moved}px once (${geom(held)}) but `
+ `${held2.moved}px on re-measure (${geom(held2)}) — one flake, not a finding\n`);
}
if (below && !below.skip && below.moved !== null && Math.abs(below.moved) > TOLERANCE)
found(`${where}: ${GROWTH}px grew BELOW the reader and the page moved ${below.moved}px under them `
+ `(offset ${below.scrollDelta >= 0 ? '+' : ''}${below.scrollDelta}, ${below.boxDesc}) — content arriving under the reader must not move them`
+ ((below.writes && below.writes.length) ? ` — writes: ${JSON.stringify(below.writes)}` : ''));
if (swap.skip)
process.stdout.write(` ${where}: the swap measured nothing (${swap.skip})\n`);
else if (swap.moved === null)
found(`${where}: the reader's element did not survive the swap, so nothing was proven`);
else if (Math.abs(swap.moved) > TOLERANCE)
/* `correctedAt` is null here BY CONSTRUCTION: swap() only sets it once the drift has
* fallen back under tolerance, so a moved this large at the end of the window means
* it never did — NEVER CORRECTED, not corrected late. `writes` — HOLD's finding
* already appends it, this one did not, which is why three CI runs of the same finding
* could not say whether the theme ever attempted a correction: an empty array here means
* it never wrote, a non-empty one means it wrote somewhere the mark did not see. */
found(`${where}: a section was refilled the way a poll refills one and the page never `
+ `came back — still ${swap.moved}px off after ${SWAP_WINDOW}ms `
+ `(the engine clamped ${swap.clamped}px of offset away)`
+ `, growth witness: ${swap.witness}, theme still at refill: ${swap.themeStill}, theme said: ${swap.why}, anchor said: ${swap.awhy}, late trail: [${swap.trail}], anchor trail: [${swap.atrail}], longest frame gap: ${swap.gapMax}ms ending at +${swap.gapAt}ms`
+ ` — writes: ${JSON.stringify(swap.writes || [])}`);
else if (swap.correctedAt !== null && swap.correctedAt > LATE_MS)
found(`${where}: a section was refilled the way a poll refills one and the correction `
+ `landed ${swap.correctedAt}ms after the refill (over the ${LATE_MS}ms late `
+ `threshold — visible to the reader as a jump)`
+ `, growth witness: ${swap.witness}, theme still at refill: ${swap.themeStill}, theme said: ${swap.why}, anchor said: ${swap.awhy}, late trail: [${swap.trail}], anchor trail: [${swap.atrail}], longest frame gap: ${swap.gapMax}ms ending at +${swap.gapAt}ms`
+ ((swap.writes && swap.writes.length) ? ` — writes: ${JSON.stringify(swap.writes)}` : ''));
/* The floor is judged on the CLAMP, not on the movement, and only where the theme owns the job:
* with the correction switched off nobody compensates the pad the probe grows, so the
* reader moves by exactly that and should. What must not happen is the engine taking an
* offset away. Where the engine anchors for itself the same subtraction measures its
* compensation rather than a clamp — 629px of it, and the reader still level — so it is
* printed rather than judged. */
if (noEngineAnchor && swap.floorClamped !== null && swap.floorClamped !== undefined && swap.floorClamped > TOLERANCE)
found(`${where}: with the correction switched off the engine clamped `
+ `${swap.floorClamped}px away — the content column's floor is not holding the document up`);
if (quiet.unexplained)
found(`${where}: the offset moved on its own ${quiet.unexplained} time(s) mid-flick (worst ${quiet.biggest}px) `
+ '— a correction landing inside a scroll is itself a jump'
+ (quiet.surprise ? `, first at step ${quiet.surprise.step}${quiet.surprise.padded ? ' (pad inserted)' : ''}: `
+ `asked ${quiet.surprise.expected}, read ${quiet.surprise.now} after ${quiet.surprise.gap}ms, document ${quiet.surprise.docDelta >= 0 ? '+' : ''}${quiet.surprise.docDelta}px, `
+ `engineTrusted ${quiet.surprise.trusted}, late trail: [${quiet.surprise.trail}], anchor trail: [${quiet.surprise.atrail}], `
+ `theme writes: ${JSON.stringify(quiet.surprise.writes)}` : ''));
/* THE PARKED READER, ACROSS REAL TICKS — see the note on TICK. Nothing here inserted the
* growth: a page that ticks at all and still moves is the engine's own anchoring (or its
* absence of one), the fault HOLD/SWAP's synthetic pad and QUIET's motion cannot reach. */
if (tick.skip)
process.stdout.write(` ${where}: TICK measured nothing (${tick.skip})\n`);
else if (Math.abs(tick.moved) > TOLERANCE)
found(`${where}: the reader drifted ${tick.moved}px across real poll ticks `
+ `(${tick.mutations} mutation(s) observed) while parked and nobody scrolling`);
/* SEVERAL REFILLS ON ONE PAGE — see the note on REPEAT. Two things are watched apart,
* because they are two different faults: the reader actually moving (a correction ran
* where it should not have, or none ran where one should have) is a finding regardless
* of the trust flag; the flag going false while the reader never moved at all is a
* SEPARATE finding — the engine was doing the job the whole time, and the switch took
* the correction away from it anyway (task missrule). */
if (repeat.skip)
process.stdout.write(` ${where}: REPEAT measured nothing (${repeat.skip})\n`);
else {
const jumped = repeat.refills.find((r) => r.moved === null || Math.abs(r.moved) > TOLERANCE);
if (jumped)
found(`${where}: refill ${jumped.i + 1}/${REPEAT_TIMES} on the same section left the `
+ `reader ${jumped.moved === null ? 'without a surviving reference' : jumped.moved + 'px off'} `
+ `(engineTrusted ${jumped.engineTrusted}, corrected ${jumped.correctedAt === null ? 'never' : jumped.correctedAt + 'ms'})`
+ `, growth witness: ${repeat.witness}, theme still at refill: ${jumped.themeStill}, theme said: ${jumped.why}, anchor said: ${jumped.awhy}, late trail: [${jumped.trail}], anchor trail: [${jumped.atrail}], longest frame gap: ${jumped.gapMax}ms ending at +${jumped.gapAt}ms`
+ (jumped.writes && jumped.writes.length ? ` — writes: ${JSON.stringify(jumped.writes)}` : ''));
else if (repeat.trustedBefore === true && repeat.trustedAfter === false)
found(`${where}: _engineTrusted went false after ${REPEAT_TIMES} refills the reader `
+ `never moved for — the engine was anchoring correctly the whole time `
+ `(misses: ${JSON.stringify(repeat.refills.map((r) => r.engineTrusted))})`);
}
/* signed for readability: a positive offset is the scroller compensating for growth
* above the reader, which is what tells "the engine declined to anchor" (0px here)
* apart from "the mark misreported" (moved itself would be the tell, not this term) */
const signed = (v) => (v === null || v === undefined ? '-' : (v >= 0 ? '+' : '') + v);
process.stdout.write(` ${where} reader moved ${held.moved}px (${geom(held)}, `
+ `scroll ${held.scrollDelta >= 0 ? '+' : ''}${held.scrollDelta}, ${held.scroller})`
+ ` below ${below && !below.skip && below.moved !== null ? below.moved + 'px' : '-'}`
+ ` swap moved ${swap.skip ? '-' : swap.moved + 'px'} `
+ `[offset ${swap.skip ? '-' : signed(swap.offsetDelta)}]`
+ ` corrected ${swap.skip ? '-' : (swap.correctedAt === null ? 'never' : swap.correctedAt + 'ms')}${swap.skip ? '' : ` painted ${swap.painted}/${swap.frames}`}`
+ ` floor alone: clamped ${swap.skip || swap.floorClamped === null ? '-' : swap.floorClamped + 'px'}`
+ `, reader ${swap.skip || swap.floorMoved === null ? '-' : swap.floorMoved + 'px'} `
+ `[offset ${swap.skip || swap.floorOffsetDelta === null ? '-' : signed(swap.floorOffsetDelta)}]`
+ ` mid-flick surprises ${quiet.unexplained}`
+ (quiet.stalls ? ` (${quiet.stalls} step(s) too slow to still be a flick, not counted)` : '')
+ ` tick drift ${tick.skip ? '-' : tick.moved + 'px/' + tick.mutations + 'mut'}`
+ ` ${REPEAT_TIMES}x repeat ${repeat.skip ? '-' : repeat.refills.map((r) => (r.moved === null ? '?' : r.moved) + 'px').join('/')} `
+ `trusted ${repeat.skip ? '-' : `${repeat.trustedBefore}->${repeat.trustedAfter}`}` + '\n');
await ctx.close();
}
/* THE STANDS RUN AT THE SAME TIME, the way live-audit and spa-parity already do, and so — since
* task sweepspeed — do the ENGINES: nothing measured here is a frame rate, every wait is on an EVENT
* (the scroll landing, `fit.restAt()` reporting a reference at this offset) with a bounded fallback,
* so several routers and several browsers answering at once cannot move an answer — unlike
* scroll-jank, whose subject IS frame pacing and which therefore stays serial.
*
* This gate was once the whole release's critical path at 52 minutes against live's 17 (docs/ci.md);
* CI's own fix was to shard firefox and webkit onto separate runners, which a single local process
* cannot do — what this can do instead is stop paying for them one after another IN that process.
* Each engine gets its own browser, and the three (or however many `--engines` named) now launch and
* run concurrently rather than in the `for` loop's own sequence. */
await Promise.all(ENGINES.map(async (engine) => {
if (!pw[engine]) { console.error(`scroll-anchor: no such engine "${engine}"`); process.exit(1); }
const browser = await pw[engine].launch();
await Promise.all(list.map(async (stand) => {
/* One flat list of GROUPS per stand — a group is both `noEngineAnchor` states of the same
* page/scroller/density, kept together rather than split by raw cell index. TICK and REPEAT
* only run with the engine's own anchoring live (~26s of real poll ticks between them), so a
* plain `i % workers.length` over individual cells hands one worker every expensive cell and
* the other every cheap one — measured live, task sweepspeed: 16 anchor-OFF cells done against
* 4 anchor-ON ones at the same wall-clock point, the split defeating its own purpose. Splitting
* whole groups keeps every worker's share an even mix of both. */
const groups = [];
for (const PAGE of PAGES)
for (const { width, layout } of SCROLLERS)
for (const density of DENSITIES)
/* Three states, not two — see the note beside the init script in runCell():
* the engine working, the engine off with the theme knowing, and the engine
* off with the theme still trusting it. The third costs what the second does
* (TICK and REPEAT skip it), and it is the only one that exercises
* `lateDrift()` as the page's ONLY corrector without waiting for a loaded
* runner to arrange it. */
groups.push([ { noEngineAnchor: false }, { noEngineAnchor: true }, { declines: true } ]
.map((mode) => ({ PAGE, width, layout, density, ...mode })));
const twin = PAIRS.get(stand.id);
const workers = twin ? [ stand.base, twin.base ] : [ stand.base ];
/* Split evenly across the pair rather than handing the twin a copy of the same list — every
* cell still runs exactly once, on whichever of the two containers gets to it, so the sweep
* is exactly as wide as before and the wall clock for THIS stand is roughly halved. */
const perWorker = workers.map(() => []);
groups.forEach((g, i) => perWorker[i % workers.length].push(...g));
const sessions = await Promise.all(workers.map((base) => captureSession(browser, base)));
await Promise.all(workers.map(async (base, wi) => {
for (const cell of perWorker[wi]) {
/* --bail: the cells after the first finding measure the same fix attempt again */
if (BAIL && findings.length) return;
await runCell(browser, engine, stand.id, base, sessions[wi], cell);
}
}));
}));
await browser.close();
}));
if (unopened.length) {
console.error(`\nscroll-anchor: ${unopened.length} cell(s) never opened — the browser or the stand,`
+ ' not the theme:\n');
for (const u of unopened) console.error(' ' + u);
console.error('\nA page that did not load measured nothing about the reader. Tell the two apart by'
+ '\nre-running the same cell: a real anchor finding reproduces, a runner does not.\n');
}
if (findings.length) {
console.error(`\nscroll-anchor: ${findings.length} finding(s)\n`);
for (const f of findings) console.error(' ' + f);
console.error('\nfs-fit.js keeps the reader\'s place where the engine does not (ENGINE_ANCHORS), and must');
console.error('stay out of the way where it does. docs/chrome.md.\n');
process.exit(1);
}
/* Bulk failure to open is its own defect: the sweep did not measure what it claims to have. */
if (unopened.length && unopened.length > Math.max(3, (runs + unopened.length) * UNOPENED_TOLERANCE)) {
console.error(`scroll-anchor: ${unopened.length} of ${runs + unopened.length} cell(s) never opened `
+ '— too many to call the runner. Fix the stand or the browser, then re-run.\n');
process.exit(1);
}
/* A sweep that measured nothing has not shown that the reader stays put, so it may not say so. */
if (!runs) {
console.error('\nscroll-anchor: 0 run(s) — every cell was skipped, so nothing was measured.\n');
process.exit(1);
}
console.log(`scroll-anchor: ${runs} run(s), the reader stayed put with and without the engine's own anchoring.`);