Repository: serverless/serverless (JavaScript) — Bug Fix

Issue:
SLS Invoke Local Exiting Too Soon
<!--
1. If you have a question and not a bug report please ask first at http://forum.serverless.com
2. Please check if an issue already exists. This bug may have already been documented
3. Check out and follow our Guidelines: https://github.com/serverless/serverless/blob/master/CONTRIBUTING.md
4. Fill out the whole template so we have a good overview on the issue
5. Do not remove any section of the template. If something is not applicable leave it empty but leave it in the Issue
6. Please follow the template, otherwise we'll have to ask you to update it
-->

# This is a Bug Report

## Description

* What went wrong? sls invoke local with a nodejs handler that uses callbacks exiting too soon
* What did you expect should have happened? sls invoke local should wait for handler callback to be called before exiting
* What was the config you used?
* What stacktrace or error message from your provider did you see?

Similar or dependent issues:
* #5349

## Additional Data

* ***Serverless Framework Version you're using***:1.33.0+
* ***Operating System***:Mac OS
* ***Stack Trace***:
* ***Provider Error messages***:


Seems that #5349 has broken sls invoke local for nodejs when using callbacks.

For example - when running this handler function with sls invoke local, the cli exits before the callback is run and output is not displayed in the console:

```
module.exports.hello = (event, context, callback) => {
    setTimeout(function(err, obj) {
        callback({
            statusCode: 200,
            body: JSON.stringify({
                message: 'Go Serverless v1.0! Your function executed successfully!',
                input: event
            })
        })
    }, 1000);
};
```

When run with serverless <= v1.32.0 it works as expected.


Discussion:

Resolving patch:
diff --git a/lib/plugins/aws/invokeLocal/fixture/asyncHandlerWithSuccess.js b/lib/plugins/aws/invokeLocal/fixture/asyncHandlerWithSuccess.js
index 188e7dd99e4..cc77b245772 100644
--- a/lib/plugins/aws/invokeLocal/fixture/asyncHandlerWithSuccess.js
+++ b/lib/plugins/aws/invokeLocal/fixture/asyncHandlerWithSuccess.js
@@ -22,6 +22,10 @@ module.exports.withMessageByCallback = (event, context, callback) => {
   return Promise.resolve();
 };
 
