/**
 * @license
 * Copyright The Closure Library Authors.
 * SPDX-License-Identifier: Apache-2.0
 */

/**
 * @fileoverview Unit tests for WebChannelBase.@suppress {accessControls}
 * Private methods are accessed for test purposes.
 */

goog.module('goog.labs.net.webChannel.webChannelBaseTest');
goog.setTestOnly();

const ChannelRequest = goog.require('goog.labs.net.webChannel.ChannelRequest');
const ForwardChannelRequestPool = goog.require('goog.labs.net.webChannel.ForwardChannelRequestPool');
const MockClock = goog.require('goog.testing.MockClock');
const PropertyReplacer = goog.require('goog.testing.PropertyReplacer');
const Stat = goog.require('goog.labs.net.webChannel.requestStats.Stat');
const StructsMap = goog.require('goog.structs.Map');
const Timer = goog.require('goog.Timer');
const Uri = goog.requireType('goog.Uri');
const WebChannelBase = goog.require('goog.labs.net.webChannel.WebChannelBase');
const WebChannelBaseTransport = goog.require('goog.labs.net.webChannel.WebChannelBaseTransport');
const WebChannelDebug = goog.require('goog.labs.net.webChannel.WebChannelDebug');
const Wire = goog.require('goog.labs.net.webChannel.Wire');
const XhrIo = goog.requireType('goog.net.XhrIo');
const asserts = goog.require('goog.testing.asserts');
const dom = goog.require('goog.dom');
const functions = goog.require('goog.functions');
const googArray = goog.require('goog.array');
const googJson = goog.require('goog.json');
const netUtils = goog.require('goog.labs.net.webChannel.netUtils');
const requestStats = goog.require('goog.labs.net.webChannel.requestStats');
const testSuite = goog.require('goog.testing.testSuite');

/** Delay between a network failure and the next network request. */
const RETRY_TIME = 1000;

/** A really long time - used to make sure no more timeouts will fire. */
const ALL_DAY_MS = 1000 * 60 * 60 * 24;

const DEFAULT_ERROR_HTTP_STATUS_CODE = 503;

const stubs = new PropertyReplacer();

let channel;
let deliveredMaps;
let handler;
let mockClock;
let gotError;
let numStatEvents;
let lastStatEvent;
let numTimingEvents;
let lastPostSize;
let lastPostRtt;
let lastPostRetryCount;

// Set to true to see the channel debug output in the browser window.
const debug = false;
// Debug message to print out when debug is true.
let debugMessage = '';

function debugToWindow(message) {
  if (debug) {
    debugMessage += `${message}<br>`;
    dom.getElement('debug').innerHTML = debugMessage;
  }
}

/**
 * Stubs netUtils to always time out. It maintains the
 * contract given by netUtils.testNetwork, but always
 * times out (calling callback(false)).
 * stubNetUtils should be called in tests that require it before
 * a call to testNetwork happens. It is reset at tearDown.
 */
function stubNetUtils() {
  stubs.set(netUtils, 'testLoadImage', (url, timeout, callback) => {
    Timer.callOnce(goog.partial(callback, false), timeout);
  });
}

/**
 * Stubs
 * ForwardChannelRequestPool.isSpdyOrHttp2Enabled_ to
 * manage the max pool size for the forward channel.
 * @param {boolean} spdyEnabled Whether SPDY is enabled for the test.
 */
function stubSpdyCheck(spdyEnabled) {
  stubs.set(
      ForwardChannelRequestPool, 'isSpdyOrHttp2Enabled_', () => spdyEnabled);
}

/**
 * Mock ChannelRequest.
 * @final
 */
class MockChannelRequest {
  constructor(
      channel, channelDebug, sessionId = undefined, requestId = undefined,
      retryId = undefined) {
    this.channel_ = channel;
    this.channelDebug_ = channelDebug;
    this.sessionId_ = sessionId;
    this.requestId_ = requestId;
    this.successful_ = true;
    this.lastError_ = null;
    this.lastStatusCode_ = 200;
    this.errorResponseHeaders_ = undefined;

    // For debugging, keep track of whether this is a back or forward channel.
    this.isBack = !!(requestId == 'rpc');
    this.isForward = !this.isBack;

    this.pendingMessages_ = [];

    this.postData_ = null;
    this.requestStartTime_ = null;
  }

  /** @param {?Object} extraHeaders The HTTP headers. */
  setExtraHeaders(extraHeaders) {}

  /** @param {number} timeout The timeout in MS for when we fail the request. */
  setTimeout(timeout) {}

  /**
   * @param {number} throttle The throttle in ms. A value of zero indicates no
   *     throttle.
   */
  setReadyStateChangeThrottle(throttle) {}

  /**
   * @param {?Uri} uri The uri of the request.
   * @param {?string} postData The data for the post body.
   * @param {boolean} decodeChunks Whether to the result is expected to be
   *     encoded for chunking and thus requires decoding.
   */
  xmlHttpPost(uri, postData, decodeChunks) {
    this.channelDebug_.debug(`---> POST: ${uri}, ${postData}, ${decodeChunks}`);
    this.postData_ = postData;
    this.requestStartTime_ = Date.now();
  }

  /**
   * @param {?Uri} uri The uri of the request.
   * @param {boolean} decodeChunks Whether to the result is expected to be
   *     encoded for chunking and thus requires decoding.
   * @param {?string} hostPrefix The host prefix, if we might be using a
   *     secondary domain. Note that it should also be in the URL, adding this
   *     won't cause it to be added to the URL.
   */
  xmlHttpGet(uri, decodeChunks, hostPrefix) {
    this.channelDebug_.debug(
        `<--- GET: ${uri}, ${decodeChunks}, ${hostPrefix}`);
    this.requestStartTime_ = Date.now();
  }

  /** @param {?Uri} uri The uri to send a request to. */
  sendCloseRequest(uri) {
    this.requestStartTime_ = Date.now();
  }

  /** Cancel. */
  cancel() {
    this.successful_ = false;
  }

  /** @return {boolean} */
  getSuccess() {
    return this.successful_;
  }

  /** @return {?ChannelRequest.Error} The last error. */
  getLastError() {
    return this.lastError_;
  }

  /** @return {!Object<string, string>|undefined} Error response headers. */
  getErrorResponseHeaders() {
    return this.errorResponseHeaders_;
  }

