mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
Three review findings against the tracer's honesty contract: - The readiness -> paint frame was unguarded. `awaitDeferredCommit` checks age, frame gap, and visibility on every frame it drives, but the one rAF it queues before `record()` checked neither age nor gap, so a suspension in that seam (App Nap fires no visibilitychange) recorded the whole absence as a clean switch. - DM actions await `open_dm` before `goChannel`, so that relay round-trip sat outside the measurement; Pulse's note actions used raw `navigate` and produced no measurement at all. Callers now capture the click anchor and hand it to `goChannel`, and Pulse routes through `goChannel`. - A `navigate()` rejection left the trace it opened active, so an untraced re-entry could settle the failed attempt. Cancellation is by trace identity, so a newer same-channel attempt is never erased. Signed-off-by: Max Lampert <maxwell@squareup.com>
638 lines
24 KiB
JavaScript
638 lines
24 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
shouldAttributeFetch,
|
|
buildSwitchPerfLogRecord,
|
|
resolveSettleAction,
|
|
CHANNEL_SWITCH_MEASURE,
|
|
beginChannelSwitchTrace,
|
|
resetChannelSwitchTrace,
|
|
resolveFinalFrame,
|
|
resolveSettleWait,
|
|
settleChannelSwitchTrace,
|
|
summarizeChannelSwitchTrace,
|
|
} from "./channelSwitchPerf.ts";
|
|
|
|
function trace(overrides = {}) {
|
|
return {
|
|
channelId: "abcdef1234567890",
|
|
startedAt: 1_000,
|
|
routeCommitAt: null,
|
|
windowFetch: null,
|
|
membersFetch: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
test("summary reports total and cache-served fetches", () => {
|
|
const summary = summarizeChannelSwitchTrace(trace(), 1_412.4);
|
|
assert.equal(
|
|
summary,
|
|
"[switch-perf] channel=abcdef12 total=412ms commit=? window=cache members=cache",
|
|
);
|
|
});
|
|
|
|
test("summary includes route commit offset and fetch timings", () => {
|
|
const summary = summarizeChannelSwitchTrace(
|
|
trace({
|
|
routeCommitAt: 1_038,
|
|
windowFetch: { durationMs: 180.6, eventCount: 250 },
|
|
membersFetch: { durationMs: 320.2, memberCount: 10_000 },
|
|
}),
|
|
1_912,
|
|
);
|
|
assert.equal(
|
|
summary,
|
|
"[switch-perf] channel=abcdef12 total=912ms commit=+38ms " +
|
|
"window=250 events in 181ms members=10000 members in 320ms",
|
|
);
|
|
});
|
|
|
|
test("log record carries rounded stage timings and fetch attributions", () => {
|
|
const record = buildSwitchPerfLogRecord(
|
|
trace({
|
|
routeCommitAt: 1_038.4,
|
|
windowFetch: { durationMs: 180.6, eventCount: 250 },
|
|
membersFetch: { durationMs: 320.2, memberCount: 10_000 },
|
|
}),
|
|
1_912.3,
|
|
);
|
|
assert.equal(record.channelId, "abcdef1234567890");
|
|
assert.equal(record.totalMs, 912);
|
|
assert.equal(record.commitOffsetMs, 38);
|
|
assert.deepEqual(record.windowFetch, { durationMs: 181, eventCount: 250 });
|
|
assert.deepEqual(record.membersFetch, {
|
|
durationMs: 320,
|
|
memberCount: 10_000,
|
|
});
|
|
assert.equal(typeof record.ts, "string");
|
|
});
|
|
|
|
test("log record marks cache-served fetches and missing commit as null", () => {
|
|
const record = buildSwitchPerfLogRecord(trace(), 1_412);
|
|
assert.equal(record.commitOffsetMs, null);
|
|
assert.equal(record.windowFetch, null);
|
|
assert.equal(record.membersFetch, null);
|
|
});
|
|
|
|
test("settle resolves only the trace for the settled channel", () => {
|
|
const active = trace();
|
|
assert.deepEqual(resolveSettleAction(active, "abcdef1234567890", 2_000), {
|
|
settledTrace: active,
|
|
clearActive: true,
|
|
});
|
|
assert.deepEqual(resolveSettleAction(null, "abcdef1234567890", 2_000), {
|
|
settledTrace: null,
|
|
clearActive: false,
|
|
});
|
|
});
|
|
|
|
test("a mismatched settle never clobbers a newer switch's trace", () => {
|
|
// Channel A settles after the user already clicked channel B: B's trace
|
|
// must survive so B still gets measured.
|
|
const nextSwitch = trace({ channelId: "bbbb0000bbbb0000" });
|
|
assert.deepEqual(resolveSettleAction(nextSwitch, "abcdef1234567890", 2_000), {
|
|
settledTrace: null,
|
|
clearActive: false,
|
|
});
|
|
});
|
|
|
|
test("settle drops a trace that has timed out", () => {
|
|
const stale = trace({ startedAt: 1_000 });
|
|
assert.deepEqual(resolveSettleAction(stale, "abcdef1234567890", 31_001), {
|
|
settledTrace: null,
|
|
clearActive: true,
|
|
});
|
|
assert.deepEqual(
|
|
resolveSettleAction(stale, "abcdef1234567890", 11_000).settledTrace,
|
|
stale,
|
|
);
|
|
});
|
|
|
|
test("the settle wait records truncated — never as an honest settle — at deadline", () => {
|
|
// Still pending, before the deadline: keep waiting.
|
|
assert.equal(resolveSettleWait(4_999, 5_000, true, 0), "wait");
|
|
// Render caught up: record cleanly.
|
|
assert.deepEqual(resolveSettleWait(1_000, 5_000, false, 0), {
|
|
settleWaitTruncated: false,
|
|
});
|
|
// Deadline expired while still pending: the record must say so — a >5s
|
|
// switch reported as an ordinary settle would hide exactly the tail this
|
|
// tracer exists to expose.
|
|
assert.deepEqual(resolveSettleWait(5_000, 5_000, true, 0), {
|
|
settleWaitTruncated: true,
|
|
});
|
|
});
|
|
|
|
test("a frame-starved trace is dropped, not recorded as a clean settle", () => {
|
|
// rAF suspends in hidden windows, so a queued settle can fire minutes
|
|
// after the click with renderPending long since false — the absence must
|
|
// not be charged to the switch. Nothing legitimate can be older than the
|
|
// 30s settle-entry timeout plus the 5s render wait.
|
|
assert.equal(resolveSettleWait(35_001, 40_000, false, 0), "drop");
|
|
assert.equal(resolveSettleWait(35_001, 40_000, true, 0), "drop");
|
|
// At the bound (a 29.9s settle plus a truncated 5s wait) records survive.
|
|
assert.deepEqual(resolveSettleWait(35_000, 34_900, true, 0), {
|
|
settleWaitTruncated: true,
|
|
});
|
|
});
|
|
|
|
test("a not-pending frame landing past the wait deadline is starvation, not a settle", () => {
|
|
// Settle entered at t=1s (deadline 6s), render caught up, then frames
|
|
// stalled (system suspend, App Nap — no visibilitychange): the next frame
|
|
// lands at t=20s with nothing pending. The gap is starvation; recording
|
|
// it would fabricate a clean 20s switch well under the 35s age guard.
|
|
assert.equal(resolveSettleWait(20_000, 6_000, false, 0), "drop");
|
|
// Still-pending arrivals past the deadline remain truncated records: the
|
|
// render genuinely wasn't done, which is the tail the tracer must keep.
|
|
assert.deepEqual(resolveSettleWait(6_001, 6_000, true, 0), {
|
|
settleWaitTruncated: true,
|
|
});
|
|
});
|
|
|
|
test("a starved frame gap drops even while the render-pending marker is latched", () => {
|
|
// During a suspension React can't flush the deferred commit, so the
|
|
// pending marker stays latched — its truth is NOT evidence the render was
|
|
// slow. A single inter-frame gap beyond any plausible main-thread stall
|
|
// means the process was suspended; recording a truncated 22s "switch"
|
|
// would fabricate the very regression the tracer hunts.
|
|
assert.equal(resolveSettleWait(22_300, 5_300, true, 0, 22_000), "drop");
|
|
// Heavy-but-real frames (multi-hundred-ms long tasks) still record.
|
|
assert.deepEqual(resolveSettleWait(5_400, 5_300, true, 0, 900), {
|
|
settleWaitTruncated: true,
|
|
});
|
|
// The first frame has no predecessor: no gap to judge.
|
|
assert.deepEqual(resolveSettleWait(1_000, 5_300, false, 0, null), {
|
|
settleWaitTruncated: false,
|
|
});
|
|
});
|
|
|
|
test("a truncated settle is flagged in the summary and the log record", () => {
|
|
const summary = summarizeChannelSwitchTrace(trace(), 1_412, true);
|
|
assert.ok(summary.endsWith(" settle=truncated"), summary);
|
|
const record = buildSwitchPerfLogRecord(trace(), 1_412, true);
|
|
assert.equal(record.settleWaitTruncated, true);
|
|
// Clean settles keep the field out of the line entirely.
|
|
assert.ok(
|
|
!("settleWaitTruncated" in buildSwitchPerfLogRecord(trace(), 1_412)),
|
|
);
|
|
});
|
|
|
|
test("fetches attribute only when started after the switch began", () => {
|
|
const active = trace({ channelId: "abcdef1234567890", startedAt: 1_000 });
|
|
// Started before the switch (stale A→B→A leg): not attributable.
|
|
assert.equal(shouldAttributeFetch(active, "abcdef1234567890", 999), false);
|
|
// Started at/after the switch: attributable.
|
|
assert.equal(shouldAttributeFetch(active, "abcdef1234567890", 1_000), true);
|
|
assert.equal(shouldAttributeFetch(active, "abcdef1234567890", 1_500), true);
|
|
// Other channel or no trace: never.
|
|
assert.equal(shouldAttributeFetch(active, "bbbb0000bbbb0000", 1_500), false);
|
|
assert.equal(shouldAttributeFetch(null, "abcdef1234567890", 1_500), false);
|
|
});
|
|
|
|
test("a superseded members fetch never claims the trace's one-shot slot", async () => {
|
|
const { openChannelMembersFetch, traceChannelMembersFetch } = await import(
|
|
"./channelSwitchPerf.ts"
|
|
);
|
|
await withSettleHarness(async ({ begin, settle, flush }) => {
|
|
begin("aaaa1111aaaa1111");
|
|
const startedAt = performance.now();
|
|
// Fetch #1 starts, then a live join/leave invalidation replaces it with
|
|
// fetch #2. #1 resolves first (the Tauri call can't be cancelled) but
|
|
// must not attribute: its roster is not the one rendered. The query's
|
|
// AbortSignal is deliberately not used for this — consuming it flips
|
|
// React Query to cancel-and-revert on last-observer unsubscribe, which
|
|
// discards warm rosters on interrupted switches.
|
|
const first = openChannelMembersFetch("aaaa1111aaaa1111");
|
|
const second = openChannelMembersFetch("aaaa1111aaaa1111");
|
|
traceChannelMembersFetch("aaaa1111aaaa1111", 9_999, 900, startedAt, first);
|
|
traceChannelMembersFetch(
|
|
"aaaa1111aaaa1111",
|
|
10_002,
|
|
120,
|
|
startedAt,
|
|
second,
|
|
);
|
|
settle("aaaa1111aaaa1111");
|
|
flush();
|
|
const measure = performance
|
|
.getEntriesByName("buzz:channel-switch:click-to-settled")
|
|
.at(-1);
|
|
assert.equal(measure?.detail?.membersFetch?.memberCount, 10_002);
|
|
assert.equal(measure?.detail?.membersFetch?.durationMs, 120);
|
|
});
|
|
});
|
|
|
|
test("a suspension before the first settle frame drops, not records truncated", async () => {
|
|
await withSettleHarness(async ({ begin, settle, flush, measures }) => {
|
|
const virtualClock = { now: 0 };
|
|
performance.now = () => virtualClock.now;
|
|
try {
|
|
// The deferred marker stays latched during a suspension, so its truth
|
|
// is not evidence of slow rendering — the settle-entry → first-frame
|
|
// window must be starvation-guarded like every later frame.
|
|
globalThis.document.querySelector = () => ({});
|
|
begin("aaaa1111aaaa1111");
|
|
settle("aaaa1111aaaa1111");
|
|
virtualClock.now = 20_000;
|
|
flush();
|
|
assert.deepEqual(measures(), []);
|
|
} finally {
|
|
delete performance.now;
|
|
}
|
|
});
|
|
});
|
|
|
|
// --- Settle lifecycle: rapid switches and community resets ----------------
|
|
|
|
async function withSettleHarness(run, documentOverrides = {}) {
|
|
const frames = [];
|
|
const originalWindow = globalThis.window;
|
|
const originalDocument = globalThis.document;
|
|
globalThis.window = {
|
|
requestAnimationFrame: (cb) => frames.push(cb) && frames.length,
|
|
cancelAnimationFrame: () => {},
|
|
};
|
|
globalThis.document = {
|
|
addEventListener: () => {},
|
|
querySelector: () => null,
|
|
removeEventListener: () => {},
|
|
visibilityState: "visible",
|
|
...documentOverrides,
|
|
};
|
|
const {
|
|
abandonChannelSwitchTrace,
|
|
beginChannelSwitchTrace,
|
|
cancelRouteExitAbandon,
|
|
scheduleRouteExitAbandon,
|
|
settleChannelSwitchTrace,
|
|
resetChannelSwitchTrace,
|
|
CHANNEL_SWITCH_MEASURE,
|
|
} = await import("./channelSwitchPerf.ts");
|
|
performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE);
|
|
const flush = () => {
|
|
// Drain chained rAFs until quiescent.
|
|
for (let i = 0; i < 20 && frames.length > 0; i += 1) {
|
|
for (const cb of frames.splice(0, frames.length)) cb();
|
|
}
|
|
};
|
|
const measures = () =>
|
|
performance
|
|
.getEntriesByName(CHANNEL_SWITCH_MEASURE)
|
|
.map((entry) => entry.detail?.channelId);
|
|
try {
|
|
await run({
|
|
abandon: abandonChannelSwitchTrace,
|
|
begin: beginChannelSwitchTrace,
|
|
cancelAbandon: cancelRouteExitAbandon,
|
|
scheduleAbandon: scheduleRouteExitAbandon,
|
|
settle: settleChannelSwitchTrace,
|
|
reset: resetChannelSwitchTrace,
|
|
flush,
|
|
measures,
|
|
});
|
|
} finally {
|
|
resetChannelSwitchTrace();
|
|
performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE);
|
|
if (originalWindow === undefined) delete globalThis.window;
|
|
else globalThis.window = originalWindow;
|
|
if (originalDocument === undefined) delete globalThis.document;
|
|
else globalThis.document = originalDocument;
|
|
}
|
|
}
|
|
|
|
test("a switch begun during A's deferred wait drops A's record (no clock theft)", async () => {
|
|
await withSettleHarness(async ({ begin, settle, flush, measures }) => {
|
|
begin("aaaa1111aaaa1111");
|
|
settle("aaaa1111aaaa1111"); // A's deferred-paint wait is now queued
|
|
begin("bbbb2222bbbb2222"); // rapid follow-up switch replaces the trace
|
|
flush();
|
|
// A must NOT be recorded: its settledAt would be sampled from B's
|
|
// timeline, charging B's delay to A.
|
|
assert.deepEqual(measures(), []);
|
|
settle("bbbb2222bbbb2222");
|
|
flush();
|
|
assert.deepEqual(measures(), ["bbbb2222bbbb2222"]);
|
|
});
|
|
});
|
|
|
|
test("a community reset during the deferred wait drops the record", async () => {
|
|
await withSettleHarness(async ({ begin, settle, reset, flush, measures }) => {
|
|
begin("aaaa1111aaaa1111");
|
|
settle("aaaa1111aaaa1111");
|
|
reset();
|
|
flush();
|
|
assert.deepEqual(measures(), []);
|
|
});
|
|
});
|
|
|
|
test("an undisturbed settle records exactly one measure", async () => {
|
|
await withSettleHarness(async ({ begin, settle, flush, measures }) => {
|
|
begin("aaaa1111aaaa1111");
|
|
settle("aaaa1111aaaa1111");
|
|
flush();
|
|
assert.deepEqual(measures(), ["aaaa1111aaaa1111"]);
|
|
});
|
|
});
|
|
|
|
test("beginning a switch clears the previous switch's settled mark and measure", async () => {
|
|
await withSettleHarness(async ({ begin, settle, flush, measures }) => {
|
|
const { CHANNEL_SWITCH_SETTLED_MARK } = await import(
|
|
"./channelSwitchPerf.ts"
|
|
);
|
|
begin("aaaa1111aaaa1111");
|
|
settle("aaaa1111aaaa1111");
|
|
flush();
|
|
assert.deepEqual(measures(), ["aaaa1111aaaa1111"]);
|
|
// A consumer polling the buffer mid-switch (the Playwright specs, the
|
|
// Performance panel) must never read the PREVIOUS switch's entries as
|
|
// the current one's.
|
|
begin("bbbb2222bbbb2222");
|
|
assert.deepEqual(measures(), []);
|
|
assert.equal(
|
|
performance.getEntriesByName(CHANNEL_SWITCH_SETTLED_MARK).length,
|
|
0,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("a window hidden between click and settle entry drops the trace", async () => {
|
|
const visibilityListeners = [];
|
|
await withSettleHarness(
|
|
async ({ begin, settle, flush, measures }) => {
|
|
begin("aaaa1111aaaa1111");
|
|
assert.ok(visibilityListeners.length >= 1, "watcher armed at begin");
|
|
// The window hides while the fetch is in flight (cmd-H / minimize),
|
|
// then the user returns and the settle runs with the window visible
|
|
// again: the absence sits inside totalMs, so the trace must drop.
|
|
globalThis.document.visibilityState = "hidden";
|
|
for (const listener of visibilityListeners) listener();
|
|
globalThis.document.visibilityState = "visible";
|
|
settle("aaaa1111aaaa1111");
|
|
flush();
|
|
assert.deepEqual(measures(), []);
|
|
},
|
|
{
|
|
addEventListener: (type, listener) => {
|
|
if (type === "visibilitychange") visibilityListeners.push(listener);
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
test("abandoned switches never accumulate start marks", async () => {
|
|
await withSettleHarness(async ({ abandon, begin }) => {
|
|
const { CHANNEL_SWITCH_START_MARK } = await import(
|
|
"./channelSwitchPerf.ts"
|
|
);
|
|
performance.clearMarks?.(CHANNEL_SWITCH_START_MARK);
|
|
// Traces that die without recording (forum visits, route exits, drops)
|
|
// never reach record()'s buffer clearing — begin() must bound the
|
|
// buffer itself or weeks-long sessions accumulate a mark per abandon.
|
|
for (const channelId of ["aaaa", "bbbb", "cccc", "dddd"]) {
|
|
begin(channelId);
|
|
abandon(channelId);
|
|
}
|
|
assert.equal(
|
|
performance.getEntriesByName(CHANNEL_SWITCH_START_MARK).length,
|
|
1,
|
|
);
|
|
performance.clearMarks?.(CHANNEL_SWITCH_START_MARK);
|
|
});
|
|
});
|
|
|
|
test("a settle in a hidden window drops the trace instead of recording", async () => {
|
|
await withSettleHarness(
|
|
async ({ begin, settle, flush, measures }) => {
|
|
begin("aaaa1111aaaa1111");
|
|
// rAF is suspended while hidden; the queued chain would only fire when
|
|
// the user returns, charging the whole absence to the switch.
|
|
settle("aaaa1111aaaa1111");
|
|
flush();
|
|
assert.deepEqual(measures(), []);
|
|
// The trace was released, not wedged: a later stale settle is a no-op.
|
|
settle("aaaa1111aaaa1111");
|
|
flush();
|
|
assert.deepEqual(measures(), []);
|
|
},
|
|
{ visibilityState: "hidden" },
|
|
);
|
|
});
|
|
|
|
test("a window hidden during the settle wait drops the record", async () => {
|
|
const visibilityListeners = [];
|
|
await withSettleHarness(
|
|
async ({ begin, settle, flush, measures }) => {
|
|
begin("aaaa1111aaaa1111");
|
|
settle("aaaa1111aaaa1111");
|
|
assert.equal(visibilityListeners.length, 1, "wait registers a listener");
|
|
// The user cmd-tabs away mid-wait; frames resume only on return.
|
|
visibilityListeners[0]();
|
|
flush();
|
|
assert.deepEqual(measures(), []);
|
|
},
|
|
{
|
|
addEventListener: (type, listener) => {
|
|
if (type === "visibilitychange") visibilityListeners.push(listener);
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
test("a scheduled route-exit abandon canceled in the same task keeps the trace", async () => {
|
|
await withSettleHarness(
|
|
async ({
|
|
begin,
|
|
cancelAbandon,
|
|
scheduleAbandon,
|
|
settle,
|
|
flush,
|
|
measures,
|
|
}) => {
|
|
begin("aaaa1111aaaa1111");
|
|
// StrictMode's dev-only effect replay: cleanup schedules the abandon,
|
|
// the synchronous re-setup cancels it before the microtask runs.
|
|
scheduleAbandon("aaaa1111aaaa1111");
|
|
cancelAbandon("aaaa1111aaaa1111");
|
|
await Promise.resolve();
|
|
settle("aaaa1111aaaa1111");
|
|
flush();
|
|
assert.deepEqual(measures(), ["aaaa1111aaaa1111"]);
|
|
},
|
|
);
|
|
});
|
|
|
|
test("the trace anchors at the input event, not handler dispatch", async () => {
|
|
await withSettleHarness(async ({ begin, settle, flush }) => {
|
|
// Real-clock gap: earlier tests fired visibilitychange listeners, and a
|
|
// back-dated anchor overlapping those timestamps is (correctly) dropped
|
|
// by the hidden-window guard. Let them age out first.
|
|
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
// A click can sit queued behind a long task before its handler runs;
|
|
// that input delay is felt switch latency and must be inside totalMs.
|
|
// window.event is set only during synchronous dispatch, so this anchor
|
|
// can never leak in from async continuations.
|
|
globalThis.window.event = { timeStamp: performance.now() - 550 };
|
|
begin("aaaa1111aaaa1111");
|
|
delete globalThis.window.event;
|
|
const startMark = performance
|
|
.getEntriesByName("buzz:channel-switch:start")
|
|
.at(-1);
|
|
settle("aaaa1111aaaa1111");
|
|
flush();
|
|
const measure = performance
|
|
.getEntriesByName("buzz:channel-switch:click-to-settled")
|
|
.at(-1);
|
|
assert.ok(measure, "measure recorded");
|
|
assert.ok(
|
|
measure.duration >= 550,
|
|
`input delay must be inside the measure (got ${measure.duration})`,
|
|
);
|
|
// The mark and the measure must share the anchor, or the Performance
|
|
// panel shows two different switch durations for the same switch.
|
|
assert.equal(startMark?.startTime, measure.startTime);
|
|
});
|
|
});
|
|
|
|
test("beginning a switch revokes a pending route-exit abandon for that channel", async () => {
|
|
await withSettleHarness(
|
|
async ({ begin, scheduleAbandon, settle, flush, measures }) => {
|
|
// Same-task unmount-then-renavigate to the same channel: the cleanup
|
|
// schedules the abandon, then goChannel synchronously opens a fresh
|
|
// trace before the microtask drains. The stale abandon must not kill
|
|
// the new trace.
|
|
scheduleAbandon("aaaa1111aaaa1111");
|
|
begin("aaaa1111aaaa1111");
|
|
await Promise.resolve();
|
|
settle("aaaa1111aaaa1111");
|
|
flush();
|
|
assert.deepEqual(measures(), ["aaaa1111aaaa1111"]);
|
|
},
|
|
);
|
|
});
|
|
|
|
test("an uncanceled route-exit abandon drops the trace before any frame fires", async () => {
|
|
await withSettleHarness(
|
|
async ({ begin, scheduleAbandon, settle, flush, measures }) => {
|
|
begin("aaaa1111aaaa1111");
|
|
scheduleAbandon("aaaa1111aaaa1111");
|
|
await Promise.resolve();
|
|
settle("aaaa1111aaaa1111");
|
|
flush();
|
|
assert.deepEqual(measures(), []);
|
|
},
|
|
);
|
|
});
|
|
|
|
test("leaving the channel surface abandons the trace; history-back records nothing", async () => {
|
|
await withSettleHarness(
|
|
async ({ abandon, begin, settle, flush, measures }) => {
|
|
begin("aaaa1111aaaa1111");
|
|
// Route exit (Projects/Home): the channel screen unmounts before the
|
|
// trace settled and abandons it.
|
|
abandon("aaaa1111aaaa1111");
|
|
// History-back re-enters the channel without goChannel; its settle must
|
|
// find no trace — otherwise the time spent away would be recorded as
|
|
// switch latency.
|
|
settle("aaaa1111aaaa1111");
|
|
flush();
|
|
assert.deepEqual(measures(), []);
|
|
},
|
|
);
|
|
});
|
|
|
|
test("suspension between readiness and the paint frame drops the record", () => {
|
|
// The reviewer's repro: readiness at t=10ms, final frame at t=20_000ms.
|
|
// Recording here would emit total=20000ms as an ordinary clean switch —
|
|
// App Nap fires no visibilitychange, so the hidden-window guard cannot see
|
|
// it and the frame-gap guard in awaitDeferredCommit has already run.
|
|
assert.equal(resolveFinalFrame(20_000, 10, 0), "drop");
|
|
// A normal paint frame one refresh interval after readiness still records.
|
|
assert.equal(resolveFinalFrame(27, 10, 0), "record");
|
|
// Boundary: exactly the frame-gap cap is still a record; one past it drops.
|
|
assert.equal(resolveFinalFrame(3_010, 10, 0), "record");
|
|
assert.equal(resolveFinalFrame(3_011, 10, 0), "drop");
|
|
});
|
|
|
|
test("the final frame also honors the overall trace age cap", () => {
|
|
// Readiness landed just under the age cap and the paint frame is prompt,
|
|
// so the frame gap is innocent — only the age check can catch this.
|
|
assert.equal(resolveFinalFrame(35_001, 35_000, 0), "drop");
|
|
assert.equal(resolveFinalFrame(35_000, 34_999, 0), "record");
|
|
});
|
|
|
|
// Drives the real settle lifecycle with a controllable clock and rAF queue so
|
|
// a stall can be injected at one exact seam. Offsets are rebased above the
|
|
// real clock: earlier tests in this file fire visibilitychange, and a trace
|
|
// back-dated below those timestamps is (correctly) dropped at settle entry —
|
|
// which would make every assertion here vacuous.
|
|
function withClockedFrames(run) {
|
|
const frames = [];
|
|
const originalWindow = globalThis.window;
|
|
const originalDocument = globalThis.document;
|
|
const originalNow = performance.now;
|
|
const base = originalNow.call(performance) + 1_000;
|
|
let clock = base;
|
|
performance.now = () => clock;
|
|
globalThis.window = {
|
|
requestAnimationFrame: (cb) => frames.push(cb) && frames.length,
|
|
cancelAnimationFrame: () => {},
|
|
};
|
|
globalThis.document = {
|
|
addEventListener: () => {},
|
|
// No pending marker: readiness is reached on the first settle frame.
|
|
querySelector: () => null,
|
|
removeEventListener: () => {},
|
|
visibilityState: "visible",
|
|
};
|
|
performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE);
|
|
const at = (offset) => {
|
|
clock = base + offset;
|
|
};
|
|
const step = (offset) => {
|
|
at(offset);
|
|
for (const cb of frames.splice(0, frames.length)) cb();
|
|
};
|
|
const measures = () =>
|
|
performance.getEntriesByName(CHANNEL_SWITCH_MEASURE).length;
|
|
try {
|
|
run({ at, step, measures });
|
|
} finally {
|
|
performance.now = originalNow;
|
|
resetChannelSwitchTrace();
|
|
performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE);
|
|
if (originalWindow === undefined) delete globalThis.window;
|
|
else globalThis.window = originalWindow;
|
|
if (originalDocument === undefined) delete globalThis.document;
|
|
else globalThis.document = originalDocument;
|
|
}
|
|
}
|
|
|
|
test("a stall between readiness and the paint frame records nothing", () => {
|
|
withClockedFrames(({ at, step, measures }) => {
|
|
at(0);
|
|
beginChannelSwitchTrace("aaaa");
|
|
at(10);
|
|
settleChannelSwitchTrace("aaaa");
|
|
// Readiness frame: no pending marker, inside the wait deadline.
|
|
step(10);
|
|
// Process suspended here (App Nap): no visibilitychange fires, so only
|
|
// the final-frame guard can catch it.
|
|
step(20_000);
|
|
assert.equal(measures(), 0, "a 20s suspension must not record a switch");
|
|
});
|
|
});
|
|
|
|
test("a prompt paint frame after readiness still records", () => {
|
|
withClockedFrames(({ at, step, measures }) => {
|
|
at(0);
|
|
beginChannelSwitchTrace("aaaa");
|
|
at(10);
|
|
settleChannelSwitchTrace("aaaa");
|
|
step(10);
|
|
step(26);
|
|
assert.equal(measures(), 1, "the guard must not drop healthy switches");
|
|
});
|
|
});
|