shaka-player
Version:
DASH/EME video player library
506 lines (428 loc) • 16.3 kB
JavaScript
/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
describe('Walker', () => {
const AbortableOperation = shaka.util.AbortableOperation;
// For our tests, we will not use the payload in our routing logic. To
// avoid distracting the reader with payload details, hide them here.
const payload = {
factory: null,
mediaElement: null,
mimeType: null,
startTime: null,
startTimeOfLoad: NaN,
uri: null,
keepAdManager: false,
};
// The graph topology that we will be using for our tests.
//
// [ A ] ---> [ B ] ---> [ E ]
// ^ |
// | v
// [ D ] <--- [ C ]
//
/** @type {shaka.routing.Node} */
const nodeA = {name: 'a'};
/** @type {shaka.routing.Node} */
const nodeB = {name: 'b'};
/** @type {shaka.routing.Node} */
const nodeC = {name: 'c'};
/** @type {shaka.routing.Node} */
const nodeD = {name: 'd'};
/** @type {shaka.routing.Node} */
const nodeE = {name: 'e'};
/**
* @param {shaka.routing.Node} at
* @param {shaka.routing.Node} goingTo
* @return {shaka.routing.Node}
*/
function getNext(at, goingTo) {
// In this graph, where you start determines where you go. The one exception
// is node B, which acts as a fork between nodes C and E. This fork is
// important in testing interrupts.
let goTo = null;
if (at == nodeA) {
goTo = nodeB;
}
if (at == nodeB) {
goTo = goingTo == nodeE ? nodeE : nodeC;
}
if (at == nodeC) {
goTo = nodeD;
}
if (at == nodeD) {
goTo = nodeA;
}
goog.asserts.assert(goTo, 'We should have found a next step.');
return goTo;
}
/** @type {!shaka.routing.Walker} */
let walker;
/** @type {!jasmine.Spy} */
let enterNodeSpy;
/** @type {!jasmine.Spy} */
let handleErrorSpy;
/** @type {!jasmine.Spy} */
let idleSpy;
beforeEach(() => {
enterNodeSpy = jasmine.createSpy('enterNode');
enterNodeSpy.and.returnValue(AbortableOperation.completed(undefined));
handleErrorSpy = jasmine.createSpy('handleError');
idleSpy = jasmine.createSpy('idle');
const implementation = {
getNext: (at, has, goingTo, wants) => getNext(at, goingTo),
enterNode: shaka.test.Util.spyFunc(enterNodeSpy),
handleError: shaka.test.Util.spyFunc(handleErrorSpy),
onIdle: shaka.test.Util.spyFunc(idleSpy),
};
walker = new shaka.routing.Walker(nodeA, payload, implementation);
});
afterEach(async () => {
await walker.destroy();
});
it('enters idle after initialization', async () => {
await waitOnSpy(idleSpy);
});
it('enters idle after completing route', async () => {
// Execute a route but then wait a couple interrupter cycles to allow the
// walker time to idle.
await completesRoute(startNewRoute(nodeD, /* interruptible= */ false));
await waitOnSpy(idleSpy);
});
it('enters idle after error', async () => {
// The specific error does not matter.
const error = new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.TEXT,
shaka.util.Error.Code.BAD_ENCODING);
enterNodeSpy.and.callFake((at) => {
return at == nodeC ?
shaka.util.AbortableOperation.failed(error) :
shaka.util.AbortableOperation.completed(undefined);
});
handleErrorSpy.and.returnValue(nodeA);
// Go to nodeD, passing through nodeC. This will fail, calling handleError,
// and returning the walker to nodeA. Wait a couple interrupter cycles to
// allow the walker time to idle. The route needs to be interruptible since
// we are going to return an aborted operation.
await failsRoute(startNewRoute(nodeD, /* interruptible= */ true));
await waitOnSpy(idleSpy);
});
// The walker should move node-by-node, so starting in node A (see beforeEach)
// and going to nodeD, we should see the walker enter nodeB, nodeC, and nodeD.
// We won't see it enter nodeA because it starts there and therefore never
// "enters" that node.
it('moves node-by-node', async () => {
// We don't expect any errors in this test.
handleErrorSpy.and.callFake(fail);
await completesRoute(startNewRoute(nodeD, /* interruptible= */ false));
const steps = getStepsTaken();
expect(steps).toEqual([nodeB, nodeC, nodeD]);
});
// We want to make sure that if a route is registered as interruptible, we
// can interrupt the route and start a new route.
//
// For this we will have the walker start going to node E, but when it
// enters node B (the fork) we will interrupt it and tell it to go to
// node A.
it('can interrupt interruptible routes', async () => {
// We don't expect any errors in this test.
handleErrorSpy.and.callFake(fail);
/** @type {!shaka.util.PublicPromise} */
const atA = new shaka.util.PublicPromise();
const interrupt = () => {
const goToA = startNewRoute(nodeA, /* interruptible= */ false);
goToA.onEnd = () => atA.resolve();
};
const goingToE = startNewRoute(nodeE, /* interruptible= */ true);
goingToE.onEnter = (node) => {
if (node == nodeB) {
interrupt();
}
};
await atA;
const steps = getStepsTaken();
expect(steps).toEqual([nodeB, nodeC, nodeD, nodeA]);
});
// We want to make sure that a non-interruptible route cannot be interrupted
// by starting a new route. To do this, we are going to start off by going to
// node c. When we get to node b, we will try to start a new route to node e.
//
// If the route was interrupted, we would go to straight to node e (node b is
// a fork in the graph). However, since we expect the first route to finish,
// we expect to see the walker go to node c and then continue around to get to
// node e.
it('cannot interrupt non-interruptible routes', async () => {
// We don't expect any errors in this test.
handleErrorSpy.and.callFake(fail);
/** @type {!shaka.util.PublicPromise} */
const atE = new shaka.util.PublicPromise();
const interrupt = () => {
const goToE = startNewRoute(nodeE, /* interruptible= */ false);
goToE.onEnd = () => atE.resolve();
};
// Create a "trap" so that once we enter node B (the fork) that we will
// issue a new route - this will ensure that we are trying to interrupt a
// route mid-execution.
const goingToC = startNewRoute(nodeC, /* interruptible= */ false);
goingToC.onEnter = (node) => {
interrupt();
};
await atE;
const steps = getStepsTaken();
expect(steps).toEqual([
// First route.
nodeB, nodeC,
// Second route.
nodeD, nodeA, nodeB, nodeE,
]);
});
// We do not want to execute steps for a route that will be interrupted
// right after it starts. For this example, we queue-up three routes:
// 1. Non-interruptible
// 2. Interruptible
// 3. Interruptible
//
// What we expect to see is that Route 1 finishes, Route 2 starts but takes
// no steps, and Route 3 finishes.
it('does not take steps for route interrupted before starting', async () => {
/**
* When a route starts, it will assign this value to its id (1, 2, or 3)
* so we always know who was the most recent route to start.
*
* @type {?number}
*/
let currentRoute = null;
/**
* We use this set to know who took steps. When the walker takes a step
* we will add |currentRoute| to this set. That way we will know what
* route we took steps on.
*
* @type {!Array.<?number>}
*/
const tookSteps = [];
enterNodeSpy.and.callFake((node) => {
tookSteps.push(currentRoute);
return AbortableOperation.completed(undefined);
});
const route1 = startNewRoute(nodeD, /* interruptible= */ false);
route1.onStart = () => {
currentRoute = 1;
};
const route2 = startNewRoute(nodeC, /* interruptible= */ true);
route2.onStart = () => {
currentRoute = 2;
};
const route3 = startNewRoute(nodeE, /* interruptible= */ true);
route3.onStart = () => {
currentRoute = 3;
};
// Wait until we get to the end of route 3, that should be the end.
await completesRoute(route3);
// Make sure we had the correct routes when taking each step.
expect(tookSteps).toEqual([
1, // A to B
1, // B to C
1, // C to D
3, // D to A
3, // A to B
3, // B to E
]);
});
// When we destroy the walker, it should cancel all routes - even the
// non-interruptible routes.
it('cancels all routes when destroyed', async () => {
// Start-up a couple routes, and then destroy the walker. We expect to see
// both routes have their |onCancel| callbacks called. We make the first
// be non-interruptible and the second route interruptible so that we can
// see both types be cancelled by |destroy|. The non-interruptible route
// must before first or else it would interrupt the other route.
const goToC = startNewRoute(nodeC, /* interruptible= */ false);
const goToB = startNewRoute(nodeB, /* interruptible= */ true);
/** @type {!jasmine.Spy} */
const canceledCSpy = jasmine.createSpy('cancel c');
goToC.onCancel = shaka.test.Util.spyFunc(canceledCSpy);
/** @type {!jasmine.Spy} */
const canceledBSpy = jasmine.createSpy('cancel b');
goToB.onCancel = shaka.test.Util.spyFunc(canceledBSpy);
await walker.destroy();
expect(canceledCSpy).toHaveBeenCalled();
expect(canceledBSpy).toHaveBeenCalled();
});
it('calls handleError when step fails', async () => {
// The specific error does not matter.
const error = new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.TEXT,
shaka.util.Error.Code.BAD_ENCODING);
// Make the walker fail when it hits node C. This should allow us to
// exercise the |handleError| path.
enterNodeSpy.and.callFake((at) => {
return at == nodeC ?
shaka.util.AbortableOperation.failed(error) :
shaka.util.AbortableOperation.completed(undefined);
});
// We want to handle the error and return to a safe state, so just put the
// walker back at node A.
handleErrorSpy.and.returnValue(nodeA);
// Go to D (passing through C). This should throw an error, so wait for the
// error to be seen. The route must be abortable because we are going to
// throw an abortable error.
await failsRoute(startNewRoute(nodeD, /* interruptible= */ true));
expect(handleErrorSpy).toHaveBeenCalled();
});
// When we interrupt a route that has a step that can be aborted, it should
// abort the operation and enter the error recovery mode.
//
// To model this we will make a node be a never-resolving node. We will enter
// the node and then get stuck. From there we will request a new route, the
// blocked op will abort, and then we will go to our new destination.
it('can abort current step', async () => {
// Because we need a node to start at after resetting, we will just use A.
// There is no special reason for node A.
handleErrorSpy.and.returnValue(Promise.resolve(nodeA));
// Block when we enter node C so that we can re-route to node E and finish a
// path.
blockWalkerAt(nodeC);
// Wait for us to enter node d before continuing. We introduce a small delay
// to ensure that we are "stuck" on the abortable operation.
const goingToD = startNewRoute(nodeD, /* interruptible= */ true);
await waitUntilEntering(goingToD, nodeC);
await shaka.test.Util.shortDelay();
await completesRoute(startNewRoute(nodeE, /* interruptible= */ true));
expect(handleErrorSpy).toHaveBeenCalled();
});
// If we are in the middle of a node and |destroy| is called, we want to
// ensure that we exit as soon as possible. If the current step is abortable
// then we want to abort.
it('can abort current step with destroy', async () => {
// Because we need a node to start at after resetting, we will just use A.
// There is no special reason for node A.
handleErrorSpy.and.returnValue(Promise.resolve(nodeA));
// Block when we enter node C so that we can re-route to node E and finish a
// path.
blockWalkerAt(nodeC);
// Wait for us to enter node d before continuing. We introduce a small delay
// to ensure that we are "stuck" on the abortable operation.
const goingToD = startNewRoute(nodeD, /* interruptible= */ true);
await waitUntilEntering(goingToD, nodeC);
await shaka.test.Util.shortDelay();
// We are "stuck" in nodeC. We will now destroy the walker which should
// abort the nodeC step, enter error recovery mode, and then shutdown.
await walker.destroy();
expect(handleErrorSpy).toHaveBeenCalled();
});
/**
* Ask the walker to start a new route. Since the requests from our tests
* are very basic, wrapping the call should not hide too much information.
*
* @param {shaka.routing.Node} goingTo
* @param {boolean} interruptible
*
* @return {shaka.routing.Walker.Listeners}
*/
function startNewRoute(goingTo, interruptible) {
return walker.startNewRoute((currentPayload) => {
return {
node: goingTo,
payload: payload,
interruptible: interruptible,
};
});
}
/**
* Get the series of nodes that the walker went through during its "journey".
*
* @return {!Array.<shaka.routing.Node>}
*/
function getStepsTaken() {
// Use |onNode| to get the steps that completed.
return enterNodeSpy.calls.allArgs().map((args) => args[0]);
}
/**
* Configure the |enterNodeSpy| so that we will block when we enter |node|. In
* order to unblock, the route will need to be interrupted.
*
* @param {shaka.routing.Node} node
*/
function blockWalkerAt(node) {
/** @type {!shaka.util.AbortableOperation} */
const completedOp = AbortableOperation.completed(undefined);
/** @type {!shaka.util.PublicPromise} */
const waitForever = new shaka.util.PublicPromise();
/** @type {!shaka.util.AbortableOperation} */
const blockingOp = new AbortableOperation(
waitForever,
() => {
waitForever.reject(new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.PLAYER,
shaka.util.Error.Code.OPERATION_ABORTED));
return waitForever;
});
enterNodeSpy.and.callFake((at) => {
return at == node ? blockingOp : completedOp;
});
}
/**
* Create a promise that will resolve when we enter |node| while on the route
* that produced |events|.
*
* @param {shaka.routing.Walker.Listeners} events
* @param {shaka.routing.Node} node
* @return {!Promise}
*/
function waitUntilEntering(events, node) {
return new Promise((resolve) => {
events.onEnter = (node) => {
if (node == nodeC) {
resolve();
}
};
});
}
/**
* Create a promise from a walker's route's listeners. This assumes that the
* route should finish. The promise will resolve if the route completes and
* will reject if the route fails to complete for any reason.
*
* @param {shaka.routing.Walker.Listeners} events
* @return {!Promise}
*/
function completesRoute(events) {
return new Promise((resolve, reject) => {
events.onEnd = resolve;
events.onCancel = reject;
events.onError = reject;
});
}
/**
* Create a promise from a walker's route's listeners. This assumes that the
* route should not finish. The promise will resolve if the route fails and
* will reject if the route completes.
*
* @param {shaka.routing.Walker.Listeners} events
* @return {!Promise}
*/
function failsRoute(events) {
return new Promise((resolve, reject) => {
events.onEnd = reject;
events.onCancel = resolve;
events.onError = resolve;
});
}
/**
* Wrap a spy in a promise so that the promise will resolve once the spy is
* called.
*
* @param {!jasmine.Spy} spy
* @return {!Promise}
*/
function waitOnSpy(spy) {
return new Promise((resolve) => {
spy.and.callFake(resolve);
});
}
});