UNPKG

videojs-contrib-eme

Version:

Supports Encrypted Media Extensions for playback of encrypted content in Video.js

895 lines (763 loc) 26.9 kB
import document from 'global/document'; import QUnit from 'qunit'; import sinon from 'sinon'; import videojs from 'video.js'; import window from 'global/window'; import { default as plugin, hasSession, setupSessions, handleEncryptedEvent, handleMsNeedKeyEvent, handleWebKitNeedKeyEvent, getOptions, removeSession, emeErrorHandler } from '../src/plugin'; import { getMockEventBus } from './utils'; const Player = videojs.getComponent('Player'); function noop() {} QUnit.test('the environment is sane', function(assert) { assert.strictEqual(typeof Array.isArray, 'function', 'es5 exists'); assert.strictEqual(typeof sinon, 'object', 'sinon exists'); assert.strictEqual(typeof videojs, 'function', 'videojs exists'); assert.strictEqual(typeof plugin, 'function', 'plugin is a function'); }); QUnit.module('videojs-contrib-eme', { beforeEach() { // Mock the environment's timers because certain things - particularly // player readiness - are asynchronous in video.js 5. This MUST come // before any player is created; otherwise, timers could get created // with the actual timer methods! this.clock = sinon.useFakeTimers(); this.fixture = document.getElementById('qunit-fixture'); this.video = document.createElement('video'); this.fixture.appendChild(this.video); this.player = videojs(this.video); this.origRequestMediaKeySystemAccess = window.navigator.requestMediaKeySystemAccess; window.navigator.requestMediaKeySystemAccess = (keySystem, options) => { return Promise.resolve({ keySystem: 'org.w3.clearkey', createMediaKeys: () => { return { createSession: () => new videojs.EventTarget() }; } }); }; }, afterEach() { window.navigator.requestMediaKeySystemAccess = this.origRequestMediaKeySystemAccess; this.clock.restore(); } }); QUnit.test('registers itself with video.js', function(assert) { assert.strictEqual( typeof Player.prototype.eme, 'function', 'videojs-contrib-eme plugin was registered' ); }); QUnit.test('exposes options', function(assert) { assert.notOk(this.player.eme.options, 'options is unavailable at start'); this.player.eme(); assert.deepEqual( this.player.eme.options, {}, 'options defaults to empty object once initialized' ); this.video = document.createElement('video'); this.video.setAttribute('data-setup', JSON.stringify({ plugins: { eme: { applicationId: 'application-id', publisherId: 'publisher-id' } } })); this.fixture.appendChild(this.video); this.player = videojs(this.video); assert.ok(this.player.eme.options, 'exposes options'); assert.strictEqual( this.player.eme.options.applicationId, 'application-id', 'exposes applicationId' ); assert.strictEqual( this.player.eme.options.publisherId, 'publisher-id', 'exposes publisherId' ); }); QUnit.test('exposes detectSupportedCDMs()', function(assert) { assert.notOk(this.player.eme.detectSupportedCDMs, 'detectSupportedCDMs is unavailable at start'); this.player.eme(); assert.ok(this.player.eme.detectSupportedCDMs, 'detectSupportedCDMs is available after initialization'); }); // skip test for prefix-only Safari if (!window.MediaKeys) { QUnit.test('initializeMediaKeys standard', function(assert) { assert.expect(9); const done = assert.async(); const initData = new Uint8Array([1, 2, 3]).buffer; let errors = 0; const options = { keySystems: { 'org.w3.clearkey': { pssh: initData } } }; const callback = (error) => { const sessions = this.player.eme.sessions; assert.equal(sessions.length, 1, 'created a session when keySystems in options'); assert.deepEqual(sessions[0].initData, initData, 'captured initData in the session'); assert.equal( error, 'Error: Missing url/licenseUri or getLicense in com.widevine.alpha configuration.', 'callback receives error' ); }; this.player.eme(); this.player.on('error', () => { errors++; assert.equal(errors, 1, 'error triggered only once'); assert.equal( this.player.error().message, 'Missing url/licenseUri or getLicense in com.widevine.alpha configuration.', 'error is called on player' ); this.player.error(null); }); this.player.eme.initializeMediaKeys(options, callback); // need to clear sessions to have the error trigger again this.player.eme.sessions = []; this.player.eme.initializeMediaKeys(options, callback, true); setTimeout(() => { assert.equal( this.player.error(), null, 'no error called on player with suppressError = true' ); done(); }); this.clock.tick(1); }); } QUnit.test.skip('initializeMediaKeys ms-prefix', function(assert) { assert.expect(19); const done = assert.async(); // stub setMediaKeys const setMediaKeys = this.player.tech_.el_.setMediaKeys; let throwError = true; let errors = 0; let keySession; let errorMessage; const origMediaKeys = window.MediaKeys; const origWebKitMediaKeys = window.WebKitMediaKeys; window.MediaKeys = undefined; window.WebKitMediaKeys = undefined; if (!window.MSMediaKeys) { window.MSMediaKeys = function() {}; } this.player.tech_.el_.setMediaKeys = null; if (!this.player.tech_.el_.msSetMediaKeys) { this.player.tech_.el_.msSetMediaKeys = () => { this.player.tech_.el_.msKeys = { createSession: () => { if (throwError) { throw new Error('error creating keySession'); } else { keySession = new videojs.EventTarget(); return keySession; } } }; }; } const initData = new Uint8Array([1, 2, 3]).buffer; const options = { keySystems: { 'com.microsoft.playready': { pssh: initData } } }; const callback = (error) => { const sessions = this.player.eme.sessions; assert.equal(sessions.length, 1, 'created a session when keySystems in options'); assert.deepEqual(sessions[0].initData, initData, 'captured initData in the session'); assert.notEqual(error, undefined, 'callback receives error'); }; const reset = () => { this.player.eme.sessions = []; keySession = null; }; const asyncKeySessionError = () => { if (keySession) { // we stubbed the keySession setTimeout(() => { keySession.error = {code: 1, systemCode: 2}; keySession.trigger({ target: keySession, type: 'mskeyerror' }); }); this.clock.tick(1); } }; this.player.eme(); this.player.on('error', () => { errors++; assert.equal( this.player.error().message, errorMessage, 'error is called on player' ); this.player.error(null); }); // sync error thrown by handleMsNeedKeyEvent errorMessage = 'error creating keySession'; this.player.eme.initializeMediaKeys(options, callback); reset(); this.player.eme.initializeMediaKeys(options, callback, true); reset(); // async error event on key session throwError = false; errorMessage = 'Unexpected key error from key session with code: 1 and systemCode: 2'; this.player.eme.initializeMediaKeys(options, callback); asyncKeySessionError(); reset(); this.player.eme.initializeMediaKeys(options, callback, true); asyncKeySessionError(); reset(); setTimeout(() => { // `error` will be called on the player 3 times, because a key session // error can't be suppressed on IE11 assert.equal(errors, 5, 'error called on player 3 times'); assert.equal( this.player.error(), null, 'no error called on player with suppressError = true' ); window.MediaKeys = origMediaKeys; window.WebKitMediaKeys = origWebKitMediaKeys; done(); }); this.clock.tick(1); this.player.tech_.el_.msSetMediaKeys = null; this.player.tech_.el_.setMediaKeys = setMediaKeys; }); QUnit.test('tech error listener is removed on dispose', function(assert) { const done = assert.async(1); let called = 0; const origMediaKeys = window.MediaKeys; const origWebKitMediaKeys = window.WebKitMediaKeys; window.MediaKeys = undefined; window.WebKitMediaKeys = undefined; if (!window.MSMediaKeys) { window.MSMediaKeys = noop.bind(this); } this.player.error = (error) => { assert.equal(error.originalError.type, 'mskeyerror', 'is expected error type'); called++; }; this.player.eme(); this.player.ready(() => { assert.equal(called, 0, 'never called'); this.player.tech_.trigger('mskeyerror'); assert.equal(called, 1, 'called once'); this.player.dispose(); this.player.tech_.trigger('mskeyerror'); assert.equal(called, 1, 'not called after player disposal'); this.player.error = undefined; window.MediaKeys = origMediaKeys; window.WebKitMediaKeys = origWebKitMediaKeys; done(); }); this.clock.tick(1); }); QUnit.test('only registers for spec-compliant events even if legacy APIs are available', function(assert) { const done = assert.async(1); const origMediaKeys = window.MediaKeys; const origMSMediaKeys = window.MSMediaKeys; const origWebKitMediaKeys = window.WebKitMediaKeys; const events = { encrypted: 0, msneedkey: 0, webkitneedkey: 0 }; this.player.tech_.el_ = { addEventListener: e => events[e]++, hasAttribute: () => false }; window.MediaKeys = noop; window.MSMediaKeys = noop; window.WebKitMediaKeys = noop; this.player.eme(); this.player.ready(() => { assert.equal(events.encrypted, 1, 'registers for encrypted events'); assert.equal(events.msneedkey, 0, "doesn't register for msneedkey events"); assert.equal(events.webkitneedkey, 0, "doesn't register for webkitneedkey events"); window.MediaKeys = origMediaKeys; window.MSMediaKeys = origMSMediaKeys; window.WebKitMediaKeys = origWebKitMediaKeys; done(); }); this.clock.tick(1); }); QUnit.module('plugin guard functions', { beforeEach() { this.fixture = document.getElementById('qunit-fixture'); this.video = document.createElement('video'); this.fixture.appendChild(this.video); this.player = videojs(this.video); this.options = { keySystems: { 'org.w3.clearkey': {url: 'some-url'} } }; this.origXhr = videojs.xhr; videojs.xhr = (params, callback) => { return callback(null, {statusCode: 200}, new Uint8Array([0, 1, 2, 3]).buffer); }; this.initData1 = new Uint8Array([1, 2, 3]).buffer; this.initData2 = new Uint8Array([4, 5, 6]).buffer; this.event1 = { // mock video target to prevent errors since it's a pain to mock out the continuation // of functionality on a successful pass through of the guards target: {}, initData: this.initData1 }; this.event2 = { target: {}, initData: this.initData2 }; if (!window.MSMediaKeys) { window.MSMediaKeys = noop.bind(this); } if (!window.WebKitMediaKeys) { window.WebKitMediaKeys = noop.bind(this); } this.origRequestMediaKeySystemAccess = window.navigator.requestMediaKeySystemAccess; window.navigator.requestMediaKeySystemAccess = (keySystem, options) => { return Promise.resolve({ keySystem: 'org.w3.clearkey', createMediaKeys: () => { return { createSession: () => new videojs.EventTarget() }; } }); }; }, afterEach() { window.navigator.requestMediaKeySystemAccess = this.origRequestMediaKeySystemAccess; videojs.xhr = this.origXhr; } }); QUnit.test('handleEncryptedEvent checks for required options', function(assert) { const done = assert.async(); const sessions = []; handleEncryptedEvent(this.player, this.event1, {}, sessions).then(() => { assert.equal(sessions.length, 0, 'did not create a session when no options'); done(); }); }); QUnit.test('handleEncryptedEvent checks for legacy fairplay', function(assert) { const done = assert.async(); const sessions = []; const options = { keySystems: { 'com.apple.fps.1_0': {url: 'some-url'} } }; handleEncryptedEvent(this.player, this.event1, options, sessions).then(() => { assert.equal(sessions.length, 0, 'did not create a session when no options'); done(); }); }); QUnit.test('handleEncryptedEvent checks for required init data', function(assert) { const done = assert.async(); const sessions = []; handleEncryptedEvent(this.player, { target: {}, initData: null }, this.options, sessions).then(() => { assert.equal(sessions.length, 0, 'did not create a session when no init data'); done(); }); }); QUnit.test('handleEncryptedEvent creates session', function(assert) { const done = assert.async(); const sessions = []; // testing the rejection path because this isn't a real session handleEncryptedEvent(this.player, this.event1, this.options, sessions).catch(() => { assert.equal(sessions.length, 1, 'created a session when keySystems in options'); assert.equal(sessions[0].initData, this.initData1, 'captured initData in the session'); done(); }); }); QUnit.test('handleEncryptedEvent creates new session for new init data', function(assert) { const done = assert.async(); const sessions = []; // testing the rejection path because this isn't a real session handleEncryptedEvent(this.player, this.event1, this.options, sessions).catch(() => { return handleEncryptedEvent(this.player, this.event2, this.options, sessions).catch(() => { assert.equal(sessions.length, 2, 'created a new session when new init data'); assert.equal(sessions[0].initData, this.initData1, 'retained session init data'); assert.equal(sessions[1].initData, this.initData2, 'added new session init data'); done(); }); }); }); QUnit.test('handleEncryptedEvent doesn\'t create duplicate sessions', function(assert) { const done = assert.async(); const sessions = []; // testing the rejection path because this isn't a real session handleEncryptedEvent(this.player, this.event1, this.options, sessions).catch(() => { return handleEncryptedEvent(this.player, this.event2, this.options, sessions).catch(() => { return handleEncryptedEvent(this.player, this.event2, this.options, sessions).then(() => { assert.equal(sessions.length, 2, 'no new session when same init data'); assert.equal(sessions[0].initData, this.initData1, 'retained session init data'); assert.equal(sessions[1].initData, this.initData2, 'retained session init data'); done(); }); }); }); }); QUnit.test('handleEncryptedEvent uses predefined init data', function(assert) { const done = assert.async(); const options = { keySystems: { 'org.w3.clearkey': { pssh: this.initData1 } } }; const sessions = []; // testing the rejection path because this isn't a real session handleEncryptedEvent(this.player, this.event2, options, sessions).catch(() => { assert.equal(sessions.length, 1, 'created a session when keySystems in options'); assert.deepEqual(sessions[0].initData, this.initData1, 'captured initData in the session'); done(); }); }); QUnit.test('handleMsNeedKeyEvent uses predefined init data', function(assert) { const options = { keySystems: { 'com.microsoft.playready': { pssh: this.initData1 } } }; const sessions = []; this.event2.target = { msSetMediaKeys: () => { this.event2.target.msKeys = { createSession: () => new videojs.EventTarget() }; } }; handleMsNeedKeyEvent(this.event2, options, sessions, getMockEventBus()); assert.equal(sessions.length, 1, 'created a session when keySystems in options'); assert.deepEqual(sessions[0].initData, this.initData1, 'captured initData in the session'); this.event2.target = {}; }); QUnit.test('handleMsNeedKeyEvent checks for required options', function(assert) { const event = { initData: new Uint8Array([1, 2, 3]), // mock video target to prevent errors since it's a pain to mock out the continuation // of functionality on a successful pass through of the guards target: { msSetMediaKeys() { this.msKeys = { createSession: () => new videojs.EventTarget() }; } } }; let options = {}; const sessions = []; const mockEventBus = getMockEventBus(); handleMsNeedKeyEvent(event, options, sessions, mockEventBus); assert.equal(sessions.length, 0, 'no session created when no options'); options = { keySystems: {} }; handleMsNeedKeyEvent(event, options, sessions, mockEventBus); assert.equal(sessions.length, 0, 'no session created when no PlayReady key system'); options = { keySystems: { 'com.microsoft.notplayready': true } }; handleMsNeedKeyEvent(event, options, sessions, mockEventBus); assert.equal( sessions.length, 0, 'no session created when no proper PlayReady key system' ); options = { keySystems: { 'com.microsoft.playready': true } }; handleMsNeedKeyEvent(event, options, sessions, mockEventBus); assert.equal(sessions.length, 1, 'session created'); assert.ok(sessions[0].playready, 'created a PlayReady session'); const createdSession = sessions[0]; // even when there's new init data, we should not create a new session event.initData = new Uint8Array([4, 5, 6]); handleMsNeedKeyEvent(event, options, sessions, mockEventBus); assert.equal(sessions.length, 1, 'no new session created'); assert.equal(sessions[0], createdSession, 'did not replace session'); }); QUnit.test('handleMsNeedKeyEvent checks for required init data', function(assert) { const event = { // mock video target to prevent errors since it's a pain to mock out the continuation // of functionality on a successful pass through of the guards target: {}, initData: null }; const options = { keySystems: { 'com.microsoft.playready': true } }; const sessions = []; handleMsNeedKeyEvent(event, options, sessions, getMockEventBus()); assert.equal(sessions.length, 0, 'no session created when no init data'); }); QUnit.test('handleWebKitNeedKeyEvent checks for required options', function(assert) { const event = { initData: new Uint8Array([1, 2, 3, 4]), target: {webkitSetMediaKeys: noop} }; const done = assert.async(4); let options = {}; handleWebKitNeedKeyEvent(event, options).then((val) => { assert.equal(val, undefined, 'resolves an empty promise when no options'); done(); }); options = { keySystems: {} }; handleWebKitNeedKeyEvent(event, options, {}, () => {}).then((val) => { assert.equal( val, undefined, 'resolves an empty promise when no FairPlay key system' ); done(); }); options = { keySystems: { 'com.apple.notfps.1_0': {} } }; handleWebKitNeedKeyEvent(event, options, {}, () => {}).then((val) => { assert.equal( val, undefined, 'resolves an empty promise when no proper FairPlay key system' ); done(); }); options = { keySystems: { 'com.apple.fps.1_0': {} } }; const promise = handleWebKitNeedKeyEvent(event, options, {}, () => {}); promise.catch((err) => { assert.equal( err, 'Could not create key session', 'expected error message' ); done(); }); assert.ok(promise, 'returns promise when proper FairPlay key system'); }); QUnit.test('handleWebKitNeedKeyEvent checks for required init data', function(assert) { const done = assert.async(); const event = { initData: null }; const options = { keySystems: { 'com.apple.fps.1_0': {} } }; handleWebKitNeedKeyEvent(event, options).then((val) => { assert.equal(val, undefined, 'resolves an empty promise when no init data'); done(); }); }); QUnit.module('plugin isolated functions'); QUnit.test('hasSession determines if a session exists', function(assert) { // cases in spec (where initData should always be an ArrayBuffer) const initData = new Uint8Array([1, 2, 3]).buffer; assert.notOk(hasSession([], initData), 'false when no sessions'); assert.ok( hasSession([{ initData }], initData), 'true when initData is present in a session' ); assert.ok( hasSession([ {}, { initData: new Uint8Array([1, 2, 3]).buffer } ], initData), 'true when same initData contents present in a session' ); assert.notOk( hasSession([{ initData: new Uint8Array([1, 2]).buffer }], initData), 'false when initData contents not present in a session' ); // cases outside of spec (where initData is not always an ArrayBuffer) assert.ok( hasSession([{ initData: new Uint8Array([1, 2, 3]) }], initData), 'true even if session initData is a typed array and initData is an ArrayBuffer' ); assert.ok( hasSession( [{ initData: new Uint8Array([1, 2, 3]).buffer }], new Uint8Array([1, 2, 3]) ), 'true even if session initData is an ArrayBuffer and initData is a typed array' ); assert.ok( hasSession([{ initData: new Uint8Array([1, 2, 3]) }], new Uint8Array([1, 2, 3])), 'true even if both session initData and initData are typed arrays' ); }); QUnit.test('setupSessions sets up sessions for new sources', function(assert) { // mock the player with an eme plugin object attached to it let src = 'some-src'; const player = { eme: {}, src: () => src }; setupSessions(player); assert.ok( Array.isArray(player.eme.sessions), 'creates a sessions array when none exist' ); assert.equal(player.eme.sessions.length, 0, 'sessions array is empty'); assert.equal(player.eme.activeSrc, 'some-src', 'set activeSrc property'); setupSessions(player); assert.equal(player.eme.sessions.length, 0, 'sessions array is still empty'); assert.equal(player.eme.activeSrc, 'some-src', 'activeSrc property did not change'); player.eme.sessions.push({}); src = 'other-src'; setupSessions(player); assert.equal(player.eme.sessions.length, 0, 'sessions array reset'); assert.equal(player.eme.activeSrc, 'other-src', 'activeSrc property changed'); player.eme.sessions.push({}); setupSessions(player); assert.equal(player.eme.sessions.length, 1, 'sessions array unchanged'); assert.equal(player.eme.activeSrc, 'other-src', 'activeSrc property unchanged'); }); QUnit.test('getOptions prioritizes eme options over source options', function(assert) { const player = { eme: { options: { keySystems: { keySystem1: { audioContentType: 'audio-content-type', videoContentType: 'video-content-type' }, keySystem3: { licenseUrl: 'license-url-3' } }, extraOption: 'extra-option' } }, currentSource() { return { keySystems: { keySystem1: { licenseUrl: 'license-url-1', videoContentType: 'source-video-content-type' }, keySystem2: { licenseUrl: 'license-url-2' } }, type: 'application/dash+xml' }; } }; assert.deepEqual(getOptions(player), { keySystems: { keySystem1: { audioContentType: 'audio-content-type', videoContentType: 'video-content-type', licenseUrl: 'license-url-1' }, keySystem2: { licenseUrl: 'license-url-2' }, keySystem3: { licenseUrl: 'license-url-3' } }, type: 'application/dash+xml', extraOption: 'extra-option' }, 'updates source options with eme options'); }); QUnit.test('removeSession removes sessions', function(assert) { const initData1 = new Uint8Array([1, 2, 3]); const initData2 = new Uint8Array([2, 3, 4]); const initData3 = new Uint8Array([3, 4, 5]); const sessions = [{ initData: initData1 }, { initData: initData2 }, { initData: initData3 }]; removeSession(sessions, initData2); assert.deepEqual( sessions, [{ initData: initData1 }, { initData: initData3 }], 'removed session with initData' ); removeSession(sessions, null); assert.deepEqual( sessions, [{ initData: initData1 }, { initData: initData3 }], 'does nothing when passed null' ); removeSession(sessions, new Uint8Array([6, 7, 8])); assert.deepEqual( sessions, [{ initData: initData1 }, { initData: initData3 }], 'does nothing when passed non-matching initData' ); removeSession(sessions, new Uint8Array([1, 2, 3])); assert.deepEqual( sessions, [{ initData: initData1 }, { initData: initData3 }], 'did not remove session because initData is not the same reference' ); removeSession(sessions, initData1); assert.deepEqual( sessions, [{ initData: initData3 }], 'removed session with initData' ); removeSession(sessions, initData3); assert.deepEqual(sessions, [], 'removed session with initData'); removeSession(sessions, initData2); assert.deepEqual(sessions, [], 'does nothing when no sessions'); }); QUnit.test('emeError properly handles various parameter types', function(assert) { let errorObj; const player = { tech_: { el_: new videojs.EventTarget() }, error: (obj) => { errorObj = obj; } }; const emeError = emeErrorHandler(player); emeError(undefined); assert.equal(errorObj.message, null, 'null error message'); emeError({}); assert.equal(errorObj.message, null, 'null error message'); emeError(new Error('some error')); assert.equal(errorObj.message, 'some error', 'use error text when error'); emeError('some string'); assert.equal(errorObj.message, 'some string', 'use string when string'); emeError({type: 'mskeyerror', message: 'some event'}); assert.equal(errorObj.message, 'some event', 'use message property when object has it'); const metadata = { errorType: 'foo', keySystem: 'bar', config: { 'com.apple.fps.1_0': { certificateUri: 'foo.bar.certificate', licenseUri: 'bar.foo.license' } } }; const errorString = 'string error'; emeError(errorString, metadata); assert.equal(errorObj.message, errorString, 'error message is expected value'); assert.equal(errorObj.metadata, metadata, 'metadata object is expected value'); const mockErrorObject = { type: 'foo', message: errorString }; emeError(mockErrorObject, metadata); assert.equal(errorObj.originalError, mockErrorObject, 'originalError object is added to new errorObject'); assert.equal(errorObj.message, errorString, 'error message is expected value'); assert.equal(errorObj.metadata, metadata, 'metadata object is expected value'); });