Repository: saucelabs/node-saucelabs
Pull request #94 (2020-09-01 19:47:00)

Expose ondemand url from client object
To make it easier for consumer to set up the correct WebDriver endpoint we should expose it from the client.

ref: modernweb-dev/web#472


Patch:
diff --git a/README.md b/README.md
index 9ff799f..8da7f0f 100644
--- a/README.md
+++ b/README.md
@@ -128,6 +128,9 @@ import SauceLabs from 'saucelabs';
     // using constructor options
     // const myAccount = new SauceLabs({ user: "YOUR-USER", key: "YOUR-ACCESS-KEY"});
 
+    // get full webdriver url from the client depending on region:
+    console.log(myAccount.webdriverEndpoint) // outputs "https://ondemand.us-west-1.saucelabs.com/"
+
     // get job details of last run job
     const jobs = await myAccount.listJobs(
         process.env.SAUCE_USERNAME,
@@ -202,6 +205,22 @@ import SauceLabs from 'saucelabs';
 
 > You may wonder why `listJobs` requires a `username` as first parameter since you've already defined the process.env. The reason for this is that Sauce Labs supports a concept of Team Accounts, so-called sub-accounts, grouped together. As such functions like the mentioned could list jobs not only for the requesting account, but also for the individual team account. Learn more about it [here](https://wiki.saucelabs.com/display/DOCS/Managing+Team+Members+and+Accounts)
 
+### `webdriverEndpoint` property
+
+You can use the `webdriverEndpoint` property of the client to get the full WebDriver endpoint to connect to Sauce Labs, e.g.:
+
+```js
+const myAccount = new SauceLabs({
+    user: "YOUR-USER",
+    key: "YOUR-ACCESS-KEY",
+    region: 'eu' // run in EU datacenter
+});
+
+// get full webdriver url from the client depending on `region` and `headless` option:
+console.log(myAccount.webdriverEndpoint);
+// outputs: "https://ondemand.eu-central-1.saucelabs.com/"
+```
+
 ## Breaking changes from v1 to v2
 
 Public APIs have changed from v1 to v2. Methods in v1 accepted a `callback` trailing parameter which is no more available with v2, instead all methods now return a Promise which can be `awaited` or `then`'d.
diff --git a/scripts/generate-typings.js b/scripts/generate-typings.js
index 9278738..17a6d25 100644
--- a/scripts/generate-typings.js
+++ b/scripts/generate-typings.js
@@ -74,7 +74,13 @@ fs.readdir(path.join(__dirname, '../apis'), (err, files) => {
     ].join('\n\n')
 
     result += `\n\ndeclare class SauceLabs {\n
-    constructor(options: SauceLabsOptions)\n\n
+    constructor(options: SauceLabsOptions)
+
+    username: string;
+    region: string;
+    tld: string;
+    headless: boolean;
+    webdriverEndpoint: string;\n\n
     ${methods}
 }
 
diff --git a/src/index.js b/src/index.js
index abb2365..739f455 100644
--- a/src/index.js
+++ b/src/index.js
@@ -10,7 +10,7 @@ import { camelCase } from 'change-case'
 
 import {
     createHMAC, getAPIHost, getAssetHost, toString, getParameters,
-    isValidType, createProxyAgent, getStrictSsl
+    isValidType, createProxyAgent, getStrictSsl, getRegionSubDomain
 } from './utils'
 import {
     PROTOCOL_MAP, DEFAULT_OPTIONS, SYMBOL_INSPECT, SYMBOL_TOSTRING,
@@ -44,13 +44,15 @@ export default class SauceLabs {
         this.region = this._options.region
         this.tld = this._options.tld
         this.headless = this._options.headless
+        this.webdriverEndpoint = `https://ondemand.${getRegionSubDomain(options)}.saucelabs.com/`
 
         return new Proxy({
             username: this.username,
             key: `XXXXXXXX-XXXX-XXXX-XXXX-XXXXXX${(this._accessKey || '').slice(-6)}`,
             region: this._options.region,
             headless: this._options.headless,
-            proxy: this._options.proxy
+            proxy: this._options.proxy,
+            webdriverEndpoint: this.webdriverEndpoint
         }, { get: ::this.get })
     }
 
@@ -226,7 +228,7 @@ export default class SauceLabs {
         args.push(`--user=${this.username}`)
         args.push(`--api-key=${this._accessKey}`)
 
-        if (!args.some(arg => arg.startsWith("--rest-url"))) {
+        if (!args.some(arg => arg.startsWith('--rest-url'))) {
             args.push(`--rest-url=${restUrl}`)
         }
 
diff --git a/src/utils.js b/src/utils.js
index e7a76c7..3ecb5d0 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -1,7 +1,6 @@
 import crypto from 'crypto'
 import tunnel from 'tunnel'
 import url from 'url'
-import path from 'path'
 
 import { ASSET_REGION_MAPPING, TO_STRING_TAG, PARAMETERS_MAP, DEFAULT_PROTOCOL } from './constants'
 
@@ -26,12 +25,25 @@ export function createHMAC (username, key, jobId) {
     }))
 }
 