  /** @return {number} The status code of the last request. */
  getLastStatusCode() {
    return this.lastStatusCode_;
  }

  /** @return {string|undefined} The session ID. */
  getSessionId() {
    return this.sessionId_;
  }

  /** @return {string|number|undefined} The request ID. */
  getRequestId() {
    return this.requestId_;
  }

  /** @return {?string} The POST data provided by the request initiator. */
  getPostData() {
    return this.postData_;
  }

  /**
   * @return {?number} The time the request started, as returned by Date.now().
   */
  getRequestStartTime() {
    return this.requestStartTime_;
  }

  /** @return {?XhrIo} Any XhrIo request created for this object. */
  getXhr() {
    return null;
  }

  /**
   * @param {!Array<?Wire.QueuedMap>} messages The pending messages for this
   *     request.
   */
  setPendingMessages(messages) {
    this.pendingMessages_ = messages;
  }

  /**
   * @return {!Array<?Wire.QueuedMap>} The pending messages for this request.
   */
  getPendingMessages() {
    return this.pendingMessages_;
  }

  /** @return {boolean} true if X_HTTP_INITIAL_RESPONSE has been handled. */
  isInitialResponseDecoded() {
    return false;
  }

  /** Decodes X_HTTP_INITIAL_RESPONSE if present. */
  setDecodeInitialResponse() {}
}

function getSingleForwardRequest() {
  /** @suppress {visibility} Accessing private properties. */
  const pool = channel.forwardChannelRequestPool_;
  if (!pool.hasPendingRequest()) {
    return null;
  }
  return pool.request_ || pool.requestPool_.getValues()[0];
}

/**
 * Helper function to return a formatted string representing an array of maps.
 */
function formatArrayOfMaps(arrayOfMaps) {
  const result = [];
  for (let i = 0; i < arrayOfMaps.length; i++) {
    const map = arrayOfMaps[i];

    if (Object.getPrototypeOf(map.map) === Object.prototype) {  // Object map
      for (const key in map.map) {
        const tmp =
            key + ':' + map.map[key] + (map.context ? ':' + map.context : '');
        result.push(tmp);
      }
    } else if (
        typeof map.map.keys === 'function' &&
        typeof map.map.get === 'function') {  // MapLike
      for (const key of map.map.keys()) {
        const tmp = key + ':' + map.map.get(key) +
            (map.context ? ':' + map.context : '');
        result.push(tmp);
      }
    } else {
      throw new Error('Unknown input type for map: ' + String(map));
    }
  }
  return result.join(', ');
}

/**
 * @param {number=} serverVersion
 * @param {string=} hostPrefix
 * @param {string=} opt_uriPrefix
 * @param {boolean=} spdyEnabled
 */
function connectForwardChannel(
    serverVersion = undefined, hostPrefix = undefined, opt_uriPrefix,
    spdyEnabled = undefined) {
  stubSpdyCheck(!!spdyEnabled);
  const uriPrefix = opt_uriPrefix || '';
  channel.connect(`${uriPrefix}/bind`, null);
  mockClock.tick(0);
  completeForwardChannel(serverVersion, hostPrefix);
}

/**
 * @param {number=} serverVersion
 * @param {string=} hostPrefix
 * @param {string=} uriPrefix
 * @param {boolean=} spdyEnabled
 */
function connect(
    serverVersion = undefined, hostPrefix = undefined, uriPrefix = undefined,
    spdyEnabled = undefined) {
  connectForwardChannel(serverVersion, hostPrefix, uriPrefix, spdyEnabled);
  completeBackChannel();
}

function disconnect() {
  channel.disconnect();
  mockClock.tick(0);
}

/**
 * @param {number=} serverVersion
 * @param {string=} hostPrefix
 */
function completeForwardChannel(
    serverVersion = undefined, hostPrefix = undefined) {
  const responseData = '[[0,["c","1234567890ABCDEF",' +
      (hostPrefix ? `"${hostPrefix}"` : 'null') +
      (serverVersion ? `,${serverVersion}` : '') + ']]]';
  channel.onRequestData(getSingleForwardRequest(), responseData);
  channel.onRequestComplete(getSingleForwardRequest());
  mockClock.tick(0);
}

/** @suppress {visibility} Accessing private properties. */
function completeBackChannel() {
  channel.onRequestData(channel.backChannelRequest_, '[[1,["foo"]]]');
  channel.onRequestComplete(channel.backChannelRequest_);
  mockClock.tick(0);
}

function responseDone() {
  channel.onRequestData(getSingleForwardRequest(), '[1,0,0]');  // mock data
  channel.onRequestComplete(getSingleForwardRequest());
  mockClock.tick(0);
}

/**
 * @param {number=} lastArrayIdSentFromServer
 * @param {number=} outstandingDataSize
 */
function responseNoBackchannel(
    lastArrayIdSentFromServer = undefined, outstandingDataSize = undefined) {
  const responseData =
      googJson.serialize([0, lastArrayIdSentFromServer, outstandingDataSize]);
  channel.onRequestData(getSingleForwardRequest(), responseData);
  channel.onRequestComplete(getSingleForwardRequest());
  mockClock.tick(0);
}

function response(lastArrayIdSentFromServer, outstandingDataSize) {
  const responseData =
      googJson.serialize([1, lastArrayIdSentFromServer, outstandingDataSize]);
  channel.onRequestData(getSingleForwardRequest(), responseData);
  channel.onRequestComplete(getSingleForwardRequest());
  mockClock.tick(0);
}

/** @suppress {visibility} Accessing private properties. */
function receive(data) {
  channel.onRequestData(channel.backChannelRequest_, `[[1,${data}]]`);
  channel.onRequestComplete(channel.backChannelRequest_);
  mockClock.tick(0);
}

function responseTimeout() {
  getSingleForwardRequest().lastError_ = ChannelRequest.Error.TIMEOUT;
  getSingleForwardRequest().successful_ = false;
  channel.onRequestComplete(getSingleForwardRequest());
  mockClock.tick(0);
}

/** Fails the first forward request. */
function responseRequestFailed() {
  getSingleForwardRequest().lastError_ = ChannelRequest.Error.STATUS;
  getSingleForwardRequest().lastStatusCode_ = DEFAULT_ERROR_HTTP_STATUS_CODE;
  getSingleForwardRequest().successful_ = false;
  channel.onRequestComplete(getSingleForwardRequest());
  mockClock.tick(0);
}

