UNPKG

video-ad-sdk

Version:

VAST/VPAID SDK that allows video ads to be played on top of any player

770 lines (641 loc) 22.1 kB
import { inlineAd, inlineParsedXML, vastInlineXML, vastVpaidInlineXML, vastWrapperXML, vpaidInlineAd, vpaidInlineParsedXML, wrapperAd, wrapperParsedXML } from '../../../fixtures' import {defer} from '../../utils/defer' import {requestAd} from '../../vastRequest/requestAd' import {requestNextAd} from '../../vastRequest/requestNextAd' import {VastError} from '../../vastRequest/helpers/vastError' import {run} from '../run' import {runWaterfall} from '../runWaterfall' import {VideoAdContainer} from '../../adContainer/VideoAdContainer' import {VastAdUnit} from '../../adUnit/VastAdUnit' import {trackError, ErrorCode} from '../../tracker' import type {VastChain} from '../../types' import {_protected} from '../../adUnit/VideoAdUnit' import {isIos} from '../../utils/isIos' jest.mock('../../utils/isIos') jest.mock('../../vastRequest/requestAd', () => ({requestAd: jest.fn()})) jest.mock('../../vastRequest/requestNextAd', () => ({requestNextAd: jest.fn()})) jest.mock('../run', () => ({run: jest.fn()})) jest.mock('../../tracker', () => ({ ...jest.requireActual('../../tracker'), linearEvents: {}, trackError: jest.fn() })) const noop = (): void => {} describe('runWaterfall', () => { let adTag: string let vastAdChain: VastChain let options: any let placeholder: HTMLElement let adContainer: VideoAdContainer let adUnit: VastAdUnit beforeEach(() => { ;(isIos as jest.Mock).mockReturnValue(false) adTag = 'https://test.example.com/adtag' vastAdChain = [ { ad: inlineAd, parsedXML: inlineParsedXML, requestTag: 'https://test.example.com/vastadtaguri', XML: vastInlineXML }, { ad: wrapperAd, parsedXML: wrapperParsedXML, requestTag: 'https://test.example.com/vastadtaguri', XML: vastWrapperXML } ] options = { tracker: jest.fn() } placeholder = document.createElement('div') adContainer = new VideoAdContainer( placeholder, document.createElement('video') ) adUnit = new VastAdUnit(vastAdChain, adContainer, options) }) afterEach(() => { jest.clearAllMocks() jest.resetAllMocks() }) describe('videoElement', () => { it('must call load synchronously for iOS devices if you pass the video element', () => { const {videoElement} = adContainer Object.defineProperty(videoElement, 'load', { value: jest.fn() }) Object.defineProperty(videoElement, 'canPlayType', { value: jest.fn(() => true) }) ;(isIos as jest.Mock).mockReturnValue(true) runWaterfall(adTag, placeholder, { ...options, videoElement }) expect(videoElement.load).toHaveBeenCalledTimes(1) }) it("must not call load synchronously for iOS devices if you don't pass the video element", () => { const {videoElement} = adContainer Object.defineProperty(videoElement, 'load', { value: jest.fn() }) ;(isIos as jest.Mock).mockReturnValue(true) runWaterfall(adTag, placeholder, { ...options }) expect(videoElement.load).toHaveBeenCalledTimes(0) }) it('must not call load if the device is not iOS', () => { const {videoElement} = adContainer Object.defineProperty(videoElement, 'load', { value: jest.fn() }) ;(isIos as jest.Mock).mockReturnValue(false) runWaterfall(adTag, placeholder, { ...options, videoElement }) expect(videoElement.load).toHaveBeenCalledTimes(0) }) }) describe('after fetching Vast response', () => { test('must throw if it gets a vpaid ad with vpaidEnabled flag set to false', async () => { const onError = jest.fn() const vpaidChain = [ { ad: vpaidInlineAd, parsedXML: vpaidInlineParsedXML, requestTag: 'https://test.example.com/vastadtaguri', XML: vastVpaidInlineXML } ] ;(requestAd as jest.Mock).mockReturnValue(Promise.resolve(vpaidChain)) await new Promise((resolve) => { runWaterfall(adTag, placeholder, { ...options, onError, onRunFinish: resolve, vpaidEnabled: false }) }) expect(onError).toHaveBeenCalledTimes(2) const error = onError.mock.calls[0][0] expect(error.code).toBe(ErrorCode.VAST_UNEXPECTED_AD_TYPE) expect(error.message).toBe( 'VPAID ads are not supported by the current player' ) expect(trackError).toHaveBeenCalledWith( vpaidChain, expect.objectContaining({ errorCode: ErrorCode.VAST_UNEXPECTED_AD_TYPE, tracker: options.tracker }) ) }) test('must call onError if Vast response is undefined', async () => { const onError = jest.fn() ;(requestAd as jest.Mock).mockReturnValue(Promise.resolve()) await new Promise((resolve) => { runWaterfall(adTag, placeholder, { ...options, onError, onRunFinish: resolve }) }) expect(onError).toHaveBeenCalledTimes(1) expect(onError.mock.calls[0][0].message).toBe('Invalid VastChain') }) test('must call onError if Vast response is an empty array', async () => { const onError = jest.fn() ;(requestAd as jest.Mock).mockReturnValue(Promise.resolve([])) await new Promise((resolve) => { runWaterfall(adTag, placeholder, { ...options, onError, onRunFinish: resolve }) }) expect(onError).toHaveBeenCalledTimes(2) expect(onError.mock.calls[0][0].message).toBe('Invalid VastChain') }) test('must throw if the vastChain has an error and track the error', async () => { const onError = jest.fn() const vastChainError = new Error('boom') const vastChainWithError = [ { ...vastAdChain[0], error: vastChainError, errorCode: ErrorCode.UNKNOWN_ERROR } ] ;(requestAd as jest.Mock).mockReturnValue( Promise.resolve(vastChainWithError) ) await new Promise((resolve) => { runWaterfall(adTag, placeholder, { ...options, onError, onRunFinish: resolve }) }) expect(onError).toHaveBeenCalledTimes(2) expect(onError.mock.calls[0][0]).toBe(vastChainError) expect(trackError).toHaveBeenCalledWith( vastChainWithError, expect.objectContaining({ errorCode: ErrorCode.UNKNOWN_ERROR, tracker: options.tracker }) ) }) test('must throw if options.hooks.validateVastResponse fails', async () => { const onError = jest.fn() const vastChainError = new VastError('boom') vastChainError.code = ErrorCode.UNKNOWN_ERROR ;(requestAd as jest.Mock).mockReturnValue(Promise.resolve(vastAdChain)) await new Promise((resolve) => { runWaterfall(adTag, placeholder, { ...options, hooks: { validateVastResponse: () => { throw vastChainError } }, onError, onRunFinish: resolve }) }) expect(onError).toHaveBeenCalledTimes(2) expect(onError.mock.calls[0][0]).toBe(vastChainError) expect(trackError).toHaveBeenCalledWith( vastAdChain, expect.objectContaining({ errorCode: ErrorCode.UNKNOWN_ERROR, tracker: options.tracker }) ) }) test('must be possible to transform the vast response before calling run', async () => { const deferred = defer<void>() ;(requestAd as jest.Mock).mockReturnValue(Promise.resolve(vastAdChain)) ;(run as jest.Mock).mockReturnValue(Promise.resolve(adUnit)) const onAdStart = jest.fn() runWaterfall(adTag, placeholder, { ...options, hooks: { transformVastResponse: (vastResponse: any) => { vastResponse.__transformed = true return vastResponse } }, onAdStart: (...args) => { deferred.resolve() onAdStart(...args) } }) await deferred.promise expect(onAdStart).toHaveBeenCalledTimes(1) expect(onAdStart).toHaveBeenCalledWith(adUnit) expect(requestAd).toHaveBeenCalledTimes(1) expect(requestAd).toHaveBeenCalledWith( adTag, expect.objectContaining(options) ) expect(run).toHaveBeenCalledTimes(1) expect(run).toHaveBeenCalledWith( vastAdChain, placeholder, expect.objectContaining(options) ) expect((vastAdChain as any).__transformed).toBe(true) }) }) describe('options.onAdStart', () => { test('must be called once the adUnit starts with the started ad unit', async () => { const deferred = defer<void>() ;(requestAd as jest.Mock).mockReturnValue(Promise.resolve(vastAdChain)) ;(run as jest.Mock).mockReturnValue(Promise.resolve(adUnit)) const onAdStart = jest.fn() runWaterfall(adTag, placeholder, { ...options, onAdStart: (...args) => { deferred.resolve() onAdStart(...args) } }) await deferred.promise expect(onAdStart).toHaveBeenCalledTimes(1) expect(onAdStart).toHaveBeenCalledWith(adUnit) expect(requestAd).toHaveBeenCalledTimes(1) expect(requestAd).toHaveBeenCalledWith( adTag, expect.objectContaining(options) ) expect(run).toHaveBeenCalledTimes(1) expect(run).toHaveBeenCalledWith( vastAdChain, placeholder, expect.objectContaining(options) ) }) }) describe('options.onError', () => { test('must be called if there is an error with the waterfall', async () => { const runError = new Error('Error running the ad') const requestError = new Error('Error with the request') const onError = jest.fn() const deferred = defer<void>() ;(run as jest.Mock).mockReturnValue(Promise.reject(runError)) ;(requestAd as jest.Mock).mockReturnValue(Promise.resolve(vastAdChain)) ;(requestNextAd as jest.Mock).mockReturnValueOnce( Promise.resolve(vastAdChain) ) ;(requestNextAd as jest.Mock).mockReturnValueOnce( Promise.reject(requestError) ) runWaterfall(adTag, placeholder, { onError, onAdReady: noop, onRunFinish: () => deferred.resolve() }) await deferred.promise expect(onError).toHaveBeenCalledTimes(3) expect(onError).toHaveBeenCalledWith(runError, { adUnit: undefined, vastChain: vastAdChain }) expect(onError).toHaveBeenCalledWith(requestError, { adUnit: undefined, vastChain: undefined }) expect(requestAd).toHaveBeenCalledTimes(1) expect(requestAd).toHaveBeenCalledWith(adTag, expect.any(Object)) expect(requestNextAd).toHaveBeenCalledTimes(2) expect(requestNextAd).toHaveBeenCalledWith( vastAdChain, expect.any(Object) ) expect(run).toHaveBeenCalledTimes(2) expect(run).toHaveBeenCalledWith( vastAdChain, placeholder, expect.any(Object) ) }) test('must be called if there is an error with the ad unit', async () => { ;(requestAd as jest.Mock).mockReturnValue(Promise.resolve(vastAdChain)) ;(run as jest.Mock).mockReturnValue(Promise.resolve(adUnit)) const deferred = defer<void>() const onError = jest.fn() adUnit.onError = jest.fn() runWaterfall(adTag, placeholder, { ...options, onAdStart: () => deferred.resolve(), onError }) await deferred.promise const simulateAdUnitError = (adUnit.onError as jest.Mock).mock.calls[0][0] const mockError = new Error('mock error') simulateAdUnitError(mockError, { adUnit, vastChain: vastAdChain }) expect(onError).toHaveBeenCalledTimes(1) expect(onError).toHaveBeenCalledWith(mockError, { adUnit, vastChain: adUnit.vastChain }) }) }) describe('options.onRunFinish', () => { test('must be called once the ad run finishes', async () => { ;(requestAd as jest.Mock).mockReturnValue(Promise.resolve(vastAdChain)) ;(run as jest.Mock).mockReturnValue(Promise.resolve(adUnit)) const deferred = defer<void>() const onRunFinish = jest.fn() runWaterfall(adTag, placeholder, { ...options, onAdStart: () => deferred.resolve(), onRunFinish }) await deferred.promise adUnit[_protected].finish() expect(onRunFinish).toHaveBeenCalledTimes(1) }) test('must be called if there is an error with the waterfall', async () => { const runError = new Error('Error running the ad') const requestError = new Error('Error with the request') const onRunFinish = jest.fn() const deferred = defer<void>() ;(run as jest.Mock).mockReturnValue(Promise.reject(runError)) ;(requestAd as jest.Mock).mockReturnValue(Promise.resolve(vastAdChain)) ;(requestNextAd as jest.Mock).mockReturnValueOnce( Promise.resolve(vastAdChain) ) ;(requestNextAd as jest.Mock).mockReturnValueOnce( Promise.reject(requestError) ) runWaterfall(adTag, placeholder, { onAdReady: noop, onRunFinish: () => { deferred.resolve() onRunFinish() } }) await deferred.promise expect(onRunFinish).toHaveBeenCalledTimes(1) }) }) describe('with timeout', () => { let origDateNow: typeof Date.now beforeEach(() => { origDateNow = Date.now Date.now = jest.fn() }) afterEach(() => { Date.now = origDateNow }) test('must update the timeout', async () => { const deferred = defer<void>() const testOptions = { timeout: 1000 } ;(Date.now as jest.Mock).mockReturnValueOnce(1000) ;(Date.now as jest.Mock).mockReturnValueOnce(1100) ;(run as jest.Mock).mockReturnValue(Promise.resolve(adUnit)) ;(requestAd as jest.Mock).mockReturnValue(Promise.resolve(vastAdChain)) runWaterfall(adTag, placeholder, { ...testOptions, onAdReady: noop, onAdStart: () => deferred.resolve() }) await deferred.promise expect(requestAd).toHaveBeenCalledTimes(1) expect(requestAd).toHaveBeenCalledWith( adTag, expect.objectContaining({ ...testOptions, timeout: 1000 }) ) expect(run).toHaveBeenCalledTimes(1) expect(run).toHaveBeenCalledWith( vastAdChain, placeholder, expect.objectContaining({ ...testOptions, timeout: 900 }) ) }) test('must update the timeout with each loop', async () => { const deferred = defer<void>() const runError = new Error('Error running the ad') const requestError = new Error('Error with the request') const testOptions = { timeout: 1000 } ;(Date.now as jest.Mock).mockReturnValueOnce(1000) ;(Date.now as jest.Mock).mockReturnValueOnce(1100) ;(Date.now as jest.Mock).mockReturnValueOnce(1200) ;(Date.now as jest.Mock).mockReturnValueOnce(1300) ;(Date.now as jest.Mock).mockReturnValueOnce(1400) ;(Date.now as jest.Mock).mockReturnValueOnce(1500) ;(Date.now as jest.Mock).mockReturnValueOnce(1600) ;(run as jest.Mock).mockReturnValue(Promise.reject(runError)) ;(requestAd as jest.Mock).mockReturnValue(Promise.resolve(vastAdChain)) ;(requestNextAd as jest.Mock).mockReturnValueOnce( Promise.resolve(vastAdChain) ) ;(requestNextAd as jest.Mock).mockReturnValueOnce( Promise.reject(requestError) ) runWaterfall(adTag, placeholder, { ...testOptions, onAdReady: noop, onRunFinish: () => deferred.resolve() }) await deferred.promise expect(requestAd).toHaveBeenCalledTimes(1) expect(requestNextAd).toHaveBeenCalledTimes(2) expect(run).toHaveBeenCalledTimes(2) expect(requestAd).toHaveBeenCalledWith( adTag, expect.objectContaining({ timeout: 1000 }) ) expect(run).toHaveBeenCalledWith( vastAdChain, placeholder, expect.objectContaining({ timeout: 900 }) ) expect(requestNextAd).toHaveBeenCalledWith( vastAdChain, expect.objectContaining({ timeout: 800 }) ) expect(run).toHaveBeenCalledWith( vastAdChain, placeholder, expect.objectContaining({ timeout: 700 }) ) expect(requestNextAd).toHaveBeenCalledWith( vastAdChain, expect.objectContaining({ timeout: 600 }) ) }) it('must finish the ad run if it times out fetching an ad', async () => { const deferred = defer<void>() const testOptions = { timeout: 1000 } const onAdStart = jest.fn() const onError = jest.fn() ;(Date.now as jest.Mock).mockReturnValueOnce(1000) ;(Date.now as jest.Mock).mockReturnValueOnce(10000) runWaterfall(adTag, placeholder, { ...testOptions, onAdStart, onError, onAdReady: noop, onRunFinish: () => deferred.resolve() }) await deferred.promise expect(requestAd).toHaveBeenCalledTimes(1) expect(requestAd).toHaveBeenCalledWith( adTag, expect.objectContaining({ ...testOptions, timeout: 1000 }) ) expect(run).toHaveBeenCalledTimes(0) expect(onAdStart).toHaveBeenCalledTimes(0) expect(onError).toHaveBeenCalledTimes(1) }) test('must not continue the waterfall if ad run has timed out', async () => { const deferred = defer<void>() const testOptions = { timeout: 1000 } const onAdStart = jest.fn() const onError = jest.fn() ;(Date.now as jest.Mock).mockReturnValueOnce(1000) ;(Date.now as jest.Mock).mockReturnValueOnce(1100) ;(Date.now as jest.Mock).mockReturnValueOnce(2100) ;(run as jest.Mock).mockReturnValue( Promise.reject(new Error('Ad start timeout simulation')) ) ;(requestAd as jest.Mock).mockReturnValue(Promise.resolve(vastAdChain)) ;(requestNextAd as jest.Mock).mockReturnValueOnce( Promise.resolve(vastAdChain) ) runWaterfall(adTag, placeholder, { ...testOptions, onAdStart, onError, onAdReady: noop, onRunFinish: () => deferred.resolve() }) await deferred.promise expect(requestAd).toHaveBeenCalledTimes(1) expect(requestAd).toHaveBeenCalledWith( adTag, expect.objectContaining({ ...testOptions, timeout: 1000 }) ) expect(run).toHaveBeenCalledTimes(1) expect(onAdStart).toHaveBeenCalledTimes(0) expect(onError).toHaveBeenCalledTimes(1) }) }) describe('cancel fn', () => { test('if called while fetching the vast chain, must prevent the ad run', async () => { ;(requestAd as jest.Mock).mockReturnValue(Promise.resolve(vastAdChain)) ;(run as jest.Mock).mockReturnValue(Promise.resolve(adUnit)) const deferred = defer<void>() const cancelWaterfall = runWaterfall(adTag, placeholder, { ...options, onAdReady: noop, onRunFinish: () => deferred.resolve() }) cancelWaterfall() await deferred.promise expect(requestAd).toHaveBeenCalledTimes(1) expect(run).not.toHaveBeenCalled() }) test('if called while starting the adUnit it must cancel the ad unit and call onRunFinish', async () => { let cancelWaterfall: any jest.spyOn(adUnit, 'cancel') ;(requestAd as jest.Mock).mockReturnValue(Promise.resolve(vastAdChain)) ;(run as jest.Mock).mockImplementation(() => { cancelWaterfall() return Promise.resolve(adUnit) }) const deferred = defer<void>() const onAdStart = jest.fn() cancelWaterfall = runWaterfall(adTag, placeholder, { ...options, onAdStart, onRunFinish: () => deferred.resolve() }) await deferred.promise expect(requestAd).toHaveBeenCalledTimes(1) expect(run).toHaveBeenCalledTimes(1) expect(onAdStart).not.toHaveBeenCalled() expect(adUnit.cancel).toHaveBeenCalledTimes(1) }) test('if called after an ad unit started it must cancel the ad unit.', async () => { const deferred = defer<void>() jest.spyOn(adUnit, 'cancel') ;(requestAd as jest.Mock).mockReturnValue(Promise.resolve(vastAdChain)) ;(run as jest.Mock).mockReturnValue(Promise.resolve(adUnit)) const cancelWaterfall = runWaterfall(adTag, placeholder, { ...options, onAdStart: () => deferred.resolve() }) await deferred.promise expect(adUnit.cancel).not.toHaveBeenCalled() cancelWaterfall() expect(adUnit.cancel).toHaveBeenCalledTimes(1) }) test('if called after the ad run finished, it must do nothing', async () => { ;(requestAd as jest.Mock).mockReturnValue(Promise.resolve(vastAdChain)) ;(run as jest.Mock).mockReturnValue(Promise.resolve(adUnit)) jest.spyOn(adUnit, 'cancel') const deferred = defer<void>() const onRunFinish = jest.fn() const cancelWaterfall = runWaterfall(adTag, placeholder, { ...options, onAdStart: () => deferred.resolve(), onRunFinish }) await deferred.promise adUnit[_protected].finish() expect(adUnit.cancel).not.toHaveBeenCalled() cancelWaterfall() expect(adUnit.cancel).toHaveBeenCalledTimes(0) }) }) })