mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
W3 trackpad-momentum upscroll GATE: merge-blocking, offline, both engines
The live trackpad probe (upscroll-trackpad-live.perf.ts) proved the WebKit
compensation defect against real #buzz-bugs but needs staging + a
port-forward + a member nsec, so it can never gate a PR merge. This gate
reproduces the SAME felt-lurch metric under the SAME momentum actuation on
the deterministic mock jitter-corpus bridge — no network, greppable in CI,
on BOTH engines.
Covers both lurch sources (W3 trace, thread):
- CV-realization lurch: jitter-corpus is the heterogeneous 400-row seed
whose estimateRowHeight mismatch T1.1/T1.2 already gate.
- prepend-commit-under-momentum: 400 rows > 300 CHANNEL_HISTORY_LIMIT, so
fetchOlder pages behind an until-cursor. useLoadOlderOnScroll fires off an
IntersectionObserver on a 600px-margin top sentinel — pure geometry,
input-cadence-independent — so momentum triggers it like live.
channelWindowDelayMs=1000 defers the commit so it lands frames later,
under momentum, reproducing the commit race (not an instant same-frame
commit).
Engine fidelity mirrors T1.1/T1.2: overflow-anchor forced 'none' so
Chromium reproduces the shipped WKWebView (no native scroll anchoring);
under playwright.perf-webkit.config.ts this is the real WebKit family.
Three assertions (Eva's thresholds + Quinn's W4 caveat):
- peak felt lurches (|dev|>10px, non-commit) == 0 — the wheel-fight spike.
- smooth frames (|dev|<=1px) >= 99%.
- RMS deviation across the run <= placeholder — the skip-forever guard:
a too-tight staleness skip-bound never fires the correction under
momentum, showing as a run of sub-10px deviations that dodge the peak
gate but sum to main's raw-shift drift. Peak is blind to that shape.
RED at contract 6b9203ca under the WebKit mirror is mandatory and verified:
WebKit 132 lurches / 94.42% smooth / RMS 4.24px; Chromium-with-mirror 131
lurches / 97.87% smooth. Dawn's engine-order-independent W1 fix turns it
green on both engines. Exact lurch and RMS ceilings pinned against Dawn's
first green candidate, same discipline as T1.2.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
co-authored by
Tyler Longwell
parent
6b9203cad7
commit
7c2587472c
@@ -0,0 +1,385 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
/**
|
||||
* TRACKPAD-MOMENTUM UPSCROLL GATE (W3) — the merge-blocking, offline sibling of
|
||||
* `upscroll-trackpad-live.perf.ts`.
|
||||
*
|
||||
* Eva's kickoff (thread) localised the WebKit-compensation defect: the T2
|
||||
* writer is near-perfect on Chromium and near-ineffective on WKWebView, both
|
||||
* settled (68.3% vs 98.9%) and — far worse — under continuous trackpad
|
||||
* momentum (39 felt lurches up to 204px per 30-swipe pass). The live probe
|
||||
* proved it against real #buzz-bugs but needs staging + a port-forward + a
|
||||
* member nsec, so it can never gate a PR merge. This gate reproduces the SAME
|
||||
* felt-lurch metric under the SAME momentum actuation on the deterministic
|
||||
* mock `jitter-corpus` bridge — no network, greppable in CI, on BOTH engines.
|
||||
*
|
||||
* WHY THE MOCK COVERS BOTH LURCH SOURCES (the W3 trace, thread):
|
||||
* 1. CV-realization lurch — jitter-corpus is the heterogeneous 400-row seed
|
||||
* whose `estimateRowHeight` mismatch is the realization error T1.1/T1.2
|
||||
* already gate; momentum crossing those rows realises them under the
|
||||
* reading row.
|
||||
* 2. Prepend-commit-under-momentum — jitter-corpus is 400 rows > the 300
|
||||
* CHANNEL_HISTORY_LIMIT, so `fetchOlder` pages behind an until-cursor.
|
||||
* `useLoadOlderOnScroll` fires the fetch off an IntersectionObserver on a
|
||||
* 600px-margin top sentinel — pure geometry, input-cadence-independent —
|
||||
* so momentum triggers it exactly like live. We set
|
||||
* `channelWindowDelayMs` so the page commits several frames LATER, under
|
||||
* continuous momentum, reproducing the commit race rather than an instant
|
||||
* same-frame commit.
|
||||
*
|
||||
* ENGINE FIDELITY — same mirror as T1.1/T1.2: the shipped WKWebView has no
|
||||
* `overflow-anchor`, so we force `overflow-anchor: none` on the scroller and
|
||||
* Chromium reproduces the shipped engine. Under Playwright `perf-webkit` this
|
||||
* is the real WebKit family. We log `CSS.supports(...)` for the record.
|
||||
*
|
||||
* WHAT IT MEASURES — identical to the live probe. A per-RAF in-page sampler,
|
||||
* independent of input timing, records scrollTop + the tracked centre row's
|
||||
* rect.top + mounted count + fetch count every frame. For a solid page,
|
||||
* rowMove(frame) = rect.top delta = scrollTop_before - scrollTop_after
|
||||
* so per-frame deviation = rowMove - appliedScroll. Nonzero = content shifted
|
||||
* under the viewport that the input did NOT ask for — the felt jump — whether
|
||||
* from CV realization, a losing compensation race, or a prepend anchor miss.
|
||||
* Prepend-commit frames (mounted grew) are scored separately: the page legitly
|
||||
* gains height there, so a one-frame jump is expected and NOT a lurch.
|
||||
*
|
||||
* TWO GATE SHAPES (Quinn's W4): peak-per-frame catches a single big lurch (a
|
||||
* wheel-fight spike); RMS-across-the-run catches SKIP-FOREVER — a too-tight
|
||||
* staleness skip-bound that never fires the correction under momentum, showing
|
||||
* as a run of small deviations that dodge the peak threshold but sum to drift.
|
||||
* The gate asserts BOTH so Dawn's skip-bound is a two-sided constraint.
|
||||
*
|
||||
* RED-AT-TIP IS LOAD-BEARING (same discipline as T1.2): this gate MUST fail on
|
||||
* the contract SHA (`6b9203ca`) under the WebKit mirror. That red is the proof
|
||||
* the metric has teeth. A correct engine-order-independent fix (Dawn's W1)
|
||||
* turns it green. If a future change greens the tip WITHOUT the fix, this gate
|
||||
* is VOID — do not relax thresholds; restore the mirror/corpus so it reds.
|
||||
*
|
||||
* Run (Chromium):
|
||||
* pnpm build && npx playwright test --config=playwright.perf.config.ts \
|
||||
* upscroll-trackpad-gate
|
||||
* Run (WebKit — the engine that actually reds at tip):
|
||||
* npx playwright test --config=playwright.perf-webkit.config.ts \
|
||||
* upscroll-trackpad-gate --project=perf-webkit
|
||||
*/
|
||||
|
||||
// A felt lurch: the tracked reading row jumped in one frame by more than this
|
||||
// beyond what the input asked, OUTSIDE a prepend-commit frame. Eva's target
|
||||
// (thread) is the felt threshold from the live probe: |dev|>10px is a jump the
|
||||
// eye catches. Gate = zero such frames.
|
||||
const MAX_FELT_LURCHES = 0;
|
||||
const LURCH_PX = 10;
|
||||
// Sustained smoothness: fraction of scored frames whose row tracked the input
|
||||
// within 1px. Eva's target: >=99% on both engines.
|
||||
const MIN_SMOOTH_FRACTION = 0.99;
|
||||
// RMS of per-frame deviation across the whole run (px). Quinn's W4 caveat: a
|
||||
// too-tight staleness skip-bound causes SKIP-FOREVER — the correction never
|
||||
// fires under momentum, so we're back to main's raw uncompensated shift, but as
|
||||
// a run of small-to-medium per-notch deviations that each DODGE the peak
|
||||
// LURCH_PX threshold while summing to large drift. Peak-per-frame is blind to
|
||||
// that shape; RMS is not. This is the second assertion Quinn asked the mock
|
||||
// GATE to carry. Placeholder ceiling; PINNED against Dawn's first green
|
||||
// candidate, same as the lurch count.
|
||||
const MAX_RMS_DEVIATION_PX = 1.0;
|
||||
// Keep the tracked row this far (px) from both viewport edges so it stays
|
||||
// realized across a frame — no straddling-row un-realization artifact.
|
||||
const SAFE_MARGIN = 60;
|
||||
// Swipes per run. Enough to cross several fetchOlder pages on the 400-row seed.
|
||||
const SWIPES = Number(process.env.BUZZ_PERF_SWIPES ?? 30);
|
||||
// Deferred-commit latency for the mock fetchOlder page, so the prepend commits
|
||||
// under continuous momentum (mirrors the live ~1s network fetch).
|
||||
const FETCH_DELAY_MS = Number(process.env.BUZZ_PERF_FETCH_DELAY_MS ?? 1000);
|
||||
|
||||
// One macOS-ish swipe: finger ramp (accelerating) + momentum tail (exp decay).
|
||||
// Byte-for-byte the profile the live probe uses so the two agree.
|
||||
function swipeDeltas(): number[] {
|
||||
const deltas: number[] = [];
|
||||
for (let i = 0; i < 12; i++) deltas.push(4 + Math.round((32 * i) / 11));
|
||||
let v = 36;
|
||||
while (v >= 1) {
|
||||
deltas.push(Math.round(v));
|
||||
v *= 0.94;
|
||||
}
|
||||
return deltas; // ~68 events, ~1500px total
|
||||
}
|
||||
|
||||
type Frame = {
|
||||
t: number;
|
||||
scrollTop: number;
|
||||
rowId: string | null;
|
||||
rowTop: number | null;
|
||||
mounted: number;
|
||||
fetch: number;
|
||||
};
|
||||
|
||||
test("GATE: trackpad-momentum upscroll produces zero felt lurches on both engines", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(300_000);
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(
|
||||
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
||||
);
|
||||
|
||||
await page.getByTestId("channel-jitter-corpus").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("jitter-corpus");
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector(
|
||||
'[data-testid="message-timeline"]',
|
||||
) as HTMLDivElement | null;
|
||||
return !!el && el.scrollHeight > el.clientHeight + 1000;
|
||||
});
|
||||
|
||||
// Defer the next fetchOlder page so it commits under momentum, not instantly.
|
||||
// channelWindowDelayMs is read live by the bridge per fetch.
|
||||
await page.evaluate((delayMs: number) => {
|
||||
window.__BUZZ_E2E__ = {
|
||||
...window.__BUZZ_E2E__,
|
||||
mock: { ...window.__BUZZ_E2E__?.mock, channelWindowDelayMs: delayMs },
|
||||
};
|
||||
}, FETCH_DELAY_MS);
|
||||
|
||||
const anchorSupport = await page.evaluate(() =>
|
||||
typeof CSS !== "undefined" && typeof CSS.supports === "function"
|
||||
? CSS.supports("overflow-anchor", "auto")
|
||||
: false,
|
||||
);
|
||||
|
||||
// Pin to the true bottom so everything above is unpainted (at estimate), then
|
||||
// force the WKWebView mirror (no native scroll anchoring).
|
||||
await timeline.evaluate((element) => {
|
||||
const el = element as HTMLDivElement;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
el.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
(el as HTMLElement).style.overflowAnchor = "none";
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// ---- Per-RAF sampler, in page, independent of input cadence ----
|
||||
await timeline.evaluate((element, margin: number) => {
|
||||
const el = element as HTMLDivElement;
|
||||
const store = window as unknown as {
|
||||
__FRAMES__: Frame[];
|
||||
__SAMPLER_STOP__?: boolean;
|
||||
__CHANNEL_WINDOW_FETCH_COUNT__?: number;
|
||||
};
|
||||
type Frame = {
|
||||
t: number;
|
||||
scrollTop: number;
|
||||
rowId: string | null;
|
||||
rowTop: number | null;
|
||||
mounted: number;
|
||||
fetch: number;
|
||||
};
|
||||
store.__FRAMES__ = [];
|
||||
let trackedId: string | null = null;
|
||||
const pick = (): string | null => {
|
||||
const box = el.getBoundingClientRect();
|
||||
const mid = box.top + box.height / 2;
|
||||
let best: { id: string; d: number } | null = null;
|
||||
for (const row of el.querySelectorAll<HTMLElement>("[data-message-id]")) {
|
||||
const r = row.getBoundingClientRect();
|
||||
if (r.top <= box.top + margin || r.bottom >= box.bottom - margin)
|
||||
continue;
|
||||
const d = Math.abs((r.top + r.bottom) / 2 - mid);
|
||||
if (!best || d < best.d) best = { id: row.dataset.messageId ?? "", d };
|
||||
}
|
||||
return best?.id || null;
|
||||
};
|
||||
const loop = () => {
|
||||
if (store.__SAMPLER_STOP__) return;
|
||||
const box = el.getBoundingClientRect();
|
||||
let rowTop: number | null = null;
|
||||
if (trackedId) {
|
||||
const row = el.querySelector<HTMLElement>(
|
||||
`[data-message-id="${CSS.escape(trackedId)}"]`,
|
||||
);
|
||||
if (row) {
|
||||
const r = row.getBoundingClientRect();
|
||||
if (r.top > box.top + margin && r.bottom < box.bottom - margin)
|
||||
rowTop = r.top;
|
||||
}
|
||||
}
|
||||
if (rowTop === null) {
|
||||
trackedId = pick();
|
||||
if (trackedId) {
|
||||
const r = el
|
||||
.querySelector<HTMLElement>(
|
||||
`[data-message-id="${CSS.escape(trackedId)}"]`,
|
||||
)
|
||||
?.getBoundingClientRect();
|
||||
rowTop = r ? r.top : null;
|
||||
}
|
||||
}
|
||||
store.__FRAMES__.push({
|
||||
t: performance.now(),
|
||||
scrollTop: el.scrollTop,
|
||||
rowId: trackedId,
|
||||
rowTop,
|
||||
mounted: el.querySelectorAll("[data-message-id]").length,
|
||||
fetch: store.__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0,
|
||||
});
|
||||
requestAnimationFrame(loop);
|
||||
};
|
||||
requestAnimationFrame(loop);
|
||||
}, SAFE_MARGIN);
|
||||
|
||||
// ---- Trusted trackpad-like wheel input via CDP (Chromium) / mouse.wheel ----
|
||||
const box = await timeline.boundingBox();
|
||||
if (!box) throw new Error("no timeline box");
|
||||
const cx = box.x + box.width / 2;
|
||||
const cy = box.y + box.height / 2;
|
||||
const isChromium =
|
||||
page.context().browser()?.browserType().name() === "chromium";
|
||||
const cdp = isChromium ? await page.context().newCDPSession(page) : null;
|
||||
await page.mouse.move(cx, cy);
|
||||
|
||||
for (let s = 0; s < SWIPES; s++) {
|
||||
const deltas = swipeDeltas();
|
||||
for (const d of deltas) {
|
||||
if (cdp) {
|
||||
await cdp.send("Input.dispatchMouseEvent", {
|
||||
type: "mouseWheel",
|
||||
x: cx,
|
||||
y: cy,
|
||||
deltaX: 0,
|
||||
deltaY: -d,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
} else {
|
||||
await page.mouse.wheel(0, -d);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 8));
|
||||
}
|
||||
await page.waitForTimeout(120);
|
||||
const at = await timeline.evaluate(
|
||||
(el) => (el as HTMLDivElement).scrollTop,
|
||||
);
|
||||
if (at <= 0) {
|
||||
// At the wall — let the deferred prepend land, then keep swiping.
|
||||
await page.waitForTimeout(FETCH_DELAY_MS + 1500);
|
||||
}
|
||||
}
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { __SAMPLER_STOP__?: boolean }).__SAMPLER_STOP__ =
|
||||
true;
|
||||
});
|
||||
const frames = (await page.evaluate(
|
||||
() => (window as unknown as { __FRAMES__: Frame[] }).__FRAMES__,
|
||||
)) as Frame[];
|
||||
|
||||
// ---- Analysis: per-frame deviation of row motion vs applied scroll ----
|
||||
type Dev = {
|
||||
i: number;
|
||||
dev: number;
|
||||
applied: number;
|
||||
rowMove: number;
|
||||
dt: number;
|
||||
mountedGrew: boolean;
|
||||
fetch: number;
|
||||
scrollTop: number;
|
||||
};
|
||||
const devs: Dev[] = [];
|
||||
for (let i = 1; i < frames.length; i++) {
|
||||
const a = frames[i - 1];
|
||||
const b = frames[i];
|
||||
if (!a.rowId || a.rowId !== b.rowId) continue; // re-pick boundary
|
||||
if (a.rowTop === null || b.rowTop === null) continue;
|
||||
const applied = a.scrollTop - b.scrollTop;
|
||||
const rowMove = b.rowTop - a.rowTop;
|
||||
devs.push({
|
||||
i,
|
||||
dev: rowMove - applied,
|
||||
applied,
|
||||
rowMove,
|
||||
dt: b.t - a.t,
|
||||
mountedGrew: b.mounted > a.mounted,
|
||||
fetch: b.fetch,
|
||||
scrollTop: b.scrollTop,
|
||||
});
|
||||
}
|
||||
|
||||
// Prepend-commit frames legitimately gain height; scored separately.
|
||||
const scored = devs.filter((d) => !d.mountedGrew);
|
||||
const commits = devs.filter((d) => d.mountedGrew);
|
||||
const feltLurches = scored
|
||||
.filter((d) => Math.abs(d.dev) > LURCH_PX)
|
||||
.sort((x, y) => Math.abs(y.dev) - Math.abs(x.dev));
|
||||
const smoothCount = scored.filter((d) => Math.abs(d.dev) <= 1).length;
|
||||
const smoothFraction = smoothCount / Math.max(1, scored.length);
|
||||
const worstLurch = feltLurches.length ? Math.abs(feltLurches[0].dev) : 0;
|
||||
// RMS deviation across ALL scored frames (Quinn's skip-forever signature):
|
||||
// a run of sub-LURCH_PX deviations that dodge the peak gate still lifts RMS.
|
||||
const rmsDeviation = Math.sqrt(
|
||||
scored.reduce((a, d) => a + d.dev * d.dev, 0) / Math.max(1, scored.length),
|
||||
);
|
||||
// Cumulative unasked drift = signed sum of deviation; a one-directional
|
||||
// skip-forever accumulates here even if each frame is small. Diagnostic.
|
||||
const cumulativeDrift = scored.reduce((a, d) => a + d.dev, 0);
|
||||
const prependObserved = commits.length > 0;
|
||||
// Anti-cheat: the reading row must actually track the input. Total input
|
||||
// applied over the run must be a meaningful upscroll (a frozen/half-applying
|
||||
// scroller has near-zero motion and would false-green on the lurch count).
|
||||
const totalApplied = scored.reduce((a, d) => a + d.applied, 0);
|
||||
const finalMounted = frames[frames.length - 1]?.mounted ?? 0;
|
||||
|
||||
const engine = page.context().browser()?.browserType().name();
|
||||
/* eslint-disable no-console */
|
||||
console.log(
|
||||
`\n=== TRACKPAD-MOMENTUM UPSCROLL GATE (mock jitter-corpus) engine=${engine} ===`,
|
||||
);
|
||||
console.log(
|
||||
`overflow-anchor supported by this engine: ${anchorSupport} (forced 'none' to mirror WKWebView)`,
|
||||
);
|
||||
console.log(
|
||||
`frames=${frames.length} scored=${scored.length} commits=${commits.length} swipes=${SWIPES} fetchPages=${frames[frames.length - 1]?.fetch ?? 0} finalMounted=${finalMounted}`,
|
||||
);
|
||||
console.log(
|
||||
`felt lurches (|dev|>${LURCH_PX}px, non-commit): ${feltLurches.length} (gate <= ${MAX_FELT_LURCHES})`,
|
||||
);
|
||||
console.log(`worst single-frame lurch: ${worstLurch.toFixed(1)}px`);
|
||||
console.log(
|
||||
`smooth frames (|dev|<=1px): ${smoothCount}/${scored.length} = ${(100 * smoothFraction).toFixed(2)}% (gate >= ${(100 * MIN_SMOOTH_FRACTION).toFixed(0)}%)`,
|
||||
);
|
||||
console.log(
|
||||
`rms deviation (skip-forever guard): ${rmsDeviation.toFixed(2)}px (gate <= ${MAX_RMS_DEVIATION_PX}) cumulativeDrift=${cumulativeDrift.toFixed(0)}px`,
|
||||
);
|
||||
console.log(
|
||||
`scored a fetchOlder prepend: ${prependObserved} (prepend-commit half exercised)`,
|
||||
);
|
||||
console.log(
|
||||
`total upscroll applied (anti-cheat): ${totalApplied.toFixed(0)}px`,
|
||||
);
|
||||
for (const d of feltLurches.slice(0, 20))
|
||||
console.log(
|
||||
` lurch frame=${d.i} dev=${d.dev.toFixed(1)}px applied=${d.applied.toFixed(1)} rowMove=${d.rowMove.toFixed(1)} dt=${d.dt.toFixed(1)}ms fetch=${d.fetch} scrollTop=${d.scrollTop.toFixed(0)}`,
|
||||
);
|
||||
console.log("===========================================================\n");
|
||||
/* eslint-enable no-console */
|
||||
|
||||
// Sanity: the run actually exercised a meaningful momentum upscroll.
|
||||
expect(frames.length).toBeGreaterThan(500);
|
||||
expect(finalMounted).toBeGreaterThanOrEqual(80);
|
||||
|
||||
// COVERAGE: the run must cross at least one fetchOlder prepend, so BOTH lurch
|
||||
// sources (CV realization + prepend-commit-under-momentum) are under the gate.
|
||||
// Corpus-structural (400-row seed > 300 limit) — holds on RED tip and GREEN
|
||||
// fix alike; a run that never paged is a coverage regression, not a verdict.
|
||||
expect(prependObserved).toBe(true);
|
||||
|
||||
// ANTI-CHEAT: the reading row must track the input. A frozen or half-applying
|
||||
// scroller (near-zero motion) is caught here well before the lurch count.
|
||||
expect(totalApplied).toBeGreaterThan(SWIPES * 200);
|
||||
|
||||
// THE GATE. RED at tip under the WebKit mirror (dozens of felt lurches up to
|
||||
// ~200px); Dawn's engine-order-independent fix turns it green on both engines.
|
||||
expect(feltLurches.length).toBeLessThanOrEqual(MAX_FELT_LURCHES);
|
||||
expect(smoothFraction).toBeGreaterThanOrEqual(MIN_SMOOTH_FRACTION);
|
||||
// Quinn's skip-forever guard: sustained sub-lurch under-compensation.
|
||||
expect(rmsDeviation).toBeLessThanOrEqual(MAX_RMS_DEVIATION_PX);
|
||||
});
|
||||
Reference in New Issue
Block a user