import { createMediaTracker } from '@financial-times/media-tracking-sdk' import YouTubeClip from '../client' jest.mock('@financial-times/media-tracking-sdk', () => ({ createMediaTracker: jest.fn(), })) interface MockYouTubePlayer { getDuration: jest.Mock getCurrentTime: jest.Mock getPlayerState: jest.Mock getVideoData: jest.Mock getVideoUrl: jest.Mock getIframe: jest.Mock destroy: jest.Mock } const createMediaTrackerMock = jest.mocked(createMediaTracker) const mediaTrackerMock = { mount: jest.fn(), unmount: jest.fn(), flushWatched: jest.fn(), getProgress: jest.fn(() => 0), } function setupMockYT(mockPlayerConstructor: jest.Mock) { ;( window as unknown as Window & { YT: { Player: jest.Mock; PlayerState: object } } ).YT = { Player: mockPlayerConstructor, PlayerState: { ENDED: 0, PLAYING: 1, PAUSED: 2, BUFFERING: 3, }, } } describe('YouTubeClip', () => { let mockPlayer: MockYouTubePlayer beforeEach(() => { document.body.innerHTML = '
' document.documentElement.removeAttribute('data-underlying-url') mockPlayer = { getDuration: jest.fn().mockReturnValue(100), getCurrentTime: jest.fn().mockReturnValue(0), getPlayerState: jest.fn().mockResolvedValue(-1), getVideoData: jest.fn().mockReturnValue({ video_id: 'test123', title: 'Test Video', }), getVideoUrl: jest.fn().mockReturnValue('https://youtu.be/test123'), getIframe: jest.fn().mockReturnValue(document.createElement('iframe')), destroy: jest.fn(), } createMediaTrackerMock.mockReset() createMediaTrackerMock.mockReturnValue(mediaTrackerMock) Object.values(mediaTrackerMock).forEach((mock) => mock.mockReset()) mediaTrackerMock.getProgress.mockReturnValue(0) delete (window as Partial).YT delete (window as Partial).onYouTubeIframeAPIReady document.head.innerHTML = '' }) afterEach(() => { jest.useRealTimers() jest.restoreAllMocks() }) describe('.init', () => { describe('When there are no placeholders in the document', () => { beforeEach(() => { document.body.innerHTML = '' document.head.innerHTML = '' }) it('should not insert the youtube script into the document head', () => { YouTubeClip.init() expect(document.head.innerHTML).not.toContain( 'https://www.youtube.com/iframe_api' ) }) it('should return an empty array', () => { const instances = YouTubeClip.init() expect(instances).toHaveLength(0) }) }) describe('when there are multiple placeholders in the document', () => { beforeEach(() => { document.body.innerHTML = '
' + '
' document.head.innerHTML = '' }) it('should create an instance for every placeholder', () => { const mockPlayerConstructor = jest.fn().mockReturnValue(mockPlayer) setupMockYT(mockPlayerConstructor) const instances = YouTubeClip.init() expect(instances.length).toEqual(2) instances.forEach((instance) => { expect(instance).toBeInstanceOf(YouTubeClip) }) expect(instances[0]?.videoId).toBe('12345') expect(instances[1]?.videoId).toBe('6789') }) }) describe('when there is a single placeholder in the document', () => { beforeEach(() => { document.body.innerHTML = '
' document.head.innerHTML = '' }) it('should create an instance for every placeholder', () => { const mockPlayerConstructor = jest.fn().mockReturnValue(mockPlayer) setupMockYT(mockPlayerConstructor) const instances = YouTubeClip.init() expect(instances.length).toEqual(1) instances.forEach((instance) => { expect(instance).toBeInstanceOf(YouTubeClip) }) expect(instances[0]?.videoId).toBe('12345') }) }) describe('When YT already exists on the window', () => { beforeEach(() => { const mockPlayerConstructor = jest.fn().mockReturnValue(mockPlayer) setupMockYT(mockPlayerConstructor) document.body.innerHTML = '
' document.head.innerHTML = '' }) it('should not insert the youtube script into the document head', () => { const instances = YouTubeClip.init() expect(instances).toHaveLength(1) expect(document.head.innerHTML).toBe('') }) it('should not create a onYouTubeIframeAPIReady function on the window', () => { const readyFunction = (window.onYouTubeIframeAPIReady = () => {}) expect(window.onYouTubeIframeAPIReady).toBe(readyFunction) YouTubeClip.init() expect(window.onYouTubeIframeAPIReady).toBe(readyFunction) }) }) describe('When YT does not already exist on the window', () => { beforeEach(() => { document.body.innerHTML = '
' document.head.innerHTML = '' }) it('should insert the youtube script into the document head', () => { YouTubeClip.init() expect(document.head.innerHTML).toContain( 'https://www.youtube.com/iframe_api' ) }) it('should create a new onYouTubeIframeAPIReady function on the window', () => { expect(window.onYouTubeIframeAPIReady).toBeUndefined() YouTubeClip.init() expect(window.onYouTubeIframeAPIReady).toBeInstanceOf(Function) }) }) }) describe('.constructor', () => { it('should call the youtube player class', () => { const mockPlayerConstructor = jest.fn().mockReturnValue(mockPlayer) setupMockYT(mockPlayerConstructor) const spyYTPlayer = jest.spyOn(window.YT, 'Player') YouTubeClip.init() expect(spyYTPlayer).toHaveBeenCalled() }) it('should pass the correct arguments to the youtube player class', () => { const mockPlayerConstructor = jest.fn().mockReturnValue(mockPlayer) setupMockYT(mockPlayerConstructor) const spyYTPlayer = jest.spyOn(window.YT, 'Player') YouTubeClip.init() const placeholderArgument = spyYTPlayer.mock.calls[0] expect(placeholderArgument).toMatchInlineSnapshot(` [
, { "events": { "onStateChange": [Function], }, "host": "https://www.youtube-nocookie.com", "videoId": "12345", }, ] `) }) it('should create and mount a media tracker', () => { const mockPlayerConstructor = jest.fn().mockReturnValue(mockPlayer) setupMockYT(mockPlayerConstructor) const [instance] = YouTubeClip.init() expect(createMediaTrackerMock).toHaveBeenCalledWith( expect.objectContaining({ mediaType: 'video', adapter: expect.any(Object), getContext: expect.any(Function), }) ) expect(mediaTrackerMock.mount).toHaveBeenCalledTimes(1) expect(instance).toBeInstanceOf(YouTubeClip) }) it('should provide page context to the media tracker', () => { const mockPlayerConstructor = jest.fn().mockReturnValue(mockPlayer) setupMockYT(mockPlayerConstructor) YouTubeClip.init(document, { rootContentId: 'root-content-id' }) const trackerConfig = createMediaTrackerMock.mock.calls[0]![0] expect(trackerConfig.getContext()).toEqual({ url: 'http://localhost/', referrer: '', rootContentId: 'root-content-id', }) }) }) describe('.destroy', () => { let instance: YouTubeClip let placeholder: HTMLElement beforeEach(() => { const mockPlayerConstructor = jest.fn().mockReturnValue(mockPlayer) setupMockYT(mockPlayerConstructor) placeholder = document.querySelector( '[data-component="youtube-video"]' ) as HTMLElement instance = new YouTubeClip(placeholder) }) it('should destroy the player', () => { instance.destroy() expect(mockPlayer.destroy).toHaveBeenCalledTimes(1) }) it('should flush watched metrics, unmount the media tracker, and destroy the player on unload', () => { instance.unload() expect(mediaTrackerMock.flushWatched).toHaveBeenCalledTimes(1) expect(mediaTrackerMock.unmount).toHaveBeenCalledTimes(1) expect( mediaTrackerMock.flushWatched.mock.invocationCallOrder[0]! ).toBeLessThan(mediaTrackerMock.unmount.mock.invocationCallOrder[0]!) expect(mockPlayer.destroy).toHaveBeenCalledTimes(1) }) it('should stop timeupdate tracking', () => { jest.useFakeTimers() const dispatchEventSpy = jest.spyOn(placeholder, 'dispatchEvent') instance['onPlayerStateChange']({ data: YT.PlayerState.PLAYING, target: instance.player, } as YT.OnStateChangeEvent) instance.destroy() jest.advanceTimersByTime(250) expect(dispatchEventSpy).toHaveBeenCalledWith(expect.any(Event)) expect( dispatchEventSpy.mock.calls.filter( ([event]) => event.type === 'timeupdate' ) ).toHaveLength(0) }) describe('The youtube player destroy function errors', () => { it('should throw an error', () => { mockPlayer.destroy.mockImplementation(() => { throw new Error('error') }) expect(() => instance.destroy()).toThrowErrorMatchingInlineSnapshot( `"Failed to destroy YouTube player instance."` ) expect(mockPlayer.destroy).toBeCalledTimes(1) }) }) }) describe('Private methods', () => { describe('.onPlayerStateChange', () => { let instance: YouTubeClip let placeholder: HTMLElement beforeEach(() => { const mockPlayerConstructor = jest.fn().mockReturnValue(mockPlayer) setupMockYT(mockPlayerConstructor) placeholder = document.querySelector( '[data-component="youtube-video"]' ) as HTMLElement instance = new YouTubeClip(placeholder) }) it('should dispatch a waiting event when buffering', () => { const listener = jest.fn() placeholder.addEventListener('waiting', listener) instance['onPlayerStateChange']({ data: YT.PlayerState.BUFFERING, target: instance.player, } as YT.OnStateChangeEvent) expect(listener).toHaveBeenCalledTimes(1) }) it('should dispatch playing and start timeupdate tracking when playing', () => { jest.useFakeTimers() const events: string[] = [] placeholder.addEventListener('playing', (event) => events.push(event.type) ) placeholder.addEventListener('timeupdate', (event) => events.push(event.type) ) instance['onPlayerStateChange']({ data: YT.PlayerState.PLAYING, target: instance.player, } as YT.OnStateChangeEvent) jest.advanceTimersByTime(250) expect(events).toEqual(['playing', 'timeupdate']) }) it('should update loop state on timeupdate using the media tracker progress', () => { jest.useFakeTimers() mediaTrackerMock.getProgress .mockReturnValueOnce(80) .mockReturnValueOnce(10) instance['onPlayerStateChange']({ data: YT.PlayerState.PLAYING, target: instance.player, } as YT.OnStateChangeEvent) jest.advanceTimersByTime(500) const trackerConfig = createMediaTrackerMock.mock.calls[0]![0] const adapter = trackerConfig.adapter as { getLoopCount: () => number } expect(mediaTrackerMock.getProgress).toHaveBeenCalledTimes(2) expect(adapter.getLoopCount()).toBe(1) }) it('should dispatch pause and stop timeupdate tracking when paused', () => { jest.useFakeTimers() const events: string[] = [] placeholder.addEventListener('pause', (event) => events.push(event.type) ) placeholder.addEventListener('timeupdate', (event) => events.push(event.type) ) instance['onPlayerStateChange']({ data: YT.PlayerState.PLAYING, target: instance.player, } as YT.OnStateChangeEvent) instance['onPlayerStateChange']({ data: YT.PlayerState.PAUSED, target: instance.player, } as YT.OnStateChangeEvent) jest.advanceTimersByTime(250) expect(events).toEqual(['pause']) }) it('should dispatch ended and stop timeupdate tracking when ended', () => { jest.useFakeTimers() const events: string[] = [] placeholder.addEventListener('ended', (event) => events.push(event.type) ) placeholder.addEventListener('timeupdate', (event) => events.push(event.type) ) instance['onPlayerStateChange']({ data: YT.PlayerState.PLAYING, target: instance.player, } as YT.OnStateChangeEvent) instance['onPlayerStateChange']({ data: YT.PlayerState.ENDED, target: instance.player, } as YT.OnStateChangeEvent) jest.advanceTimersByTime(250) expect(events).toEqual(['ended']) }) }) describe('.fireEvent', () => { it('should dispatch the player event from the placeholder', async () => { const mockPlayerConstructor = jest.fn().mockReturnValue(mockPlayer) setupMockYT(mockPlayerConstructor) const placeholder = document.querySelector( '[data-component="youtube-video"]' ) as HTMLElement const instance = new YouTubeClip(placeholder) const listener = jest.fn() placeholder.addEventListener('playing', listener) await instance['fireEvent']('playing') expect(listener).toHaveBeenCalledTimes(1) }) }) }) })