Repository: saucelabs/node-saucelabs
Pull request #83 (2020-05-28 10:45:56)

Add testrunner API endpoint
With the Sauce Labs Testrunner Toolkit we introduced a new endpoint that allows anyone to upload assets to his job. This is a new microservice with a new OpenAPI spec. The new command can be used as follows:

via CLI:
```sh
sl uploadJobAssets 690c5877710c422d8be4c622b40c747f --files ./video.mp4 --files ./log.json
```

or via Node.js
```js
await myAccount.uploadJobAssets(
    '76e693dbe6ff4910abb0bc3d752a971e',
    ['video.mp4', 'log.json']
)
```


Patch:
diff --git a/README.md b/README.md
index 4d0b0ee..fe934a3 100644
--- a/README.md
+++ b/README.md
@@ -95,6 +95,12 @@ or download a job asset:
 $ sl downloadJobAsset 690c5877710c422d8be4c622b40c747f video.mp4 --filepath ./video.mp4
 ```
 
+or upload a job asset:
+
+```sh
+$ sl uploadJobAssets 690c5877710c422d8be4c622b40c747f --files ./video.mp4 --files ./log.json
+```
+
 or start Sauce Connect Proxy in EU datacenter:
 
 ```sh
@@ -123,12 +129,12 @@ import SauceLabs from 'saucelabs';
     // const myAccount = new SauceLabs({ user: "YOUR-USER", key: "YOUR-ACCESS-KEY"});
 
     // get job details of last run job
