UNPKG

videojs-contrib-eme

Version:

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

1,657 lines (1,492 loc) 47.9 kB
import document from 'global/document'; import QUnit from 'qunit'; import videojs from 'video.js'; import window from 'global/window'; import { defaultGetLicense, standard5July2016, makeNewRequest, getSupportedKeySystem, addSession, addPendingSessions, getSupportedConfigurations } from '../src/eme'; import { getMockEventBus } from './utils'; import sinon from 'sinon'; // mock session to make testing easier (so we can trigger events) const getMockSession = () => { const mockSession = { addEventListener: (type, listener) => mockSession.listeners.push({ type, listener }), generateRequest(initDataType, initData) { // noop return new Promise((resolve, reject) => resolve()); }, keyStatuses: new Map(), close: () => { mockSession.numCloses++; // fake a promise for easy testing return { then: (nextCall) => { nextCall(); return Promise.resolve(); } }; }, numCloses: 0, listeners: [] }; return mockSession; }; const resolveReject = (rejectVariable, rejectMessage) => { return new Promise((resolve, reject) => { if (rejectVariable) { reject(rejectMessage); return; } resolve(); }); }; QUnit.module('videojs-contrib-eme eme', { beforeEach() { this.fixture = document.getElementById('qunit-fixture'); this.video = document.createElement('video'); this.fixture.appendChild(this.video); this.player = videojs(this.video); this.origXhr = videojs.xhr; }, afterEach() { videojs.xhr = this.origXhr; } }); QUnit.test('keystatuseschange triggers keystatuschange on eventBus for each key', function(assert) { const callCount = {total: 0, 1: {}, 2: {}, 3: {}, 4: {}, 5: {}}; const initData = new Uint8Array([1, 2, 3]); const mockSession = getMockSession(); const eventBus = { trigger: (event) => { if (typeof event === 'string' || event.type !== 'keystatuschange') { return; } if (!callCount[event.keyId][event.status]) { callCount[event.keyId][event.status] = 0; } callCount[event.keyId][event.status]++; callCount.total++; }, isDisposed: () => { return false; } }; makeNewRequest(this.player, { mediaKeys: { createSession: () => mockSession }, initDataType: '', initData, options: {}, getLicense() {}, removeSession() {}, eventBus, emeError() {} }); assert.equal(mockSession.listeners.length, 2, 'added listeners'); assert.equal( mockSession.listeners[1].type, 'keystatuseschange', 'added keystatuseschange listener' ); // no key statuses mockSession.listeners[1].listener(); assert.equal(callCount.total, 0, 'no events dispatched yet'); mockSession.keyStatuses.set(1, 'unrecognized'); mockSession.keyStatuses.set(2, 'expired'); mockSession.keyStatuses.set(3, 'internal-error'); mockSession.keyStatuses.set(4, 'output-restricted'); mockSession.keyStatuses.set(5, 'output-restricted'); mockSession.listeners[1].listener(); assert.equal( callCount[1].unrecognized, 1, 'dispatched `unrecognized` key status for key 1' ); assert.equal( callCount[2].expired, 1, 'dispatched `expired` key status for key 2' ); assert.equal( callCount[3]['internal-error'], 1, 'dispatched `internal-error` key status for key 3' ); assert.equal( callCount[4]['output-restricted'], 1, 'dispatched `output-restricted` key status for key 4' ); assert.equal( callCount[5]['output-restricted'], 1, 'dispatched `output-restricted` key status for key 5' ); assert.equal(callCount.total, 5, '5 keystatuschange events received so far'); // Change a single key and check that it's triggered for all keys mockSession.keyStatuses.set(1, 'usable'); mockSession.listeners[1].listener(); assert.equal( callCount[1].usable, 1, 'dispatched `usable` key status for key 1' ); assert.equal( callCount[2].expired, 2, 'dispatched `expired` key status for key 2' ); assert.equal( callCount[3]['internal-error'], 2, 'dispatched `internal-error` key status for key 3' ); assert.equal( callCount[4]['output-restricted'], 2, 'dispatched `output-restricted` key status for key 4' ); assert.equal( callCount[5]['output-restricted'], 2, 'dispatched `output-restricted` key status for key 5' ); assert.equal(callCount.total, 10, '10 keystatuschange events received so far'); // Change the key statuses and recheck mockSession.keyStatuses.set(1, 'output-downscaled'); mockSession.keyStatuses.set(2, 'released'); mockSession.keyStatuses.set(3, 'usable'); mockSession.keyStatuses.set(4, 'status-pending'); mockSession.keyStatuses.set(5, 'usable'); mockSession.listeners[1].listener(); assert.equal( callCount[1]['output-downscaled'], 1, 'dispatched `output-downscaled` key status for key 1' ); assert.equal( callCount[2].released, 1, 'dispatched `released` key status for key 2' ); assert.equal( callCount[3].usable, 1, 'dispatched `usable` key status for key 3' ); assert.equal( callCount[4]['status-pending'], 1, 'dispatched `status-pending` key status for key 4' ); assert.equal( callCount[5].usable, 1, 'dispatched `usable` key status for key 5' ); assert.equal(callCount.total, 15, '15 keystatuschange events received so far'); }); QUnit.test('keystatuseschange with expired key closes and recreates session', function(assert) { const removeSessionCalls = []; // once the eme module gets the removeSession function, the session argument is already // bound to the function (note that it's a custom session maintained by the plugin, not // the native session), so only initData is passed const removeSession = (initData) => removeSessionCalls.push(initData); const initData = new Uint8Array([1, 2, 3]); const mockSession = getMockSession(); const eventBus = { trigger: (name) => {}, isDisposed: () => { return false; } }; let creates = 0; makeNewRequest(this.player, { mediaKeys: { createSession: () => { creates++; return mockSession; } }, initDataType: '', initData, options: {}, getLicense() {}, removeSession, eventBus }); assert.equal(creates, 1, 'created session'); assert.equal(mockSession.listeners.length, 2, 'added listeners'); assert.equal( mockSession.listeners[1].type, 'keystatuseschange', 'added keystatuseschange listener' ); assert.equal(mockSession.numCloses, 0, 'no session close calls'); assert.equal(removeSessionCalls.length, 0, 'no removeSession calls'); // no key statuses mockSession.listeners[1].listener(); assert.equal(mockSession.numCloses, 0, 'no session close calls'); assert.equal(removeSessionCalls.length, 0, 'no removeSession calls'); mockSession.keyStatuses.set(1, 'unrecognized'); mockSession.listeners[1].listener(); assert.equal(mockSession.numCloses, 0, 'no session close calls'); assert.equal(removeSessionCalls.length, 0, 'no removeSession calls'); mockSession.keyStatuses.set(2, 'expired'); mockSession.listeners[1].listener(); assert.equal(mockSession.numCloses, 1, 'closed session'); // close promise is fake and resolves synchronously, so we can assert removes // synchronously assert.equal(removeSessionCalls.length, 1, 'called remove session'); assert.equal(removeSessionCalls[0], initData, 'called to remove session with initData'); assert.equal(creates, 2, 'created another session'); }); QUnit.test('keystatuseschange with internal-error logs a warning', function(assert) { const origWarn = videojs.log.warn; const initData = new Uint8Array([1, 2, 3]); const mockSession = getMockSession(); const warnCalls = []; const eventBus = { trigger: (name) => {}, isDisposed: () => { return false; } }; videojs.log.warn = (...args) => warnCalls.push(args); makeNewRequest(this.player, { mediaKeys: { createSession: () => mockSession }, initDataType: '', initData, options: {}, getLicense() {}, removeSession() {}, eventBus }); assert.equal(mockSession.listeners.length, 2, 'added listeners'); assert.equal( mockSession.listeners[1].type, 'keystatuseschange', 'added keystatuseschange listener' ); // no key statuses mockSession.listeners[1].listener(); assert.equal(warnCalls.length, 0, 'no warn logs'); mockSession.keyStatuses.set(1, 'internal-error'); const keyStatusChangeEvent = {}; mockSession.listeners[1].listener(keyStatusChangeEvent); assert.equal(warnCalls.length, 1, 'one warn log'); assert.equal( warnCalls[0][0], 'Key status reported as "internal-error." Leaving the session open ' + 'since we don\'t have enough details to know if this error is fatal.', 'logged correct warning' ); assert.equal(warnCalls[0][1], keyStatusChangeEvent, 'logged event object'); videojs.log.warn = origWarn; }); QUnit.test('accepts a license URL as an option', function(assert) { const done = assert.async(); const origXhr = videojs.xhr; const xhrCalls = []; const mockSession = getMockSession(); const mockEventBus = getMockEventBus(); const mockMessageEvent = { type: 'message', message: 'the-message', messageType: 'license-request' }; videojs.xhr = (options) => { xhrCalls.push(options); }; const createSession = () => mockSession; const keySystemAccess = { keySystem: 'com.widevine.alpha', createMediaKeys: () => { return { createSession }; } }; standard5July2016({ player: this.player, keySystemAccess, video: { setMediaKeys: (createdMediaKeys) => Promise.resolve(createdMediaKeys) }, initDataType: '', initData: '', options: { keySystems: { 'com.widevine.alpha': 'some-url' } }, eventBus: mockEventBus }).catch((e) => {}); setTimeout(() => { assert.equal(mockSession.listeners.length, 2, 'added listeners'); assert.equal( mockSession.listeners[0].type, 'message', 'added message listener' ); // Simulate 'message' event mockSession.listeners[0].listener(mockMessageEvent); assert.equal(mockEventBus.calls[0].type, 'keysystemaccesscomplete', 'keysystemaccesscomplete fired'); assert.deepEqual(mockEventBus.calls[0].mediaKeys, { createSession }, 'keysystemaccesscomplete payload fired'); assert.equal(mockEventBus.calls[1].type, 'keysessioncreated', 'keysessioncreated fired'); assert.equal(mockEventBus.calls[1].keySession, mockSession, 'keysessioncreated payload fired'); assert.equal(mockEventBus.calls[2].type, 'keymessage', 'keymessage event type is expected type'); assert.equal(mockEventBus.calls[2].messageEvent, mockMessageEvent, 'keymessage event is expected message event'); assert.equal(xhrCalls.length, 1, 'made one XHR'); assert.deepEqual(xhrCalls[0], { uri: 'some-url', method: 'POST', responseType: 'arraybuffer', requestType: 'license', metadata: { keySystem: 'com.widevine.alpha' }, body: 'the-message', headers: { 'Content-type': 'application/octet-stream' } }, 'made request with proper options'); videojs.xhr = origXhr; done(); }); }); QUnit.test('accepts a license URL as property', function(assert) { const done = assert.async(); const origXhr = videojs.xhr; const xhrCalls = []; const mockSession = getMockSession(); const mockEventBus = getMockEventBus(); const createSession = () => mockSession; const mockMessageEvent = { type: 'message', message: 'the-message', messageType: 'license-request' }; const keySystemAccess = { keySystem: 'com.widevine.alpha', createMediaKeys: () => { return { createSession }; } }; videojs.xhr = (options) => { xhrCalls.push(options); }; standard5July2016({ player: this.player, keySystemAccess, video: { setMediaKeys: (createdMediaKeys) => Promise.resolve(createdMediaKeys) }, initDataType: '', initData: '', options: { keySystems: { 'com.widevine.alpha': { url: 'some-url' } } }, eventBus: mockEventBus }).catch((e) => {}); setTimeout(() => { assert.equal(mockSession.listeners.length, 2, 'added listeners'); assert.equal( mockSession.listeners[0].type, 'message', 'added message listener' ); // Simulate 'message' event mockSession.listeners[0].listener(mockMessageEvent); assert.equal(mockEventBus.calls[0].type, 'keysystemaccesscomplete', 'keysystemaccesscomplete fired'); assert.deepEqual(mockEventBus.calls[0].mediaKeys, { createSession }, 'keysystemaccesscomplete payload'); assert.equal(mockEventBus.calls[1].type, 'keysessioncreated', 'keymessage fired'); assert.equal(mockEventBus.calls[2].messageEvent, mockMessageEvent, 'keymessage event is expected message event'); assert.equal(xhrCalls.length, 1, 'made one XHR'); assert.deepEqual(xhrCalls[0], { uri: 'some-url', method: 'POST', responseType: 'arraybuffer', requestType: 'license', metadata: { keySystem: 'com.widevine.alpha' }, body: 'the-message', headers: { 'Content-type': 'application/octet-stream' } }, 'made request with proper options'); videojs.xhr = origXhr; done(); }); }); QUnit.test('5 July 2016 lifecycle', function(assert) { assert.expect(34); let errors = 0; const done = assert.async(); const callbacks = {}; const callCounts = { getCertificate: 0, getLicense: 0, createSession: 0, keySessionGenerateRequest: 0, keySessionUpdate: 0, createMediaKeys: 0, licenseRequestAttempts: 0, keysessionUpdatedEvent: 0 }; const getCertificate = (emeOptions, callback) => { callCounts.getCertificate++; callbacks.getCertificate = callback; }; const getLicense = (emeOptions, keyMessage, callback) => { callCounts.getLicense++; callbacks.getLicense = callback; }; let setMediaKeys; const video = { setMediaKeys: (mediaKeys) => { setMediaKeys = mediaKeys; return Promise.resolve(mediaKeys); } }; const options = { keySystems: { 'org.w3.clearkey': { getCertificate, getLicense } } }; const eventBus = { trigger: (event) => { const name = typeof event === 'string' ? event : event.type; if (name === 'licenserequestattempted') { callCounts.licenseRequestAttempts++; } if (name === 'keysessionupdated') { callCounts.keysessionUpdatedEvent++; } }, isDisposed: () => { return false; } }; const keySessionEventListeners = {}; const mediaKeys = { createSession: () => { callCounts.createSession++; return { addEventListener: (name, callback) => { keySessionEventListeners[name] = callback; }, generateRequest: () => { callCounts.keySessionGenerateRequest++; return new Promise(() => {}); }, update: () => { callCounts.keySessionUpdate++; return Promise.resolve(); }, close: () => {} }; } }; const keySystemAccess = { keySystem: 'org.w3.clearkey', createMediaKeys: () => { callCounts.createMediaKeys++; return mediaKeys; } }; standard5July2016({ player: this.player, video, initDataType: '', initData: '', keySystemAccess, options, eventBus }).then(() => done()).catch(() => errors++); // Step 1: get certificate assert.equal(callCounts.getCertificate, 1, 'certificate requested'); assert.equal(callCounts.createMediaKeys, 0, 'media keys not created'); assert.notEqual(mediaKeys, setMediaKeys, 'media keys not yet set'); assert.equal(callCounts.createSession, 0, 'key session not created'); assert.equal(callCounts.keySessionGenerateRequest, 0, 'key session request not made'); assert.equal(callCounts.getLicense, 0, 'license not requested'); assert.equal(callCounts.keySessionUpdate, 0, 'key session not updated'); assert.equal( callCounts.licenseRequestAttempts, 0, 'license request event not triggered (since no callback yet)' ); callbacks.getCertificate(null, ''); // getCertificate promise resolution setTimeout(() => { // Step 2: create media keys, set them, and generate key session request assert.equal(callCounts.getCertificate, 1, 'certificate requested'); assert.equal(callCounts.createMediaKeys, 1, 'media keys created'); assert.equal(mediaKeys, setMediaKeys, 'media keys set'); assert.equal(callCounts.createSession, 1, 'key session created'); assert.equal(callCounts.keySessionGenerateRequest, 1, 'key session request made'); assert.equal(callCounts.getLicense, 0, 'license not requested'); assert.equal(callCounts.keySessionUpdate, 0, 'key session not updated'); assert.equal( callCounts.licenseRequestAttempts, 0, 'license request event not triggered (since no callback yet)' ); keySessionEventListeners.message({messageType: 'license-request'}); // Step 3: get license assert.equal(callCounts.getCertificate, 1, 'certificate requested'); assert.equal(callCounts.createMediaKeys, 1, 'media keys created'); assert.equal(mediaKeys, setMediaKeys, 'media keys set'); assert.equal(callCounts.createSession, 1, 'key session created'); assert.equal(callCounts.keySessionGenerateRequest, 1, 'key session request made'); assert.equal(callCounts.getLicense, 1, 'license requested'); assert.equal(callCounts.keySessionUpdate, 0, 'key session not updated'); assert.equal( callCounts.licenseRequestAttempts, 0, 'license request event not triggered (since no callback yet)' ); callbacks.getLicense(); // getLicense promise resolution setTimeout(() => { // Step 4: update key session assert.equal(callCounts.getCertificate, 1, 'certificate requested'); assert.equal(callCounts.createMediaKeys, 1, 'media keys created'); assert.equal(mediaKeys, setMediaKeys, 'media keys set'); assert.equal(callCounts.createSession, 1, 'key session created'); assert.equal(callCounts.keySessionGenerateRequest, 1, 'key session request made'); assert.equal(callCounts.getLicense, 1, 'license requested'); assert.equal(callCounts.keySessionUpdate, 1, 'key session updated'); assert.equal( callCounts.licenseRequestAttempts, 1, 'license request event triggered' ); assert.equal(callCounts.keysessionUpdatedEvent, 1, 'keysessionupdated event fired once'); assert.equal(errors, 0, 'no errors occurred'); }); }); }); // Skip this test in Safari, getSupportedKeySystem is never used in Safari. if (!videojs.browser.IS_ANY_SAFARI) { QUnit.test('getSupportedKeySystem error', function(assert) { const done = assert.async(1); getSupportedKeySystem({'un.supported.keysystem': {}}).catch((err) => { assert.equal(err.name, 'NotSupportedError', 'keysystem access request fails'); done(); }); }); } QUnit.test('errors when missing url/licenseUri or getLicense', function(assert) { const options = { keySystems: { 'com.widevine.alpha': {} } }; const keySystemAccess = { keySystem: 'com.widevine.alpha' }; const done = assert.async(1); standard5July2016({ player: this.player, video: {}, keySystemAccess, options, eventBus: getMockEventBus() }).catch((err) => { assert.equal( err, 'Error: Missing url/licenseUri or getLicense in com.widevine.alpha keySystem configuration.', 'correct error message' ); done(); }); }); QUnit.test('errors when missing certificateUri and getCertificate for fairplay', function(assert) { const options = { keySystems: { 'com.apple.fps': {url: 'fake-url'} } }; const keySystemAccess = { keySystem: 'com.apple.fps' }; const done = assert.async(); standard5July2016({ player: this.player, video: {}, keySystemAccess, options }).catch((err) => { assert.equal( err, 'Error: Missing getCertificate or certificateUri in com.apple.fps keySystem configuration.', 'correct error message' ); done(); }); }); QUnit.test('rejects promise when getCertificate throws error', function(assert) { const getCertificate = (options, callback) => { callback('error fetching certificate'); }; const options = { keySystems: { 'com.widevine.alpha': { url: 'some-url', getCertificate } } }; const keySystemAccess = { keySystem: 'com.widevine.alpha' }; const done = assert.async(1); const expectedError = 'error fetching certificate'; const emeError = (error, metadata) => { assert.equal(error, expectedError, 'emeError called with expected message'); assert.equal(metadata.errorType, videojs.Error.EMEFailedToCreateMediaKeys, 'emeError called with expected type'); assert.equal(metadata.keySystem, 'com.widevine.alpha', 'emeError called with expected type'); }; standard5July2016({ player: this.player, video: {}, keySystemAccess, options, eventBus: getMockEventBus(), emeError }).catch((err) => { assert.equal(err, expectedError, 'correct error message'); done(); }); }); QUnit.test('rejects promise when createMediaKeys rejects', function(assert) { const options = { keySystems: { 'com.widevine.alpha': 'some-url' } }; const keySystemAccess = { keySystem: 'com.widevine.alpha', createMediaKeys: () => { return Promise.reject(); } }; const done = assert.async(1); const emeError = (_, metadata) => { assert.equal(metadata.errorType, videojs.Error.EMEFailedToCreateMediaKeys, 'emeError called with expected errorType'); assert.equal(metadata.keySystem, 'com.widevine.alpha', 'emeError called with expected keySystem'); }; standard5July2016({ player: this.player, video: {}, keySystemAccess, options, eventBus: getMockEventBus(), emeError }).catch((err) => { assert.equal( err, 'Failed to create and initialize a MediaKeys object', 'uses generic message' ); done(); }); }); QUnit.test('rejects promise when createMediaKeys rejects', function(assert) { const options = { keySystems: { 'com.widevine.alpha': 'some-url' } }; const keySystemAccess = { keySystem: 'com.widevine.alpha', createMediaKeys: () => { return Promise.reject('failed creating mediaKeys'); } }; const done = assert.async(1); const expectedError = 'failed creating mediaKeys'; const emeError = (error, metadata) => { assert.equal(error, expectedError, 'emeError called with expected error'); assert.equal(metadata.errorType, videojs.Error.EMEFailedToCreateMediaKeys, 'emeError called with expected errorType'); assert.equal(metadata.keySystem, 'com.widevine.alpha', 'emeError called with expected keySystem'); }; standard5July2016({ player: this.player, video: {}, keySystemAccess, options, eventBus: getMockEventBus(), emeError }).catch((err) => { assert.equal(err, expectedError, 'uses specific error when given'); done(); }); }); QUnit.test('rejects promise when addPendingSessions rejects', function(assert) { let rejectSetServerCertificate = true; const rejectGenerateRequest = true; let rejectSetMediaKeys = true; const options = { keySystems: { 'com.widevine.alpha': { url: 'some-url', getCertificate: (emeOptions, callback) => { callback(null, 'some certificate'); } } } }; const keySystemAccess = { keySystem: 'com.widevine.alpha', createMediaKeys: () => { return Promise.resolve({ setServerCertificate: () => resolveReject( rejectSetServerCertificate, 'setServerCertificate failed' ), createSession: () => { return { addEventListener: () => {}, generateRequest: () => resolveReject( rejectGenerateRequest, 'generateRequest failed' ), close: () => {} }; } }); } }; const video = { setMediaKeys: () => resolveReject(rejectSetMediaKeys, 'setMediaKeys failed') }; const done = assert.async(3); const callbacks = []; const expectedErrors = [ { error: 'setServerCertificate failed', errorType: videojs.Error.EMEFailedToSetServerCertificate }, { error: 'setMediaKeys failed', errorType: videojs.Error.EMEFailedToAttachMediaKeysToVideoElement }, { error: 'generateRequest failed', errorType: videojs.Error.EMEFailedToGenerateLicenseRequest } ]; const test = (errMessage, testDescription) => { let expectedErrorsLength = 0; const emeErrors = []; video.mediaKeysObject = undefined; standard5July2016({ player: this.player, video, keySystemAccess, options, eventBus: getMockEventBus(), emeError: (error, metadata) => { expectedErrorsLength++; emeErrors.push({error, errorType: metadata.errorType }); } }).catch((err) => { assert.equal(err, errMessage, testDescription); assert.equal(emeErrors.length, expectedErrorsLength, 'emeError called expected number of times'); for (let i = 0; i < expectedErrors.length; i++) { assert.equal(emeErrors[i].error, expectedErrors[i].error, 'expected eme error'); assert.equal(emeErrors[i].errorType, expectedErrors[i].errorType, 'expected eme errorType'); } expectedErrors.shift(); done(); if (callbacks[0]) { callbacks.shift()(); } }); }; callbacks.push(() => { rejectSetServerCertificate = false; test('Unable to create or initialize key session', 'second promise fails'); }); callbacks.push(() => { rejectSetMediaKeys = false; test('Unable to create or initialize key session', 'third promise fails'); }); test('Unable to create or initialize key session', 'first promise fails'); }); QUnit.test('getLicense not called for messageType that isnt license-request or license-renewal', function(assert) { const done = assert.async(); let getLicenseCalls = 0; const options = { keySystems: { 'com.widevine.alpha': { url: 'some-url', getLicense(emeOptions, keyMessage, callback) { getLicenseCalls++; } } } }; const keySystemAccess = { keySystem: 'com.widevine.alpha', createMediaKeys: () => { return Promise.resolve({ setServerCertificate: () => Promise.resolve(), createSession: () => { return { addEventListener: (event, callback) => { if (event === 'message') { setTimeout(() => { callback({message: 'whatever', messageType: 'do-not-request-license'}); assert.equal(getLicenseCalls, 0, 'did not call getLicense'); done(); }); } }, keyStatuses: [], generateRequest: () => Promise.resolve(), close: () => {} }; } }); } }; const video = { setMediaKeys: () => Promise.resolve() }; standard5July2016({ player: this.player, video, keySystemAccess, options, eventBus: getMockEventBus() }); }); QUnit.test('getLicense promise rejection', function(assert) { const options = { keySystems: { 'com.widevine.alpha': { url: 'some-url', getLicense(emeOptions, keyMessage, callback) { callback('error getting license'); } } } }; const keySystemAccess = { keySystem: 'com.widevine.alpha', createMediaKeys: () => { return Promise.resolve({ setServerCertificate: () => Promise.resolve(), createSession: () => { return { addEventListener: (event, callback) => { setTimeout(() => { callback({message: 'whatever', messageType: 'license-request'}); }); }, keyStatuses: [], generateRequest: () => Promise.resolve(), close: () => {} }; } }); } }; const video = { setMediaKeys: () => Promise.resolve() }; const done = assert.async(1); standard5July2016({ player: this.player, video, keySystemAccess, options, eventBus: getMockEventBus() }).catch((err) => { assert.equal(err, 'error getting license', 'correct error message'); done(); }); }); QUnit.test('getLicense calls back with error for 400 and 500 status codes', function(assert) { const getLicenseCallback = sinon.spy(); const getLicense = defaultGetLicense('', {}); function toArrayBuffer(obj) { const json = JSON.stringify(obj); const buffer = new ArrayBuffer(json.length); const bufferView = new Uint8Array(buffer); for (let i = 0; i < json.length; i++) { bufferView[i] = json.charCodeAt(i); } return buffer; } videojs.xhr = (params, callback) => { return callback(null, {statusCode: 400}, toArrayBuffer({body: 'some-body'})); }; getLicense({}, null, getLicenseCallback); videojs.xhr = (params, callback) => { return callback(null, {statusCode: 500}, toArrayBuffer({body: 'some-body'})); }; getLicense({}, null, getLicenseCallback); videojs.xhr = (params, callback) => { return callback(null, {statusCode: 599}, toArrayBuffer({body: 'some-body'})); }; getLicense({}, null, getLicenseCallback); assert.equal(getLicenseCallback.callCount, 3, 'correct callcount'); assert.ok(getLicenseCallback.alwaysCalledWith({ cause: JSON.stringify({body: 'some-body'}) }), 'getLicense callback called with correct error'); }); QUnit.test('getLicense calls back with response body for non-400/500 status codes', function(assert) { const getLicenseCallback = sinon.spy(); const getLicense = defaultGetLicense('', {}); videojs.xhr = (params, callback) => { return callback(null, {statusCode: 200}, {body: 'some-body'}); }; getLicense({}, null, getLicenseCallback); videojs.xhr = (params, callback) => { return callback(null, {statusCode: 399}, {body: 'some-body'}); }; getLicense({}, null, getLicenseCallback); videojs.xhr = (params, callback) => { return callback(null, {statusCode: 600}, {body: 'some-body'}); }; getLicense({}, null, getLicenseCallback); assert.equal(getLicenseCallback.callCount, 3, 'correct callcount'); assert.equal(getLicenseCallback.alwaysCalledWith(null, {body: 'some-body'}), true, 'getLicense callback called with correct args'); }); QUnit.test('keySession.update promise rejection', function(assert) { const options = { keySystems: { 'com.widevine.alpha': { url: 'some-url', getLicense(emeOptions, keyMessage, callback) { callback(null, 'license'); } } } }; const keySystemAccess = { keySystem: 'com.widevine.alpha', createMediaKeys: () => { return Promise.resolve({ setServerCertificate: () => Promise.resolve(), createSession: () => { return { addEventListener: (event, callback) => { setTimeout(() => { callback({messageType: 'license-request', message: 'whatever'}); }); }, keyStatuses: [], generateRequest: () => Promise.resolve(), update: () => Promise.reject('keySession update failed'), close: () => {} }; } }); } }; const video = { setMediaKeys: () => Promise.resolve() }; const done = assert.async(1); const emeError = (error, metadata) => { assert.equal(error, 'keySession update failed', 'correct error message'); assert.equal(metadata.errorType, videojs.Error.EMEFailedToUpdateSessionWithReceivedLicenseKeys, 'errorType is correct'); assert.equal(metadata.keySystem, 'com.widevine.alpha', 'keySystem is correct'); done(); }; standard5July2016({ player: this.player, video, keySystemAccess, options, eventBus: getMockEventBus(), emeError }); }); QUnit.test('emeHeaders option sets headers on default license xhr request', function(assert) { const done = assert.async(); const origXhr = videojs.xhr; const xhrCalls = []; const mockSession = getMockSession(); const mockEventBus = getMockEventBus(); const mockMessageEvent = { type: 'message', message: 'the-message', messageType: 'license-request' }; const createSession = () => mockSession; videojs.xhr = (options) => { xhrCalls.push(options); }; const keySystemAccess = { keySystem: 'com.widevine.alpha', createMediaKeys: () => { return { createSession }; } }; standard5July2016({ player: this.player, keySystemAccess, video: { setMediaKeys: (createdMediaKeys) => Promise.resolve(createdMediaKeys) }, initDataType: '', initData: '', options: { keySystems: { 'com.widevine.alpha': 'some-url' }, emeHeaders: { 'Some-Header': 'some-header-value' } }, eventBus: mockEventBus }).catch((e) => {}); setTimeout(() => { // Simulate 'message' event mockSession.listeners[0].listener(mockMessageEvent); assert.equal(mockEventBus.calls[0].type, 'keysystemaccesscomplete', 'keysystemaccesscomplete fired'); assert.deepEqual(mockEventBus.calls[0].mediaKeys, { createSession }, 'keysystemaccesscomplete payload'); assert.equal(mockEventBus.calls[1].type, 'keysessioncreated', 'keymessage fired'); assert.equal(mockEventBus.calls[2].messageEvent, mockMessageEvent, 'keymessage event is expected message event'); assert.equal(xhrCalls.length, 1, 'made one XHR'); assert.deepEqual(xhrCalls[0], { uri: 'some-url', method: 'POST', responseType: 'arraybuffer', requestType: 'license', metadata: { keySystem: 'com.widevine.alpha' }, body: 'the-message', headers: { 'Content-type': 'application/octet-stream', 'Some-Header': 'some-header-value' } }, 'made request with proper emeHeaders option value'); videojs.xhr = origXhr; done(); }); }); QUnit.test('licenseHeaders keySystems property overrides emeHeaders value', function(assert) { const done = assert.async(); const origXhr = videojs.xhr; const xhrCalls = []; const mockSession = getMockSession(); const mockEventBus = getMockEventBus(); const mockMessageEvent = { type: 'message', message: 'the-message', messageType: 'license-request' }; const createSession = () => mockSession; videojs.xhr = (options) => { xhrCalls.push(options); }; const keySystemAccess = { keySystem: 'com.widevine.alpha', createMediaKeys: () => { return { createSession }; } }; standard5July2016({ player: this.player, keySystemAccess, video: { setMediaKeys: (createdMediaKeys) => Promise.resolve(createdMediaKeys) }, initDataType: '', initData: '', options: { keySystems: { 'com.widevine.alpha': { url: 'some-url', licenseHeaders: { 'Some-Header': 'priority-header-value' } } }, emeHeaders: { 'Some-Header': 'lower-priority-header-value' } }, eventBus: mockEventBus }).catch((e) => {}); setTimeout(() => { // Simulate 'message' event mockSession.listeners[0].listener(mockMessageEvent); assert.equal(mockEventBus.calls[0].type, 'keysystemaccesscomplete', 'keysystemaccesscomplete fired'); assert.deepEqual(mockEventBus.calls[0].mediaKeys, { createSession }, 'keysystemaccesscomplete payload'); assert.equal(mockEventBus.calls[1].type, 'keysessioncreated', 'keymessage fired'); assert.equal(mockEventBus.calls[1].keySession, mockSession, 'keymessage payload'); assert.equal(mockEventBus.calls[2].type, 'keymessage', 'keymessage event is expected message event'); assert.equal(mockEventBus.calls[2].messageEvent, mockMessageEvent, 'keymessage event is expected message event'); assert.equal(xhrCalls.length, 1, 'made one XHR'); assert.deepEqual(xhrCalls[0], { uri: 'some-url', method: 'POST', responseType: 'arraybuffer', requestType: 'license', metadata: { keySystem: 'com.widevine.alpha' }, body: 'the-message', headers: { 'Content-type': 'application/octet-stream', 'Some-Header': 'priority-header-value' } }, 'made request with proper licenseHeaders value'); videojs.xhr = origXhr; done(); }); }); QUnit.test('sets required fairplay defaults if not explicitly configured', function(assert) { const origRequestMediaKeySystemAccess = window.navigator.requestMediaKeySystemAccess; window.navigator.requestMediaKeySystemAccess = (keySystem, systemOptions) => { assert.ok( systemOptions[0].initDataTypes.indexOf('sinf') !== -1, 'includes required initDataType' ); assert.ok( systemOptions[0].videoCapabilities[0].contentType.indexOf('video/mp4') !== -1, 'includes required video contentType' ); }; getSupportedKeySystem({'com.apple.fps': {}}); window.requestMediaKeySystemAccess = origRequestMediaKeySystemAccess; }); QUnit.test('makeNewRequest triggers keysessioncreated', function(assert) { const done = assert.async(); const mockSession = getMockSession(); makeNewRequest(this.player, { mediaKeys: { createSession: () => mockSession }, eventBus: { trigger: (event) => { if (event.type === 'keysessioncreated') { assert.ok(true, 'got a keysessioncreated event'); done(); } }, isDisposed: () => { return false; } } }); }); QUnit.test.skip('keySession is closed when player is disposed', function(assert) { const mockSession = getMockSession(); const done = assert.async(); makeNewRequest(this.player, { mediaKeys: { createSession: () => mockSession }, eventBus: { trigger: (event) => { if (event.type === 'keysessionclosed') { assert.ok(true, 'got a keysessionclosed event'); done(); } }, isDisposed: () => { return false; } } }); assert.equal(mockSession.numCloses, 0, 'no close() calls initially'); this.player.dispose(); assert.equal(mockSession.numCloses, 1, 'close() called once after dipose'); }); QUnit.test('emeError is called when keySession.close fails', function(assert) { const mockSession = getMockSession(); const done = assert.async(); const expectedErrorMessage = 'Failed to close session'; mockSession.close = () => { return Promise.reject(expectedErrorMessage); }; makeNewRequest(this.player, { mediaKeys: { createSession: () => mockSession }, eventBus: { trigger: () => {}, isDisposed: () => { return false; } }, emeError: (error, metadata) => { assert.equal(error, expectedErrorMessage, 'expected eme error message'); assert.equal(metadata.errorType, videojs.Error.EMEFailedToCloseSession, 'expected eme error type'); done(); } }); this.player.dispose(); }); QUnit.test('emeError called when session.generateRequest fails', function(assert) { const mockSession = getMockSession(); const done = assert.async(); const expectedErrorMessage = 'generate request failed'; mockSession.generateRequest = () => { return Promise.reject(expectedErrorMessage); }; makeNewRequest(this.player, { mediaKeys: { createSession: () => mockSession }, eventBus: { trigger: () => {}, isDisposed: () => { return false; } }, emeError: (error, metadata) => { assert.equal(error, expectedErrorMessage, 'expected eme error message'); assert.equal(metadata.errorType, videojs.Error.EMEFailedToGenerateLicenseRequest, 'expected eme error type'); } }).catch((error) => { assert.equal(error, 'Unable to create or initialize key session', 'expected message'); done(); }); }); QUnit.test('emeError called when mediaKeys.createSession fails', function(assert) { const done = assert.async(); const expectedError = new Error('session could not be created'); makeNewRequest(this.player, { mediaKeys: { createSession: () => { throw expectedError; } }, eventBus: { trigger: () => {} }, emeError: (error, metadata) => { assert.equal(error, expectedError, 'expected eme error message'); assert.equal(metadata.errorType, videojs.Error.EMEFailedToCreateMediaKeySession, 'expected eme error type'); done(); } }); }); QUnit.module('session management', { beforeEach() { this.fixture = document.getElementById('qunit-fixture'); this.video = document.createElement('video'); this.fixture.appendChild(this.video); this.player = videojs(this.video); } }); QUnit.test('addSession saves options', function(assert) { const video = { pendingSessionData: [] }; const initDataType = 'temporary'; const initData = new Uint8Array(); const options = { some: 'option' }; const getLicense = () => ''; const removeSession = () => ''; const eventBus = { trigger: () => {} }; const contentId = null; const emeError = () => {}; addSession({ video, contentId, initDataType, initData, options, getLicense, removeSession, eventBus, emeError }); assert.deepEqual( video.pendingSessionData, [{ initDataType, initData, options, getLicense, removeSession, eventBus, contentId, emeError, keySystem: undefined }], 'saved options into pendingSessionData array' ); }); QUnit.test('addPendingSessions reuses saved options', function(assert) { assert.expect(5); const done = assert.async(); const options = { some: 'option' }; const getLicense = (emeOptions, message) => { assert.deepEqual(emeOptions, options, 'used pending session data options'); return Promise.resolve('license'); }; const eventListeners = []; const pendingSessionData = [{ initDataType: 'temporary', initData: new Uint8Array(), options, getLicense, removeSession: () => '', eventBus: { trigger: () => {}, isDisposed: () => { return false; } } }]; const video = { pendingSessionData, // internal API, not used in this test setMediaKeys: () => Promise.resolve() }; const createdMediaKeys = { createSession: () => { return { addEventListener: (event, callback) => eventListeners.push({ event, callback }), generateRequest: (initDataType, initData) => { assert.equal( initDataType, pendingSessionData[0].initDataType, 'generateRequest received correct initDataType' ); assert.equal( initData, pendingSessionData[0].initData, 'generateRequest received correct initData' ); assert.equal(eventListeners.length, 2, 'added two event listeners'); assert.equal( eventListeners[0].event, 'message', 'added listener for message event' ); // callback should call getLicense, which continues this test eventListeners[0].callback({messageType: 'license-request', message: 'test message'}); return Promise.resolve(); }, // this call and everything after is beyond the scope of this test update: () => Promise.resolve(), close: () => {} }; } }; return addPendingSessions({ player: this.player, video, createdMediaKeys }).then((resolve, reject) => { done(); }); }); QUnit.module('videojs-contrib-eme getSupportedConfigurations', { beforeEach() { this.fixture = document.getElementById('qunit-fixture'); this.video = document.createElement('video'); this.fixture.appendChild(this.video); this.player = videojs(this.video); } }); QUnit.test('includes audio and video content types', function(assert) { assert.deepEqual( getSupportedConfigurations('com.widevine.alpha', { audioContentType: 'audio/mp4; codecs="mp4a.40.2"', videoContentType: 'video/mp4; codecs="avc1.42E01E"' }), [{ audioCapabilities: [{ contentType: 'audio/mp4; codecs="mp4a.40.2"' }], videoCapabilities: [{ contentType: 'video/mp4; codecs="avc1.42E01E"' }] }], 'included audio and video content types' ); }); QUnit.test('includes audio and video robustness', function(assert) { assert.deepEqual( getSupportedConfigurations('com.widevine.alpha', { audioRobustness: 'SW_SECURE_CRYPTO', videoRobustness: 'SW_SECURE_CRYPTO' }), [{ audioCapabilities: [{ robustness: 'SW_SECURE_CRYPTO' }], videoCapabilities: [{ robustness: 'SW_SECURE_CRYPTO' }] }], 'included audio and video robustness' ); }); QUnit.test('includes audio and video content types and robustness', function(assert) { assert.deepEqual( getSupportedConfigurations('com.widevine.alpha', { audioContentType: 'audio/mp4; codecs="mp4a.40.2"', audioRobustness: 'SW_SECURE_CRYPTO', videoContentType: 'video/mp4; codecs="avc1.42E01E"', videoRobustness: 'SW_SECURE_CRYPTO' }), [{ audioCapabilities: [{ contentType: 'audio/mp4; codecs="mp4a.40.2"', robustness: 'SW_SECURE_CRYPTO' }], videoCapabilities: [{ contentType: 'video/mp4; codecs="avc1.42E01E"', robustness: 'SW_SECURE_CRYPTO' }] }], 'included audio and video content types and robustness' ); }); QUnit.test('includes persistentState', function(assert) { assert.deepEqual( getSupportedConfigurations('com.widevine.alpha', { persistentState: 'optional' }), [{ persistentState: 'optional' }], 'included persistentState' ); }); QUnit.test('uses supportedConfigurations directly if provided', function(assert) { assert.deepEqual( getSupportedConfigurations('com.widevine.alpha', { supportedConfigurations: [{ initDataTypes: ['cenc'], audioCapabilities: [{ contentType: 'audio/mp4; codecs="mp4a.40.2"', robustness: 'SW_SECURE_CRYPTO', extraOption: 'audio-extra' }], videoCapabilities: [{ contentType: 'video/mp4; codecs="avc1.42E01E"', robustness: 'SW_SECURE_CRYPTO', extraOption: 'video-extra' }] }], // should not be used audioContentType: 'audio/mp4; codecs="mp4a.40.5"', audioRobustness: 'HW_SECURE_CRYPTO', videoContentType: 'video/mp4; codecs="avc1.42001e"', videoRobustness: 'HW_SECURE_CRYPTO' }), [{ initDataTypes: ['cenc'], audioCapabilities: [{ contentType: 'audio/mp4; codecs="mp4a.40.2"', robustness: 'SW_SECURE_CRYPTO', extraOption: 'audio-extra' }], videoCapabilities: [{ contentType: 'video/mp4; codecs="avc1.42E01E"', robustness: 'SW_SECURE_CRYPTO', extraOption: 'video-extra' }] }], 'used supportedConfigurations directly' ); });