返回 DeepSeek-Reasonix
transcript-dom-harness.tsx
根目录 / desktop / frontend / src / __tests__ / transcript-dom-harness.tsx
1 // Shared jsdom harness for Transcript rendering tests. The scroll container,
2 // block measurements, and window extent use deterministic layout metrics.
3
4 import { JSDOM } from "jsdom";
5 import React, { act } from "react";
6 import { createRoot, type Root } from "react-dom/client";
7 import { createServer, type ViteDevServer } from "vite";
8 import type { ReasoningDisplayMode } from "../lib/reasoningDisplayPreference";
9 import type { Item } from "../lib/useController";
10 import { TranscriptTestClock } from "./transcript-test-clock";
11
12 export interface TranscriptHarnessOptions {
13 deterministic?: boolean;
14 /** Viewport height of the scroll container. Default huge: every row mounts. */
15 viewportHeight?: number;
16 /** Fixed measured height for every transcript row. */
17 rowHeight?: number;
18 /** Extra localStorage seed values (display mode, fold preference, …). */
19 storage?: Record<string, string>;
20 /** Authoritative reasoning mode to hydrate before Transcript is imported. */
21 reasoningDisplayMode?: ReasoningDisplayMode;
22 }
23
24 export interface TranscriptHarness {
25 clock: TranscriptTestClock;
26 resizeNotifications: Array<() => void>;
27 observers: Array<{ target: Element; notify: () => void }>;
28 dom: JSDOM;
29 container: HTMLElement;
30 server: ViteDevServer;
31 scrollElement: () => HTMLElement;
32 render: (items: Item[], props?: Record<string, unknown>) => Promise<void>;
33 flush: () => Promise<void>;
34 settle: () => Promise<void>;
35 waitFor: (condition: () => boolean, description: string, attempts?: number) => Promise<void>;
36 unmount: () => Promise<void>;
37 close: () => Promise<void>;
38 loadModule: <T>(path: string) => Promise<T>;
39 }
40
41 export async function createTranscriptHarness(options: TranscriptHarnessOptions = {}): Promise<TranscriptHarness> {
42 const viewportHeight = options.viewportHeight ?? 100_000;
43 const rowHeight = options.rowHeight ?? 10;
44 const clock = new TranscriptTestClock();
45 const resizeNotifications: Array<() => void> = [];
46 const observers: Array<{ target: Element; notify: () => void }> = [];
47
48 const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
49 pretendToBeVisual: true,
50 url: "http://localhost/",
51 });
52 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
53 globalThis.window = dom.window as unknown as Window & typeof globalThis;
54 globalThis.document = dom.window.document;
55 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
56 globalThis.Node = dom.window.Node;
57 globalThis.Element = dom.window.Element;
58 globalThis.HTMLElement = dom.window.HTMLElement;
59 globalThis.Event = dom.window.Event;
60 globalThis.CustomEvent = dom.window.CustomEvent;
61 globalThis.MouseEvent = dom.window.MouseEvent;
62 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
63 globalThis.WheelEvent = dom.window.WheelEvent;
64 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
65 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
66 if (options.deterministic) {
67 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame = clock.requestAnimationFrame;
68 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame = clock.cancelAnimationFrame;
69 }
70 globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window) as typeof getComputedStyle;
71 class TranscriptResizeObserver {
72 constructor(private readonly callback: ResizeObserverCallback) {}
73 observe(target: Element) {
74 const element = target as HTMLElement;
75 const height = element.classList.contains("transcript__row")
76 ? rowHeight
77 : element.classList.contains("transcript__block")
78 ? Math.max(rowHeight, element.querySelectorAll(".transcript__row").length * rowHeight)
79 : element.classList.contains("transcript")
80 ? viewportHeight
81 : element.classList.contains("transcript__header")
82 ? rowHeight
83 : 0;
84 const notify = () => this.callback([{
85 target,
86 contentRect: { width: 800, height, top: 0, right: 800, bottom: height, left: 0, x: 0, y: 0, toJSON: () => ({}) },
87 borderBoxSize: [{ inlineSize: 800, blockSize: height }],
88 contentBoxSize: [{ inlineSize: 800, blockSize: height }],
89 devicePixelContentBoxSize: [{ inlineSize: 800, blockSize: height }],
90 } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver);
91 observers.push({ target, notify });
92 if (options.deterministic) resizeNotifications.push(notify);
93 else queueMicrotask(notify);
94 }
95 unobserve() {}
96 disconnect() {}
97 }
98 globalThis.ResizeObserver = TranscriptResizeObserver as unknown as typeof ResizeObserver;
99 dom.window.ResizeObserver = TranscriptResizeObserver as unknown as typeof ResizeObserver;
100 Object.defineProperty(dom.window, "matchMedia", {
101 configurable: true,
102 value: () => ({
103 matches: true, // prefers-reduced-motion: keep visual transitions out of the assertions
104 media: "(prefers-reduced-motion: reduce)",
105 onchange: null,
106 addEventListener() {},
107 removeEventListener() {},
108 addListener() {},
109 removeListener() {},
110 dispatchEvent: () => false,
111 }),
112 });
113 const storage = new Map<string, string>(Object.entries(options.storage ?? {}));
114 Object.defineProperty(globalThis, "localStorage", {
115 configurable: true,
116 value: {
117 getItem: (key: string) => storage.get(key) ?? null,
118 setItem: (key: string, value: string) => void storage.set(key, value),
119 removeItem: (key: string) => void storage.delete(key),
120 clear: () => storage.clear(),
121 key: () => null,
122 length: 0,
123 },
124 });
125
126 const proto = dom.window.HTMLElement.prototype;
127 const heightOf = (element: HTMLElement): number => {
128 if (element.classList.contains("transcript")) return viewportHeight;
129 if (element.classList.contains("transcript__window")) return Number.parseFloat(element.style.height) || 0;
130 if (element.classList.contains("transcript__block")) return Math.max(rowHeight, element.querySelectorAll(".transcript__row").length * rowHeight);
131 if (element.classList.contains("transcript__row")) return rowHeight;
132 if (element.classList.contains("tooltip-trigger")) return 24;
133 if (element.getAttribute("role") === "tooltip") return 24;
134 return Array.from(element.children).reduce((height, child) => height + ((child as HTMLElement).style.position === "absolute" ? 0 : heightOf(child as HTMLElement)), 0);
135 };
136 const topOf = (element: HTMLElement): number => {
137 if (element.classList.contains("transcript") || !element.parentElement) return 0;
138 const parent = element.parentElement;
139 const parentTop = topOf(parent) - (parent.classList.contains("transcript") ? parent.scrollTop : 0);
140 if (element.style.position === "absolute") return parentTop + (Number.parseFloat(element.style.top) || 0);
141 let top = parentTop;
142 for (const sibling of parent.children) {
143 if (sibling === element) break;
144 if ((sibling as HTMLElement).style.position !== "absolute") top += heightOf(sibling as HTMLElement);
145 }
146 return top;
147 };
148 proto.getBoundingClientRect = function () {
149 const top = topOf(this), height = heightOf(this);
150 return { top, bottom: top + height, left: 0, right: 800, width: 800, height, x: 0, y: top, toJSON: () => ({}) };
151 };
152 Object.defineProperty(proto, "attachEvent", { configurable: true, value: () => {} });
153 Object.defineProperty(proto, "detachEvent", { configurable: true, value: () => {} });
154 Object.defineProperty(proto, "offsetHeight", {
155 configurable: true,
156 get(this: HTMLElement) {
157 if (this.classList.contains("transcript")) return viewportHeight;
158 if (this.classList.contains("transcript__row")) return rowHeight;
159 if (this.classList.contains("transcript__block")) return Math.max(rowHeight, this.querySelectorAll(".transcript__row").length * rowHeight);
160 return 0;
161 },
162 });
163 Object.defineProperty(proto, "offsetWidth", {
164 configurable: true,
165 get() {
166 return 800;
167 },
168 });
169 Object.defineProperty(proto, "clientHeight", {
170 configurable: true,
171 get(this: HTMLElement) {
172 if (this.classList.contains("transcript")) return viewportHeight;
173 return 0;
174 },
175 });
176 Object.defineProperty(proto, "clientWidth", {
177 configurable: true,
178 get(this: HTMLElement) {
179 if (this.classList.contains("transcript")) return 800;
180 return 0;
181 },
182 });
183 Object.defineProperty(proto, "scrollHeight", {
184 configurable: true,
185 get(this: HTMLElement) {
186 if (this.classList.contains("transcript")) {
187 const window = this.querySelector<HTMLElement>(".transcript__window");
188 const windowHeight = Number.parseFloat(window?.style.height || "0");
189 const residentRows = Array.from(this.querySelectorAll(".transcript__resident-tail .transcript__row"))
190 .filter((row) => !row.closest(".transcript__window-item")).length;
191 return Math.max(0, windowHeight + residentRows * rowHeight);
192 }
193 return 0;
194 },
195 });
196 // Native layout clamps scrollTop when the extent shrinks. jsdom stores an
197 // unconstrained number instead; without this, first measurements can leave
198 // a fictitious viewport thousands of pixels beyond the entire document.
199 const nativeScrollTop = Object.getOwnPropertyDescriptor(dom.window.Element.prototype, "scrollTop")!;
200 Object.defineProperty(proto, "scrollTop", {
201 configurable: true,
202 get(this: HTMLElement) {
203 const raw = nativeScrollTop.get!.call(this) as number;
204 if (!this.classList.contains("transcript")) return raw;
205 const maximum = this.scrollHeight - this.clientHeight;
206 if (!Number.isFinite(maximum)) return raw;
207 const top = Math.max(0, Math.min(Math.max(0, maximum), raw));
208 if (top !== raw) nativeScrollTop.set!.call(this, top);
209 return top;
210 },
211 set(this: HTMLElement, value: number) {
212 const maximum = this.scrollHeight - this.clientHeight;
213 const top = this.classList.contains("transcript") && Number.isFinite(maximum)
214 ? Math.max(0, Math.min(Math.max(0, maximum), value)) : value;
215 nativeScrollTop.set!.call(this, top);
216 },
217 });
218 // Keep generic element scroll methods available to nested controls. The
219 // transcript itself writes through TranscriptViewportWriter.
220 (proto as unknown as { scrollTo: (arg?: number | ScrollToOptions) => void }).scrollTo = function (
221 this: HTMLElement,
222 arg?: number | ScrollToOptions,
223 ) {
224 const max = Math.max(0, this.scrollHeight - this.clientHeight);
225 if (typeof arg === "number") {
226 this.scrollTop = Math.max(0, Math.min(max, arg));
227 } else if (arg && typeof arg.top === "number") {
228 this.scrollTop = Math.max(0, Math.min(max, arg.top));
229 }
230 };
231 (proto as unknown as { scrollBy: (arg?: number | ScrollToOptions) => void }).scrollBy = function (
232 this: HTMLElement,
233 arg?: number | ScrollToOptions,
234 ) {
235 const max = Math.max(0, this.scrollHeight - this.clientHeight);
236 if (typeof arg === "number") {
237 this.scrollTop = Math.max(0, Math.min(max, this.scrollTop + arg));
238 } else if (arg && typeof arg.top === "number") {
239 this.scrollTop = Math.max(0, Math.min(max, this.scrollTop + arg.top));
240 }
241 };
242
243 const server = await createServer({
244 appType: "custom",
245 logLevel: "silent",
246 server: { middlewareMode: true },
247 });
248 if (options.reasoningDisplayMode) {
249 const preference = await server.ssrLoadModule("/src/lib/reasoningDisplayPreference.ts") as {
250 hydrateReasoningDisplayMode: (mode: unknown, explicit: boolean) => void;
251 };
252 preference.hydrateReasoningDisplayMode(options.reasoningDisplayMode, true);
253 }
254 // Module I/O is not owned by the fake animation clock. Await the same
255 // presentation prerequisite as the production window before advancing
256 // deterministic frames; a tight fake-clock loop cannot finish disk imports.
257 if (options.deterministic) {
258 const markdown = await server.ssrLoadModule("/src/components/Markdown.tsx");
259 await markdown.preloadMarkdownHistory();
260 }
261 const { TranscriptTestSurface } = await server.ssrLoadModule("/src/__tests__/transcript-test-surface.tsx");
262 const { LocaleProvider } = await server.ssrLoadModule("/src/lib/i18n.tsx");
263 const TranscriptComponent = TranscriptTestSurface as React.ComponentType<Record<string, unknown>>;
264 const Locale = LocaleProvider as React.ComponentType<{ children?: React.ReactNode }>;
265
266 const container = dom.window.document.getElementById("root")!;
267 let root: Root | null = createRoot(container);
268
269 const flush = async () => {
270 await act(async () => {
271 if (options.deterministic) {
272 resizeNotifications.splice(0).forEach((notify) => notify());
273 clock.flushFrames();
274 await new Promise<void>((resolve) => setImmediate(resolve));
275 } else await new Promise((resolve) => setTimeout(resolve, 30));
276 });
277 };
278
279 // Drain lazy Markdown work, ResizeObserver delivery, and the kernel's
280 // coalesced tail frame before a test takes manual control of the viewport.
281 const settle = async () => {
282 for (let i = 0; i < 8; i += 1) {
283 await flush();
284 }
285 };
286
287 // Each attempt costs one 30ms flush, so the default is a three-second budget,
288 // not eight tries. It returns the moment the condition holds, so a generous
289 // cap is free on the happy path and only makes a genuine failure slower —
290 // which beats failing a correct test on a loaded runner.
291 const waitFor = async (condition: () => boolean, description: string, attempts = 100) => {
292 for (let i = 0; i < attempts; i += 1) {
293 if (condition()) return;
294 await flush();
295 }
296 if (!condition()) throw new Error(`timed out waiting for ${description}`);
297 };
298
299 return {
300 clock,
301 resizeNotifications,
302 observers,
303 dom,
304 container,
305 server,
306 scrollElement: () => {
307 const el = container.querySelector<HTMLElement>(".transcript");
308 if (!el) throw new Error("transcript scroll element not mounted");
309 return el;
310 },
311 render: async (items, props = {}) => {
312 await act(async () => {
313 root!.render(
314 React.createElement(
315 Locale,
316 null,
317 React.createElement(TranscriptComponent, {
318 items,
319 onPrompt: () => {},
320 questionNavigator: false,
321 viewportHeight,
322 rowHeight,
323 kernelClock: options.deterministic ? clock : undefined,
324 ...props,
325 }),
326 ),
327 );
328 });
329 // Lazy Markdown and window measurement can schedule a second commit.
330 await flush();
331 await flush();
332 },
333 flush,
334 settle,
335 waitFor,
336 unmount: async () => {
337 const current = root;
338 root = null;
339 await act(async () => current?.unmount());
340 },
341 close: async () => {
342 // React.lazy Markdown chunks may resolve just after the last act() in a
343 // block. Let those requests settle before tearing down Vite's SSR
344 // module runner; otherwise the runner reports a transport disconnect
345 // even though every assertion completed.
346 await new Promise((resolve) => setTimeout(resolve, 100));
347 await server.close();
348 },
349 loadModule: <T,>(path: string) => server.ssrLoadModule(path) as Promise<T>,
350 };
351 }
352
352 lines Plain Text