1// @vitest-environment jsdom
2
3import { act } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6import { WorkLogDetails } from "./WorkLog";
7
8// jsdom has no layout, so the scroller's geometry is simulated: a 384px
9// viewport over content whose height the test grows like a streamed trace.
10const VIEWPORT = 384;
11let contentHeight = 0;
12let scrollTop = 0;
13
14let root: Root;
15let host: HTMLDivElement;
16
17function render(followLive: boolean, text: string) {
18 act(() => {
19 root.render(
20 <WorkLogDetails followLive={followLive}>
21 <p>{text}</p>
22 </WorkLogDetails>,
23 );
24 });
25}
26
27function scroller() {
28 const element = host.firstElementChild;
29 if (!(element instanceof HTMLDivElement)) throw new Error("missing scroller");
30 return element;
31}
32
33function stream(followLive: boolean, text: string) {
34 contentHeight += 200;
35 render(followLive, text);
36}
37
38function readerScrollsTo(top: number) {
39 scroller().scrollTop = top;
40 act(() => {
41 scroller().dispatchEvent(new Event("scroll"));
42 });
43}
44
45const maxScrollTop = () => contentHeight - VIEWPORT;
46
47beforeEach(() => {
48 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
49 contentHeight = 1000;
50 scrollTop = 0;
51 Object.defineProperties(HTMLDivElement.prototype, {
52 clientHeight: { configurable: true, get: () => VIEWPORT },
53 scrollHeight: { configurable: true, get: () => contentHeight },
54 scrollTop: {
55 configurable: true,
56 get: () => scrollTop,
57 set: (value: number) => {
58 scrollTop = Math.max(0, Math.min(value, maxScrollTop()));
59 },
60 },
61 });
62 host = document.createElement("div");
63 document.body.append(host);
64 root = createRoot(host);
65});
66
67afterEach(() => {
68 act(() => root.unmount());
69 host.remove();
70 for (const key of ["clientHeight", "scrollHeight", "scrollTop"]) {
71 Reflect.deleteProperty(HTMLDivElement.prototype, key);
72 }
73 vi.unstubAllGlobals();
74});
75
76describe("WorkLogDetails followLive", () => {
77 it("keeps the newest streamed text in view", () => {
78 render(true, "a");
79 expect(scrollTop).toBe(maxScrollTop());
80
81 stream(true, "ab");
82 stream(true, "abc");
83 expect(scrollTop).toBe(maxScrollTop());
84 });
85
86 it("stops following when the reader scrolls up and resumes at the end", () => {
87 render(true, "a");
88 readerScrollsTo(100);
89
90 stream(true, "ab");
91 expect(scrollTop).toBe(100);
92
93 readerScrollsTo(maxScrollTop());
94 stream(true, "abc");
95 expect(scrollTop).toBe(maxScrollTop());
96 });
97
98 it("does not move a trace that is not streaming", () => {
99 render(false, "a");
100 stream(false, "ab");
101 expect(scrollTop).toBe(0);
102 });
103});