diff --git a/README.md b/README.md index 4d8d3695..4669c6d0 100644 --- a/README.md +++ b/README.md @@ -104,12 +104,12 @@ or start Sauce Connect Proxy in EU datacenter: # start Sauce Connect tunnel for eu-central-1 region $ sl sc --region eu --tunnel-name "my-tunnel" # run a specific Sauce Connect version -$ sl sc --scVersion 4.9.1 +$ sl sc --scVersion 5.2.2 # see all available Sauce Connect parameters via: $ sl sc --help ``` -You can see all available Sauce Connect parameters on the [Sauce Labs Docs](https://docs.saucelabs.com/dev/cli/sauce-connect-proxy/). +You can see all available Sauce Connect parameters on the [Sauce Labs Docs](https://docs.saucelabs.com/dev/cli/sauce-connect-5/run/). ### As NPM Package @@ -179,7 +179,7 @@ import SauceLabs from 'saucelabs'; */ logger: (stdout) => console.log(stdout), /** - * see all available parameters here: https://docs.saucelabs.com/dev/cli/sauce-connect-proxy/ + * see all available parameters here: https://docs.saucelabs.com/dev/cli/sauce-connect-5/run/ * all parameters have to be applied camel cased instead of with hyphens, e.g. * to apply the `--tunnel-name` parameter, set: */ @@ -219,6 +219,7 @@ const myAccount = new SauceLabs({ user: 'YOUR-USER', key: 'YOUR-ACCESS-KEY', region: 'eu', // run in EU datacenter + tunnelName: 'my-tunnel', }); // get full webdriver url from the client depending on `region` option: diff --git a/apis/sauce.json b/apis/sauce.json index 675a3805..dadb030f 100644 --- a/apis/sauce.json +++ b/apis/sauce.json @@ -458,81 +458,33 @@ }, "type": "object" }, - "SauceConnectDownloadInfo": { + "SauceConnectDownload": { "type": "object", "properties": { - "download_url": { - "type": "string" - }, - "sha1": { - "type": "string", - "nullable": true - }, - "sha256": { - "type": "string", - "nullable": true - }, - "version": { - "type": "string", - "nullable": true - } - } - }, - "SauceConnectByPlatform": { - "type": "object", - "properties": { - "linux": { - "$ref": "#/definitions/SauceConnectDownloadInfo", - "nullable": true - }, - "linux-arm64": { - "$ref": "#/definitions/SauceConnectDownloadInfo", - "nullable": true - }, - "win32": { - "$ref": "#/definitions/SauceConnectDownloadInfo", - "nullable": true - }, - "osx": { - "$ref": "#/definitions/SauceConnectDownloadInfo" - } - } - }, - "SauceConnectVersions": { - "type": "object", - "properties": { - "latest_version": { - "type": "string" - }, - "client_version": { - "type": "string" - }, - "status": { - "type": "string", - "enum": ["LATEST", "UPGRADE", "PRERELEASE", "UNKNOWN", "EOL"] - }, - "info_url": { - "type": "string" - }, - "download_url": { - "type": "string" - }, - "sha1": { - "type": "string", - "nullable": true - }, - "sha256": { - "type": "string", - "nullable": true - } - }, - "downloads": { - "$ref": "#/definitions/SauceConnectByPlatform" - }, - "all_downloads": { - "type": "array", - "items": { - "$ref": "#/definitions/SauceConnectByPlatform" + "download": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "version": { + "type": "string" + }, + "checksums": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "algorithm": { + "type": "string" + } + } + } + } + } } } }, @@ -820,17 +772,24 @@ "required": true, "type": "string" }, - "clientHost": { - "description": "SC client host OS and CPU arch", + "clientArch": { + "description": "SC client host CPU arch", "in": "query", - "name": "client_host", + "name": "arch", + "required": false, + "type": "string" + }, + "clientOS": { + "description": "SC client host OS", + "in": "query", + "name": "os", "required": false, "type": "string" }, "clientVersion": { "description": "SC client version", "in": "query", - "name": "client_version", + "name": "version", "required": false, "type": "string" }, @@ -1768,25 +1727,26 @@ "tags": ["Tunnel"] } }, - "/v1/public/tunnels/info/versions": { + "/v1/public/tunnels/sauce-connect/download": { "get": { - "operationId": "sc_versions", + "description": "Get Sauce Connect download information for the latest version", + "operationId": "sc_download", "parameters": [ { - "$ref": "#/parameters/clientVersion" + "$ref": "#/parameters/clientArch" }, { - "$ref": "#/parameters/clientHost" + "$ref": "#/parameters/clientOS" }, { - "$ref": "#/parameters/all" + "$ref": "#/parameters/clientVersion" } ], "responses": { "200": { - "description": "Tunnels", + "description": "download", "schema": { - "$ref": "#/definitions/SauceConnectVersions" + "$ref": "#/definitions/SauceConnectDownload" } }, "default": { @@ -1796,7 +1756,6 @@ } } }, - "summary": "Get tunnels for the user or all the users in the team", "tags": ["Tunnel"] } }, diff --git a/docs/interface.md b/docs/interface.md index 48278315..d6990fa4 100644 --- a/docs/interface.md +++ b/docs/interface.md @@ -185,12 +185,12 @@ The following commands are available via package or cli tool: - GET /v1/public/tunnels/info/versions
- Get tunnels for the user or all the users in the team + GET /v1/public/tunnels/sauce-connect/download
+ Get Sauce Connect download information for the latest version

Example:

- api.scVersions({ ...options }) + api.scDownload({ ...options })

Options

