import React from "react" import { flushSync } from "react-dom" import { createRoot, Root } from "react-dom/client" import { act } from "react" import _ from "lodash" import ReactTestUtils from "react-dom/test-utils" // Install legacy ReactTestUtils shims at module load. React 19 removed // scryRenderedDOMComponents*/findRenderedDOMComponent* helpers; tests in this // repo still call them with `comp.getComponent()` (which under React 19 we // make return the container DOM element), so we install querySelector-based // fallbacks that match the legacy signature. These are a stop-gap until tests // migrate to React Testing Library (Phase C). // react-dom/test-utils is CommonJS without __esModule, so the default import is the module object // itself and the shims below reach every other importer of it const ReactTestUtilsModule: any = ReactTestUtils function ensureLegacyShim(name: string, impl: (...args: any[]) => any) { if (typeof ReactTestUtilsModule[name] !== "function") { ReactTestUtilsModule[name] = impl } } ensureLegacyShim("scryRenderedDOMComponentsWithTag", (tree: any, tag: string) => { const root = tree as Element return Array.from(root.querySelectorAll(tag)) }) ensureLegacyShim("findRenderedDOMComponentWithTag", (tree: any, tag: string) => { const matches = (tree as Element).querySelectorAll(tag) if (matches.length !== 1) { throw new Error(`Did not find exactly one match (found: ${matches.length}) for tag:${tag}`) } return matches[0] }) ensureLegacyShim("scryRenderedDOMComponentsWithClass", (tree: any, className: string) => { return Array.from((tree as Element).getElementsByClassName(className)) }) ensureLegacyShim("findRenderedDOMComponentWithClass", (tree: any, className: string) => { const matches = (tree as Element).getElementsByClassName(className) if (matches.length !== 1) { throw new Error(`Did not find exactly one match (found: ${matches.length}) for class:${className}`) } return matches[0] }) ensureLegacyShim("findRenderedComponentWithType", (tree: any, _type: any) => { // Tests in this repo only use this to grab a sole rendered subtree root; // returning the container suffices for that pattern. return tree }) ensureLegacyShim("findAllInRenderedTree", (tree: any, test: (n: any) => boolean) => { const matches: any[] = [] const walk = (node: any) => { if (test(node)) matches.push(node) if (node && node.childNodes) { node.childNodes.forEach(walk) } } walk(tree) return matches }) // React 19 removed Simulate. Map to native event dispatch so legacy tests work. if (!ReactTestUtilsModule.Simulate) { // For change events on controlled inputs, React tracks the input's value // internally via a `_valueTracker`. When the test sets `el.value = "x"` // directly, React's monkey-patched setter records the new value. A // subsequently-dispatched "input" event then sees tracker.value === el.value // and concludes nothing changed, so onChange never fires. We sidestep that // by clearing the tracker's stored value before dispatching, so React // compares the (real) new value to an empty stored value and treats the // input as having actually changed. const changeFire = (el: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement) => { const tracker = (el as any)._valueTracker if (tracker) { tracker.setValue("") } // Wrap in act so React 18+ commits state updates synchronously before // the test's next assertion runs. act(() => { el.dispatchEvent(new Event("input", { bubbles: true })) el.dispatchEvent(new Event("change", { bubbles: true })) }) } const fire = (type: string) => (el: any, eventData?: any) => { if (type === "change") { changeFire(el) return } let event: Event if (type === "keyDown" || type === "keyUp" || type === "keyPress") { const eventType = type === "keyDown" ? "keydown" : type === "keyUp" ? "keyup" : "keypress" event = new KeyboardEvent(eventType, { bubbles: true, ...(eventData || {}) }) } else if (type === "blur" || type === "focus") { // React 17+ delegates focus/blur using bubbling focusin/focusout events // at the root container, so dispatch those (plain blur/focus don't bubble // and React's listener won't see them). const bubbleType = type === "blur" ? "focusout" : "focusin" event = new (window as any).FocusEvent(bubbleType, { bubbles: true, ...(eventData || {}) }) } else { event = new (window as any).MouseEvent(type, { bubbles: true, ...(eventData || {}) }) } act(() => { el.dispatchEvent(event) }) } ReactTestUtilsModule.Simulate = { click: fire("click"), change: fire("change"), blur: fire("blur"), focus: fire("focus"), keyDown: fire("keyDown"), keyUp: fire("keyUp"), keyPress: fire("keyPress") } } /** Passthrough wrapper used to capture an instance ref for compatibility with * legacy test helpers (older React versions used this for tree walking). */ class PassthroughRoot extends React.Component<{ children: React.ReactNode }> { render() { return this.props.children } } // Test component that can be created, have its element changed, have clicks and other actions simulated // Used for unit testing React components. // // Under React 19, `getComponent()` returns the container DOM element so the // ReactTestUtils shims above (which use querySelector) work directly. export default class TestComponent { div: HTMLDivElement root: Root passthrough: any comp: any constructor(elem: any) { this.div = document.createElement("div") this.root = createRoot(this.div) // flushSync forces React 19 to commit synchronously so test assertions // run after the DOM is fully rendered. flushSync(() => { this.root.render( React.createElement(PassthroughRoot, { ref: (c: any) => { this.passthrough = c } } as any, elem) ) }) // For legacy ReactTestUtils helpers we expose the container DOM element this.comp = this.div } setElement(elem: any) { flushSync(() => { this.root.render( React.createElement(PassthroughRoot, { ref: (c: any) => { this.passthrough = c } } as any, elem) ) }) } getComponent() { return this.comp } destroy() { this.root.unmount() } // Finds DOM node by pattern (optional) findDOMNodesByText(pattern: any) { const matches: any = [] function findRecursively(node: any): any { // Recurse to children if (node.nodeType === 1) { return (() => { const result = [] for (let subnode of node.childNodes) { // Check text match if (subnode.nodeType === 3 && subnode.nodeValue.match(pattern)) { matches.push(node) } result.push(findRecursively(subnode)) } return result })() } } findRecursively(this.div) return matches } // Finds a DOM node by pattern findDOMNodeByText(pattern: any) { return this.findDOMNodesByText(pattern)[0] } // Find a subcomponent by a pattern (deprecated) findComponentByText(pattern: any) { return this.findDOMNodesByText(pattern) } // Find input field findInput() { const inputs = this.div.querySelectorAll("input") if (inputs.length !== 1) { throw new Error(`Expected 1 input, found ${inputs.length}`) } return inputs[0] } findComponentById(id: any) { // Use attribute selector form so ids with CSS-special chars (e.g. ":" in // "itemB:choiceB") work without depending on CSS.escape (not in jsdom 24). const safe = String(id).replace(/"/g, '\\"') return this.div.querySelector(`[id="${safe}"]`) } static click(comp: any) { return ReactTestUtilsModule.Simulate.click(comp) } static pressEnter(comp: any) { return ReactTestUtilsModule.Simulate.keyDown(comp, { key: "Enter", keyCode: 13, which: 13 }) } static pressTab(comp: any) { return ReactTestUtilsModule.Simulate.keyDown(comp, { key: "Tab", keyCode: 9, which: 9 }) } static changeValue(comp: any, value: any) { // React tracks input values via a "value" setter it monkey-patches on // mount. Plain `comp.value = value` bypasses that tracker, so React's // onChange never fires. We must set via the original prototype setter // (which writes to the DOM in a way React still detects as a change) // and dispatch an "input" event (React 16+ listens for input). const proto = comp instanceof HTMLTextAreaElement ? window.HTMLTextAreaElement.prototype : comp instanceof HTMLSelectElement ? window.HTMLSelectElement.prototype : window.HTMLInputElement.prototype const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set setter?.call(comp, value) comp.dispatchEvent(new Event("input", { bubbles: true })) comp.dispatchEvent(new Event("change", { bubbles: true })) } }