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)
})
})
})
})