Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 120 additions & 21 deletions jest-setup.ts
Original file line number Diff line number Diff line change
@@ -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();
}
Expand All @@ -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<string | symbol, unknown>)[prop];
}

// For everything else, just pass through to the real spy
return (target as Record<string | symbol, unknown>)[prop];
}
});
},
}) as SmartSpy;
}

function spyOn<T extends object, K extends keyof T>(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<T extends object, K extends keyof T>(
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);
}

Expand Down
50 changes: 40 additions & 10 deletions src/mocks/web-animations-api/__tests__/cssNumberishHelpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { cssNumberishToNumber, cssNumberishToNumberWithDefault, initCSSTypedOM } from '../cssNumberishHelpers';
import {
cssNumberishToNumber,
cssNumberishToNumberWithDefault,
initCSSTypedOM,
} from '../cssNumberishHelpers';

describe('cssNumberishHelpers', () => {
beforeAll(() => {
Expand Down Expand Up @@ -37,32 +41,43 @@ 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);
});

it('should handle CSSMathValue for dimensionless calculations', () => {
// 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);
});

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();
Expand All @@ -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(
Expand All @@ -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();
});
});
});
});
7 changes: 7 additions & 0 deletions src/mocks/web-animations-api/elementAnimations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}