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

goog.module('goog.debug.ErrorReporterTest');
goog.setTestOnly();

const DebugError = goog.require('goog.debug.Error');
const ErrorReporter = goog.require('goog.debug.ErrorReporter');
const PropertyReplacer = goog.require('goog.testing.PropertyReplacer');
const dispose = goog.require('goog.dispose');
const errorcontext = goog.require('goog.debug.errorcontext');
const events = goog.require('goog.events');
const functions = goog.require('goog.functions');
const product = goog.require('goog.userAgent.product');
const testSuite = goog.require('goog.testing.testSuite');
const userAgent = goog.require('goog.userAgent');

class MockXhrIo {
  onReadyStateChangeEntryPoint_() {}

  static protectEntryPoints() {}

  static send(
      url, callback = undefined, method = undefined, content = undefined,
      headers = undefined, timeInterval = undefined) {
    MockXhrIo.lastUrl = url;
    MockXhrIo.lastContent = content;
    MockXhrIo.lastHeaders = headers;
  }
}

MockXhrIo.lastUrl = null;

let errorReporter;
const originalSetTimeout = window.setTimeout;
const stubs = new PropertyReplacer();
const url = 'http://www.your.tst/more/bogus.js';
const encodedUrl = 'http%3A%2F%2Fwww.your.tst%2Fmore%2Fbogus.js';

/**
 * @param {*} filename
 * @param {*} line
 * @param {*} message
 * @param {*=} stack
 * @param {*=} cause
 * @return {*}
 */
function createError(
    filename, line, message, stack = undefined, cause = undefined) {
  const error = {
    message: message,
    fileName: filename,
    lineNumber: line,
    toString: function() {
      return 'Error: ' + message;
    }
  };
  if (stack) {
    error['stack'] = stack;
  }
  if (cause) {
    error['cause'] = cause;
  }

  return error;
}

/**
 * @param {*} script
 * @param {*} line
 * @param {*} message
 * @param {*=} stack
 * @param {*=} cause
 */
function throwAnErrorWith(
    script, line, message, stack = undefined, cause = undefined) {
  throw createError(script, line, message, stack, cause);
}

