mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
test(desktop): live-staging upscroll probes + WebKit perf project
Two live probes that run the built GUI against a real relay (port-forward + Host rewrite) and walk real scrollback in a real channel: - upscroll-1px-live.perf.ts: settled steps (BUZZ_PERF_STEP_PX), scores per-step row movement through real fetchOlder loads, measures the felt jump at each prepend commit. - upscroll-trackpad-live.perf.ts: macOS trackpad replica — wheel events at ~8ms spacing in finger-ramp + momentum-decay swipe profiles, no settling, with a per-RAF sampler scoring tracked-row motion vs applied scroll. Plus playwright.perf-webkit.config.ts: the same probes on Playwright WebKit. This is the config that exposed the T2 compensation being near-ineffective on WebKit-family engines under momentum input while Chromium scored clean — Chromium-only probing cannot see the defect class the shipped WKWebView app exhibits. 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
104f3cd62e
commit
6b9203cad7
@@ -0,0 +1,23 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
timeout: 900_000,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: [["list"]],
|
||||
use: { baseURL: "http://127.0.0.1:4173" },
|
||||
projects: [
|
||||
{
|
||||
name: "perf-webkit",
|
||||
testMatch: ["**/*.perf.ts"],
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "python3 -m http.server 4173 -d dist",
|
||||
cwd: ".",
|
||||
reuseExistingServer: true,
|
||||
url: "http://127.0.0.1:4173",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,433 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { decode } from "nostr-tools/nip19";
|
||||
import { getPublicKey } from "nostr-tools/pure";
|
||||
|
||||
import { installRelayBridge } from "../helpers/bridge";
|
||||
|
||||
/**
|
||||
* LIVE 1-PIXEL UPSCROLL PROBE — Tyler's ask (2026-07-08):
|
||||
* run the GUI against the LIVE staging relay, scroll up 1px at a time for
|
||||
* many thousands of pixels (crossing multiple real fetchOlder scrollback
|
||||
* loads in #buzz-bugs), and record how many pixels of VISIBLE MOVEMENT occur
|
||||
* per 1px of input.
|
||||
*
|
||||
* Method: same live-relay harness as scrollback-buzzbugs.perf.ts (identity
|
||||
* override, port-forward + Host rewrite, ACAO injection). Actuation is a
|
||||
* synchronous `scrollTop -= 1` + scroll event per step with one RAF settle,
|
||||
* batched in-page (800 steps/evaluate) so 15k+ steps are feasible. Per step
|
||||
* we record:
|
||||
* move = tracked center row's rect.top delta (the felt movement)
|
||||
* appliedTop = scrollTop_before - scrollTop_after (what the scroller did)
|
||||
* mounted = [data-message-id] count (prepend detection)
|
||||
* fetch = __CHANNEL_WINDOW_FETCH_COUNT__ (fetchOlder activity)
|
||||
* Smooth = move === +1 every step. Jank = |move| >> 1 (realization lurch,
|
||||
* anchor jump, prepend re-anchor).
|
||||
*
|
||||
* WKWebView mirror: `overflow-anchor: none` forced on the scroller (the
|
||||
* shipped engine has no scroll anchoring); the production computed value is
|
||||
* logged first, un-forced, for the anchor-contract record.
|
||||
*
|
||||
* Run:
|
||||
* kubectl --context bke-coder-stage -n sprout port-forward svc/sprout-relay 13000:3000 &
|
||||
* BUZZ_E2E_RELAY_URL=http://127.0.0.1:13000 \
|
||||
* BUZZ_COMMUNITY_HOST=sprout-oss.stage.blox.sqprod.co \
|
||||
* BUZZ_PERF_NSEC=nsec1... \
|
||||
* npx playwright test --config=playwright.perf.config.ts upscroll-1px-live
|
||||
*/
|
||||
|
||||
const RELAY_HTTP =
|
||||
process.env.BUZZ_E2E_RELAY_URL ?? "https://sprout-oss.stage.blox.sqprod.co";
|
||||
const NSEC = process.env.BUZZ_PERF_NSEC ?? "";
|
||||
const COMMUNITY_HOST = process.env.BUZZ_COMMUNITY_HOST ?? "";
|
||||
const TARGET_CHANNEL = process.env.BUZZ_PERF_CHANNEL ?? "buzz-bugs";
|
||||
// Stop when BOTH: at least this many fetchOlder pages landed AND this many
|
||||
// pixels walked — or the step cap / top of history, whichever first.
|
||||
const MIN_FETCHES = Number(process.env.BUZZ_PERF_MIN_FETCHES ?? 3);
|
||||
const MIN_PX = Number(process.env.BUZZ_PERF_MIN_PX ?? 8000);
|
||||
const MAX_STEPS = Number(process.env.BUZZ_PERF_MAX_STEPS ?? 20000);
|
||||
const BATCH = 800;
|
||||
const SAFE_MARGIN = 60;
|
||||
// Pixels per input step (1 = original probe; 100+ = Tyler's big-notch repro).
|
||||
const STEP_PX = Number(process.env.BUZZ_PERF_STEP_PX ?? 1);
|
||||
|
||||
const IDENTITY_OVERRIDE_KEY = "buzz:e2e-identity-override.v1";
|
||||
const ONBOARDING_PREFIX = "buzz-onboarding-complete.v1:";
|
||||
const WELCOME_PREFIX = "buzz-welcome-channel-ensured.v2:";
|
||||
const REAL_CHROME_UA =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36";
|
||||
|
||||
test.use({ userAgent: REAL_CHROME_UA });
|
||||
|
||||
function deriveIdentity(nsec: string) {
|
||||
const decoded = decode(nsec.trim());
|
||||
if (decoded.type !== "nsec") throw new Error("BUZZ_PERF_NSEC is not an nsec");
|
||||
const skBytes = decoded.data as Uint8Array;
|
||||
return {
|
||||
privateKey: Buffer.from(skBytes).toString("hex"),
|
||||
pubkey: getPublicKey(skBytes),
|
||||
username: "perf-eva",
|
||||
};
|
||||
}
|
||||
|
||||
type Step = {
|
||||
i: number;
|
||||
move: number | null; // tracked row rect.top delta; null = row lost this step
|
||||
appliedTop: number;
|
||||
mounted: number;
|
||||
fetch: number;
|
||||
scrollTop: number;
|
||||
repick: boolean; // tracked row re-picked before this step
|
||||
};
|
||||
|
||||
test("MEASURE: live 1px upscroll movement profile", async ({ page }) => {
|
||||
test.setTimeout(900_000);
|
||||
if (!NSEC) throw new Error("Set BUZZ_PERF_NSEC to a real member nsec");
|
||||
const identity = deriveIdentity(NSEC);
|
||||
|
||||
await installRelayBridge(page, "tyler");
|
||||
const wsUrl = RELAY_HTTP.replace(/^http/, "ws");
|
||||
await page.addInitScript(
|
||||
({ ident, onboardingPrefix, welcomePrefix, relayUrl, overrideKey }) => {
|
||||
window.localStorage.setItem(overrideKey, JSON.stringify(ident));
|
||||
window.localStorage.setItem(`${onboardingPrefix}${ident.pubkey}`, "true");
|
||||
window.localStorage.setItem(
|
||||
`${welcomePrefix}${encodeURIComponent(relayUrl)}:${ident.pubkey}`,
|
||||
"true",
|
||||
);
|
||||
const w = window as unknown as { __BUZZ_E2E__?: Record<string, unknown> };
|
||||
w.__BUZZ_E2E__ = { ...(w.__BUZZ_E2E__ ?? {}), identity: ident };
|
||||
},
|
||||
{
|
||||
ident: identity,
|
||||
onboardingPrefix: ONBOARDING_PREFIX,
|
||||
welcomePrefix: WELCOME_PREFIX,
|
||||
relayUrl: wsUrl,
|
||||
overrideKey: IDENTITY_OVERRIDE_KEY,
|
||||
},
|
||||
);
|
||||
|
||||
const relayHost = new URL(RELAY_HTTP).host;
|
||||
await page.route(
|
||||
(url) => url.host === relayHost,
|
||||
async (route) => {
|
||||
const req = route.request();
|
||||
const fwd = { ...req.headers(), "user-agent": REAL_CHROME_UA };
|
||||
delete fwd["sec-ch-ua"];
|
||||
delete fwd["sec-ch-ua-mobile"];
|
||||
delete fwd["sec-ch-ua-platform"];
|
||||
if (COMMUNITY_HOST) fwd.host = COMMUNITY_HOST;
|
||||
// The port-forward can drop mid-run; retry a few times rather than
|
||||
// aborting a 15k-step walk on one transient ECONNREFUSED.
|
||||
let resp: Awaited<ReturnType<typeof route.fetch>> | null = null;
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
resp = await route.fetch({ headers: fwd });
|
||||
break;
|
||||
} catch (err) {
|
||||
if (attempt === 4) throw err;
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
}
|
||||
}
|
||||
if (!resp) throw new Error("unreachable");
|
||||
const body = req.postData() ?? "";
|
||||
if (body.includes("before_id") || body.includes("until")) {
|
||||
console.log(
|
||||
`[net] ${new URL(req.url()).pathname} ${resp.status()} ${(await resp.body()).byteLength}b body=${body.slice(0, 140)}`,
|
||||
);
|
||||
}
|
||||
const headers = { ...resp.headers() };
|
||||
headers["access-control-allow-origin"] = "*";
|
||||
headers["access-control-allow-headers"] = "*";
|
||||
headers["access-control-allow-methods"] = "*";
|
||||
await route.fulfill({ response: resp, headers, body: await resp.body() });
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("app-sidebar").waitFor({ state: "visible" });
|
||||
const chan = page.getByTestId(`channel-${TARGET_CHANNEL}`).first();
|
||||
await chan.waitFor({ state: "visible", timeout: 45_000 });
|
||||
await chan.click();
|
||||
await page
|
||||
.locator('[data-testid="message-timeline"] [data-message-id]')
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 30_000 });
|
||||
await page.waitForTimeout(3000); // let the open burst settle
|
||||
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
|
||||
// Production anchor contract, read BEFORE forcing anything.
|
||||
const anchor = await timeline.evaluate((el) => ({
|
||||
supports: CSS.supports("overflow-anchor", "none"),
|
||||
computed: getComputedStyle(el as HTMLElement).overflowAnchor ?? "(none)",
|
||||
}));
|
||||
console.log(
|
||||
`[anchor] CSS.supports=${anchor.supports} production computed=${anchor.computed}`,
|
||||
);
|
||||
// WKWebView mirror: no engine-side anchoring during the walk.
|
||||
await timeline.evaluate((el) => {
|
||||
(el as HTMLElement).style.overflowAnchor = "none";
|
||||
});
|
||||
|
||||
// Pin to true bottom.
|
||||
await timeline.evaluate((element) => {
|
||||
const el = element as HTMLDivElement;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
el.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const all: Step[] = [];
|
||||
const prependCommits: Array<{
|
||||
step: number;
|
||||
mountedFrom: number;
|
||||
mountedTo: number;
|
||||
scrollTopTo: number;
|
||||
feltJump: number | null;
|
||||
}> = [];
|
||||
let ended = "cap";
|
||||
let stepIndex = 0;
|
||||
|
||||
while (stepIndex < MAX_STEPS) {
|
||||
const batch = await timeline.evaluate(
|
||||
async (
|
||||
element,
|
||||
args: { n: number; margin: number; base: number; step: number },
|
||||
) => {
|
||||
const el = element as HTMLDivElement;
|
||||
const probe = window as unknown as {
|
||||
__CHANNEL_WINDOW_FETCH_COUNT__?: number;
|
||||
};
|
||||
const out: Array<{
|
||||
i: number;
|
||||
move: number | null;
|
||||
appliedTop: number;
|
||||
mounted: number;
|
||||
fetch: number;
|
||||
scrollTop: number;
|
||||
repick: boolean;
|
||||
}> = [];
|
||||
let end = "";
|
||||
let trackedId: string | null = null;
|
||||
|
||||
const pick = (): { id: string; top: number } | null => {
|
||||
const box = el.getBoundingClientRect();
|
||||
const mid = box.top + box.height / 2;
|
||||
let best: { id: string; top: number; d: number } | null = null;
|
||||
for (const row of el.querySelectorAll<HTMLElement>(
|
||||
"[data-message-id]",
|
||||
)) {
|
||||
const r = row.getBoundingClientRect();
|
||||
if (
|
||||
r.top <= box.top + args.margin ||
|
||||
r.bottom >= box.bottom - args.margin
|
||||
)
|
||||
continue;
|
||||
const d = Math.abs((r.top + r.bottom) / 2 - mid);
|
||||
if (!best || d < best.d)
|
||||
best = { id: row.dataset.messageId ?? "", top: r.top, d };
|
||||
}
|
||||
return best && best.id ? { id: best.id, top: best.top } : null;
|
||||
};
|
||||
|
||||
const locate = (id: string): number | null => {
|
||||
const box = el.getBoundingClientRect();
|
||||
const row = el.querySelector<HTMLElement>(
|
||||
`[data-message-id="${CSS.escape(id)}"]`,
|
||||
);
|
||||
if (!row) return null;
|
||||
const r = row.getBoundingClientRect();
|
||||
if (
|
||||
r.top <= box.top + args.margin ||
|
||||
r.bottom >= box.bottom - args.margin
|
||||
)
|
||||
return null; // left safe band
|
||||
return r.top;
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.n; i++) {
|
||||
if (el.scrollTop <= 0) {
|
||||
end = "top";
|
||||
break;
|
||||
}
|
||||
let repick = false;
|
||||
let beforeTop = trackedId ? locate(trackedId) : null;
|
||||
if (beforeTop === null) {
|
||||
const p = pick();
|
||||
repick = true;
|
||||
if (!p) {
|
||||
trackedId = null;
|
||||
} else {
|
||||
trackedId = p.id;
|
||||
beforeTop = p.top;
|
||||
}
|
||||
}
|
||||
const beforeScroll = el.scrollTop;
|
||||
|
||||
el.scrollTop = Math.max(0, el.scrollTop - args.step);
|
||||
el.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
await new Promise<void>((r) => requestAnimationFrame(() => r()));
|
||||
|
||||
const afterTop = trackedId ? locate(trackedId) : null;
|
||||
out.push({
|
||||
i: args.base + i,
|
||||
move:
|
||||
beforeTop !== null && afterTop !== null
|
||||
? afterTop - beforeTop
|
||||
: null,
|
||||
appliedTop: beforeScroll - el.scrollTop,
|
||||
mounted: el.querySelectorAll("[data-message-id]").length,
|
||||
fetch: probe.__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0,
|
||||
scrollTop: el.scrollTop,
|
||||
repick,
|
||||
});
|
||||
}
|
||||
return { out, end };
|
||||
},
|
||||
{ n: BATCH, margin: SAFE_MARGIN, base: stepIndex, step: STEP_PX },
|
||||
);
|
||||
|
||||
all.push(...batch.out);
|
||||
stepIndex += batch.out.length;
|
||||
const fetches = all.length ? all[all.length - 1].fetch : 0;
|
||||
const walked = all.reduce((s, x) => s + Math.max(0, x.appliedTop), 0);
|
||||
console.log(
|
||||
`[progress] steps=${stepIndex} walkedPx=${walked.toFixed(0)} fetches=${fetches} mounted=${all[all.length - 1]?.mounted ?? "?"} scrollTop=${all[all.length - 1]?.scrollTop.toFixed(0) ?? "?"}`,
|
||||
);
|
||||
if (batch.end === "top") {
|
||||
// We are at scrollTop=0. A fetchOlder page may be in flight (the 600px
|
||||
// sentinel fires well before the top). Wait for the prepend to commit,
|
||||
// measure the FELT jump it causes on a visible row, then keep walking
|
||||
// into the paged-in history. Only a timeout means true top-of-history.
|
||||
const beforeWait = await timeline.evaluate((element) => {
|
||||
const el = element as HTMLDivElement;
|
||||
const first = el.querySelector<HTMLElement>("[data-message-id]");
|
||||
return {
|
||||
mounted: el.querySelectorAll("[data-message-id]").length,
|
||||
scrollTop: el.scrollTop,
|
||||
firstId: first?.dataset.messageId ?? null,
|
||||
firstTop: first?.getBoundingClientRect().top ?? 0,
|
||||
};
|
||||
});
|
||||
const grew = await page
|
||||
.waitForFunction(
|
||||
(n) =>
|
||||
document.querySelectorAll(
|
||||
'[data-testid="message-timeline"] [data-message-id]',
|
||||
).length > n,
|
||||
beforeWait.mounted,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!grew) {
|
||||
ended = "top-of-history";
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(300); // let the re-anchor settle
|
||||
const afterWait = await timeline.evaluate(
|
||||
(element, firstId: string | null) => {
|
||||
const el = element as HTMLDivElement;
|
||||
const row = firstId
|
||||
? el.querySelector<HTMLElement>(
|
||||
`[data-message-id="${CSS.escape(firstId)}"]`,
|
||||
)
|
||||
: null;
|
||||
return {
|
||||
mounted: el.querySelectorAll("[data-message-id]").length,
|
||||
scrollTop: el.scrollTop,
|
||||
firstTop: row?.getBoundingClientRect().top ?? null,
|
||||
};
|
||||
},
|
||||
beforeWait.firstId,
|
||||
);
|
||||
const felt =
|
||||
afterWait.firstTop !== null
|
||||
? afterWait.firstTop - beforeWait.firstTop
|
||||
: null;
|
||||
console.log(
|
||||
`[prepend-commit] mounted ${beforeWait.mounted}->${afterWait.mounted} scrollTop ${beforeWait.scrollTop.toFixed(0)}->${afterWait.scrollTop.toFixed(0)} FELT-JUMP of anchored row=${felt === null ? "row-gone" : `${felt.toFixed(1)}px`}`,
|
||||
);
|
||||
prependCommits.push({
|
||||
step: stepIndex,
|
||||
mountedFrom: beforeWait.mounted,
|
||||
mountedTo: afterWait.mounted,
|
||||
scrollTopTo: afterWait.scrollTop,
|
||||
feltJump: felt,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (fetches >= MIN_FETCHES && walked >= MIN_PX) {
|
||||
ended = "target-reached";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- REPORT ----
|
||||
const scored = all.filter((s) => s.move !== null) as Array<
|
||||
Step & { move: number }
|
||||
>;
|
||||
const hist = new Map<number, number>();
|
||||
for (const s of scored) {
|
||||
const b = Math.round(s.move);
|
||||
hist.set(b, (hist.get(b) ?? 0) + 1);
|
||||
}
|
||||
console.log(
|
||||
`\n=== LIVE UPSCROLL PROFILE (step=${STEP_PX}px): #${TARGET_CHANNEL} ===`,
|
||||
);
|
||||
console.log(
|
||||
`relay=${RELAY_HTTP} steps=${all.length} scored=${scored.length} ended=${ended}`,
|
||||
);
|
||||
console.log(
|
||||
`total input px=${all.length} · applied px=${all.reduce((s, x) => s + Math.max(0, x.appliedTop), 0)} · fetchOlder pages=${all.length ? all[all.length - 1].fetch : 0}`,
|
||||
);
|
||||
console.log("\n--- movement histogram (px moved per 1px input, rounded) ---");
|
||||
for (const [k, v] of [...hist.entries()].sort((a, b) => a[0] - b[0]))
|
||||
console.log(` ${String(k).padStart(6)}px : ${v}`);
|
||||
|
||||
const smooth = scored.filter(
|
||||
(s) => Math.abs(s.move - STEP_PX) <= Math.max(0.5, STEP_PX * 0.1),
|
||||
).length;
|
||||
console.log(
|
||||
`\nsmooth steps (move≈STEP=${STEP_PX}px): ${smooth}/${scored.length} = ${((100 * smooth) / Math.max(1, scored.length)).toFixed(2)}%`,
|
||||
);
|
||||
|
||||
const jumps = scored
|
||||
.filter((s) => Math.abs(s.move - STEP_PX) > Math.max(2, STEP_PX * 0.25))
|
||||
.sort((a, b) => Math.abs(b.move) - Math.abs(a.move));
|
||||
console.log(
|
||||
`\n--- jumps |move-STEP| > max(2, STEP*0.25): ${jumps.length} ---`,
|
||||
);
|
||||
let prevFetch = 0;
|
||||
const fetchSteps: number[] = [];
|
||||
for (const s of all) {
|
||||
if (s.fetch > prevFetch) fetchSteps.push(s.i);
|
||||
prevFetch = s.fetch;
|
||||
}
|
||||
console.log(`fetchOlder landed at steps: ${fetchSteps.join(", ")}`);
|
||||
for (const s of jumps.slice(0, 40))
|
||||
console.log(
|
||||
` step=${s.i} move=${s.move.toFixed(1)}px applied=${s.appliedTop.toFixed(1)} mounted=${s.mounted} fetch=${s.fetch} scrollTop=${s.scrollTop.toFixed(0)} repick=${s.repick}`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`\n--- prepend commits observed at top (${prependCommits.length}) ---`,
|
||||
);
|
||||
for (const c of prependCommits)
|
||||
console.log(
|
||||
` step=${c.step} mounted ${c.mountedFrom}->${c.mountedTo} scrollTopAfter=${c.scrollTopTo.toFixed(0)} feltJump=${c.feltJump === null ? "row-gone" : `${c.feltJump.toFixed(1)}px`}`,
|
||||
);
|
||||
|
||||
// Prepend re-anchors (scrollTop jumped up) for the record.
|
||||
const reanchors = all.filter((s) => s.appliedTop < -50);
|
||||
console.log(
|
||||
`\n--- prepend re-anchors (appliedTop < -50): ${reanchors.length} ---`,
|
||||
);
|
||||
for (const s of reanchors.slice(0, 20))
|
||||
console.log(
|
||||
` step=${s.i} appliedTop=${s.appliedTop.toFixed(0)} mounted=${s.mounted} fetch=${s.fetch} scrollTop=${s.scrollTop.toFixed(0)}`,
|
||||
);
|
||||
console.log("==============================================\n");
|
||||
|
||||
expect(all.length * STEP_PX).toBeGreaterThan(1000);
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { decode } from "nostr-tools/nip19";
|
||||
import { getPublicKey } from "nostr-tools/pure";
|
||||
|
||||
import { installRelayBridge } from "../helpers/bridge";
|
||||
|
||||
/**
|
||||
* LIVE TRACKPAD-MOMENTUM UPSCROLL PROBE — Tyler: "the jumping is bad even on
|
||||
* PR 1650" with a trackpad. The prior probes settled 1-2 RAFs after every
|
||||
* step; a trackpad does not. This probe replicates macOS trackpad swipes:
|
||||
* trusted CDP mouseWheel events at ~8ms spacing — finger ramp then an
|
||||
* exponentially-decaying momentum tail — fired CONTINUOUSLY through real
|
||||
* fetchOlder commits in #buzz-bugs against live staging. No settling.
|
||||
*
|
||||
* Measurement is a per-RAF in-page sampler, independent of input timing:
|
||||
* every frame it records scrollTop + the tracked center row's rect.top +
|
||||
* mounted count + fetch count. For a solid page,
|
||||
* rowMove(frame) = rect.top delta = scrollTop_before - scrollTop_after.
|
||||
* Per-frame deviation = rowMove - appliedScroll. Nonzero = content shifted
|
||||
* under the viewport that the input didn't ask for — the felt jump —
|
||||
* regardless of whether it came from CV realization, a losing compensation
|
||||
* race, or a prepend anchor miss.
|
||||
*
|
||||
* Run: same env as upscroll-1px-live.perf.ts.
|
||||
*/
|
||||
|
||||
const RELAY_HTTP =
|
||||
process.env.BUZZ_E2E_RELAY_URL ?? "https://sprout-oss.stage.blox.sqprod.co";
|
||||
const NSEC = process.env.BUZZ_PERF_NSEC ?? "";
|
||||
const COMMUNITY_HOST = process.env.BUZZ_COMMUNITY_HOST ?? "";
|
||||
const TARGET_CHANNEL = process.env.BUZZ_PERF_CHANNEL ?? "buzz-bugs";
|
||||
const SWIPES = Number(process.env.BUZZ_PERF_SWIPES ?? 30);
|
||||
const SAFE_MARGIN = 60;
|
||||
|
||||
const IDENTITY_OVERRIDE_KEY = "buzz:e2e-identity-override.v1";
|
||||
const ONBOARDING_PREFIX = "buzz-onboarding-complete.v1:";
|
||||
const WELCOME_PREFIX = "buzz-welcome-channel-ensured.v2:";
|
||||
const REAL_CHROME_UA =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36";
|
||||
|
||||
test.use({ userAgent: REAL_CHROME_UA });
|
||||
|
||||
function deriveIdentity(nsec: string) {
|
||||
const decoded = decode(nsec.trim());
|
||||
if (decoded.type !== "nsec") throw new Error("BUZZ_PERF_NSEC is not an nsec");
|
||||
const skBytes = decoded.data as Uint8Array;
|
||||
return {
|
||||
privateKey: Buffer.from(skBytes).toString("hex"),
|
||||
pubkey: getPublicKey(skBytes),
|
||||
username: "perf-eva",
|
||||
};
|
||||
}
|
||||
|
||||
// One macOS-ish swipe: finger ramp (accelerating) + momentum tail (exp decay).
|
||||
function swipeDeltas(): number[] {
|
||||
const deltas: number[] = [];
|
||||
// finger: ~12 events ramping 4 -> 36 px
|
||||
for (let i = 0; i < 12; i++) deltas.push(4 + Math.round((32 * i) / 11));
|
||||
// momentum: decay from 36 by 0.94/event until < 1
|
||||
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("MEASURE: live trackpad-momentum upscroll profile", async ({ page }) => {
|
||||
test.setTimeout(900_000);
|
||||
if (!NSEC) throw new Error("Set BUZZ_PERF_NSEC to a real member nsec");
|
||||
const identity = deriveIdentity(NSEC);
|
||||
|
||||
await installRelayBridge(page, "tyler");
|
||||
const wsUrl = RELAY_HTTP.replace(/^http/, "ws");
|
||||
await page.addInitScript(
|
||||
({ ident, onboardingPrefix, welcomePrefix, relayUrl, overrideKey }) => {
|
||||
window.localStorage.setItem(overrideKey, JSON.stringify(ident));
|
||||
window.localStorage.setItem(`${onboardingPrefix}${ident.pubkey}`, "true");
|
||||
window.localStorage.setItem(
|
||||
`${welcomePrefix}${encodeURIComponent(relayUrl)}:${ident.pubkey}`,
|
||||
"true",
|
||||
);
|
||||
const w = window as unknown as { __BUZZ_E2E__?: Record<string, unknown> };
|
||||
w.__BUZZ_E2E__ = { ...(w.__BUZZ_E2E__ ?? {}), identity: ident };
|
||||
},
|
||||
{
|
||||
ident: identity,
|
||||
onboardingPrefix: ONBOARDING_PREFIX,
|
||||
welcomePrefix: WELCOME_PREFIX,
|
||||
relayUrl: wsUrl,
|
||||
overrideKey: IDENTITY_OVERRIDE_KEY,
|
||||
},
|
||||
);
|
||||
|
||||
const relayHost = new URL(RELAY_HTTP).host;
|
||||
await page.route(
|
||||
(url) => url.host === relayHost,
|
||||
async (route) => {
|
||||
const req = route.request();
|
||||
const fwd = { ...req.headers(), "user-agent": REAL_CHROME_UA };
|
||||
delete fwd["sec-ch-ua"];
|
||||
delete fwd["sec-ch-ua-mobile"];
|
||||
delete fwd["sec-ch-ua-platform"];
|
||||
if (COMMUNITY_HOST) fwd.host = COMMUNITY_HOST;
|
||||
let resp: Awaited<ReturnType<typeof route.fetch>> | null = null;
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
resp = await route.fetch({ headers: fwd });
|
||||
break;
|
||||
} catch (err) {
|
||||
if (attempt === 4) throw err;
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
}
|
||||
}
|
||||
if (!resp) throw new Error("unreachable");
|
||||
const headers = { ...resp.headers() };
|
||||
headers["access-control-allow-origin"] = "*";
|
||||
headers["access-control-allow-headers"] = "*";
|
||||
headers["access-control-allow-methods"] = "*";
|
||||
await route.fulfill({ response: resp, headers, body: await resp.body() });
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("app-sidebar").waitFor({ state: "visible" });
|
||||
const chan = page.getByTestId(`channel-${TARGET_CHANNEL}`).first();
|
||||
await chan.waitFor({ state: "visible", timeout: 45_000 });
|
||||
await chan.click();
|
||||
await page
|
||||
.locator('[data-testid="message-timeline"] [data-message-id]')
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 30_000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
// WKWebView mirror.
|
||||
await timeline.evaluate((el) => {
|
||||
(el as HTMLElement).style.overflowAnchor = "none";
|
||||
});
|
||||
await timeline.evaluate((element) => {
|
||||
const el = element as HTMLDivElement;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
el.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
});
|
||||
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 ----
|
||||
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));
|
||||
}
|
||||
// brief finger-off pause between swipes, like a real gesture chain
|
||||
await page.waitForTimeout(120);
|
||||
const at = await timeline.evaluate(
|
||||
(el) => (el as HTMLDivElement).scrollTop,
|
||||
);
|
||||
if (at <= 0) {
|
||||
// at the wall — give the prepend a chance to land, keep swiping after
|
||||
await page.waitForTimeout(2500);
|
||||
}
|
||||
if (s % 5 === 4)
|
||||
console.log(
|
||||
`[swipe ${s + 1}/${SWIPES}] scrollTop=${at.toFixed(0)} frames=${await page.evaluate(() => (window as unknown as { __FRAMES__: unknown[] }).__FRAMES__.length)}`,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
const scored = devs.filter((d) => !d.mountedGrew); // prepend-commit frames reported separately
|
||||
const commits = devs.filter((d) => d.mountedGrew);
|
||||
const jumps = scored
|
||||
.filter((d) => Math.abs(d.dev) > 2)
|
||||
.sort((x, y) => Math.abs(y.dev) - Math.abs(x.dev));
|
||||
|
||||
const hist = new Map<number, number>();
|
||||
for (const d of scored) {
|
||||
const bucket =
|
||||
Math.abs(d.dev) <= 1
|
||||
? 0
|
||||
: Math.sign(d.dev) * 2 ** Math.ceil(Math.log2(Math.abs(d.dev)));
|
||||
hist.set(bucket, (hist.get(bucket) ?? 0) + 1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n=== LIVE TRACKPAD PROFILE: #${TARGET_CHANNEL} engine=${page.context().browser()?.browserType().name()} ===`,
|
||||
);
|
||||
console.log(
|
||||
`frames=${frames.length} scoredFramePairs=${scored.length} swipes=${SWIPES} fetchPages=${frames[frames.length - 1]?.fetch ?? 0} finalMounted=${frames[frames.length - 1]?.mounted ?? 0}`,
|
||||
);
|
||||
console.log(
|
||||
"\n--- per-frame deviation histogram (0 = tracked row moved exactly what input asked; buckets are +/- powers of 2 px) ---",
|
||||
);
|
||||
for (const [k, v] of [...hist.entries()].sort((a, b) => a[0] - b[0]))
|
||||
console.log(` ${String(k).padStart(6)} : ${v}`);
|
||||
console.log(
|
||||
`\nsmooth frames (|dev|<=1px): ${scored.filter((d) => Math.abs(d.dev) <= 1).length}/${scored.length} = ${((100 * scored.filter((d) => Math.abs(d.dev) <= 1).length) / Math.max(1, scored.length)).toFixed(2)}%`,
|
||||
);
|
||||
console.log(`\n--- jump frames |dev|>2px: ${jumps.length} ---`);
|
||||
for (const d of jumps.slice(0, 50))
|
||||
console.log(
|
||||
` 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--- prepend-commit frames (${commits.length}) ---`);
|
||||
for (const d of commits)
|
||||
console.log(
|
||||
` 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)}`,
|
||||
);
|
||||
|
||||
// long frames = jank of a different kind (main-thread stalls)
|
||||
const longFrames = devs.filter((d) => d.dt > 34);
|
||||
console.log(
|
||||
`\n--- long frames (>34ms, i.e. dropped at least one 60Hz frame): ${longFrames.length} ---`,
|
||||
);
|
||||
for (const d of longFrames.slice(0, 25))
|
||||
console.log(
|
||||
` frame=${d.i} dt=${d.dt.toFixed(0)}ms dev=${d.dev.toFixed(1)} applied=${d.applied.toFixed(1)} fetch=${d.fetch} scrollTop=${d.scrollTop.toFixed(0)}`,
|
||||
);
|
||||
console.log("==============================================\n");
|
||||
|
||||
expect(frames.length).toBeGreaterThan(500);
|
||||
});
|
||||
Reference in New Issue
Block a user