diff --git a/package.json b/package.json index bf500b3..969f6eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jsdom-testing-mocks", - "version": "1.5.0-beta.5", + "version": "1.5.0-beta.6", "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 1fc4b41..3d7f367 100644 --- a/src/mocks/web-animations-api/Animation.ts +++ b/src/mocks/web-animations-api/Animation.ts @@ -1,7 +1,6 @@ -import './AnimationEffect'; -import './KeyframeEffect'; -import './AnimationPlaybackEvent'; -import './DocumentTimeline'; +import { mockKeyframeEffect } from './KeyframeEffect'; +import { mockAnimationPlaybackEvent } from './AnimationPlaybackEvent'; +import { mockDocumentTimeline } from './DocumentTimeline'; import { getEasingFunctionFromString } from './easingFunctions'; type ActiveAnimationTimeline = AnimationTimeline & { @@ -167,6 +166,11 @@ class MockedAnimation extends EventTarget implements Animation { }); } + #silentlyRejectFinishedPromise(error: Error) { + this.#finishedPromise.catch(noop); + this.#resolvers.finished.reject(error); + } + #hasPendingTask() { return this.#pendingPauseTask || this.#pendingPlayTask; } @@ -401,7 +405,9 @@ class MockedAnimation extends EventTarget implements Animation { this.#applyPendingPlaybackRate(); // 5. Reject animation’s current ready promise with a DOMException named "AbortError". - this.#resolvers.ready.reject(new DOMException('AbortError')); + this.#silentlyRejectFinishedPromise( + new DOMException(undefined, 'AbortError') + ); // 6. Set the [[PromiseIsHandled]] internal slot of animation’s current ready promise to true. @@ -1145,8 +1151,7 @@ class MockedAnimation extends EventTarget implements Animation { this.#resetPendingTasks(); // Reject the current finished promise with a DOMException named "AbortError". - // this.#resolvers.finished.reject(new DOMException('AbortError')); - this.#resolvers.finished.reject( + this.#silentlyRejectFinishedPromise( new DOMException('The user aborted a request.', 'AbortError') ); @@ -1688,12 +1693,18 @@ class MockedAnimation extends EventTarget implements Animation { } } -if (typeof Animation === 'undefined') { - Object.defineProperty(window, 'Animation', { - writable: true, - configurable: true, - value: MockedAnimation, - }); +function mockAnimation() { + mockKeyframeEffect(); + mockAnimationPlaybackEvent(); + mockDocumentTimeline(); + + if (typeof Animation === 'undefined') { + Object.defineProperty(window, 'Animation', { + writable: true, + configurable: true, + value: MockedAnimation, + }); + } } -export { MockedAnimation }; +export { MockedAnimation, mockAnimation }; diff --git a/src/mocks/web-animations-api/AnimationEffect.test.ts b/src/mocks/web-animations-api/AnimationEffect.test.ts index 1c2e4bb..99d08fd 100644 --- a/src/mocks/web-animations-api/AnimationEffect.test.ts +++ b/src/mocks/web-animations-api/AnimationEffect.test.ts @@ -1,4 +1,6 @@ -import './AnimationEffect'; +import { mockAnimationEffect } from './AnimationEffect'; + +mockAnimationEffect(); describe('AnimationEffect', () => { it('should be defined', () => { diff --git a/src/mocks/web-animations-api/AnimationEffect.ts b/src/mocks/web-animations-api/AnimationEffect.ts index 4c3a314..65ef2d5 100644 --- a/src/mocks/web-animations-api/AnimationEffect.ts +++ b/src/mocks/web-animations-api/AnimationEffect.ts @@ -71,12 +71,14 @@ class MockedAnimationEffect implements AnimationEffect { } } -if (typeof AnimationEffect === 'undefined') { - Object.defineProperty(window, 'AnimationEffect', { - writable: true, - configurable: true, - value: MockedAnimationEffect, - }); +function mockAnimationEffect() { + if (typeof AnimationEffect === 'undefined') { + Object.defineProperty(window, 'AnimationEffect', { + writable: true, + configurable: true, + value: MockedAnimationEffect, + }); + } } -export { MockedAnimationEffect }; +export { MockedAnimationEffect, mockAnimationEffect }; diff --git a/src/mocks/web-animations-api/AnimationPlaybackEvent.ts b/src/mocks/web-animations-api/AnimationPlaybackEvent.ts index 63a1d19..ff83950 100644 --- a/src/mocks/web-animations-api/AnimationPlaybackEvent.ts +++ b/src/mocks/web-animations-api/AnimationPlaybackEvent.ts @@ -1,13 +1,19 @@ -class MockedAnimationPlaybackEvent extends Event - implements AnimationPlaybackEvent { +class MockedAnimationPlaybackEvent + extends Event + implements AnimationPlaybackEvent +{ readonly currentTime = null; readonly timelineTime = null; } -if (typeof AnimationPlaybackEvent === 'undefined') { - Object.defineProperty(window, 'AnimationPlaybackEvent', { - writable: true, - configurable: true, - value: MockedAnimationPlaybackEvent, - }); +function mockAnimationPlaybackEvent() { + if (typeof AnimationPlaybackEvent === 'undefined') { + Object.defineProperty(window, 'AnimationPlaybackEvent', { + writable: true, + configurable: true, + value: MockedAnimationPlaybackEvent, + }); + } } + +export { mockAnimationPlaybackEvent }; diff --git a/src/mocks/web-animations-api/AnimationTimeline.test.ts b/src/mocks/web-animations-api/AnimationTimeline.test.ts index 26e301e..ef222bd 100644 --- a/src/mocks/web-animations-api/AnimationTimeline.test.ts +++ b/src/mocks/web-animations-api/AnimationTimeline.test.ts @@ -1,4 +1,6 @@ -import './AnimationTimeline'; +import { mockAnimationTimeline } from './AnimationTimeline'; + +mockAnimationTimeline(); describe('AnimationTimeline', () => { it('should be defined', () => { diff --git a/src/mocks/web-animations-api/AnimationTimeline.ts b/src/mocks/web-animations-api/AnimationTimeline.ts index 3386ed2..749ad75 100644 --- a/src/mocks/web-animations-api/AnimationTimeline.ts +++ b/src/mocks/web-animations-api/AnimationTimeline.ts @@ -10,10 +10,14 @@ class MockedAnimationTimeline implements AnimationTimeline { } } -if (typeof AnimationTimeline === 'undefined') { - Object.defineProperty(window, 'AnimationTimeline', { - writable: true, - configurable: true, - value: MockedAnimationTimeline, - }); +function mockAnimationTimeline() { + if (typeof AnimationTimeline === 'undefined') { + Object.defineProperty(window, 'AnimationTimeline', { + writable: true, + configurable: true, + value: MockedAnimationTimeline, + }); + } } + +export { MockedAnimationTimeline, mockAnimationTimeline }; diff --git a/src/mocks/web-animations-api/DocumentTimeline.test.ts b/src/mocks/web-animations-api/DocumentTimeline.test.ts index 03a275e..9af3aa6 100644 --- a/src/mocks/web-animations-api/DocumentTimeline.test.ts +++ b/src/mocks/web-animations-api/DocumentTimeline.test.ts @@ -1,5 +1,6 @@ -import './DocumentTimeline'; +import { mockDocumentTimeline } from './DocumentTimeline'; +mockDocumentTimeline(); jest.useFakeTimers(); describe('DocumentTimeline', () => { diff --git a/src/mocks/web-animations-api/DocumentTimeline.ts b/src/mocks/web-animations-api/DocumentTimeline.ts index c181439..2e1abd5 100644 --- a/src/mocks/web-animations-api/DocumentTimeline.ts +++ b/src/mocks/web-animations-api/DocumentTimeline.ts @@ -1,7 +1,10 @@ -import './AnimationTimeline'; +import { + mockAnimationTimeline, + MockedAnimationTimeline, +} from './AnimationTimeline'; class MockedDocumentTimeline - extends AnimationTimeline + extends MockedAnimationTimeline implements DocumentTimeline { #originTime = 0; @@ -17,16 +20,22 @@ class MockedDocumentTimeline } } -if (typeof DocumentTimeline === 'undefined') { - Object.defineProperty(window, 'DocumentTimeline', { - writable: true, - configurable: true, - value: MockedDocumentTimeline, - }); - - Object.defineProperty(Document.prototype, 'timeline', { - writable: true, - configurable: true, - value: new MockedDocumentTimeline(), - }); +function mockDocumentTimeline() { + mockAnimationTimeline(); + + if (typeof DocumentTimeline === 'undefined') { + Object.defineProperty(window, 'DocumentTimeline', { + writable: true, + configurable: true, + value: MockedDocumentTimeline, + }); + + Object.defineProperty(Document.prototype, 'timeline', { + writable: true, + configurable: true, + value: new MockedDocumentTimeline(), + }); + } } + +export { mockDocumentTimeline }; diff --git a/src/mocks/web-animations-api/KeyframeEffect.test.ts b/src/mocks/web-animations-api/KeyframeEffect.test.ts index 2dc986e..d9a9edc 100644 --- a/src/mocks/web-animations-api/KeyframeEffect.test.ts +++ b/src/mocks/web-animations-api/KeyframeEffect.test.ts @@ -1,8 +1,11 @@ import { MockedKeyframeEffect, convertPropertyIndexedKeyframes, + mockKeyframeEffect, } from './KeyframeEffect'; +mockKeyframeEffect(); + describe('KeyframeEffect', () => { it('should have correct properties by default', () => { const element = document.createElement('div'); diff --git a/src/mocks/web-animations-api/KeyframeEffect.ts b/src/mocks/web-animations-api/KeyframeEffect.ts index 57f1116..387d01c 100644 --- a/src/mocks/web-animations-api/KeyframeEffect.ts +++ b/src/mocks/web-animations-api/KeyframeEffect.ts @@ -1,4 +1,4 @@ -import { MockedAnimationEffect } from "./AnimationEffect"; +import { mockAnimationEffect, MockedAnimationEffect } from './AnimationEffect'; /** Given the structure of PropertyIndexedKeyframes as such: @@ -35,7 +35,7 @@ export function convertPropertyIndexedKeyframes( const piKeyframe = propertyArray[keyframeIndex]; - if (typeof piKeyframe === "undefined" || piKeyframe === null) { + if (typeof piKeyframe === 'undefined' || piKeyframe === null) { continue; } @@ -62,7 +62,7 @@ class MockedKeyframeEffect extends MockedAnimationEffect implements KeyframeEffect { - composite: CompositeOperation = "replace"; + composite: CompositeOperation = 'replace'; iterationComposite: IterationCompositeOperation; pseudoElement: string | null = null; target: Element | null; @@ -75,7 +75,7 @@ class MockedKeyframeEffect ) { super(); - if (typeof options === "number") { + if (typeof options === 'number') { options = { duration: options }; } @@ -83,9 +83,9 @@ class MockedKeyframeEffect this.setKeyframes(keyframes); this.target = target; - this.composite = composite || "replace"; + this.composite = composite || 'replace'; // not actually implemented, just to make ts happy - this.iterationComposite = iterationComposite || "replace"; + this.iterationComposite = iterationComposite || 'replace'; this.pseudoElement = pseudoElement || null; this.updateTiming(timing); } @@ -96,14 +96,14 @@ class MockedKeyframeEffect keyframes.forEach((keyframe) => { const offset = keyframe.offset; - if (typeof offset === "number") { + if (typeof offset === 'number') { if (offset < 0 || offset > 1) { throw new TypeError( "Failed to construct 'KeyframeEffect': Offsets must be null or in the range [0,1]." ); } - if (typeof lastExplicitOffset === "number") { + if (typeof lastExplicitOffset === 'number') { if (offset < lastExplicitOffset) { throw new TypeError( "Failed to construct 'KeyframeEffect': Offsets must be monotonically non-decreasing." @@ -131,7 +131,7 @@ class MockedKeyframeEffect const computedKeyframe = { offset: offset ?? null, composite: composite ?? this.composite, - easing: easing ?? "linear", + easing: easing ?? 'linear', computedOffset: currentOffset, ...keyframe, }; @@ -144,7 +144,7 @@ class MockedKeyframeEffect for (let i = index + 1; i < totalKeyframes; i++) { const offset = this.#keyframes[i].offset; - if (typeof offset === "number") { + if (typeof offset === 'number') { nextOffset = offset; keyframesUntilNextOffset = i - index; break; @@ -157,7 +157,7 @@ class MockedKeyframeEffect } const offsetDiff = - typeof keyframesUntilNextOffset === "number" && + typeof keyframesUntilNextOffset === 'number' && keyframesUntilNextOffset > 0 ? (nextOffset - currentOffset) / keyframesUntilNextOffset : 0; @@ -186,12 +186,16 @@ class MockedKeyframeEffect } } -if (typeof KeyframeEffect === "undefined") { - Object.defineProperty(window, "KeyframeEffect", { - writable: true, - configurable: true, - value: MockedKeyframeEffect, - }); +function mockKeyframeEffect() { + mockAnimationEffect(); + + if (typeof KeyframeEffect === 'undefined') { + Object.defineProperty(window, 'KeyframeEffect', { + writable: true, + configurable: true, + value: MockedKeyframeEffect, + }); + } } -export { MockedKeyframeEffect }; +export { MockedKeyframeEffect, mockKeyframeEffect }; diff --git a/src/mocks/web-animations-api/index.ts b/src/mocks/web-animations-api/index.ts index d25b872..10f4831 100644 --- a/src/mocks/web-animations-api/index.ts +++ b/src/mocks/web-animations-api/index.ts @@ -1,4 +1,4 @@ -import './Animation'; +import { mockAnimation } from './Animation'; const elementAnimations = new Map(); @@ -42,13 +42,13 @@ function getAllAnimations() { return Array.from(elementAnimations.values()).flat(); } -// type MockAnimationsApiOptions = {}; - -function mockAnimationsApi(/* {}: MockAnimationsApiOptions = {} */) { +function mockAnimationsApi() { const savedAnimate = Element.prototype.animate; const savedGetAnimations = Element.prototype.getAnimations; const savedGetAllAnimations = Document.prototype.getAnimations; + mockAnimation(); + Object.defineProperties(Element.prototype, { animate: { writable: true, @@ -77,8 +77,6 @@ function mockAnimationsApi(/* {}: MockAnimationsApiOptions = {} */) { Element.prototype.getAnimations = savedGetAnimations; Document.prototype.getAnimations = savedGetAllAnimations; }); - - // return {}; } export { mockAnimationsApi }; diff --git a/src/mocks/web-animations-api/testHelpers.ts b/src/mocks/web-animations-api/testHelpers.ts deleted file mode 100644 index 5956932..0000000 --- a/src/mocks/web-animations-api/testHelpers.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const wait = (time: number) => new Promise((r) => setTimeout(r, time)); -export const expectTime = ( - time: number | null | undefined, - expected: number -) => { - expect((time ?? 0) / 1000).toBeCloseTo(expected / 1000, 1); -}; - -// export function flushPromises(): Promise { -// return new Promise(jest.requireActual('timers').setImmediate); -// } - -const tick = () => - new Promise((res) => jest.requireActual('timers').setImmediate(res)); - -export const advanceTimersByTime = async (time: number) => - jest.advanceTimersByTime(time) && (await tick()); diff --git a/src/mocks/web-animations-api/tests/cancel.test.ts b/src/mocks/web-animations-api/tests/cancel.test.ts index 606ab0d..db7dcc5 100644 --- a/src/mocks/web-animations-api/tests/cancel.test.ts +++ b/src/mocks/web-animations-api/tests/cancel.test.ts @@ -1,68 +1,184 @@ -import { playAnimation, FRAME_DURATION } from './tools'; +import { playAnimation, FRAME_DURATION, framesToTime } from './tools'; import { mockAnimationsApi } from '../index'; mockAnimationsApi(); -jest.useFakeTimers(); - describe('Animation', () => { - beforeEach(() => { - const syncShift = FRAME_DURATION - (performance.now() % FRAME_DURATION); - - jest.advanceTimersByTime(syncShift); - }); + describe('real timers', () => { + describe('cancel', () => { + it('it doesn\'t cancel if state is "idle"', () => { + jest.useRealTimers(); - describe('cancel', () => { - it('it doesn\'t cancel if state is "idle"', () => { - const element = document.createElement('div'); + const element = document.createElement('div'); - const effect = new KeyframeEffect( - element, - { transform: 'translateX(100px)' }, - 200 - ); + const effect = new KeyframeEffect( + element, + { transform: 'translateX(100px)' }, + 200 + ); - const animation = new Animation(effect); + const animation = new Animation(effect); - const finishedPromise = animation.finished; + const finishedPromise = animation.finished; - animation.cancel(); - // animation.play(); + animation.cancel(); - // expect(animation.playState).toBe('running'); - expect(finishedPromise === animation.finished).toBe(true); - }); + expect(finishedPromise === animation.finished).toBe(true); + }); - it('rejects the finished promise with an error, if state is "running"', (done) => { - const element = document.createElement('div'); + it("it cancels a running animation, but doesn't throw", (done) => { + jest.useRealTimers(); - const effect = new KeyframeEffect( - element, - { transform: 'translateX(100px)' }, - 200 - ); + const element = document.createElement('div'); - const animation = new Animation(effect); + const effect = new KeyframeEffect( + element, + { transform: 'translateX(100px)' }, + framesToTime(10) + ); - playAnimation(animation).then(() => { - const initialFinishedPromise = animation.finished; + const animation = new Animation(effect); - animation.finished.catch((error: unknown) => { - expect(error).toBeInstanceOf(DOMException); + animation.play(); - if (error instanceof DOMException) { - expect(error.name).toBe('AbortError'); - expect(error.message).toEqual('The user aborted a request.'); - } + const finishedPromise = animation.finished; - expect(animation.playState).toBe('idle'); - expect(animation.currentTime).toBeNull(); - expect(animation.finished !== initialFinishedPromise).toBe(true); + setTimeout(() => { + animation.cancel(); + }, framesToTime(3)); + setTimeout(() => { + expect(finishedPromise).not.toBe(animation.finished); done(); + }, framesToTime(5)); + }); + + it('rejects the finished promise with an error, if state is "running"', (done) => { + jest.useRealTimers(); + + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + { transform: 'translateX(100px)' }, + 200 + ); + + const animation = new Animation(effect); + + animation.play(); + + animation.ready.then(() => { + const initialFinishedPromise = animation.finished; + + animation.finished.catch((error: unknown) => { + expect(error).toBeInstanceOf(DOMException); + + if (error instanceof DOMException) { + expect(error.name).toBe('AbortError'); + expect(error.message).toEqual('The user aborted a request.'); + } + + expect(animation.playState).toBe('idle'); + expect(animation.currentTime).toBeNull(); + expect(animation.finished !== initialFinishedPromise).toBe(true); + + done(); + }); + + animation.cancel(); }); + }); + }); + }); + + describe('fake timers', () => { + beforeEach(() => { + jest.useFakeTimers(); + + const syncShift = FRAME_DURATION - (performance.now() % FRAME_DURATION); + + jest.advanceTimersByTime(syncShift); + }); + + describe('cancel', () => { + it('it doesn\'t cancel if state is "idle"', () => { + jest.useFakeTimers(); + + const element = document.createElement('div'); + const effect = new KeyframeEffect( + element, + { transform: 'translateX(100px)' }, + 200 + ); + + const animation = new Animation(effect); + + const finishedPromise = animation.finished; + + animation.cancel(); + + expect(finishedPromise === animation.finished).toBe(true); + }); + + it("it cancels a running animation, but doesn't throw", async () => { + jest.useFakeTimers(); + + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + { transform: 'translateX(100px)' }, + framesToTime(10) + ); + + const animation = new Animation(effect); + + await playAnimation(animation); + + jest.advanceTimersByTime(framesToTime(3)); + + const finishedPromise = animation.finished; animation.cancel(); + jest.advanceTimersByTime(framesToTime(3)); + + expect(finishedPromise).not.toBe(animation.finished); + }); + + it('rejects the finished promise with an error, if state is "running"', (done) => { + jest.useFakeTimers(); + + const element = document.createElement('div'); + + const effect = new KeyframeEffect( + element, + { transform: 'translateX(100px)' }, + 200 + ); + + const animation = new Animation(effect); + + playAnimation(animation).then(() => { + const initialFinishedPromise = animation.finished; + + animation.finished.catch((error: unknown) => { + expect(error).toBeInstanceOf(DOMException); + + if (error instanceof DOMException) { + expect(error.name).toBe('AbortError'); + expect(error.message).toEqual('The user aborted a request.'); + } + + expect(animation.playState).toBe('idle'); + expect(animation.currentTime).toBeNull(); + expect(animation.finished !== initialFinishedPromise).toBe(true); + + done(); + }); + + animation.cancel(); + }); }); }); });