Skip to content

Commit a669335

Browse files
authored
Merge pull request #76 from trurl-master/claude/issue-74-20250818-1038
fix: improve SmartSpy type safety and cross-framework test compatibility
2 parents 59d22a3 + c15fec6 commit a669335

File tree

3 files changed

+167
-31
lines changed

3 files changed

+167
-31
lines changed

jest-setup.ts

Lines changed: 120 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,33 @@
1+
/**
2+
* Jest-specific setup for unified testing framework abstraction.
3+
*
4+
* This file provides Jest implementations of the unified testing utilities
5+
* available on the global `runner` object. It creates a consistent interface
6+
* that allows the library's tests to work identically across Jest, Vitest,
7+
* and SWC test environments.
8+
*
9+
* The key pattern here is the SmartSpy system, which wraps framework-specific
10+
* spy objects (like Jest's SpyInstance) to provide a unified API.
11+
*/
12+
13+
/**
14+
* Unified spy interface that works consistently across Jest and Vitest.
15+
*
16+
* This interface defines the common spy methods that our library needs,
17+
* providing a consistent API regardless of the underlying testing framework.
18+
* The implementation only includes the methods we actually use in our tests.
19+
*/
20+
interface SmartSpy {
21+
/** Mock the implementation of the spied method */
22+
mockImplementation: (fn: () => void) => SmartSpy;
23+
/** Assert that the spy was called with specific arguments */
24+
toHaveBeenCalledWith: (...args: unknown[]) => void;
25+
/** Restore the original method implementation */
26+
mockRestore: () => void;
27+
/** Allow access to any other spy properties/methods from the underlying framework */
28+
[key: string]: unknown;
29+
}
30+
131
function useFakeTimers() {
232
jest.useFakeTimers();
333
}
@@ -14,32 +44,101 @@ function fn() {
1444
return jest.fn();
1545
}
1646

17-
// Smart proxy that only implements what we need
18-
function createSmartSpy(realSpy: unknown) {
47+
/**
48+
* Creates a unified spy interface that works consistently across Jest and Vitest.
49+
*
50+
* This function wraps a Jest SpyInstance in a Proxy to provide a consistent API
51+
* that matches the SmartSpy interface expected by the testing framework abstraction.
52+
*
53+
* The proxy intercepts calls to specific methods (mockImplementation, toHaveBeenCalledWith,
54+
* mockRestore) and ensures they work the same way regardless of whether the underlying
55+
* spy is from Jest or Vitest. For all other properties/methods, it passes through
56+
* to the original spy.
57+
*
58+
* This is part of the broader pattern in this library where we create unified interfaces
59+
* across different testing frameworks (Jest, Vitest, SWC) so that the library's mocks
60+
* work consistently in any environment.
61+
*
62+
* @param realSpy - The underlying Jest SpyInstance to wrap
63+
* @returns A proxy that implements the SmartSpy interface
64+
*/
65+
function createSmartSpy(realSpy: jest.SpyInstance): SmartSpy {
1966
return new Proxy(realSpy as object, {
2067
get(target, prop) {
21-
// Only implement the methods we actually use
22-
if (prop === 'mockImplementation') {
23-
return (fn: () => void) => {
24-
(target as { mockImplementation: (fn: () => void) => unknown }).mockImplementation(fn);
25-
return createSmartSpy(target);
26-
};
27-
}
28-
if (prop === 'toHaveBeenCalledWith') {
29-
return (target as { toHaveBeenCalledWith: (...args: unknown[]) => unknown }).toHaveBeenCalledWith.bind(target);
30-
}
31-
if (prop === 'mockRestore') {
32-
return (target as { mockRestore: () => void }).mockRestore.bind(target);
68+
// Switch on the specific methods we need to implement
69+
switch (prop) {
70+
case 'mockImplementation':
71+
return (fn: () => void) => {
72+
(target as jest.SpyInstance).mockImplementation(fn);
73+
return createSmartSpy(target as jest.SpyInstance);
74+
};
75+
76+
case 'toHaveBeenCalledWith':
77+
return (...args: unknown[]) => {
78+
// Jest SpyInstance doesn't have toHaveBeenCalledWith as a method,
79+
// but Vitest spies do. For compatibility, we implement it by checking
80+
// the spy's call history manually to match Vitest's behavior.
81+
const jestSpy = target as jest.SpyInstance;
82+
83+
// Check if any call matches the expected arguments
84+
const calls = jestSpy.mock.calls;
85+
const hasMatchingCall = calls.some((call: unknown[]) => {
86+
if (call.length !== args.length) return false;
87+
return call.every((arg: unknown, index: number) => {
88+
// Use Jest's deep equality matching
89+
try {
90+
expect(arg).toEqual(args[index]);
91+
return true;
92+
} catch {
93+
return false;
94+
}
95+
});
96+
});
97+
98+
if (!hasMatchingCall) {
99+
throw new Error(
100+
`Expected spy to have been called with [${args.map((arg: unknown) => JSON.stringify(arg)).join(', ')}], ` +
101+
`but it was called with: ${calls
102+
.map(
103+
(call: unknown[]) =>
104+
`[${call.map((arg: unknown) => JSON.stringify(arg)).join(', ')}]`
105+
)
106+
.join(', ')}`
107+
);
108+
}
109+
};
110+
111+
case 'mockRestore':
112+
return () => {
113+
(target as jest.SpyInstance).mockRestore();
114+
};
115+
116+
default:
117+
// For everything else, just pass through to the real spy
118+
return (target as Record<string | symbol, unknown>)[prop];
33119
}
34-
35-
// For everything else, just pass through to the real spy
36-
return (target as Record<string | symbol, unknown>)[prop];
37-
}
38-
});
120+
},
121+
}) as SmartSpy;
39122
}
40123