function responseUnknownSessionId() {
  getSingleForwardRequest().lastError_ =
      ChannelRequest.Error.UNKNOWN_SESSION_ID;
  getSingleForwardRequest().successful_ = false;
  channel.onRequestComplete(getSingleForwardRequest());
  mockClock.tick(0);
}

/**
 * Enum for map types to test.
 * @enum {number}
 */
const MapTypes = {
  OBJECT_MAP: 0,
  STRUCTS_MAP: 1,
  ES6_MAP: 2,
};

/**
 * @param {string} key
 * @param {string} value
 * @param {string=} context
 * @param {!MapTypes=} mapType
 */
function sendMap(
    key, value, context = undefined, mapType = MapTypes.OBJECT_MAP) {
  let map;
  if (mapType == MapTypes.OBJECT_MAP) {
    map = {};
    map[key] = value;
  } else if (mapType == MapTypes.STRUCTS_MAP) {
    map = new StructsMap();
    map.set(key, value);
  } else if (mapType == MapTypes.ES6_MAP) {
    map = new Map();
    map.set(key, value);
  } else {
    throw new Error('Unsupported map type :)');
  }

  channel.sendMap(map, context);
  mockClock.tick(0);
}

function hasForwardChannel() {
  return !!getSingleForwardRequest();
}

/** @suppress {visibility} Accessing private properties. */
function hasBackChannel() {
  return !!channel.backChannelRequest_;
}

/** @suppress {visibility} Accessing private properties. */
function hasDeadBackChannelTimer() {
  return channel.deadBackChannelTimerId_ != null;
}

function assertHasForwardChannel() {
  assertTrue('Forward channel missing.', hasForwardChannel());
}

function assertHasBackChannel() {
  assertTrue('Back channel missing.', hasBackChannel());
}

/**
 * @param {!MapTypes=} mapType
 */
function sendMapOnce(mapType = MapTypes.OBJECT_MAP) {
  assertEquals(1, numTimingEvents);
  sendMap('foo', 'bar', /* context= */ undefined, mapType);
  responseDone();
  assertEquals(2, numTimingEvents);
  assertEquals('foo:bar', formatArrayOfMaps(deliveredMaps));
}

function sendMapTwice() {
  sendMap('foo1', 'bar1');
  responseDone();
  assertEquals('foo1:bar1', formatArrayOfMaps(deliveredMaps));
  sendMap('foo2', 'bar2');
  responseDone();
  assertEquals('foo2:bar2', formatArrayOfMaps(deliveredMaps));
}

/** @suppress {visibility} Accessing private properties. */
function setFailFastWhileWaitingForRetry() {
  assertEquals(1, numTimingEvents);

  sendMap('foo', 'bar');
  assertNull(channel.forwardChannelTimerId_);
  assertNotNull(getSingleForwardRequest());
  assertEquals(0, channel.forwardChannelRetryCount_);

  // Watchdog timeout.
  responseTimeout();
  assertNotNull(channel.forwardChannelTimerId_);
  assertNull(getSingleForwardRequest());
  assertEquals(1, channel.forwardChannelRetryCount_);

  // Almost finish the between-retry timeout.
  mockClock.tick(RETRY_TIME - 1);
  assertNotNull(channel.forwardChannelTimerId_);
  assertNull(getSingleForwardRequest());
  assertEquals(1, channel.forwardChannelRetryCount_);

  // Setting max retries to 0 should cancel the timer and raise an error.
  channel.setFailFast(true);
  assertNull(channel.forwardChannelTimerId_);
  assertNull(getSingleForwardRequest());
  assertEquals(1, channel.forwardChannelRetryCount_);

  // We get the error immediately before starting to ping google.com.
  assertTrue(gotError);
  assertEquals(0, deliveredMaps.length);

  // Simulate that timing out. We should not get another error.
  gotError = false;
  mockClock.tick(netUtils.NETWORK_TIMEOUT);
  assertFalse('Extra error after network ping timed out.', gotError);

  // Make sure no more retry timers are firing.
  mockClock.tick(ALL_DAY_MS);
  assertNull(channel.forwardChannelTimerId_);
  assertNull(getSingleForwardRequest());
  assertEquals(1, channel.forwardChannelRetryCount_);
  assertEquals(1, numTimingEvents);
}

/** @suppress {visibility} Accessing private properties. */
function setFailFastWhileRetryXhrIsInFlight() {
  assertEquals(1, numTimingEvents);

  sendMap('foo', 'bar');
  assertNull(channel.forwardChannelTimerId_);
  assertNotNull(getSingleForwardRequest());
  assertEquals(0, channel.forwardChannelRetryCount_);

  // Watchdog timeout.
  responseTimeout();
  assertNotNull(channel.forwardChannelTimerId_);
  assertNull(getSingleForwardRequest());
  assertEquals(1, channel.forwardChannelRetryCount_);

  // Wait for the between-retry timeout.
  mockClock.tick(RETRY_TIME);
  assertNull(channel.forwardChannelTimerId_);
  assertNotNull(getSingleForwardRequest());
  assertEquals(1, channel.forwardChannelRetryCount_);

  // Simulate a second watchdog timeout.
  responseTimeout();
  assertNotNull(channel.forwardChannelTimerId_);
  assertNull(getSingleForwardRequest());
  assertEquals(2, channel.forwardChannelRetryCount_);

  // Wait for another between-retry timeout.
  mockClock.tick(RETRY_TIME);
  // Now the third req is in flight.
  assertNull(channel.forwardChannelTimerId_);
  assertNotNull(getSingleForwardRequest());
  assertEquals(2, channel.forwardChannelRetryCount_);

  // Set fail fast, killing the request
  channel.setFailFast(true);
  assertNull(channel.forwardChannelTimerId_);
  assertNull(getSingleForwardRequest());
  assertEquals(2, channel.forwardChannelRetryCount_);

  // We get the error immediately before starting to ping google.com.
  assertTrue(gotError);

  // Simulate that timing out. We should not get another error.
  gotError = false;
  mockClock.tick(netUtils.NETWORK_TIMEOUT);
  assertFalse('Extra error after network ping timed out.', gotError);

  // Make sure no more retry timers are firing.
  mockClock.tick(ALL_DAY_MS);
  assertNull(channel.forwardChannelTimerId_);
  assertNull(getSingleForwardRequest());
  assertEquals(2, channel.forwardChannelRetryCount_);
  assertEquals(1, numTimingEvents);
}