-    const job = await myAccount.listJobs(
+    const jobs = await myAccount.listJobs(
         process.env.SAUCE_USERNAME,
         { limit: 1, full: true }
     );
 
-    console.log(job);
+    console.log(jobs);
     /**
      * outputs:
      * { jobs:
@@ -181,8 +187,16 @@ import SauceLabs from 'saucelabs';
     })
 
     // run a test
+    // ...
 
+    // close Sauce Connect
     await sc.close()
+
+    // upload additional log files and attach it to your Sauce job
+    await myAccount.uploadJobAssets(
+        '76e693dbe6ff4910abb0bc3d752a971e',
+        ['video.mp4', 'log.json']
+    )
 })()
 ```
 
diff --git a/apis/performance.json b/apis/performance.json
index 38c5983..30cf3e8 100644
--- a/apis/performance.json
+++ b/apis/performance.json
@@ -5,7 +5,31 @@
     "version":"0.247.0",
     "description":"Performance API provides essential information about performance of tested web application."
   },
-  "host":"api.us-west-1.saucelabs.com",
+  "host":"saucelabs.com",
+  "servers":[
+    {
+      "url":"https://api.{region}.saucelabs.{tld}",
+      "variables":{
+        "region":{
+          "default":"us-west-1",
+          "description":"region of datacenter",
+          "enum":[
+            "us-west-1",
+            "eu-central-1",
+            "staging"
+          ]
+        },
+        "tld":{
+          "default":"com",
+          "description":"internal or external API",
+          "enum":[
+            "net",
+            "com"
+          ]
+        }
+      }
+    }
+  ],
   "basePath":"/v2/performance",
   "schemes":[
     "https"
diff --git a/apis/rdc.json b/apis/rdc.json
index 77237da..f867751 100644
--- a/apis/rdc.json
+++ b/apis/rdc.json
@@ -5,6 +5,12 @@
     "version":"1.0.0",
     "title":"API Reference"
   },
+  "servers":[
+    {
+      "url":"https://app.testobject.com",
+      "variables":{}
+    }
+  ],
   "host":"app.testobject.com",
   "basePath":"/api/rest",
   "tags":[
diff --git a/apis/sauce.json b/apis/sauce.json
index 5238f97..b3506bb 100644
--- a/apis/sauce.json
+++ b/apis/sauce.json
@@ -1,5 +1,60 @@
 {
+  "swagger":"2.0",
+  "info":{
+    "contact":{
+      "email":"support@saucelabs.com",
+      "name":"API Support",
+      "url":"https://saucelabs.com/support"
+    },
+    "description":"This is a REST API documentation provided by Sauce Labs",
+    "license":{
+      "name":"Apache 2.0",
+      "url":"http://www.apache.org/licenses/LICENSE-2.0.html"
+    },
+    "termsOfService":"https://saucelabs.com/terms-of-service",
+    "title":"Sauce Labs REST API",
+    "version":"0.0.1"
+  },
+  "host":"saucelabs.com",
   "basePath":"/rest",
+  "servers":[
+    {
+      "url":"https://api.{region}.saucelabs.{tld}",
+      "variables":{
+        "region":{
+          "default":"us-west-1",
+          "description":"region of datacenter",
+          "enum":[
+            "us-west-1",
+            "us-east-1",
+            "eu-central-1",
+            "staging"
+          ]
+        },
+        "tld":{
+          "default":"com",
+          "description":"internal or external API",
+          "enum":[
+            "net",
+            "com"
+          ]
+        }
+      }
+    }
+  ],
+  "schemes":[
+    "https"
+  ],
+  "securityDefinitions":{
+    "BasicAuth":{
+      "type":"basic"
+    },
+    "PrivApiKey":{
+      "in":"header",
+      "name":"Signature",
+      "type":"apiKey"
+    }
+  },
   "definitions":{
     "Activity":{
       "properties":{
@@ -717,37 +772,28 @@
       "type":"object"
     }
   },
-  "host":"saucelabs.com",
-  "info":{
-    "contact":{
-      "email":"support@saucelabs.com",
-      "name":"API Support",
-      "url":"https://saucelabs.com/support"
+  "parameters":{
+    "assetName": {
+      "description": "filename of the asset",
+      "in": "path",
+      "name": "assetName",
+      "required": true,
+      "type": "string"
     },
-    "description":"This is a REST API documentation provided by Sauce Labs",
-    "license":{
-      "name":"Apache 2.0",
-      "url":"http://www.apache.org/licenses/LICENSE-2.0.html"
+    "filepath": {
+      "description": "file path to store the asset at",
+      "in": "path",
+      "name": "filepath",
+      "required": false,
+      "type": "string"
     },
-    "termsOfService":"https://saucelabs.com/terms-of-service",
-    "title":"Sauce Labs REST API",
-    "version":"0.0.1"
-  },
-  "parameters":{
-    "assetName":{
-      "description":"filename of the asset",
+    "filename":{
+      "description":"filename",
       "in":"path",
-      "name":"assetName",
+      "name":"filename",
       "required":true,
       "type":"string"
     },
-    "filepath":{
-      "description":"file path to store the asset at",
-      "in":"path",
-      "name":"filepath",
-      "required":false,
-      "type":"string"
-    },
     "full":{
       "description":"Should the return result contain everything or just the basics",
       "in":"query",
@@ -2297,10 +2343,10 @@
             "$ref":"#/parameters/id"
           },
           {
-            "$ref":"#/parameters/assetName"
+            "$ref":"#/parameters/filename"
           },
           {
-            "$ref":"#/parameters/filepath"
+            "$ref": "#/parameters/filepath"
           }
         ],
         "responses":{
@@ -2329,26 +2375,5 @@
         ]
       }
     }
-  },
-  "schemes":[
-    "https"
-  ],
-  "security":[
-    {
-      "BasicAuth":[
-
-      ]
-    }
-  ],
-  "securityDefinitions":{
-    "BasicAuth":{
-      "type":"basic"
-    },
-    "PrivApiKey":{
-      "in":"header",
-      "name":"Signature",
-      "type":"apiKey"
-    }
-  },
-  "swagger":"2.0"
+  }
 }
diff --git a/package.json b/package.json
index 82b56bd..135514a 100644
--- a/package.json
+++ b/package.json
@@ -41,6 +41,7 @@
   "dependencies": {
     "bin-wrapper": "^4.1.0",
     "change-case": "^4.1.1",
+    "form-data": "^3.0.0",
     "got": "^11.1.4",
     "hash.js": "^1.1.7",
     "tunnel": "0.0.6",
@@ -83,8 +84,8 @@
       "global": {
         "branches": 93,
         "functions": 100,
-        "lines": 97,
-        "statements": 97
+        "lines": 98,
+        "statements": 98
       }
     },
     "testEnvironment": "node"
diff --git a/scripts/constants.js b/scripts/constants.js
index eda1078..9031041 100644
--- a/scripts/constants.js
+++ b/scripts/constants.js
@@ -1,7 +1,5 @@
-const { REGION_MAPPING, SAUCE_CONNECT_CLI_PARAMS } = require('../src/constants')
-const regions = Object.keys(REGION_MAPPING)
-    .map(key => `"${key}"`)
-    .join(' | ')
+const { SAUCE_CONNECT_CLI_PARAMS, ASSET_REGION_MAPPING } = require('../src/constants')
+const regions = Object.keys(ASSET_REGION_MAPPING).map(key => `"${key}"`).join(' | ')
 
 exports.TS_IMPORTS = `
 import { ChildProcess } from 'child_process';
diff --git a/scripts/generate-typings.js b/scripts/generate-typings.js
index 50c7ea2..9278738 100644
--- a/scripts/generate-typings.js
+++ b/scripts/generate-typings.js
@@ -11,6 +11,14 @@ const {
 
 function generateTypingsForApi(file) {
     const swagger = JSON.parse(fs.readFileSync(file, 'UTF-8'))
+
+    if (swagger.swagger !== '2.0') {
+        return console.log(
+            `Specification for ${file} has not the Swagger v2 format.\n` +
+            'TypeScript generation currently is only supported for Swagger v2.0.'
+        )
+    }
+
     const definitions = CodeGen.getTypescriptCode({
         className: 'SauceLabs',
         swagger,
@@ -56,12 +64,13 @@ fs.readdir(path.join(__dirname, '../apis'), (err, files) => {
     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
-        })
+        ...files
+            .map((api) => generateTypingsForApi('./apis/' + api))
+            .filter(Boolean)
+            .map((typings) => {
+                result += `\n\n ${typings.defintions}`
+                return typings.methods
+            })
     ].join('\n\n')
 
     result += `\n\ndeclare class SauceLabs {\n
diff --git a/src/cli.js b/src/cli.js
index 80292da..617945e 100644
--- a/src/cli.js
+++ b/src/cli.js
@@ -56,7 +56,12 @@ export const run = () => {
                 return process.exit(1)
             }
 
-            return api
+            /**
+             * only return for testing purposes
+             */
+            if (process.env.JEST_WORKER_ID) {
+                return api
+            }
         })
     }
 
