UNPKG

@snowplow/javascript-tracker

Version:
560 lines (508 loc) 18.1 kB
/* * Copyright (c) 2022 Snowplow Analytics Ltd, 2010 Anthon Pang * All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: * * 1. Redistributions of source code must retain the above copyright notice, this * list of conditions and the following disclaimer. * * 2. Redistributions in binary form must reproduce the above copyright notice, * this list of conditions and the following disclaimer in the documentation * and/or other materials provided with the distribution. * * 3. Neither the name of the copyright holder nor the names of its * contributors may be used to endorse or promote products derived from * this software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ import _ from 'lodash'; import { DockerWrapper, start, stop, fetchResults, clearCache } from '../micro'; import { waitUntil } from './helpers'; const playVideoElement1Callback = () => { return (done: (_: void) => void) => { const promise = (document.getElementById('html5') as HTMLVideoElement).play(); if (promise) { promise.then(done); } else { done(); } // IE does not return a promise }; }; const playVideoElement2Callback = () => { return (done: (_: void) => void) => { const promise = (document.getElementById('html5-2') as HTMLVideoElement).play(); if (promise) { promise.then(done); } else { done(); } // IE does not return a promise }; }; const makeExpectedEvent = ( eventType: string, values: { mediaPlayer?: any; mediaElement?: any; videoElement?: any }, htmlId = 'html5' ) => { const data = { context: [ { schema: 'iglu:org.whatwg/media_element/jsonschema/1-0-0', data: { htmlId: htmlId, mediaType: 'VIDEO', autoPlay: false, buffered: jasmine.any(Array), controls: true, currentSrc: jasmine.stringMatching( /http\:\/\/snowplow-js-tracker\.local\:8080\/media\/(test-video\.mp4|not-a-video\.unsupported_format)/ ), defaultMuted: true, defaultPlaybackRate: 1, disableRemotePlayback: undefined, error: null, networkState: jasmine.stringMatching(/NETWORK_(EMPTY|IDLE|LOADING|NO_SOURCE)/), preload: jasmine.stringMatching(/auto|metadata|none||/), readyState: jasmine.stringMatching(/HAVE_(NOTHING|METADATA|CURRENT_DATA|FUTURE_DATA|ENOUGH_DATA)/), seekable: jasmine.any(Array), seeking: false, src: jasmine.stringMatching( /http\:\/\/snowplow-js-tracker\.local\:8080\/media\/(test-video\.mp4|not-a-video\.unsupported_format)/ ), textTracks: [ { label: 'English', language: 'en', kind: 'captions', mode: jasmine.stringMatching(/showing|hidden|disabled|/), }, ], fileExtension: jasmine.stringMatching(/mp4|unsupported_format/), fullscreen: false, pictureInPicture: false, ...values.mediaElement, }, }, { schema: 'iglu:com.snowplowanalytics.snowplow/media_player/jsonschema/1-0-0', data: { currentTime: jasmine.any(Number), duration: 20, ended: false, loop: false, muted: true, paused: false, playbackRate: 1, volume: 100, ...values.mediaPlayer, }, }, { schema: 'iglu:org.whatwg/video_element/jsonschema/1-0-0', data: { autoPictureInPicture: undefined, disablePictureInPicture: undefined, poster: '', videoHeight: 144, videoWidth: 176, ...values.videoElement, }, }, ], unstruct_event: { schema: 'iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0', data: { schema: 'iglu:com.snowplowanalytics.snowplow/media_player_event/jsonschema/1-0-0', data: { type: eventType, label: 'test-label' }, }, }, }; if (browser.capabilities.browserName === 'internet explorer') { data.context[0].data.defaultMuted = false; } return data; }; const compare = (expected: any, received: any) => { if (received === undefined) { expect(received).toBeDefined(); return; } for (let i = 0; i < expected.context.length; i++) { expect(expected.context[i].schema).toEqual(received.event.contexts.data[i].schema); Object.keys(expected.context[i].data).forEach((key) => { // jasmine doesn't have an 'or' matcher and the error state of 'loadstart' is very inconsistent // so we put the potential values in an array and check if the received value is in the array // However, we want to check the 'textTracks' property with toEqual, so that needs to be excluded if ( expected.unstruct_event.data.data.type === 'loadstart' && Array.isArray(expected.context[i].data[key]) && key !== 'textTracks' ) { expect(expected.context[i].data[key]).toContain(received.event.contexts.data[i].data[key]); } else { expect(expected.context[i].data[key]).toEqual(received.event.contexts.data[i].data[key]); } }); } expect(expected.unstruct_event).toEqual(received.event.unstruct_event); }; let docker: DockerWrapper; let log: Array<unknown> = []; describe('Media Tracker', () => { const getFirstEventOfEventType = (eventType: string) => { for (let i = log.length - 1; i >= 0; i--) { if ((log[i] as any).event.unstruct_event.data.data.type === eventType) { return log[i]; } } }; if ( browser.capabilities.browserName === 'internet explorer' && (browser.capabilities.version === '9' || browser.capabilities.browserVersion === '10') ) { fit('Skip IE 9 and 10', () => true); return; } if (browser.capabilities.browserName === 'safari' && browser.capabilities.version === '8.0') { fit('Skip Safari 8', () => true); return; } beforeAll(async () => { await browser.call(async () => (docker = await start())); await browser.url('/index.html'); await browser.setCookies({ name: 'container', value: docker.url }); await browser.url('media/tracking.html'); await waitUntil(browser, () => $('#html5').isExisting(), { timeout: 10000, timeoutMsg: 'expected html5 after 5s', }); let actions = [ playVideoElement1Callback(), (done: () => void) => { (document.getElementById('html5') as HTMLVideoElement).pause(); done(); }, (done: () => void) => { (document.getElementById('html5') as HTMLVideoElement).volume = 0.5; done(); }, (done: () => void) => { (document.getElementById('html5') as HTMLVideoElement).playbackRate = 0.9; done(); }, (done: () => void) => { (document.getElementById('html5') as HTMLVideoElement).currentTime = 18; done(); }, playVideoElement1Callback(), ]; for (const a of actions) { await browser.executeAsync(a); await browser.pause(500); } // 'ended' should be the final event, if not, try again await waitUntil( browser, async () => { return await browser.call(async () => { let log = await fetchResults(docker.url); return log.some((l: any) => l.event.unstruct_event.data.data.type === 'ended'); }); }, { interval: 2000, timeout: 60000, timeoutMsg: 'All events not found before timeout', } ); log = await browser.call(async () => await fetchResults(docker.url)); }); afterAll(async () => { await waitUntil(browser, async () => { return await browser.call(async () => await clearCache(docker.url)); }); }); const expected = { ready: { mediaPlayer: { paused: true, duration: jasmine.any(Number) }, videoElement: { videoHeight: jasmine.any(Number), videoWidth: jasmine.any(Number) }, }, play: {}, pause: { mediaPlayer: { paused: true } }, volumechange: { mediaPlayer: { paused: true, volume: 50 } }, ratechange: { mediaPlayer: { paused: true, volume: 50, playbackRate: 0.9 } }, seeked: { mediaPlayer: { paused: true, volume: 50, playbackRate: 0.9 } }, percentprogress: { mediaPlayer: { volume: 50, playbackRate: jasmine.any(Number) } }, ended: { mediaPlayer: { volume: 50, playbackRate: jasmine.any(Number), paused: jasmine.any(Boolean), ended: true }, }, }; Object.entries(expected).forEach(([name, properties]) => { it('tracks ' + name, () => { const expected = makeExpectedEvent(name, properties); const received = getFirstEventOfEventType(name); compare(expected, received); }); }); }); describe('Media Tracker (2 videos, 1 tracker)', () => { beforeAll(async () => { await browser.url('/media/tracking-2-players.html'); await waitUntil(browser, () => $('#html5').isExisting(), { timeout: 10000, timeoutMsg: 'expected html5 after 5s', }); let actions = [ playVideoElement1Callback(), playVideoElement2Callback(), (done: () => void) => { (document.getElementById('html5') as HTMLVideoElement).pause(); done(); }, (done: () => void) => { (document.getElementById('html5-2') as HTMLVideoElement).pause(); done(); }, ]; for (const a of actions) { await browser.executeAsync(a); await browser.pause(200); } // wait until we have 2 'pause' events await waitUntil( browser, async () => { return await browser.call(async () => { let log = await fetchResults(docker.url); return log.filter((l: any) => l.event.unstruct_event.data.data.type === 'pause').length === 2; }); }, { interval: 2000, timeout: 60000, timeoutMsg: 'All events not found before timeout', } ); log = await browser.call(async () => await fetchResults(docker.url)); }); afterAll(async () => { await waitUntil(browser, async () => { return await browser.call(async () => await clearCache(docker.url)); }); }); const getFirstEventOfEventTypeWithId = (eventType: string, id: string) => { let results = log.filter( (l: any) => l.event.unstruct_event.data.data.type === eventType && l.event.contexts.data[0].data.htmlId === id ); return results[results.length - 1]; }; it('tracks two players with a single tracker', () => { const expectedOne = makeExpectedEvent('pause', { mediaPlayer: { paused: true } }); const recievedOne = getFirstEventOfEventTypeWithId('pause', 'html5'); compare(expectedOne, recievedOne); const expectedTwo = makeExpectedEvent('pause', { mediaPlayer: { paused: true } }, 'html5-2'); const recievedTwo = getFirstEventOfEventTypeWithId('pause', 'html5-2'); compare(expectedTwo, recievedTwo); }); }); describe('Media Tracker (1 video, 2 trackers)', () => { beforeAll(async () => { await browser.url('media/tracking-2-trackers.html'); await waitUntil(browser, () => $('#html5').isExisting(), { timeout: 10000, timeoutMsg: 'expected html5 after 5s', }); await browser.executeAsync(playVideoElement1Callback()); await browser.pause(200); await browser.execute(() => (document.getElementById('html5') as HTMLVideoElement).pause()); // wait until we have 2 'pause' events await waitUntil( browser, async () => { return await browser.call(async () => { let log = await fetchResults(docker.url); return log.filter((l: any) => l.event.unstruct_event.data.data.type === 'pause').length === 2; }); }, { interval: 2000, timeout: 60000, timeoutMsg: 'All events not found before timeout', } ); log = await browser.call(async () => await fetchResults(docker.url)); }); afterAll(async () => { await waitUntil(browser, async () => { return await browser.call(async () => await clearCache(docker.url)); }); }); const getEventsOfEventType = (eventType: string, limit: number = 1): Array<any> => { const results = log.filter((l: any) => l.event.unstruct_event.data.data.type === eventType); return results.slice(results.length - limit); }; it('tracks one player with two trackers', () => { const expected = makeExpectedEvent('pause', { mediaPlayer: { paused: true } }); const result = getEventsOfEventType('pause', 2); compare(expected, result[0]); compare(expected, result[1]); const tracker_names = result.map((r: any) => r.event.name_tracker); expect(tracker_names).toContain('sp1'); expect(tracker_names).toContain('sp2'); }); }); describe('Media Tracker - All Events', () => { beforeAll(async () => { await browser.url('media/tracking-all-events.html'); await waitUntil(browser, () => $('#html5').isExisting(), { timeout: 10000, timeoutMsg: 'expected html5 after 5s', }); const video_url = await browser.execute(() => { return (document.getElementById('html5') as HTMLVideoElement).src; }); let actions = [ playVideoElement1Callback(), (done: () => void) => { (document.getElementById('html5') as HTMLVideoElement).pause(); done(); }, (done: () => void) => { (document.getElementById('html5') as HTMLVideoElement).currentTime = 18; done(); }, playVideoElement1Callback(), (done: () => void) => { (document.getElementById('html5') as HTMLVideoElement).textTracks[0].mode = 'disabled'; done(); }, (done: () => void) => { (document.getElementById('html5') as HTMLVideoElement).src = 'not-a-video.unsupported_format'; done(); }, ]; for (const a of actions) { await browser.executeAsync(a); await browser.pause(1000); } await browser.execute((video_url) => { (document.getElementById('html5') as HTMLVideoElement).src = video_url; }, video_url); await waitUntil( browser, async () => { return await browser.call(async () => { let log = await fetchResults(docker.url); // Events can occur in any order, so we can't just check for the last event // 40 - 45 events are expected, due to the timing of 'timeupdate' events return log.length > 40; }); }, { interval: 2000, timeout: 60000, timeoutMsg: 'All events not found before timeout', } ); log = await browser.call(async () => await fetchResults(docker.url)); }); afterAll(async () => { await browser.call(async () => await stop(docker.container)); }); const getFirstEventOfEventType = (eventType: string) => { let results = log.filter((l: any) => l.event.unstruct_event.data.data.type === eventType); return results[results.length - 1]; }; const expected = { canplay: { mediaPlayer: { paused: true, }, }, canplaythrough: { mediaPlayer: { paused: true, }, }, timeupdate: {}, playing: {}, seeking: { mediaElement: { seeking: true, }, mediaPlayer: { paused: true, }, }, error: { mediaElement: { networkState: 'NETWORK_NO_SOURCE', readyState: 'HAVE_NOTHING', currentSrc: 'http://snowplow-js-tracker.local:8080/media/not-a-video.unsupported_format', src: 'http://snowplow-js-tracker.local:8080/media/not-a-video.unsupported_format', error: jasmine.any(Object), }, mediaPlayer: { duration: 0, paused: true, }, videoElement: { videoHeight: 0, videoWidth: 0, }, }, loadstart: { mediaElement: { error: [jasmine.any(Object), null], }, mediaPlayer: { currentTime: 0, paused: true, duration: 0, }, videoElement: { videoHeight: 0, videoWidth: 0, }, }, loadedmetadata: { mediaPlayer: { paused: true, }, }, durationchange: { mediaPlayer: { paused: true, }, }, }; Object.entries(expected).forEach(([name, properties]) => { // I can't find a good way of triggering an error event for firefox 53 or chrome 60, so they can // be skipped for now (events past 'error' are fired as a result of the error occouring) if ( (name === 'error' && browser.capabilities.browserName === 'firefox' && browser.capabilities.browserVersion === '53.0') || (browser.capabilities.browserName === 'chrome' && browser.capabilities.version === '60.0.3112.78') ) { fit('Skip events from error', () => true); return; } else { it('tracks ' + name, () => { const expected = makeExpectedEvent(name, properties); const received = getFirstEventOfEventType(name); compare(expected, received); }); } }); });