- + diff --git a/e2e/sc.test.js b/e2e/sc.test.js index 351602df..0dff0934 100644 --- a/e2e/sc.test.js +++ b/e2e/sc.test.js @@ -23,27 +23,42 @@ test('should be able to get Sauce Connect versions', async () => { return; } const api = new SauceLabs(); - const scVersion = await api.scVersions({ - clientVersion: '5.1.0', - clientHost: 'darwin-arm64', + const response = await api.scDownload({ + version: '5.2.2', + arch: 'arm64', + os: 'macos', }); - expect(scVersion.status).toEqual('UPGRADE'); - expect(scVersion.latest_version).toMatch(/5\./); - console.log(scVersion.download_url); + + expect(response.download.version).toEqual('5.2.2'); + expect(response.download.url).toMatch(/5\.2\.2/); + console.log(response.download.url); }); test('should not be able to run Sauce Connect due to invalid credentials', async () => { if (SKIP_TEST) { return; } - const api = new SauceLabs({key: 'foobar'}); + const api = new SauceLabs({key: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'}); const err = await api .startSauceConnect({ logger: console.log.bind(console), tunnelName: `node-saucelabs E2E test - ${ID}`, }) .catch((err) => err); - expect(err.message).toContain('Unauthorized'); + expect(err.message).toContain('Not authorized'); +}); + +test('should not be able to run Sauce Connect due to missing tunnel-name', async () => { + if (SKIP_TEST) { + return; + } + const api = new SauceLabs(); + const err = await api + .startSauceConnect({ + logger: console.log.bind(console), + }) + .catch((err) => err); + expect(err.message).toContain('Missing tunnel-name'); }); test('should be able to run Sauce Connect', async () => { diff --git a/src/commands/sc.js b/src/commands/sc.js index 0a8751bf..cea9a22c 100644 --- a/src/commands/sc.js +++ b/src/commands/sc.js @@ -2,7 +2,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 describe = `Sauce Connect Proxy interface. + - Only the 'sc run' command is currently supported + - See https://docs.saucelabs.com/dev/cli/sauce-connect-5/run/ for detailed CLI documentation. + - Sauce Connect Proxy 4.x.x cannot be used with the library version 9.0.0 and newer + - Some Sauce Connect CLI option aliases differ from the 'sc' binary + - Some CLI options differ from the 'sc' binary: + - '--proxy' corresponds to https://docs.saucelabs.com/dev/cli/sauce-connect-5/run/#proxy-sauce + - '--sc-upstream-proxy' corresponds to https://docs.saucelabs.com/dev/cli/sauce-connect-5/run/#proxy +`; export const builder = (yargs) => { for (const option of SAUCE_CONNECT_CLI_PARAMS) { yargs.option(option.name, option); diff --git a/src/constants.js b/src/constants.js index ce640c16..9727a641 100644 --- a/src/constants.js +++ b/src/constants.js @@ -3,25 +3,8 @@ import os from 'os'; import {version} from '../package.json'; -export const DEFAULT_SAUCE_CONNECT_VERSION = '4.9.1'; -export const SAUCE_CONNECT_BASE = 'https://saucelabs.com/downloads'; -export const SAUCE_CONNECT_VERSIONS_ENDPOINT = - 'https://saucelabs.com/versions.json'; -export const SAUCE_CONNECT_PLATFORM_DATA = { - darwin: { - url: `${SAUCE_CONNECT_BASE}/sc-%s-osx.zip`, - use: 'bin/sc', - }, - linux: { - url: `${SAUCE_CONNECT_BASE}/sc-%s-linux.tar.gz`, - use: 'bin/sc', - }, - win32: { - url: `${SAUCE_CONNECT_BASE}/sc-%s-win32.zip`, - use: 'bin/sc', - }, -}; - +export const DEFAULT_SAUCE_CONNECT_VERSION = '5.2.2'; +export const DEFAULT_RUNNER_NAME = 'node-saucelabs'; export const SAUCE_VERSION_NOTE = `node-saucelabs v${version}\nSauce Connect v${DEFAULT_SAUCE_CONNECT_VERSION}`; const protocols = [ @@ -156,218 +139,178 @@ export const SAUCE_CONNECT_CLI_PARAMS = [ description: 'Specify the Sauce Connect version you want to use.', default: DEFAULT_SAUCE_CONNECT_VERSION, }, - { - /** - * Sauce Connect parameter - */ - alias: 'a', - name: 'auth', - description: - 'Perform basic authentication when an URL on 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")', - deprecated: true, - }, { alias: 'c', name: 'config-file', description: 'Path to YAML config file.', }, { - alias: 'D', - name: 'direct-domains', + alias: 'i', + name: 'tunnel-name', 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.', + 'Tunnel name used for this tunnel or the tunnels in the same HA pool.', }, { - name: 'dns', + alias: 'M', + name: 'metadata', description: - 'Use specified name server(s). Example: --dns 8.8.8.8,8.8.4.4:53', + 'Custom metadata key-value pairs. This flag is, primarily, used by Sauce Labs to assign custom properties to the tunnel for reporting purposes.', }, { - name: 'doctor', + alias: 's', + name: 'shared', description: - 'Perform checks to detect possible misconfiguration or problems.', + "Share the tunnel within the same org unit. Only the 'all' option is currently supported. See here: https://docs.saucelabs.com/basics/acct-team-mgmt/sauce-connect-proxy-tunnels/.", + }, + { + alias: 't', + name: 'tunnel-pool', type: 'boolean', - deprecated: true, + description: + 'Denotes a tunnel as part of a high availability tunnel pool. See here: https://docs.saucelabs.com/secure-connections/sauce-connect/setup-configuration/high-availability/.', }, { alias: 'F', - name: 'fast-fail-regexps', + name: 'deny-domains', description: - 'Comma-separated list of regular expressions. Requests matching one of these will get dropped instantly and will not go through the tunnel.', + "Deny requests to the matching domains. Prefix domains with '-' to exclude requests from being denied. Special keyword 'all' matches all domains.", }, { - alias: 'z', - name: 'log-stats', - description: 'Log statistics about HTTP traffic every .', - type: 'number', - deprecated: true, + alias: 'D', + name: 'direct-domains', + description: + "Forward matching requests to their origin server over the public internet. Requests that don't match \"direct domains\" will be forwarded to customer-side over the Sauce Connect Proxy connection. You can specify --direct-domains or --tunnel-domains, but not both. Prefix domains with '-' to exclude requests from being forwarded directly.", }, { - alias: 'l', - name: 'logfile', - description: 'Specify custom logfile.', + alias: 'B', + name: 'tls-passthrough-domains', + description: + "Pass matching requests to their origin server without SSL/TLS re-encryption. Requests that don't match will be re-encrypted. You can specify --tls-passthrough-domains or --tls-resign-domains, but not both. Prefix domains with '-' to exclude requests from being passed through.", }, { - name: 'max-logsize', + alias: 'b', + name: 'tls-resign-domains', description: - 'Rotate logfile after reaching size. Disabled by default.', - type: 'number', + "Resign SSL/TLS certificates for matching requests. You can specify --tls-resign-domains or --tls-passthrough-domains, but not both. Prefix domains with '-' to exclude requests from being resigned.", }, { - alias: 'M', - name: 'max-missed-acks', + alias: 'T', + name: 'tunnel-domains', description: - 'The max number of keepalive acks that can be missed before triggering reconnect.', - type: 'number', - deprecated: true, + "Forward matching requests over the Sauce Connect Proxy connection. Requests not matching \"tunnel domains\" will be forwarded to their origin server over the public internet. You can specify --tunnel-domains or --direct-domains, but not both. Prefix domains with '-' to exclude requests from being forwarded over the SC Proxy connection. Special keyword 'all' matches all domains.", }, { - name: 'metrics-address', - description: 'host:port server used to expose client-side metrics.', - deprecated: true, + name: 'tunnel-connections', + description: + 'Number of connections to the Sauce Connect server. By default it is set to the number of CPUs on the machine. Total number of concurrent requests that can be handled is limited by the number of connections multiplied by the number of streams, see --tunnel-max-concurrent-streams flag. For example with 4 connections and 256 streams, the total number of concurrent requests is 1024.', }, { - name: 'status-address', - description: 'host:port server used to expose client status.', + name: 'tunnel-max-concurrent-streams', + description: + 'Maximal number of concurrent HTTP/2 streams per TCP connection.', }, { - name: 'no-autodetect', - description: 'Disable the autodetection of proxy settings.', - type: 'boolean', + alias: 'a', + name: 'auth', + description: 'Site or upstream proxy basic authentication credentials.', }, { - alias: 'N', - name: 'no-proxy-caching', + alias: 'H', + name: 'header', description: - 'Disable caching in Sauce Connect. All requests will be sent through the tunnel.', - type: 'boolean', - deprecated: true, + 'The header name will be normalized to canonical form. The header value should not contain any newlines or carriage returns. The flag can be specified multiple times. The following example removes the User-Agent header and all headers starting with X-.', }, { - name: 'no-remove-colliding-tunnels', + name: 'pac', 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.", - type: 'boolean', - deprecated: true, + 'Proxy Auto-Configuration file to use for upstream proxy selection.', }, { - alias: 'B', - name: 'no-ssl-bump-domains', + name: 'sc-upstream-proxy', description: - 'Comma-separated list of domains. Requests whose host matches one of these will not be SSL re-encrypted.', + 'Upstream proxy for test sessions . It is used for requests received from the Sauce Connect Server only.', }, { - name: 'pac', + name: 'proxy-localhost', description: - 'Proxy autoconfiguration. Can be an http(s) or local file:// (absolute path only) URI.', + 'Setting this to "allow" enables sending requests to localhost through the upstream proxy. Setting this to "direct" sends requests to localhost directly without using the upstream proxy. By default, requests to localhost are denied.', }, { - 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.', - type: 'boolean', + name: 'proxy-sauce', + description: + 'Establish a tunnel through an upstream proxy. Proxy for requests to Sauce Labs REST API and Sauce Connect servers only. See the -x, --proxy flag for more details on the format.', }, { - alias: 'w', - name: 'proxy-userpwd', + name: 'dns-round-robin', description: - 'Username and password required to access the proxy configured with -p.', + 'If more than one DNS server is specified with the --dns-server flag, passing this flag will enable round-robin selection.', }, { - alias: 'f', - name: 'readyfile', - description: 'File that will be touched to signal when tunnel is ready.', + alias: 'n', + name: 'dns-server', + description: + 'DNS server(s) to use instead of system default. There are two execution policies, when more then one server is specified. Fallback: the first server in a list is used as primary, the rest are used as fallbacks. Round robin: the servers are used in a round-robin fashion. The port is optional, if not specified the default port is 53.', }, { - alias: 'X', - name: 'scproxy-port', - description: 'Port on which scproxy will be listening.', + name: 'dns-timeout', + description: + 'Timeout for dialing DNS servers. Only used if DNS servers are specified.', }, { - name: 'scproxy-read-limit', + name: 'cacert-file', 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.', - deprecated: true, + 'Add your own CA certificates to verify against. The system root certificates will be used in addition to any certificates in this list. Use this flag multiple times to specify multiple CA certificate files.', }, { - name: 'scproxy-write-limit', + name: 'http-dial-timeout', 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.', - deprecated: true, + 'The maximum amount of time a dial will wait for a connect to complete. With or without a timeout, the operating system may impose its own earlier timeout. For instance, TCP timeouts are often around 3 minutes.', }, { - alias: 'P', - name: 'se-port', + name: 'http-idle-conn-timeout', 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, + 'The maximum amount of time an idle (keep-alive) connection will remain idle before closing itself. Zero means no limit.', }, { - alias: 's', - name: 'shared-tunnel', + name: 'http-response-header-timeout', description: - 'Let sub-accounts of the tunnel owner use the tunnel if requested.', - type: 'boolean', + "The amount of time to wait for a server's response headers after fully writing the request (including its body, if any).This time does not include the time to read the response body. Zero means no limit.", }, { - name: 'tunnel-cainfo', + name: 'http-tls-handshake-timeout', description: - 'CA certificate bundle to use for verifying tunnel connections. (default "/usr/local/etc/openssl/cert.pem")', + 'The maximum amount of time waiting to wait for a TLS handshake. Zero means no limit.', }, { - name: 'tunnel-capath', + name: 'http-tls-keylog-file', description: - 'Directory of CA certs to use for verifying tunnel connections. (default "/etc/ssl/certs")', - deprecated: true, + 'File to log TLS master secrets in NSS key log format. By default, the value is taken from the SSLKEYLOGFILE environment variable. It can be used to allow external programs such as Wireshark to decrypt TLS connections.', }, { - name: 'tunnel-cert', + name: 'api-address', description: - 'Specify certificate to use for the tunnel connection, either public or private. Default: private. (default "private")', + 'The server address to listen on. If the host is empty, the server will listen on all available interfaces.', }, { - 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'.", + name: 'api-basic-auth', + description: 'Basic authentication credentials to protect the server.', }, { - name: 'tunnel-identifier', + name: 'api-idle-timeout', description: - 'Tunnel name used for this tunnel or the tunnels in the same HA pool.', - deprecated: true, + 'The maximum amount of time to wait for the next request before closing connection.', }, { - alias: 'i', - name: 'tunnel-name', - description: - 'Tunnel name used for this tunnel or the tunnels in the same HA pool.', + name: 'log-file', + description: 'Path to the log file, if empty, logs to stdout.', }, { - name: 'tunnel-pool', - description: 'The tunnel is a part of a high availability tunnel pool.', + name: 'log-http', + description: 'HTTP request and response logging mode.', }, { - alias: 'v', - name: 'verbose', - type: 'boolean', - description: 'Enable verbose logging. Can be used up to two times.', + name: 'log-level', + description: 'Log level.', }, ]; export const SC_BOOLEAN_CLI_PARAMS = SAUCE_CONNECT_CLI_PARAMS.filter( @@ -387,10 +330,6 @@ export const SC_PARAMS_TO_STRIP = [ ]; export const SC_READY_MESSAGE = 'Sauce Connect is up, you may start your tests'; -export const SC_FAILURE_MESSAGES = [ - 'Sauce Connect could not establish a connection', - 'Sauce Connect failed to start', -]; -export const SC_WAIT_FOR_MESSAGES = ['\u001b[K', 'Please wait for']; // "\u001b" = Escape character -export const SC_CLOSE_MESSAGE = 'Goodbye'; +export const SC_FAILURE_MESSAGES = ['fatal error exiting']; +export const SC_CLOSE_MESSAGE = 'tunnel was shutdown'; export const SC_CLOSE_TIMEOUT = 5000; diff --git a/src/index.js b/src/index.js index a745bb7e..7dedfc84 100644 --- a/src/index.js +++ b/src/index.js @@ -7,9 +7,9 @@ import {spawn} from 'child_process'; import got from 'got'; import FormData from 'form-data'; import {camelCase} from 'change-case'; -import queryString from 'query-string'; - import { + getCPUArch, + getPlatform, createHMAC, getAPIHost, getAssetHost, @@ -20,6 +20,8 @@ import { getStrictSsl, getRegionSubDomain, } from './utils'; +import queryString from 'query-string'; + import { PROTOCOL_MAP, DEFAULT_OPTIONS, @@ -33,9 +35,8 @@ import { SC_CLOSE_TIMEOUT, DEFAULT_SAUCE_CONNECT_VERSION, SC_FAILURE_MESSAGES, - SAUCE_CONNECT_VERSIONS_ENDPOINT, - SC_WAIT_FOR_MESSAGES, SC_BOOLEAN_CLI_PARAMS, + DEFAULT_RUNNER_NAME, } from './constants'; import SauceConnectLoader from './sauceConnectLoader'; @@ -58,7 +59,8 @@ export default class SauceLabs { }); if (typeof this._options.proxy === 'string') { - var proxyAgent = createProxyAgent(this._options.proxy); + this.proxy = this._options.proxy; + const proxyAgent = createProxyAgent(this.proxy); this._api = got.extend( { agent: proxyAgent, @@ -240,33 +242,44 @@ export default class SauceLabs { } } - let sauceConnectVersion = argv.scVersion; - if (!sauceConnectVersion) { - sauceConnectVersion = await this._getLatestSauceConnectVersion(); + const sauceConnectVersion = argv.scVersion || DEFAULT_SAUCE_CONNECT_VERSION; + if (sauceConnectVersion.startsWith('4')) { + throw new Error( + `This Sauce Connect version (${sauceConnectVersion}) is no longer supported. Please use Sauce Connect 5.` + ); + } + + // Provide a default runner name. It's used for identifying the tunnel's initiation method. + if (!argv['metadata']) { + argv = {...argv, metadata: `runner=${DEFAULT_RUNNER_NAME}`}; + } else if (!argv['metadata'].includes('runner=')) { + argv = { + ...argv, + metadata: `runner=${DEFAULT_RUNNER_NAME},${argv['metadata']}`, + }; } + + const scUpstreamProxy = argv.scUpstreamProxy; const args = Object.entries(argv) /** * filter out yargs, yargs params and custom parameters */ .filter( ([k]) => - !['_', '$0', 'sc-version', 'logger', ...SC_PARAMS_TO_STRIP].includes( - k - ) + ![ + '_', + '$0', + 'sc-version', + 'sc-upstream-proxy', + 'tunnel-name', + 'logger', + ...SC_PARAMS_TO_STRIP, + ].includes(k) ) /** * remove duplicate params by yargs */ .filter(([k]) => !k.match(/[A-Z]/g)) - /** - * replace tunnel-identifier for tunnel-name - */ - .map(([k, v]) => [k === 'tunnel-identifier' ? 'tunnel-name' : k, v]) - /** - * SC uses `--no-XXX` params which gets parsed out by yargs - * therefor we need to re-add it here - */ - .map(([k, v]) => [typeof v === 'boolean' && !v ? `no-${k}` : k, v]) /** * SC doesn't like boolean values, so we need to make sure to * no pass it along when we deal with a boolean param @@ -274,16 +287,56 @@ export default class SauceLabs { .map(([k, v]) => SC_BOOLEAN_CLI_PARAMS.includes(k) ? `--${k}` : `--${k}=${v}` ); - args.push(`--user=${this.username}`); - args.push(`--api-key=${this._accessKey}`); + args.push(`--username=${this.username}`); + args.push(`--access-key=${this._accessKey}`); + if (scUpstreamProxy) { + // map `--sc-upstream-proxy` to sc's `--proxy`. It's done because the app CLI + // conflicts with sc's CLI, `--proxy` here is equivalent to `--proxy-sauce` in sc. + // See: https://docs.saucelabs.com/dev/cli/sauce-connect-5/run/#proxy + // See: https://docs.saucelabs.com/dev/cli/sauce-connect-5/run/#proxy and + // https://docs.saucelabs.com/dev/cli/sauce-connect-5/run/#proxy-sauce + args.push(`--proxy=${scUpstreamProxy}`); + } + if (this.proxy) { + args.push(`--proxy-sauce=${this.proxy}`); + } const region = argv.region || this.region; if (region) { const scRegion = getRegionSubDomain({region}); args.push(`--region=${scRegion}`); + } else { + // --region is required for Sauce Connect 5. + throw new Error('Missing region'); + } + + const tunnelName = argv.tunnelName; + if (tunnelName) { + args.push(`--tunnel-name=${tunnelName}`); + } else { + // --tunnel-name is required for Sauce Connect 5. + throw new Error('Missing tunnel-name'); } - const scLoader = new SauceConnectLoader({sauceConnectVersion}); - await scLoader.verifyAlreadyDownloaded(); + + // download and verify the Sauce Connect client + let scLoader = new SauceConnectLoader(sauceConnectVersion); + const isDownloaded = await scLoader.verifyAlreadyDownloaded(); + + if (!isDownloaded) { + console.info(`Downloading Sauce Connect v${sauceConnectVersion}...`); + let download = await this._getSauceConnectDownload(sauceConnectVersion); + // downloaded version may differ from the input version, eg. if a partial version is given as input + // update scLoader if necessary + if (download.version != sauceConnectVersion) { + scLoader = new SauceConnectLoader(download.version); + } + await scLoader.verifyAlreadyDownloaded({url: download.url}); + } + + if (args.length == 0 || args[0] != 'run') { + args.unshift('run'); + } + const cp = spawn(scLoader.path, args); return new Promise((resolve, reject) => { const close = () => @@ -302,19 +355,6 @@ export default class SauceLabs { cp.stderr.on('data', (data) => { const output = data.toString(); - - /** - * check if error output is just an escape sequence or - * other expected data - */ - if ( - SC_WAIT_FOR_MESSAGES.find((msg) => - escape(output).includes(escape(msg)) - ) - ) { - return; - } - return reject(new Error(output)); }); cp.stdout.on('data', (data) => { @@ -353,17 +393,43 @@ export default class SauceLabs { }); } - async _getLatestSauceConnectVersion() { + /** + * Retrieve the download URL for the Sauce Connect client specific to this device's OS and architecture. + * Throws an exception on any error response + * @param {string} version Full or partial version for the download to match + * @returns {Object} download + * @returns {string} download.url + * @returns {string} download.version + * @returns {Object[]} download.checksums + * @returns {string} download.checksums[].value + * @returns {string} download.checksums[].algorithm + */ + async _getSauceConnectDownload(version) { + const platform = getPlatform(); + const cpuArch = getCPUArch(); + var response = {}; try { - const {body} = await this._api.get(SAUCE_CONNECT_VERSIONS_ENDPOINT, { - responseType: 'json', + response = await this._callAPI('scDownload', { + os: platform, + arch: cpuArch, + version: version, }); - const responseJson = body.data; - return responseJson['Sauce Connect']['version']; } catch (err) { - // fallback - return DEFAULT_SAUCE_CONNECT_VERSION; + // if this endpoint is down, the start tunnels endpoint is likely down as well. + throw new Error(`Failed to retrieve Sauce Connect download. ${err}`); + } + + if (response.error) { + // likely an input value error. some platform/arch combinations may not be supported. + throw new Error( + `Failed to retrieve Sauce Connect download. code: ${response.error.code} message: ${response.error.message}` + ); + } + if (!response.download) { + // unexpected, inconsistent with API definition + throw new Error(`Failed to retrieve Sauce Connect download.`); } + return response.download; } async _downloadJobAsset(jobId, assetName, {filepath} = {}) { diff --git a/src/sauceConnectLoader.js b/src/sauceConnectLoader.js index debdd59c..34380ccc 100644 --- a/src/sauceConnectLoader.js +++ b/src/sauceConnectLoader.js @@ -20,9 +20,8 @@ * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER * DEALINGS IN THE SOFTWARE. */ -import {format} from 'util'; -import {SAUCE_CONNECT_PLATFORM_DATA} from './constants'; -import {getPlatform} from './utils'; +// import SauceLabs from './'; +import {getCPUArch, getPlatform, isWindows} from './utils'; import {mkdirSync, unlinkSync, createWriteStream} from 'fs'; import {basename, join} from 'path'; import https from 'https'; @@ -30,33 +29,33 @@ import fs from 'fs/promises'; import compressing from 'compressing'; export default class SauceConnectLoader { - constructor(options = {}) { - const platform = getPlatform(); - const platformData = SAUCE_CONNECT_PLATFORM_DATA[platform]; - if (!platformData) { - throw new ReferenceError(`Unsupported platform ${platform}`); - } - const {url, use} = platformData; - this.url = format(url, options.sauceConnectVersion); + constructor(version) { this.destDir = join(__dirname, 'sc-loader'); this.destSC = join( __dirname, 'sc-loader', - `.sc-v${options.sauceConnectVersion}` + `.sc-v${version}-${getPlatform()}-${getCPUArch()}` ); - this.path = join(this.destSC, use); + let scBinary = 'sc'; + if (isWindows()) { + scBinary += '.exe'; + } + this.path = join(this.destSC, scBinary); } /** - * Verify if SC was already downloaded, + * Verify if SC was already downloaded. * if not then download it * * @api public */ - verifyAlreadyDownloaded() { + verifyAlreadyDownloaded(options = {}) { return fs.stat(this.path).catch((err) => { if (err?.code === 'ENOENT') { - return this._download(); + if (options.url) { + return this._download(options.url); + } + return false; } throw err; }); @@ -65,13 +64,13 @@ export default class SauceConnectLoader { /** * Download Sauce Connect */ - _download() { + _download(sauceConnectURL) { mkdirSync(this.destDir, {recursive: true}); - const compressedFilePath = join(this.destDir, basename(this.url)); + const compressedFilePath = join(this.destDir, basename(sauceConnectURL)); return new Promise((resolve, reject) => { const file = createWriteStream(compressedFilePath); https - .get(this.url, (response) => { + .get(sauceConnectURL, (response) => { response.pipe(file); file.on('finish', () => { file.close(); @@ -83,25 +82,18 @@ export default class SauceConnectLoader { reject(err); }); }).then(() => { - if (getPlatform() === 'linux') { + if (compressedFilePath.endsWith('.tar.gz')) { return compressing.tgz - .uncompress(compressedFilePath, this.destDir, { - strip: 1, - }) + .uncompress(compressedFilePath, this.destSC) .then(() => { - const extractedDir = compressedFilePath.replace('.tar.gz', ''); - return fs.rename(extractedDir, this.destSC).then(() => { - // ensure the sc executable is actually executable - return fs.chmod(this.path, 0o755); - }); + // ensure the sc executable is actually executable + return fs.chmod(this.path, 0o755); }); } else { return compressing.zip - .uncompress(compressedFilePath, this.destSC, { - strip: 1, - }) + .uncompress(compressedFilePath, this.destSC) .then(() => { - if (getPlatform() !== 'win32') { + if (!isWindows()) { // ensure the sc executable is actually executable return fs.chmod(this.path, 0o755); } diff --git a/src/utils.js b/src/utils.js index 342492f8..8cab378c 100644 --- a/src/utils.js +++ b/src/utils.js @@ -192,8 +192,35 @@ export function getStrictSsl() { } /** - * Mainly just here for testing + * Returns whether this platform is running a Windows OS + */ +export function isWindows() { + return process.platform.startsWith('win'); +} + +/** + * Returns an OS platform compatible with scDownload endpoint + * - macos + * - linux + * - windows */ export function getPlatform() { + if (isWindows()) { + return 'windows'; + } else if (process.platform == 'darwin') { + return 'macos'; + } return process.platform; } + +/** + * Returns CPU architecture compatible with scDownload endpoint + * - 'x86_64' + * - 'arm64' + */ +export function getCPUArch() { + if (process.arch == 'x64') { + return 'x86_64'; + } + return process.arch; +} diff --git a/tests/__responses__/all_versions.json b/tests/__responses__/all_versions.json deleted file mode 100644 index 59c2fdec..00000000 --- a/tests/__responses__/all_versions.json +++ /dev/null @@ -1,270 +0,0 @@ -{ - "downloads": { - "linux": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.1.1/sauce-connect-5.1.1_linux.x86_64.tar.gz", - "sha256": "6247e61e39ff054cf524341a681f4045c557be79bcf63c8c501e634ef2d54a41" - }, - "linux-arm64": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.1.1/sauce-connect-5.1.1_linux.aarch64.tar.gz", - "sha256": "b4951cf64a724ceb03cf0ae676a35a0ca721210c0e42426384dcec23159f1fe5" - }, - "osx": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.1.1/sauce-connect-5.1.1_darwin.all.zip", - "sha256": "d0a29f2df3277e8468c55810ef99685bed632ec6d2eed9fdbd941f2f8faf2354" - }, - "win32": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.1.1/sauce-connect-5.1.1_windows.x86_64.zip", - "sha256": "d3e692cca8b9311a4822e8a6f8715e787319e70dc5551cbb212f5be68d36f7bb" - }, - "windows-arm64": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.1.1/sauce-connect-5.1.1_windows.aarch64.zip", - "sha256": "fbb1b15c9a5ae6435cba0dc43f195df32764e5c7b1fcc6a2e625df58ea05642d" - } - }, - "info_url": "https://docs.saucelabs.com/secure-connections/sauce-connect-5/installation/", - "latest_version": "5.1.1", - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.1.1/sauce-connect-5.1.1_darwin.all.zip", - "sha256": "d0a29f2df3277e8468c55810ef99685bed632ec6d2eed9fdbd941f2f8faf2354", - "status": "LATEST", - "client_version": "5.1.1", - "all_downloads": { - "4.7.0": { - "linux": { - "download_url": "https://saucelabs.com/downloads/sc-4.7.0-linux.tar.gz", - "sha1": "f0bf8e35894e9b35bf9fae8f4f34e83845b4bb6b" - }, - "osx": { - "download_url": "https://saucelabs.com/downloads/sc-4.7.0-osx.zip", - "sha1": "8e41a471bdf4cfeed7cd06d6af9dd081b9aa028d" - } - }, - "4.7.1": { - "linux": { - "download_url": "https://saucelabs.com/downloads/sc-4.7.1-linux.tar.gz", - "sha1": "e5d7f82ad98251a653d1b0537f1103e49eda5e11" - }, - "linux-arm64": { - "download_url": "https://saucelabs.com/downloads/sc-4.7.1-linux-arm64.tar.gz", - "sha1": "d07c8f62ec64168f9cc80d73a59976764f2c62b4" - }, - "osx": { - "download_url": "https://saucelabs.com/downloads/sc-4.7.1-osx.zip", - "sha1": "1f18defa14a5cc4b663bf07213411f6bdd535b6d" - }, - "win32": { - "download_url": "https://saucelabs.com/downloads/sc-4.7.1-win32.zip", - "sha1": "9c91e5adbd023973efe0eb14d2d427d2c0ef3c25" - } - }, - "4.8.0": { - "linux": { - "download_url": "https://saucelabs.com/downloads/sc-4.8.0-linux.tar.gz", - "sha1": "a6bcfeab41b245e503c1f2aad382bfa8956893a1" - }, - "linux-arm64": { - "download_url": "https://saucelabs.com/downloads/sc-4.8.0-linux-arm64.tar.gz", - "sha1": "8ce9b5a740710e6eef1be70b1b1d347df938d46a" - }, - "osx": { - "download_url": "https://saucelabs.com/downloads/sc-4.8.0-osx.zip", - "sha1": "8c4c7de20c68b704cffddcaddea44a6773b05746" - }, - "win32": { - "download_url": "https://saucelabs.com/downloads/sc-4.8.0-win32.zip", - "sha1": "48382adec66130d96148ccaff46894088366ed90" - } - }, - "4.8.1": { - "linux": { - "download_url": "https://saucelabs.com/downloads/sc-4.8.1-linux.tar.gz", - "sha1": "9c16682e4c9716734432789884f868212f95f563" - }, - "linux-arm64": { - "download_url": "https://saucelabs.com/downloads/sc-4.8.1-linux-arm64.tar.gz", - "sha1": "2a6a5fd0ad90c1d776048e4f9fd60a1a8a26c3a2" - }, - "osx": { - "download_url": "https://saucelabs.com/downloads/sc-4.8.1-osx.zip", - "sha1": "4c5b8b570994a76396c75858455032bfdbb83589" - }, - "win32": { - "download_url": "https://saucelabs.com/downloads/sc-4.8.1-win32.zip", - "sha1": "f3df33f01bf8d9585cfcda084b54300089266159" - } - }, - "4.8.2": { - "linux": { - "download_url": "https://saucelabs.com/downloads/sc-4.8.2-linux.tar.gz", - "sha1": "e65e77e849a80d1eb1de03ba56abf5a4d51cf1c5" - }, - "linux-arm64": { - "download_url": "https://saucelabs.com/downloads/sc-4.8.2-linux-arm64.tar.gz", - "sha1": "fd782a658f4d28b9792edaf9df730a87ae797cba" - }, - "osx": { - "download_url": "https://saucelabs.com/downloads/sc-4.8.2-osx.zip", - "sha1": "5c2f81f6b0f246a641384d33df5c091ca0174730" - }, - "win32": { - "download_url": "https://saucelabs.com/downloads/sc-4.8.2-win32.zip", - "sha1": "1c81cbe9d1b25b8f8483cc1163d54d94191f7665" - } - }, - "4.8.3": { - "linux": { - "download_url": "https://saucelabs.com/downloads/sc-4.8.3-linux.tar.gz", - "sha1": "1af0d45ce48e3f0707164dbf0577adad2e6b7853" - }, - "linux-arm64": { - "download_url": "https://saucelabs.com/downloads/sc-4.8.3-linux-arm64.tar.gz", - "sha1": "de6c894d018a1dd1f38c3c90bd4b8084c2e9fd4d" - }, - "osx": { - "download_url": "https://saucelabs.com/downloads/sc-4.8.3-osx.zip", - "sha1": "c6edb040d3c7398c0f2be41957846b85a8d1e6e6" - }, - "win32": { - "download_url": "https://saucelabs.com/downloads/sc-4.8.3-win32.zip", - "sha1": "6df7d8114954b09d61e54ac8c642137fa98d342f" - } - }, - "4.9.0": { - "linux": { - "download_url": "https://saucelabs.com/downloads/sc-4.9.0-linux.tar.gz", - "sha1": "f263177c700ebc29a0c5772a04e9b04bc1487c91" - }, - "linux-arm64": { - "download_url": "https://saucelabs.com/downloads/sc-4.9.0-linux-arm64.tar.gz", - "sha1": "04f697d585bdc7d95d7663dea52f5b895628b0ba" - }, - "osx": { - "download_url": "https://saucelabs.com/downloads/sc-4.9.0-osx.zip", - "sha1": "f3080fbd76a3847c9c19dae6131f93a1c3abb008" - }, - "win32": { - "download_url": "https://saucelabs.com/downloads/sc-4.9.0-win32.zip", - "sha1": "fe35f66126ddd6e8d043790906206c2999d69f1a" - } - }, - "4.9.1": { - "linux": { - "download_url": "https://saucelabs.com/downloads/sc-4.9.1-linux.tar.gz", - "sha1": "9310bc860f7870a1f872b11c4dc6073a1ad34e5e" - }, - "linux-arm64": { - "download_url": "https://saucelabs.com/downloads/sc-4.9.1-linux-arm64.tar.gz", - "sha1": "535e6c9edcc0ca94cac7c9f800f910dcea808cbf" - }, - "osx": { - "download_url": "https://saucelabs.com/downloads/sc-4.9.1-osx.zip", - "sha1": "64f9c1bac5d4f5b9acb6fbb629b6df0f5671b4c8" - }, - "win32": { - "download_url": "https://saucelabs.com/downloads/sc-4.9.1-win32.zip", - "sha1": "63858695eb6840306921607a97af0083c0697bf3" - } - }, - "4.9.2": { - "linux": { - "download_url": "https://saucelabs.com/downloads/sc-4.9.2-linux.tar.gz", - "sha1": "5589571bdc186f3f1b05fe6ce68529501a42fb43" - }, - "linux-arm64": { - "download_url": "https://saucelabs.com/downloads/sc-4.9.2-linux-arm64.tar.gz", - "sha1": "8b02c4343b74c36c575817ea4a6eae5fb5718f6c" - }, - "win32": { - "download_url": "https://saucelabs.com/downloads/sc-4.9.2-win32.zip", - "sha1": "47c19feda3fb684f88acd816e9c8f2e3d4a1e3c0" - } - }, - "5.0.0": { - "linux": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.0.0/sauce-connect-5.0.0_linux.x86_64.tar.gz", - "sha1": "1dfcae164bf28dc5071d496777f23187285f7578" - }, - "linux-arm64": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.0.0/sauce-connect-5.0.0_linux.aarch64.tar.gz", - "sha1": "9861f28c8703a8d4a0f7824684e467974e202350" - }, - "osx": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.0.0/sauce-connect-5.0.0_darwin.all.zip", - "sha1": "19d56467d90a98cafd85ed1f2b7647c95d8fe27b" - }, - "win32": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.0.0/sauce-connect-5.0.0_windows.x86_64.zip", - "sha1": "40f1375d0d28e8c704f8b829fb842bedd11ea6c9" - }, - "windows-arm64": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.0.0/sauce-connect-5.0.0_windows.aarch64.zip", - "sha1": "a7be976a39a72e0c1c931f7b981649a93c0a4b36" - } - }, - "5.0.1": { - "linux": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.0.1/sauce-connect-5.0.1_linux.x86_64.tar.gz", - "sha1": "23fe2956742c1244757e249e5e05a59e96039482" - }, - "linux-arm64": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.0.1/sauce-connect-5.0.1_linux.aarch64.tar.gz", - "sha1": "962c1cd7884ad703e213d86ba9df8d7b5d58dbe7" - }, - "osx": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.0.1/sauce-connect-5.0.1_darwin.all.zip", - "sha1": "5eaa165b5eae2c57c8fc07f7272404c09a56cb89" - }, - "win32": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.0.1/sauce-connect-5.0.1_windows.x86_64.zip", - "sha1": "f6afc12802581922fdff2ee7472f1b3a1d259ec9" - }, - "windows-arm64": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.0.1/sauce-connect-5.0.1_windows.aarch64.zip", - "sha1": "c92ae859e212fc83221b92486886e741bb6f336c" - } - }, - "5.1.0": { - "linux": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.1.0/sauce-connect-5.1.0_linux.x86_64.tar.gz", - "sha256": "69ef7830b8ea3fc2bd9129cbf3f5ff04a22c6a7cd8a3c9e1e1ff0ee411b0ca20" - }, - "linux-arm64": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.1.0/sauce-connect-5.1.0_linux.aarch64.tar.gz", - "sha256": "679b940e6e7e99ded716fd22e0a4229be012c85aaad9d0ca9242e2bbcf8f50ed" - }, - "osx": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.1.0/sauce-connect-5.1.0_darwin.all.zip", - "sha256": "e587d5a9b5e5c928600d9e5e35d74ae9926218c7e13ab1da90f26da411d360cd" - }, - "win32": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.1.0/sauce-connect-5.1.0_windows.x86_64.zip", - "sha256": "291f149685fa20015f78423a5650438978aa38ba3c72671fe834d80c706e32c4" - }, - "windows-arm64": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.1.0/sauce-connect-5.1.0_windows.aarch64.zip", - "sha256": "a6b1e61f93eb8afb4bf461d7df43724ae1cafdb9553ca7f7cc59905ed431d7f7" - } - }, - "5.1.1": { - "linux": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.1.1/sauce-connect-5.1.1_linux.x86_64.tar.gz", - "sha256": "6247e61e39ff054cf524341a681f4045c557be79bcf63c8c501e634ef2d54a41" - }, - "linux-arm64": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.1.1/sauce-connect-5.1.1_linux.aarch64.tar.gz", - "sha256": "b4951cf64a724ceb03cf0ae676a35a0ca721210c0e42426384dcec23159f1fe5" - }, - "osx": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.1.1/sauce-connect-5.1.1_darwin.all.zip", - "sha256": "d0a29f2df3277e8468c55810ef99685bed632ec6d2eed9fdbd941f2f8faf2354" - }, - "win32": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.1.1/sauce-connect-5.1.1_windows.x86_64.zip", - "sha256": "d3e692cca8b9311a4822e8a6f8715e787319e70dc5551cbb212f5be68d36f7bb" - }, - "windows-arm64": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.1.1/sauce-connect-5.1.1_windows.aarch64.zip", - "sha256": "fbb1b15c9a5ae6435cba0dc43f195df32764e5c7b1fcc6a2e625df58ea05642d" - } - } - } -} diff --git a/tests/__responses__/download_error.json b/tests/__responses__/download_error.json new file mode 100644 index 00000000..96592833 --- /dev/null +++ b/tests/__responses__/download_error.json @@ -0,0 +1,6 @@ +{ + "error": { + "code": "404", + "message": "Invalid input" + } +} diff --git a/tests/__responses__/download_macos.json b/tests/__responses__/download_macos.json new file mode 100644 index 00000000..442a24fe --- /dev/null +++ b/tests/__responses__/download_macos.json @@ -0,0 +1,12 @@ +{ + "download": { + "checksums": [ + { + "value": "1384bb85b2d29d177933fc8e894c8f6ac60d83b666435d12e9fca7f50b350459", + "algorithm": "sha256" + } + ], + "url": "https://saucelabs.com/downloads/sauce-connect/5.2.2/sauce-connect-5.2.2_darwin.all.zip", + "version": "5.2.2" + } +} diff --git a/tests/__responses__/download_windows_x86_64.json b/tests/__responses__/download_windows_x86_64.json new file mode 100644 index 00000000..0ee1cd56 --- /dev/null +++ b/tests/__responses__/download_windows_x86_64.json @@ -0,0 +1,12 @@ +{ + "download": { + "checksums": [ + { + "value": "fb932db5af5c4ed3dbdae9c939ae77da5d9440a3b0de60701643518af7b53ff1", + "algorithm": "sha256" + } + ], + "url": "https://saucelabs.com/downloads/sauce-connect/5.2.2/sauce-connect-5.2.2_windows.x86_64.zip", + "version": "5.2.2" + } +} diff --git a/tests/__responses__/latest_versions.json b/tests/__responses__/latest_versions.json deleted file mode 100644 index 05479459..00000000 --- a/tests/__responses__/latest_versions.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "downloads": { - "linux": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.1.1/sauce-connect-5.1.1_linux.x86_64.tar.gz", - "sha256": "6247e61e39ff054cf524341a681f4045c557be79bcf63c8c501e634ef2d54a41" - }, - "linux-arm64": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.1.1/sauce-connect-5.1.1_linux.aarch64.tar.gz", - "sha256": "b4951cf64a724ceb03cf0ae676a35a0ca721210c0e42426384dcec23159f1fe5" - }, - "osx": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.1.1/sauce-connect-5.1.1_darwin.all.zip", - "sha256": "d0a29f2df3277e8468c55810ef99685bed632ec6d2eed9fdbd941f2f8faf2354" - }, - "win32": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.1.1/sauce-connect-5.1.1_windows.x86_64.zip", - "sha256": "d3e692cca8b9311a4822e8a6f8715e787319e70dc5551cbb212f5be68d36f7bb" - }, - "windows-arm64": { - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.1.1/sauce-connect-5.1.1_windows.aarch64.zip", - "sha256": "fbb1b15c9a5ae6435cba0dc43f195df32764e5c7b1fcc6a2e625df58ea05642d" - } - }, - "info_url": "https://docs.saucelabs.com/secure-connections/sauce-connect-5/installation/", - "latest_version": "5.1.1", - "download_url": "https://saucelabs.com/downloads/sauce-connect/5.1.1/sauce-connect-5.1.1_darwin.all.zip", - "sha256": "d0a29f2df3277e8468c55810ef99685bed632ec6d2eed9fdbd941f2f8faf2354", - "status": "LATEST", - "client_version": "5.1.1" -} diff --git a/tests/__responses__/versions.json b/tests/__responses__/versions.json deleted file mode 100644 index dab9b3c0..00000000 --- a/tests/__responses__/versions.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "Sauce Connect": { - "download_url": "https://docs.saucelabs.com/secure-connections/sauce-connect/installation/", - "linux": { - "build": "b491a8aaea43d70ef6cb1f37a11191de01fa8446", - "download_url": "https://saucelabs.com/downloads/sc-4.9.2-linux.tar.gz", - "sha1": "5589571bdc186f3f1b05fe6ce68529501a42fb43" - }, - "linux-arm64": { - "build": "b491a8aaea43d70ef6cb1f37a11191de01fa8446", - "download_url": "https://saucelabs.com/downloads/sc-4.9.2-linux-arm64.tar.gz", - "sha1": "8b02c4343b74c36c575817ea4a6eae5fb5718f6c" - }, - "osx": { - "build": "55cc68ffd9a2891e9b717ed459b1d0e970555f9c", - "download_url": "https://saucelabs.com/downloads/sc-4.9.1-osx.zip", - "sha1": "64f9c1bac5d4f5b9acb6fbb629b6df0f5671b4c8" - }, - "version": "4.9.2", - "win32": { - "build": "b491a8aaea43d70ef6cb1f37a11191de01fa8446", - "download_url": "https://saucelabs.com/downloads/sc-4.9.2-win32.zip", - "sha1": "47c19feda3fb684f88acd816e9c8f2e3d4a1e3c0" - } - } -} diff --git a/tests/__snapshots__/index.test.js.snap b/tests/__snapshots__/index.test.js.snap index 481a63c2..7897bb3d 100644 --- a/tests/__snapshots__/index.test.js.snap +++ b/tests/__snapshots__/index.test.js.snap @@ -1,15 +1,31 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should contain all download links 1`] = ` +exports[`should contain expected macos download link 1`] = ` [ [ - "https://api.us-west-1.saucelabs.com/rest/v1/public/tunnels/info/versions", + "https://api.us-west-1.saucelabs.com/rest/v1/public/tunnels/sauce-connect/download", { "responseType": "json", "searchParams": { - "all": true, - "client_host": "darwin-arm64", - "client_version": "5.1.1", + "arch": "x86_64", + "os": "macos", + "version": "5.2.2", + }, + }, + ], +] +`; + +exports[`should contain expected windows download link 1`] = ` +[ + [ + "https://api.us-west-1.saucelabs.com/rest/v1/public/tunnels/sauce-connect/download", + { + "responseType": "json", + "searchParams": { + "arch": "x86_64", + "os": "windows", + "version": "5.2.2", }, }, ], @@ -138,47 +154,20 @@ exports[`should get user by username 1`] = ` ] `; -exports[`should have status latest 1`] = ` -[ - [ - "https://api.us-west-1.saucelabs.com/rest/v1/public/tunnels/info/versions", - { - "responseType": "json", - "searchParams": { - "client_host": "darwin-arm64", - "client_version": "5.1.1", - }, - }, - ], -] -`; - -exports[`startSauceConnect should start sauce connect with fallback default version in case the call to the API failed 1`] = ` -[ - [ - "/foo/bar", - [ - "--proxy-tunnel", - "--tunnel-name=my-tunnel", - "--user=foo", - "--api-key=bar", - "--region=us-west-1", - ], - ], -] -`; - exports[`startSauceConnect should start sauce connect with proper parsed args 1`] = ` [ [ "/foo/bar", [ - "--proxy-tunnel", - "--verbose", - "--tunnel-name=my-tunnel", - "--user=foo", - "--api-key=bar", + "run", + "--proxy-tunnel=abc", + "--metadata=runner=example", + "--verbose=true", + "--username=foo", + "--access-key=bar", + "--proxy=http://example.com:8080", "--region=eu-central-1", + "--tunnel-name=my-tunnel", ], ], ] diff --git a/tests/index.test.js b/tests/index.test.js index ae8598b4..170fbfd3 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -7,9 +7,9 @@ import FormData from 'form-data'; import SauceLabs from '../src'; -import versions from './__responses__/versions.json'; -import allVersions from './__responses__/all_versions.json'; -import latestVersions from './__responses__/latest_versions.json'; +import downloadMacos from './__responses__/download_macos.json'; +import downloadWindows from './__responses__/download_windows_x86_64.json'; +import downloadError from './__responses__/download_error.json'; const instances = []; @@ -423,43 +423,60 @@ test('should fail if file parameter is invalid', async () => { expect(result.message).toContain('Invalid file parameter'); }); -test('should contain all download links', async () => { +test('should contain expected macos download link', async () => { const api = new SauceLabs({user: 'foo', key: 'bar'}); got.mockReturnValue( Promise.resolve({ body: { - ...allVersions, + ...downloadMacos, }, }) ); - const scVersions = await api.scVersions({ - clientVersion: '5.1.1', - clientHost: 'darwin-arm64', - all: true, + const scDownload = await api.scDownload({ + version: '5.2.2', + os: 'macos', + arch: 'x86_64', }); expect(got.mock.calls).toMatchSnapshot(); - expect(scVersions.all_downloads['5.1.1'].linux).toMatchObject({ - download_url: - 'https://saucelabs.com/downloads/sauce-connect/5.1.1/sauce-connect-5.1.1_linux.x86_64.tar.gz', - sha256: '6247e61e39ff054cf524341a681f4045c557be79bcf63c8c501e634ef2d54a41', + expect(scDownload.download).toMatchObject({ + checksums: [ + { + value: + '1384bb85b2d29d177933fc8e894c8f6ac60d83b666435d12e9fca7f50b350459', + algorithm: 'sha256', + }, + ], + url: 'https://saucelabs.com/downloads/sauce-connect/5.2.2/sauce-connect-5.2.2_darwin.all.zip', + version: '5.2.2', }); }); -test('should have status latest', async () => { +test('should contain expected windows download link', async () => { const api = new SauceLabs({user: 'foo', key: 'bar'}); got.mockReturnValue( Promise.resolve({ body: { - ...latestVersions, + ...downloadWindows, }, }) ); - const scVersions = await api.scVersions({ - clientVersion: '5.1.1', - clientHost: 'darwin-arm64', + const scDownload = await api.scDownload({ + version: '5.2.2', + os: 'windows', + arch: 'x86_64', }); expect(got.mock.calls).toMatchSnapshot(); - expect(scVersions.status).toEqual('LATEST'); + expect(scDownload.download).toMatchObject({ + checksums: [ + { + value: + 'fb932db5af5c4ed3dbdae9c939ae77da5d9440a3b0de60701643518af7b53ff1', + algorithm: 'sha256', + }, + ], + url: 'https://saucelabs.com/downloads/sauce-connect/5.2.2/sauce-connect-5.2.2_windows.x86_64.zip', + version: '5.2.2', + }); }); describe('startSauceConnect', () => { @@ -476,52 +493,85 @@ describe('startSauceConnect', () => { ); await api.startSauceConnect({ scVersion: '1.2.3', - tunnelIdentifier: 'my-tunnel', + tunnelName: 'my-tunnel', 'proxy-tunnel': 'abc', + metadata: 'runner=example', verbose: true, region: 'eu', + scUpstreamProxy: 'http://example.com:8080', logger: (log) => logs.push(log), }); expect(spawn).toBeCalledTimes(1); expect(spawn.mock.calls).toMatchSnapshot(); expect(logs).toHaveLength(1); - expect(instances).toHaveLength(1); + expect(instances).toHaveLength(2); }); - it('should start sauce connect with latest version if no version is specified in the args', async () => { + it('should throw an error if there is an error response from the download API', async () => { const logs = []; const api = new SauceLabs({user: 'foo', key: 'bar'}); got.mockReturnValue( Promise.resolve({ body: { - data: { - ...versions, - }, + ...downloadError, }, }) ); - setTimeout( - () => - stdoutEmitter.emit( - 'data', - 'Sauce Connect is up, you may start your tests' - ), - 50 + const err = await api + .startSauceConnect({ + tunnelName: 'my-tunnel', + 'proxy-tunnel': 'abc', + logger: (log) => logs.push(log), + }) + .catch((err) => err); + expect(err.message).toContain('code: 404 message: Invalid input'); + }); + + it('should throw an error if there is an invalid response from the download API', async () => { + const logs = []; + const api = new SauceLabs({user: 'foo', key: 'bar'}); + got.mockReturnValue( + Promise.resolve({ + body: {}, // empty response + }) ); - await api.startSauceConnect({ - tunnelIdentifier: 'my-tunnel', - 'proxy-tunnel': 'abc', - logger: (log) => logs.push(log), - }); + const err = await api + .startSauceConnect({ + tunnelName: 'my-tunnel', + 'proxy-tunnel': 'abc', + logger: (log) => logs.push(log), + }) + .catch((err) => err); + expect(err.message).toBe('Failed to retrieve Sauce Connect download.'); }); - it('should start sauce connect with fallback default version in case the call to the API failed', async () => { + it('should throw an error if the call to the download API failed', async () => { const logs = []; const api = new SauceLabs({user: 'foo', key: 'bar'}); got.mockImplementation(() => { throw new Error('Endpoint not available!'); }); + const err = await api + .startSauceConnect({ + tunnelName: 'my-tunnel', + 'proxy-tunnel': 'abc', + logger: (log) => logs.push(log), + }) + .catch((err) => err); + expect(err.message).toContain('Endpoint not available!'); + }); + + it('should start sauce connect with the default version if no version is specified in the args', async () => { + const logs = []; + const api = new SauceLabs({user: 'foo', key: 'bar'}); + got.mockReturnValue( + Promise.resolve({ + body: { + ...downloadMacos, + }, + }) + ); setTimeout( () => stdoutEmitter.emit( @@ -531,41 +581,26 @@ describe('startSauceConnect', () => { 50 ); await api.startSauceConnect({ - tunnelIdentifier: 'my-tunnel', + tunnelName: 'my-tunnel', 'proxy-tunnel': 'abc', logger: (log) => logs.push(log), }); - expect(spawn.mock.calls).toMatchSnapshot(); }); - it('should properly fail if connection could not be established', async () => { - const errMessage = 'Sauce Connect could not establish a connection'; + it('should properly fail on fatal error', async () => { + const errMessage = 'fatal error exiting: any error message'; const api = new SauceLabs({user: 'foo', key: 'bar'}); setTimeout(() => stdoutEmitter.emit('data', errMessage), 50); const err = await api .startSauceConnect({ scVersion: '1.2.3', - tunnelIdentifier: 'my-tunnel', + tunnelName: 'my-tunnel', 'proxy-tunnel': 'abc', }) .catch((err) => err); expect(err.message).toBe(errMessage); }); - it('should properly fail if user is not authorized', async () => { - const errMessage = 'Sauce Connect failed to start - 401 (Unauthorized).'; - const api = new SauceLabs({user: 'foo', key: 'bar'}); - setTimeout(() => stdoutEmitter.emit('data', errMessage), 50); - const err = await api - .startSauceConnect({ - scVersion: '1.2.3', - tunnelIdentifier: 'my-tunnel', - 'proxy-tunnel': 'abc', - }) - .catch((err) => err); - expect(err.message).toContain(errMessage); - }); - it('should close sauce connect', async () => { const api = new SauceLabs({user: 'foo', key: 'bar'}); setTimeout( @@ -576,13 +611,10 @@ describe('startSauceConnect', () => { ), 50 ); - const sc = await api.startSauceConnect( - {tunnelIdentifier: 'my-tunnel'}, - true - ); + const sc = await api.startSauceConnect({tunnelName: 'my-tunnel'}, true); setTimeout(() => { sc.cp.stdout.emit('data', 'Some other message'); - sc.cp.stdout.emit('data', 'Goodbye'); + sc.cp.stdout.emit('data', 'tunnel was shutdown'); }, 100); await sc.close(); expect(process.kill).toBeCalledWith(123, 'SIGINT'); @@ -592,29 +624,44 @@ describe('startSauceConnect', () => { const api = new SauceLabs({user: 'foo', key: 'bar'}); setTimeout(() => stderrEmitter.emit('data', 'Uuups'), 50); const res = await api - .startSauceConnect({tunnelIdentifier: 'my-tunnel'}) + .startSauceConnect({tunnelName: 'my-tunnel'}) .catch((err) => err); expect(res).toEqual(new Error('Uuups')); }); - it('should not fail if stderr is expected character', async () => { + it('should fail on Sauce Connect v4', async () => { const api = new SauceLabs({user: 'foo', key: 'bar'}); - setTimeout(() => stderrEmitter.emit('data', '\u001b[K'), 50); - setTimeout( - () => - stdoutEmitter.emit( - 'data', - 'Sauce Connect is up, you may start your tests' - ), - 150 - ); + const scVersion = '4.9.2'; const res = await api - .startSauceConnect({tunnelIdentifier: 'my-tunnel'}) + .startSauceConnect({ + tunnelName: 'my-tunnel', + scVersion: scVersion, + }) .catch((err) => err); - expect(res instanceof Error).toBe(false); + expect(res).toEqual( + new Error( + `This Sauce Connect version (${scVersion}) is no longer supported. Please use Sauce Connect 5.` + ) + ); }); }); +it('should fail with an invalid region', async () => { + const api = new SauceLabs({user: 'foo', key: 'bar', region: ''}); + const res = await api + .startSauceConnect({ + tunnelName: 'my-tunnel', + }) + .catch((err) => err); + expect(res).toEqual(new Error(`Missing region`)); +}); + +it('should fail when tunnelName is not given', async () => { + const api = new SauceLabs({user: 'foo', key: 'bar'}); + const res = await api.startSauceConnect({}).catch((err) => err); + expect(res).toEqual(new Error(`Missing tunnel-name`)); +}); + test('should output failure msg for createJob API', async () => { const response = new Error('Response code 422 (Unprocessable Entity)'); response.statusCode = 422; diff --git a/tests/sauceConnectLoader.test.js b/tests/sauceConnectLoader.test.js index 938e3a0b..5eebaf1c 100644 --- a/tests/sauceConnectLoader.test.js +++ b/tests/sauceConnectLoader.test.js @@ -6,11 +6,30 @@ import compressing from 'compressing'; describe('SauceConnectLoader', () => { describe('constructor', () => { - test('should throw if platform is unsupported', () => { - jest.spyOn(utils, 'getPlatform').mockImplementation(() => 'whatever'); - expect( - () => new SauceConnectLoader({sauceConnectVersion: '1.2.3'}) - ).toThrow(ReferenceError, 'Unsupported platform whatever'); + test('should have .exe path for windows platform', () => { + // Enable monkey patching process.platform. + const originalPlatform = process.platform; + let platform = 'win32'; + Object.defineProperty(process, 'platform', {get: () => platform}); + + const scl = new SauceConnectLoader('1.2.3'); + expect(scl.path).toContain('exe'); + + // Restore the original value of process.platform. + platform = originalPlatform; + }); + + test('should not have .exe path for non-windows platform', () => { + // Enable monkey patching process.platform. + const originalPlatform = process.platform; + let platform = 'linux'; + Object.defineProperty(process, 'platform', {get: () => platform}); + + const scl = new SauceConnectLoader('1.2.3'); + expect(scl.path).not.toContain('exe'); + + // Restore the original value of process.platform. + platform = originalPlatform; }); }); @@ -29,9 +48,7 @@ describe('SauceConnectLoader', () => { jest .spyOn(fs.promises, 'stat') .mockImplementation(() => Promise.resolve()); - scl = new SauceConnectLoader({ - sauceConnectVersion: '1.2.3', - }); + scl = new SauceConnectLoader('1.2.3'); }); test('should not attempt to download anything', async () => { @@ -45,14 +62,18 @@ describe('SauceConnectLoader', () => { jest .spyOn(fs.promises, 'stat') .mockImplementation(() => Promise.reject({code: 'ENOENT'})); - scl = new SauceConnectLoader({ - sauceConnectVersion: '1.2.3', - }); + scl = new SauceConnectLoader('1.2.3'); }); test('should attempt to download', async () => { + let url = 'https://this-is-a-test.com'; + await scl.verifyAlreadyDownloaded({url: url}); + expect(scl._download).toHaveBeenCalledWith(url); + }); + + test('should not attempt to download when url is not provided', async () => { await scl.verifyAlreadyDownloaded(); - expect(scl._download).toHaveBeenCalled(); + expect(scl._download).not.toHaveBeenCalled(); }); }); @@ -61,9 +82,7 @@ describe('SauceConnectLoader', () => { jest .spyOn(fs.promises, 'stat') .mockImplementation(() => Promise.reject(new Error())); - scl = new SauceConnectLoader({ - sauceConnectVersion: '1.2.3', - }); + scl = new SauceConnectLoader('1.2.3'); }); test('should reject', () => { @@ -86,7 +105,7 @@ describe('SauceConnectLoader', () => { jest .spyOn(fs.promises, 'rename') .mockImplementation(() => Promise.resolve()); - scl = new SauceConnectLoader({sauceConnectVersion: '1.2.3'}); + scl = new SauceConnectLoader('1.2.3'); mockHttpsGet = jest.spyOn(https, 'get'); mockCompressingLinux = jest @@ -99,33 +118,43 @@ describe('SauceConnectLoader', () => { test('should download and uncompress - linux', async () => { jest.spyOn(utils, 'getPlatform').mockImplementation(() => 'linux'); - await scl._download(); - expect(mockHttpsGet).toHaveBeenCalledWith( - scl.url, - expect.any(Function) - ); + + // linux downloads have .tar.gz compression + const url = 'https://httpbin.org/get/some_file.tar.gz'; + await scl._download(url); + expect(mockHttpsGet).toHaveBeenCalledWith(url, expect.any(Function)); expect(mockCompressingLinux).toHaveBeenCalled(); }); - test('should download and uncompress - mac', async () => { - jest.spyOn(utils, 'getPlatform').mockImplementation(() => 'darwin'); - await scl._download(); - expect(mockHttpsGet).toHaveBeenCalledWith( - scl.url, - expect.any(Function) - ); + test('should download and uncompress - macos', async () => { + jest.spyOn(utils, 'isWindows').mockImplementation(() => false); + + // macos downloads have .zip compression + const url = 'https://httpbin.org/get/some_file.zip'; + await scl._download(url); + expect(mockHttpsGet).toHaveBeenCalledWith(url, expect.any(Function)); expect(mockCompressingWinMac).toHaveBeenCalled(); }); - test('should download and uncompress - win', async () => { - jest.spyOn(utils, 'getPlatform').mockImplementation(() => 'win32'); - await scl._download(); - expect(mockHttpsGet).toHaveBeenCalledWith( - scl.url, - expect.any(Function) - ); + test('should download and uncompress - windows', async () => { + jest.spyOn(utils, 'isWindows').mockImplementation(() => true); + + // windows downloads have .zip compression + const url = 'https://httpbin.org/get/some_file.zip'; + await scl._download(url); + expect(mockHttpsGet).toHaveBeenCalledWith(url, expect.any(Function)); expect(mockCompressingWinMac).toHaveBeenCalled(); }); }); + + describe('_download()', () => { + test('should handle download error', async () => { + const scl = new SauceConnectLoader('1.2.3'); + const url = 'https://this-will-not-resolve.penguin'; + ``; + const err = await scl._download(url).catch((err) => err); + expect(err.message).toContain('this-will-not-resolve.penguin'); + }); + }); }); }); diff --git a/tests/typings/test.ts b/tests/typings/test.ts index 23ad5e7c..9975fdb9 100644 --- a/tests/typings/test.ts +++ b/tests/typings/test.ts @@ -12,8 +12,8 @@ async function foobar() { console.log(job.selenium_version); const sc = await api.startSauceConnect({ - scVersion: '4.5.4', - tunnelIdentifier: '1234', + scVersion: '5.1.3', + tunnelName: '1234', logger: (output: string) => console.log(output), }); sc.cp.pid; diff --git a/tests/utils.test.js b/tests/utils.test.js index 9925399b..4b55b08b 100644 --- a/tests/utils.test.js +++ b/tests/utils.test.js @@ -9,6 +9,7 @@ import { getStrictSsl, getAssetHost, createProxyAgent, + getCPUArch, getPlatform, } from '../src/utils'; @@ -19,7 +20,46 @@ test('createHMAC', async () => { }); test('getPlatform', () => { - expect(getPlatform()).toBe(process.platform); + // Enable monkey patching process.platform. + const originalPlatform = process.platform; + let platform = null; + Object.defineProperty(process, 'platform', {get: () => platform}); + + platform = 'win32'; + expect(getPlatform()).toBe('windows'); + + platform = 'darwin'; + expect(getPlatform()).toBe('macos'); + + platform = 'linux'; + expect(getPlatform()).toBe('linux'); + + // Restore the original value of process.platform. + platform = originalPlatform; +}); + +test('getCPUArch', () => { + let result = process.arch; + if (result == 'x64') { + result = 'x86_64'; + } + expect(getCPUArch()).toBe(result); +}); + +test('getCPUArch', () => { + // Enable monkey patching process.arch. + const originalArch = process.arch; + let arch = null; + Object.defineProperty(process, 'arch', {get: () => arch}); + + arch = 'x64'; + expect(getCPUArch()).toBe('x86_64'); + + arch = 'arm64'; + expect(getCPUArch()).toBe('arm64'); + + // Restore the original value of process.platform. + arch = originalArch; }); test('getAPIHost', () => {