diff --git a/jest-setup.ts b/jest-setup.ts index 0f5a1ff..353490e 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -1,3 +1,33 @@ +/** + * Jest-specific setup for unified testing framework abstraction. + * + * This file provides Jest implementations of the unified testing utilities + * available on the global `runner` object. It creates a consistent interface + * that allows the library's tests to work identically across Jest, Vitest, + * and SWC test environments. + * + * The key pattern here is the SmartSpy system, which wraps framework-specific + * spy objects (like Jest's SpyInstance) to provide a unified API. + */ + +/** + * Unified spy interface that works consistently across Jest and Vitest. + * + * This interface defines the common spy methods that our library needs, + * providing a consistent API regardless of the underlying testing framework. + * The implementation only includes the methods we actually use in our tests. + */ +interface SmartSpy { + /** Mock the implementation of the spied method */ + mockImplementation: (fn: () => void) => SmartSpy; + /** Assert that the spy was called with specific arguments */ + toHaveBeenCalledWith: (...args: unknown[]) => void; + /** Restore the original method implementation */ + mockRestore: () => void; + /** Allow access to any other spy properties/methods from the underlying framework */ + [key: string]: unknown; +} + function useFakeTimers() { jest.useFakeTimers(); } @@ -14,32 +44,101 @@ function fn() { return jest.fn(); } -// Smart proxy that only implements what we need -function createSmartSpy(realSpy: unknown) { +/** + * Creates a unified spy interface that works consistently across Jest and Vitest. + * + * This function wraps a Jest SpyInstance in a Proxy to provide a consistent API + * that matches the SmartSpy interface expected by the testing framework abstraction. + * + * The proxy intercepts calls to specific methods (mockImplementation, toHaveBeenCalledWith, + * mockRestore) and ensures they work the same way regardless of whether the underlying + * spy is from Jest or Vitest. For all other properties/methods, it passes through + * to the original spy. + * + * This is part of the broader pattern in this library where we create unified interfaces + * across different testing frameworks (Jest, Vitest, SWC) so that the library's mocks + * work consistently in any environment. + * + * @param realSpy - The underlying Jest SpyInstance to wrap + * @returns A proxy that implements the SmartSpy interface + */ +function createSmartSpy(realSpy: jest.SpyInstance): SmartSpy { return new Proxy(realSpy as object, { get(target, prop) { - // Only implement the methods we actually use - if (prop === 'mockImplementation') { - return (fn: () => void) => { - (target as { mockImplementation: (fn: () => void) => unknown }).mockImplementation(fn); - return createSmartSpy(target); - }; - } - if (prop === 'toHaveBeenCalledWith') { - return (target as { toHaveBeenCalledWith: (...args: unknown[]) => unknown }).toHaveBeenCalledWith.bind(target); - } - if (prop === 'mockRestore') { - return (target as { mockRestore: () => void }).mockRestore.bind(target); + // Switch on the specific methods we need to implement + switch (prop) { + case 'mockImplementation': + return (fn: () => void) => { + (target as jest.SpyInstance).mockImplementation(fn); + return createSmartSpy(target as jest.SpyInstance); + }; + + case 'toHaveBeenCalledWith': + return (...args: unknown[]) => { + // Jest SpyInstance doesn't have toHaveBeenCalledWith as a method, + // but Vitest spies do. For compatibility, we implement it by checking + // the spy's call history manually to match Vitest's behavior. + const jestSpy = target as jest.SpyInstance; + + // Check if any call matches the expected arguments + const calls = jestSpy.mock.calls; + const hasMatchingCall = calls.some((call: unknown[]) => { + if (call.length !== args.length) return false; + return call.every((arg: unknown, index: number) => { + // Use Jest's deep equality matching + try { + expect(arg).toEqual(args[index]); + return true; + } catch { + return false; + } + }); + }); + + if (!hasMatchingCall) { + throw new Error( + `Expected spy to have been called with [${args.map((arg: unknown) => JSON.stringify(arg)).join(', ')}], ` + + `but it was called with: ${calls + .map( + (call: unknown[]) => + `[${call.map((arg: unknown) => JSON.stringify(arg)).join(', ')}]` + ) + .join(', ')}` + ); + } + }; + + case 'mockRestore': + return () => { + (target as jest.SpyInstance).mockRestore(); + }; + + default: + // For everything else, just pass through to the real spy + return (target as Record)[prop]; } - - // For everything else, just pass through to the real spy - return (target as Record)[prop]; - } - }); + }, + }) as SmartSpy; } -function spyOn(object: T, method: K) { - const realSpy = (jest.spyOn as (obj: T, method: K) => unknown)(object, method); +/** + * Creates a spy on an object method using Jest's spyOn, wrapped with SmartSpy interface. + * + * This is the Jest-specific implementation of the unified spyOn function that's available + * on the global `runner` object. It creates a Jest spy and wraps it with createSmartSpy + * to provide a consistent interface across testing frameworks. + * + * @param object - The object to spy on + * @param method - The method name to spy on + * @returns A SmartSpy that provides unified spy functionality + */ +function spyOn( + object: T, + method: K +): SmartSpy { + // Use a more specific type assertion to avoid 'any' + const spyFunction = jest.spyOn as (object: T, method: K) => jest.SpyInstance; + const realSpy = spyFunction(object, method); return createSmartSpy(realSpy); } diff --git a/src/mocks/web-animations-api/__tests__/cssNumberishHelpers.test.ts b/src/mocks/web-animations-api/__tests__/cssNumberishHelpers.test.ts index 5d881a7..e062c60 100644 --- a/src/mocks/web-animations-api/__tests__/cssNumberishHelpers.test.ts +++ b/src/mocks/web-animations-api/__tests__/cssNumberishHelpers.test.ts @@ -1,4 +1,8 @@ -import { cssNumberishToNumber, cssNumberishToNumberWithDefault, initCSSTypedOM } from '../cssNumberishHelpers'; +import { + cssNumberishToNumber, + cssNumberishToNumberWithDefault, + initCSSTypedOM, +} from '../cssNumberishHelpers'; describe('cssNumberishHelpers', () => { beforeAll(() => { @@ -37,16 +41,25 @@ describe('cssNumberishHelpers', () => { }); it('should return null for non-time units like px', () => { + const consoleWarnSpy = runner + .spyOn(console, 'warn') + .mockImplementation(() => { + /* do nothing */ + }); const oneHundredPx = new CSSUnitValue(100, 'px'); expect(cssNumberishToNumber(oneHundredPx)).toBeNull(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + "jsdom-testing-mocks: Unsupported CSS unit 'px' in cssNumberishToNumber. Returning null." + ); + consoleWarnSpy.mockRestore(); }); it('should handle CSSMathValue for time calculations', () => { // Test time-dimensioned math values const timeSum = CSS.s(1).add(CSS.ms(500)); // 1.5 seconds = 1500ms expect(cssNumberishToNumber(timeSum)).toBe(1500); - - const timeProduct = CSS.s(2).mul(CSS.number(1.5)); // 3 seconds = 3000ms + + const timeProduct = CSS.s(2).mul(CSS.number(1.5)); // 3 seconds = 3000ms expect(cssNumberishToNumber(timeProduct)).toBe(3000); }); @@ -54,7 +67,7 @@ describe('cssNumberishHelpers', () => { // Test dimensionless math values const numberSum = CSS.number(2).add(CSS.number(3)); // = 5 expect(cssNumberishToNumber(numberSum)).toBe(5); - + const iterations = CSS.number(2.5).mul(CSS.number(2)); // = 5 expect(cssNumberishToNumber(iterations)).toBe(5); }); @@ -62,7 +75,9 @@ describe('cssNumberishHelpers', () => { it('should return null and warn for incompatible CSSMathValue dimensions', () => { const consoleWarnSpy = runner .spyOn(console, 'warn') - .mockImplementation(() => { /* do nothing */ }); + .mockImplementation(() => { + /* do nothing */ + }); const lengthValue = CSS.px(100).add(CSS.em(1)); // Different length units create CSSMathSum expect(cssNumberishToNumber(lengthValue)).toBeNull(); @@ -73,11 +88,12 @@ describe('cssNumberishHelpers', () => { consoleWarnSpy.mockRestore(); }); - it('should return null and warn for unsupported values', () => { const consoleWarnSpy = runner .spyOn(console, 'warn') - .mockImplementation(() => { /* do nothing */ }); + .mockImplementation(() => { + /* do nothing */ + }); expect(cssNumberishToNumber({} as unknown as CSSNumberish)).toBeNull(); expect(consoleWarnSpy).toHaveBeenCalledWith( @@ -91,12 +107,26 @@ describe('cssNumberishHelpers', () => { describe('cssNumberishToNumberWithDefault', () => { it('should return the converted value if conversion succeeds', () => { expect(cssNumberishToNumberWithDefault(100, 50)).toBe(100); - expect(cssNumberishToNumberWithDefault(new CSSUnitValue(2, 's'), 500)).toBe(2000); + expect( + cssNumberishToNumberWithDefault(new CSSUnitValue(2, 's'), 500) + ).toBe(2000); }); it('should return the default value if conversion fails', () => { expect(cssNumberishToNumberWithDefault(null, 50)).toBe(50); - expect(cssNumberishToNumberWithDefault(new CSSUnitValue(100, 'px'), 42)).toBe(42); + + const consoleWarnSpy = runner + .spyOn(console, 'warn') + .mockImplementation(() => { + /* do nothing */ + }); + expect( + cssNumberishToNumberWithDefault(new CSSUnitValue(100, 'px'), 42) + ).toBe(42); + expect(consoleWarnSpy).toHaveBeenCalledWith( + "jsdom-testing-mocks: Unsupported CSS unit 'px' in cssNumberishToNumber. Returning null." + ); + consoleWarnSpy.mockRestore(); }); }); -}); \ No newline at end of file +}); diff --git a/src/mocks/web-animations-api/elementAnimations.ts b/src/mocks/web-animations-api/elementAnimations.ts index d7d896e..56c3091 100644 --- a/src/mocks/web-animations-api/elementAnimations.ts +++ b/src/mocks/web-animations-api/elementAnimations.ts @@ -28,5 +28,12 @@ export function getAllAnimations() { } export function clearAnimations() { + // Cancel all running animations to prevent requestAnimationFrame errors during teardown + const allAnimations = getAllAnimations(); + allAnimations.forEach((animation) => { + // Cancel the animation properly - this stops the requestAnimationFrame loop + animation.cancel(); + }); + elementAnimations.clear(); }