UNPKG

jsdom-testing-mocks

Version:

A set of tools for emulating browser behavior in jsdom environment

302 lines (248 loc) 8.19 kB
import { RequireAtLeastOne } from 'type-fest'; import { mockDOMRect } from './size/DOMRect'; import { isJsdomEnv, WrongEnvironmentError } from '../helper'; import { getConfig } from '../tools'; const config = getConfig(); type Sizes = { borderBoxSize: ResizeObserverSize[]; contentBoxSize: ResizeObserverSize[]; contentRect: DOMRectReadOnly; }; type ResizeObserverSizeInput = RequireAtLeastOne<ResizeObserverSize>; type SizeInput = { borderBoxSize: ResizeObserverSizeInput[] | ResizeObserverSizeInput; contentBoxSize: ResizeObserverSizeInput[] | ResizeObserverSizeInput; }; type Size = RequireAtLeastOne<SizeInput>; type State = { observers: MockedResizeObserver[]; targetObservers: Map<HTMLElement, MockedResizeObserver[]>; elementSizes: Map<HTMLElement, Sizes>; }; const state: State = { observers: [], targetObservers: new Map(), elementSizes: new Map(), }; function resetState() { state.observers = []; state.targetObservers = new Map(); state.elementSizes = new Map(); } function defineResizeObserverSize( input: ResizeObserverSizeInput ): ResizeObserverSize { return { blockSize: input.blockSize ?? 0, inlineSize: input.inlineSize ?? 0, }; } class MockedResizeObserver implements ResizeObserver { callback: ResizeObserverCallback; observationTargets = new Set<HTMLElement>(); activeTargets = new Set<HTMLElement>(); constructor(callback: ResizeObserverCallback) { this.callback = callback; state.observers.push(this); } observe = (node: HTMLElement) => { this.observationTargets.add(node); this.activeTargets.add(node); if (state.targetObservers.has(node)) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion state.targetObservers.get(node)!.push(this); } else { state.targetObservers.set(node, [this]); } }; unobserve = (node: HTMLElement) => { this.observationTargets.delete(node); const targetObservers = state.targetObservers.get(node); if (targetObservers) { const index = targetObservers.findIndex((mro) => mro === this); targetObservers.splice(index, 1); if (targetObservers.length === 0) { state.targetObservers.delete(node); } } }; disconnect = () => { for (const node of this.observationTargets) { const targetObservers = state.targetObservers.get(node); if (targetObservers) { const index = targetObservers.findIndex((mro) => mro === this); targetObservers.splice(index, 1); if (targetObservers.length === 0) { state.targetObservers.delete(node); } } } this.observationTargets.clear(); }; } function elementToEntry(element: HTMLElement): ResizeObserverEntry | null { const boundingClientRect = element.getBoundingClientRect(); let sizes = state.elementSizes.get(element); if (!sizes) { sizes = { borderBoxSize: [ { blockSize: boundingClientRect.width, inlineSize: boundingClientRect.height, }, ], contentBoxSize: [ { blockSize: boundingClientRect.width, inlineSize: boundingClientRect.height, }, ], contentRect: boundingClientRect, }; } if (sizes.contentRect.width === 0 && sizes.contentRect.height === 0) { return null; } return { borderBoxSize: Object.freeze(sizes.borderBoxSize), contentBoxSize: Object.freeze(sizes.contentBoxSize), contentRect: sizes.contentRect, devicePixelContentBoxSize: Object.freeze( sizes.contentBoxSize.map((size) => ({ blockSize: size.blockSize * window.devicePixelRatio, inlineSize: size.inlineSize * window.devicePixelRatio, })) ), target: element, }; } function mockResizeObserver() { if (!isJsdomEnv()) { throw new WrongEnvironmentError(); } mockDOMRect(); const savedImplementation = window.ResizeObserver; Object.defineProperty(window, 'ResizeObserver', { writable: true, configurable: true, value: MockedResizeObserver, }); config.afterEach(() => { resetState(); }); config.afterAll(() => { window.ResizeObserver = savedImplementation; }); return { getObservers: (element?: HTMLElement) => { if (element) { return [...(state.targetObservers.get(element) ?? [])]; } return [...state.observers]; }, getObservedElements: (observer?: ResizeObserver) => { if (observer) { return [...(observer as MockedResizeObserver).observationTargets]; } return [...state.targetObservers.keys()]; }, mockElementSize: (element: HTMLElement, size: Size) => { let contentBoxSize: ResizeObserverSize[]; let borderBoxSize: ResizeObserverSize[]; if (!size.borderBoxSize && size.contentBoxSize) { if (!Array.isArray(size.contentBoxSize)) { size.contentBoxSize = [size.contentBoxSize]; } contentBoxSize = size.contentBoxSize.map(defineResizeObserverSize); borderBoxSize = contentBoxSize; } else if (size.borderBoxSize && !size.contentBoxSize) { if (!Array.isArray(size.borderBoxSize)) { size.borderBoxSize = [size.borderBoxSize]; } contentBoxSize = size.borderBoxSize.map(defineResizeObserverSize); borderBoxSize = contentBoxSize; } else if (size.borderBoxSize && size.contentBoxSize) { if (!Array.isArray(size.borderBoxSize)) { size.borderBoxSize = [size.borderBoxSize]; } if (!Array.isArray(size.contentBoxSize)) { size.contentBoxSize = [size.contentBoxSize]; } contentBoxSize = size.contentBoxSize.map(defineResizeObserverSize); borderBoxSize = size.borderBoxSize.map(defineResizeObserverSize); if (borderBoxSize.length !== contentBoxSize.length) { throw new Error( 'Both borderBoxSize and contentBoxSize must have the same amount of elements.' ); } } else { throw new Error( 'Neither borderBoxSize nor contentBoxSize was provided.' ); } // verify contentBoxSize and borderBoxSize are not negative contentBoxSize.forEach((size, index) => { if (size.blockSize < 0) { throw new Error( `contentBoxSize[${index}].blockSize must not be negative.` ); } if (size.inlineSize < 0) { throw new Error( `contentBoxSize[${index}].inlineSize must not be negative.` ); } }); borderBoxSize.forEach((size, index) => { if (size.blockSize < 0) { throw new Error( `borderBoxSize[${index}].blockSize must not be negative.` ); } if (size.inlineSize < 0) { throw new Error( `borderBoxSize[${index}].inlineSize must not be negative.` ); } }); const contentRect = new DOMRect( 0, 0, contentBoxSize.reduce((acc, size) => acc + size.inlineSize, 0), contentBoxSize.reduce((acc, size) => acc + size.blockSize, 0) ); state.elementSizes.set(element, { contentBoxSize, borderBoxSize, contentRect, }); }, resize: ( elements: HTMLElement | HTMLElement[] = [], { ignoreImplicit = false } = {} ) => { config.act(() => { if (!Array.isArray(elements)) { elements = [elements]; } for (const observer of state.observers) { const observedSubset = elements.filter((element) => observer.observationTargets.has(element) ); const observedSubsetAndActive = new Set([ ...observedSubset, ...(ignoreImplicit ? [] : observer.activeTargets), ]); observer.activeTargets.clear(); const entries = Array.from(observedSubsetAndActive) .map(elementToEntry) .filter(Boolean) as ResizeObserverEntry[]; if (entries.length > 0) { observer.callback(entries, observer); } } }); }, }; } export { mockResizeObserver };