diff --git a/src/constants.js b/src/constants.js
index f0fd2df..6c0e1d8 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -14,12 +14,13 @@ export const SAUCE_VERSION_NOTE = `node-saucelabs v${version}\nSauce Connect v${
 const protocols = [
     require('../apis/sauce.json'),
     require('../apis/rdc.json'),
-    require('../apis/performance.json')
+    require('../apis/performance.json'),
+    require('../apis/testrunner.json')
 ]
 
 const protocolFlattened = new Map()
 const parametersFlattened = new Map()
-for (const { paths, parameters, host, basePath } of protocols) {
+for (const { paths, parameters, basePath, servers } of protocols) {
     for (const [name, description] of Object.entries(parameters || {})) {
         parametersFlattened.set(name, description)
     }
@@ -45,7 +46,7 @@ for (const { paths, parameters, host, basePath } of protocols) {
 
             protocolFlattened.set(
                 commandName,
-                { method, endpoint, description, host, basePath }
+                { method, endpoint, description, servers, basePath }
             )
         }
     }
@@ -71,17 +72,20 @@ export const DEFAULT_OPTIONS = {
     proxy: process.env.HTTPS_PROXY || process.env.HTTP_PROXY
 }
 
-export const REGION_MAPPING = {
-    'us': 'us-west-1.', // default endpoint
+export const ASSET_REGION_MAPPING = {
+    'us': '',
     'eu': 'eu-central-1.',
-    'us-west-1': 'us-west-1.',
-    'eu-central-1': 'eu-central-1.'
+    'us-west-1': '',
+    'us-east-1': 'us-east-1.',
+    'eu-central-1': 'eu-central-1.',
+    'staging': 'staging.'
 }
 
 export const SYMBOL_INSPECT = Symbol.for('nodejs.util.inspect.custom')
 export const SYMBOL_TOSTRING = Symbol.toStringTag
 export const SYMBOL_ITERATOR = Symbol.iterator
 export const TO_STRING_TAG = 'SauceLabs API Client'
+export const DEFAULT_PROTOCOL = 'https://'
 
 export const USAGE = `Sauce Labs API CLI
 
diff --git a/src/index.js b/src/index.js
index 8b6627b..629c06f 100644
--- a/src/index.js
+++ b/src/index.js
@@ -4,11 +4,12 @@ import path from 'path'
 import { spawn } from 'child_process'
 
 import got from 'got'
