shaka-player
Version:
DASH/EME video player library
490 lines (424 loc) • 16 kB
JavaScript
/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// The receiver is only meant to run on the Chromecast, so we have the
// ability to use modern APIs there that may not be available on all of the
// browsers our library supports. Because of this, CastReceiver tests will
// only be run on Chrome and Chromecast.
/** @return {boolean} */
const castReceiverIntegrationSupport =
() => shaka.util.Platform.isChrome() || shaka.util.Platform.isChromecast();
filterDescribe('CastReceiver', castReceiverIntegrationSupport, () => {
const CastReceiver = shaka.cast.CastReceiver;
const CastUtils = shaka.cast.CastUtils;
const originalCast = window['cast'];
const originalUserAgent = navigator.userAgent;
const eventManager = new shaka.util.EventManager();
let mockReceiverManager;
let mockReceiverApi;
let mockShakaMessageBus;
let mockGenericMessageBus;
/** @type {shaka.cast.CastReceiver} */
let receiver;
/** @type {shaka.Player} */
let player;
/** @type {HTMLVideoElement} */
let video;
/** @type {shaka.util.PublicPromise} */
let messageWaitPromise;
/** @type {Array.<string>} */
let pendingMessages = null;
/** @type {!Array.<function()>} */
let toRestore;
let pendingWaitWrapperCalls = 0;
/** @type {!Object.<string, ?shaka.extern.DrmSupportType>} */
let support = {};
let fakeInitState;
beforeAll(async () => {
// In uncompiled mode, there is a UA check for Chromecast in order to make
// manual testing easier. For these automated tests, we want to act as if
// we are running on the Chromecast, even in Chrome.
// Since we can't write to window.navigator or navigator.userAgent, we use
// Object.defineProperty.
Object.defineProperty(window['navigator'],
'userAgent', {value: 'CrKey', configurable: true});
shaka.net.NetworkingEngine.registerScheme(
'test', shaka.test.TestScheme.plugin);
shaka.media.ManifestParser.registerParserByMime(
'application/x-test-manifest',
shaka.test.TestScheme.ManifestParser.factory);
await shaka.test.TestScheme.createManifests(shaka, '');
support = await shaka.media.DrmEngine.probeSupport();
});
beforeEach(() => {
mockReceiverApi = createMockReceiverApi();
const mockCanDisplayType = jasmine.createSpy('canDisplayType');
mockCanDisplayType.and.returnValue(true);
// We're using quotes to access window.cast because the compiler
// knows about lots of Cast-specific APIs we aren't mocking. We
// don't need this mock strictly type-checked.
window['cast'] = {
receiver: mockReceiverApi,
__platform__: {canDisplayType: mockCanDisplayType},
};
mockReceiverManager = createMockReceiverManager();
mockShakaMessageBus = createMockMessageBus();
mockGenericMessageBus = createMockMessageBus();
video = shaka.test.UiUtils.createVideoElement();
document.body.appendChild(video);
player = new shaka.Player(video);
receiver = new CastReceiver(video, player);
toRestore = [];
pendingWaitWrapperCalls = 0;
messageWaitPromise = null;
pendingMessages = null;
fakeInitState = {
player: {
configure: {},
},
playerAfterLoad: {
setTextTrackVisibility: true,
},
video: {
loop: true,
playbackRate: 5,
},
manifest: 'test:sintel_no_text',
startTime: 0,
};
});
afterEach(async () => {
for (const restoreCallback of toRestore) {
restoreCallback();
}
await receiver.destroy();
document.body.removeChild(video);
player = null;
video = null;
receiver = null;
if (messageWaitPromise) {
messageWaitPromise.resolve([]);
}
messageWaitPromise = null;
pendingMessages = null;
});
afterAll(() => {
if (originalUserAgent) {
window['cast'] = originalCast;
Object.defineProperty(window['navigator'],
'userAgent', {value: originalUserAgent});
}
});
describe('state changed event', () => {
it('does not trigger a stack overflow', async () => {
const p = waitForLoadedData();
// We had a regression in which polling attributes eventually triggered a
// stack overflow because of a state change event that fired every time
// we checked the state. The error itself got swallowed and hidden
// inside CastReceiver, but would cause tests to disconnect in some
// environments (consistently in GitHub Actions VMs, inconsistently
// elsewhere).
//
// Testing for this is subtle: if we try to catch the error, it will be
// caught at a point when the stack has already or is about to overflow.
// Then if we call "fail", Jasmine will grow the stack further,
// triggering *another* overflow inside of fail(), causing our test to
// *pass*. So we need to fail fast. The best way I have found is to
// catch the very first recursion of pollAttributes_, long before we
// overflow, fail, then return early to avoid the actual recursion.
const original = /** @type {?} */(receiver).pollAttributes_;
let numRecursions = 0;
// eslint-disable-next-line no-restricted-syntax
/** @type {?} */(receiver).pollAttributes_ = function() {
try {
if (numRecursions > 0) {
fail('Found recursion in pollAttributes_!');
return undefined;
}
numRecursions++;
return original.apply(receiver, arguments);
} finally {
numRecursions--;
}
};
// Start the process of loading by sending a fake init message.
fakeConnectedSenders(1);
fakeIncomingMessage({
type: 'init',
initState: fakeInitState,
appData: {},
}, mockShakaMessageBus);
await p;
});
});
describe('without drm', () => {
it('sends reasonably-sized updates', async () => {
// Use an unencrypted asset.
fakeInitState.manifest = 'test:sintel';
const p = waitForLoadedData();
// Start the process of loading by sending a fake init message.
fakeConnectedSenders(1);
fakeIncomingMessage({
type: 'init',
initState: fakeInitState,
appData: {},
}, mockShakaMessageBus);
await p;
// Wait for an update message.
const messages = await waitForUpdateMessages();
for (const message of messages) {
// Check that the update message is of a reasonable size. From previous
// testing we found that the socket would silently reject data that got
// too big. 6KB is safely below the limit.
expect(message.length).toBeLessThan(6000);
}
});
it('has reasonable average message size', async () => {
// Use an unencrypted asset.
fakeInitState.manifest = 'test:sintel';
const p = waitForLoadedData();
// Start the process of loading by sending a fake init message.
fakeConnectedSenders(1);
fakeIncomingMessage({
type: 'init',
initState: fakeInitState,
appData: {},
}, mockShakaMessageBus);
await p;
// Collect messages over 50 update cycles, and average their length.
// Not all properties are passed along on every update message, so
// the average length is expected to be lower than the length of the first
// update message.
let totalLength = 0;
let totalMessages = 0;
for (let i = 0; i < 50; i++) {
// eslint-disable-next-line no-await-in-loop
const messages = await waitForUpdateMessages();
for (const message of messages) {
totalLength += message.length;
totalMessages += 1;
}
}
expect(totalLength / totalMessages).toBeLessThan(2000);
});
});
filterDescribe('with drm', () => support['com.widevine.alpha'], () => {
drmIt('sends reasonably-sized updates', async () => {
// Use an encrypted asset, to make sure DRM info doesn't balloon the size.
fakeInitState.manifest = 'test:sintel-enc';
fakeInitState.player.configure['drm'] = {
'servers': {
'com.widevine.alpha': 'https://cwip-shaka-proxy.appspot.com/no_auth',
},
};
const p = waitForLoadedData();
// Start the process of loading by sending a fake init message.
fakeConnectedSenders(1);
fakeIncomingMessage({
type: 'init',
initState: fakeInitState,
appData: {},
}, mockShakaMessageBus);
await p;
// Wait for an update message.
const messages = await waitForUpdateMessages();
for (const message of messages) {
// Check that the update message is of a reasonable size. From previous
// testing we found that the socket would silently reject data that got
// too big. 6KB is safely below the limit.
expect(message.length).toBeLessThan(6000);
}
});
drmIt('has reasonable average message size', async () => {
// Use an encrypted asset, to make sure DRM info doesn't balloon the size.
fakeInitState.manifest = 'test:sintel-enc';
fakeInitState.player.configure['drm'] = {
'servers': {
'com.widevine.alpha': 'https://cwip-shaka-proxy.appspot.com/no_auth',
},
};
const p = waitForLoadedData();
// Start the process of loading by sending a fake init message.
fakeConnectedSenders(1);
fakeIncomingMessage({
type: 'init',
initState: fakeInitState,
appData: {},
}, mockShakaMessageBus);
await p;
// Collect messages over 50 update cycles, and average their length.
// Not all properties are passed along on every update message, so
// the average length is expected to be lower than the length of the first
// update message.
let totalLength = 0;
let totalMessages = 0;
for (let i = 0; i < 50; i++) {
// eslint-disable-next-line no-await-in-loop
const messages = await waitForUpdateMessages();
for (const message of messages) {
totalLength += message.length;
totalMessages += 1;
}
}
expect(totalLength / totalMessages).toBeLessThan(2000);
});
});
it('sends update messages every stage of loading', async () => {
// Add wrappers to various methods along player.load to make sure that,
// at each stage, the cast receiver can form an update message without
// causing an error.
waitForUpdateMessageWrapper(
shaka.media.ManifestParser, 'ManifestParser', 'getFactory');
waitForUpdateMessageWrapper(
// eslint-disable-next-line no-restricted-syntax
shaka.test.TestScheme.ManifestParser.prototype, 'ManifestParser',
'start');
waitForUpdateMessageWrapper(
// eslint-disable-next-line no-restricted-syntax
shaka.media.DrmEngine.prototype, 'DrmEngine', 'initForPlayback');
waitForUpdateMessageWrapper(
// eslint-disable-next-line no-restricted-syntax
shaka.media.DrmEngine.prototype, 'DrmEngine', 'attach');
waitForUpdateMessageWrapper(
// eslint-disable-next-line no-restricted-syntax
shaka.media.StreamingEngine.prototype, 'StreamingEngine', 'start');
const p = waitForLoadedData();
// Start the process of loading by sending a fake init message.
fakeConnectedSenders(1);
fakeIncomingMessage({
type: 'init',
initState: fakeInitState,
appData: {},
}, mockShakaMessageBus);
await p;
// Make sure that each of the methods covered by
// waitForUpdateMessageWrapper is called by this point.
expect(pendingWaitWrapperCalls).toBe(0);
// Wait for a final update message before proceeding.
await waitForUpdateMessages();
});
/**
* Creates a wrapper around a method on a given prototype, which makes it
* wait on waitForUpdateMessages before returning, and registers that wrapper
* to be uninstalled afterwards.
* The replaced method is expected to be a method that returns a promise.
* @param {!Object} prototype
* @param {string} name
* @param {string} methodName
*/
function waitForUpdateMessageWrapper(prototype, name, methodName) {
pendingWaitWrapperCalls += 1;
const original = prototype[methodName];
// eslint-disable-next-line no-restricted-syntax
prototype[methodName] = /** @this {Object} @return {*} */ async function() {
pendingWaitWrapperCalls -= 1;
shaka.log.debug(
'Waiting for update message before calling ' +
name + '.' + methodName + '...');
const originalArguments = Array.from(arguments);
await waitForUpdateMessages();
// eslint-disable-next-line no-restricted-syntax
return original.apply(this, originalArguments);
};
toRestore.push(() => {
prototype[methodName] = original;
});
}
function waitForLoadedData() {
return new Promise((resolve, reject) => {
eventManager.listenOnce(video, 'loadeddata', resolve);
eventManager.listenOnce(player, 'error', reject);
});
}
function waitForUpdateMessages() {
pendingMessages = [];
messageWaitPromise = new shaka.util.PublicPromise();
return messageWaitPromise;
}
function createMockReceiverApi() {
return {
CastReceiverManager: {
getInstance: () => mockReceiverManager,
},
media: {
// Defined by the SDK, but we aren't loading it here.
MetadataType: {
GENERIC: 0,
MUSIC_TRACK: 3,
},
},
};
}
function createMockReceiverManager() {
return {
start: jasmine.createSpy('CastReceiverManager.start'),
stop: jasmine.createSpy('CastReceiverManager.stop'),
setSystemVolumeLevel:
jasmine.createSpy('CastReceiverManager.setSystemVolumeLevel'),
setSystemVolumeMuted:
jasmine.createSpy('CastReceiverManager.setSystemVolumeMuted'),
getSenders: jasmine.createSpy('CastReceiverManager.getSenders'),
getSystemVolume: () => ({level: 1, muted: false}),
getCastMessageBus: (namespace) => {
if (namespace == CastUtils.SHAKA_MESSAGE_NAMESPACE) {
return mockShakaMessageBus;
}
return mockGenericMessageBus;
},
};
}
function createMockMessageBus() {
const bus = {
messages: [],
broadcast: jasmine.createSpy('CastMessageBus.broadcast'),
getCastChannel: jasmine.createSpy('CastMessageBus.getCastChannel'),
};
// For convenience, deserialize and store sent messages.
bus.broadcast.and.callFake((message) => {
bus.messages.push(CastUtils.deserialize(message));
// Check to see if it's an update message.
const parsed = CastUtils.deserialize(message);
if (parsed.type == 'update' && pendingMessages) {
shaka.log.debug('Received update message. Proceeding...');
// The code waiting on this Promise will get an array of all of the
// messages processed within this tick.
pendingMessages.push(message);
messageWaitPromise.resolve(pendingMessages);
}
});
const channel = {
messages: [],
send: (message) => {
channel.messages.push(CastUtils.deserialize(message));
},
};
bus.getCastChannel.and.returnValue(channel);
return bus;
}
/**
* @param {number} num
*/
function fakeConnectedSenders(num) {
const senderArray = [];
while (num--) {
senderArray.push('senderId');
}
mockReceiverManager.getSenders.and.returnValue(senderArray);
mockReceiverManager.onSenderConnected();
}
/**
* @param {*} message
* @param {!cast.receiver.CastMessageBus} bus
* @param {string=} senderId
*/
function fakeIncomingMessage(message, bus, senderId) {
const serialized = CastUtils.serialize(message);
const messageEvent = {
senderId: senderId,
data: serialized,
};
bus.onMessage(messageEvent);
}
});