| 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 |