+import FormData from 'form-data'
 import BinWrapper from 'bin-wrapper'
 import { camelCase } from 'change-case'
 
 import {
-    createHMAC, getSauceEndpoint, toString, getParameters,
+    createHMAC, getAPIHost, getAssetHost, toString, getParameters,
     isValidType, createProxyAgent, getStrictSsl
 } from './utils'
 import {
@@ -41,6 +42,7 @@ export default class SauceLabs {
          * public fields
          */
         this.region = this._options.region
+        this.tld = this._options.tld
         this.headless = this._options.headless
 
         return new Proxy({
@@ -85,6 +87,13 @@ export default class SauceLabs {
             return ::this._startSauceConnect
         }
 
+        /**
+         * have special implementations for certain operations
+         */
+        if (propName === 'uploadJobAssets') {
+            return ::this._uploadJobAssets
+        }
+
         /**
          * allow to return publicly registered class properties
          */
@@ -111,7 +120,7 @@ export default class SauceLabs {
         }
 
         return async (...args) => {
-            const { description, method, endpoint, host, basePath } = PROTOCOL_MAP.get(propName)
+            const { description, method, endpoint, servers, basePath } = PROTOCOL_MAP.get(propName)
             const params = getParameters(description.parameters)
             const pathParams = params.filter(p => p.in === 'path')
 
@@ -134,7 +143,7 @@ export default class SauceLabs {
              * check for body param (as last parameter as we don't expect request
              * parameters for non idempotent requests)
              */
-            let bodyOption = params.find(p => p.in === 'body')
+            let bodyOption = params.find(p => p.in === 'body') || description.requestBody
                 ? args[pathParams.length]
                 : null
 
@@ -173,7 +182,7 @@ export default class SauceLabs {
             /**
              * make request
              */
-            const uri = getSauceEndpoint(host + basePath, this._options.region, this._options.headless) + url
+            const uri = getAPIHost(servers, basePath, this._options) + url
             try {
                 const response = await this._api[method](uri, {
                     ...(
@@ -202,8 +211,8 @@ export default class SauceLabs {
         }
 
         const sauceConnectVersion = argv.scVersion || DEFAULT_SAUCE_CONNECT_VERSION
-        const { host, basePath } = PROTOCOL_MAP.get('listJobs')
-        const restUrl = getSauceEndpoint(host + basePath, this._options.region, this._options.headless) + '/v1'
+        const { servers, basePath } = PROTOCOL_MAP.get('listJobs')
+        const restUrl = getAPIHost(servers, basePath, this._options) + '/v1'
         const args = Object.entries(argv)
             /**
              * filter out yargs, yargs params and custom parameters
@@ -285,7 +294,7 @@ export default class SauceLabs {
         }
 
         const hmac = await createHMAC(this.username, this._accessKey, jobId)
-        const host = getSauceEndpoint('saucelabs.com', this._options.region, this._options.headless, 'https://assets.')
+        const host = getAssetHost(this._options)
         const responseType = assetName.endsWith('mp4') ? 'buffer' : 'text'
         const uri = `${host}/jobs/${jobId}/${assetName}?ts=${Date.now()}&auth=${hmac}`
 
@@ -319,4 +328,37 @@ export default class SauceLabs {
             throw new Error(`There was an error downloading asset ${assetName}: ${err.message}`)
         }
     }
+
+    async _uploadJobAssets (jobId, { files = [] } = {}) {
+        if (files.length === 0) {
+            throw new Error('No files to upload selected')
+        }
+
+        const { servers, basePath, method, endpoint } = PROTOCOL_MAP.get('uploadJobAssets')
+        const uri = getAPIHost(servers, basePath, this._options) + endpoint.replace('{jobId}', jobId)
+        const body = new FormData()
+
+        for (const filePath of files) {
+            const readStream = fs.createReadStream(filePath.startsWith('/')
+                ? filePath
+                : path.join(process.cwd(), filePath)
+            )
+            body.append('file[]', readStream)
+        }
+
+        try {
+            const res = await this._api(uri, { method, body })
+
+            /**
+             * parse asset as json if proper content type is given
+             */
+            if (res.headers['content-type'] === 'application/json' && typeof res.body === 'string') {
+                res.body = JSON.parse(res.body)
+            }
+
+            return res.body
+        } catch (err) {
+            throw new Error(`There was an error uploading assets: ${err.message}`)
+        }
+    }
 }
diff --git a/src/utils.js b/src/utils.js
index e4a5435..8f1393c 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -1,8 +1,9 @@
 import crypto from 'crypto'
 import tunnel from 'tunnel'
 import url from 'url'
+import path from 'path'
 
-import { REGION_MAPPING, TO_STRING_TAG, PARAMETERS_MAP } from './constants'
+import { ASSET_REGION_MAPPING, TO_STRING_TAG, PARAMETERS_MAP, DEFAULT_PROTOCOL } from './constants'
 
 /**
  * Create HMAC token to receive job assets
@@ -33,27 +34,58 @@ export function createHMAC (username, key, jobId) {
  * @param  {string}  protocol  protcol to be used
  * @return {string}            endpoint base url (e.g. `https://us-east1.headless.saucelabs.com`)
  */
-export function getSauceEndpoint (hostname, region, headless, protocol = 'https://') {
-    const dcRegion = REGION_MAPPING[region] ? region : 'us'
-    let locale = headless ? 'us-east-1.' : REGION_MAPPING[dcRegion]
-    let subdomain = ''
+export function getAPIHost (servers, basePath, options) {
+    /**
+     * allows to set an arbitrary host (for internal use only)
+     */
+    const apiUrl = options.host || servers[0].url
+    let host = DEFAULT_PROTOCOL + path.join(apiUrl.replace(DEFAULT_PROTOCOL, ''), basePath)
 
     /**
-     * check if endpoint base has subdomain
-     * e.g. api.saucelabs.com
+     * allow short region handles to stay backwards compatible
+     * ToDo(Christian): consider to remove when making a breaking update
      */
-    if (!headless && hostname.split('.').length > 2) {
-        subdomain = hostname.split('.')[0] + '.'
-        hostname = hostname.split('.').slice(-2).join('.')
-        // RDC only has 1 endpoint for both DC's, so if the url contains `testobject` clear the `locale`
-        locale = hostname.includes('testobject') ? '' : locale
-    } else if (!headless && region === 'us') {
-        locale = ''
-    } else if (locale === 'us-west-1.') { // us-west-1 is currently not a valid endpoint for Sauce
-        locale = ''
+    if (options.region) {
+        if (options.region === 'us') options.region = 'us-west-1'
+        if (options.region === 'eu') options.region = 'eu-central-1'
+        if (options.headless) options.region = 'us-east-1'
+    }
+
+    for (const [option, value] of Object.entries(servers[0].variables)) {
+        const hostOption = options[option] || value.default
+
+        /**
+         * check if option is valid
+         */
+        if (!value.enum.includes(hostOption)) {
+            throw new Error(`Option "${option}" contains invalid value ("${hostOption}"), allowed are: ${value.enum.join(', ')}`)
+        }
+
+        host = host.replace(`{${option}}`, hostOption)
+    }
+
+    return host
+}
+
+/**
+ * helper to generate host for assets, like:
+ * https://assets.saucelabs.com/jobs/<jobId>/selenium-server.log
+ * https://assets.eu-central-1.saucelabs.com/jobs/<jobId>/log.json
+ * https://assets.us-east-1.saucelabs.com/jobs/<jobId>/log.json
+ * https://assets.staging.saucelabs.net/jobs/<jobId>/log.json
+ */
+export function getAssetHost (options) {
+    if (options.headless) {
+        options.region = 'us-east-1'
+    }
+
+    if (options.region === 'staging') {
+        options.tld = options.tld || 'net'
     }
 
-    return protocol + subdomain + locale + hostname
+    const tld = options.tld || 'com'
+    const region = ASSET_REGION_MAPPING[options.region] || ''
+    return `https://assets.${region}saucelabs.${tld}`
 }
 
 /**


Test patch:
diff --git a/apis/testrunner.json b/apis/testrunner.json
new file mode 100644
index 0000000..9e4322f
--- /dev/null
+++ b/apis/testrunner.json
@@ -0,0 +1,141 @@
+{
+  "openapi":"3.0.0",
+  "info":{
+    "description":"A service that orchestrates the Sauce cloud as well as test runner packages.",
+    "version":"0.1.7",
+    "title":"Testrunner Orchestrator",
+    "termsOfService":"https://saucelabs.com/terms-of-service",
+    "contact":{
+      "name":"Open Source Program Office at Sauce",
+      "email":"opensource@saucelabs.com",
+      "url":"https://saucelabs.com"
+    }
+  },
+  "externalDocs":{
+    "description":"Sauce Labs Wiki",
+    "url":"https://wiki.saucelabs.com"
+  },
+  "basePath": "/v1/testrunner",
+  "servers":[
+    {
+      "url":"https://api.{region}.saucelabs.{tld}",
+      "variables":{
+        "region":{
+          "default":"us-west-1",
+          "description":"region of datacenter",
+          "enum":[
+            "us-west-1",
+            "eu-central-1",
+            "staging"
+          ]
+        },
+        "tld":{
+          "default":"com",
+          "description":"internal or external API",
+          "enum":[
+            "net",
+            "com"
+          ]
+        }
+      }
+    }
+  ],
+  "tags":[
+    {
+      "name":"Log Files",
+      "description":"Log files that were captured while the test was running."
+    }
+  ],
+  "parameters": {
+    "jobId": {
+      "in":"path",
+      "name":"jobId",
+      "required":true,
+      "description":"id of the job that was run on Sauce Labs",
+      "schema":{
+        "type":"string"
+      }
+    },
+    "files": {
+      "description": "s path to upload and attach to your job",
+      "in": "path",
+      "name": "files",
+      "required": false,
+      "type": "string"
+    }
+  },
+  "components":{
+    "securitySchemes":{
+      "bearerAuth":{
+        "type":"http",
+        "scheme":"bearer",
+        "bearerFormat":"JWT"
+      },
+      "basicAuth":{
+        "type":"http",
+        "scheme":"basic"
+      }
+    }
+  },
+  "paths":{
+    "/jobs/{jobId}/assets":{
+      "put":{
+        "operationId":"uploadJobAssets",
+        "summary":"Upload job assets",
+        "externalDocs":{
+          "description":"TBD",
+          "url":"https://wiki.saucelabs.com"
+        },
+        "tags":[
+          "Job"
+        ],
+        "parameters":[
+          {
+            "$ref": "#/parameters/jobId"
+          },
+          {
+            "$ref": "#/parameters/files"
+          }
+        ],
+        "requestBody":{
+          "content":{
+            "multipart/form-data":{
+              "schema":{
+                "type":"object",
+                "properties":{
+                  "file":{
+                    "type":"array",
+                    "items":{
+                      "type":"string",
+                      "format":"binary"
+                    }
+                  }
+                }
+              }
+            }
+          }
+        },
+        "responses":{
+          "200":{
+            "description":"Object containing list of uploaded files.",
+            "content":{
+              "application/json":{
+                "schema":{
+                  "type":"array",
+                  "items":{
+                    "type":"string",
+                    "description":"uploaded file name"
+                  },
+                  "example":[
+                    "log.json",
+                    "video.mp4"
+                  ]
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/tests/__mocks__/form-data.js b/tests/__mocks__/form-data.js
new file mode 100644
index 0000000..f3a883f
--- /dev/null
+++ b/tests/__mocks__/form-data.js
@@ -0,0 +1,9 @@
+const instances = []
+
+export default class FormDataMock {
+    constructor () {
+        this.append = jest.fn()
+        this.instances = instances
+        instances.push(this)
+    }
+}
diff --git a/tests/__snapshots__/index.test.js.snap b/tests/__snapshots__/index.test.js.snap
index 3d5c439..3ff82ed 100644
--- a/tests/__snapshots__/index.test.js.snap
+++ b/tests/__snapshots__/index.test.js.snap
@@ -9,7 +9,7 @@ Array [
       "--tunnel-identifier=my-tunnel",
       "--user=foo",
       "--api-key=bar",
-      "--rest-url=https://us-east-1.saucelabs.com/rest/v1",
+      "--rest-url=https://api.us-east-1.saucelabs.com/rest/v1",
     ],
   ],
 ]
diff --git a/tests/index.test.js b/tests/index.test.js
index 2286815..ba1f050 100644
--- a/tests/index.test.js
+++ b/tests/index.test.js
@@ -1,6 +1,7 @@
 import util from 'util'
 import got from 'got'
 import { spawn } from 'child_process'
+import FormData from 'form-data'
 
 import SauceLabs from '../src'
 import { instances } from 'bin-wrapper'
@@ -59,6 +60,12 @@ test('should return public properties', () => {
     expect(api._accessKey).toBe(undefined)
 })
 
+test('should return nothing if Symbol was accessed', () => {
+    const sym = Symbol('foo')
+    const api = new SauceLabs({ user: 'foo', key: 'bar' })
+    expect(typeof api[sym]).toBe('undefined')
+})
+
 test('should grab username and access key from env variable', () => {
     jest.resetModules()
     process.env.SAUCE_USERNAME = 'barfoo'
@@ -100,7 +107,7 @@ test('should allow to call an API method with param in url', async () => {
     const api = new SauceLabs({ user: 'foo', key: 'bar' })
     await api.getUserConcurrency('someuser')
     expect(got.mock.calls[0][0])
-        .toBe('https://saucelabs.com/rest/v1.1/users/someuser/concurrency')
+        .toBe('https://api.us-west-1.saucelabs.com/rest/v1.1/users/someuser/concurrency')
 })
 
 test('should allow to call an API method with param as option', async () => {
@@ -112,7 +119,7 @@ test('should allow to call an API method with param as option', async () => {
 
     const uri = got.mock.calls[0][0]
     const req = got.mock.calls[0][1]
-    expect(uri).toBe('https://saucelabs.com/rest/v1.1/someuser/jobs')
+    expect(uri).toBe('https://api.us-west-1.saucelabs.com/rest/v1.1/someuser/jobs')
     expect(req.searchParams).toEqual({
         limit: 123,
         full: true
@@ -248,6 +255,58 @@ test('should put asset into file as json file', async () => {
     expect(fs.writeFileSync).toBeCalledWith('/asset.json', undefined, { encoding: 'utf8' })
 })
 
+test('should allow to upload files', async () => {
+    fs.createReadStream.mockReturnValue({
+        name: '/somefile',
+        path: 'somepath',
+    })
+
+    const api = new SauceLabs({ user: 'foo', key: 'bar' })
+    const body = { foo: 'bar' }
+    got.mockReturnValue(Promise.resolve({
+        headers: {
+            'content-type': 'application/json'
+        },
+        body: JSON.stringify(body)
+    }))
+    const result = await api.uploadJobAssets('some-id', {
+        files: ['log.json', 'selenium-server.json']
+    })
+
+    const { instances } = new FormData()
+    expect(instances[0].append).toBeCalledTimes(2)
+    expect(instances[0].append).toBeCalledWith('file[]', {name: '/somefile', path: 'somepath'})
+
+    const uri = got.mock.calls[0][0]
+    expect(uri).toBe('https://api.us-west-1.saucelabs.com/v1/testrunner/jobs/some-id/assets')
+
+    expect(result).toEqual(body)
+})
+
+test('should throw if custom error if upload fails', async () => {
+    fs.createReadStream.mockReturnValue({
+        name: '/somefile',
+        path: 'somepath',
+    })
+
+    const api = new SauceLabs({ user: 'foo', key: 'bar' })
+    got.mockReturnValue(Promise.reject(new Error('uups')))
+    const result = await api.uploadJobAssets('some-id', {
+        files: ['log.json', '/selenium-server.json']
+    })
+        .catch((err) => err)
+
+    expect(result.message).toBe('There was an error uploading assets: uups')
+})
+
+test('should not even try to upload if no files were selected', async () => {
+    const api = new SauceLabs({ user: 'foo', key: 'bar' })
+    const result = await api.uploadJobAssets('some-id')
+        .catch((err) => err)
+
+    expect(result.message).toBe('No files to upload selected')
+})
+
 describe('startSauceConnect', () => {
     it('should start sauce connect with proper parsed args', async () => {
         const logs = []
diff --git a/tests/typings/test.ts b/tests/typings/test.ts
index dcdf869..77df682 100644
--- a/tests/typings/test.ts
+++ b/tests/typings/test.ts
@@ -15,7 +15,7 @@ async function foobar () {
     const sc = await api.startSauceConnect({
         scVersion: '4.5.4',
         tunnelIdentifier: '1234',
-        logger: (output) => console.log(output)
+        logger: (output: string) => console.log(output)
     })
     sc.cp.pid
     await sc.close()
diff --git a/tests/utils.test.js b/tests/utils.test.js
index 7a12934..b8a8957 100644
--- a/tests/utils.test.js
+++ b/tests/utils.test.js
@@ -1,35 +1,66 @@
-import { createHMAC, getSauceEndpoint, toString, isValidType, createProxyAgent, getStrictSsl } from '../src/utils'
 import http from 'http'
 import https from 'https'
+import sauceAPI from '../apis/sauce.json'
+import rdcAPI from '../apis/rdc.json'
+import {
+    createHMAC, getAPIHost, toString, isValidType, getStrictSsl, getAssetHost, createProxyAgent
+} from '../src/utils'
 
 test('createHMAC', async () => {
     expect(await createHMAC('foo', 'bar', 'loo123'))
         .toBe('b975a69fa344ed43e1035b0698788705')
 })
 
-test('getSauceEndpoint', () => {
-    expect(getSauceEndpoint('saucelabs.com', 'us', false))
-        .toBe('https://saucelabs.com')
-    expect(getSauceEndpoint('saucelabs.com', 'us-west-1', false))
-        .toBe('https://saucelabs.com')
-    expect(getSauceEndpoint('saucelabs.com', 'I_DONT_EXIST', false))
-        .toBe('https://saucelabs.com')
-    expect(getSauceEndpoint('saucelabs.com', 'us', false, 'http://'))
-        .toBe('http://saucelabs.com')
-    expect(getSauceEndpoint('saucelabs.com', 'eu', false))
-        .toBe('https://eu-central-1.saucelabs.com')
-    expect(getSauceEndpoint('saucelabs.com', 'eu-central-1', false))
-        .toBe('https://eu-central-1.saucelabs.com')
-    expect(getSauceEndpoint('saucelabs.com', 'us', true))
-        .toBe('https://us-east-1.saucelabs.com')
-    expect(getSauceEndpoint('api.saucelabs.com', 'us', false))
-        .toBe('https://api.us-west-1.saucelabs.com')
-    expect(getSauceEndpoint('api.saucelabs.com', 'eu', false))
-        .toBe('https://api.eu-central-1.saucelabs.com')
-    expect(getSauceEndpoint('app.testobject.com', 'eu', false))
-        .toBe('https://app.testobject.com')
-    expect(getSauceEndpoint('app.testobject.com', 'us', false))
-        .toBe('https://app.testobject.com')
+test('getAPIHost', () => {
+    expect(getAPIHost(sauceAPI.servers, sauceAPI.basePath, {}))
+        .toBe('https://api.us-west-1.saucelabs.com/rest')
+    expect(getAPIHost(sauceAPI.servers, sauceAPI.basePath, { region: 'eu' }))
+        .toBe('https://api.eu-central-1.saucelabs.com/rest')
+    expect(getAPIHost(sauceAPI.servers, sauceAPI.basePath, { region: 'eu-central-1' }))
+        .toBe('https://api.eu-central-1.saucelabs.com/rest')
+    expect(getAPIHost(sauceAPI.servers, sauceAPI.basePath, { region: 'us' }))
+        .toBe('https://api.us-west-1.saucelabs.com/rest')
+    expect(getAPIHost(sauceAPI.servers, sauceAPI.basePath, { region: 'us-west-1' }))
+        .toBe('https://api.us-west-1.saucelabs.com/rest')
+    expect(getAPIHost(sauceAPI.servers, sauceAPI.basePath, { region: 'us-east-1' }))
+        .toBe('https://api.us-east-1.saucelabs.com/rest')
+    expect(getAPIHost(sauceAPI.servers, sauceAPI.basePath, { region: 'us-west-1', headless: true }))
+        .toBe('https://api.us-east-1.saucelabs.com/rest')
+    expect(() => getAPIHost(sauceAPI.servers, sauceAPI.basePath, { region: 'foobar' }))
+        .toThrow()
+
+    expect(getAPIHost(sauceAPI.servers, sauceAPI.basePath, { tld: 'net' }))
+        .toBe('https://api.us-west-1.saucelabs.net/rest')
+    expect(getAPIHost(sauceAPI.servers, sauceAPI.basePath, { tld: 'net', region: 'staging' }))
+        .toBe('https://api.staging.saucelabs.net/rest')
+    expect(() => getAPIHost(sauceAPI.servers, sauceAPI.basePath, { tld: 'info' }))
+        .toThrow()
+
+    expect(getAPIHost(rdcAPI.servers, rdcAPI.basePath, {}))
+        .toBe('https://app.testobject.com/api/rest')
+    expect(getAPIHost(rdcAPI.servers, rdcAPI.basePath, { region: 'us-west-1', headless: true }))
+        .toBe('https://app.testobject.com/api/rest')
+})
+
+test('getAssetHost', () => {
+    expect(getAssetHost({}))
+        .toBe('https://assets.saucelabs.com')
+    expect(getAssetHost({ region: 'us' }))
+        .toBe('https://assets.saucelabs.com')
+    expect(getAssetHost({ region: 'us-west-1' }))
+        .toBe('https://assets.saucelabs.com')
+    expect(getAssetHost({ region: 'eu' }))
+        .toBe('https://assets.eu-central-1.saucelabs.com')
+    expect(getAssetHost({ region: 'eu-central-1' }))
+        .toBe('https://assets.eu-central-1.saucelabs.com')
+    expect(getAssetHost({ headless: true }))
+        .toBe('https://assets.us-east-1.saucelabs.com')
+    expect(getAssetHost({ headless: true, region: 'eu' }))
+        .toBe('https://assets.us-east-1.saucelabs.com')
+    expect(getAssetHost({ region: 'staging' }))
+        .toBe('https://assets.staging.saucelabs.net')
+    expect(getAssetHost({ region: 'staging', tld: 'com' }))
+        .toBe('https://assets.staging.saucelabs.com')
 })
 
 test('toString', () => {