+module.exports.withMessageAndDelayByCallback = (event, context, callback) => {
+  setTimeout(() => callback(null, 'Succeed'), 1);
+};
+
 module.exports.withMessageByLambdaProxy = () =>
   Promise.resolve({
     statusCode: 200,
diff --git a/lib/plugins/aws/invokeLocal/fixture/handlerWithSuccess.js b/lib/plugins/aws/invokeLocal/fixture/handlerWithSuccess.js
index 256621b483b..6fbc7750f56 100644
--- a/lib/plugins/aws/invokeLocal/fixture/handlerWithSuccess.js
+++ b/lib/plugins/aws/invokeLocal/fixture/handlerWithSuccess.js
@@ -4,6 +4,8 @@ module.exports.withErrorByDone = (event, context) => {
   context.done(new Error('failed'));
 };
 
+module.exports.withMessageByReturn = () => 'Succeed';
+
 module.exports.withMessageByDone = (event, context) => {
   context.done(null, 'Succeed');
 };
diff --git a/lib/plugins/aws/invokeLocal/index.js b/lib/plugins/aws/invokeLocal/index.js
index 1c0bd9e5d00..20a63c7ed51 100644
--- a/lib/plugins/aws/invokeLocal/index.js
+++ b/lib/plugins/aws/invokeLocal/index.js
@@ -312,60 +312,63 @@ class AwsInvokeLocal {
       this.serverless.cli.consoleLog(JSON.stringify(result, null, 4));
     }
 
-    const callback = (err, result) => {
-      if (!hasResponded) {
-        hasResponded = true;
-        if (err) {
-          handleError.call(this, err);
-        } else if (result) {
-          handleResult.call(this, result);
+    return new Promise((resolve) => {
+      const callback = (err, result) => {
+        if (!hasResponded) {
+          hasResponded = true;
+          if (err) {
+            handleError.call(this, err);
+          } else if (result) {
+            handleResult.call(this, result);
+          }
         }
+        resolve();
+      };
+
+      const startTime = new Date();
+      const timeout = Number(this.options.functionObj.timeout)
+        || Number(this.serverless.service.provider.timeout)
+        || 6;
+      let context = {
+        awsRequestId: 'id',
+        invokeid: 'id',
+        logGroupName: this.provider.naming.getLogGroupName(this.options.functionObj.name),
+        logStreamName: '2015/09/22/[HEAD]13370a84ca4ed8b77c427af260',
+        functionVersion: 'HEAD',
+        isDefaultFunctionVersion: true,
+
+        functionName: this.options.functionObj.name,
+        memoryLimitInMB: '1024',
+
+        succeed(result) {
+          return callback(null, result);
+        },
+        fail(error) {
+          return callback(error);
+        },
+        done(error, result) {
+          return callback(error, result);
+        },
+        getRemainingTimeInMillis() {
+          return Math.max((timeout * 1000) - ((new Date()).valueOf() - startTime.valueOf()), 0);
+        },
+      };
+
+      if (customContext) {
+        context = customContext;
       }
-    };
-
-    const startTime = new Date();
-    const timeout = Number(this.options.functionObj.timeout)
-      || Number(this.serverless.service.provider.timeout)
-      || 6;
-    let context = {
-      awsRequestId: 'id',
-      invokeid: 'id',
-      logGroupName: this.provider.naming.getLogGroupName(this.options.functionObj.name),
-      logStreamName: '2015/09/22/[HEAD]13370a84ca4ed8b77c427af260',
-      functionVersion: 'HEAD',
-      isDefaultFunctionVersion: true,
-
-      functionName: this.options.functionObj.name,
-      memoryLimitInMB: '1024',
-
-      succeed(result) {
-        return callback(null, result);
-      },
-      fail(error) {
-        return callback(error);
-      },
-      done(error, result) {
-        return callback(error, result);
-      },
-      getRemainingTimeInMillis() {
-        return Math.max((timeout * 1000) - ((new Date()).valueOf() - startTime.valueOf()), 0);
-      },
-    };
-
-    if (customContext) {
-      context = customContext;
-    }
 
-    const maybeThennable = lambda(event, context, callback);
-    if (!_.isUndefined(maybeThennable) && _.isFunction(maybeThennable.then)) {
-      return maybeThennable
-        .then(
-          callback.bind(this, null),
-          callback.bind(this)
-        );
-    }
+      const maybeThennable = lambda(event, context, callback);
+      if (!_.isUndefined(maybeThennable)) {
+        return Promise.resolve(maybeThennable)
+          .then(
+            callback.bind(this, null),
+            callback.bind(this)
+          );
+      }
 
-    return maybeThennable;
+      return maybeThennable;
+    });
   }
 }
 


Test patch:
diff --git a/lib/plugins/aws/invokeLocal/index.test.js b/lib/plugins/aws/invokeLocal/index.test.js
index 74c2cdda5d6..40cacc1648a 100644
--- a/lib/plugins/aws/invokeLocal/index.test.js
+++ b/lib/plugins/aws/invokeLocal/index.test.js
@@ -433,6 +433,15 @@ describe('AwsInvokeLocal', () => {
       serverless.cli.consoleLog.restore();
     });
 
+    describe('with sync return value', () => {
+      it('should succeed if succeed', () => {
+        awsInvokeLocal.serverless.config.servicePath = __dirname;
+
+        return awsInvokeLocal.invokeLocalNodeJs('fixture/handlerWithSuccess', 'withMessageByReturn')
+          .then(() => expect(serverless.cli.consoleLog.lastCall.args[0]).to.contain('"Succeed"'));
+      });
+    });
+
     describe('with done method', () => {
       it('should exit with error exit code', () => {
         awsInvokeLocal.serverless.config.servicePath = __dirname;
@@ -625,6 +634,24 @@ describe('AwsInvokeLocal', () => {
       });
     });
 
+    describe("by callback method even if callback isn't called syncronously", () => {
+      it('should succeed once if succeed if by callback', () => {
+        awsInvokeLocal.serverless.config.servicePath = __dirname;
+
+        return awsInvokeLocal.invokeLocalNodeJs(
+          'fixture/asyncHandlerWithSuccess',
+          'withMessageAndDelayByCallback'
+        )
+          .then(() => {
+            expect(serverless.cli.consoleLog.lastCall.args[0]).to.contain('"Succeed"');
+            const calls = serverless.cli.consoleLog.getCalls().reduce((acc, call) => (
+              _.includes(call.args[0], 'Succeed') ? [call].concat(acc) : acc
+            ), []);
+            expect(calls.length).to.equal(1);
+          });
+      });
+    });
+
     describe('with Lambda Proxy with application/json response', () => {
       it('should succeed if succeed', () => {
         awsInvokeLocal.serverless.config.servicePath = __dirname;