diff --git a/global.d.ts b/global.d.ts index 55f1024..f07aa0d 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1,12 +1,41 @@ +/* eslint-disable @typescript-eslint/no-empty-interface */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { Mock as ViMock } from 'vitest'; + +// By re-declaring the vitest module, we can augment its types. +declare module 'vitest' { + /** + * Augment vitest's Mock type to be compatible with jest.Mock. + * This allows us to use a single, consistent type for mocks across both test runners. + */ + export interface Mock + extends jest.Mock {} + + /** + * Augment vitest's SpyInstance to be compatible with jest.SpyInstance. + * Note the swapped generic arguments: + * - Vitest: SpyInstance<[Args], ReturnValue> + * - Jest: SpyInstance + * This declaration makes them interoperable. + */ + export interface SpyInstance + extends jest.SpyInstance {} +} + export {}; +interface Runner { + name: 'vi' | 'jest'; + useFakeTimers: () => void; + useRealTimers: () => void; + advanceTimersByTime: (time: number) => Promise | void; + /** A generic function to create a mock function, compatible with both runners. */ + fn: () => jest.Mock; + /** A generic function to spy on a method, compatible with both runners. */ + spyOn: typeof jest.spyOn; +} + declare global { // eslint-disable-next-line no-var - var runner: { - name: 'vi' | 'jest'; - useFakeTimers: () => void; - useRealTimers: () => void; - advanceTimersByTime: (time: number) => Promise; - fn: () => jest.Mock; - }; + var runner: Runner; } diff --git a/jest-setup.ts b/jest-setup.ts index f123b11..1f07c57 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -14,10 +14,15 @@ function fn() { return jest.fn(); } +function spyOn(...args: Parameters) { + return jest.spyOn(...args); +} + globalThis.runner = { name: 'jest', useFakeTimers, useRealTimers, advanceTimersByTime, fn, + spyOn, }; diff --git a/package.json b/package.json index 28bbdfc..497a266 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jsdom-testing-mocks", - "version": "1.13.1", + "version": "1.14.0", "author": "Ivan Galiatin", "license": "MIT", "description": "A set of tools for emulating browser behavior in jsdom environment", diff --git a/src/mocks/web-animations-api/Animation.ts b/src/mocks/web-animations-api/Animation.ts index 34bb7a0..22e4afc 100644 --- a/src/mocks/web-animations-api/Animation.ts +++ b/src/mocks/web-animations-api/Animation.ts @@ -4,6 +4,7 @@ import { mockDocumentTimeline } from './DocumentTimeline'; import { getEasingFunctionFromString } from './easingFunctions'; import { addAnimation, removeAnimation } from './elementAnimations'; import { getConfig } from '../../tools'; +import { cssNumberishToNumber, numberToCSSNumberish } from './cssNumberishHelpers'; type ActiveAnimationTimeline = AnimationTimeline & { currentTime: NonNullable; @@ -40,6 +41,8 @@ export const RENAMED_KEYFRAME_PROPERTIES: { cssOffset: 'offset', }; + + // eslint-disable-next-line @typescript-eslint/no-empty-function const noop = () => {}; @@ -58,7 +61,7 @@ class MockedAnimation extends EventTarget implements Animation { // implementation details #finishedPromise: Promise; #readyPromise: Promise; - #startTime: CSSNumberish | null = null; + #startTime: number | null = null; #pendingPauseTask: (() => void) | null = null; #pendingPlayTask: (() => void) | null = null; #previousCurrentTime: number | null = null; @@ -117,7 +120,21 @@ class MockedAnimation extends EventTarget implements Animation { } #getTiming() { - return this.#effect!.getTiming() as DefinedEffectTiming; + if (!this.#effect) { + // Per the spec, if there's no effect, we should return a default timing object. + // https://www.w3.org/TR/web-animations-1/#dom-animation-effect + return { + delay: 0, + direction: 'normal', + duration: 0, + easing: 'linear', + endDelay: 0, + fill: 'auto', + iterationStart: 0, + iterations: 1, + } as DefinedEffectTiming; + } + return this.#effect.getTiming() as DefinedEffectTiming; } #getComputedTiming() { @@ -271,7 +288,11 @@ class MockedAnimation extends EventTarget implements Animation { return 'idle'; } - const { delay, activeDuration, endTime } = this.#getComputedTiming(); + const computedTiming = this.#getComputedTiming(); + const delay = cssNumberishToNumber(computedTiming.delay) ?? 0; + const activeDuration = cssNumberishToNumber(computedTiming.activeDuration) ?? 0; + const endTime = cssNumberishToNumber(computedTiming.endTime) ?? 0; + const localTimeNum = cssNumberishToNumber(localTime) ?? 0; const beforeActiveBoundaryTime = Math.max(Math.min(delay, endTime), 0); const activeAfterBoundaryTime = Math.max( @@ -280,17 +301,17 @@ class MockedAnimation extends EventTarget implements Animation { ); if ( - localTime < beforeActiveBoundaryTime || + localTimeNum < beforeActiveBoundaryTime || (this.#animationDirection === 'backwards' && - localTime === beforeActiveBoundaryTime) + localTimeNum === beforeActiveBoundaryTime) ) { return 'before'; } if ( - localTime > activeAfterBoundaryTime || + localTimeNum > activeAfterBoundaryTime || (this.#animationDirection === 'forwards' && - localTime === activeAfterBoundaryTime) + localTimeNum === activeAfterBoundaryTime) ) { return 'after'; } @@ -445,7 +466,12 @@ class MockedAnimation extends EventTarget implements Animation { ) { return null; } else { - return (this.#timeline.currentTime - this.startTime) * this.playbackRate; + const timelineTime = cssNumberishToNumber(this.#timeline.currentTime); + const startTime = cssNumberishToNumber(this.startTime); + if (timelineTime === null || startTime === null) { + return null; + } + return (timelineTime - startTime) * this.playbackRate; } } @@ -453,7 +479,7 @@ class MockedAnimation extends EventTarget implements Animation { let startTime = null; if (this.#timeline) { - const timelineTime = this.#timeline.currentTime; + const timelineTime = cssNumberishToNumber(this.#timeline.currentTime); if (timelineTime !== null) { startTime = timelineTime - seekTime / this.playbackRate; @@ -537,12 +563,12 @@ class MockedAnimation extends EventTarget implements Animation { this.#previousCurrentTime = null; } - get startTime() { + get startTime(): CSSNumberish | null { return this.#startTime; } // 4.4.5. Setting the start time of an animation - set startTime(newTime: number | null) { + set startTime(newTime: CSSNumberish | null) { // 1. Let timeline time be the current time value of the timeline that animation is associated with. If there is no timeline associated with animation or the associated timeline is inactive, let the timeline time be unresolved. const timelineTime = this.#timeline?.currentTime ?? null; @@ -552,13 +578,13 @@ class MockedAnimation extends EventTarget implements Animation { } // 3. Let previous current time be animation’s current time. - this.#previousCurrentTime = this.currentTime; + this.#previousCurrentTime = cssNumberishToNumber(this.currentTime); // 4. Apply any pending playback rate on animation. this.#applyPendingPlaybackRate(); // 5. Set animation’s start time to new start time. - this.#startTime = newTime; + this.#startTime = cssNumberishToNumber(newTime); // 6. Update animation’s hold time based on the first matching condition from the following, if (newTime !== null) { @@ -691,15 +717,21 @@ class MockedAnimation extends EventTarget implements Animation { if (this.#holdTime !== null) { this.#applyPendingPlaybackRate(); + const readyTimeNum = cssNumberishToNumber(readyTime); + if (readyTimeNum === null) return; + const holdTimeNum = this.#holdTime; const newStartTime = this.#playbackRate === 0 - ? readyTime - : readyTime - this.#holdTime / this.#playbackRate; + ? readyTimeNum + : readyTimeNum - holdTimeNum / this.#playbackRate; - this.startTime = newStartTime; + this.startTime = numberToCSSNumberish(newStartTime); } else if (this.#startTime !== null && this.#pendingPlaybackRate !== null) { + const readyTimeNum = cssNumberishToNumber(readyTime); + const startTimeNum = cssNumberishToNumber(this.#startTime); + if (readyTimeNum === null || startTimeNum === null) return; const currentTimeToMatch = - (readyTime - this.#startTime) * this.playbackRate; + (readyTimeNum - startTimeNum) * this.playbackRate; this.#applyPendingPlaybackRate(); @@ -707,8 +739,8 @@ class MockedAnimation extends EventTarget implements Animation { this.#holdTime = currentTimeToMatch; } else { const newStartTime = - readyTime - currentTimeToMatch / this.#playbackRate; - this.startTime = newStartTime; + readyTimeNum - currentTimeToMatch / this.#playbackRate; + this.startTime = numberToCSSNumberish(newStartTime); } } @@ -738,10 +770,12 @@ class MockedAnimation extends EventTarget implements Animation { const effectEnd = this.#getComputedTiming().endTime; // condition 1 + const currentTimeNum = cssNumberishToNumber(currentTime); + const effectEndNum = cssNumberishToNumber(effectEnd); if ( this.#effectivePlaybackRate > 0 && autoRewind && - (currentTime === null || currentTime < 0 || currentTime >= effectEnd) + (currentTimeNum === null || currentTimeNum < 0 || (effectEndNum !== null && currentTimeNum >= effectEndNum)) ) { seekTime = 0; } @@ -749,16 +783,16 @@ class MockedAnimation extends EventTarget implements Animation { else if ( this.#effectivePlaybackRate < 0 && autoRewind && - (currentTime === null || currentTime <= 0 || currentTime > effectEnd) + (currentTimeNum === null || currentTimeNum <= 0 || (effectEndNum !== null && currentTimeNum > effectEndNum)) ) { - if (effectEnd === Infinity) { + if (effectEndNum === Infinity) { throw new DOMException( "Failed to execute 'play' on 'Animation': Cannot play reversed Animation with infinite target effect end.", 'InvalidStateError' ); } - seekTime = effectEnd; + seekTime = effectEndNum ?? 0; } // condition 3 else if (this.#effectivePlaybackRate === 0 && currentTime === null) { @@ -848,7 +882,11 @@ class MockedAnimation extends EventTarget implements Animation { // 2. If animation’s start time is resolved and its hold time is not resolved, let animation’s hold time be the result of evaluating (ready time - start time) × playback rate. if (this.#startTime !== null && this.#holdTime === null) { - this.#holdTime = (readyTime - this.#startTime) * this.#playbackRate; + const readyTimeNum = cssNumberishToNumber(readyTime); + const startTimeNum = cssNumberishToNumber(this.#startTime); + if (readyTimeNum !== null && startTimeNum !== null) { + this.#holdTime = (readyTimeNum - startTimeNum) * this.#playbackRate; + } } // Note: The hold time might be already set if the animation is finished, or if the animation has a pending play task. In either case we want to preserve the hold time as we enter the paused state. @@ -907,7 +945,8 @@ class MockedAnimation extends EventTarget implements Animation { 'InvalidStateError' ); } else { - seekTime = effectEnd; + const effectEndNum = cssNumberishToNumber(effectEnd); + seekTime = effectEndNum ?? 0; } } } @@ -1052,19 +1091,21 @@ class MockedAnimation extends EventTarget implements Animation { // 2. Let the hold time be unresolved. const playbackRate = this.playbackRate; - if (playbackRate > 0 && unconstrainedCurrentTime >= effectEnd) { + const unconstrainedCurrentTimeNum = cssNumberishToNumber(unconstrainedCurrentTime); + const effectEndNum = cssNumberishToNumber(effectEnd); + if (playbackRate > 0 && unconstrainedCurrentTimeNum !== null && effectEndNum !== null && unconstrainedCurrentTimeNum >= effectEndNum) { if (didSeek) { - this.#holdTime = unconstrainedCurrentTime; + this.#holdTime = unconstrainedCurrentTimeNum; } else { if (this.#previousCurrentTime === null) { - this.#holdTime = effectEnd; + this.#holdTime = effectEndNum; } else { - this.#holdTime = Math.max(this.#previousCurrentTime, effectEnd); + this.#holdTime = Math.max(this.#previousCurrentTime, effectEndNum); } } - } else if (playbackRate < 0 && unconstrainedCurrentTime <= 0) { + } else if (playbackRate < 0 && unconstrainedCurrentTimeNum !== null && unconstrainedCurrentTimeNum <= 0) { if (didSeek) { - this.#holdTime = unconstrainedCurrentTime; + this.#holdTime = unconstrainedCurrentTimeNum; } else { if (this.#previousCurrentTime === null) { this.#holdTime = 0; @@ -1074,8 +1115,10 @@ class MockedAnimation extends EventTarget implements Animation { } } else if (playbackRate !== 0 && this.#isTimelineActive()) { if (didSeek && this.#holdTime !== null) { - this.startTime = - this.timeline.currentTime - this.#holdTime / playbackRate; + const timelineTimeNum = cssNumberishToNumber(this.timeline.currentTime); + if (timelineTimeNum !== null) { + this.startTime = numberToCSSNumberish(timelineTimeNum - this.#holdTime / playbackRate); + } } this.#holdTime = null; } @@ -1140,11 +1183,17 @@ class MockedAnimation extends EventTarget implements Animation { const limit = this.#playbackRate > 0 ? effectEnd : 0; // 4. Silently set the current time to limit. - this.#setCurrentTimeSilent(limit); + const limitNum = cssNumberishToNumber(limit); + if (limitNum !== null) { + this.#setCurrentTimeSilent(limitNum); + } // 5. If animation’s start time is unresolved and animation has an associated active timeline, let the start time be the result of evaluating timeline time - (limit / playback rate) where timeline time is the current time value of the associated timeline. if (this.#startTime === null && this.#isTimelineActive()) { - this.#startTime = this.timeline.currentTime - limit / this.#playbackRate; + const timelineTimeNum = cssNumberishToNumber(this.timeline.currentTime); + if (timelineTimeNum !== null && limitNum !== null) { + this.#startTime = timelineTimeNum - limitNum / this.#playbackRate; + } } // 6. If there is a pending pause task and start time is resolved, @@ -1246,7 +1295,10 @@ class MockedAnimation extends EventTarget implements Animation { // 4. If previous time is resolved, set the current time of animation to previous time if (previousTime !== null) { - this.currentTime = previousTime; + const previousTimeNum = cssNumberishToNumber(previousTime); + if (previousTimeNum !== null) { + this.currentTime = previousTimeNum; + } } } @@ -1285,14 +1337,19 @@ class MockedAnimation extends EventTarget implements Animation { if (this.#pendingPlaybackRate !== 0) { if (timelineTime) { - this.#startTime = unconstrainedCurrentTime - ? timelineTime - - unconstrainedCurrentTime / this.#pendingPlaybackRate - : null; + const timelineTimeNum = cssNumberishToNumber(timelineTime); + const unconstrainedCurrentTimeNum = cssNumberishToNumber(unconstrainedCurrentTime); + if (timelineTimeNum !== null) { + this.#startTime = unconstrainedCurrentTimeNum + ? timelineTimeNum - + unconstrainedCurrentTimeNum / this.#pendingPlaybackRate + : null; + } } } else { // 3. If pending playback rate is zero, let animation’s start time be timeline time. - this.#startTime = timelineTime; + const timelineTimeNum = cssNumberishToNumber(timelineTime); + this.#startTime = timelineTimeNum; } // 4. Apply any pending playback rate on animation. @@ -1379,8 +1436,15 @@ class MockedAnimation extends EventTarget implements Animation { else if ( currentTime !== null && ((this.#effectivePlaybackRate > 0 && - currentTime >= this.#getComputedTiming().endTime) || - (this.#effectivePlaybackRate < 0 && currentTime <= 0)) + (() => { + const currentTimeNum = cssNumberishToNumber(currentTime); + const endTimeNum = cssNumberishToNumber(this.#getComputedTiming().endTime); + return currentTimeNum !== null && endTimeNum !== null && currentTimeNum >= endTimeNum; + })()) || + (this.#effectivePlaybackRate < 0 && (() => { + const currentTimeNum = cssNumberishToNumber(currentTime); + return currentTimeNum !== null && currentTimeNum <= 0; + })())) ) { return 'finished'; } @@ -1426,21 +1490,38 @@ class MockedAnimation extends EventTarget implements Animation { switch (this.#phase) { case 'before': if (this.#fillMode === 'backwards' || this.#fillMode === 'both') { - return Math.max(localTime - computedTiming.delay, 0); + const localTimeNum = cssNumberishToNumber(localTime); + const delayNum = cssNumberishToNumber(computedTiming.delay); + if (localTimeNum !== null && delayNum !== null) { + return Math.max(localTimeNum - delayNum, 0); + } + return null; } else { return null; } - case 'active': - return localTime - computedTiming.delay; + case 'active': { + const localTimeNum = cssNumberishToNumber(localTime); + const delayNum = cssNumberishToNumber(computedTiming.delay); + if (localTimeNum !== null && delayNum !== null) { + return localTimeNum - delayNum; + } + return null; + } case 'after': if (this.#fillMode === 'forwards' || this.#fillMode === 'both') { - return Math.max( - Math.min( - localTime - computedTiming.delay, - computedTiming.activeDuration - ), - 0 - ); + const localTimeNum = cssNumberishToNumber(localTime); + const delayNum = cssNumberishToNumber(computedTiming.delay); + const activeDurationNum = cssNumberishToNumber(computedTiming.activeDuration); + if (localTimeNum !== null && delayNum !== null && activeDurationNum !== null) { + return Math.max( + Math.min( + localTimeNum - delayNum, + activeDurationNum + ), + 0 + ); + } + return null; } else { return null; } @@ -1546,7 +1627,12 @@ class MockedAnimation extends EventTarget implements Animation { const iterationProgress = this.#iterationProgress; // overall progress should be defined here, see #overallProgress getter - const overallProgress = this.#overallProgress!; + const overallProgress = this.#overallProgress; + if (overallProgress === null) { + // This case should ideally not be reached if activeTime is not null, + // but as a fallback, we can return 0. + return 0; + } if (iterationProgress === 1.0) { return Math.floor(overallProgress) - 1; diff --git a/src/mocks/web-animations-api/AnimationEffect.ts b/src/mocks/web-animations-api/AnimationEffect.ts index 65ef2d5..bc2c511 100644 --- a/src/mocks/web-animations-api/AnimationEffect.ts +++ b/src/mocks/web-animations-api/AnimationEffect.ts @@ -1,3 +1,5 @@ +import { cssNumberishToNumber } from './cssNumberishHelpers'; + class MockedAnimationEffect implements AnimationEffect { #timing: EffectTiming = { delay: 0, @@ -22,7 +24,8 @@ class MockedAnimationEffect implements AnimationEffect { return 0; } - return this.#timing.duration ?? 0; + const durationNum = cssNumberishToNumber(this.#timing.duration ?? null); + return durationNum ?? 0; } getTiming() { diff --git a/src/mocks/web-animations-api/KeyframeEffect.ts b/src/mocks/web-animations-api/KeyframeEffect.ts index 387d01c..75660b4 100644 --- a/src/mocks/web-animations-api/KeyframeEffect.ts +++ b/src/mocks/web-animations-api/KeyframeEffect.ts @@ -1,4 +1,5 @@ import { mockAnimationEffect, MockedAnimationEffect } from './AnimationEffect'; +import { cssNumberishToNumber } from './cssNumberishHelpers'; /** Given the structure of PropertyIndexedKeyframes as such: @@ -87,7 +88,41 @@ class MockedKeyframeEffect // not actually implemented, just to make ts happy this.iterationComposite = iterationComposite || 'replace'; this.pseudoElement = pseudoElement || null; - this.updateTiming(timing); + + // Only update timing if options were provided + if (Object.keys(timing).length > 0) { + // Convert CSSNumberish values to numbers for updateTiming + const convertedTiming: OptionalEffectTiming = {}; + + if (timing.delay !== undefined) { + convertedTiming.delay = cssNumberishToNumber(timing.delay) ?? timing.delay; + } + if (timing.duration !== undefined) { + convertedTiming.duration = typeof timing.duration === 'string' ? timing.duration : cssNumberishToNumber(timing.duration) ?? 0; + } + if (timing.endDelay !== undefined) { + convertedTiming.endDelay = cssNumberishToNumber(timing.endDelay) ?? timing.endDelay; + } + if (timing.iterationStart !== undefined) { + convertedTiming.iterationStart = cssNumberishToNumber(timing.iterationStart) ?? timing.iterationStart; + } + if (timing.iterations !== undefined) { + convertedTiming.iterations = cssNumberishToNumber(timing.iterations) ?? timing.iterations; + } + if (timing.direction !== undefined) { + convertedTiming.direction = timing.direction; + } + if (timing.easing !== undefined) { + convertedTiming.easing = timing.easing; + } + if (timing.fill !== undefined) { + convertedTiming.fill = timing.fill; + } + if (timing.playbackRate !== undefined) { + convertedTiming.playbackRate = timing.playbackRate; + } + this.updateTiming(convertedTiming); + } } #validateKeyframes(keyframes: Keyframe[]) { diff --git a/src/mocks/web-animations-api/__tests__/DocumentTimeline.test.ts b/src/mocks/web-animations-api/__tests__/DocumentTimeline.test.ts index 790c294..8828a3d 100644 --- a/src/mocks/web-animations-api/__tests__/DocumentTimeline.test.ts +++ b/src/mocks/web-animations-api/__tests__/DocumentTimeline.test.ts @@ -1,4 +1,5 @@ import { mockDocumentTimeline } from '../DocumentTimeline'; +import { cssNumberishToNumber } from '../cssNumberishHelpers'; mockDocumentTimeline(); runner.useFakeTimers(); @@ -18,8 +19,11 @@ describe('DocumentTimeline', () => { it('should set default origin time to 0', () => { const timeline = new DocumentTimeline(); - expect((timeline.currentTime ?? 0) / 10).toBeCloseTo( - (document.timeline.currentTime ?? 0) / 10, + const timelineCurrentTime = cssNumberishToNumber(timeline.currentTime) ?? 0; + const documentCurrentTime = cssNumberishToNumber(document.timeline.currentTime) ?? 0; + + expect(timelineCurrentTime / 10).toBeCloseTo( + documentCurrentTime / 10, 1 ); }); @@ -27,8 +31,11 @@ describe('DocumentTimeline', () => { it('should set origin time to the given value', () => { const timeline = new DocumentTimeline({ originTime: 100 }); - expect(timeline.currentTime ?? 0).toBeCloseTo( - (document.timeline.currentTime ?? 0) - 100, + const timelineCurrentTime = cssNumberishToNumber(timeline.currentTime) ?? 0; + const documentCurrentTime = cssNumberishToNumber(document.timeline.currentTime) ?? 0; + + expect(timelineCurrentTime).toBeCloseTo( + documentCurrentTime - 100, 0 ); }); diff --git a/src/mocks/web-animations-api/__tests__/cssNumberishHelpers.test.ts b/src/mocks/web-animations-api/__tests__/cssNumberishHelpers.test.ts new file mode 100644 index 0000000..bc29a1a --- /dev/null +++ b/src/mocks/web-animations-api/__tests__/cssNumberishHelpers.test.ts @@ -0,0 +1,61 @@ +import { cssNumberishToNumber } from '../cssNumberishHelpers'; + +// Mock CSSUnitValue for testing since it's not in the default JSDOM env +class MockCSSUnitValue { + value: number; + unit: string; + constructor(value: number, unit: string) { + this.value = value; + this.unit = unit; + } +} + +// @ts-ignore +global.CSSUnitValue = MockCSSUnitValue; + +describe('cssNumberishHelpers', () => { + describe('cssNumberishToNumber', () => { + it('should return null if the input is null', () => { + expect(cssNumberishToNumber(null)).toBeNull(); + }); + + it('should return the same number if the input is a number', () => { + expect(cssNumberishToNumber(100)).toBe(100); + }); + + it('should convert seconds to milliseconds', () => { + const twoSeconds = new CSSUnitValue(2, 's'); + expect(cssNumberishToNumber(twoSeconds)).toBe(2000); + }); + + it('should return the same value for milliseconds', () => { + const twoHundredMs = new CSSUnitValue(200, 'ms'); + expect(cssNumberishToNumber(twoHundredMs)).toBe(200); + }); + + it('should return null for non-time units like px', () => { + const oneHundredPx = new CSSUnitValue(100, 'px'); + expect(cssNumberishToNumber(oneHundredPx)).toBeNull(); + }); + + it('should return null and warn for unsupported CSSMathValue', () => { + // Mock CSSMathValue + class MockCSSMathValue {} + // @ts-ignore + global.CSSMathValue = MockCSSMathValue; + + const consoleWarnSpy = runner.spyOn(console, 'warn').mockImplementation(() => { + // This is a mock implementation to prevent console output during the test. + }); + + const mathValue = new MockCSSMathValue(); + // @ts-ignore + expect(cssNumberishToNumber(mathValue)).toBeNull(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'jsdom-testing-mocks: Complex CSSNumericValue (like calc()) are not supported. Returning null.' + ); + + consoleWarnSpy.mockRestore(); + }); + }); +}); \ No newline at end of file diff --git a/src/mocks/web-animations-api/cssNumberishHelpers.ts b/src/mocks/web-animations-api/cssNumberishHelpers.ts new file mode 100644 index 0000000..4cd5026 --- /dev/null +++ b/src/mocks/web-animations-api/cssNumberishHelpers.ts @@ -0,0 +1,68 @@ +/** + * Helper functions for converting between CSSNumberish and number types + * + * CSSNumberish can be either a number or CSSNumericValue, but most of our + * internal logic works with numbers. These helpers provide safe conversion + * at the boundaries between public APIs and internal implementation. + */ + +/** + * Converts CSSNumberish to number, handling null and CSSUnitValue values. + * It's aware of time units and converts them to milliseconds, as the + * Web Animations API typically works with milliseconds. For unsupported types + * like CSSMathValue (e.g., calc()), it will return null and log a warning. + * + * @param value - The CSSNumberish value to convert + * @returns The number value in milliseconds, or null if the input is null or cannot be converted + */ +export function cssNumberishToNumber(value: CSSNumberish | null): number | null { + if (value === null) return null; + if (typeof value === 'number') return value; + + // Handle CSSUnitValue (e.g., CSS.s(2), CSS.ms(500)) + // We do a safe check in case the class doesn't exist in the JSDOM environment. + if (typeof CSSUnitValue !== 'undefined' && value instanceof CSSUnitValue) { + if (value.unit === 's') { + return value.value * 1000; // Convert seconds to milliseconds + } + if (value.unit === 'ms' || value.unit === 'number') { + return value.value; + } + // For other units like 'px', '%', etc., we can't convert to a timeline value. + console.warn(`jsdom-testing-mocks: Unsupported CSS unit '${value.unit}' in cssNumberishToNumber. Returning null.`); + return null; + } + + // Handle complex CSSNumericValue types that are not supported by this mock + if ( + (typeof CSSMathValue !== 'undefined' && value instanceof CSSMathValue) + ) { + console.warn( + `jsdom-testing-mocks: Complex CSSNumericValue (like calc()) are not supported. Returning null.` + ); + return null; + } + + // Fallback for any other unknown case. This is unlikely to be hit. + return Number(value); +} + +/** + * Converts number to CSSNumberish + * @param value - The number value to convert + * @returns The CSSNumberish value, or null if the input is null + */ +export function numberToCSSNumberish(value: number | null): CSSNumberish | null { + return value; +} + +/** + * Converts CSSNumberish to number with a default value + * @param value - The CSSNumberish value to convert + * @param defaultValue - The default value to return if conversion fails + * @returns The number value, or the default value if conversion fails + */ +export function cssNumberishToNumberWithDefault(value: CSSNumberish | null, defaultValue: number): number { + const converted = cssNumberishToNumber(value); + return converted ?? defaultValue; +} \ No newline at end of file diff --git a/vitest-setup.ts b/vitest-setup.ts index af51d0a..3aa5c9b 100644 --- a/vitest-setup.ts +++ b/vitest-setup.ts @@ -28,10 +28,15 @@ function fn() { return vi.fn(); } +function spyOn(...args: Parameters) { + return vi.spyOn(...args); +} + globalThis.runner = { name: 'vi', useFakeTimers, useRealTimers, advanceTimersByTime, fn, + spyOn, };