testSuite({
  setUp() {
    stubs.set(goog.net, 'XhrIo', MockXhrIo);
    // NOTE: bypass compiler check for the define
    ErrorReporter['ALLOW_AUTO_PROTECT'] = true;
  },

  tearDown() {
    dispose(errorReporter);
    stubs.reset();
    MockXhrIo.lastUrl = null;
  },

  testsendErrorReport() {
    errorReporter = new ErrorReporter('/log');
    errorReporter.sendErrorReport('message', 'filename.js', 123, 'trace');

    assertEquals(
        '/log?script=filename.js&error=message&line=123', MockXhrIo.lastUrl);
    assertEquals('trace=trace', MockXhrIo.lastContent);
  },

  /** @suppress {checkTypes} suppression added to enable type checking */
  testsendErrorReportWithCustomSender() {
    let uri = null;
    let method = null;
    let content = null;
    let headers = null;
    function mockXhrSender(uriIn, methodIn, contentIn, headersIn) {
      uri = uriIn;
      method = methodIn;
      content = contentIn;
      headers = headersIn;
    }

    errorReporter = new ErrorReporter('/log');
    errorReporter.setXhrSender(mockXhrSender);
    errorReporter.sendErrorReport('message', 'filename.js', 123, 'trace');

    assertEquals('/log?script=filename.js&error=message&line=123', uri);
    assertEquals('POST', method);
    assertEquals('trace=trace', content);
    assertUndefined(headers);
  },

  testsendErrorReport_noTrace() {
    errorReporter = new ErrorReporter('/log');
    errorReporter.sendErrorReport('message', 'filename.js', 123);

    assertEquals(
        '/log?script=filename.js&error=message&line=123', MockXhrIo.lastUrl);
    assertEquals('', MockXhrIo.lastContent);
  },

  test_nonInternetExplorerSendErrorReport() {
    if (product.SAFARI) {
      // TODO(user): Disabled so we can get the rest of the Closure test
      // suite running in a continuous build. Will investigate later.
      return;
    }

    stubs.set(userAgent, 'IE', false);
    stubs.set(globalThis, 'setTimeout', (fcn, time) => {
      fcn.call();
    });

    errorReporter = ErrorReporter.install('/errorreporter');

    const errorFunction = goog.partial(throwAnErrorWith, url, 5, 'Hello :)');

    try {
      globalThis.setTimeout(errorFunction, 0);
    } catch (e) {
      // Expected. The error is rethrown after sending.
    }

    assertEquals(
        `/errorreporter?script=${encodedUrl}&error=Hello%20%3A)&line=5`,
        MockXhrIo.lastUrl);
    assertEquals('trace=Not%20available', MockXhrIo.lastContent);
  },



  test_nonInternetExplorerSendErrorReportWithTrace() {
    stubs.set(userAgent, 'IE', false);
    stubs.set(globalThis, 'setTimeout', (fcn, time) => {
      fcn.call();
    });

    errorReporter = ErrorReporter.install('/errorreporter');

    const trace = 'Error(\"Something Wrong\")@:0\n' +
        '$MF$E$Nx$([object Object])@http://a.b.c:83/a/f.js:901\n' +
        '([object Object])@http://a.b.c:813/a/f.js:37';

    const errorFunction =
        goog.partial(throwAnErrorWith, url, 5, 'Hello :)', trace);

    try {
      globalThis.setTimeout(errorFunction, 0);
    } catch (e) {
      // Expected. The error is rethrown after sending.
    }

    assertEquals(
        `/errorreporter?script=${encodedUrl}&error=Hello%20%3A)&line=5`,
        MockXhrIo.lastUrl);
    assertEquals(
        'trace=' +
            'Error(%22Something%20Wrong%22)%40%3A0%0A' +
            '%24MF%24E%24Nx%24(%5Bobject%20Object%5D)%40' +
            'http%3A%2F%2Fa.b.c%3A83%2Fa%2Ff.js%3A901%0A' +
            '(%5Bobject%20Object%5D)%40http%3A%2F%2Fa.b.c%3A813%2Fa%2Ff.js%3A37',
        MockXhrIo.lastContent);
  },

  test_nonInternetExplorerSendErrorReportWithTraceAndCauses() {
    stubs.set(userAgent, 'IE', false);
    stubs.set(globalThis, 'setTimeout', (fcn, time) => {
      fcn.call();
    });

    const maintrace = 'Error(\"Something Wrong\")@:0\n' +
        '$MF$E$Nx$([object Object])@http://a.b.c:83/a/f.js:901\n' +
        '([object Object])@http://a.b.c:813/a/f.js:37';

    // For this cause, the error message is part of the stacktrace.
    const causetrace1 =
        'Error: Cause1 Error\n([object Object])@http://a.b.c:813/b/d.js:35';
    const causetrace2 =
        '$AB$B$Wx$([object Object])@http://a.b.c:83/c/e.js:101\n' +
        '([object Object])@http://a.b.c:813/c/d.js:3';

    const cause2 =
        createError(url, 1, 'Cause2 Error', causetrace2, 'String cause');
    const cause1 = createError(url, 12, 'Cause1 Error', causetrace1, cause2);

    const expectedTrace = 'Error("Something Wrong")@:0\n' +
        '$MF$E$Nx$([object Object])@http://a.b.c:83/a/f.js:901\n' +
        '([object Object])@http://a.b.c:813/a/f.js:37\n' +
        'Caused by: Error: Cause1 Error\n' +
        '([object Object])@http://a.b.c:813/b/d.js:35\n' +
        'Caused by: Cause2 Error\n' +
        '$AB$B$Wx$([object Object])@http://a.b.c:83/c/e.js:101\n' +
        '([object Object])@http://a.b.c:813/c/d.js:3\n' +
        'Caused by: String cause';

    errorReporter = ErrorReporter.install('/errorreporter');
    events.listen(errorReporter, ErrorReporter.ExceptionEvent.TYPE, (event) => {
      assertEquals(expectedTrace, event.error.stack);
    });

    const errorFunction =
        goog.partial(throwAnErrorWith, url, 5, 'MainError', maintrace, cause1);

    try {
      globalThis.setTimeout(errorFunction, 0);
    } catch (e) {
      // Expected. The error is rethrown after sending.
    }

    assertEquals(
        `/errorreporter?script=${encodedUrl}&error=MainError&line=5`,
        MockXhrIo.lastUrl);
    assertEquals(
        'trace=' + encodeURIComponent(expectedTrace), MockXhrIo.lastContent);
  },

  test_nonInternetExplorerSendErrorReportWithCyclicCauses() {
    stubs.set(userAgent, 'IE', false);
    stubs.set(globalThis, 'setTimeout', (fcn, time) => {
      fcn.call();
    });

    const maintrace = 'Error(\"Something Wrong\")@:0\n' +
        '$MF$E$Nx$([object Object])@http://a.b.c:83/a/f.js:901\n' +
        '([object Object])@http://a.b.c:813/a/f.js:37';

    // For this cause, the error message is part of the stacktrace.
    const causetrace1 =
        'Error: Cause1 Error\n([object Object])@http://a.b.c:813/b/d.js:35';
    const causetrace2 =
        '$AB$B$Wx$([object Object])@http://a.b.c:83/c/e.js:101\n' +
        '([object Object])@http://a.b.c:813/c/d.js:3';

    const cause2 = createError(url, 1, 'Cause2 Error', causetrace2);
    const cause1 = createError(url, 12, 'Cause1 Error', causetrace1, cause2);
    // introduce a cycle
    cause2['cause'] = cause1;

    const expectedTrace = 'Error("Something Wrong")@:0\n' +
        '$MF$E$Nx$([object Object])@http://a.b.c:83/a/f.js:901\n' +
        '([object Object])@http://a.b.c:813/a/f.js:37\n' +
        'Caused by: Error: Cause1 Error\n' +
        '([object Object])@http://a.b.c:813/b/d.js:35\n' +
        'Caused by: Cause2 Error\n' +
        '$AB$B$Wx$([object Object])@http://a.b.c:83/c/e.js:101\n' +
        '([object Object])@http://a.b.c:813/c/d.js:3';

    errorReporter = ErrorReporter.install('/errorreporter');
    events.listen(errorReporter, ErrorReporter.ExceptionEvent.TYPE, (event) => {
      assertEquals(expectedTrace, event.error.stack);
    });

    const errorFunction =
        goog.partial(throwAnErrorWith, url, 5, 'MainError', maintrace, cause1);

    try {
      globalThis.setTimeout(errorFunction, 0);
    } catch (e) {
      // Expected. The error is rethrown after sending.
    }

    assertEquals(
        `/errorreporter?script=${encodedUrl}&error=MainError&line=5`,
        MockXhrIo.lastUrl);
    assertEquals(
        'trace=' + encodeURIComponent(expectedTrace), MockXhrIo.lastContent);
  },

  testProtectAdditionalEntryPoint_nonIE() {
    stubs.set(userAgent, 'IE', false);

    errorReporter = ErrorReporter.install('/errorreporter');
    const fn = () => {};
    const protectedFn = errorReporter.protectAdditionalEntryPoint(fn);
    assertNotNull(protectedFn);
    assertNotEquals(fn, protectedFn);
  },

  testProtectAdditionalEntryPoint_IE() {
    stubs.set(userAgent, 'IE', true);

    errorReporter = ErrorReporter.install('/errorreporter');
    const fn = () => {};
    const protectedFn = errorReporter.protectAdditionalEntryPoint(fn);
    assertNotNull(protectedFn);
    assertNotEquals(fn, protectedFn);
  },

  testHandleException_dispatchesEvent() {
    errorReporter = ErrorReporter.install('/errorreporter');
    let loggedErrors = 0;
    events.listen(errorReporter, ErrorReporter.ExceptionEvent.TYPE, (event) => {
      assertNotNullNorUndefined(event.error);
      loggedErrors++;
    });
    errorReporter.handleException(new Error());
    errorReporter.handleException(new Error());
    assertEquals(
        'Expected 2 errors. ' +
            '(Ensure an exception was not swallowed.)',
        2, loggedErrors);
  },

  testHandleException_includesContext() {
    errorReporter = ErrorReporter.install('/errorreporter');
    let loggedErrors = 0;
    const testError = new Error('test error');
    const testContext = {'contextParam': 'contextValue'};
    events.listen(errorReporter, ErrorReporter.ExceptionEvent.TYPE, (event) => {
      assertNotNullNorUndefined(event.error);
      assertObjectEquals({contextParam: 'contextValue'}, event.context);
      loggedErrors++;
    });
    errorReporter.handleException(testError, testContext);
    assertEquals(
        'Expected 1 error. ' +
            '(Ensure an exception was not swallowed.)',
        1, loggedErrors);
  },

  testContextProvider() {
    errorReporter =
        ErrorReporter.install('/errorreporter', (error, context) => {
          /**
           * @suppress {strictMissingProperties} suppression added to enable
           * type checking
           */
          context.providedContext = 'value';
        });
    let loggedErrors = 0;
    const testError = new Error('test error');
    events.listen(errorReporter, ErrorReporter.ExceptionEvent.TYPE, (event) => {
      assertNotNullNorUndefined(event.error);
      assertObjectEquals({providedContext: 'value'}, event.context);
      loggedErrors++;
    });
    errorReporter.handleException(testError);
    assertEquals(
        'Expected 1 error. ' +
            '(Ensure an exception was not swallowed.)',
        1, loggedErrors);
  },

  testContextProvider_withOtherContext() {
    errorReporter =
        ErrorReporter.install('/errorreporter', (error, context) => {
          /**
           * @suppress {strictMissingProperties} suppression added to enable
           * type checking
           */
          context.providedContext = 'value';
        });
    let loggedErrors = 0;
    const testError = new Error('test error');
    events.listen(errorReporter, ErrorReporter.ExceptionEvent.TYPE, (event) => {
      assertNotNullNorUndefined(event.error);
      assertObjectEquals(
          {providedContext: 'value', otherContext: 'value'}, event.context);
      loggedErrors++;
    });
    errorReporter.handleException(testError, {'otherContext': 'value'});
    assertEquals(
        'Expected 1 error. ' +
            '(Ensure an exception was not swallowed.)',
        1, loggedErrors);
  },

  testErrorWithContext() {
    errorReporter = ErrorReporter.install('/errorreporter');
    let loggedErrors = 0;
    const testError = new Error('test error');
    errorcontext.addErrorContext(testError, 'key1', 'value1');
    errorcontext.addErrorContext(testError, 'animalType', 'dog');
    events.listen(errorReporter, ErrorReporter.ExceptionEvent.TYPE, (event) => {
      assertNotNullNorUndefined(event.error);
      assertObjectEquals({key1: 'value1', animalType: 'dog'}, event.context);
      loggedErrors++;
    });
    errorReporter.handleException(testError);
    assertEquals(
        'Expected 1 error. ' +
            '(Ensure an exception was not swallowed.)',
        1, loggedErrors);
  },

  testErrorWithDifferentContextSources() {
    errorReporter =
        ErrorReporter.install('/errorreporter', (error, context) => {
          /**
           * @suppress {strictMissingProperties} suppression added to enable
           * type checking
           */
          context.providedContext = 'provided ctx';
        });
    let loggedErrors = 0;
    const testError = new Error('test error');
    errorcontext.addErrorContext(testError, 'addErrorContext', 'some value');
    events.listen(errorReporter, ErrorReporter.ExceptionEvent.TYPE, (event) => {
      assertNotNullNorUndefined(event.error);
      assertObjectEquals(
          {
            addErrorContext: 'some value',
            providedContext: 'provided ctx',
            handleExceptionContext: 'another value',
          },
          event.context);
      loggedErrors++;
    });
    errorReporter.handleException(
        testError, {handleExceptionContext: 'another value'});
    assertEquals(
        'Expected 1 error. ' +
            '(Ensure an exception was not swallowed.)',
        1, loggedErrors);
  },

  testHandleException_ignoresExceptionsDuringEventDispatch() {
    errorReporter = ErrorReporter.install('/errorreporter');
    events.listen(errorReporter, ErrorReporter.ExceptionEvent.TYPE, (event) => {
      throw new Error('This exception should be swallowed.');
    });
    errorReporter.handleException(new Error());
  },

  testHandleException_doNotReportErrorToServer() {
    const error = new DebugError();
    error.reportErrorToServer = false;
    errorReporter.handleException(error);
    assertNull(MockXhrIo.lastUrl);
  },

  testDisposal() {
    errorReporter = ErrorReporter.install('/errorreporter');
    if (!userAgent.IE) {
      assertNotEquals(originalSetTimeout, window.setTimeout);
    }
    dispose(errorReporter);
    assertEquals(originalSetTimeout, window.setTimeout);
  },

  testSetContextPrefix() {
    errorReporter = new ErrorReporter('/log');
    errorReporter.setContextPrefix('baz.');
    errorReporter.sendErrorReport(
        'message', 'filename.js', 123, 'trace', {'foo': 'bar'});
    assertEquals('trace=trace&baz.foo=bar', MockXhrIo.lastContent);
  },

  testTruncationLimit() {
    errorReporter = new ErrorReporter('/log');
    errorReporter.setTruncationLimit(6);
    errorReporter.sendErrorReport(
        'message', 'filename.js', 123, 'trace', {'foo': 'bar'});
    assertEquals('trace=', MockXhrIo.lastContent);
  },

  testZeroTruncationLimit() {
    errorReporter = new ErrorReporter('/log');
    errorReporter.setTruncationLimit(0);
    errorReporter.sendErrorReport(
        'message', 'filename.js', 123, 'trace', {'foo': 'bar'});
    assertEquals('', MockXhrIo.lastContent);
  },

  testTruncationLimitLargerThanBody() {
    errorReporter = new ErrorReporter('/log');
    errorReporter.setTruncationLimit(9999);
    errorReporter.sendErrorReport(
        'message', 'filename.js', 123, 'trace', {'foo': 'bar'});
    assertEquals('trace=trace&context.foo=bar', MockXhrIo.lastContent);
  },

  testSetNegativeTruncationLimit() {
    errorReporter = new ErrorReporter('/log');
    assertThrows(() => {
      errorReporter.setTruncationLimit(-10);
    });
  },

  testSetTruncationLimitNull() {
    errorReporter = new ErrorReporter('/log');
    errorReporter.setTruncationLimit(null);
    errorReporter.sendErrorReport(
        'message', 'filename.js', 123, 'trace', {'foo': 'bar'});
    assertEquals('trace=trace&context.foo=bar', MockXhrIo.lastContent);
  },

  testAttemptAutoProtectWithAllowAutoProtectOff() {
    // Use computed property to bypass compiler check for the define value
    ErrorReporter['ALLOW_AUTO_PROTECT'] = false;
    assertThrows(() => {
      errorReporter = new ErrorReporter('/log', (e, context) => {}, false);
    });
  },

  testSetAdditionalArgumentsArgsEmptyObject() {
    errorReporter = new ErrorReporter('/log');
    errorReporter.setAdditionalArguments({});
    errorReporter.sendErrorReport(
        'message', 'filename.js', 123, 'trace', {'foo': 'bar'});
    assertEquals(
        '/log?script=filename.js&error=message&line=123', MockXhrIo.lastUrl);
  },

  testSetAdditionalArgumentsSingleArgument() {
    errorReporter = new ErrorReporter('/log');
    errorReporter.setAdditionalArguments({'extra': 'arg'});
    errorReporter.sendErrorReport(
        'message', 'filename.js', 123, 'trace', {'foo': 'bar'});
    assertEquals(
        '/log?script=filename.js&error=message&line=123&extra=arg',
        MockXhrIo.lastUrl);
  },

  testSetAdditionalArgumentsMultipleArguments() {
    errorReporter = new ErrorReporter('/log');
    errorReporter.setAdditionalArguments({'extra': 'arg', 'cat': 'dog'});
    errorReporter.sendErrorReport(
        'message', 'filename.js', 123, 'trace', {'foo': 'bar'});
    assertEquals(
        '/log?script=filename.js&error=message&line=123&extra=arg&cat=dog',
        MockXhrIo.lastUrl);
  },
});