+/**
+ * Translate region shorthandle option into the full region
+ * @param  {object}  options  client options
+ * @return {string}           full region
+ */
+export function getRegionSubDomain (options = {}) {
+    let region = options.region || 'us-west-1'
+
+    if (options.region === 'us') region = 'us-west-1'
+    if (options.region === 'eu') region = 'eu-central-1'
+    if (options.headless) region = 'us-east-1'
+    return region
+}
+
 /**
  * get sauce API url
- * @param  {string}  hostname  host name of the API that is being used
- * @param  {string}  region    region of the datacenter
- * @param  {boolean} headless  true if job is running on headless instance
- * @param  {string}  protocol  protcol to be used
+ * @param  {string}  servers   OpenAPI spec servers property
+ * @param  {string}  basePath  OpenAPI spec base path
+ * @param  {object}  options   client options
  * @return {string}            endpoint base url (e.g. `https://us-east1.headless.saucelabs.com`)
  */
 export function getAPIHost (servers, basePath, options) {
@@ -46,9 +58,7 @@ export function getAPIHost (servers, basePath, options) {
      * ToDo(Christian): consider to remove when making a breaking update
      */
     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'
+        options.region = getRegionSubDomain(options)
     }
 
     for (const [option, value] of Object.entries(servers[0].variables)) {


Test patch:
diff --git a/tests/index.test.js b/tests/index.test.js
index a1be062..60727d6 100644
--- a/tests/index.test.js
+++ b/tests/index.test.js
@@ -30,16 +30,36 @@ beforeEach(() => {
     spawn.mockClear()
     process.kill = jest.fn()
 })
-
 test('should be inspectable', () => {
     const api = new SauceLabs({ user: 'foo', key: 'bar' })
+    /**
+     * we can't use snapshotting here as the result varies
+     * between different node versions
+     */
     expect(util.inspect(api)).toContain(`{
   username: 'foo',
   key: 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXbar',
   region: 'us',
   headless: false,
-  proxy: undefined
-}`)
+  proxy: undefined`)
+})
+
+test('should expose a webdriverEndpoint', () => {
+    const api = new SauceLabs({ user: 'foo', key: 'bar' })
+    expect(api.webdriverEndpoint)
+        .toBe('https://ondemand.us-west-1.saucelabs.com/')
+
+    const api2 = new SauceLabs({ user: 'foo', key: 'bar', region: 'eu' })
+    expect(api2.webdriverEndpoint)
+        .toBe('https://ondemand.eu-central-1.saucelabs.com/')
+
+    const api3 = new SauceLabs({ user: 'foo', key: 'bar', region: 'eu', headless: true })
+    expect(api3.webdriverEndpoint)
+        .toBe('https://ondemand.us-east-1.saucelabs.com/')
+
+    const api4 = new SauceLabs({ user: 'foo', key: 'bar', region: 'us-central-3' })
+    expect(api4.webdriverEndpoint)
+        .toBe('https://ondemand.us-central-3.saucelabs.com/')
 })
 
 test('should have to string tag', () => {
diff --git a/tests/typings/test.ts b/tests/typings/test.ts
index 77df682..0ddbbab 100644
--- a/tests/typings/test.ts
+++ b/tests/typings/test.ts
@@ -22,4 +22,10 @@ async function foobar () {
 
     await api.downloadJobAsset( 'job_id', 'video.mp4')
     await api.downloadJobAsset( 'job_id', 'video.mp4', './video.mp4')
+
+    api.username.slice(1, 1)
+    api.region.slice(1, 1)
+    api.tld.slice(1, 1)
+    api.headless.valueOf()
+    api.webdriverEndpoint.slice(1, 1)
 }