Repository: saucelabs/node-saucelabs
Pull request #81 (2020-05-18 11:15:17)

Add support for Sauce Connect Proxy
Currently users would need two packages: `saucelabs` and [`sauce-connect-launcher`](https://www.npmjs.com/package/sauce-connect-launcher) to access all Sauce Platform capabilities. This PR adds support to start and stop a Sauce Connect Proxy tunnel to simplify usage. Users can from now on just this NPM package if they want to run Sauce Connect like:

```sh
$ sl sc --region eu --se-port 1234
```

or

```js
const api = new SauceLabs({ region: 'eu' })
await api.startSauceConnect({ sePort: 1234 })
```

The package will automatically set correct parameters to start the tunnel either in US, EU or US-East datacenters. The package is using [`bin-wrapper`](https://www.npmjs.com/package/bin-wrapper) to download and extract the actual Sauce Connect binary.

christian-bromann: > I do think it's useful to have it here, on the other hand also wonder if this should be separated.

There is already a separated package for this called [`sauce-connect-launcher`](https://www.npmjs.com/package/sauce-connect-launcher). So people can use that if they prefer to have 2 packages in their dependency tree. However it has no typescript support nor updated API interfaces (still uses good old callbacks instead of promises). Please note that Sauce Connect is __not__ downloaded when the package is being installed, only if it is used the first time.

Patch:
diff --git a/README.md b/README.md
index 652d0b5..4aa6f14 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
 
 # Node Sauce Labs [![Build Status](https://travis-ci.org/saucelabs/node-saucelabs.svg?branch=master)](https://travis-ci.org/saucelabs/node-saucelabs)
 
-Wrapper around all Sauce Labs REST APIs for [Node.js](http://nodejs.org/) (v8 or higher) and the browser.
+Wrapper around all Sauce Labs REST APIs for [Node.js](http://nodejs.org/) (v8 or higher) including support for [Sauce Connect Proxy](https://wiki.saucelabs.com/display/DOCS/Sauce+Connect+Proxy) TypeScript definitions.
 
 ## Install
 
@@ -78,21 +78,33 @@ $ sl listJobs $SAUCE_USERNAME --limit 5 --region eu
 You can find all available commands and options with description by calling:
 
 ```sh
-sl --help
+$ sl --help
 # show description for specific command
-sl listJobs --help
+$ sl listJobs --help
 ```
 
 or update the job status by calling:
 
 ```sh
-sl updateJob cb-onboarding 690c5877710c422d8be4c622b40c747f "{\"passed\":false}"
+$ sl updateJob cb-onboarding 690c5877710c422d8be4c622b40c747f "{\"passed\":false}"
 ```
 
 or download a job asset:
 
 ```sh
-sl downloadJobAsset 690c5877710c422d8be4c622b40c747f video.mp4 --filepath ./video.mp4
+$ sl downloadJobAsset 690c5877710c422d8be4c622b40c747f video.mp4 --filepath ./video.mp4
+```
+
+or start Sauce Connect Proxy in EU datacenter:
+
+```sh
+$ sl sc --region eu --se-port 4445
+
+# see all available Sauce Connect parameters via:
+$ sl sc --help
+```
+
+You can see all available Sauce Connect parameters on the [Sauce Labs Wiki Page](https://wiki.saucelabs.com/display/DOCS/Sauce+Connect+Proxy+Command-Line+Quick+Reference+Guide).
 
 ### As NPM Package
 
@@ -149,6 +161,22 @@ import SauceLabs from 'saucelabs';
             breakpointed: null,
             browser: 'googlechrome' } ] }
      */
+
+    /**
+     * start Sauce Connect Proxy
+     */
+    const sc = await api.startSauceConnect({
+        /**
+         * see all available parameters here: https://wiki.saucelabs.com/display/DOCS/Sauce+Connect+Proxy+Command-Line+Quick+Reference+Guide
+         * all parameters have to be applied camel cased instead of with hyphens, e.g.
+         * to apply the `--se-port` parameter, set:
+         */
+        sePort: 1234
+    })
+
+    // run a test
+
+    await sc.close()
 })()
 ```
 
diff --git a/package.json b/package.json
index de26846..70c6097 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,7 @@
     "watch": "npm run compile -- --watch"
   },
   "dependencies": {
+    "bin-wrapper": "^4.1.0",
     "change-case": "^4.1.1",
     "got": "^10.6.0",
     "hash.js": "^1.1.7",
@@ -80,7 +81,7 @@
     "collectCoverage": true,
     "coverageThreshold": {
       "global": {
-        "branches": 94,
+        "branches": 93,
         "functions": 100,
         "lines": 96,
         "statements": 96
diff --git a/scripts/constants.js b/scripts/constants.js
new file mode 100644
index 0000000..0b808df
--- /dev/null
+++ b/scripts/constants.js
@@ -0,0 +1,70 @@
+const { REGION_MAPPING, SAUCE_CONNECT_CLI_PARAMS } = require('../src/constants')
+const regions = Object.keys(REGION_MAPPING)
+    .map(key => `"${key}"`)
+    .join(' | ')
+
+exports.TS_IMPORTS = `
+import { ChildProcess } from 'child_process';
+`
+
+exports.TS_SAUCELABS_OBJ = `
+export interface SauceLabsOptions {
+    /**
+     * Your Sauce Labs username.
+     */
+    user: string;
+    /**
+     * Your Sauce Labs access key.
+     */
+    key: string;
+    /**
+     * Your Sauce Labs datacenter region. The following regions are available:
+     *
+     * - us-west-1 (short 'us')
+     * - eu-central-1 (short 'eu')
+     * - us-east-1 (headless)
+     */
+    region?: ${regions};
+    /**
+     * If set to true you are accessing the headless Sauce instances (this discards the region option).
+     */
+    headless?: boolean;
+    /**
+     * If you want to tunnel your API request through a proxy please see the [got proxy docs](https://github.com/sindresorhus/got/blob/master/readme.md#proxies) for more information.
+     */
+    proxy?: object;
+}`
+
+exports.TC_SAUCE_CONNECT_OBJ = `
+export interface SauceConnectOptions {
+${SAUCE_CONNECT_CLI_PARAMS.map((option) => `
+    /**
+     * ${option.description} ${option.default ? `(default: ${option.default})` : ''}
+     */
+    ${option.name.replace(/-[a-z]/g, (r) => r.slice(1).toUpperCase())}?: ${option.type || 'string'};
+`).join('\n')}
+}
+`
+
+exports.TC_SAUCE_CONNECT_CLASS = `
+export interface SauceConnectInstance {
+    /**
+     * Sauce Connect child process
+     */
+    cp: ChildProcess;
+    /**
+     * shutdown Sauce Connect
+     */
+    close: () => Promise<undefined>;
+}
+`
+
+exports.TC_START_SC = `
+    /**
+     * Start Sauce Connect
+     * @method
+     * @name SauceLabs#startSauceConnect
+     * @param {string} id - job id
+     */
+    startSauceConnect(params: SauceConnectOptions): Promise<SauceConnectInstance>;
+`
diff --git a/scripts/generate-typings.js b/scripts/generate-typings.js
index ac827ec..50c7ea2 100644
--- a/scripts/generate-typings.js
+++ b/scripts/generate-typings.js
@@ -4,6 +4,11 @@ const { camelCase } = require('change-case')
 const CodeGen = require('swagger-typescript-codegen').CodeGen
 const prettier = require('prettier')
 
+const {
+    TS_IMPORTS, TS_SAUCELABS_OBJ, TC_SAUCE_CONNECT_CLASS,
+    TC_SAUCE_CONNECT_OBJ, TC_START_SC
+} = require('./constants')
+
 function generateTypingsForApi(file) {
     const swagger = JSON.parse(fs.readFileSync(file, 'UTF-8'))
     const definitions = CodeGen.getTypescriptCode({
@@ -48,44 +53,16 @@ fs.readdir(path.join(__dirname, '../apis'), (err, files) => {
         throw err
     }
 
-    const regions = Object.keys(require('../src/constants').REGION_MAPPING)
-        .map(key => `"${key}"`)
-        .join(' | ')
-
-    let result = `
-export interface SauceLabsOptions {
-    /**
-     * Your Sauce Labs username.
-     */
-    user: string;
-    /**
-     * Your Sauce Labs access key.
-     */
-    key: string;
-    /**
-     * Your Sauce Labs datacenter region. The following regions are available:
-     *
-     * - us-west-1 (short 'us')
-     * - eu-central-1 (short 'eu')
-     * - us-east-1 (headless)
-     */
-    region?: ${regions};
-    /**
-     * If set to true you are accessing the headless Sauce instances (this discards the region option).
-     */
-    headless?: boolean;
-    /**
-     * If you want to tunnel your API request through a proxy please see the [got proxy docs](https://github.com/sindresorhus/got/blob/master/readme.md#proxies) for more information.
-     */
-    proxy?: object;
-}`
-
-    const methods = files.map((api) => {
-        const typings = generateTypingsForApi('./apis/' + api)
-        result += `\n\n ${typings.defintions}`
+    let result = `${TS_IMPORTS}\n${TS_SAUCELABS_OBJ}\n${TC_SAUCE_CONNECT_CLASS}\n${TC_SAUCE_CONNECT_OBJ}`
+    const methods = [
+        TC_START_SC,
+        ...files.map((api) => {
+            const typings = generateTypingsForApi('./apis/' + api)
+            result += `\n\n ${typings.defintions}`
 
-        return typings.methods
-    }).join('\n\n')
+            return typings.methods
+        })
+    ].join('\n\n')
 
     result += `\n\ndeclare class SauceLabs {\n
     constructor(options: SauceLabsOptions)\n\n
diff --git a/src/cli.js b/src/cli.js
index 878d787..80292da 100644
--- a/src/cli.js
+++ b/src/cli.js
@@ -1,6 +1,5 @@
 import yargs from 'yargs'
-
-import { USAGE, CLI_PARAMS, EPILOG, PROTOCOL_MAP, DEFAULT_OPTIONS } from './constants'
+import { USAGE, CLI_PARAMS, EPILOG, PROTOCOL_MAP, DEFAULT_OPTIONS, SAUCE_VERSION_NOTE } from './constants'
 import { getParameters } from './utils'
 import SauceLabs from './'
 
@@ -8,7 +7,9 @@ export const run = () => {
     let argv = yargs.usage(USAGE)
         .epilog(EPILOG)
         .demandCommand()
+        .commandDir('commands')
         .help()
+        .version(SAUCE_VERSION_NOTE)
 
     for (const [commandName, options] of PROTOCOL_MAP) {
         const params = getParameters(options.description.parameters)
diff --git a/src/commands/sc.js b/src/commands/sc.js
new file mode 100644
index 0000000..f0c0225
--- /dev/null
+++ b/src/commands/sc.js
@@ -0,0 +1,15 @@
+import SauceLabs from './..'
+import { DEFAULT_OPTIONS, SAUCE_CONNECT_CLI_PARAMS } from '../constants'
+
+export const command = 'sc [flags]'
+export const describe = 'Sauce Connect interface'
+export const builder = (yargs) => {
+    for (const option of SAUCE_CONNECT_CLI_PARAMS) {
+        yargs.option(option.name, option)
+    }
+}
+export const handler = async (argv) => {
+    const { user, key, headless, region, proxy } = Object.assign({}, DEFAULT_OPTIONS, argv)
+    const api = new SauceLabs({ user, key, headless, region, proxy })
+    return api.startSauceConnect(argv, true)
+}
diff --git a/src/constants.js b/src/constants.js
index 24f89e9..48d064c 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -1,4 +1,15 @@
 import { camelCase } from 'change-case'
+import { version } from '../package.json'
+
+export const SAUCE_CONNECT_VERSION = '4.5.4'
+export const SAUCE_CONNECT_BASE = 'https://saucelabs.com/downloads'
+export const SAUCE_CONNECT_DISTS = [
+    [`${SAUCE_CONNECT_BASE}/sc-${SAUCE_CONNECT_VERSION}-osx.zip`, 'darwin'],
+    [`${SAUCE_CONNECT_BASE}/sc-${SAUCE_CONNECT_VERSION}-win32.zip`, 'win32'],
+    [`${SAUCE_CONNECT_BASE}/sc-${SAUCE_CONNECT_VERSION}-linux.tar.gz`, 'linux', 'x64'],
+    [`${SAUCE_CONNECT_BASE}/sc-${SAUCE_CONNECT_VERSION}_linux32.tar.gz`, 'linux']
+]
+export const SAUCE_VERSION_NOTE = `node-saucelabs v${version}\nSauce Connect v${SAUCE_CONNECT_VERSION}`
 
 const protocols = [
     require('../apis/sauce.json'),
@@ -96,13 +107,148 @@ export const CLI_PARAMS = [{
     default: DEFAULT_OPTIONS.region,
     description: 'your Sauce Labs datacenter region, the following regions are available: `us-west-1` (short `us`), `eu-central-1` (short `eu`)'
 }, {
-    alias: 'h',
     name: 'headless',
     default: DEFAULT_OPTIONS.headless,
-    description: 'if set to true you are accessing the headless Sauce instances (this discards the `region` option)'
+    description: 'if set to true you are accessing the headless Sauce instances (this discards the `region` option)',
+    boolean: true
 }, {
     alias: 'p',
     name: 'proxy',
     description: 'use a proxy for fetching data instead of environment variables',
     default: DEFAULT_OPTIONS.proxy
 }]
+const CLI_PARAM_KEYS = CLI_PARAMS.map((param) => param.name)
+const CLI_PARAM_ALIASES = CLI_PARAMS.map((param) => param.alias).filter(Boolean)
+
+export const SAUCE_CONNECT_CLI_PARAMS = [{
+    alias: 'a',
+    name: 'auth',
+    description: 'Perform basic authentication when an URL on <host:port> asks for a username and password.'
+}, {
+    name: 'cainfo',
+    description: 'CA certificate bundle to use for verifying REST connections. (default "/usr/local/etc/openssl/cert.pem")'
+}, {
+    name: 'capath',
+    description: 'Directory of CA certs to use for verifying REST connections. (default "/etc/ssl/certs")'
+}, {
+    alias: 'c',
+    name: 'config-file',
+    description: 'Path to YAML config file. Please refer to https://wiki.saucelabs.com/display/DOCS/Sauce+Connect+Command+Line+Reference for a sample configuration file.'
+}, {
+    alias: 'D',
+    name: 'direct-domains',
+    description: 'Comma-separated list of domains. Requests whose host matches one of these will be relayed directly through the internet, instead of through the tunnel.'
+}, {
+    name: 'dns',
+    description: 'Use specified name server. To specify multiple servers, separate them with comma. Use IP addresses, optionally with a port number, the two separated by a colon. Example: --dns 8.8.8.8,8.8.4.4:53'
+}, {
+    name: 'doctor',
+    description: 'Perform checks to detect possible misconfiguration or problems.'
+}, {
+    alias: 'F',
+    name: 'fast-fail-regexps',
+    description: 'Comma-separated list of regular expressions. Requests matching one of these will get dropped instantly and will not go through the tunnel.'
+}, {
+    alias: 'z',
+    name: 'log-stats',
+    description: 'Log statistics about HTTP traffic every <seconds>.',
+    type: 'number'
+}, {
+    alias: 'l',
+    name: 'logfile',
+    description: 'Specify custom logfile.'
+}, {
+    name: 'max-logsize',
+    description: 'Rotate logfile after reaching <bytes> size. Disabled by default.',
+    type: 'number'
+}, {
+    alias: 'M',
+    name: 'max-missed-acks',
+    description: 'The maximum amount of keepalive acks that can be missed before the client will trigger a reconnect. (default 30)',
+    type: 'number',
+    default: 30
+}, {
+    name: 'metrics-address',
+    description: 'host:port for the internal web server used to expose client side metrics. (default "localhost:8888")'
+}, {
+    name: 'no-autodetect',
+    description: 'Disable the autodetection of proxy settings.'
+}, {
+    alias: 'N',
+    name: 'no-proxy-caching',
+    description: 'Disable caching in Sauce Connect. All requests will be sent through the tunnel.'
+}, {
+    name: 'no-remove-colliding-tunnels',
+    description: 'Don\'t remove identified tunnels with the same name, or any other default tunnels if this is a default tunnel. Jobs will be distributed between these tunnels, enabling load balancing and high availability. By default, colliding tunnels will be removed when Sauce Connect is starting up.'
+}, {
+    alias: 'B',
+    name: 'no-ssl-bump-domains',
+    description: 'Comma-separated list of domains. Requests whose host matches one of these will not be SSL re-encrypted.'
+}, {
+    name: 'pac',
+    description: 'Proxy autoconfiguration. Can be an http(s) or local file:// (absolute path only) URI.'
+}, {
+    alias: 'd',
+    name: 'pidfile',
+    description: 'File that will be created with the pid of the process.'
+}, {
+    alias: 'T',
+    name: 'proxy-tunnel',
+    description: 'Use the proxy configured with -p for the tunnel connection.'
+}, {
+    alias: 'w',
+    name: 'proxy-userpwd',
+    description: 'Username and password required to access the proxy configured with -p.'
+}, {
+    alias: 'f',
+    name: 'readyfile',
+    description: 'File that will be touched to signal when tunnel is ready.'
+}, {
+    alias: 'x',
+    name: 'rest-url',
+    description: 'Advanced feature: Connect to Sauce REST API at alternative URL. Use only if directed to do so by Sauce Labs support. (default "https://saucelabs.com/rest/v1")'
+}, {
+    alias: 'X',
+    name: 'scproxy-port',
+    description: 'Port on which scproxy will be listening.'
+}, {
+    name: 'scproxy-read-limit',
+    description: 'Rate limit reads in scproxy to X bytes per second. This option can be used to adjust local network transfer rate in order not to overload the tunnel connection.'
+}, {
+    name: 'scproxy-write-limit',
+    description: 'Rate limit writes in scproxy to X bytes per second. This option can be used to adjust local network transfer rate in order not to overload the tunnel connection.'
+}, {
+    alias: 'P',
+    name: 'se-port',
+    description: 'Port on which Sauce Connect\'s Selenium relay will listen for requests. Selenium commands reaching Connect on this port will be relayed to Sauce Labs securely and reliably through Connect\'s tunnel (default 4445)',
+    type: 'number',
+    default: 4445
+}, {
+    alias: 's',
+    name: 'shared-tunnel',
+    description: 'Let sub-accounts of the tunnel owner use the tunnel if requested.'
+}, {
+    name: 'tunnel-cainfo',
+    description: 'CA certificate bundle to use for verifying tunnel connections. (default "/usr/local/etc/openssl/cert.pem")'
+}, {
+    name: 'tunnel-capath',
+    description: 'Directory of CA certs to use for verifying tunnel connections. (default "/etc/ssl/certs")'
+}, {
+    name: 'tunnel-cert',
+    description: 'Specify certificate to use for the tunnel connection, either public or private. Default: private. (default "private")'
+}, {
+    alias: 't',
+    name: 'tunnel-domains',
+    description: 'Inverse of \'--direct-domains\'. Only requests for domains in this list will be sent through the tunnel. Overrides \'--direct-domains\'.'
+}, {
+    alias: 'i',
+    name: 'tunnel-identifier',
+    description: 'Don\'t automatically assign jobs to this tunnel. Jobs will use it only by explicitly providing the right identifier.'
+}]
+const SAUCE_CONNECT_CLI_PARAM_ALIASES = SAUCE_CONNECT_CLI_PARAMS.map((param) => param.alias).filter(Boolean)
+export const SC_CLI_PARAM_KEYS = SAUCE_CONNECT_CLI_PARAMS.map((param) => param.name)
+export const SC_PARAMS_TO_STRIP = [...CLI_PARAM_KEYS, ...CLI_PARAM_ALIASES, ...SAUCE_CONNECT_CLI_PARAM_ALIASES]
+
+export const SC_READY_MESSAGE = 'Sauce Connect is up, you may start your tests'
+export const SC_CLOSE_MESSAGE = 'Goodbye'
+export const SC_CLOSE_TIMEOUT = 5000
diff --git a/src/index.js b/src/index.js
index 7787c1c..1a92204 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,6 +1,9 @@
 import fs from 'fs'
 import path from 'path'
+import { spawn } from 'child_process'
+
 import got from 'got'
+import BinWrapper from 'bin-wrapper'
 import { camelCase } from 'change-case'
 
 import {
@@ -9,7 +12,9 @@ import {
 } from './utils'
 import {
     PROTOCOL_MAP, DEFAULT_OPTIONS, SYMBOL_INSPECT, SYMBOL_TOSTRING,
-    SYMBOL_ITERATOR, TO_STRING_TAG
+    SYMBOL_ITERATOR, TO_STRING_TAG, SAUCE_CONNECT_VERSION,
+    SAUCE_CONNECT_DISTS, SC_PARAMS_TO_STRIP, SC_READY_MESSAGE,
+    SC_CLOSE_MESSAGE, SC_CLOSE_TIMEOUT
 } from './constants'
 
 export default class SauceLabs {
@@ -72,6 +77,13 @@ export default class SauceLabs {
             return
         }
 
+        /**
+         * provide access to Sauce Connect interface
+         */
+        if (propName === 'startSauceConnect') {
+            return ::this._startSauceConnect
+        }
+
         /**
          * allow to return publicly registered class properties
          */
@@ -178,6 +190,71 @@ export default class SauceLabs {
         }
     }
 
+    async _startSauceConnect (argv, fromCLI) {
+        if (!fromCLI) {
+            for (const [k, v] of Object.entries(argv)) {
+                if (k.includes('-')) {
+                    continue
+                }
+                argv[k.split(/(?=[A-Z])/).join('-').toLowerCase()] = v
+            }
+        }
+
+        const { host, basePath } = PROTOCOL_MAP.get('listJobs')
+        const restUrl = getSauceEndpoint(host + basePath, this._options.region, this._options.headless) + '/v1'
+        const args = Object.entries(argv)
+            /**
+             * filter out yargs and yargs params
+             */
+            .filter(([k]) => !['_', '$0', ...SC_PARAMS_TO_STRIP].includes(k))
+            /**
+             * remove duplicate params by yargs
+             */
+            .filter(([k]) => !k.match(/[A-Z]/g))
+            .map(([k, v]) => `--${k}=${v}`)
+
+        args.push(`--user=${this.username}`)
+        args.push(`--api-key=${this._accessKey}`)
+        args.push(`--rest-url=${restUrl}`)
+
+        const bin = SAUCE_CONNECT_DISTS.reduce((bin, dist) => {
+            bin.src(...dist)
+            return bin
+        }, new BinWrapper())
+
+        bin
+            .dest(path.join(__dirname, 'bin', 'bin'))
+            .use(process.platform.startsWith('win') ? 'sc.exe' : 'sc')
+            .version(`v${SAUCE_CONNECT_VERSION}`)
+
+        await bin.run(['--version'])
+        const cp = spawn(bin.path(), args)
+        return new Promise((resolve, reject) => {
+            const close = () => new Promise((resolveClose) => {
+                process.kill(cp.pid, 'SIGINT')
+                const timeout = setTimeout(resolveClose, SC_CLOSE_TIMEOUT)
+                cp.stdout.on('data', (data) => {
+                    const output = data.toString()
+                    if (output.includes(SC_CLOSE_MESSAGE)) {
+                        clearTimeout(timeout)
+                        return resolveClose(returnObj)
+                    }
+                })
+            })
+            const returnObj = { cp, close }
+
+            cp.stderr.on('data', (data) => reject(new Error(data.toString())))
+            cp.stdout.on('data', (data) => {
+                if (data.toString().includes(SC_READY_MESSAGE)) {
+                    return resolve(returnObj)
+                }
+            })
+
+            process.on('SIGINT', close)
+            return returnObj
+        })
+    }
+
     async _downloadJobAsset (jobId, assetName, { filepath } = {}) {
         /**
          * check job id


Test patch:
diff --git a/tests/__mocks__/bin-wrapper.js b/tests/__mocks__/bin-wrapper.js
new file mode 100644
index 0000000..33c055f
--- /dev/null
+++ b/tests/__mocks__/bin-wrapper.js
@@ -0,0 +1,12 @@
+class BinWrapperMock {
+    constructor () {
+        this.run = jest.fn().mockReturnValue(Promise.resolve()),
+        this.path = jest.fn().mockReturnValue('/foo/bar'),
+        this.src = jest.fn()
+        this.dest = jest.fn().mockReturnValue(this)
+        this.use = jest.fn().mockReturnValue(this)
+        this.version = jest.fn().mockReturnValue(this)
+    }
+}
+
+export default BinWrapperMock
diff --git a/tests/__mocks__/yargs.js b/tests/__mocks__/yargs.js
index b1823ff..8406152 100644
--- a/tests/__mocks__/yargs.js
+++ b/tests/__mocks__/yargs.js
@@ -6,5 +6,7 @@ yargsMock.help = jest.fn().mockReturnValue(yargsMock)
 yargsMock.command = jest.fn().mockReturnValue(yargsMock)
 yargsMock.option = jest.fn().mockReturnValue(yargsMock)
 yargsMock.positional = jest.fn().mockReturnValue(yargsMock)
+yargsMock.commandDir = jest.fn().mockReturnValue(yargsMock)
+yargsMock.version = jest.fn().mockReturnValue(yargsMock)
 
 export default yargsMock
diff --git a/tests/__snapshots__/index.test.js.snap b/tests/__snapshots__/index.test.js.snap
new file mode 100644
index 0000000..ff825bf
--- /dev/null
+++ b/tests/__snapshots__/index.test.js.snap
@@ -0,0 +1,16 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`startSauceConnect should start sauce connect with proper parsed args 1`] = `
+Array [
+  Array [
+    "/foo/bar",
+    Array [
+      "--proxy-tunnel=abc",
+      "--se-port=1234",
+      "--user=foo",
+      "--api-key=bar",
+      "--rest-url=https://us-east-1.saucelabs.com/rest/v1",
+    ],
+  ],
+]
+`;
diff --git a/tests/commands/sc.test.js b/tests/commands/sc.test.js
new file mode 100644
index 0000000..e60b2b1
--- /dev/null
+++ b/tests/commands/sc.test.js
@@ -0,0 +1,27 @@
+import { builder, handler } from '../../src/commands/sc'
+
+jest.mock('../../src/index', () => {
+    class SauceLabsMock {
+        constructor () {
+            this.scStarted = false
+            this.startSauceConnect = jest.fn(() => {
+                this.scStarted = true
+                return this
+            })
+        }
+    }
+
+    return SauceLabsMock
+})
+
+test('builder', () => {
+    const yargs = { option: jest.fn() }
+    builder(yargs)
+    expect(yargs.option).toBeCalledWith('se-port', expect.any(Object))
+})
+
+test('handler', async () => {
+    const api = await handler({ headless: true })
+    expect(api.scStarted).toBe(true)
+    expect(api.startSauceConnect).toBeCalledWith({ headless: true }, true)
+})
diff --git a/tests/index.test.js b/tests/index.test.js
index 4eeb318..fcd2c50 100644
--- a/tests/index.test.js
+++ b/tests/index.test.js
@@ -1,11 +1,34 @@
 import util from 'util'
 import got from 'got'
+import { spawn } from 'child_process'
 
 import SauceLabs from '../src'
 
 jest.mock('fs')
 const fs = require('fs')
 
+jest.mock('child_process', () => {
+    const EventEmitter = require('events')
+    const stdoutEmitter = new EventEmitter()
+    const stderrEmitter = new EventEmitter()
+    const spawnMock = {
+        pid: 123,
+        stderr: stderrEmitter,
+        stdout: stdoutEmitter,
+        on: jest.fn()
+    }
+    const spawn = jest.fn().mockReturnValue(spawnMock)
+    return { spawn }
+})
+
+const stdoutEmitter = spawn().stdout
+const stderrEmitter = spawn().stderr
+const origKill = ::process.kill
+beforeEach(() => {
+    spawn.mockClear()
+    process.kill = jest.fn()
+})
+
 test('should be inspectable', () => {
     const api = new SauceLabs({ user: 'foo', key: 'bar' })
     expect(util.inspect(api)).toContain(`{
@@ -224,10 +247,40 @@ test('should put asset into file as json file', async () => {
     expect(fs.writeFileSync).toBeCalledWith('/asset.json', undefined, { encoding: 'utf8' })
 })
 
+describe('startSauceConnect', () => {
+    it('should start sauce connect with proper parsed args', async () => {
+        const api = new SauceLabs({ user: 'foo', key: 'bar', headless: true })
+        setTimeout(() => stdoutEmitter.emit('data', 'Sauce Connect is up, you may start your tests'), 50)
+        await api.startSauceConnect({ sePort: 1234, 'proxy-tunnel': 'abc' })
+        expect(spawn).toBeCalledTimes(1)
+        expect(spawn.mock.calls).toMatchSnapshot()
+    })
+
+    it('should close sauce connect', async () => {
+        const api = new SauceLabs({ user: 'foo', key: 'bar', headless: true })
+        setTimeout(() => stdoutEmitter.emit('data', 'Sauce Connect is up, you may start your tests'), 50)
+        const sc = await api.startSauceConnect({ sePort: 1234 }, true)
+        setTimeout(() => {
+            sc.cp.stdout.emit('data', 'Some other message')
+            sc.cp.stdout.emit('data', 'Goodbye')
+        }, 100)
+        await sc.close()
+        expect(process.kill).toBeCalledWith(123, 'SIGINT')
+    })
+
+    it('should fail if stderr is emitted', async () => {
+        const api = new SauceLabs({ user: 'foo', key: 'bar', headless: true })
+        setTimeout(() => stderrEmitter.emit('data', 'Uuups'), 50)
+        const res = await api.startSauceConnect({ sePort: 1234 }).catch((err) => err)
+        expect(res).toEqual(new Error('Uuups'))
+    })
+})
+
 afterEach(() => {
     fs.writeFileSync.mockClear()
     got.mockClear()
     got.extend.mockClear()
     got.put.mockClear()
     got.get.mockClear()
+    process.kill = origKill
 })
diff --git a/tests/typings/test.ts b/tests/typings/test.ts
index be9b9af..9735e39 100644
--- a/tests/typings/test.ts
+++ b/tests/typings/test.ts
@@ -12,6 +12,10 @@ async function foobar () {
     const job = await api.getJobV1_1('foobar')
     console.log(job.selenium_version)
 
+    const sc = await api.startSauceConnect({ sePort: 1234 })
+    sc.cp.pid
+    await sc.close()
+
     await api.downloadJobAsset( 'job_id', 'video.mp4')
     await api.downloadJobAsset( 'job_id', 'video.mp4', './video.mp4')
 }
diff --git a/tests/typings/tsconfig.json b/tests/typings/tsconfig.json
index bc79515..3493f71 100644
--- a/tests/typings/tsconfig.json
+++ b/tests/typings/tsconfig.json
@@ -3,7 +3,8 @@
     "outDir": "dist",
     "noImplicitAny": true,
     "types": [
-      "saucelabs"
+      "saucelabs",
+      "node"
     ]
   }
 }