function requestFailedClosesChannel() {
  assertEquals(1, numTimingEvents);

  sendMap('foo', 'bar');
  responseRequestFailed();

  assertEquals(
      'Should be closed immediately after request failed.',
      WebChannelBase.State.CLOSED, channel.getState());

  mockClock.tick(netUtils.NETWORK_TIMEOUT);

  assertEquals(
      'Should remain closed after the ping timeout.',
      WebChannelBase.State.CLOSED, channel.getState());
  assertEquals(1, numTimingEvents);
  assertEquals(DEFAULT_ERROR_HTTP_STATUS_CODE, channel.getLastStatusCode());
}

/** @suppress {visibility} Accessing private properties. */
function outgoingMapsAwaitsResponse() {
  assertEquals(0, channel.outgoingMaps_.length);

  sendMap('foo1', 'bar');
  assertEquals(0, channel.outgoingMaps_.length);
  sendMap('foo2', 'bar');
  assertEquals(1, channel.outgoingMaps_.length);
  sendMap('foo3', 'bar');
  assertEquals(2, channel.outgoingMaps_.length);
  sendMap('foo4', 'bar');
  assertEquals(3, channel.outgoingMaps_.length);

  responseDone();
  // Now the forward channel request is completed and a new started, so all maps
  // are dequeued from the array of outgoing maps into this new forward request.
  assertEquals(0, channel.outgoingMaps_.length);
}

