text: /*! @license
   * Shaka Player
   * Copyright 2016 Google LLC
   * SPDX-License-Identifier: Apache-2.0
   */
  
  goog.require('shaka.Player');
  goog.require('shaka.test.UiUtils');
  goog.require('shaka.test.Util');
  goog.require('shaka.util.Error');
  
  describe('Player Load Graph', () => {
    const SMALL_MP4_CONTENT_URI = '/base/test/test/assets/small.mp4';
  
    /** @type {!HTMLVideoElement} */
    let video;
    /** @type {shaka.Player} */
    let player;
  
    /** @type {!jasmine.Spy} */
    let stateChangeSpy;
  
    /** @type {!jasmine.Spy} */
    let stateIdleSpy;
  
    beforeAll(() => {
      video = shaka.test.UiUtils.createVideoElement();
      document.body.appendChild(video);
    });
  
    afterAll(() => {
      document.body.removeChild(video);
    });
  
    beforeEach(() => {
      stateChangeSpy = jasmine.createSpy('stateChange');
      stateIdleSpy = jasmine.createSpy('stateIdle');
    });
  
    /**
     * @param {HTMLMediaElement} attachedTo
     */
    function createPlayer(attachedTo) {
      player = new shaka.Player(attachedTo);
      player.addEventListener(
          'onstatechange',
          shaka.test.Util.spyFunc(stateChangeSpy));
      player.addEventListener(
          'onstateidle',
          shaka.test.Util.spyFunc(stateIdleSpy));
    }
  
    // Even though some test will destroy the player, we want to make sure that
    // we don't allow the player to stay attached to the video element.
    afterEach(async () => {
      await player.destroy();
    });
  
    it('attach and initialize media source when constructed with media element',
        async () => {
          expect(video.src).toBeFalsy();
  
          createPlayer(/* attachedTo= */ video);
  
          // Wait until we enter the media source state.
          await new Promise((resolve) => {
            whenEnteringState('media-source', resolve);
          });
  
          expect(video.src).toBeTruthy();
        });
  
    it('does not set video.src when no video is provided', async () => {
      expect(video.src).toBeFalsy();
  
      createPlayer(/* attachedTo= */ null);
  
      // Wait until the player has hit an idle state (no more internal loading
      // actions).
      await spyIsCalled(stateIdleSpy);
  
      expect(video.src).toBeFalsy();
    });
  
    it('attach + initializeMediaSource=true will initialize media source',
        async () => {
          createPlayer(/* attachedTo= */ null);
  
          expect(video.src).toBeFalsy();
          await player.attach(video, /* initializeMediaSource= */ true);
          expect(video.src).toBeTruthy();
        });
  
    it('attach + initializeMediaSource=false will not intialize media source',
        async () => {
          createPlayer(/* attachedTo= */ null);
  
          expect(video.src).toBeFalsy();
          await player.attach(video, /* initializeMediaSource= */ false);
          expect(video.src).toBeFalsy();
        });
  
    it('unload + initializeMediaSource=false does not initialize media source',
        async () => {
          createPlayer(/* attachedTo= */ null);
  
          await player.attach(video);
          await player.load('test:sintel');
  
          await player.unload(/* initializeMediaSource= */ false);
          expect(video.src).toBeFalsy();
        });
  
    it('unload + initializeMediaSource=true initializes media source',
        async () => {
          createPlayer(/* attachedTo= */ null);
  
          await player.attach(video);
          await player.load('test:sintel');
  
          await player.unload(/* initializeMediaSource= */ true);
          expect(video.src).toBeTruthy();
        });
  
    // There was a bug when calling unload before calling load would cause
    // the load to continue before the (first) unload was complete.
    // https://github.com/shaka-project/shaka-player/issues/612
    it('load will wait for unload to finish', async () => {
      createPlayer(/* attachedTo= */ null);
  
      await player.attach(video);
      await player.load('test:sintel');
  
      // We are going to call |unload| and |load| right after each other. What
      // we expect to see is that the player is fully unloaded before the load
      // occurs.
  
      const unload = player.unload();
      const load = player.load('test:sintel');
  
      await unload;
      await load;
  
      expect(getVisitedStates()).toEqual([
        'attach',
  
        // First call to |load|.
        'media-source',
        'manifest-parser',
        'manifest',
        'drm-engine',
        'load',
  
        // Our call to |unload| would have started the transition to
        // "unloaded", but since we called |load| right away, the transition
        // to "unloaded" was most likely done by the call to |load|.
        'unload',
        'attach',
        'media-source',
        'manifest-parser',
        'manifest',
        'drm-engine',
        'load',
      ]);
    });
  
    it('load and unload can be called multiple times', async () => {
      createPlayer(/* attachedTo= */ null);
  
      await player.attach(video);
  
      await player.load('test:sintel');
      await player.unload();
  
      await player.load('test:sintel');
      await player.unload();
  
      expect(getVisitedStates()).toEqual([
        'attach',
        'media-source',
  
        // Load and unload 1
        'manifest-parser',
        'manifest',
        'drm-engine',
        'load',
        'unload',
        'attach',
        'media-source',
  
        // Load and unload 2
        'manifest-parser',
        'manifest',
        'drm-engine',
        'load',
        'unload',
        'attach',
        'media-source',
      ]);
    });
  
    it('load can be called multiple times', async () => {
      createPlayer(/* attachedTo= */ null);
  
      await player.attach(video);
  
      await player.load('test:sintel');
      await player.load('test:sintel');
      await player.load('test:sintel');
  
      expect(getVisitedStates()).toEqual([
        'attach',
  
        // Load 1
        'media-source',
        'manifest-parser',
        'manifest',
        'drm-engine',
        'load',
  
        // Load 2
        'unload',
        'attach',
        'media-source',
        'manifest-parser',
        'manifest',
        'drm-engine',
        'load',
  
        // Load 3
        'unload',
        'attach',
        'media-source',
        'manifest-parser',
        'manifest',
        'drm-engine',
        'load',
      ]);
    });
  
    it('load will interrupt load', async () => {
      createPlayer(/* attachedTo= */ null);
  
      await player.attach(video);
  
      const load1 = player.load('test:sintel');
      const load2 = player.load('test:sintel');
  
      // Load 1 should have been interrupted because of load 2.
      await expectAsync(load1).toBeRejected();
      // Load 2 should finish with no issues.
      await load2;
    });
  
    it('unload will interrupt load', async () => {
      createPlayer(/* attachedTo= */ null);
  
      await player.attach(video);
  
      const load = player.load('test:sintel');
      const unload = player.unload();
  
      await expectAsync(load).toBeRejected();
      await unload;
  
      // We should never have gotten into the loaded state.
      expect(getVisitedStates()).not.toContain('load');
    });
  
    it('destroy will interrupt load', async () => {
      createPlayer(/* attachedTo= */ null);
  
      await player.attach(video);
  
      const load = player.load('test:sintel');
      const destroy = player.destroy();
  
      await expectAsync(load).toBeRejected();
      await destroy;
  
      // We should never have gotten into the loaded state.
      expect(getVisitedStates()).not.toContain('load');
    });
  
    // When |destroy| is called, the player should move through the unload state
    // on its way to the detached state.
    it('destroy will unload and then detach', async () => {
      createPlayer(/* attachedTo= */ null);
  
      await player.attach(video);
  
      await player.load('test:sintel');
      await player.destroy();
  
      // We really only care about the last two elements (unload and detach),
      // however the test is easier to read if we list the full chain.
      expect(getVisitedStates()).toEqual([
        'attach',
        'media-source',
        'manifest-parser',
        'manifest',
        'drm-engine',
        'load',
        'unload',
        'detach',
      ]);
    });
  
    // Calling |unload| multiple times should not cause any problems. Calling
    // |unload| after another |unload| call should just have the player re-enter
    // the state it was waiting in.
    it('unloading multiple times is okay', async () => {
      createPlayer(/* attachedTo= */ null);
  
      await player.attach(video);
  
      await player.load('test:sintel');
      await player.unload();
      await player.unload();
  
      expect(getVisitedStates()).toEqual([
        // |player.attach|
        'attach',
        'media-source',
  
        // |player.load|
        'manifest-parser',
        'manifest',
        'drm-engine',
        'load',
  
        // |player.unload| (first call)
        'unload',
        'attach',
        'media-source',
  
        // |player.unload| (second call)
        'unload',
        'attach',
        'media-source',
      ]);
    });
  
    // When we destroy, it will allow a current unload operation to occur even
    // though we are going to unload and detach as part of |destroy|.
    it('destroy will not interrupt unload', async () => {
      createPlayer(/* attachedTo= */ null);
  
      await player.attach(video);
      await player.load('test:sintel');
  
      const unload = player.unload();
      const destroy = player.destroy();
  
      await unload;
      await destroy;
    });
  
    // While out tests will naturally test this (because we destroy in
    // afterEach), this test will explicitly express our intentions to support
    // the use-case of calling |destroy| multiple times on a player instance.
    it('destroying multiple times is okay', async () => {
      createPlayer(/* attachedTo= */ null);
  
      await player.attach(video);
      await player.load('test:sintel');
  
      await player.destroy();
      await player.destroy();
    });
  
    // As a regression test for Issue #1570, this checks that when we
    // pre-initialize media source engine, we do not re-create the media source
    // instance when loading.
    it('pre-initialized media source is used when player continues loading',
        async () => {
          createPlayer(/* attachedTo= */ null);
  
          // After we attach and initialize media source, we should just see
          // two states in our history.
          await player.attach(video, /* initializeMediaSource= */ true);
          expect(getVisitedStates()).toEqual([
            'attach',
            'media-source',
          ]);
  
          // When we load, the only change in the visited states should be that
          // we added "load".
          await player.load('test:sintel');
          expect(getVisitedStates()).toEqual([
            'attach',
            'media-source',
            'manifest-parser',
            'manifest',
            'drm-engine',
            'load',
          ]);
        });
  
    // We want to make sure that we can interrupt the load process at key-points
    // in time. After each node in the graph, we should be able to reroute and do
    // something different.
    //
    // To test this, we test that we can successfully unload the player after each
    // node after attached. We exclude the nodes before (and including) attach
    // since unloading will put us back at attach (creating a infinite loop).
    describe('interrupt after', () => {
      /**
       * Given the name of a state, tell the player to load content but unload
       * when it reaches |state|. The load should be interrupted and the player
       * should return to the unloaded state.
       *
       * @param {string} state
       * @return {!Promise}
       */
      async function testInterruptAfter(state) {
        createPlayer(/* attachedTo= */ null);
  
        let pendingUnload;
        whenEnteringState(state, () => {
          pendingUnload = player.unload(/* initMediaSource= */ false);
        });
  
        // We attach manually so that we had time to override the state change
        // spy's action.
        await player.attach(video);
        await expectAsync(player.load('test:sintel')).toBeRejected();
  
        // By the time that |player.load| failed, we should have started
        // |player.unload|.
        expect(pendingUnload).toBeTruthy();
        await pendingUnload;
      }
  
      it('media source', async () => {
        await testInterruptAfter('media-source');
      });
  
      it('manifest-parser', async () => {
        await testInterruptAfter('manifest-parser');
      });
  
      it('manifest', async () => {
        await testInterruptAfter('manifest');
      });
  
      it('drm-engine', async () => {
        await testInterruptAfter('drm-engine');
      });
    });
  
    describe('error handling', () => {
      beforeEach(() => {
        createPlayer(/* attachedTo= */ null);
      });
  
      it('returns to attach after load error', async () => {
        // The easiest way we can inject an error is to fail fetching the
        // manifest. To do this, we force the network request by throwing an error
        // in a request filter. The specific error does not matter.
        const networkingEngine = player.getNetworkingEngine();
        expect(networkingEngine).toBeTruthy();
        networkingEngine.registerRequestFilter(() => {
          throw new shaka.util.Error(
              shaka.util.Error.Severity.CRITICAL,
              shaka.util.Error.Category.NETWORK,
              shaka.util.Error.Code.REQUEST_FILTER_ERROR);
        });
  
        // Make the two requests one-after-another so that we don't have any idle
        // time between them.
        const attachRequest = player.attach(video);
        const loadRequest = player.load('test:sintel');
  
        await attachRequest;
        await expectAsync(loadRequest).toBeRejected();
  
        // Wait a couple interrupter cycles to allow the player to enter idle
        // state.
        const event = await spyIsCalled(stateIdleSpy);
  
        // Since attached and loaded in the same interrupter cycle, there won't be
        // any idle time until we finish failing to load. We expect to idle in
        // attach.
        expect(event.state).toBe('attach');
      });
    });
  
    // Some platforms will not have media source support, so we want to make sure
    // that the player will behave as expected when media source is missing.
    describe('without media source', () => {
      let mediaSource;
  
      beforeEach(async () => {
        // Remove our media source support. In order to remove it, we need to set
        // it via [] notation or else closure will stop us.
        mediaSource = window.MediaSource;
        window['MediaSource'] = undefined;
  
        createPlayer(/* attachTo= */ null);
        await spyIsCalled(stateIdleSpy);
      });
  
      afterEach(() => {
        // Restore our media source support to what it was before. If we did not
        // have support before, this will do nothing.
        window['MediaSource'] = mediaSource;
      });
  
      it('attaching ignores init media source flag', async () => {
        // Normally the player would initialize media source after attaching to
        // the media element, however since we don't support media source, it
        // should stop at the attach state.
        player.attach(video, /* initMediaSource= */ true);
  
        const event = await spyIsCalled(stateIdleSpy);
        expect(event.state).toBe('attach');
      });
  
      it('loading ignores media source path', async () => {
        await player.attach(video, /* initMediaSource= */ false);
  
        // Normally the player would load content like this with the media source
        // path, but since we don't have media source support, it should use the
        // src= path.
        player.load(SMALL_MP4_CONTENT_URI);
  
        const event = await spyIsCalled(stateIdleSpy);
        expect(event.state).toBe('src-equals');
      });
  
      it('unloading ignores init media source flag', async () => {
        await player.attach(video, /* initMediaSource= */ false);
        await player.load(SMALL_MP4_CONTENT_URI);
  
        // Normally the player would try to go to the media source state because
        // we are saying to initialize media source after unloading, but since we
        // don't have media source, it should stop at the attach state.
        player.unload(/* initMediaSource= */ true);
  
        const event = await spyIsCalled(stateIdleSpy);
        expect(event.state).toBe('attach');
      });
    });
  
    // We want to make sure that we can move from any state to any of our
    // destination states. This means moving to a state (directly or indirectly)
    // and then telling it to go to one of our destination states (e.g. attach,
    // load with media source, load with src=).
    describe('routing', () => {
      beforeEach(async () => {
        createPlayer(/* attachedTo= */ null);
        await spyIsCalled(stateIdleSpy);
      });
  
      it('goes from detach to detach', async () => {
        await startIn('detach');
        await goTo('detach');
      });
  
      it('goes from detach to attach', async () => {
        await startIn('detach');
        await goTo('attach');
      });
  
      it('goes from detach to media source', async () => {
        await startIn('detach');
        await goTo('media-source');
      });
  
      it('goes from attach to detach', async () => {
        await startIn('attach');
        await goTo('detach');
      });
  
      it('goes from attach to attach', async () => {
        await startIn('attach');
        await goTo('attach');
      });
  
      it('goes from attach to media source', async () => {
        await startIn('attach');
        await goTo('media-source');
      });
  
      it('goes from attach to load', async () => {
        await startIn('attach');
        await goTo('load');
      });
  
      it('goes from attach to src equals', async () => {
        await startIn('attach');
        await goTo('src-equals');
      });
  
      it('goes from media source to detach', async () => {
        await startIn('media-source');
        await goTo('detach');
      });
  
      it('goes from media source to attach', async () => {
        await startIn('media-source');
        await goTo('attach');
      });
  
      it('goes from media source to media source', async () => {
        await startIn('media-source');
        await goTo('media-source');
      });
  
      it('goes from media source to load', async () => {
        await startIn('media-source');
        await goTo('load');
      });
  
      it('goes from media source to src equals', async () => {
        await startIn('media-source');
        await goTo('src-equals');
      });
  
      it('goes from load to detach', async () => {
        await startIn('load');
        await goTo('detach');
      });
  
      it('goes from load to attach', async () => {
        await startIn('load');
        await goTo('attach');
      });
  
      it('goes from load to media source', async () => {
        await startIn('load');
        await goTo('media-source');
      });
  
      it('goes from load to load', async () => {
        await startIn('load');
        await goTo('load');
      });
  
      it('goes from load to src equals', async () => {
        await startIn('load');
        await goTo('src-equals');
      });
  
      it('goes from src equals to detach', async () => {
        await startIn('src-equals');
        await goTo('detach');
      });
  
      it('goes from src equals to attach', async () => {
        await startIn('src-equals');
        await goTo('attach');
      });
  
      it('goes from src equals to media source', async () => {
        await startIn('src-equals');
        await goTo('media-source');
      });
  
      it('goes from src equals to load', async () => {
        await startIn('src-equals');
        await goTo('load');
      });
  
      it('goes from src equals to src equals', async () => {
        await startIn('src-equals');
        await goTo('src-equals');
      });
  
      it('goes from manifest parser to detach', async () => {
        await passingThrough('manifest-parser', () => {
          return goTo('detach');
        });
      });
  
      it('goes from manifest parser to attach', async () => {
        await passingThrough('manifest-parser', () => {
          return goTo('attach');
        });
      });
  
      it('goes from manifest parser to media source', async () => {
        await passingThrough('manifest-parser', () => {
          return goTo('media-source');
        });
      });
  
      it('goes from manifest parser to load', async () => {
        await passingThrough('manifest-parser', () => {
          return goTo('load');
        });
      });
  
      it('goes from manifest parser to src equals', async () => {
        await passingThrough('manifest-parser', () => {
          return goTo('src-equals');
        });
      });
  
      it('goes from manifest to detach', async () => {
        await passingThrough('manifest', () => {
          return goTo('detach');
        });
      });
  
      it('goes from manifest to attach', async () => {
        await passingThrough('manifest', () => {
          return goTo('attach');
        });
      });
  
      it('goes from manifest to media source', async () => {
        await passingThrough('manifest', () => {
          return goTo('media-source');
        });
      });
  
      it('goes from manifest to load', async () => {
        await passingThrough('manifest', () => {
          return goTo('load');
        });
      });
  
      it('goes from manifest to src equals', async () => {
        await passingThrough('manifest', () => {
          return goTo('src-equals');
        });
      });
  
      it('goes from drm engine to detach', async () => {
        await passingThrough('drm-engine', () => {
          return goTo('detach');
        });
      });
  
      it('goes from drm engine to attach', async () => {
        await passingThrough('drm-engine', () => {
          return goTo('attach');
        });
      });
  
      it('goes from drm engine to media source', async () => {
        await passingThrough('drm-engine', () => {
          return goTo('media-source');
        });
      });
  
      it('goes from drm engine to load', async () => {
        await passingThrough('drm-engine', () => {
          return goTo('load');
        });
      });
  
      it('goes from drm engine to src equals', async () => {
        await passingThrough('drm-engine', () => {
          return goTo('src-equals');
        });
      });
  
      it('goes from unload to detach', async () => {
        await passingThrough('unload', () => {
          return goTo('detach');
        });
      });
  
      it('goes from unload to attach', async () => {
        await passingThrough('unload', () => {
          return goTo('attach');
        });
      });
  
      it('goes from unload to media source', async () => {
        await passingThrough('unload', () => {
          return goTo('media-source');
        });
      });
  
      it('goes from unload to load', async () => {
        await passingThrough('unload', () => {
          return goTo('load');
        });
      });
  
      it('goes from unload to src equals', async () => {
        await passingThrough('unload', () => {
          return goTo('src-equals');
        });
      });
  
      /**
       * Put the player into the specific state. This method's purpose is to make
       * it easier to see when the test is assuming the starting state of the
       * player.
       *
       * For states that require the player to be attached to a media element,
       * this will ensure that |attach| is called before making the call to move
       * to the specific state.
       *
       * @param {string} state
       * @return {!Promise}
       */
      async function startIn(state) {
        /** @type {!Map.<string, function():!Promise>} */
        const actions = new Map()
            .set('detach', async () => {
              await player.detach();
            })
            .set('attach', async () => {
              await player.attach(video, /* initMediaSource= */ false);
            })
            .set('media-source', async () => {
              await player.attach(video, /* initMediaSource= */ true);
            })
            .set('load', async () => {
              await player.attach(video, /* initMediaSource= */ true);
              await player.load('test:sintel');
            })
            .set('src-equals', async () => {
              await player.attach(video, /* initMediaSource= */ false);
              await player.load(SMALL_MP4_CONTENT_URI, 0, 'video/mp4');
            });
  
        const action = actions.get(state);
        expect(action).toBeTruthy();
  
        // Do not wait for the action to complete, our idle spy makes us wait. We
        // want to know where we stop, so using the idle spy is more accurate in
        // this situation.
        action();
  
        // Make sure that the player stops in the state that we asked it go to.
        const event = await spyIsCalled(stateIdleSpy);
        expect(event.state).toBe(state);
      }
  
      /**
       * Some states are intermediaries, making it impossible to "start" in them.
       * Instead this method calls |doThis| when we are passing through the state.
       * The goal of this method is to make it possible to test routing when the
       * current route is interrupted to go somewhere.
       *
       * @param {string} state
       * @param {function():!Promise} doThis
       * @return {!Promise}
       */
      async function passingThrough(state, doThis) {
        /** @type {!Set.<string>} */
        const preLoadStates = new Set([
          'manifest-parser',
          'manifest',
          'drm-engine',
        ]);
  
        /** @type {!Set.<string>} */
        const postLoadStates = new Set([
          'unload',
        ]);
  
        // Only a subset of the possible states are actually intermediary states.
        const validState = preLoadStates.has(state) || postLoadStates.has(state);
        expect(validState).toBeTruthy();
  
        // We don't await the last action because it should not finish, however we
        // need to handle the failure or else Jasmine will fail with "Unhandled
        // rejection".
        if (preLoadStates.has(state)) {
          await player.attach(video);
          player.load('test:sintel').catch(() => {});
        } else {
          await player.attach(video);
          await player.load('test:sintel');
          player.unload().catch(() => {});
        }
  
        return new Promise((resolve, reject) => {
          let called = false;
  
          whenEnteringState(state, () => {
            // Make sure we don't execute more than once per promise.
            if (called) {
              return;
            }
            called = true;
  
            // We need to call doThis in-sync with entering the state so that it
            // can start in the same interpreter cycle. If we did not do this, the
            // player could have changed states before |doThis| was called.
            doThis().then(resolve, reject);
          });
        });
      }
  
      /**
       * Go to a specific state. This does not ensure the current state before
       * starting the state change.
       *
       * @param {string} state
       * @return {!Promise}
       */
      async function goTo(state) {
        /** @type {!Map.<string, function():!Promise>} */
        const actions = new Map()
            .set('detach', () => {
              return player.detach();
            })
            .set('attach', () => {
              return player.attach(video, /* initMediaSource= */ false);
            })
            .set('media-source', () => {
              return player.attach(video, /* initMediaSource= */ true);
            })
            .set('load', () => {
              return player.load('test:sintel');
            })
            .set('src-equals', () => {
              return player.load(SMALL_MP4_CONTENT_URI, 0, 'video/mp4');
            });
  
        const action = actions.get(state);
        expect(action).toBeTruthy();
  
        // Do not wait for the action to complete, our idle spy make us wait. We
        // want to know where we stop, so using the idle spy is more accurate in
        // this situation.
        action();
  
        const event = await spyIsCalled(stateIdleSpy);
        expect(event.state).toBe(state);
      }
    });
  
    /**
     * Get a list of all the states that the walker went through after
     * |beforeEach| finished.
     *
     * @return {!Array.<string>}
     */
    function getVisitedStates() {
      const states = [];
      for (const call of stateChangeSpy.calls.allArgs()) {
        states.push(call[0].state);
      }
      return states;
    }
  
    /**
     * Overwrite our |stateChangeSpy| so that it will do |doThis| when we
     * enter |state|. |doThis| will be executed synchronously to ensure that
     * it is done before the walker does its next action.
     *
     * @param {string} state
     * @param {function()} doThis
     */
    function whenEnteringState(state, doThis) {
      stateChangeSpy.and.callFake((event) => {
        if (event.state == state) {
          doThis();
        }
      });
    }
  
    /**
     * Wrap a spy in a promise so that it will resolve when the spy is called.
     *
     * @param {!jasmine.Spy} spy
     * @return {!Promise}
     */
    function spyIsCalled(spy) {
      return new Promise((resolve) => {
        spy.and.callFake(resolve);
      });
    }
  });
  