/*! @license
 * Shaka Player
 * Copyright 2016 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */


/**
 * @typedef {{
 *   length: number,
 *   start: jasmine.Spy,
 *   end: jasmine.Spy
 * }}
 */
let MockTimeRanges;


/**
 * @typedef {{
 *   abort: jasmine.Spy,
 *   appendBuffer: jasmine.Spy,
 *   remove: jasmine.Spy,
 *   updating: boolean,
 *   addEventListener: jasmine.Spy,
 *   removeEventListener: jasmine.Spy,
 *   buffered: (MockTimeRanges|TimeRanges),
 *   timestampOffset: number,
 *   appendWindowEnd: number,
 *   updateend: function(),
 *   error: function()
 * }}
 */
let MockSourceBuffer;


describe('MediaSourceEngine', () => {
  const Util = shaka.test.Util;
  const ContentType = shaka.util.ManifestParserUtils.ContentType;

  const originalIsTypeSupported = window.MediaSource.isTypeSupported;
  const originalTextEngine = shaka.text.TextEngine;
  const originalCreateMediaSource =
      // eslint-disable-next-line no-restricted-syntax
      shaka.media.MediaSourceEngine.prototype.createMediaSource;
  const originalTransmuxer = shaka.media.Transmuxer;

  // Jasmine Spies don't handle toHaveBeenCalledWith well with objects, so use
  // some numbers instead.
  const buffer = /** @type {!ArrayBuffer} */ (/** @type {?} */ (1));
  const buffer2 = /** @type {!ArrayBuffer} */ (/** @type {?} */ (2));
  const buffer3 = /** @type {!ArrayBuffer} */ (/** @type {?} */ (3));

  const fakeVideoStream = {mimeType: 'video/foo'};
  const fakeAudioStream = {mimeType: 'audio/foo'};
  const fakeTextStream = {mimeType: 'text/foo'};
  const fakeTransportStream = {mimeType: 'tsMimetype'};

  let audioSourceBuffer;
  let videoSourceBuffer;
  let mockVideo;
  /** @type {HTMLMediaElement} */
  let video;
  let mockMediaSource;

  let mockTextEngine;
  /** @type {!shaka.test.FakeTextDisplayer} */
  let mockTextDisplayer;
  /** @type {!shaka.test.FakeClosedCaptionParser} */
  let mockClosedCaptionParser;
  /** @type {!shaka.test.FakeTransmuxer} */
  let mockTransmuxer;

  /** @type {!jasmine.Spy} */
  let createMediaSourceSpy;

  /** @type {!shaka.media.MediaSourceEngine} */
  let mediaSourceEngine;

  beforeAll(() => {
    // Since this is not an integration test, we don't want MediaSourceEngine to
    // fail assertions based on browser support for types.  Pretend that all
    // video and audio types are supported.
    window.MediaSource.isTypeSupported = (mimeType) => {
      const type = mimeType.split('/')[0];
      return type == 'video' || type == 'audio';
    };
  });

  afterAll(() => {
    window.MediaSource.isTypeSupported = originalIsTypeSupported;
    shaka.media.Transmuxer = originalTransmuxer;
  });

  beforeEach(/** @suppress {invalidCasts} */ () => {
    audioSourceBuffer = createMockSourceBuffer();
    videoSourceBuffer = createMockSourceBuffer();
    mockMediaSource = createMockMediaSource();
    mockMediaSource.addSourceBuffer.and.callFake((mimeType) => {
      const type = mimeType.split('/')[0];
      return type == 'audio' ? audioSourceBuffer : videoSourceBuffer;
    });
    mockTransmuxer = new shaka.test.FakeTransmuxer();

    // eslint-disable-next-line no-restricted-syntax
    shaka.media.Transmuxer = /** @type {?} */ (function() {
      return /** @type {?} */ (mockTransmuxer);
    });
    shaka.media.Transmuxer.convertTsCodecs = originalTransmuxer.convertTsCodecs;
    shaka.media.Transmuxer.isSupported = (mimeType, contentType) => {
      return mimeType == 'tsMimetype';
    };

    shaka.text.TextEngine = createMockTextEngineCtor();

    createMediaSourceSpy = jasmine.createSpy('createMediaSource');
    createMediaSourceSpy.and.callFake((p) => {
      p.resolve();
      return mockMediaSource;
    });
    // eslint-disable-next-line no-restricted-syntax
    shaka.media.MediaSourceEngine.prototype.createMediaSource =
        Util.spyFunc(createMediaSourceSpy);

    // MediaSourceEngine uses video to:
    //  - set src attribute
    //  - read error codes when operations fail
    //  - seek to flush the pipeline on some platforms
    //  - check buffered.length to assert that flushing the pipeline is okay
    mockVideo = {
      src: '',
      error: null,
      currentTime: 0,
      buffered: {
        length: 0,
      },
      removeAttribute: /** @this {HTMLVideoElement} */ (attr) => {
        // Only called with attr == 'src'.
        // This assertion alerts us if the requirements for this mock change.
        goog.asserts.assert(attr == 'src', 'Unexpected removeAttribute() call');
        mockVideo.src = '';
      },
      load: /** @this {HTMLVideoElement} */ () => {
        // This assertion alerts us if the requirements for this mock change.
        goog.asserts.assert(mockVideo.src == '', 'Unexpected load() call');
      },
    };
    video = /** @type {HTMLMediaElement} */(mockVideo);
    mockClosedCaptionParser = new shaka.test.FakeClosedCaptionParser();
    mockTextDisplayer = new shaka.test.FakeTextDisplayer();
    mediaSourceEngine = new shaka.media.MediaSourceEngine(
        video,
        mockClosedCaptionParser,
        mockTextDisplayer);
  });

  afterEach(() => {
    mockTextEngine = null;
    shaka.text.TextEngine = originalTextEngine;
    // eslint-disable-next-line no-restricted-syntax
    shaka.media.MediaSourceEngine.prototype.createMediaSource =
        originalCreateMediaSource;
  });

  describe('constructor', () => {
    const originalCreateObjectURL =
      shaka.media.MediaSourceEngine.createObjectURL;
    const originalMediaSource = window.MediaSource;
    /** @type {jasmine.Spy} */
    let createObjectURLSpy;

    beforeEach(async () => {
      // Mock out MediaSource so we can test the production version of
      // createMediaSource.  To do this, the test must call the
      // MediaSourceEngine constructor again.  The call beforeEach was done with
      // a mocked createMediaSource.
      createMediaSourceSpy.calls.reset();
      createMediaSourceSpy.and.callFake(originalCreateMediaSource);

      createObjectURLSpy = jasmine.createSpy('createObjectURL');
      createObjectURLSpy.and.returnValue('blob:foo');
      shaka.media.MediaSourceEngine.createObjectURL =
        Util.spyFunc(createObjectURLSpy);

      const mediaSourceSpy =
          jasmine.createSpy('MediaSource').and.callFake(() => {
            return mockMediaSource;
          });
      window.MediaSource = Util.spyFunc(mediaSourceSpy);

      await mediaSourceEngine.destroy();
    });

    afterAll(() => {
      shaka.media.MediaSourceEngine.createObjectURL = originalCreateObjectURL;
      window.MediaSource = originalMediaSource;
    });

    it('creates a MediaSource object and sets video.src', () => {
      mediaSourceEngine = new shaka.media.MediaSourceEngine(
          video,
          new shaka.test.FakeClosedCaptionParser(),
          new shaka.test.FakeTextDisplayer());

      expect(createMediaSourceSpy).toHaveBeenCalled();
      expect(createObjectURLSpy).toHaveBeenCalled();
      expect(mockVideo.src).toBe('blob:foo');
    });
  });

  describe('init', () => {
    it('creates SourceBuffers for the given types', async () => {
      const initObject = new Map();
      initObject.set(ContentType.AUDIO, fakeAudioStream);
      initObject.set(ContentType.VIDEO, fakeVideoStream);
      await mediaSourceEngine.init(initObject, false);
      expect(mockMediaSource.addSourceBuffer).toHaveBeenCalledWith('audio/foo');
      expect(mockMediaSource.addSourceBuffer).toHaveBeenCalledWith('video/foo');
      expect(shaka.text.TextEngine).not.toHaveBeenCalled();
    });

    it('creates TextEngines for text types', async () => {
      const initObject = new Map();
      initObject.set(ContentType.TEXT, fakeTextStream);
      await mediaSourceEngine.init(initObject, false);
      expect(mockMediaSource.addSourceBuffer).not.toHaveBeenCalled();
      expect(shaka.text.TextEngine).toHaveBeenCalled();
    });
  });

  describe('bufferStart and bufferEnd', () => {
    beforeEach(async () => {
      const initObject = new Map();
      initObject.set(ContentType.AUDIO, fakeAudioStream);
      initObject.set(ContentType.TEXT, fakeTextStream);
      await mediaSourceEngine.init(initObject, false);
    });

    it('returns correct timestamps for one range', () => {
      audioSourceBuffer.buffered = createFakeBuffered([{start: 0, end: 10}]);

      expect(mediaSourceEngine.bufferStart(ContentType.AUDIO)).toBeCloseTo(0);
      expect(mediaSourceEngine.bufferEnd(ContentType.AUDIO)).toBeCloseTo(10);
    });

    it('returns correct timestamps for multiple ranges', () => {
      audioSourceBuffer.buffered =
          createFakeBuffered([{start: 5, end: 10}, {start: 20, end: 30}]);

      expect(mediaSourceEngine.bufferStart(ContentType.AUDIO)).toBeCloseTo(5);
      expect(mediaSourceEngine.bufferEnd(ContentType.AUDIO)).toBeCloseTo(30);
    });

    it('returns null if there are no ranges', () => {
      audioSourceBuffer.buffered = createFakeBuffered([]);

      expect(mediaSourceEngine.bufferStart(ContentType.AUDIO)).toBeNull();
      expect(mediaSourceEngine.bufferEnd(ContentType.AUDIO)).toBeNull();
    });

    it('will forward to TextEngine', () => {
      mockTextEngine.bufferStart.and.returnValue(10);
      mockTextEngine.bufferEnd.and.returnValue(20);

      expect(mockTextEngine.bufferStart).not.toHaveBeenCalled();
      expect(mediaSourceEngine.bufferStart(ContentType.TEXT)).toBe(10);
      expect(mockTextEngine.bufferStart).toHaveBeenCalled();

      expect(mockTextEngine.bufferEnd).not.toHaveBeenCalled();
      expect(mediaSourceEngine.bufferEnd(ContentType.TEXT)).toBe(20);
      expect(mockTextEngine.bufferEnd).toHaveBeenCalled();
    });
  });

  describe('bufferedAheadOf', () => {
    beforeEach(async () => {
      const initObject = new Map();
      initObject.set(ContentType.AUDIO, fakeAudioStream);
      initObject.set(ContentType.TEXT, fakeTextStream);
      await mediaSourceEngine.init(initObject, false);
    });

    it('returns the amount of data ahead of the given position', () => {
      audioSourceBuffer.buffered = createFakeBuffered([{start: 0, end: 10}]);

      expect(mediaSourceEngine.bufferedAheadOf(ContentType.AUDIO, 0))
          .toBeCloseTo(10);
      expect(mediaSourceEngine.bufferedAheadOf(ContentType.AUDIO, 5))
          .toBeCloseTo(5);
      expect(mediaSourceEngine.bufferedAheadOf(ContentType.AUDIO, 9.9))
          .toBeCloseTo(0.1);
    });

    it('returns zero when given an unbuffered time', () => {
      audioSourceBuffer.buffered = createFakeBuffered([{start: 5, end: 10}]);

      expect(mediaSourceEngine.bufferedAheadOf(ContentType.AUDIO, 10))
          .toBeCloseTo(0);
      expect(mediaSourceEngine.bufferedAheadOf(ContentType.AUDIO, 100))
          .toBeCloseTo(0);
    });

    it('returns the correct amount with multiple ranges', () => {
      audioSourceBuffer.buffered =
          createFakeBuffered([{start: 1, end: 3}, {start: 6, end: 10}]);

      // in range 0
      expect(mediaSourceEngine.bufferedAheadOf(ContentType.AUDIO, 1))
          .toBeCloseTo(6);
      expect(mediaSourceEngine.bufferedAheadOf(ContentType.AUDIO, 2.5))
          .toBeCloseTo(4.5);

      // between range 0 and 1
      expect(mediaSourceEngine.bufferedAheadOf(ContentType.AUDIO, 5))
          .toBeCloseTo(4);

      // in range 1
      expect(mediaSourceEngine.bufferedAheadOf(ContentType.AUDIO, 6))
          .toBeCloseTo(4);
      expect(mediaSourceEngine.bufferedAheadOf(ContentType.AUDIO, 9.9))
          .toBeCloseTo(0.1);
    });

    it('will forward to TextEngine', () => {
      mockTextEngine.bufferedAheadOf.and.returnValue(10);

      expect(mockTextEngine.bufferedAheadOf).not.toHaveBeenCalled();
      expect(mediaSourceEngine.bufferedAheadOf(ContentType.TEXT, 5)).toBe(10);
      expect(mockTextEngine.bufferedAheadOf).toHaveBeenCalledWith(5);
    });
  });

  describe('appendBuffer', () => {
    beforeEach(async () => {
      captureEvents(audioSourceBuffer, ['updateend', 'error']);
      captureEvents(videoSourceBuffer, ['updateend', 'error']);
      const initObject = new Map();
      initObject.set(ContentType.AUDIO, fakeAudioStream);
      initObject.set(ContentType.VIDEO, fakeVideoStream);
      initObject.set(ContentType.TEXT, fakeTextStream);
      await mediaSourceEngine.init(initObject, false);
    });

    it('appends the given data', async () => {
      const p = mediaSourceEngine.appendBuffer(
          ContentType.AUDIO, buffer, null, null,
          /* hasClosedCaptions= */ false);
      expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer);
      audioSourceBuffer.updateend();
      await p;
    });

    it('rejects promise when operation throws', async () => {
      audioSourceBuffer.appendBuffer.and.throwError('fail!');
      mockVideo.error = {code: 5};
      const expected = Util.jasmineError(new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.MEDIA,
          shaka.util.Error.Code.MEDIA_SOURCE_OPERATION_THREW,
          jasmine.objectContaining({message: 'fail!'})));
      await expectAsync(
          mediaSourceEngine.appendBuffer(
              ContentType.AUDIO, buffer, null, null,
              /* hasClosedCaptions= */ false))
          .toBeRejectedWith(expected);
      expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer);
    });

    it('rejects promise when op. throws QuotaExceededError', async () => {
      const fakeDOMException = {name: 'QuotaExceededError'};
      audioSourceBuffer.appendBuffer.and.callFake(() => {
        throw fakeDOMException;
      });
      mockVideo.error = {code: 5};
      const expected = Util.jasmineError(new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.MEDIA,
          shaka.util.Error.Code.QUOTA_EXCEEDED_ERROR,
          ContentType.AUDIO));
      await expectAsync(
          mediaSourceEngine.appendBuffer(
              ContentType.AUDIO, buffer, null, null,
              /* hasClosedCaptions= */ false))
          .toBeRejectedWith(expected);
      expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer);
    });

    it('handles QuotaExceededError for pending operations', async () => {
      const fakeDOMException = {name: 'QuotaExceededError'};
      audioSourceBuffer.appendBuffer.and.callFake(() => {
        if (audioSourceBuffer.appendBuffer.calls.count() > 1) {
          throw fakeDOMException;
        }
      });
      mockVideo.error = {code: 5};
      const expected = Util.jasmineError(new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.MEDIA,
          shaka.util.Error.Code.QUOTA_EXCEEDED_ERROR,
          ContentType.AUDIO));

      const p1 = mediaSourceEngine.appendBuffer(
          ContentType.AUDIO, buffer, null, null,
          /* hasClosedCaptions= */ false);
      const p2 = mediaSourceEngine.appendBuffer(
          ContentType.AUDIO, buffer, null, null,
          /* hasClosedCaptions= */ false);
      audioSourceBuffer.updateend();
      await expectAsync(p1).toBeResolved();
      await expectAsync(p2).toBeRejectedWith(expected);
    });

    it('rejects the promise if this operation fails async', async () => {
      mockVideo.error = {code: 5};
      const p = mediaSourceEngine.appendBuffer(
          ContentType.AUDIO, buffer, null, null,
          /* hasClosedCaptions= */ false);
      audioSourceBuffer.error();
      audioSourceBuffer.updateend();

      const expected = Util.jasmineError(new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.MEDIA,
          shaka.util.Error.Code.MEDIA_SOURCE_OPERATION_FAILED,
          5));
      await expectAsync(p).toBeRejectedWith(expected);
      expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer);
    });

    it('queues operations on a single SourceBuffer', async () => {
      /** @type {!shaka.test.StatusPromise} */
      const p1 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer(
          ContentType.AUDIO, buffer, null, null,
          /* hasClosedCaptions= */ false));
      /** @type {!shaka.test.StatusPromise} */
      const p2 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer(
          ContentType.AUDIO, buffer2, null, null,
          /* hasClosedCaptions= */ false));

      expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer);
      expect(audioSourceBuffer.appendBuffer).not.toHaveBeenCalledWith(buffer2);
      expect(p1.status).toBe('pending');
      expect(p2.status).toBe('pending');

      audioSourceBuffer.updateend();
      await p1;
      expect(p2.status).toBe('pending');
      expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer2);
      audioSourceBuffer.updateend();
      await p2;
    });

    it('queues operations independently for different types', async () => {
      /** @type {!shaka.test.StatusPromise} */
      const p1 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer(
          ContentType.AUDIO, buffer, null, null,
          /* hasClosedCaptions= */ false));
      /** @type {!shaka.test.StatusPromise} */
      const p2 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer(
          ContentType.AUDIO, buffer2, null, null,
          /* hasClosedCaptions= */ false));
      /** @type {!shaka.test.StatusPromise} */
      const p3 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer(
          ContentType.VIDEO, buffer3, null, null,
          /* hasClosedCaptions= */ false));

      expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer);
      expect(audioSourceBuffer.appendBuffer).not.toHaveBeenCalledWith(buffer2);
      expect(videoSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer3);
      expect(p1.status).toBe('pending');
      expect(p2.status).toBe('pending');
      expect(p3.status).toBe('pending');

      audioSourceBuffer.updateend();
      videoSourceBuffer.updateend();
      // Wait a tick between each updateend() and the status check that follows.
      await p1;
      expect(p2.status).toBe('pending');
      expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer2);
      await p3;
      audioSourceBuffer.updateend();
      await p2;
    });

    it('continues if an operation throws', async () => {
      audioSourceBuffer.appendBuffer.and.callFake((value) => {
        if (value == 2) {
          // throw synchronously.
          throw new Error();
        } else {
          // complete successfully asynchronously.
          Promise.resolve().then(() => {
            audioSourceBuffer.updateend();
          });
        }
      });

      const p1 = mediaSourceEngine.appendBuffer(
          ContentType.AUDIO, buffer, null, null,
          /* hasClosedCaptions= */ false);
      const p2 = mediaSourceEngine.appendBuffer(
          ContentType.AUDIO, buffer2, null, null,
          /* hasClosedCaptions= */ false);
      const p3 = mediaSourceEngine.appendBuffer(
          ContentType.AUDIO, buffer3, null, null,
          /* hasClosedCaptions= */ false);

      await expectAsync(p1).toBeResolved();
      await expectAsync(p2).toBeRejected();
      await expectAsync(p3).toBeResolved();
      expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer);
      expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer2);
      expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer3);
    });

    it('forwards to TextEngine', async () => {
      const data = new ArrayBuffer(0);
      expect(mockTextEngine.appendBuffer).not.toHaveBeenCalled();
      await mediaSourceEngine.appendBuffer(
          ContentType.TEXT, data, 0, 10, /* hasClosedCaptions= */ false);
      expect(mockTextEngine.appendBuffer).toHaveBeenCalledWith(
          data, 0, 10);
    });

    it('appends transmuxed data and captions', async () => {
      const initObject = new Map();
      initObject.set(ContentType.VIDEO, fakeTransportStream);

      const output = {
        data: new Uint8Array(1),
        captions: [{}],
      };
      mockTransmuxer.transmux.and.returnValue(Promise.resolve(output));

      const init = async () => {
        await mediaSourceEngine.init(initObject, false);
        await mediaSourceEngine.appendBuffer(
            ContentType.VIDEO, buffer, null, null,
            /* hasClosedCaptions= */ false);
        expect(mockTextEngine.storeAndAppendClosedCaptions).toHaveBeenCalled();
        expect(videoSourceBuffer.appendBuffer).toHaveBeenCalled();
      };

      // The 'updateend' event fires once the data is done appending to the
      // media source.  We only append to the media source once transmuxing is
      // done.  Since transmuxing is done using Promises, we need to delay the
      // event until MediaSourceEngine calls appendBuffer.
      const delay = async () => {
        await Util.shortDelay();
        videoSourceBuffer.updateend();
      };
      await Promise.all([init(), delay()]);
    });

    it('appends only transmuxed data without embedded text', async () => {
      const initObject = new Map();
      initObject.set(ContentType.VIDEO, fakeTransportStream);

      const output = {
        data: new Uint8Array(1),
        captions: [],
      };
      mockTransmuxer.transmux.and.returnValue(Promise.resolve(output));

      const init = async () => {
        await mediaSourceEngine.init(initObject, false);
        await mediaSourceEngine.appendBuffer(
            ContentType.VIDEO, buffer, null, null,
            /* hasClosedCaptions= */ false);
        expect(mockTextEngine.storeAndAppendClosedCaptions)
            .not.toHaveBeenCalled();
        expect(videoSourceBuffer.appendBuffer)
            .toHaveBeenCalledWith(output.data);
      };

      // The 'updateend' event fires once the data is done appending to the
      // media source.  We only append to the media source once transmuxing is
      // done.  Since transmuxing is done using Promises, we need to delay the
      // event until MediaSourceEngine calls appendBuffer.
      const delay = async () => {
        await Util.shortDelay();
        videoSourceBuffer.updateend();
      };
      await Promise.all([init(), delay()]);
    });

    it('appends parsed closed captions from CaptionParser', async () => {
      const initObject = new Map();
      initObject.set(ContentType.VIDEO, fakeVideoStream);

      mockClosedCaptionParser.parseFromSpy.and.callFake((data, onCaptions) => {
        onCaptions(['foo', 'bar']);
      });

      await mediaSourceEngine.init(initObject, false);

      // Initialize the closed caption parser.
      const appendInit = mediaSourceEngine.appendBuffer(
          ContentType.VIDEO, buffer, null, null, true);
      // In MediaSourceEngine, appendBuffer() is async and Promise-based, but
      // at the browser level, it's event-based.
      // MediaSourceEngine waits for the 'updateend' event from the
      // SourceBuffer, and uses that to resolve the appendBuffer Promise.
      // Here, we must trigger the event on the fake/mock SourceBuffer before
      // waiting on the appendBuffer Promise.
      videoSourceBuffer.updateend();
      await appendInit;

      expect(mockTextEngine.storeAndAppendClosedCaptions).not
          .toHaveBeenCalled();
      // Parse and append the closed captions embedded in video stream.
      const appendVideo = mediaSourceEngine.appendBuffer(
          ContentType.VIDEO, buffer, 0, Infinity, true);
      videoSourceBuffer.updateend();
      await appendVideo;

      expect(mockTextEngine.storeAndAppendClosedCaptions).toHaveBeenCalled();
    });

    it('appends closed caption data only when mux.js is available',
        async () => {
          const originalMuxjs = window.muxjs;

          try {
            window['muxjs'] = null;
            const initObject = new Map();
            initObject.set(ContentType.VIDEO, fakeVideoStream);
            await mediaSourceEngine.init(initObject, false);

            const appendBuffer = mediaSourceEngine.appendBuffer(
                ContentType.VIDEO, buffer, null, null, true);
            // In MediaSourceEngine, appendBuffer() is async and Promise-based,
            // but at the browser level, it's event-based. MediaSourceEngine
            // waits for the 'updateend' event from the SourceBuffer, and uses
            // that to resolve the appendBuffer Promise. Here, we must trigger
            // the event on the fake/mock SourceBuffer before waiting on the
            // appendBuffer Promise.
            videoSourceBuffer.updateend();
            await appendBuffer;
            expect(mockClosedCaptionParser.initSpy).not.toHaveBeenCalled();
            expect(mockTextEngine.storeAndAppendClosedCaptions).not
                .toHaveBeenCalled();
          } finally {
            window['muxjs'] = originalMuxjs;
          }
        });
  });

  describe('remove', () => {
    beforeEach(async () => {
      captureEvents(audioSourceBuffer, ['updateend', 'error']);
      captureEvents(videoSourceBuffer, ['updateend', 'error']);
      const initObject = new Map();
      initObject.set(ContentType.AUDIO, fakeAudioStream);
      initObject.set(ContentType.VIDEO, fakeVideoStream);
      initObject.set(ContentType.TEXT, fakeTextStream);
      await mediaSourceEngine.init(initObject, false);
    });

    it('removes the given data', async () => {
      const p = mediaSourceEngine.remove(ContentType.AUDIO, 1, 5);
      audioSourceBuffer.updateend();

      await p;
      expect(audioSourceBuffer.remove).toHaveBeenCalledWith(1, 5);
    });

    it('rejects promise when operation throws', async () => {
      audioSourceBuffer.remove.and.throwError('fail!');
      mockVideo.error = {code: 5};

      const expected = Util.jasmineError(new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.MEDIA,
          shaka.util.Error.Code.MEDIA_SOURCE_OPERATION_THREW,
          jasmine.objectContaining({message: 'fail!'})));
      await expectAsync(mediaSourceEngine.remove(ContentType.AUDIO, 1, 5))
          .toBeRejectedWith(expected);
      expect(audioSourceBuffer.remove).toHaveBeenCalledWith(1, 5);
    });

    it('rejects the promise if this operation fails async', async () => {
      mockVideo.error = {code: 5};
      const p = mediaSourceEngine.remove(ContentType.AUDIO, 1, 5);
      audioSourceBuffer.error();
      audioSourceBuffer.updateend();

      const expected = Util.jasmineError(new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.MEDIA,
          shaka.util.Error.Code.MEDIA_SOURCE_OPERATION_FAILED,
          5));
      await expectAsync(p).toBeRejectedWith(expected);
      expect(audioSourceBuffer.remove).toHaveBeenCalledWith(1, 5);
    });

    it('queues operations on a single SourceBuffer', async () => {
      /** @type {!shaka.test.StatusPromise} */
      const p1 = new shaka.test.StatusPromise(
          mediaSourceEngine.remove(ContentType.AUDIO, 1, 5));
      /** @type {!shaka.test.StatusPromise} */
      const p2 = new shaka.test.StatusPromise(
          mediaSourceEngine.remove(ContentType.AUDIO, 6, 10));

      expect(audioSourceBuffer.remove).toHaveBeenCalledWith(1, 5);
      expect(audioSourceBuffer.remove).not.toHaveBeenCalledWith(6, 10);
      expect(p1.status).toBe('pending');
      expect(p2.status).toBe('pending');

      audioSourceBuffer.updateend();
      await p1;
      expect(p2.status).toBe('pending');
      expect(audioSourceBuffer.remove).toHaveBeenCalledWith(6, 10);
      audioSourceBuffer.updateend();
      await p2;
    });

    it('queues operations independently for different types', async () => {
      /** @type {!shaka.test.StatusPromise} */
      const p1 = new shaka.test.StatusPromise(
          mediaSourceEngine.remove(ContentType.AUDIO, 1, 5));
      /** @type {!shaka.test.StatusPromise} */
      const p2 = new shaka.test.StatusPromise(
          mediaSourceEngine.remove(ContentType.AUDIO, 6, 10));
      /** @type {!shaka.test.StatusPromise} */
      const p3 = new shaka.test.StatusPromise(
          mediaSourceEngine.remove(ContentType.VIDEO, 3, 8));

      expect(audioSourceBuffer.remove).toHaveBeenCalledWith(1, 5);
      expect(audioSourceBuffer.remove).not.toHaveBeenCalledWith(6, 10);
      expect(videoSourceBuffer.remove).toHaveBeenCalledWith(3, 8);
      expect(p1.status).toBe('pending');
      expect(p2.status).toBe('pending');
      expect(p3.status).toBe('pending');

      audioSourceBuffer.updateend();
      videoSourceBuffer.updateend();
      await p1;
      expect(p2.status).toBe('pending');
      expect(audioSourceBuffer.remove).toHaveBeenCalledWith(6, 10);
      await p3;
      audioSourceBuffer.updateend();
      await p2;
    });

    it('continues if an operation throws', async () => {
      audioSourceBuffer.remove.and.callFake((start, end) => {
        if (start == 2) {
          // throw synchronously.
          throw new Error();
        } else {
          // complete successfully asynchronously.
          Promise.resolve().then(() => {
            audioSourceBuffer.updateend();
          });
        }
      });

      const p1 = mediaSourceEngine.remove(ContentType.AUDIO, 1, 2);
      const p2 = mediaSourceEngine.remove(ContentType.AUDIO, 2, 3);
      const p3 = mediaSourceEngine.remove(ContentType.AUDIO, 3, 4);

      await expectAsync(p1).toBeResolved();
      await expectAsync(p2).toBeRejected();
      await expectAsync(p3).toBeResolved();
      expect(audioSourceBuffer.remove).toHaveBeenCalledWith(1, 2);
      expect(audioSourceBuffer.remove).toHaveBeenCalledWith(2, 3);
      expect(audioSourceBuffer.remove).toHaveBeenCalledWith(3, 4);
    });

    it('will forward to TextEngine', async () => {
      expect(mockTextEngine.remove).not.toHaveBeenCalled();
      await mediaSourceEngine.remove(ContentType.TEXT, 10, 20);
      expect(mockTextEngine.remove).toHaveBeenCalledWith(10, 20);
    });
  });

  describe('clear', () => {
    beforeEach(async () => {
      captureEvents(audioSourceBuffer, ['updateend', 'error']);
      captureEvents(videoSourceBuffer, ['updateend', 'error']);
      const initObject = new Map();
      initObject.set(ContentType.AUDIO, fakeAudioStream);
      initObject.set(ContentType.VIDEO, fakeVideoStream);
      initObject.set(ContentType.TEXT, fakeTextStream);
      await mediaSourceEngine.init(initObject, false);
    });

    it('clears the given data', async () => {
      mockMediaSource.durationGetter.and.returnValue(20);
      const p = mediaSourceEngine.clear(ContentType.AUDIO);
      audioSourceBuffer.updateend();

      await p;
      expect(audioSourceBuffer.remove).toHaveBeenCalledTimes(1);
      expect(audioSourceBuffer.remove.calls.argsFor(0)[0]).toBe(0);
      expect(audioSourceBuffer.remove.calls.argsFor(0)[1] >= 20).toBeTruthy();
    });

    it('does not seek', async () => {
      // We had a bug in which we got into a seek loop. Seeking caused
      // StreamingEngine to call clear().  Clearing triggered a pipeline flush
      // which was implemented by seeking.  See issue #569.

      // This loop is difficult to test for directly.

      // A unit test on StreamingEngine would not suffice, since reproduction of
      // the bug would involve making the mock MediaSourceEngine seek on clear.
      // Since the fix was to remove the implicit seek, this behavior would then
      // be removed from the mock, which would render the test useless.

      // An integration test involving both StreamingEngine and MediaSourcEngine
      // would also be problematic.  The bug involved a race, so it would be
      // difficult to reproduce the necessary timing.  And if we succeeded, it
      // would be tough to detect that we were definitely in a seek loop, since
      // nothing was mocked.

      // So the best option seems to be to enforce that clear() does not result
      // in a seek.  This can be done here, in a unit test on MediaSourceEngine.
      // It does not reproduce the seek loop, but it does ensure that the test
      // would fail if we ever reintroduced this behavior.

      const originalTime = 10;
      mockVideo.currentTime = originalTime;

      mockMediaSource.durationGetter.and.returnValue(20);
      const p = mediaSourceEngine.clear(ContentType.AUDIO);
      audioSourceBuffer.updateend();

      await p;
      expect(mockVideo.currentTime).toBe(originalTime);
    });

    it('will forward to TextEngine', async () => {
      expect(mockTextEngine.setTimestampOffset).not.toHaveBeenCalled();
      expect(mockTextEngine.setAppendWindow).not.toHaveBeenCalled();
      await mediaSourceEngine.setStreamProperties(ContentType.TEXT,
          /* timestampOffset= */ 10,
          /* appendWindowStart= */ 0,
          /* appendWindowEnd= */ 20);
      expect(mockTextEngine.setTimestampOffset).toHaveBeenCalledWith(10);
      expect(mockTextEngine.setAppendWindow).toHaveBeenCalledWith(0, 20);
    });
  });

  describe('endOfStream', () => {
    beforeEach(async () => {
      captureEvents(audioSourceBuffer, ['updateend', 'error']);
      captureEvents(videoSourceBuffer, ['updateend', 'error']);
      const initObject = new Map();
      initObject.set(ContentType.AUDIO, fakeAudioStream);
      initObject.set(ContentType.VIDEO, fakeVideoStream);
      await mediaSourceEngine.init(initObject, false);
    });

    it('ends the MediaSource stream with the given reason', async () => {
      await mediaSourceEngine.endOfStream('foo');
      expect(mockMediaSource.endOfStream).toHaveBeenCalledWith('foo');
    });

    it('waits for all previous operations to complete', async () => {
      /** @type {!shaka.test.StatusPromise} */
      const p1 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer(
          ContentType.AUDIO, buffer, null, null,
          /* hasClosedCaptions= */ false));
      /** @type {!shaka.test.StatusPromise} */
      const p2 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer(
          ContentType.VIDEO, buffer, null, null,
          /* hasClosedCaptions= */ false));
      /** @type {!shaka.test.StatusPromise} */
      const p3 = new shaka.test.StatusPromise(mediaSourceEngine.endOfStream());

      expect(mockMediaSource.endOfStream).not.toHaveBeenCalled();
      expect(p1.status).toBe('pending');
      expect(p2.status).toBe('pending');
      expect(p3.status).toBe('pending');

      audioSourceBuffer.updateend();
      await p1;
      expect(p2.status).toBe('pending');
      expect(p3.status).toBe('pending');
      videoSourceBuffer.updateend();
      await p2;
      await p3;
      expect(mockMediaSource.endOfStream).toHaveBeenCalled();
    });

    it('makes subsequent operations wait', async () => {
      /** @type {!Promise} */
      const p1 = mediaSourceEngine.endOfStream();
      mediaSourceEngine.appendBuffer(ContentType.AUDIO, buffer, null, null,
          /* hasClosedCaptions= */ false);
      mediaSourceEngine.appendBuffer(ContentType.VIDEO, buffer, null, null,
          /* hasClosedCaptions= */ false);
      mediaSourceEngine.appendBuffer(ContentType.VIDEO, buffer2, null, null,
          /* hasClosedCaptions= */ false);

      // endOfStream hasn't been called yet because blocking multiple queues
      // takes an extra tick, even when they are empty.
      expect(mockMediaSource.endOfStream).not.toHaveBeenCalled();

      expect(audioSourceBuffer.appendBuffer).not.toHaveBeenCalled();
      expect(videoSourceBuffer.appendBuffer).not.toHaveBeenCalled();

      await p1;
      expect(mockMediaSource.endOfStream).toHaveBeenCalled();
      // The next operations have already been kicked off.
      expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer);
      expect(videoSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer);
      // This one is still in queue.
      expect(videoSourceBuffer.appendBuffer).not.toHaveBeenCalledWith(buffer2);
      audioSourceBuffer.updateend();
      videoSourceBuffer.updateend();
      await Promise.resolve();
      expect(videoSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer2);
      videoSourceBuffer.updateend();
    });

    it('runs subsequent operations if this operation throws', async () => {
      mockMediaSource.endOfStream.and.throwError(new Error());
      /** @type {!Promise} */
      const p1 = mediaSourceEngine.endOfStream();
      mediaSourceEngine.appendBuffer(ContentType.AUDIO, buffer, null, null,
          /* hasClosedCaptions= */ false);

      expect(audioSourceBuffer.appendBuffer).not.toHaveBeenCalled();

      await expectAsync(p1).toBeRejected();
      expect(mockMediaSource.endOfStream).toHaveBeenCalled();
      await Util.shortDelay();
      expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(1);
      audioSourceBuffer.updateend();
    });
  });

  describe('setDuration', () => {
    beforeEach(async () => {
      mockMediaSource.durationGetter.and.returnValue(0);
      captureEvents(audioSourceBuffer, ['updateend', 'error']);
      captureEvents(videoSourceBuffer, ['updateend', 'error']);
      const initObject = new Map();
      initObject.set(ContentType.AUDIO, fakeAudioStream);
      initObject.set(ContentType.VIDEO, fakeVideoStream);
      await mediaSourceEngine.init(initObject, false);
    });

    it('sets the given duration', async () => {
      await mediaSourceEngine.setDuration(100);
      expect(mockMediaSource.durationSetter).toHaveBeenCalledWith(100);
    });

    it('waits for all previous operations to complete', async () => {
      /** @type {!shaka.test.StatusPromise} */
      const p1 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer(
          ContentType.AUDIO, buffer, null, null,
          /* hasClosedCaptions= */ false));
      /** @type {!shaka.test.StatusPromise} */
      const p2 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer(
          ContentType.VIDEO, buffer, null, null,
          /* hasClosedCaptions= */ false));
      /** @type {!shaka.test.StatusPromise} */
      const p3 =
          new shaka.test.StatusPromise(mediaSourceEngine.setDuration(100));

      expect(mockMediaSource.durationSetter).not.toHaveBeenCalled();
      expect(p1.status).toBe('pending');
      expect(p2.status).toBe('pending');
      expect(p3.status).toBe('pending');

      audioSourceBuffer.updateend();
      await p1;
      expect(p2.status).toBe('pending');
      expect(p3.status).toBe('pending');
      videoSourceBuffer.updateend();
      await p2;
      await p3;
      expect(mockMediaSource.durationSetter).toHaveBeenCalledWith(100);
    });

    it('makes subsequent operations wait', async () => {
      /** @type {!Promise} */
      const p1 = mediaSourceEngine.setDuration(100);
      mediaSourceEngine.appendBuffer(ContentType.AUDIO, buffer, null, null,
          /* hasClosedCaptions= */ false);
      mediaSourceEngine.appendBuffer(ContentType.VIDEO, buffer, null, null,
          /* hasClosedCaptions= */ false);
      mediaSourceEngine.appendBuffer(ContentType.VIDEO, buffer2, null, null,
          /* hasClosedCaptions= */ false);

      // The setter hasn't been called yet because blocking multiple queues
      // takes an extra tick, even when they are empty.
      expect(mockMediaSource.durationSetter).not.toHaveBeenCalled();

      expect(audioSourceBuffer.appendBuffer).not.toHaveBeenCalled();
      expect(videoSourceBuffer.appendBuffer).not.toHaveBeenCalled();

      await p1;
      expect(mockMediaSource.durationSetter).toHaveBeenCalled();
      // The next operations have already been kicked off.
      expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer);
      expect(videoSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer);
      // This one is still in queue.
      expect(videoSourceBuffer.appendBuffer)
          .not.toHaveBeenCalledWith(buffer2);
      audioSourceBuffer.updateend();
      videoSourceBuffer.updateend();
      await Promise.resolve();
      expect(videoSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer2);
      videoSourceBuffer.updateend();
    });

    it('runs subsequent operations if this operation throws', async () => {
      mockMediaSource.durationSetter.and.throwError(new Error());
      /** @type {!Promise} */
      const p1 = mediaSourceEngine.setDuration(100);
      mediaSourceEngine.appendBuffer(ContentType.AUDIO, buffer, null, null,
          /* hasClosedCaptions= */ false);

      expect(audioSourceBuffer.appendBuffer).not.toHaveBeenCalled();

      await expectAsync(p1).toBeRejected();
      expect(mockMediaSource.durationSetter).toHaveBeenCalled();
      await Util.shortDelay();
      expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer);
      audioSourceBuffer.updateend();
    });
  });

  describe('destroy', () => {
    beforeEach(async () => {
      captureEvents(audioSourceBuffer, ['updateend', 'error']);
      captureEvents(videoSourceBuffer, ['updateend', 'error']);
      const initObject = new Map();
      initObject.set(ContentType.AUDIO, fakeAudioStream);
      initObject.set(ContentType.VIDEO, fakeVideoStream);
      await mediaSourceEngine.init(initObject, false);
    });

    it('waits for all operations to complete', async () => {
      mediaSourceEngine.appendBuffer(ContentType.AUDIO, buffer, null, null,
          /* hasClosedCaptions= */ false);
      mediaSourceEngine.appendBuffer(ContentType.VIDEO, buffer, null, null,
          /* hasClosedCaptions= */ false);

      /** @type {!shaka.test.StatusPromise} */
      const d = new shaka.test.StatusPromise(mediaSourceEngine.destroy());

      expect(d.status).toBe('pending');
      await Util.shortDelay();
      expect(d.status).toBe('pending');
      audioSourceBuffer.updateend();
      await Util.shortDelay();
      expect(d.status).toBe('pending');
      videoSourceBuffer.updateend();
      await d;
    });

    it('resolves even when a pending operation fails', async () => {
      const p = mediaSourceEngine.appendBuffer(
          ContentType.AUDIO, buffer, null, null,
          /* hasClosedCaptions= */ false);
      const d = mediaSourceEngine.destroy();

      audioSourceBuffer.error();
      audioSourceBuffer.updateend();
      await expectAsync(p).toBeRejected();
      await d;
    });

    it('waits for blocking operations to complete', async () => {
      /** @type {!shaka.test.StatusPromise} */
      const p = new shaka.test.StatusPromise(mediaSourceEngine.endOfStream());
      /** @type {!shaka.test.StatusPromise} */
      const d = new shaka.test.StatusPromise(mediaSourceEngine.destroy());

      expect(p.status).toBe('pending');
      expect(d.status).toBe('pending');
      await p;
      expect(d.status).toBe('pending');
      await d;
    });

    it('cancels operations that have not yet started', async () => {
      mediaSourceEngine.appendBuffer(
          ContentType.AUDIO, buffer, null, null,
          /* hasClosedCaptions= */ false);
      const rejected = mediaSourceEngine.appendBuffer(
          ContentType.AUDIO, buffer2, null, null,
          /* hasClosedCaptions= */ false);
      // Create the expectation first so we don't get unhandled rejection errors
      const expected = expectAsync(rejected).toBeRejected();

      expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer);
      expect(audioSourceBuffer.appendBuffer).not.toHaveBeenCalledWith(buffer2);

      /** @type {!shaka.test.StatusPromise} */
      const d = new shaka.test.StatusPromise(mediaSourceEngine.destroy());

      expect(d.status).toBe('pending');
      await Util.shortDelay();
      expect(d.status).toBe('pending');
      await expected;
      expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer);
      expect(audioSourceBuffer.appendBuffer).not.toHaveBeenCalledWith(buffer2);
      audioSourceBuffer.updateend();
      await d;
      expect(audioSourceBuffer.appendBuffer).not.toHaveBeenCalledWith(buffer2);
    });

    it('cancels blocking operations that have not yet started', async () => {
      const p1 = mediaSourceEngine.appendBuffer(
          ContentType.AUDIO, buffer, null, null,
          /* hasClosedCaptions= */ false);
      const p2 = mediaSourceEngine.endOfStream();
      const d = mediaSourceEngine.destroy();

      audioSourceBuffer.updateend();
      await expectAsync(p1).toBeResolved();
      await expectAsync(p2).toBeRejected();
      await d;
    });

    it('prevents new operations from being added', async () => {
      const d = mediaSourceEngine.destroy();
      await expectAsync(
          mediaSourceEngine.appendBuffer(
              ContentType.AUDIO, buffer, null, null,
              /* hasClosedCaptions= */ false))
          .toBeRejected();
      await d;
      expect(audioSourceBuffer.appendBuffer).not.toHaveBeenCalled();
    });

    it('destroys text engines', async () => {
      mediaSourceEngine.reinitText('text/vtt');

      await mediaSourceEngine.destroy();
      expect(mockTextEngine).toBeTruthy();
      expect(mockTextEngine.destroy).toHaveBeenCalled();
    });

    // Regression test for https://github.com/google/shaka-player/issues/984
    it('destroys TextDisplayer on destroy', async () => {
      await mediaSourceEngine.destroy();
      expect(mockTextDisplayer.destroySpy).toHaveBeenCalled();
    });
  });

  function createMockMediaSource() {
    const mediaSource = {
      readyState: 'open',
      addSourceBuffer: jasmine.createSpy('addSourceBuffer'),
      endOfStream: jasmine.createSpy('endOfStream'),
      durationGetter: jasmine.createSpy('duration getter'),
      durationSetter: jasmine.createSpy('duration setter'),
      addEventListener: jasmine.createSpy('addEventListener'),
      removeEventListener: () => {},
    };
    Object.defineProperty(mediaSource, 'duration', {
      get: Util.spyFunc(mediaSource.durationGetter),
      set: Util.spyFunc(mediaSource.durationSetter),
    });
    return mediaSource;
  }

  /** @return {MockSourceBuffer} */
  function createMockSourceBuffer() {
    return {
      abort: jasmine.createSpy('abort'),
      appendBuffer: jasmine.createSpy('appendBuffer'),
      remove: jasmine.createSpy('remove'),
      updating: false,
      addEventListener: jasmine.createSpy('addEventListener'),
      removeEventListener: jasmine.createSpy('removeEventListener'),
      buffered: {
        length: 0,
        start: jasmine.createSpy('buffered.start'),
        end: jasmine.createSpy('buffered.end'),
      },
      timestampOffset: 0,
      appendWindowEnd: Infinity,
      updateend: () => {},
      error: () => {},
    };
  }

  function createMockTextEngineCtor() {
    const ctor = jasmine.createSpy('TextEngine');
    ctor['isTypeSupported'] = () => true;
    ctor.and.callFake(() => {
      expect(mockTextEngine).toBeFalsy();
      mockTextEngine = jasmine.createSpyObj('TextEngine', [
        'initParser', 'destroy', 'appendBuffer', 'remove', 'setTimestampOffset',
        'setAppendWindow', 'bufferStart', 'bufferEnd', 'bufferedAheadOf',
        'storeAndAppendClosedCaptions',
      ]);

      const resolve = () => Promise.resolve();
      mockTextEngine.destroy.and.callFake(resolve);
      mockTextEngine.appendBuffer.and.callFake(resolve);
      mockTextEngine.remove.and.callFake(resolve);
      return mockTextEngine;
    });
    return ctor;
  }

  function captureEvents(object, targetEventNames) {
    object.addEventListener.and.callFake((eventName, listener) => {
      if (targetEventNames.includes(eventName)) {
        object[eventName] = listener;
      }
    });
    object.removeEventListener.and.callFake((eventName, listener) => {
      if (targetEventNames.includes(eventName)) {
        expect(object[eventName]).toBe(listener);
        object[eventName] = null;
      }
    });
  }
});
