Repository: serverless/serverless-azure-functions
Pull request #233 (2019-08-08 18:29:22)

feat: Handle SIGINT and cleanup offline files unless specified to skip
Cleans up offline files when user interrupts offline process. Allows user to skip cleanup with CLI option.


Patch:
diff --git a/README.md b/README.md
index 7bb28e5..cd669a0 100644
--- a/README.md
+++ b/README.md
@@ -200,6 +200,9 @@ The getting started walkthrough illustrates the interactive login experience, wh
     $env:azureServicePrincipalPassword='<password>'
     ```
 
+### Example Usage
+- [Configuring API Management](docs/examples/apim.md) that sits in front of function app
+
 ### Contributing
 
 Please create issues in this repo for any problems or questions you find. Before sending a PR for any major changes, please create an issue to discuss.
diff --git a/docs/examples/apim.md b/docs/examples/apim.md
new file mode 100644
index 0000000..7faf378
--- /dev/null
+++ b/docs/examples/apim.md
@@ -0,0 +1,84 @@
+# API Management
+
+[API Management](https://azure.microsoft.com/en-us/services/api-management/) is an Azure Service for publishing, managing, securing and monitoring APIs. It can be deployed along with your Serverless function app by specifying its configuration in `serverless.yml`. Here is a basic example of how to configure API Mangement:
+
+## Simple Handler
+
+```yaml
+service: greeter
+
+provider:
+  prefix: greeter
+  name: azure
+  # Default to West US, allow for command line arg --region to override
+  region: ${opt:region, 'westus'}
+  # Default to dev, allow for command line arg -- stage to override
+  stage: ${opt:stage, 'dev'}
+  # Azure subscription ID for deployment
+  subscriptionId: 00000000-0000-0000-0000-000000000000
+
+  # Start of your API Management configuration
+  apim:
+    # API specifications
+    api:
+      # Name of the API
+      name: v1
+      subscriptionRequired: false
+      # Display name
+      displayName: v1
+      # Description of API
+      description: V1 sample app APIs
+      # HTTP protocols allowed
+      protocols:
+        - https
+      # Base path of API calls
+      path: v1
+      # Tags for ARM resource
+      tags:
+        - tag1
+        - tag2
+      # No authorization
+      authorization: none
+    # CORS Settings for APIM
+    cors:
+      allowCredentials: false
+      allowedOrigins:
+        - "*"
+      allowedMethods:
+        - GET
+        - POST
+        - PUT
+        - DELETE
+        - PATCH
+      allowedHeaders:
+        - "*"
+      exposeHeaders:
+        - "*"
+
+plugins:
+  - serverless-azure-functions
+
+functions:
+  hello:
+    handler: src/handlers/hello.handler
+
+    # API Management configuration for `hello` handler
+    apim:
+      operations:
+        # GET operation for `hello` handler
+        - method: get
+          # URL path for accessing handler
+          urlTemplate: /hello
+          # Display name inside Azure Portal
+          displayName: Hello
+    events:
+      - http: true
+        x-azure-settings:
+          methods:
+            - GET
+          authLevel : function
+      - http: true
+        x-azure-settings:
+          direction: out
+          name: res
+```
diff --git a/package-lock.json b/package-lock.json
index e64a9f6..fecdc6a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2852,11 +2852,6 @@
       "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
       "dev": true
     },
-    "charenc": {
-      "version": "0.0.2",
-      "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
-      "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc="
-    },
     "chokidar": {
       "version": "2.1.6",
       "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.6.tgz",
@@ -3306,11 +3301,6 @@
         "which": "^1.2.9"
       }
     },
-    "crypt": {
-      "version": "0.0.2",
-      "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
-      "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs="
-    },
     "crypto-browserify": {
       "version": "3.12.0",
       "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz",
@@ -7573,16 +7563,6 @@
         "object-visit": "^1.0.0"
       }
     },
-    "md5": {
-      "version": "2.2.1",
-      "resolved": "https://registry.npmjs.org/md5/-/md5-2.2.1.tgz",
-      "integrity": "sha1-U6s41f48iJG6RlMp6iP6wFQBJvk=",
-      "requires": {
-        "charenc": "~0.0.1",
-        "crypt": "~0.0.1",
-        "is-buffer": "~1.1.1"
-      }
-    },
     "md5.js": {
       "version": "1.3.5",
       "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
diff --git a/package.json b/package.json
index 6018b19..8305eba 100644
--- a/package.json
+++ b/package.json
@@ -50,7 +50,6 @@
     "js-yaml": "^3.13.1",
     "jsonpath": "^1.0.1",
     "lodash": "^4.16.6",
-    "md5": "^2.2.1",
     "open": "^6.3.0",
     "request": "^2.81.0",
     "rimraf": "^2.6.3",
diff --git a/src/armTemplates/resources/apim.ts b/src/armTemplates/resources/apim.ts
index 5003361..2bd6652 100644
--- a/src/armTemplates/resources/apim.ts
+++ b/src/armTemplates/resources/apim.ts
@@ -5,11 +5,7 @@ import { AzureNamingService } from "../../services/namingService";
 
 export class ApimResource implements ArmResourceTemplateGenerator {
   public static getResourceName(config: ServerlessAzureConfig) {
-    return AzureNamingService.getResourceName(
-      config,
-      config.provider.apim,
-      "apim"
-    );
+    return AzureNamingService.getResourceName(config, config.provider.apim, "apim");
   }
 
   public getTemplate(): ArmResourceTemplate {
diff --git a/src/armTemplates/resources/appInsights.ts b/src/armTemplates/resources/appInsights.ts
index 9426a0b..ab72cc9 100644
--- a/src/armTemplates/resources/appInsights.ts
+++ b/src/armTemplates/resources/appInsights.ts
@@ -4,11 +4,7 @@ import { AzureNamingService } from "../../services/namingService";
 
 export class AppInsightsResource implements ArmResourceTemplateGenerator {
   public static getResourceName(config: ServerlessAzureConfig) {
-    return AzureNamingService.getResourceName(
-      config,
-      config.provider.appInsights,
-      "appinsights"
-    );
+    return AzureNamingService.getResourceName(config, config.provider.appInsights, "appinsights");
   }
 
   public getTemplate(): ArmResourceTemplate {
diff --git a/src/armTemplates/resources/appServicePlan.ts b/src/armTemplates/resources/appServicePlan.ts
index 4485cb0..50d99e6 100644
--- a/src/armTemplates/resources/appServicePlan.ts
+++ b/src/armTemplates/resources/appServicePlan.ts
@@ -4,11 +4,7 @@ import { AzureNamingService } from "../../services/namingService";
 
 export class AppServicePlanResource implements ArmResourceTemplateGenerator {
   public static getResourceName(config: ServerlessAzureConfig) {
-    return AzureNamingService.getResourceName(
-      config,
-      config.provider.appInsights,
-      "asp"
-    );
+    return AzureNamingService.getResourceName(config, config.provider.appInsights, "asp");
   }
 
   public getTemplate(): ArmResourceTemplate {
diff --git a/src/armTemplates/resources/functionApp.ts b/src/armTemplates/resources/functionApp.ts
index 1af37a3..0263490 100644
--- a/src/armTemplates/resources/functionApp.ts
+++ b/src/armTemplates/resources/functionApp.ts
@@ -5,11 +5,8 @@ import { AzureNamingService } from "../../services/namingService";
 export class FunctionAppResource implements ArmResourceTemplateGenerator {
   public static getResourceName(config: ServerlessAzureConfig) {
     const safeServiceName = config.service.replace(/\s/g, "-");
-    return AzureNamingService.getResourceName(
-      config,
-      config.provider.appInsights,
-      safeServiceName
-    );
+
+    return AzureNamingService.getResourceName(config, config.provider.appInsights, safeServiceName);
   }
 
   public getTemplate(): ArmResourceTemplate {
diff --git a/src/armTemplates/resources/hostingEnvironment.ts b/src/armTemplates/resources/hostingEnvironment.ts
index 7c8332b..0cf8671 100644
--- a/src/armTemplates/resources/hostingEnvironment.ts
+++ b/src/armTemplates/resources/hostingEnvironment.ts
@@ -4,11 +4,7 @@ import { AzureNamingService } from "../../services/namingService";
 
 export class HostingEnvironmentResource implements ArmResourceTemplateGenerator {
   public static getResourceName(config: ServerlessAzureConfig) {
-    return AzureNamingService.getResourceName(
-      config,
-      config.provider.hostingEnvironment,
-      "ase"
-    );
+    return AzureNamingService.getResourceName(config, config.provider.hostingEnvironment, "ase");
   }
 
   public getTemplate(): ArmResourceTemplate {
diff --git a/src/armTemplates/resources/storageAccount.ts b/src/armTemplates/resources/storageAccount.ts
index 69dc7cd..86832ae 100644
--- a/src/armTemplates/resources/storageAccount.ts
+++ b/src/armTemplates/resources/storageAccount.ts
@@ -5,11 +5,7 @@ import configConstants from "../../config";
 
 export class StorageAccountResource implements ArmResourceTemplateGenerator {
   public static getResourceName(config: ServerlessAzureConfig) {
-    return AzureNamingService.getSafeResourceName(
-      config,
-      configConstants.naming.maxLength.storageAccount,
-      config.provider.storageAccount
-    );
+    return AzureNamingService.getSafeResourceName(config, configConstants.naming.maxLength.storageAccount, config.provider.storageAccount);
   }
 
   public getTemplate(): ArmResourceTemplate {
diff --git a/src/plugins/offline/azureOfflinePlugin.ts b/src/plugins/offline/azureOfflinePlugin.ts
index 9fd7cb0..315de16 100644
--- a/src/plugins/offline/azureOfflinePlugin.ts
+++ b/src/plugins/offline/azureOfflinePlugin.ts
@@ -30,7 +30,13 @@ export class AzureOfflinePlugin extends AzureBasePlugin {
             usage: "Start Azure Function app - assumes offline build has already occurred",
             lifecycleEvents: [
               "start"
-            ]
+            ],
+            options: {
+              nocleanup: {
+                usage: "Do not clean up offline files after finishing process",
+                shortcut: "n",
+              }
+            }
           },
           build: {
             usage: "Build necessary files for running Azure Function App offline",
@@ -44,6 +50,12 @@ export class AzureOfflinePlugin extends AzureBasePlugin {
               "cleanup"
             ]
           }
+        },
+        options: {
+          nocleanup: {
+            usage: "Do not clean up offline files after finishing process",
+            shortcut: "n",
+          }
         }
       }
     }
diff --git a/src/services/namingService.ts b/src/services/namingService.ts
index 10c3755..8d7df13 100644
--- a/src/services/namingService.ts
+++ b/src/services/namingService.ts
@@ -1,6 +1,5 @@
 import { ServerlessAzureConfig, ResourceConfig } from "../models/serverless"
 import { Guard } from "../shared/guard"
-import md5 from "md5";
 import configConstants from "../config";
 
 export class AzureNamingService {
@@ -11,23 +10,26 @@ export class AzureNamingService {
    * {prefix}-{shortRegionName}-{shortStageName}(optionally: -{suffix})
    *
    * @param config Serverless Azure Config for service (serverless.service)
-   * @param resourceConfig
-   * @param suffix
+   * @param resourceConfig The serverless resource configuration
+   * @param suffix Optional suffix to append on the end of the generated name
    */
   public static getResourceName(config: ServerlessAzureConfig, resourceConfig?: ResourceConfig, suffix?: string) {
     if (resourceConfig && resourceConfig.name) {
       return resourceConfig.name;
     }
+
     const { prefix, region, stage } = config.provider
     let name = [
       prefix,
       this.createShortAzureRegionName(region),
       this.createShortStageName(stage),
     ].join("-");
+
     if (suffix) {
       name += `-${suffix}`;
     }
-    return name.toLocaleLowerCase();
+
+    return name.toLowerCase();
   }
 
   /**
@@ -38,43 +40,53 @@ export class AzureNamingService {
    *
    * @param config Serverless Azure Config for service (serverless.service)
    * @param maxLength Maximum length of name for resource
-   * @param resourceConfig Configuration for resource from serverless configuration
+   * @param resourceConfig The serverless resource configuration
+   * @param suffix Optional suffix to append on the end of the generated name
    * @param forbidden Regex for characters to remove from name. Defaults to non-alpha-numerics
    * @param replaceWith String to replace forbidden characters. Defaults to empty string
    */
-  public static getSafeResourceName(config: ServerlessAzureConfig, maxLength: number, resourceConfig?: ResourceConfig, forbidden = /\W+/g, replaceWith = "") {
+  public static getSafeResourceName(config: ServerlessAzureConfig, maxLength: number, resourceConfig?: ResourceConfig, suffix: string = "", forbidden: RegExp = /\W+/g, replaceWith: string = "") {
     if (resourceConfig && resourceConfig.name) {
       const { name } = resourceConfig;
+
       if (name.length > maxLength) {
         throw new Error(`Name '${name}' invalid. Should be shorter than ${maxLength} characters`);
       }
+
       return name.replace(forbidden, replaceWith);
     }
 
     const { prefix, region, stage } = config.provider;
 
-    const nameHash = md5(config.service);
-
     let safePrefix = prefix.replace(forbidden, replaceWith);
     const safeRegion = this.createShortAzureRegionName(region);
     let safeStage = this.createShortStageName(stage);
-    let safeNameHash = nameHash.substr(0, 6);
+    let safeSuffix = suffix.replace(forbidden, replaceWith);
 
-    const remaining = maxLength - (safePrefix.length + safeRegion.length + safeStage.length + safeNameHash.length);
+    const remaining = maxLength - (safePrefix.length + safeRegion.length + safeStage.length + safeSuffix.length);
 
     // Dynamically adjust the substring based on space needed
     if (remaining < 0) {
-      const partLength = Math.floor(Math.abs(remaining) / 3);
+      let partLength = Math.floor(Math.abs(remaining) / 4);
+      if (partLength < 3) {
+        partLength = 3;
+      }
+
       safePrefix = safePrefix.substr(0, partLength);
       safeStage = safeStage.substr(0, partLength);
-      safeNameHash = safeNameHash.substr(0, partLength);
+      safeSuffix = safeSuffix.substr(0, partLength);
     }
 
-    return [safePrefix, safeRegion, safeStage, safeNameHash]
+    return [safePrefix, safeRegion, safeStage, safeSuffix]
       .join("")
-      .toLocaleLowerCase();
+      .toLowerCase();
   }
 
+  /**
+   * Creates a deployment name from the serverless configuration
+   * @param config The serverless azure config
+   * @param timestamp The timestamp of the deployment
+   */
   public static getDeploymentName(config: ServerlessAzureConfig, timestamp?: string) {
     let maxLength = configConstants.naming.maxLength.deploymentName;
     const suffix = configConstants.naming.suffix.deployment;
@@ -82,11 +94,13 @@ export class AzureNamingService {
     const { deploymentName } = config.provider
 
     if (timestamp) {
-      maxLength -= timestamp.length + suffix.length;;
+      maxLength -= timestamp.length + suffix.length;
+
+      const name = (deploymentName)
+        ? deploymentName.substr(0, maxLength)
+        : [AzureNamingService.getSafeResourceName(config, maxLength, null, config.service), suffix].join("-");
 
-      const name = (deploymentName) ? deploymentName.substr(0, maxLength)
-        : [ AzureNamingService.getSafeResourceName(config, maxLength), suffix ].join("-");
-      return [ name, timestamp ].join("-");
+      return [name, timestamp].join("-");
     }
 
     return deploymentName.substr(0, maxLength);
diff --git a/src/services/offlineService.ts b/src/services/offlineService.ts
index 996a1cc..7288c6c 100644
--- a/src/services/offlineService.ts
+++ b/src/services/offlineService.ts
@@ -76,10 +76,25 @@ export class OfflineService extends BaseService {
       ...this.serverless.service.provider["environment"],
     }
     this.log(`Spawning process '${command} ${spawnArgs.join(" ")}'`);
-    return new Promise((resolve, reject) => {
+    return new Promise(async (resolve, reject) => {
       const spawnOptions: SpawnOptions = { env, stdio: "inherit" };
       const childProcess = spawn(command, spawnArgs, spawnOptions);
 
+      process.on("SIGINT", async () => {
+        try {
+          if (this.getOption("nocleanup")) {
+            this.log("Skipping offline file cleanup...");
+          } else {
+            await this.cleanup();
+          }
+        } catch {
+          // Swallowing `scandir` error that gets thrown after
+          // trying to remove the same directory twice
+        } finally {
+          process.exit();
+        }
+      });
+
       childProcess.on("exit", (code) => {
         if (code === 0) {
           resolve();


Test patch:
diff --git a/src/armTemplates/resources/storageAccount.test.ts b/src/armTemplates/resources/storageAccount.test.ts
index b398cda..5d20643 100644
--- a/src/armTemplates/resources/storageAccount.test.ts
+++ b/src/armTemplates/resources/storageAccount.test.ts
@@ -1,4 +1,3 @@
-import md5 from "md5";
 import { ServerlessAzureConfig } from "../../models/serverless";
 import { AzureNamingService } from "../../services/namingService";
 import { StorageAccountResource } from "./storageAccount";
@@ -35,8 +34,7 @@ describe("Storage Account Resource", () => {
         prefix: "my-long-test-prefix-name",
         region: "Australia Southeast",
         stage: "development"
-      },
-      service: "my-long-test-api",
+      }
     };
 
     const result = StorageAccountResource.getResourceName(testConfig);
@@ -145,10 +143,9 @@ describe("Storage Account Resource", () => {
     expect(value).toContain(AzureNamingService.createShortAzureRegionName(config.provider.region));
     expect(value).toContain(createSafeString(config.provider.prefix));
     expect(value).toContain(createSafeString(config.provider.stage));
-    expect(value).toContain(md5(config.service).substr(0, 3));
   }
 
   function createSafeString(value: string) {
-    return value.replace(/\W+/g, "").toLocaleLowerCase().substr(0, 3);
+    return value.replace(/\W+/g, "").toLowerCase().substr(0, 3);
   };
 });
diff --git a/src/services/namingService.test.ts b/src/services/namingService.test.ts
index 33749c4..d58fbc1 100644
--- a/src/services/namingService.test.ts
+++ b/src/services/namingService.test.ts
@@ -1,7 +1,7 @@
 import { AzureNamingService } from "./namingService"
+import { ServerlessAzureConfig } from "../models/serverless";
 
 describe("Naming Service", () => {
-
   it("Creates a short name for an azure region", () => {
     const expected = "ausse";
     const actual = AzureNamingService.createShortAzureRegionName("australiasoutheast");
@@ -86,4 +86,57 @@ describe("Naming Service", () => {
     const actual = AzureNamingService.getNormalizedRegionName(expected);
     expect(actual).toEqual(expected);
   });
+
+  it("deployment name is generated correctly", () => {
+    const config: ServerlessAzureConfig = {
+      functions: [],
+      plugins: [],
+      provider: {
+        prefix: "sls",
+        name: "azure",
+        region: "westus",
+        stage: "dev",
+      },
+      service: "test-api"
+    };
+
+    const timestamp = Date.now();
+    const deploymentName = AzureNamingService.getDeploymentName(config, `t${timestamp}`);
+
+    expect(deploymentName).toEqual(`slswusdevtestapi-DEPLOYMENT-t${timestamp}`);
+    assertValidDeploymentName(config, deploymentName, timestamp);
+  });
+
+  it("deployment name with long suffix or service name generated correctly", () => {
+    const config: ServerlessAzureConfig = {
+      functions: [],
+      plugins: [],
+      provider: {
+        prefix: "sls-long-prefix-name",
+        name: "azure",
+        region: "westus",
+        stage: "multicloud",
+      },
+      service: "extra-long-service-name"
+    };
+
+    const timestamp = Date.now();
+    const deploymentName = AzureNamingService.getDeploymentName(config, `t${timestamp}`);
+
+    expect(deploymentName).toEqual(`slswusmulext-DEPLOYMENT-t${timestamp}`);
+    assertValidDeploymentName(config, deploymentName, timestamp);
+  });
+
+  function assertValidDeploymentName(config: ServerlessAzureConfig, value: string, timestamp: number) {
+    expect(value.length).toBeLessThanOrEqual(64);
+    expect(value).toContain(timestamp);
+    expect(value).toContain(AzureNamingService.createShortAzureRegionName(config.provider.region));
+    expect(value).toContain(createSafeString(config.provider.prefix));
+    expect(value).toContain(createSafeString(config.provider.stage));
+    expect(value).toContain(createSafeString(config.service));
+  }
+
+  function createSafeString(value: string) {
+    return value.replace(/\W+/g, "").toLowerCase().substr(0, 3);
+  };
 });
diff --git a/src/services/offlineService.test.ts b/src/services/offlineService.test.ts
index df392ba..e9b1427 100644
--- a/src/services/offlineService.test.ts
+++ b/src/services/offlineService.test.ts
@@ -9,10 +9,10 @@ import { OfflineService } from "./offlineService";
 describe("Offline Service", () => {
   let mySpawn;
 
-  function createService(sls?: Serverless): OfflineService {
+  function createService(sls?: Serverless, options?: any): OfflineService {
     return new OfflineService(
       sls || MockFactory.createTestServerless(),
-      MockFactory.createTestServerlessOptions(),
+      MockFactory.createTestServerlessOptions(options),
     )
   }
 
@@ -27,6 +27,7 @@ describe("Offline Service", () => {
 
   afterEach(() => {
     mockFs.restore();
+    jest.resetAllMocks();
   });
 
   it("builds required files for offline execution", async () => {
@@ -145,4 +146,114 @@ describe("Offline Service", () => {
     expect(call.command).toEqual("func.cmd");
     expect(call.args).toEqual(["host", "start"]);
   });
+
+  it("cleans up after offline call as default behavior", async () => {
+    mockFs({
+      hello: {
+        "function.json": "contents"
+      },
+      goodbye: {
+        "function.json": "contents"
+      },
+      "local.settings.json": "contents",
+    });
+
+    Object.defineProperty(process, "platform", {
+      value: "win32",
+      writable: true,
+    });
+
+    const sls = MockFactory.createTestServerless();
+    const processOnSpy = jest.spyOn(process, "on");
+    const unlinkSpy = jest.spyOn(fs, "unlinkSync");
+    const rmdirSpy = jest.spyOn(fs, "rmdirSync")
+
+    const service = createService(sls);
+
+    await service.start();
+
+    const calls = mySpawn.calls;
+    expect(calls).toHaveLength(1);
+    const call = calls[0];
+    expect(call.command).toEqual("func.cmd");
+    expect(call.args).toEqual(["host", "start"]);
+
+    const processOnCalls = processOnSpy.mock.calls;
+    expect(processOnCalls).toHaveLength(1);
+    expect(processOnCalls[0][0]).toEqual("SIGINT");
+    expect(processOnCalls[0][1]).toBeInstanceOf(Function);
+
+    // SIGINT handler function
+    const sigintCallback = processOnCalls[0][1] as any;
+
+    process.exit = jest.fn() as any;
+    await sigintCallback();
+
+    /* Offline Cleanup assertions*/
+
+    const unlinkCalls = unlinkSpy.mock.calls;
+
+    expect(unlinkCalls).toHaveLength(3);
+    expect(unlinkCalls[0][0]).toBe(`hello${path.sep}function.json`);
+    expect(unlinkCalls[1][0]).toBe(`goodbye${path.sep}function.json`);
+    expect(unlinkCalls[2][0]).toBe("local.settings.json");
+
+    const rmdirCalls = rmdirSpy.mock.calls;
+
+    expect(rmdirCalls[0][0]).toBe("hello");
+    expect(rmdirCalls[1][0]).toBe("goodbye");
+
+    unlinkSpy.mockRestore();
+    rmdirSpy.mockRestore();
+
+    expect(process.exit).toBeCalledTimes(1);
+  });
+
+  it("does not clean up after offline call if specified in options", async () => {
+
+    Object.defineProperty(process, "platform", {
+      value: "win32",
+      writable: true,
+    });
+
+    const processOnSpy = jest.spyOn(process, "on");
+    const unlinkSpy = jest.spyOn(fs, "unlinkSync");
+    const rmdirSpy = jest.spyOn(fs, "rmdirSync");
+
+    const sls = MockFactory.createTestServerless();
+
+
+    const service = createService(sls, {
+      "nocleanup": ""
+    });
+
+    await service.start();
+
+    const calls = mySpawn.calls;
+    expect(calls).toHaveLength(1);
+    const call = calls[0];
+    expect(call.command).toEqual("func.cmd");
+    expect(call.args).toEqual(["host", "start"]);
+
+    const processOnCalls = processOnSpy.mock.calls;
+    expect(processOnCalls).toHaveLength(1);
+    expect(processOnCalls[0][0]).toEqual("SIGINT");
+    expect(processOnCalls[0][1]).toBeInstanceOf(Function);
+
+    // SIGINT handler function
+    const sigintCallback = processOnCalls[0][1] as any;
+
+    process.exit = jest.fn() as any;
+    await sigintCallback();
+
+    /* Offline Cleanup assertions*/
+
+    expect(unlinkSpy).not.toBeCalled();
+    expect(rmdirSpy).not.toBeCalled();
+
+    unlinkSpy.mockRestore();
+    rmdirSpy.mockRestore();
+
+    expect(process.exit).toBeCalledTimes(1);
+  });
 });