diff --git a/CHANGELOG.md b/CHANGELOG.md index 4af89c0..01abbbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## v1.2 (March 16, 2019) + +- Support for all Tracking directives of the form: `Tracking event="progress"` + ## v1.1 (March 3, 2019) - Preparing for open sourcing, adding documentation diff --git a/jest.config.js b/jest.config.js index 810103f..2d10a9b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,4 +2,6 @@ module.exports = { reporters: ['default', 'jest-junit'], testEnvironment: 'jsdom', testPathIgnorePatterns: ['/build/', '/node_modules/', '/test/cypress/'], + coveragePathIgnorePatterns: ['/node_modules/', '/test/cypress/', '/src/lib/applications/'], + coverageReporters: ['text-summary', 'lcov'], }; diff --git a/package.json b/package.json index 7ee232c..c513351 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "concert-vast", - "version": "1.1.0", + "version": "1.2.0", "description": "Simple Vast Parsing for Concert Video Ads", "main": "src/index.js", "author": "Vox Media", diff --git a/src/lib/applications/video_js.js b/src/lib/applications/video_js.js index 1cc6821..bfa3056 100644 --- a/src/lib/applications/video_js.js +++ b/src/lib/applications/video_js.js @@ -155,6 +155,13 @@ export default class VideoJs { } setupQuartileSupport() { + const events = this.vast.trackingEventNamesWithOffsetPercent(); + for (const name in events) { + this.quartileSupport.addEvent({ + name: name, + offset: events[name], + }); + } this.quartileSupport.onQuartileChange(quartile => { if (!this.vastPresented()) return; this.vast.addImpressionTrackingImagesFor(quartile); diff --git a/src/lib/node_value.js b/src/lib/node_value.js index 0e83e40..206c8fd 100644 --- a/src/lib/node_value.js +++ b/src/lib/node_value.js @@ -5,6 +5,7 @@ export default class NodeValue { * @param {DOM Element} el An elemenet with a single CDATA or TEXT entity */ static fromElement(el) { + if (!el) return null; const matchedItem = Array.from(el.childNodes).find(n => { return (n.nodeType == Node.TEXT_NODE || n.nodeType == Node.CDATA_SECTION_NODE) && !!n.nodeValue.trim(); }); diff --git a/src/lib/quartile_support.js b/src/lib/quartile_support.js index 401d1e7..95e86b3 100644 --- a/src/lib/quartile_support.js +++ b/src/lib/quartile_support.js @@ -7,13 +7,26 @@ const QUARTILES = [ ]; export default class QuartileSupport { + static quartiles() { + let quartiles = {}; + QUARTILES.forEach(quart => { + quartiles[quart[1]] = quart[0]; + }); + return quartiles; + } + constructor() { + this.quartiles = QUARTILES.slice(0); this.seenQuartiles = []; this.callbacks = []; this.currentTime = 0; this.duration = Infinity; } + addEvent({ name, offset }) { + this.quartiles.push([offset, name]); + } + setDuration(time) { if (time != 0) { this.duration = time; @@ -34,7 +47,7 @@ export default class QuartileSupport { checkForQuartileEvent() { const percentComplete = this.currentTime / this.duration; - const matchingQuartiles = QUARTILES.filter(quartile => { + const matchingQuartiles = this.quartiles.filter(quartile => { return quartile[0] < percentComplete; }); diff --git a/src/lib/timecodes.js b/src/lib/timecodes.js new file mode 100644 index 0000000..376d6f4 --- /dev/null +++ b/src/lib/timecodes.js @@ -0,0 +1,53 @@ +export default class Timecode { + /** + * Converts seconds to a three segment timecode hh:mm:ss + * Examples: 30 => "00:00:30" + * 90 => "00:01:30" + * + * @param {String|Number} maybeSeconds + * @returns {String} "hh:mm:ss" timecode + */ + static secondsToTimecode(maybeSeconds) { + const seconds = Number(maybeSeconds); + + if (isNaN(seconds) || maybeSeconds.toString().indexOf('%') != -1) { + return null; + } + + const date = new Date(null); + date.setSeconds(seconds); + return date.toISOString().substr(11, 8); + } + + /** + * Converts timecode hh:mm:ss to total seconds + * Examples: "00:00:30" => 30 + * "00:10:10" => 610 + * + * @param {String} timecode Timecode input + * @returns {Number} the number of seconds represented in the timecode + */ + static timecodeToSeconds(timecode) { + let parts = timecode.split(':'); + let seconds = 0; + let multiplier = 1; + + while (parts.length > 0) { + seconds += multiplier * parseInt(parts.pop(), 10); + multiplier *= 60; + } + return seconds; + } + + /** + * Will properly format the timecode into three segment parts + * Example: "01:10" => "00:01:10" + * + * @param {String} code input timecode + * @returns {String} three segment timecode hh:mm:ss + */ + static timecodeToTimecode(code) { + const result = this.secondsToTimecode(this.timecodeToSeconds(code)); + return result; + } +} diff --git a/src/lib/vast.js b/src/lib/vast.js index 3fa84d1..e7afb46 100644 --- a/src/lib/vast.js +++ b/src/lib/vast.js @@ -89,6 +89,14 @@ export default class Vast { return this.loadedElements['TrackingEvents'].trackingUrlsFor(eventName); } + trackingEventNamesWithOffsets() { + return this.loadedElements['TrackingEvents'].trackingEventNamesWithOffsets(); + } + + trackingEventNamesWithOffsetPercent() { + return this.loadedElements['TrackingEvents'].trackingEventNamesWithOffsetPercent(); + } + addImpressionTrackingImagesFor(eventName, doc = document) { return this.loadedElements['TrackingEvents'].addImpressionTrackingImagesFor(eventName, doc); } diff --git a/src/lib/vast_elements/tracking_events.js b/src/lib/vast_elements/tracking_events.js index c52b431..a4df21e 100644 --- a/src/lib/vast_elements/tracking_events.js +++ b/src/lib/vast_elements/tracking_events.js @@ -1,23 +1,101 @@ import VastElementBase from './vast_element_base'; +import Timecodes from '../timecodes'; import NodeValue from '../node_value'; +import QuartileSupport from '../quartile_support'; + +class TrackingEvent { + constructor({ eventName, url }) { + this.eventName = eventName; + this._url = url; + } + + matches(string) { + return this.eventName == string; + } + + name() { + return this.eventName; + } + + offsetInSeconds(duration) { + const quarts = QuartileSupport.quartiles(); + return quarts[this.eventName] * duration; + } + + offsetInPercent(duration) { + const quarts = QuartileSupport.quartiles(); + return quarts[this.eventName]; + } + + url() { + return this._url; + } +} + +class ProgressTrackingEvent { + constructor({ offset, url }) { + this.offset = offset; + this._url = url; + } + + matches(secondsOrTimeCodeOrPercent) { + return ( + this.offset == secondsOrTimeCodeOrPercent || + this.offset == Timecodes.secondsToTimecode(secondsOrTimeCodeOrPercent) || + this.offset == Timecodes.timecodeToTimecode(secondsOrTimeCodeOrPercent) + ); + } + + name() { + return this.offset; + } + + offsetInSeconds(duration) { + if (this.offset.indexOf('%') != -1) { + return duration * (Number(this.offset.replace('%', '')) / 100.0); + } else { + return Timecodes.timecodeToSeconds(this.offset); + } + } + + offsetInPercent(duration) { + return Math.min(1.0, this.offsetInSeconds(duration) / duration); + } + + url() { + return this._url; + } +} export default class TrackingEvents extends VastElementBase { setup() { - this.trackingUrls = []; + this.trackingEvents = []; + this.duration = undefined; } static selector() { - return 'Ad TrackingEvents Tracking'; + return 'Ad TrackingEvents Tracking, Ad Inline Creative Duration'; } onVastReady() { - this.trackingUrls = this.elements.map(el => { - return [el.getAttribute('event'), NodeValue.fromElement(el)]; - }); + const durationValue = NodeValue.fromElement(this.elements.find(el => el.nodeName == 'Duration')); + this.duration = Timecodes.timecodeToSeconds(durationValue || '0'); + + this.trackingEvents = this.elements + .filter(el => el.nodeName != 'Duration') + .map(el => { + const trackingEvent = el.hasAttribute('offset') ? ProgressTrackingEvent : TrackingEvent; + + return new trackingEvent({ + eventName: el.getAttribute('event'), + offset: el.getAttribute('offset'), + url: NodeValue.fromElement(el), + }); + }); } trackingUrlsFor(eventName) { - return this.trackingUrls.filter(t => t[0] == eventName).map(t => t[1]); + return this.trackingEvents.filter(t => t.matches(eventName)).map(t => t.url()); } addImpressionTrackingImagesFor(eventName, doc = document) { @@ -25,4 +103,24 @@ export default class TrackingEvents extends VastElementBase { this.addImpressionUrl(url, { doc: doc, name: eventName }); }); } + + trackingEventNamesWithOffsets() { + return this.trackingEvents.reduce((all, event) => { + const offsetSeconds = event.offsetInSeconds(this.duration); + if (offsetSeconds) { + all[event.name()] = offsetSeconds; + } + return all; + }, {}); + } + + trackingEventNamesWithOffsetPercent() { + return this.trackingEvents.reduce((all, event) => { + const offsetPercent = event.offsetInPercent(this.duration); + if (offsetPercent) { + all[event.name()] = offsetPercent; + } + return all; + }, {}); + } } diff --git a/test/fixtures/vast-progress.xml b/test/fixtures/vast-progress.xml index 6484cad..365dcd3 100644 --- a/test/fixtures/vast-progress.xml +++ b/test/fixtures/vast-progress.xml @@ -1,47 +1,52 @@ - - - - iabtechlab - iabtechlab video ad - - - - http://example.com/error - http://example.com/track/impression - - - - 00:00:16 - - http://example.com/tracking/start - http://example.com/tracking/firstQuartile - http://example.com/tracking/midpoint - http://example.com/tracking/thirdQuartile - http://example.com/tracking/complete - http://example.com/tracking/progress-10 - - - - - - - - - - - - - - - - - - - - - - - - - + + + + iabtechlab + iabtechlab video ad + + + + http://example.com/error + http://example.com/track/impression + + + + 00:00:16 + + http://example.com/tracking/start + http://example.com/tracking/firstQuartile + http://example.com/tracking/midpoint + http://example.com/tracking/thirdQuartile + http://example.com/tracking/complete + http://example.com/tracking/progress-10 + http://example.com/tracking/progress-140 + http://example.com/tracking/progress-15-percent + http://example.com/tracking/progress-50-percent + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/quartile_support.spec.js b/test/quartile_support.spec.js index 4ee1240..7450ff9 100644 --- a/test/quartile_support.spec.js +++ b/test/quartile_support.spec.js @@ -18,13 +18,37 @@ describe('Basic Quartile Functionality', () => { }); }); +describe('Quartile Support for other non-quartile events', () => { + let qs; + beforeEach(() => { + qs = new QuartileSupport(); + qs.setDuration(10); + }); + + it('should allow additional events to be registered at offsets', () => { + expect(typeof qs.addEvent).toBe('function'); + }); + + it('should accept a name and an offset in seconds', () => { + qs.addEvent({ name: 'progress-10', offset: 0.1 }); + + let lastQName; + qs.onQuartileChange(quartileName => { + lastQName = quartileName; + }); + + qs.setCurrentTime(1.5); + expect(lastQName).toBe('progress-10'); + }); +}); + describe('Callbacks for changes', () => { let qs; beforeEach(() => { qs = new QuartileSupport(); }); - it('should not set start on 0s', () => { + it('should not set start on 0s', () => { let lastQName = null; qs.onQuartileChange(quartileName => { lastQName = quartileName; diff --git a/test/timecodes.spec.js b/test/timecodes.spec.js new file mode 100644 index 0000000..247e98e --- /dev/null +++ b/test/timecodes.spec.js @@ -0,0 +1,18 @@ +import Timecodes from '../src/lib/timecodes'; + +describe('Timecode utility', () => { + it('should convert seconds to timecode', () => { + expect(Timecodes.secondsToTimecode(30)).toBe('00:00:30'); + expect(Timecodes.secondsToTimecode(90)).toBe('00:01:30'); + expect(Timecodes.secondsToTimecode('30')).toBe('00:00:30'); + }); + + it('should convert timecode to seconds', () => { + expect(Timecodes.timecodeToSeconds('00:00:30')).toBe(30); + expect(Timecodes.timecodeToSeconds('00:01:30')).toBe(90); + }); + + it('should convert partial timecode to full timecode', () => { + expect(Timecodes.timecodeToTimecode('00:30')).toBe('00:00:30'); + }); +}); diff --git a/test/vast_elements/tracking_events.spec.js b/test/vast_elements/tracking_events.spec.js index 12aada8..6121a0c 100644 --- a/test/vast_elements/tracking_events.spec.js +++ b/test/vast_elements/tracking_events.spec.js @@ -47,3 +47,56 @@ describe('Media Files extension', () => { }); }); }); + +describe('Progess with time codes', () => { + let xml; + let vast; + + beforeAll(async () => { + xml = fs.readFileSync('./test/fixtures/vast-progress.xml'); + vast = new Vast(); + await vast.useXmlString(xml); + }); + + it('should return progress percent urls', () => { + expect(vast.trackingUrlsFor('15%')).toContain('http://example.com/tracking/progress-15-percent'); + }); + + it('should return progress timecode urls', () => { + expect(vast.trackingUrlsFor('00:00:10')).toContain('http://example.com/tracking/progress-10'); + expect(vast.trackingUrlsFor('00:01:40')).toContain('http://example.com/tracking/progress-140'); + }); + + it('should return progress timecode urls no matter how they are formatted', () => { + expect(vast.trackingUrlsFor('10')).toContain('http://example.com/tracking/progress-10'); + expect(vast.trackingUrlsFor('00:10')).toContain('http://example.com/tracking/progress-10'); + expect(vast.trackingUrlsFor('100')).toContain('http://example.com/tracking/progress-140'); + }); +}); + +describe('Return Tracking event names', () => { + let xml; + let vast; + + beforeAll(async () => { + xml = fs.readFileSync('./test/fixtures/vast-progress.xml'); + vast = new Vast(); + await vast.useXmlString(xml); + }); + + it('should return tracking urls with names', () => { + const eventNamesOffsets = vast.trackingEventNamesWithOffsets(); + + expect(eventNamesOffsets['15%']).toBe(2.4); + expect(eventNamesOffsets['00:01:40']).toBe(100); + expect(eventNamesOffsets['thirdQuartile']).toBe(12); + }); + + it('should return tracking urls with names', () => { + const eventNamesOffsets = vast.trackingEventNamesWithOffsetPercent(); + + expect(eventNamesOffsets['15%']).toBe(0.15); + expect(eventNamesOffsets['00:01:40']).toBe(1.0); + expect(eventNamesOffsets['thirdQuartile']).toBe(0.75); + }); +});