// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The SFC licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.

/**
 * @fileoverview Contains tests against promise error handling. Many tests use
 * NativePromise to control test termination independent of promise
 * (and notably promise.ControlFlow).
 */

'use strict';

const testutil = require('./testutil');

const assert = require('assert');
const promise = require('../../lib/promise');

const NativePromise = Promise;
const StubError = testutil.StubError;
const throwStubError = testutil.throwStubError;
const assertIsStubError = testutil.assertIsStubError;

describe('promise error handling', function() {
  var flow, uncaughtExceptions;

  beforeEach(function setUp() {
    flow = promise.controlFlow();
    uncaughtExceptions = [];
    flow.on('uncaughtException', onUncaughtException);
  });

  afterEach(function tearDown() {
    return waitForIdle(flow).then(function() {
      assert.deepEqual(
          [], uncaughtExceptions, 'There were uncaught exceptions');
      flow.reset();
    });
  });

  function onUncaughtException(e) {
    uncaughtExceptions.push(e);
  }

  function waitForAbort(opt_flow, opt_n) {
    var n = opt_n || 1;
    var theFlow = opt_flow || flow;
    theFlow.removeAllListeners(
        promise.ControlFlow.EventType.UNCAUGHT_EXCEPTION);
    return new NativePromise(function(fulfill, reject) {
      theFlow.once('idle', function() {
        reject(Error('expected flow to report an unhandled error'));
      });

      var errors = [];
      theFlow.on('uncaughtException', onError);
      function onError(e) {
        errors.push(e);
        if (errors.length === n) {
          theFlow.removeListener('uncaughtException', onError);
          fulfill(n === 1 ? errors[0] : errors);
        }
      }
    });
  }

  function waitForIdle(opt_flow) {
    var theFlow = opt_flow || flow;
    return new NativePromise(function(fulfill, reject) {
      if (theFlow.isIdle()) {
        fulfill();
        return;
      }
      theFlow.once('idle', fulfill);
      theFlow.once('uncaughtException', reject);
    });
  }

  it('testRejectedPromiseTriggersErrorCallback', function() {
    return promise.rejected(new StubError).
        then(assert.fail, assertIsStubError);
  });

  describe('callback throws trigger subsequent error callback', function() {
    it('fulfilled promise', function() {
      return promise.fulfilled().
          then(throwStubError).
          then(assert.fail, assertIsStubError);
    });

    it('rejected promise', function() {
      var e = Error('not the droids you are looking for');
      return promise.rejected(e).
          then(assert.fail, throwStubError).
          then(assert.fail, assertIsStubError);
    });
  });

  describe('callback returns rejected promise triggers subsequent errback', function() {
    it('from fulfilled callback', function() {
      return promise.fulfilled().then(function() {
        return promise.rejected(new StubError);
      }).then(assert.fail, assertIsStubError);
    });

    it('from rejected callback', function() {
      var e = Error('not the droids you are looking for');
      return promise.rejected(e).
          then(assert.fail, function() {
            return promise.rejected(new StubError);
          }).
          then(assert.fail, assertIsStubError);
    });
  });

  it('testReportsUnhandledRejectionsThroughTheControlFlow', function() {
    promise.rejected(new StubError);
    return waitForAbort().then(assertIsStubError);
  });

  describe('multiple unhandled rejections outside a task', function() {
    it('are reported in order they occurred', function() {
      var e1 = Error('error 1');
      var e2 = Error('error 2');

      promise.rejected(e1);
      promise.rejected(e2);

      return waitForAbort(flow).then(function(error) {
        assert.ok(
            error instanceof promise.MultipleUnhandledRejectionError);
        // TODO: switch to Array.from when we drop node 0.12.x
        var errors = [];
        for (var e of error.errors) {
          errors.push(e);
        }
        assert.deepEqual([e1, e2], errors);
      });
    });
  });

  describe('does not report unhandled rejection when', function() {
    it('handler added before next tick', function() {
      promise.rejected(new StubError).then(assert.fail, assertIsStubError);
      return waitForIdle();
    });

    it('added async but before next tick', function() {
      var called = false;
      return new NativePromise(function(fulfill, reject) {
        var aPromise;
        NativePromise.resolve().then(function() {
          aPromise.then(assert.fail, function(e) {
            called = true;
            assertIsStubError(e);
          });
          waitForIdle().then(fulfill, reject);
        });
        aPromise = promise.rejected(new StubError);
      }).then(function() {
        assert.ok(called);
      })
    });
  });

  it('testTaskThrows', function() {
    return flow.execute(throwStubError).then(assert.fail, assertIsStubError);
  });

  it('testTaskReturnsRejectedPromise', function() {
    return flow.execute(function() {
      return promise.rejected(new StubError)
    }).then(assert.fail, assertIsStubError);
  });

  it('testTaskHasUnhandledRejection', function() {
    return flow.execute(function() {
      promise.rejected(new StubError)
    }).then(assert.fail, assertIsStubError);
  });

  it('testTaskfails_returnedPromiseIsUnhandled', function() {
    flow.execute(throwStubError);
    return waitForAbort().then(assertIsStubError);
  });

  it('testTaskHasUnhandledRejection_cancelsRemainingSubTasks', function() {
    var seen = [];
    flow.execute(function() {
      promise.rejected(new StubError);

      flow.execute(() => seen.push('a'))
          .then(() => seen.push('b'), (e) => seen.push(e));
      flow.execute(() => seen.push('c'))
          .then(() => seen.push('b'), (e) => seen.push(e));
    });

    return waitForAbort()
        .then(assertIsStubError)
        .then(() => assert.deepEqual([], seen));
  });

  describe('nested task failures', function() {
    it('returns up to paren', function() {
      return flow.execute(function() {
        return flow.execute(function() {
          return flow.execute(throwStubError);
        });
      }).then(assert.fail, assertIsStubError);
    });

    it('task throws; uncaught error bubbles up', function() {
      flow.execute(function() {
        flow.execute(function() {
          flow.execute(throwStubError);
        });
      });
      return waitForAbort().then(assertIsStubError);
    });

    it('task throws; uncaught error bubbles up; is caught at root', function() {
      flow.execute(function() {
        flow.execute(function() {
          flow.execute(throwStubError);
        });
      }).then(assert.fail, assertIsStubError);
      return waitForIdle();
    });

    it('unhandled rejection bubbles up', function() {
      flow.execute(function() {
        flow.execute(function() {
          flow.execute(function() {
            promise.rejected(new StubError);
          });
        });
      });
      return waitForAbort().then(assertIsStubError);
    });

    it('unhandled rejection bubbles up; caught at root', function() {
      flow.execute(function() {
        flow.execute(function() {
          promise.rejected(new StubError);
        });
      }).then(assert.fail, assertIsStubError);
      return waitForIdle();
    });

    it('mixtureof hanging and free subtasks', function() {
      flow.execute(function() {
        return flow.execute(function() {
          flow.execute(throwStubError);
        });
      });
      return waitForAbort().then(assertIsStubError);
    });

    it('cancels remaining tasks', function() {
      var seen = [];
      flow.execute(function() {
        flow.execute(() => promise.rejected(new StubError));
        flow.execute(() => seen.push('a'))
            .then(() => seen.push('b'), (e) => seen.push(e));
        flow.execute(() => seen.push('c'))
            .then(() => seen.push('b'), (e) => seen.push(e));
      });

      return waitForAbort()
          .then(assertIsStubError)
          .then(() => assert.deepEqual([], seen));
    });
  });

  it('testTaskReturnsPromiseLikeObjectThatInvokesErrback', function() {
    return flow.execute(function() {
      return {
        'then': function(_, errback) {
          errback('abc123');
        }
      };
    }).then(assert.fail, function(value) {
      assert.equal('abc123', value);
    });
  });

  describe('ControlFlow#wait();', function() {
    describe('condition throws;', function() {
      it('failure is caught', function() {
        return flow.wait(throwStubError, 50).then(assert.fail, assertIsStubError);
      });

      it('failure is not caught', function() {
        flow.wait(throwStubError, 50);
        return waitForAbort().then(assertIsStubError);
      });
    });

    describe('condition returns promise', function() {
      it('failure is caught', function() {
        return flow.wait(function() {
          return promise.rejected(new StubError);
        }, 50).then(assert.fail, assertIsStubError);
      });

      it('failure is not caught', function() {
        flow.wait(function() {
          return promise.rejected(new StubError);
        }, 50);
        return waitForAbort().then(assertIsStubError);
      });
    });

    describe('condition has unhandled promise rejection', function() {
      it('failure is caught', function() {
        return flow.wait(function() {
          promise.rejected(new StubError);
        }, 50).then(assert.fail, assertIsStubError);
      });

      it('failure is not caught', function() {
        flow.wait(function() {
          promise.rejected(new StubError);
        }, 50);
        return waitForAbort().then(assertIsStubError);
      });
    });

    describe('condition has subtask failure', function() {
      it('failure is caught', function() {
        return flow.wait(function() {
          flow.execute(function() {
            flow.execute(throwStubError);
          });
        }, 50).then(assert.fail, assertIsStubError);
      });

      it('failure is not caught', function() {
        flow.wait(function() {
          flow.execute(function() {
            flow.execute(throwStubError);
          });
        }, 50);
        return waitForAbort().then(assertIsStubError);
      });
    });
  });

  describe('errback throws a new error', function() {
    it('start with normal promise', function() {
      var error = Error('an error');
      return promise.rejected(error).
          thenCatch(function(e) {
            assert.equal(e, error);
            throw new StubError;
          }).
          thenCatch(assertIsStubError);
    });

    it('start with task result', function() {
      var error = Error('an error');
      return flow.execute(function() {
        throw error;
      }).
      thenCatch(function(e) {
        assert.equal(e, error);
        throw new StubError;
      }).
      thenCatch(assertIsStubError);
    });

    it('start with normal promise; uncaught error', function() {
      var error = Error('an error');
      promise.rejected(error).
          thenCatch(function(e) {
            assert.equal(e, error);
            throw new StubError;
          });
      return waitForAbort().then(assertIsStubError);
    });

    it('start with task result; uncaught error', function() {
      var error = Error('an error');
      flow.execute(function() {
        throw error;
      }).
      thenCatch(function(e) {
        assert.equal(e, error);
        throw new StubError;
      });
      return waitForAbort().then(assertIsStubError);
    });
  });

  it('thrownPromiseCausesCallbackRejection', function() {
    let p = promise.fulfilled(1234);
    return promise.fulfilled().then(function() {
      throw p;
    }).then(assert.fail, function(value) {
      assert.strictEqual(p, value);
    });
  });

  describe('task throws promise', function() {
    it('promise was fulfilled', function() {
      var toThrow = promise.fulfilled(1234);
      flow.execute(function() {
        throw toThrow;
      }).then(assert.fail, function(value) {
        assert.equal(toThrow, value);
        return toThrow;
      }).then(function(value) {
        assert.equal(1234, value);
      });
      return waitForIdle();
    });

    it('promise was rejected', function() {
      var toThrow = promise.rejected(new StubError);
      toThrow.thenCatch(function() {});  // For tearDown.
      flow.execute(function() {
        throw toThrow;
      }).then(assert.fail, function(e) {
        assert.equal(toThrow, e);
        return e;
      }).then(assert.fail, assertIsStubError);
      return waitForIdle();
    });
  });

  it('testFailsTaskIfThereIsAnUnhandledErrorWhileWaitingOnTaskResult', function() {
    var d = promise.defer();
    flow.execute(function() {
      promise.rejected(new StubError);
      return d.promise;
    }).then(assert.fail, assertIsStubError);

    return waitForIdle().then(function() {
      return d.promise;
    }).then(assert.fail, function(e) {
      assert.equal('CancellationError: StubError', e.toString());
    });
  });

  it('testFailsParentTaskIfAsyncScheduledTaskFails', function() {
    var d = promise.defer();
    flow.execute(function() {
      flow.execute(throwStubError);
      return d.promise;
    }).then(assert.fail, assertIsStubError);

    return waitForIdle().then(function() {
      return d.promise;
    }).then(assert.fail, function(e) {
      assert.equal('CancellationError: StubError', e.toString());
    });
  });

  describe('long stack traces', function() {
    afterEach(() => promise.LONG_STACK_TRACES = false);

    it('always includes task stacks in failures', function() {
      promise.LONG_STACK_TRACES = false;
      flow.execute(function() {
        flow.execute(function() {
          flow.execute(throwStubError, 'throw error');
        }, 'two');
      }, 'three').
      then(assert.fail, function(e) {
        assertIsStubError(e);
        if (typeof e.stack !== 'string') {
          return;
        }
        var messages = e.stack.split(/\n/).filter(function(line, index) {
          return /^From: /.test(line);
        });
        assert.deepEqual([
          'From: Task: throw error',
          'From: Task: two',
          'From: Task: three'
        ], messages);
      });
      return waitForIdle();
    });

    it('does not include completed tasks', function () {
      flow.execute(function() {}, 'succeeds');
      flow.execute(throwStubError, 'kaboom').then(assert.fail, function(e) {
        assertIsStubError(e);
        if (typeof e.stack !== 'string') {
          return;
        }
        var messages = e.stack.split(/\n/).filter(function(line, index) {
          return /^From: /.test(line);
        });
        assert.deepEqual(['From: Task: kaboom'], messages);
      });
      return waitForIdle();
    });

    it('does not include promise chain when disabled', function() {
      promise.LONG_STACK_TRACES = false;
      flow.execute(function() {
        flow.execute(function() {
          return promise.fulfilled().
              then(function() {}).
              then(function() {}).
              then(throwStubError);
        }, 'eventually assert.fails');
      }, 'start').
      then(assert.fail, function(e) {
        assertIsStubError(e);
        if (typeof e.stack !== 'string') {
          return;
        }
        var messages = e.stack.split(/\n/).filter(function(line, index) {
          return /^From: /.test(line);
        });
        assert.deepEqual([
          'From: Task: eventually assert.fails',
          'From: Task: start'
        ], messages);
      });
      return waitForIdle();
    });

    it('includes promise chain when enabled', function() {
      promise.LONG_STACK_TRACES = true;
      flow.execute(function() {
        flow.execute(function() {
          return promise.fulfilled().
              then(function() {}).
              then(function() {}).
              then(throwStubError);
        }, 'eventually assert.fails');
      }, 'start').
      then(assert.fail, function(e) {
        assertIsStubError(e);
        if (typeof e.stack !== 'string') {
          return;
        }
        var messages = e.stack.split(/\n/).filter(function(line, index) {
          return /^From: /.test(line);
        });
        assert.deepEqual([
          'From: Promise: then',
          'From: Task: eventually assert.fails',
          'From: Task: start'
        ], messages);
      });
      return waitForIdle();
    });
  });

  it('testFrameCancelsRemainingTasks_onUnhandledTaskFailure', function() {
    var run = false;
    return flow.execute(function() {
      flow.execute(throwStubError);
      flow.execute(function() { run = true; });
    }).then(assert.fail, function(e) {
      assertIsStubError(e);
      assert.ok(!run);
    });
  });

  it('testFrameCancelsRemainingTasks_onUnhandledPromiseRejection', function() {
    var run = false;
    return flow.execute(function() {
      promise.rejected(new StubError);
      flow.execute(function() { run = true; });
    }).then(assert.fail, function(e) {
      assertIsStubError(e);
      assert.ok(!run);
    });
  });

  it('testRegisteredTaskCallbacksAreDroppedWhenTaskIsCancelled_return', function() {
    var seen = [];
    return flow.execute(function() {
      flow.execute(throwStubError);

      flow.execute(function() {
        seen.push(1);
      }).then(function() {
        seen.push(2);
      }, function() {
        seen.push(3);
      });
    }).then(assert.fail, function(e) {
      assertIsStubError(e);
      assert.deepEqual([], seen);
    });
  });

  it('testRegisteredTaskCallbacksAreDroppedWhenTaskIsCancelled_withReturn', function() {
    var seen = [];
    return flow.execute(function() {
      flow.execute(throwStubError);

      return flow.execute(function() {
        seen.push(1);
      }).then(function() {
        seen.push(2);
      }, function() {
        seen.push(3);
      });
    }).then(assert.fail, function(e) {
      assertIsStubError(e);
      assert.deepEqual([], seen);
    });
  });

  it('testTasksWithinACallbackAreDroppedIfContainingTaskIsAborted', function() {
    var seen = [];
    return flow.execute(function() {
      flow.execute(throwStubError);

      // None of the callbacks on this promise should execute because the
      // task assert.failure above is never handled, causing the containing task to
      // abort.
      promise.fulfilled().then(function() {
        seen.push(1);
        return flow.execute(function() {
          seen.push(2);
        });
      }).thenFinally(function() {
        seen.push(3);
      });

    }).then(assert.fail, function(e) {
      assertIsStubError(e);
      assert.deepEqual([], seen);
    });
  });

  it('testTaskIsCancelledAfterWaitTimeout', function() {
    var seen = [];
    return flow.execute(function() {
      flow.wait(function() {
        return promise.delayed(50);
      }, 5);

      return flow.execute(function() {
        seen.push(1);
      }).then(function() {
        seen.push(2);
      }, function() {
        seen.push(3);
      });
    }).then(assert.fail, function() {
      assert.deepEqual([], seen);
    });
  });

  describe('task callbacks get cancellation error if registered after task was cancelled', function() {
    it('(a)', function() {
      var task;
      flow.execute(function() {
        flow.execute(throwStubError);
        task = flow.execute(function() {});
      }).then(assert.fail, assertIsStubError);
      return waitForIdle().then(function() {
        return task.then(assert.fail, function(e) {
          assert.ok(e instanceof promise.CancellationError);
        });
      });
    });

    it('(b)', function() {
      var seen = [];

      var task;
      flow.execute(function() {
        flow.execute(throwStubError);
        task = flow.execute(function() {});

        task.then(() => seen.push(1))
            .then(() => seen.push(2));
        task.then(() => seen.push(3))
            .then(() => seen.push(4));

      }).then(assert.fail, assertIsStubError);

      return waitForIdle().then(function() {
        return task.then(assert.fail, function(e) {
          seen.push(5);
          assert.ok(e instanceof promise.CancellationError);
        });
      }).then(() => assert.deepEqual([5], seen));
    });
  });

  it('unhandledRejectionInParallelTaskQueue', function() {
    var seen = [];
    function schedule(name) {
      return flow.execute(() => seen.push(name), name);
    }

    flow.async(function() {
      schedule('a.1');
      flow.execute(throwStubError, 'a.2 (throws)');
    });

    var b3;
    flow.async(function() {
      schedule('b.1');
      schedule('b.2');
      b3 = schedule('b.3');
    });

    var c3;
    flow.async(function() {
      schedule('c.1');
      schedule('c.2');
      c3 = schedule('c.3');
    });

    function assertWasCancelled(p) {
      return p.then(assert.fail, function(e) {
        assert.ok(e instanceof promise.CancellationError);
      });
    }

    return waitForAbort()
      .then(function() {
        assert.deepEqual(['a.1', 'b.1', 'c.1', 'b.2', 'c.2'], seen);
        assert.ok(!b3.isPending());
        assert.ok(!c3.isPending());
      })
      .then(() => assertWasCancelled(b3))
      .then(() => assertWasCancelled(c3));
  });

  it('errorsInAsyncFunctionsAreReportedAsUnhandledRejection', function() {
    flow.removeAllListeners();  // For tearDown.

    var task;
    return new Promise(function(fulfill) {
      flow.once('uncaughtException', fulfill);
      flow.async(function() {
        task = flow.execute(function() {});
        throw Error('boom');
      });
    }).then(function(error) {
      assert.ok(error instanceof promise.CancellationError);
      assert.ok(!task.isPending());
      return task.thenCatch(function(error) {
        assert.ok(error instanceof promise.CancellationError);
      });
    });
  });

  describe('does not wait for values thrown from callbacks to be resolved', function() {
    it('(a)', function() {
      var p1 = promise.fulfilled();
      var reason = promise.fulfilled('should not see me');
      return p1.then(function() {
        throw reason;
      }).then(assert.fail, function(e) {
        assert.equal(reason, e);
      });
    });

    it('(b)', function() {
      var p1 = promise.fulfilled();
      var reason = promise.rejected('should not see me');
      reason.thenCatch(function() {});  // For tearDown.
      return p1.then(function() {
        throw reason;
      }).then(assert.fail, function(e) {
        assert.equal(reason, e);
      });
    });

    it('(c)', function() {
      var p1 = promise.fulfilled();
      var reason = promise.defer();
      setTimeout(() => reason.fulfill('should not see me'), 100);
      return p1.then(function() {
        throw reason.promise;
      }).then(assert.fail, function(e) {
        assert.equal(reason.promise, e);
      });
    });

    it('(d)', function() {
      var p1 = promise.fulfilled();
      var reason = {then: function() {}};  // A thenable like object.
      return p1.then(function() {
        throw reason;
      }).then(assert.fail, function(e) {
        assert.equal(reason, e);
      });
    });
  });
});