41-
function spyOn<T extends object, K extends keyof T>(object: T, method: K) {
42-
const realSpy = (jest.spyOn as (obj: T, method: K) => unknown)(object, method);
124+
/**
125+
* Creates a spy on an object method using Jest's spyOn, wrapped with SmartSpy interface.
126+
*
127+
* This is the Jest-specific implementation of the unified spyOn function that's available
128+
* on the global `runner` object. It creates a Jest spy and wraps it with createSmartSpy
129+
* to provide a consistent interface across testing frameworks.
130+
*
131+
* @param object - The object to spy on
132+
* @param method - The method name to spy on
133+
* @returns A SmartSpy that provides unified spy functionality
134+
*/
135+
function spyOn<T extends object, K extends keyof T>(
136+
object: T,
137+
method: K
138+
): SmartSpy {
139+
// Use a more specific type assertion to avoid 'any'
140+
const spyFunction = jest.spyOn as (object: T, method: K) => jest.SpyInstance;
141+
const realSpy = spyFunction(object, method);
43142
return createSmartSpy(realSpy);
44143
}
45144

src/mocks/web-animations-api/__tests__/cssNumberishHelpers.test.ts

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { cssNumberishToNumber, cssNumberishToNumberWithDefault, initCSSTypedOM } from '../cssNumberishHelpers';
1+
import {
2+
cssNumberishToNumber,
3+
cssNumberishToNumberWithDefault,
4+
initCSSTypedOM,
5+
} from '../cssNumberishHelpers';
26

37
describe('cssNumberishHelpers', () => {
48
beforeAll(() => {
@@ -37,32 +41,43 @@ describe('cssNumberishHelpers', () => {
3741
});
3842

3943
it('should return null for non-time units like px', () => {
44+
const consoleWarnSpy = runner
45+
.spyOn(console, 'warn')
46+
.mockImplementation(() => {
47+
/* do nothing */
48+
});
4049
const oneHundredPx = new CSSUnitValue(100, 'px');
4150
expect(cssNumberishToNumber(oneHundredPx)).toBeNull();
51+
expect(consoleWarnSpy).toHaveBeenCalledWith(
52+
"jsdom-testing-mocks: Unsupported CSS unit 'px' in cssNumberishToNumber. Returning null."
53+
);
54+
consoleWarnSpy.mockRestore();
4255
});
4356

4457
it('should handle CSSMathValue for time calculations', () => {
4558
// Test time-dimensioned math values
4659
const timeSum = CSS.s(1).add(CSS.ms(500)); // 1.5 seconds = 1500ms
4760
expect(cssNumberishToNumber(timeSum)).toBe(1500);
48-
49-
const timeProduct = CSS.s(2).mul(CSS.number(1.5)); // 3 seconds = 3000ms
61+
62+
const timeProduct = CSS.s(2).mul(CSS.number(1.5)); // 3 seconds = 3000ms
5063
expect(cssNumberishToNumber(timeProduct)).toBe(3000);
5164
});
5265

5366
it('should handle CSSMathValue for dimensionless calculations', () => {
5467
// Test dimensionless math values
5568
const numberSum = CSS.number(2).add(CSS.number(3)); // = 5
5669
expect(cssNumberishToNumber(numberSum)).toBe(5);
57-
70+
5871
const iterations = CSS.number(2.5).mul(CSS.number(2)); // = 5
5972
expect(cssNumberishToNumber(iterations)).toBe(5);
6073
});
6174

6275
it('should return null and warn for incompatible CSSMathValue dimensions', () => {
6376
const consoleWarnSpy = runner
6477
.spyOn(console, 'warn')
65-
.mockImplementation(() => { /* do nothing */ });
78+
.mockImplementation(() => {
79+
/* do nothing */
80+
});
6681

6782
const lengthValue = CSS.px(100).add(CSS.em(1)); // Different length units create CSSMathSum
6883
expect(cssNumberishToNumber(lengthValue)).toBeNull();
@@ -73,11 +88,12 @@ describe('cssNumberishHelpers', () => {
7388
consoleWarnSpy.mockRestore();
7489
});
7590

76-
7791
it('should return null and warn for unsupported values', () => {
7892
const consoleWarnSpy = runner
7993
.spyOn(console, 'warn')
80-
.mockImplementation(() => { /* do nothing */ });
94+
.mockImplementation(() => {
95+
/* do nothing */
96+
});
8197

8298
expect(cssNumberishToNumber({} as unknown as CSSNumberish)).toBeNull();
8399
expect(consoleWarnSpy).toHaveBeenCalledWith(
@@ -91,12 +107,26 @@ describe('cssNumberishHelpers', () => {
91107
describe('cssNumberishToNumberWithDefault', () => {
92108
it('should return the converted value if conversion succeeds', () => {
93109
expect(cssNumberishToNumberWithDefault(100, 50)).toBe(100);
94-
expect(cssNumberishToNumberWithDefault(new CSSUnitValue(2, 's'), 500)).toBe(2000);
110+
expect(
111+
cssNumberishToNumberWithDefault(new CSSUnitValue(2, 's'), 500)
112+
).toBe(2000);
95113
});
96114

97115
it('should return the default value if conversion fails', () => {
98116
expect(cssNumberishToNumberWithDefault(null, 50)).toBe(50);
99-
expect(cssNumberishToNumberWithDefault(new CSSUnitValue(100, 'px'), 42)).toBe(42);
117+
118+
const consoleWarnSpy = runner
119+
.spyOn(console, 'warn')
120+
.mockImplementation(() => {
121+
/* do nothing */
122+
});
123+
expect(
124+
cssNumberishToNumberWithDefault(new CSSUnitValue(100, 'px'), 42)
125+
).toBe(42);
126+
expect(consoleWarnSpy).toHaveBeenCalledWith(
127+
"jsdom-testing-mocks: Unsupported CSS unit 'px' in cssNumberishToNumber. Returning null."
128+
);
129+
consoleWarnSpy.mockRestore();
100130
});
101131
});
102-
});
132+
});

src/mocks/web-animations-api/elementAnimations.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,12 @@ export function getAllAnimations() {
2828
}
2929

3030
export function clearAnimations() {
31+
// Cancel all running animations to prevent requestAnimationFrame errors during teardown
32+
const allAnimations = getAllAnimations();
33+
allAnimations.forEach((animation) => {
34+
// Cancel the animation properly - this stops the requestAnimationFrame loop
35+
animation.cancel();
36+
});
37+
3138
elementAnimations.clear();
3239
}

0 commit comments

Comments
 (0)