mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
53a47bf910
Co-authored-by: nettee <nettee.liu@gmail.com>
528 lines
20 KiB
TypeScript
528 lines
20 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
clearExceptionTrackingContext,
|
|
setExceptionTrackingContext,
|
|
} from '../../src/analytics/error-tracking';
|
|
import {
|
|
__resetChatScrollFreezeForTest,
|
|
chatScrollFreezeListenerCount,
|
|
installChatScrollFreezeObserver,
|
|
} from '../../src/observability/chat-scroll-freeze';
|
|
import { FREEZE_WHEEL_COUNT } from '../../src/observability/chat-scroll-freeze-detector';
|
|
import {
|
|
CHAT_SCROLL_TAKEOVER_STORAGE_KEY,
|
|
__resetChatScrollTakeoverForTest,
|
|
chatScrollTakeoverEngaged,
|
|
installChatScrollTakeover,
|
|
} from '../../src/runtime/chat-scroll-takeover';
|
|
|
|
/**
|
|
* What these specs encode
|
|
* ----------------------
|
|
* The chat log's compositor-side scroll extent goes stale (see
|
|
* `observability/chat-scroll-freeze.ts` for the measurement). The effect on a
|
|
* real machine, measured with real OS wheel events:
|
|
*
|
|
* scrollTop = 1700 assigned from JS → took effect
|
|
* scrollTop = 99999 assigned from JS → clamped to the real layout maximum
|
|
* 12 wheel notches asking for 1440px → stopped dead at 91
|
|
*
|
|
* So the JS write path is healthy and the wheel path is not. This module takes
|
|
* the wheel over — `preventDefault` plus a programmatic write — but ONLY after
|
|
* the probe has said the surface is frozen, and ONLY when an operator has
|
|
* turned the switch on.
|
|
*
|
|
* The switch is the load-bearing part. The probe has never been validated
|
|
* against real-world false positives (its production event count is zero, and
|
|
* that zero turns out to be a reporting bug rather than evidence of accuracy),
|
|
* so an unproven detector is not allowed to change how scrolling feels for
|
|
* everybody. Three specs below exist purely to pin "off means off".
|
|
*
|
|
* jsdom performs no layout — `scrollHeight` / `clientHeight` are 0 for every
|
|
* element it builds — so geometry is installed by hand, exactly as the freeze
|
|
* probe's own specs do it. What that CANNOT show is the thing only a browser
|
|
* has: how the takeover actually feels under a trackpad flick. That stays in
|
|
* the handoff notes rather than being faked here.
|
|
*/
|
|
|
|
const fetchMock = vi.fn();
|
|
const ORIGINAL_FETCH = globalThis.fetch;
|
|
const ORIGINAL_RAF = globalThis.requestAnimationFrame;
|
|
const ORIGINAL_CAF = globalThis.cancelAnimationFrame;
|
|
|
|
let rafSpy = vi.fn();
|
|
let cafSpy = vi.fn();
|
|
let rafHandle = 0;
|
|
let clock = 0;
|
|
|
|
function advanceClock(ms: number): void {
|
|
clock += ms;
|
|
}
|
|
|
|
function sentEvents(): Array<{ event: string; properties: Record<string, unknown> }> {
|
|
return fetchMock.mock.calls.map((call) => {
|
|
const init = call[1] as RequestInit;
|
|
return JSON.parse(init.body as string) as {
|
|
event: string;
|
|
properties: Record<string, unknown>;
|
|
};
|
|
});
|
|
}
|
|
|
|
function eventsNamed(name: string): Array<Record<string, unknown>> {
|
|
return sentEvents()
|
|
.filter((e) => e.event === name)
|
|
.map((e) => e.properties);
|
|
}
|
|
|
|
/** Scroll geometry jsdom refuses to compute, installed by hand. */
|
|
interface GeometryHandle {
|
|
setTop(value: number): void;
|
|
setContent(value: number): void;
|
|
setViewport(value: number): void;
|
|
top(): number;
|
|
/** Every write the code under test made to `scrollTop`. */
|
|
writes: number[];
|
|
}
|
|
|
|
function stubGeometry(
|
|
el: HTMLElement,
|
|
initial: { scrollTop: number; scrollHeight: number; clientHeight: number },
|
|
): GeometryHandle {
|
|
let top = initial.scrollTop;
|
|
let content = initial.scrollHeight;
|
|
let viewport = initial.clientHeight;
|
|
const writes: number[] = [];
|
|
Object.defineProperty(el, 'scrollTop', {
|
|
configurable: true,
|
|
get: () => top,
|
|
set: (value: number) => {
|
|
writes.push(value);
|
|
top = value;
|
|
},
|
|
});
|
|
Object.defineProperty(el, 'scrollHeight', { configurable: true, get: () => content });
|
|
Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => viewport });
|
|
return {
|
|
setTop: (value) => {
|
|
top = value;
|
|
},
|
|
setContent: (value) => {
|
|
content = value;
|
|
},
|
|
setViewport: (value) => {
|
|
viewport = value;
|
|
},
|
|
top: () => top,
|
|
writes,
|
|
};
|
|
}
|
|
|
|
function buildChatLog(): HTMLElement {
|
|
const log = document.createElement('div');
|
|
log.className = 'chat-log';
|
|
log.setAttribute('data-testid', 'chat-log');
|
|
document.body.appendChild(log);
|
|
return log;
|
|
}
|
|
|
|
/** The measured failing surface: 1764px of real travel, wheel stuck at 91. */
|
|
const FROZEN = { scrollTop: 91, scrollHeight: 2347, clientHeight: 583 } as const;
|
|
const LAYOUT_MAX = FROZEN.scrollHeight - FROZEN.clientHeight;
|
|
|
|
function scrolled(target: HTMLElement): void {
|
|
target.dispatchEvent(new Event('scroll', { bubbles: false }));
|
|
}
|
|
|
|
/** Dispatch one wheel notch and hand back the event, so the spec can ask whether it was consumed. */
|
|
function wheelEvent(
|
|
target: HTMLElement,
|
|
deltaY: number,
|
|
init: { deltaMode?: number; ctrlKey?: boolean } = {},
|
|
): WheelEvent {
|
|
const event = new WheelEvent('wheel', {
|
|
deltaY,
|
|
deltaMode: init.deltaMode ?? 0,
|
|
ctrlKey: init.ctrlKey ?? false,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
});
|
|
target.dispatchEvent(event);
|
|
return event;
|
|
}
|
|
|
|
function turnSwitchOn(): void {
|
|
globalThis.localStorage.setItem(CHAT_SCROLL_TAKEOVER_STORAGE_KEY, '1');
|
|
}
|
|
|
|
/**
|
|
* Drive the probe to a reported freeze on `log`, and stop the instant it calls
|
|
* it.
|
|
*
|
|
* Exactly `FREEZE_WHEEL_COUNT` notches of 120px: four stalled notches asking
|
|
* for 480px clears both bars the detector holds a report behind (four notches,
|
|
* 240px). Stopping there matters — the takeover engages during the last
|
|
* notch's frame, so a fifth would already be taken over and every spec below
|
|
* would be reasoning about a log this module had moved.
|
|
*/
|
|
function driveToFreeze(log: HTMLElement): void {
|
|
scrolled(log);
|
|
for (let i = 0; i < FREEZE_WHEEL_COUNT; i += 1) {
|
|
advanceClock(16);
|
|
wheelEvent(log, 120);
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
clock = 0;
|
|
fetchMock.mockReset();
|
|
fetchMock.mockResolvedValue(new Response('', { status: 200 }));
|
|
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
setExceptionTrackingContext({
|
|
apiKey: 'phc_test',
|
|
host: 'https://us.i.posthog.com',
|
|
distinctId: 'chat-scroll-takeover-test',
|
|
clientType: 'web',
|
|
osName: 'Mac OS X',
|
|
});
|
|
// A synchronous rAF makes both the probe and the takeover deterministic:
|
|
// one wheel in, one applied frame out, nothing to await.
|
|
rafSpy = vi.fn((cb: FrameRequestCallback) => {
|
|
cb(clock);
|
|
return ++rafHandle;
|
|
});
|
|
cafSpy = vi.fn();
|
|
globalThis.requestAnimationFrame =
|
|
rafSpy as unknown as typeof globalThis.requestAnimationFrame;
|
|
globalThis.cancelAnimationFrame =
|
|
cafSpy as unknown as typeof globalThis.cancelAnimationFrame;
|
|
vi.spyOn(performance, 'now').mockImplementation(() => clock);
|
|
globalThis.localStorage.clear();
|
|
document.body.innerHTML = '';
|
|
__resetChatScrollTakeoverForTest();
|
|
__resetChatScrollFreezeForTest();
|
|
});
|
|
|
|
afterEach(() => {
|
|
__resetChatScrollTakeoverForTest();
|
|
__resetChatScrollFreezeForTest();
|
|
vi.restoreAllMocks();
|
|
clearExceptionTrackingContext();
|
|
globalThis.localStorage.clear();
|
|
globalThis.fetch = ORIGINAL_FETCH;
|
|
globalThis.requestAnimationFrame = ORIGINAL_RAF;
|
|
globalThis.cancelAnimationFrame = ORIGINAL_CAF;
|
|
document.body.innerHTML = '';
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Off is off
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('chat scroll takeover — the switch', () => {
|
|
it('costs no listener, no timer and no frame while the switch is off', () => {
|
|
// The whole justification for shipping this dark: an unproven detector must
|
|
// not be able to change scrolling for a user who never asked for it. "Off"
|
|
// therefore has to mean *nothing runs*, not "runs and decides not to act".
|
|
const log = buildChatLog();
|
|
stubGeometry(log, FROZEN);
|
|
const addOnLog = vi.spyOn(log, 'addEventListener');
|
|
const addOnDocument = vi.spyOn(document, 'addEventListener');
|
|
const addOnWindow = vi.spyOn(window, 'addEventListener');
|
|
const timeout = vi.spyOn(globalThis, 'setTimeout');
|
|
const interval = vi.spyOn(globalThis, 'setInterval');
|
|
const framesBefore = rafSpy.mock.calls.length;
|
|
|
|
for (let i = 0; i < 20; i += 1) installChatScrollTakeover();
|
|
|
|
expect(addOnLog).not.toHaveBeenCalled();
|
|
expect(addOnDocument).not.toHaveBeenCalled();
|
|
expect(addOnWindow).not.toHaveBeenCalled();
|
|
expect(timeout).not.toHaveBeenCalled();
|
|
expect(interval).not.toHaveBeenCalled();
|
|
expect(rafSpy.mock.calls.length).toBe(framesBefore);
|
|
// …and it did not even subscribe. A subscription costs no listener, no
|
|
// timer and no frame, so none of the spies above can see one — which would
|
|
// leave "off" pinned by its outcome and not by its cost.
|
|
expect(chatScrollFreezeListenerCount()).toBe(0);
|
|
});
|
|
|
|
it('does not take the wheel over when the probe calls a freeze and the switch is off', () => {
|
|
// THE guard this change is most likely to need. A false positive from the
|
|
// detector must be inert for everybody who has not opted in.
|
|
const log = buildChatLog();
|
|
const geometry = stubGeometry(log, FROZEN);
|
|
installChatScrollFreezeObserver();
|
|
installChatScrollTakeover();
|
|
|
|
driveToFreeze(log);
|
|
expect(eventsNamed('client_chat_scroll_frozen')).toHaveLength(1);
|
|
|
|
const after = wheelEvent(log, 120);
|
|
expect(after.defaultPrevented).toBe(false);
|
|
expect(geometry.writes).toEqual([]);
|
|
expect(chatScrollTakeoverEngaged()).toBe(false);
|
|
});
|
|
|
|
it('does not take the wheel over before a freeze even when the switch is on', () => {
|
|
// The other half of the same risk: an opted-in user still scrolls
|
|
// natively until the probe has actually called it. If this ever goes red,
|
|
// the takeover is stealing healthy scrolling.
|
|
turnSwitchOn();
|
|
const log = buildChatLog();
|
|
const geometry = stubGeometry(log, { scrollTop: 0, scrollHeight: 2347, clientHeight: 583 });
|
|
installChatScrollFreezeObserver();
|
|
installChatScrollTakeover();
|
|
scrolled(log);
|
|
|
|
// A healthy scroller: every notch moves it, so no streak can accumulate.
|
|
for (let i = 0; i < 12; i += 1) {
|
|
advanceClock(16);
|
|
geometry.setTop(120 * (i + 1));
|
|
const event = wheelEvent(log, 120);
|
|
expect(event.defaultPrevented).toBe(false);
|
|
}
|
|
|
|
expect(eventsNamed('client_chat_scroll_frozen')).toHaveLength(0);
|
|
expect(geometry.writes).toEqual([]);
|
|
expect(chatScrollTakeoverEngaged()).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Engaged behaviour
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('chat scroll takeover — once the probe has called it frozen', () => {
|
|
function freezeWithSwitchOn(): { log: HTMLElement; geometry: GeometryHandle } {
|
|
turnSwitchOn();
|
|
const log = buildChatLog();
|
|
const geometry = stubGeometry(log, FROZEN);
|
|
installChatScrollFreezeObserver();
|
|
installChatScrollTakeover();
|
|
driveToFreeze(log);
|
|
return { log, geometry };
|
|
}
|
|
|
|
it('consumes the wheel and moves the log by the delta the wheel asked for', () => {
|
|
// The red spec this change exists for: the compositor will not move the
|
|
// log, the JS write path will, so the wheel is answered from JavaScript.
|
|
const { log, geometry } = freezeWithSwitchOn();
|
|
expect(eventsNamed('client_chat_scroll_frozen')).toHaveLength(1);
|
|
expect(chatScrollTakeoverEngaged()).toBe(true);
|
|
|
|
const event = wheelEvent(log, 120);
|
|
|
|
expect(event.defaultPrevented).toBe(true);
|
|
expect(geometry.writes).toEqual([FROZEN.scrollTop + 120]);
|
|
expect(geometry.top()).toBe(211);
|
|
});
|
|
|
|
it('scrolls back up too, rather than leaving two input paths fighting', () => {
|
|
// Upward wheels still work natively during a freeze, so leaving them alone
|
|
// was an option. It is the wrong one: a single trackpad flick carries both
|
|
// signs, and splitting one gesture across a native path and a programmatic
|
|
// one makes the two disagree about where the log is.
|
|
const { log, geometry } = freezeWithSwitchOn();
|
|
geometry.setTop(800);
|
|
|
|
const event = wheelEvent(log, -120);
|
|
|
|
expect(event.defaultPrevented).toBe(true);
|
|
expect(geometry.writes).toEqual([680]);
|
|
});
|
|
|
|
it('applies a burst of notches once per frame, not once per event', () => {
|
|
// Writing `scrollTop` from the handler would put a forced layout on the
|
|
// input path and make a fast flick judder. One write per frame, carrying
|
|
// the whole burst.
|
|
const { log, geometry } = freezeWithSwitchOn();
|
|
// No frame runs until we say so, so the notches have to accumulate.
|
|
rafSpy.mockImplementation(() => ++rafHandle);
|
|
const callbacks: FrameRequestCallback[] = [];
|
|
rafSpy.mockImplementation((cb: FrameRequestCallback) => {
|
|
callbacks.push(cb);
|
|
return ++rafHandle;
|
|
});
|
|
|
|
wheelEvent(log, 40);
|
|
wheelEvent(log, 40);
|
|
wheelEvent(log, 40);
|
|
expect(geometry.writes).toEqual([]);
|
|
const framesForThreeNotches = callbacks.length;
|
|
|
|
// One frame carries all three notches.
|
|
callbacks.forEach((cb) => cb(clock));
|
|
expect(geometry.writes).toEqual([FROZEN.scrollTop + 120]);
|
|
|
|
// Twice the notches, the same number of frames, and still one write.
|
|
//
|
|
// The count is compared against itself rather than pinned at the literal
|
|
// 1, because the takeover is not the only thing on this element asking
|
|
// for frames: the freeze probe keeps sampling a surface it has already
|
|
// reported, which is deliberate — it is what makes a second symptom on an
|
|
// already-reported surface catchable at all. Both coalesce per frame, so
|
|
// what this spec is about, and all it should be able to see, is that
|
|
// neither of them scales with the number of EVENTS.
|
|
callbacks.length = 0;
|
|
geometry.writes.length = 0;
|
|
geometry.setTop(FROZEN.scrollTop);
|
|
for (let i = 0; i < 6; i += 1) wheelEvent(log, 40);
|
|
expect(callbacks.length).toBe(framesForThreeNotches);
|
|
callbacks.forEach((cb) => cb(clock));
|
|
expect(geometry.writes).toEqual([FROZEN.scrollTop + 240]);
|
|
});
|
|
|
|
it('normalises line and page wheels the way the detector does', () => {
|
|
// `deltaMode` is 0 / 1 / 2 depending on device and OS. A line wheel that
|
|
// moved 3 raw units must not move the log 3px.
|
|
const { log, geometry } = freezeWithSwitchOn();
|
|
|
|
wheelEvent(log, 3, { deltaMode: 1 });
|
|
expect(geometry.writes).toEqual([FROZEN.scrollTop + 48]);
|
|
|
|
geometry.setTop(0);
|
|
wheelEvent(log, 1, { deltaMode: 2 });
|
|
expect(geometry.writes[1]).toBe(FROZEN.clientHeight);
|
|
});
|
|
|
|
it('clamps to the real layout extent instead of running off either end', () => {
|
|
const { log, geometry } = freezeWithSwitchOn();
|
|
|
|
wheelEvent(log, 99_999);
|
|
expect(geometry.writes).toEqual([LAYOUT_MAX]);
|
|
|
|
wheelEvent(log, -99_999);
|
|
expect(geometry.writes).toEqual([LAYOUT_MAX, 0]);
|
|
});
|
|
|
|
it('leaves a wheel alone when a scrollable box inside the log can still eat it', () => {
|
|
// Every code block and tool-output box in a transcript is a scrollport.
|
|
// `preventDefault` at the log would freeze all of them, which would be a
|
|
// worse bug than the one being worked around.
|
|
const { log, geometry } = freezeWithSwitchOn();
|
|
const inner = document.createElement('pre');
|
|
inner.style.overflowY = 'auto';
|
|
stubGeometry(inner, { scrollTop: 0, scrollHeight: 900, clientHeight: 200 });
|
|
log.appendChild(inner);
|
|
|
|
const event = wheelEvent(inner, 120);
|
|
|
|
expect(event.defaultPrevented).toBe(false);
|
|
expect(geometry.writes).toEqual([]);
|
|
});
|
|
|
|
it('leaves a zoom gesture alone', () => {
|
|
// ctrl+wheel is pinch-to-zoom on a trackpad. Consuming it would take page
|
|
// zoom away from the user.
|
|
const { log, geometry } = freezeWithSwitchOn();
|
|
|
|
const event = wheelEvent(log, 120, { ctrlKey: true });
|
|
|
|
expect(event.defaultPrevented).toBe(false);
|
|
expect(geometry.writes).toEqual([]);
|
|
});
|
|
|
|
it('lets the wheel chain onward once the log is genuinely at its end', () => {
|
|
// At a real edge the native behaviour is to hand the wheel to whatever is
|
|
// outside. Holding on to it there would trap the gesture inside a log that
|
|
// has nothing left to give.
|
|
const { log, geometry } = freezeWithSwitchOn();
|
|
// Drive to the bottom through the takeover itself, so the edge it sees is
|
|
// one it put the log at rather than one the spec asserted behind its back.
|
|
wheelEvent(log, 99_999);
|
|
expect(geometry.top()).toBe(LAYOUT_MAX);
|
|
geometry.writes.length = 0;
|
|
|
|
const event = wheelEvent(log, 120);
|
|
|
|
expect(event.defaultPrevented).toBe(false);
|
|
expect(geometry.writes).toEqual([]);
|
|
});
|
|
|
|
it('does not strand the user at a bottom that has since moved', () => {
|
|
// The edge check runs against a frame-old cache, which is fine for a
|
|
// settled log and a deadlock for a streaming one: parked at what used to
|
|
// be the bottom, every wheel is declined, a declined wheel schedules
|
|
// nothing, and nothing ever re-reads the geometry that would let the next
|
|
// one through — on a surface whose native path is broken. So a declined
|
|
// wheel still asks for a frame, purely to take a fresh reading.
|
|
const { log, geometry } = freezeWithSwitchOn();
|
|
wheelEvent(log, 99_999);
|
|
expect(geometry.top()).toBe(LAYOUT_MAX);
|
|
geometry.writes.length = 0;
|
|
|
|
// A turn streams in: the log is now 600px taller than the takeover thinks.
|
|
geometry.setContent(FROZEN.scrollHeight + 600);
|
|
|
|
// This one is declined against the stale bottom — and refreshes the cache.
|
|
const stale = wheelEvent(log, 120);
|
|
expect(stale.defaultPrevented).toBe(false);
|
|
expect(geometry.writes).toEqual([]);
|
|
|
|
// …so the next one reaches the content that arrived.
|
|
const fresh = wheelEvent(log, 120);
|
|
expect(fresh.defaultPrevented).toBe(true);
|
|
expect(geometry.writes).toEqual([LAYOUT_MAX + 120]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Coming off
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('chat scroll takeover — teardown', () => {
|
|
it('releases the wheel when the chat log is replaced', () => {
|
|
// Leaving and re-entering the chat tab unmounts and rebuilds the log,
|
|
// which is the one thing known to clear the stale ceiling. The replacement
|
|
// must start out scrolling natively — otherwise one freeze would degrade
|
|
// the surface for the rest of the session.
|
|
turnSwitchOn();
|
|
const first = buildChatLog();
|
|
const firstGeometry = stubGeometry(first, FROZEN);
|
|
installChatScrollFreezeObserver();
|
|
installChatScrollTakeover();
|
|
driveToFreeze(first);
|
|
expect(chatScrollTakeoverEngaged()).toBe(true);
|
|
|
|
first.remove();
|
|
const second = buildChatLog();
|
|
const secondGeometry = stubGeometry(second, {
|
|
scrollTop: 0,
|
|
scrollHeight: 2347,
|
|
clientHeight: 583,
|
|
});
|
|
advanceClock(500);
|
|
// The probe notices the swap on the next scroll out of the new log.
|
|
scrolled(second);
|
|
|
|
expect(chatScrollTakeoverEngaged()).toBe(false);
|
|
const onOld = wheelEvent(first, 120);
|
|
expect(onOld.defaultPrevented).toBe(false);
|
|
expect(firstGeometry.writes).toEqual([]);
|
|
const onNew = wheelEvent(second, 120);
|
|
expect(onNew.defaultPrevented).toBe(false);
|
|
expect(secondGeometry.writes).toEqual([]);
|
|
});
|
|
|
|
it('releases the wheel when the installer is torn down', () => {
|
|
turnSwitchOn();
|
|
const log = buildChatLog();
|
|
const geometry = stubGeometry(log, FROZEN);
|
|
installChatScrollFreezeObserver();
|
|
const teardown = installChatScrollTakeover();
|
|
driveToFreeze(log);
|
|
expect(chatScrollTakeoverEngaged()).toBe(true);
|
|
|
|
teardown();
|
|
|
|
expect(chatScrollTakeoverEngaged()).toBe(false);
|
|
const event = wheelEvent(log, 120);
|
|
expect(event.defaultPrevented).toBe(false);
|
|
expect(geometry.writes).toEqual([]);
|
|
});
|
|
});
|