// eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck import { inlineVideo, inlineVideoAutoplay, inlineVideoNoAudio, teaserClipWithMultipleSources, teaserClipWithoutExpander, } from './fixtures' import Clip from '../client/index' import { createMediaTracker } from '@financial-times/media-tracking-sdk' jest.mock('@financial-times/media-tracking-sdk', () => ({ createMediaTracker: jest.fn(), })) const createMediaTrackerMock = jest.mocked(createMediaTracker) const mediaTrackerMock = { mount: jest.fn(), unmount: jest.fn(), flushWatched: jest.fn(), getProgress: jest.fn(() => 0), } // eslint-disable-next-line @typescript-eslint/no-explicit-any let observedCallbacks: Array = [] class MockIntersectionObserver { constructor(listener) { observedCallbacks.push(listener) } // eslint-disable-next-line @typescript-eslint/no-empty-function observe() {} // eslint-disable-next-line @typescript-eslint/no-empty-function unobserve() {} disconnect() { observedCallbacks = [] } } const initialMocks = () => { window.dispatchIntersectionObserver = (props = { isIntersecting: true }) => { observedCallbacks.forEach((callback) => callback([props])) } window.IntersectionObserver = MockIntersectionObserver } initialMocks() const mockVideo = (video) => { let playInterval = null video.dispatchEvent(new Event('loadeddata')) video.dispatchEvent(new Event('canplay')) video.dispatchEvent(new Event('canplaythrough')) video.load = () => { /* do nothing */ } video.play = () => { playInterval = setInterval(() => { video.currentTime += 100 }, 100) //mock that the video has a duration of 10 seconds when we play it due it is fully loaded video.duration = 10000 video.paused = false video.dispatchEvent(new Event('playing')) } video.pause = () => { clearInterval(playInterval) video.paused = true //mock that when we pause the video at least has passed 100ms if (video.currentTime === 0) video.currentTime = 100 video.dispatchEvent(new Event('pause')) } video.addTextTrack = () => { /* do nothing */ } Object.defineProperty(video, 'paused', { writable: true, value: true, }) Object.defineProperty(video, 'duration', { writable: true, value: 0, }) Object.defineProperty(video, 'ended', { writable: true, value: false, }) Object.defineProperty(video, 'readyState', { writable: true, value: 4, }) Object.defineProperty(video, 'currentTime', { writable: true, value: 0, }) Object.defineProperty(video, 'seeking', { writable: true, value: false, }) video.mock = true } const mockClips = (clips) => { clips.forEach((clip) => { if (clip.videoEl && !clip.videoEl.mock) mockVideo(clip.videoEl) }) } class OnlineController { // eslint-disable-next-line @typescript-eslint/no-explicit-any win: any // eslint-disable-next-line @typescript-eslint/no-explicit-any onLine: any constructor(win) { this.win = win this.onLine = win.navigator.onLine // Replace the default onLine implementation with our own. Object.defineProperty(win.navigator.constructor.prototype, 'onLine', { get: () => { return this.onLine }, }) } goOnline() { const was = this.onLine this.onLine = true // Fire only on transitions. if (!was) { this.fire('online') } } goOffline() { const was = this.onLine this.onLine = false // Fire only on transitions. if (was) { this.fire('offline') } } fire(event) { this.win.dispatchEvent(new this.win.Event(event)) } } const isVisible = (el) => { return !el || el.offsetParent !== null } describe('Clip', () => { beforeEach(() => { createMediaTrackerMock.mockReturnValue(mediaTrackerMock) Object.values(mediaTrackerMock).forEach((mock) => mock.mockClear()) }) describe('a component build on top of a video player', () => { let clips: Array beforeEach(() => { document.body.innerHTML = inlineVideo clips = Clip.init() mockClips(clips) }) afterEach(() => { const elem = document.body.children[0] document.body.removeChild(elem) clips.forEach((clip) => clip.destroy()) }) it('initialises a component', () => { expect(clips.length).toBe(1) }) it('has default properties and dynamic properties read from data attributes', () => { const clip = clips[0] expect(clip.videoEl).not.toBeFalsy() expect(clip.containerEl).not.toBeFalsy() expect(clip.observer).not.toBeFalsy() expect(clip.videoEl.paused).toBe(true) expect(clip.videoEl.currentTime).toBe(0) expect(clip.videoEl.duration).toBeFalsy() expect(clip.videoEl.ended).toBe(false) expect(clip.canAutoplay).toBe(false) expect(clip.useCustomPlayer).toBe(true) expect(clip.started).toBe(false) expect(clip.opts.autorender).toBe(true) expect(clip.opts.autoplay).toBe(false) expect(clip.opts.id).toBe( clip.containerEl.getAttribute('data-cp-clip-id') ) expect(clip.opts.layout).toBe( clip.containerEl.getAttribute('data-cp-clip-layout') ) expect(clip.opts.systemTitle).toBe( clip.containerEl.getAttribute('data-cp-clip-system-title') ) expect(clip.videoEl.crossOrigin).toBe('anonymous') }) it('mounts the media tracker when the component is initialised', () => { expect(createMediaTrackerMock).toHaveBeenCalledWith( expect.objectContaining({ mediaType: 'video', customComponentEvents: [ 'cc-default', 'click:out', 'cta:click', 'view', ], adapter: expect.any(Object), getContext: expect.any(Function), }) ) expect(mediaTrackerMock.mount).toHaveBeenCalledTimes(1) }) it('updates UI state when the video starts playing', () => { const clip = clips[0] clip.videoEl.play() expect(clip.videoEl.paused).toBe(false) expect(clip.containerEl.classList.contains('cp-clip--playing')).toBe(true) expect(clip.containerEl.classList.contains('cp-clip--paused')).toBe(false) }) it('updates UI state when the video is paused', () => { const clip = clips[0] clip.videoEl.play() clip.videoEl.pause() expect(clip.videoEl.paused).toBe(true) expect(clip.containerEl.classList.contains('cp-clip--paused')).toBe(true) expect(clip.containerEl.classList.contains('cp-clip--playing')).toBe( false ) }) it('updates loop state on timeupdate using the media tracker progress', () => { const clip = clips[0] mediaTrackerMock.getProgress .mockReturnValueOnce(80) .mockReturnValueOnce(10) clip.videoEl.dispatchEvent(new Event('timeupdate')) clip.videoEl.dispatchEvent(new Event('timeupdate')) expect(mediaTrackerMock.getProgress).toHaveBeenCalledTimes(2) expect(clip.loops).toBe(1) }) it('flushes watched metrics and unmounts the media tracker on window unload', () => { const unloadEventName = 'onbeforeunload' in window ? 'beforeunload' : 'unload' window.dispatchEvent(new Event(unloadEventName)) expect(mediaTrackerMock.flushWatched).toHaveBeenCalledTimes(1) expect(mediaTrackerMock.unmount).toHaveBeenCalledTimes(1) expect( mediaTrackerMock.flushWatched.mock.invocationCallOrder[0]! ).toBeLessThan(mediaTrackerMock.unmount.mock.invocationCallOrder[0]!) }) it('fires a view component event when the component enters the viewport', () => { const clip = clips[0] clip.videoEl.play() const spy = jest.spyOn(clip, 'fireEvent') window.dispatchIntersectionObserver() expect(spy).toHaveBeenCalledWith('view') expect(clip.isInViewPort).toBe(true) window.dispatchIntersectionObserver({ isIntersecting: false }) expect(clip.isInViewPort).toBe(false) expect(clip.canAutoplay).toBe(false) }) it('if the video has audio it should be unmuted and show audio icon when playing', (done) => { const clip = clips[0] expect(clip.muted).toBe(false) expect(clip.videoEl.muted).toBe(false) expect( clip.containerEl.querySelector('[data-test-id="cp-clip__mute-icon"]') ).toBeTruthy() // eslint-disable-next-line @typescript-eslint/no-unused-vars const listener = (e) => { expect(clip.muted).toBe(false) expect(clip.videoEl.muted).toBe(false) expect( clip.containerEl.querySelector('[data-test-id="cp-clip__mute-icon"]') ).toBeTruthy() clip.videoEl.removeEventListener('playing', listener) done() } clip.videoEl.addEventListener('playing', listener) clip.videoEl.play() }) }) describe('with no audio', () => { let clips: Array beforeEach(() => { document.body.innerHTML = inlineVideoNoAudio clips = Clip.init() mockClips(clips) }) afterEach(() => { const elem = document.body.children[0] document.body.removeChild(elem) clips.forEach((clip) => clip.destroy()) }) it("if the video doesn't have audio it should show 'no audio' icon after playing", (done) => { const clip = clips[0] expect(clip.muted).toBe(true) // eslint-disable-next-line @typescript-eslint/no-unused-vars const listener = (e) => { expect(clip.muted).toBe(true) expect( clip.containerEl.querySelector('.cp-clip__no-audio') ).toBeTruthy() clip.videoEl.removeEventListener('playing', listener) done() } clip.videoEl.addEventListener('playing', listener) clip.videoEl.play() }) it("if the video doesn't have audio it shouldn't show mute icon after playing", (done) => { const clip = clips[0] // eslint-disable-next-line @typescript-eslint/no-unused-vars const listener = (e) => { expect(clip.muted).toBe(true) expect( clip.containerEl.querySelector('[data-test-id="cp-clip__mute-icon"]') ).toBeFalsy() clip.videoEl.removeEventListener('playing', listener) done() } clip.videoEl.addEventListener('playing', listener) clip.videoEl.play() }) }) describe('with autoplay', () => { let clips: Array beforeEach(() => { document.body.innerHTML = inlineVideoAutoplay clips = Clip.init() mockClips(clips) }) afterEach(() => { const elem = document.body.children[0] document.body.removeChild(elem) clips.forEach((clip) => clip.destroy()) }) it('initialises the component', () => { expect(clips.length).toBe(1) }) it('has an autoplay property', () => { const clip = clips[0] expect(clip.opts.autoplay).toBe(true) }) it('when enters in view it will autoplay', () => { const clip = clips[0] const spy = jest.spyOn(clip, 'fireEvent') window.dispatchIntersectionObserver() expect(clip.videoEl.paused).toBe(false) expect(clip.canAutoplay).toBe(true) expect(clip.isInViewPort).toBe(true) expect(spy).toHaveBeenCalledWith('view') }) it('when leaves the viewport it will pause the video', () => { const clip = clips[0] window.dispatchIntersectionObserver() window.dispatchIntersectionObserver({ isIntersecting: false }) expect(clip.videoEl.paused).toBe(true) expect(clip.autoplayPaused).toBe(true) expect(clip.isInViewPort).toBe(false) }) it('can be manually paused by users', () => { const clip = clips[0] window.dispatchIntersectionObserver() clip.videoEl.pause() expect(clip.videoEl.paused).toBe(true) expect(clip.containerEl.classList.contains('cp-clip--paused')).toBe(true) }) it('after having been paused, it can be played again by users', () => { const clip = clips[0] window.dispatchIntersectionObserver() clip.videoEl.pause() clip.videoEl.play() expect(clip.videoEl.paused).toBe(false) expect(clip.containerEl.classList.contains('cp-clip--playing')).toBe(true) }) it('pauses without emitting a Clip custom event when seeking', () => { const clip = clips[0] clip.videoEl.seeking = true const spy = jest.spyOn(clip, 'fireEvent') clip.videoEl.pause() expect(spy).not.toHaveBeenCalledWith('pause') }) it('pauses without emitting a Clip custom event when video ended', () => { const clip = clips[0] clip.videoEl.ended = true const spy = jest.spyOn(clip, 'fireEvent') clip.videoEl.pause() expect(spy).not.toHaveBeenCalledWith('pause') }) it('if the video has audio it should be muted and show mute icon even when playing', (done) => { const clip = clips[0] expect(clip.muted).toBe(true) expect(clip.videoEl.muted).toBe(true) expect( clip.containerEl.querySelector('[data-test-id="cp-clip__mute-icon"]') ).toBeTruthy() // eslint-disable-next-line @typescript-eslint/no-unused-vars const listener = (e) => { expect(clip.muted).toBe(true) expect(clip.videoEl.muted).toBe(true) expect( clip.containerEl.querySelector('[data-test-id="cp-clip__mute-icon"]') ).toBeTruthy() clip.videoEl.removeEventListener('playing', listener) done() } clip.videoEl.addEventListener('playing', listener) clip.videoEl.play() }) it('mute icon should change and set or unset the video muted', (done) => { const clip = clips[0] expect(clip.muted).toBe(true) expect(clip.videoEl.muted).toBe(true) expect(clip.muteIcon.getAttribute('data-mute')).toBe('true') //check that click again mute the video const secondListener = () => { expect(clip.muted).toBe(true) expect(clip.videoEl.muted).toBe(true) expect(clip.muteIcon.getAttribute('data-mute')).toBe('true') clip.muteIcon.removeEventListener('click', secondListener) done() } const listener = () => { expect(clip.muted).toBe(false) expect(clip.videoEl.muted).toBe(false) expect(clip.muteIcon.getAttribute('data-mute')).toBe('false') } clip.muteIcon.addEventListener('click', listener) clip.muteIcon.click() clip.muteIcon.removeEventListener('click', listener) clip.muteIcon.addEventListener('click', secondListener) clip.muteIcon.click() }) it('should pass empty string to the poster attribute if autoplay mode is enabled', () => { const clip = clips[0] expect(clip.videoEl.getAttribute('poster')).toBe('') }) }) describe('offline functionality', () => { let clips: Array const offlineWarningRegEx = /Your device appears to be offline. Reconnect to the internet to view./ beforeEach(() => { document.body.innerHTML = inlineVideo clips = Clip.init() mockClips(clips) }) afterEach(() => { const elem = document.body.children[0] document.body.removeChild(elem) clips.forEach((clip) => clip.destroy()) }) describe('when the user goes offline', () => { it('will show a message to the user', () => { const clip = clips[0] const networkState = new OnlineController(window) networkState.goOffline() expect(clip.expander._currentState).toBe('expand') expect(clip.expander.contentElement.innerHTML).toMatch( offlineWarningRegEx ) }) }) describe('when the user goes offline but then comes back online', () => { it('will NOT show a message to the user', () => { const clip = clips[0] const networkState = new OnlineController(window) networkState.goOffline() expect(clip.expander._currentState).toBe('expand') expect(clip.expander.contentElement.innerHTML).toMatch( offlineWarningRegEx ) networkState.goOnline() expect(clip.expander._currentState).toBe('collapse') expect(clip.expander.contentElement.innerHTML).not.toMatch( offlineWarningRegEx ) }) }) }) describe('Custom player', () => { let clips: Array beforeEach(() => { document.body.innerHTML = inlineVideoAutoplay clips = Clip.init() mockClips(clips) }) afterEach(() => { const elem = document.body.children[0] document.body.removeChild(elem) clips.forEach((clip) => clip.destroy()) }) it('should be present', () => { expect( document.body.querySelector('.cp-clip__video-controls') ).toBeTruthy() }) it('should show pause button when the video is played and loop is true', () => { const clip = clips[0] clip.videoEl.pause() expect( document.body.querySelector('.cp-clip__playpause-icon-pause') ).toBeFalsy() clip.videoEl.play() expect( document.body.querySelector('.cp-clip__playpause-icon-pause') ).toBeTruthy() }) it('should show play button when the video is paused', (done) => { const clip = clips[0] expect(document.body.querySelector('.cp-clip__play-icon')).toBeTruthy() const listener = () => { expect( isVisible(document.body.querySelector('.cp-clip__play-icon')) ).toEqual(false) clip.videoEl.removeEventListener('playing', listener) done() } clip.videoEl.addEventListener('playing', listener) clip.videoEl.play() }) it('should toggle the pause button to a play button when the video is paused', () => { const clip = clips[0] clip.videoEl.play() expect( document.body.querySelector('.cp-clip__playpause-icon-play') ).toBeFalsy() clip.videoEl.pause() expect( document.body.querySelector('.cp-clip__playpause-icon-play') ).toBeTruthy() }) it('it should have progress loop indicator', (done) => { const clip = clips[0] expect( document.body.querySelector( '.cp-clip__playpause-icon-autoplay-progress' ) ).toBeTruthy() expect( isVisible( document.body.querySelector( '.cp-clip__playpause-icon-autoplay-progress' ) ) ).toEqual(false) const listener = () => { expect( document.body.querySelector( '.cp-clip__playpause-icon-autoplay-progress' ) ).toBeTruthy() expect( isVisible( document.body.querySelector( '.cp-clip__playpause-icon-autoplay-progress' ) ) ).not.toEqual(true) clip.videoEl.removeEventListener('playing', listener) done() } clip.videoEl.addEventListener('playing', listener) clip.videoEl.play() }) it('shows loading indicator when waiting event is triggered', (done) => { const clip = clips[0] const spy = jest.spyOn(clip, 'fadeIn') clip.videoEl.dispatchEvent(new Event('waiting')) setTimeout(() => { expect(clip.containerEl.classList.contains('cp-clip--loading')).toBe( true ) expect(spy).toHaveBeenCalled() done() }, 100) }) it('hides loading indicator when canplay event is triggered', (done) => { const clip = clips[0] const spy = jest.spyOn(clip, 'fadeOut') clip.videoEl.dispatchEvent(new Event('canplay')) setTimeout(() => { expect(clip.containerEl.classList.contains('cp-clip--loading')).toBe( false ) expect(spy).toHaveBeenCalledWith(true) done() }, 100) }) it('it renders without a loading indicator', () => { const clip = clips[0] expect(clip.containerEl.classList.contains('cp-clip--loading')).toBe( false ) }) it('hides loading indicator when canplaythrough event is triggered', (done) => { const clip = clips[0] const spy = jest.spyOn(clip, 'fadeOut') clip.videoEl.dispatchEvent(new Event('canplaythrough')) setTimeout(() => { expect(clip.containerEl.classList.contains('cp-clip--loading')).toBe( false ) expect(spy).toHaveBeenCalledWith(true) done() }, 100) }) }) describe('Captions, credits, and descriptions', () => { let clips: Array beforeEach(() => { document.body.innerHTML = inlineVideoAutoplay clips = Clip.init() mockClips(clips) }) afterEach(() => { const elem = document.body.children[0] document.body.removeChild(elem) clips.forEach((clip) => clip.destroy()) }) it('should display credit if provided', () => { expect(document.body.querySelector('.cp-clip__credit')).toBeTruthy() }) it('should display caption if provided', () => { expect(document.body.querySelector('.cp-clip__caption')).toBeTruthy() }) it('should display description if provided', () => { expect(document.body.querySelector('.o-expander__info-box')).toBeTruthy() }) it('should hide caption and credit when description is displayed', () => { const descriptionButton = document.body.querySelector( '.cp-clip__video-meta-info > .o-expander > .o-expander__toggle' ) const caption = document.body.querySelector('.cp-clip__caption') expect(descriptionButton).toBeTruthy() expect(descriptionButton?.getAttribute('aria-expanded')).toBe('false') descriptionButton.click() expect(descriptionButton?.getAttribute('aria-expanded')).toBe('true') expect(caption?.getAttribute('class')).toContain( 'o-normalise-visually-hidden' ) }) it('should not display CC button if closed caption is not provided', () => { document.body.innerHTML = inlineVideo clips = Clip.init() mockClips(clips) const clip = clips[0] clip.videoEl.play() expect( document.body.querySelector('.cp-clip__closed-caption') ).toBeFalsy() }) it('should display CC button if closed caption is provided', () => { expect( document.body.querySelector('.cp-clip__closed-caption') ).toBeTruthy() }) it('should not display expander if Clip is rendered inside a teaser', () => { document.body.innerHTML = teaserClipWithoutExpander clips = Clip.init() mockClips(clips) const expanderElement = document.querySelector('.o-expander') expect(expanderElement).toBeNull() }) it('should not load and set the expander if Clip does not have expander elements ', () => { document.body.innerHTML = teaserClipWithoutExpander clips = Clip.init() mockClips(clips) const clip = clips[0] expect(clip.expander).toBeFalsy() }) it('should not call expander related methods if expander usage is not needed', () => { document.body.innerHTML = teaserClipWithoutExpander clips = Clip.init() mockClips(clips) const clip = clips[0] const descriptionToggleSpy = jest.spyOn(clip, 'descriptionToggle') const offlineMessageSpy = jest.spyOn(clip, 'showOffLineMessage') expect(descriptionToggleSpy).not.toHaveBeenCalled() expect(offlineMessageSpy).not.toHaveBeenCalled() }) }) describe('Error handling', () => { let clips: Array let dispatchEventSpy beforeEach(() => { document.body.innerHTML = teaserClipWithMultipleSources clips = Clip.init() mockClips(clips) dispatchEventSpy = jest .spyOn(clips[0].containerEl, 'dispatchEvent') .mockImplementation() }) afterEach(() => { const elem = document.body.children[0] document.body.removeChild(elem) clips.forEach((clip) => clip.destroy()) dispatchEventSpy.mockRestore() }) it('should not dispatch custom event if not all sources received an error event', () => { const clip = clips[0] const firstSource = clip.videoEl.querySelector('source') firstSource.dispatchEvent(new Event('error')) expect(dispatchEventSpy).not.toHaveBeenCalled() }) it('should dispatch custom event if all sources received an error event', () => { const clip = clips[0] const sources = clip.videoEl.querySelectorAll('source') sources.forEach((source) => { source.dispatchEvent(new Event('error')) }) expect(dispatchEventSpy).toHaveBeenCalled() }) }) })