testSuite({
  shouldRunTests() {
    return ChannelRequest.supportsXhrStreaming();
  },

  /**
   * @suppress {invalidCasts} The cast from MockChannelRequest to
   * ChannelRequest is invalid and will not compile.
   */
  setUpPage() {
    // Use our MockChannelRequests instead of the real ones.
    ChannelRequest.createChannelRequest =
        (channel, channelDebug, opt_sessionId, opt_requestId, opt_retryId) => {
          return /** @type {!ChannelRequest} */ (new MockChannelRequest(
              channel, channelDebug, opt_sessionId, opt_requestId,
              opt_retryId));
        };

    // Mock out the stat notification code.
    requestStats.notifyStatEvent = (stat) => {
      numStatEvents++;
      lastStatEvent = stat;
    };

    requestStats.notifyTimingEvent = (size, rtt, retries) => {
      numTimingEvents++;
      lastPostSize = size;
      lastPostRtt = rtt;
      lastPostRetryCount = retries;
    };
  },

  setUp() {
    numTimingEvents = 0;
    lastPostSize = null;
    lastPostRtt = null;
    lastPostRetryCount = null;

    mockClock = new MockClock(true);
    /** @suppress {checkTypes} suppression added to enable type checking */
    channel = new WebChannelBase('1');

    gotError = false;

    handler = new WebChannelBase.Handler();
    handler.channelOpened = () => {};
    handler.channelError = (channel, error) => {
      gotError = true;
    };
    handler.channelSuccess = (channel, request) => {
      deliveredMaps = googArray.clone(request.getPendingMessages());
    };

    /**
     * @suppress {checkTypes} The callback function type declaration is
     * skipped.
     */
    handler.channelClosed = (channel, opt_pendingMaps, opt_undeliveredMaps) => {
      // Mock out the handler, and let it set a formatted user readable string
      // of the undelivered maps which we can use when verifying our assertions.
      if (opt_pendingMaps) {
        handler.pendingMapsString = formatArrayOfMaps(opt_pendingMaps);
      }
      if (opt_undeliveredMaps) {
        handler.undeliveredMapsString = formatArrayOfMaps(opt_undeliveredMaps);
      }
    };
    handler.channelHandleMultipleArrays = () => {};
    handler.channelHandleArray = () => {};

    channel.setHandler(handler);

    // Provide a predictable retry time for testing.
    /** @suppress {visibility} Accessing private properties. */
    channel.getRetryTime_ = (retryCount) => RETRY_TIME;

    const channelDebug = new WebChannelDebug();
    channelDebug.debug = (message) => {
      debugToWindow(message);
    };
    channel.setChannelDebug(channelDebug);

    numStatEvents = 0;
    lastStatEvent = null;
  },

  tearDown() {
    mockClock.dispose();
    stubs.reset();
    debugToWindow('<hr>');
  },

  testFormatArrayOfMaps() {
    // This function is used in a non-trivial test, so let's verify that it
    // works.
    const map1 = new Map();
    map1.set('k1', 'v1');
    map1.set('k2', 'v2');
    const map2 = new Map();
    map2.set('k3', 'v3');
    const map3 = new Map();
    map3.set('k4', 'v4');
    map3.set('k5', 'v5');
    map3.set('k6', 'v6');

    // One map.
    const a = [];
    a.push(new Wire.QueuedMap(0, map1));
    assertEquals('k1:v1, k2:v2', formatArrayOfMaps(a));

    // Many maps.
    const b = [];
    b.push(new Wire.QueuedMap(0, map1));
    b.push(new Wire.QueuedMap(0, map2));
    b.push(new Wire.QueuedMap(0, map3));
    assertEquals(
        'k1:v1, k2:v2, k3:v3, k4:v4, k5:v5, k6:v6', formatArrayOfMaps(b));

    // One map with a context.
    const c = [];
    c.push(new Wire.QueuedMap(0, map1, new String('c1')));
    assertEquals('k1:v1:c1, k2:v2:c1', formatArrayOfMaps(c));
  },

  /** @suppress {visibility} Accessing private properties. */
  testConnect() {
    connect();
    assertEquals(WebChannelBase.State.OPENED, channel.getState());
    // If the server specifies no version, the client assumes the latest version
    assertEquals(Wire.LATEST_CHANNEL_VERSION, channel.channelVersion_);
    assertFalse(channel.isBuffered());
  },

  testConnect_backChannelEstablished() {
    connect();
    assertHasBackChannel();
  },

  /** @suppress {visibility} Accessing private properties. */
  testConnect_withServerHostPrefix() {
    connect(undefined, 'serverHostPrefix');
    assertEquals('serverHostPrefix', channel.hostPrefix_);
  },

  /** @suppress {visibility} Accessing private properties. */
  testConnect_withClientHostPrefix() {
    handler.correctHostPrefix = (hostPrefix) => 'clientHostPrefix';
    connect();
    assertEquals('clientHostPrefix', channel.hostPrefix_);
  },

  /** @suppress {visibility} Accessing private properties. */
  testConnect_overrideServerHostPrefix() {
    handler.correctHostPrefix = (hostPrefix) => 'clientHostPrefix';
    connect(undefined, 'serverHostPrefix');
    assertEquals('clientHostPrefix', channel.hostPrefix_);
  },

  /** @suppress {visibility} Accessing private properties. */
  testConnect_withServerVersion() {
    connect(8);
    assertEquals(8, channel.channelVersion_);
  },

  testConnect_notOkToMakeRequestForTest() {
    handler.okToMakeRequest = functions.constant(WebChannelBase.Error.NETWORK);
    channel.connect('/bind', null);
    mockClock.tick(0);
    assertEquals(WebChannelBase.State.CLOSED, channel.getState());
  },

  testConnect_notOkToMakeRequestForBind() {
    channel.connect('/bind', null);
    mockClock.tick(0);
    handler.okToMakeRequest = functions.constant(WebChannelBase.Error.NETWORK);
    completeForwardChannel();
    assertEquals(WebChannelBase.State.CLOSED, channel.getState());
  },

  testSendMap_withObjectMap() {
    connect();
    sendMapOnce(MapTypes.OBJECT_MAP);
  },

  testSendMap_withStructsMap() {
    connect();
    sendMapOnce(MapTypes.STRUCTS_MAP);
  },

  testSendMap_withEs6Map() {
    connect();
    sendMapOnce(MapTypes.ES6_MAP);
  },

  testSendMapWithSpdyEnabled() {
    connect(undefined, undefined, undefined, true);
    sendMapOnce();
  },

  testSendMap_twice() {
    connect();
    sendMapTwice();
  },

  testSendMap_twiceWithSpdyEnabled() {
    connect(undefined, undefined, undefined, true);
    sendMapTwice();
  },

  testSendMap_andReceive() {
    connect();
    sendMap('foo', 'bar');
    responseDone();
    receive('["the server reply"]');
  },

  testReceive() {
    connect();
    receive('["message from server"]');
    assertHasBackChannel();
  },

  testReceive_twice() {
    connect();
    receive('["message one from server"]');
    receive('["message two from server"]');
    assertHasBackChannel();
  },

  testReceive_andSendMap() {
    connect();
    receive('["the server reply"]');
    sendMap('foo', 'bar');
    responseDone();
    assertHasBackChannel();
  },

  testBackChannelRemainsEstablished_afterSingleSendMap() {
    connect();

    sendMap('foo', 'bar');
    responseDone();
    receive('["ack"]');

    assertHasBackChannel();
  },

  testBackChannelRemainsEstablished_afterDoubleSendMap() {
    connect();

    sendMap('foo1', 'bar1');
    sendMap('foo2', 'bar2');
    responseDone();
    receive('["ack"]');

    // This assertion would fail prior to CL 13302660.
    assertHasBackChannel();
  },

  testTimingEvent() {
    connect();
    assertEquals(1, numTimingEvents);
    sendMap('', '');
    assertEquals(1, numTimingEvents);
    mockClock.tick(20);
    let expSize = getSingleForwardRequest().getPostData().length;
    responseDone();

    assertEquals(2, numTimingEvents);
    assertEquals(expSize, lastPostSize);
    assertEquals(20, lastPostRtt);
    assertEquals(0, lastPostRetryCount);

    sendMap('abcdefg', '123456');
    expSize = getSingleForwardRequest().getPostData().length;
    responseTimeout();
    assertEquals(2, numTimingEvents);
    mockClock.tick(RETRY_TIME + 1);
    responseDone();
    assertEquals(3, numTimingEvents);
    assertEquals(expSize, lastPostSize);
    assertEquals(1, lastPostRetryCount);
    assertEquals(1, lastPostRtt);
  },

  /**
   * Make sure that dropping the forward channel retry limit below the retry
   * count reports an error, and prevents another request from firing.
   */
  testSetFailFastWhileWaitingForRetry() {
    stubNetUtils();

    connect();
    setFailFastWhileWaitingForRetry();
  },

  testSetFailFastWhileWaitingForRetryWithSpdyEnabled() {
    stubNetUtils();

    connect(undefined, undefined, undefined, true);
    setFailFastWhileWaitingForRetry();
  },

  /**
   * Make sure that dropping the forward channel retry limit below the retry
   * count reports an error, and prevents another request from firing.
   */
  testSetFailFastWhileRetryXhrIsInFlight() {
    stubNetUtils();

    connect();
    setFailFastWhileRetryXhrIsInFlight();
  },

  testSetFailFastWhileRetryXhrIsInFlightWithSpdyEnabled() {
    stubNetUtils();

    connect(undefined, undefined, undefined, true);
    setFailFastWhileRetryXhrIsInFlight();
  },

  /**
   * Makes sure that setting fail fast while not retrying doesn't cause a
   *      failure.
   * @suppress {visibility} Accessing private properties.
   */
  testSetFailFastAtRetryCount() {
    stubNetUtils();

    connect();
    assertEquals(1, numTimingEvents);

    sendMap('foo', 'bar');
    assertNull(channel.forwardChannelTimerId_);
    assertNotNull(getSingleForwardRequest());
    assertEquals(0, channel.forwardChannelRetryCount_);

    // Set fail fast.
    channel.setFailFast(true);
    // Request should still be alive.
    assertNull(channel.forwardChannelTimerId_);
    assertNotNull(getSingleForwardRequest());
    assertEquals(0, channel.forwardChannelRetryCount_);

    // Watchdog timeout. Now we should get an error.
    responseTimeout();
    assertNull(channel.forwardChannelTimerId_);
    assertNull(getSingleForwardRequest());
    assertEquals(0, channel.forwardChannelRetryCount_);

    // We get the error immediately before starting to ping google.com.
    assertTrue(gotError);
    // We get the error immediately before starting to ping google.com.
    // Simulate that timing out. We should not get another error in addition
    // to the initial failure.
    gotError = false;
    mockClock.tick(netUtils.NETWORK_TIMEOUT);
    assertFalse('Extra error after network ping timed out.', gotError);

    // Make sure no more retry timers are firing.
    mockClock.tick(ALL_DAY_MS);
    assertNull(channel.forwardChannelTimerId_);
    assertNull(getSingleForwardRequest());
    assertEquals(0, channel.forwardChannelRetryCount_);
    assertEquals(1, numTimingEvents);
  },

  testRequestFailedClosesChannel() {
    stubNetUtils();

    connect();
    requestFailedClosesChannel();
  },

  testRequestFailedClosesChannelWithSpdyEnabled() {
    stubNetUtils();

    connect(undefined, undefined, undefined, true);
    requestFailedClosesChannel();
  },

  testStatEventReportedOnlyOnce() {
    stubNetUtils();

    connect();
    sendMap('foo', 'bar');
    numStatEvents = 0;
    lastStatEvent = null;
    responseUnknownSessionId();

    assertEquals(1, numStatEvents);
    assertEquals(Stat.ERROR_OTHER, lastStatEvent);

    numStatEvents = 0;
    mockClock.tick(netUtils.NETWORK_TIMEOUT);
    assertEquals('No new stat events should be reported.', 0, numStatEvents);
  },

  testStatEventReportedOnlyOnce_onNetworkUp() {
    stubNetUtils();

    connect();
    sendMap('foo', 'bar');
    numStatEvents = 0;
    lastStatEvent = null;
    responseRequestFailed();

    assertEquals(
        'No stat event should be reported before we know the reason.', 0,
        numStatEvents);

    // Let the ping time out.
    mockClock.tick(netUtils.NETWORK_TIMEOUT);

    // Assert we report the correct stat event.
    assertEquals(1, numStatEvents);
    assertEquals(Stat.ERROR_NETWORK, lastStatEvent);
  },

  /** @suppress {visibility} Accessing private properties. */
  testStatEventReportedOnlyOnce_onNetworkDown() {
    stubNetUtils();

    connect();
    sendMap('foo', 'bar');
    numStatEvents = 0;
    lastStatEvent = null;
    responseRequestFailed();

    assertEquals(
        'No stat event should be reported before we know the reason.', 0,
        numStatEvents);

    // Wait half the ping timeout period, and then fake the network being up.
    mockClock.tick(netUtils.NETWORK_TIMEOUT / 2);
    channel.testNetworkCallback_(true);

    // Assert we report the correct stat event.
    assertEquals(1, numStatEvents);
    assertEquals(Stat.ERROR_OTHER, lastStatEvent);
  },

  testOutgoingMapsAwaitsResponse() {
    connect();
    outgoingMapsAwaitsResponse();
  },

  testOutgoingMapsAwaitsResponseWithSpdyEnabled() {
    connect(undefined, undefined, undefined, true);
    outgoingMapsAwaitsResponse();
  },

  testUndeliveredMaps_doesNotNotifyWhenSuccessful() {
    /**
     * @suppress {checkTypes} The callback function type declaration is
     * skipped.
     */
    handler.channelClosed = (channel, opt_pendingMaps, opt_undeliveredMaps) => {
      if (opt_pendingMaps || opt_undeliveredMaps) {
        fail('No pending or undelivered maps should be reported.');
      }
    };

    connect();
    sendMap('foo1', 'bar1');
    responseDone();
    sendMap('foo2', 'bar2');
    responseDone();
    disconnect();
  },

  testUndeliveredMaps_doesNotNotifyIfNothingWasSent() {
    /**
     * @suppress {checkTypes} The callback function type declaration is
     * skipped.
     */
    handler.channelClosed = (channel, opt_pendingMaps, opt_undeliveredMaps) => {
      if (opt_pendingMaps || opt_undeliveredMaps) {
        fail('No pending or undelivered maps should be reported.');
      }
    };

    connect();
    mockClock.tick(ALL_DAY_MS);
    disconnect();
  },

  // NOTE: The current setup for ALL existing testUndeliveredMaps_* tests rely
  // heavily on the non-HTTP2-or-SPDY behavior (i.e. one message can be sent at
  // any given time).
  /** @suppress {visibility} Accessing private properties. */
  testUndeliveredMaps_clearsPendingMapsAfterNotifying() {
    connect();
    sendMap('foo1', 'bar1');
    sendMap('foo2', 'bar2');
    sendMap('foo3', 'bar3');

    assertEquals(
        1, channel.forwardChannelRequestPool_.getPendingMessages().length);
    assertEquals(2, channel.outgoingMaps_.length);

    disconnect();

    assertEquals(
        0, channel.forwardChannelRequestPool_.getPendingMessages().length);
    assertEquals(0, channel.outgoingMaps_.length);
  },

  /** @suppress {missingProperties} suppression added to enable type checking */
  testUndeliveredMaps_notifiesWithContext() {
    connect();

    // First send two messages that succeed.
    sendMap('foo1', 'bar1', 'context1');
    responseDone();
    sendMap('foo2', 'bar2', 'context2');
    responseDone();

    // Pretend the server hangs and no longer responds.
    sendMap('foo3', 'bar3', 'context3');
    sendMap('foo4', 'bar4', 'context4');
    sendMap('foo5', 'bar5', 'context5');

    // Give up.
    disconnect();

    // Assert that we are informed of any undelivered messages; both about
    // #3 that was sent but which we don't know if the server received, and
    // #4 and #5 which remain in the outgoing maps and have not yet been sent.
    assertEquals('foo3:bar3:context3', handler.pendingMapsString);
    assertEquals(
        'foo4:bar4:context4, foo5:bar5:context5',
        handler.undeliveredMapsString);
  },

  /** @suppress {missingProperties} suppression added to enable type checking */
  testUndeliveredMaps_serviceUnavailable() {
    // Send a few maps, and let one fail.
    connect();
    sendMap('foo1', 'bar1');
    responseDone();
    sendMap('foo2', 'bar2');
    responseRequestFailed();

    // After a failure, the channel should be closed.
    disconnect();

    assertEquals('foo2:bar2', handler.pendingMapsString);
    assertEquals('', handler.undeliveredMapsString);
  },

  /** @suppress {missingProperties} suppression added to enable type checking */
  testUndeliveredMaps_onPingTimeout() {
    stubNetUtils();

    connect();

    // Send a message.
    sendMap('foo1', 'bar1');

    // Fake REQUEST_FAILED, triggering a ping to check the network.
    responseRequestFailed();

    // Let the ping time out, unsuccessfully.
    mockClock.tick(netUtils.NETWORK_TIMEOUT);

    // Assert channel is closed.
    assertEquals(WebChannelBase.State.CLOSED, channel.getState());

    // Assert that the handler is notified about the undelivered messages.
    assertEquals('foo1:bar1', handler.pendingMapsString);
    assertEquals('', handler.undeliveredMapsString);
  },

  /** @suppress {visibility} Accessing private properties. */
  testGetNonAckedMapsBeforeChannelClose_returnsUnionOfPendingAndUnsentMaps() {
    connect();

    // First send one message and respond with server ack.
    sendMap('foo1', 'bar1');
    responseDone();

    // Send 3 more messages which are non-acked.
    sendMap('foo2', 'bar2');
    sendMap('foo3', 'bar3');
    sendMap('foo4', 'bar4');

    // Verifies that we're indeed covering the case where 1 message is pending
    // server ack and 2 message has not been sent to the network.
    assertEquals(
        1, channel.forwardChannelRequestPool_.getPendingMessages().length);
    assertEquals(2, channel.outgoingMaps_.length);

    assertObjectEquals(
        [{foo2: 'bar2'}, {foo3: 'bar3'}, {foo4: 'bar4'}],
        channel.getNonAckedMaps().map(queuedMap => queuedMap.map));
  },

  /** @suppress {visibility} Accessing private properties. */
  testGetNonAckedMapsAfterChannelClose_returnsUnionOfPendingAndUnsentMaps() {
    connect();

    // First send one message and respond with server ack.
    sendMap('foo1', 'bar1');
    responseDone();

    // Send 3 more messages which are non-acked.
    sendMap('foo2', 'bar2');
    sendMap('foo3', 'bar3');
    sendMap('foo4', 'bar4');

    // Verifies that we're indeed covering the case where 1 message is pending
    // server ack and 2 message has not been sent to the network.
    assertEquals(
        1, channel.forwardChannelRequestPool_.getPendingMessages().length);
    assertEquals(2, channel.outgoingMaps_.length);

    disconnect();

    assertObjectEquals(
        [{foo2: 'bar2'}, {foo3: 'bar3'}, {foo4: 'bar4'}],
        channel.getNonAckedMaps().map(queuedMap => queuedMap.map));
  },

  /** @suppress {visibility} Accessing private properties. */
  testResponseNoBackchannelPostNotBeforeBackchannel() {
    connect(8);
    sendMap('foo1', 'bar1');

    mockClock.tick(10);
    assertFalse(
        channel.backChannelRequest_.getRequestStartTime() <
        getSingleForwardRequest().getRequestStartTime());
    responseNoBackchannel();
    assertNotEquals(Stat.BACKCHANNEL_MISSING, lastStatEvent);
  },

  /** @suppress {visibility} Accessing private properties. */
  testResponseNoBackchannel() {
    connect(8);
    sendMap('foo1', 'bar1');
    response(-1, 0);
    mockClock.tick(WebChannelBase.RTT_ESTIMATE + 1);
    sendMap('foo2', 'bar2');
    assertTrue(
        channel.backChannelRequest_.getRequestStartTime() +
            WebChannelBase.RTT_ESTIMATE <
        getSingleForwardRequest().getRequestStartTime());
    responseNoBackchannel();
    assertEquals(Stat.BACKCHANNEL_MISSING, lastStatEvent);
  },

  /** @suppress {visibility} Accessing private properties. */
  testResponseNoBackchannelWithNoBackchannel() {
    connect(8);
    sendMap('foo1', 'bar1');
    assertNull(channel.backChannelTimerId_);
    channel.backChannelRequest_.cancel();
    /** @suppress {visibility} Accessing private properties. */
    channel.backChannelRequest_ = null;
    responseNoBackchannel();
    assertEquals(Stat.BACKCHANNEL_MISSING, lastStatEvent);
  },

  /** @suppress {visibility} Accessing private properties. */
  testResponseNoBackchannelWithStartTimer() {
    connect(8);
    sendMap('foo1', 'bar1');

    channel.backChannelRequest_.cancel();
    /** @suppress {visibility} Accessing private properties. */
    channel.backChannelRequest_ = null;
    /** @suppress {visibility} Accessing private properties. */
    channel.backChannelTimerId_ = 123;
    responseNoBackchannel();
    assertNotEquals(Stat.BACKCHANNEL_MISSING, lastStatEvent);
  },

  /** @suppress {visibility} Accessing private properties. */
  testResponseWithNoArraySent() {
    connect(8);
    sendMap('foo1', 'bar1');

    // Send a response as if the server hasn't sent down an array.
    response(-1, 0);

    // POST response with an array ID lower than our last received is OK.
    assertEquals(1, channel.lastArrayId_);
    assertEquals(-1, channel.lastPostResponseArrayId_);
  },

  /** @suppress {visibility} Accessing private properties. */
  testResponseWithArraysMissing() {
    connect(8);
    sendMap('foo1', 'bar1');
    assertEquals(-1, channel.lastPostResponseArrayId_);

    // Send a response as if the server has sent down seven arrays.
    response(7, 111);

    assertEquals(1, channel.lastArrayId_);
    assertEquals(7, channel.lastPostResponseArrayId_);
    mockClock.tick(WebChannelBase.RTT_ESTIMATE * 2);
    assertEquals(Stat.BACKCHANNEL_DEAD, lastStatEvent);
  },

  /** @suppress {visibility} Accessing private properties. */
  testMultipleResponsesWithArraysMissing() {
    connect(8);
    sendMap('foo1', 'bar1');
    assertEquals(-1, channel.lastPostResponseArrayId_);

    // Send a response as if the server has sent down seven arrays.
    response(7, 111);

    assertEquals(1, channel.lastArrayId_);
    assertEquals(7, channel.lastPostResponseArrayId_);
    sendMap('foo2', 'bar2');
    mockClock.tick(WebChannelBase.RTT_ESTIMATE);
    response(8, 119);
    mockClock.tick(WebChannelBase.RTT_ESTIMATE);
    // The original timer should still fire.
    assertEquals(Stat.BACKCHANNEL_DEAD, lastStatEvent);
  },

  /** @suppress {visibility} Accessing private properties. */
  testOnlyRetryOnceBasedOnResponse() {
    connect(8);
    sendMap('foo1', 'bar1');
    assertEquals(-1, channel.lastPostResponseArrayId_);

    // Send a response as if the server has sent down seven arrays.
    response(7, 111);

    assertEquals(1, channel.lastArrayId_);
    assertEquals(7, channel.lastPostResponseArrayId_);
    assertTrue(hasDeadBackChannelTimer());
    mockClock.tick(WebChannelBase.RTT_ESTIMATE * 2);
    assertEquals(Stat.BACKCHANNEL_DEAD, lastStatEvent);
    assertEquals(1, channel.backChannelRetryCount_);
    mockClock.tick(WebChannelBase.RTT_ESTIMATE);
    sendMap('foo2', 'bar2');
    assertFalse(hasDeadBackChannelTimer());
    response(8, 119);
    assertFalse(hasDeadBackChannelTimer());
  },

  /** @suppress {visibility} Accessing private properties. */
  testResponseWithArraysMissingAndLiveChannel() {
    connect(8);
    sendMap('foo1', 'bar1');
    assertEquals(-1, channel.lastPostResponseArrayId_);

    // Send a response as if the server has sent down seven arrays.
    response(7, 111);

    assertEquals(1, channel.lastArrayId_);
    assertEquals(7, channel.lastPostResponseArrayId_);
    mockClock.tick(WebChannelBase.RTT_ESTIMATE);
    assertTrue(hasDeadBackChannelTimer());
    receive('["ack"]');
    assertFalse(hasDeadBackChannelTimer());
    mockClock.tick(WebChannelBase.RTT_ESTIMATE);
    assertNotEquals(Stat.BACKCHANNEL_DEAD, lastStatEvent);
  },

  /** @suppress {visibility} Accessing private properties. */
  testResponseWithBigOutstandingData() {
    connect(8);
    sendMap('foo1', 'bar1');
    assertEquals(-1, channel.lastPostResponseArrayId_);

    // Send a response as if the server has sent down seven arrays and 50kbytes.
    response(7, 50000);

    assertEquals(1, channel.lastArrayId_);
    assertEquals(7, channel.lastPostResponseArrayId_);
    assertFalse(hasDeadBackChannelTimer());
    mockClock.tick(WebChannelBase.RTT_ESTIMATE * 2);
    assertNotEquals(Stat.BACKCHANNEL_DEAD, lastStatEvent);
  },

  /** @suppress {visibility} Accessing private properties. */
  testResponseInBufferedMode() {
    connect(8);
    /** @suppress {visibility} Accessing private properties. */
    channel.enableStreaming_ = false;
    sendMap('foo1', 'bar1');
    assertEquals(-1, channel.lastPostResponseArrayId_);
    response(7, 111);

    assertEquals(1, channel.lastArrayId_);
    assertEquals(7, channel.lastPostResponseArrayId_);
    assertFalse(hasDeadBackChannelTimer());
    mockClock.tick(WebChannelBase.RTT_ESTIMATE * 2);
    assertNotEquals(Stat.BACKCHANNEL_DEAD, lastStatEvent);
  },

  testResponseWithGarbage() {
    connect(8);
    sendMap('foo1', 'bar1');
    channel.onRequestData(getSingleForwardRequest(), 'garbage');
    assertEquals(WebChannelBase.State.CLOSED, channel.getState());
  },

  testResponseWithGarbageInArray() {
    connect(8);
    sendMap('foo1', 'bar1');
    channel.onRequestData(getSingleForwardRequest(), '["garbage"]');
    assertEquals(WebChannelBase.State.CLOSED, channel.getState());
  },

  testResponseWithEvilData() {
    connect(8);
    sendMap('foo1', 'bar1');
    channel.onRequestData(
        getSingleForwardRequest(),
        'foo=<script>evil()<\/script>&' +
            'bar=<script>moreEvil()<\/script>');
    assertEquals(WebChannelBase.State.CLOSED, channel.getState());
  },

  /** @suppress {visibility} Accessing private properties. */
  testPathAbsolute() {
    connect(8, undefined, '/talkgadget');
    assertEquals(channel.backChannelUri_.getDomain(), window.location.hostname);
    assertEquals(
        channel.forwardChannelUri_.getDomain(), window.location.hostname);
  },

  /** @suppress {visibility} Accessing private properties. */
  testPathRelative() {
    connect(8, undefined, 'talkgadget');
    assertEquals(channel.backChannelUri_.getDomain(), window.location.hostname);
    assertEquals(
        channel.forwardChannelUri_.getDomain(), window.location.hostname);
  },

  /** @suppress {visibility} Accessing private properties. */
  testPathWithHost() {
    connect(8, undefined, 'https://example.com');
    assertEquals(channel.backChannelUri_.getScheme(), 'https');
    assertEquals(channel.backChannelUri_.getDomain(), 'example.com');
    assertEquals(channel.forwardChannelUri_.getScheme(), 'https');
    assertEquals(channel.forwardChannelUri_.getDomain(), 'example.com');
  },

  testCreateXhrIo() {
    let xhr = channel.createXhrIo(null);
    assertFalse(xhr.getWithCredentials());

    assertThrows(
        'Error connection to different host without CORS',
        goog.bind(channel.createXhrIo, channel, 'some_host'));

    channel.setSupportsCrossDomainXhrs(true);

    xhr = channel.createXhrIo(null);
    assertTrue(xhr.getWithCredentials());

    xhr = channel.createXhrIo('some_host');
    assertTrue(xhr.getWithCredentials());
  },

  testSpdyLimitOption() {
    const webChannelTransport = new WebChannelBaseTransport();
    stubSpdyCheck(true);
    const webChannelDefault = webChannelTransport.createWebChannel('/foo');
    assertEquals(
        10,
        webChannelDefault.getRuntimeProperties().getConcurrentRequestLimit());
    assertTrue(webChannelDefault.getRuntimeProperties().isSpdyEnabled());

    const options = {'concurrentRequestLimit': 100};

    stubSpdyCheck(false);
    const webChannelDisabled =
        webChannelTransport.createWebChannel('/foo', options);
    assertEquals(
        1,
        webChannelDisabled.getRuntimeProperties().getConcurrentRequestLimit());
    assertFalse(webChannelDisabled.getRuntimeProperties().isSpdyEnabled());

    stubSpdyCheck(true);
    const webChannelEnabled =
        webChannelTransport.createWebChannel('/foo', options);
    assertEquals(
        100,
        webChannelEnabled.getRuntimeProperties().getConcurrentRequestLimit());
    assertTrue(webChannelEnabled.getRuntimeProperties().isSpdyEnabled());
  },
});
