From ae534aeb1d0f2a80b0219ba041545026bb3f9226 Mon Sep 17 00:00:00 2001 From: Aakash Hotchandani Date: Sat, 18 Jul 2026 19:39:58 +0530 Subject: [PATCH] feat(v9): port monorepo main-parity features (caCert SDK-5953, connect-timeout+retry SDK-6152, origin-gate, a11y-browsers) Ports the four main-only (v9) features that the standalone @main branch was behind the WebdriverIO monorepo @main on: - custom CA trust (SDK-5953, #15313): new src/caCert.ts + fetchWrapper/launcher/ service/types wiring for proxyCaCertificate / BROWSERSTACK_EXTRA_CA_CERTS. - connect-timeout + retry (SDK-6152, #15312): fetchWrapper connectTimeoutMs + cli/cliUtils CLI_CONNECT_TIMEOUT_MS + fetchWithRetry. - external-grid origin gate (#15311): config/launcher isBrowserStackInfra so external-grid runs are not reported as Automate. - accessibility browser expansion (#15208): Safari + Chrome for Testing support in constants + util validation. fetchWrapper.ts is the combined monorepo-main end-state carrying both the CA and connect-timeout changes (grpc-independent; grpc types unchanged, still from ./grpc/generated). Unit tests ported for all four features. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/browserstack-service/src/caCert.ts | 136 ++++++++++++++++++ .../browserstack-service/src/cli/cliUtils.ts | 57 +++++++- packages/browserstack-service/src/config.ts | 12 +- .../browserstack-service/src/constants.ts | 14 ++ .../browserstack-service/src/fetchWrapper.ts | 75 +++++++++- packages/browserstack-service/src/launcher.ts | 11 +- packages/browserstack-service/src/service.ts | 4 + packages/browserstack-service/src/types.ts | 7 + packages/browserstack-service/src/util.ts | 96 ++++++++++--- .../tests/cli/cliUtils.test.ts | 72 ++++++++-- .../browserstack-service/tests/config.test.ts | 30 ++++ .../browserstack-service/tests/util.test.ts | 86 +++++++++-- 12 files changed, 549 insertions(+), 51 deletions(-) create mode 100644 packages/browserstack-service/src/caCert.ts diff --git a/packages/browserstack-service/src/caCert.ts b/packages/browserstack-service/src/caCert.ts new file mode 100644 index 0000000..6312699 --- /dev/null +++ b/packages/browserstack-service/src/caCert.ts @@ -0,0 +1,136 @@ +import { setGlobalDispatcher, Agent } from 'undici' +import tls from 'node:tls' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { BStackLogger } from './bstackLogger.js' + +// Convert a DER (binary) certificate Buffer to a PEM string (base64, 64-char lines). +function derToPem(der: Buffer): string { + const b64 = der.toString('base64').replace(/(.{64})/g, '$1\n') + return `-----BEGIN CERTIFICATE-----\n${b64}${b64.endsWith('\n') ? '' : '\n'}-----END CERTIFICATE-----\n` +} + +// Read a customer CA Buffer into an array of PEM cert strings, supporting BOTH PEM +// (single or multi-cert bundle) and DER (binary) — any extension (.pem/.crt/.cer/.der). +function loadCaCertsAsPem(buf: Buffer): string[] { + if (buf.includes('-----BEGIN CERTIFICATE-----')) { + return buf.toString('utf8').match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) || [] + } + return [derToPem(buf)] // DER (binary) single cert +} + +/* + * SDK-5953: trust a customer-provided CA certificate for SSL-inspecting corporate + * proxies (Zscaler, Netskope, Forcepoint). + * + * Resolution order for the cert path: + * 1. env BROWSERSTACK_EXTRA_CA_CERTS (consistency with the other SDKs) + * 2. the `proxyCaCertificate` service option + * + * The service makes outbound HTTPS via undici `fetch`: most call sites use the + * global fetch (covered by a global undici Agent), and the proxy path in + * fetchWrapper uses a ProxyAgent (which needs the CA on `requestTls`). We build a + * MERGED ca list (Node's default roots + the customer cert) so non-intercepted + * endpoints still validate. Also export NODE_EXTRA_CA_CERTS for child processes. + * + * Never throws — a misconfigured cert must not break the customer's run. + */ + +let mergedCa: string[] | undefined +let configured = false + +function resolveCaCertPath(options?: { proxyCaCertificate?: string }): string | undefined { + let p = process.env.BROWSERSTACK_EXTRA_CA_CERTS + if ((!p || !p.trim()) && options?.proxyCaCertificate) { + p = options.proxyCaCertificate + } + if (!p || !String(p).trim()) { + return undefined + } + p = String(p).trim() + try { + if (fs.existsSync(p) && fs.statSync(p).isFile()) { + return p + } + BStackLogger.warn(`proxyCaCertificate: path does not exist or is not a file, falling back to system trust store: ${p}`) + } catch (e) { + BStackLogger.warn(`proxyCaCertificate: failed to stat cert path ${p}: ${(e as Error).message}`) + } + return undefined +} + +/** The merged CA list (system roots + customer cert), or undefined when not configured. */ +export function getMergedCa(): string[] | undefined { + return mergedCa +} + +/** Idempotent. Sets a global undici dispatcher trusting the merged CA, and NODE_EXTRA_CA_CERTS. */ +export function configureCaCertificate(options?: { proxyCaCertificate?: string }): void { + if (configured) { + return + } + try { + const certPath = resolveCaCertPath(options) + if (!certPath) { + return + } + const buf = fs.readFileSync(certPath) + const isPem = buf.includes('-----BEGIN CERTIFICATE-----') + const pemCerts = loadCaCertsAsPem(buf) + if (!pemCerts.length) { + BStackLogger.warn(`proxyCaCertificate: no certificate found in ${certPath}; falling back to system trust store.`) + return + } + // Merge (not replace) the customer cert(s) with Node's default roots. Covers every + // global fetch() call in this process (undici) + the fetchWrapper ProxyAgent tunnel. + mergedCa = [...tls.rootCertificates, ...pemCerts] + // NOTE: setGlobalDispatcher REPLACES (not extends) the process-wide undici dispatcher. + // Safe here because we merge with the system roots, so every fetch() in the process still + // validates public endpoints (and still rejects the MITM cert when no custom CA is set). + // Trade-off: if something had already installed a custom global dispatcher (tuned + // timeouts/pools, interceptors), that config is discarded — the idiomatic undici approach. + setGlobalDispatcher(new Agent({ connect: { ca: mergedCa } })) + // The custom CA is now trusted for this process's undici fetch — the primary effect is + // done, so mark configured BEFORE the secondary NODE_EXTRA_CA_CERTS export below. This + // stops a failure in that best-effort export from (a) being logged as a total "setup + // failed / falling back to system trust store" when the dispatcher is in fact active, and + // (b) leaving `configured` false so a later call re-installs the global dispatcher. + configured = true + BStackLogger.info(`proxyCaCertificate: trusting custom CA from ${certPath} (merged with system roots).`) + // Child Node processes (e.g. the detached cleanup spawn) inherit NODE_EXTRA_CA_CERTS and + // trust it at startup. It must be a PEM file: reuse the customer's path when already PEM, + // else write a PEM-converted copy (Node can't load a raw DER through that var). + // SCOPE: NODE_EXTRA_CA_CERTS is Node-only — it covers this service's outbound HTTPS and + // detached Node children, but NOT the BrowserStack Local (Go) binary, which has its own + // proxy flags (--proxy-host, etc.). proxyCaCertificate intentionally covers the service's + // egress, not the Local tunnel binary (consistent with the Java agent's tool-scope). + // Best-effort + scoped: a failure here only affects detached children, not this process + // (which already trusts the CA via the global dispatcher installed above). + try { + if (!process.env.NODE_EXTRA_CA_CERTS) { + let nodeExtra = certPath + if (!isPem) { + // Write the PEM-converted trust anchor into a fresh, owner-only temp dir + // (random name via mkdtemp) with mode 0600 + O_EXCL/O_NOFOLLOW. A predictable + // path in a world-writable tmpdir would let a local attacker pre-plant or + // symlink-race the file the process is about to TRUST as a CA. + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browserstack_sdk_ca_')) + nodeExtra = path.join(tmpDir, 'ca.pem') + const fd = fs.openSync(nodeExtra, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600) + try { + fs.writeFileSync(fd, pemCerts.join('')) + } finally { + fs.closeSync(fd) + } + } + process.env.NODE_EXTRA_CA_CERTS = nodeExtra + } + } catch (e) { + BStackLogger.warn(`proxyCaCertificate: CA is trusted for this process, but exporting NODE_EXTRA_CA_CERTS for detached child processes failed (children may not trust the custom CA): ${(e as Error).message}`) + } + } catch (e) { + BStackLogger.warn(`proxyCaCertificate: setup failed, falling back to system trust store: ${(e as Error).message}`) + } +} diff --git a/packages/browserstack-service/src/cli/cliUtils.ts b/packages/browserstack-service/src/cli/cliUtils.ts index 4d68079..b584f1a 100644 --- a/packages/browserstack-service/src/cli/cliUtils.ts +++ b/packages/browserstack-service/src/cli/cliUtils.ts @@ -43,6 +43,15 @@ const CLI_LOCK_POLL_MS = 1000 const CLI_DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000 const CLI_DOWNLOAD_TMP_PREFIX = 'downloaded_file_' const CLI_DOWNLOAD_TMP_SUFFIX = '.zip' +// Connection-establishment timeout for CLI network calls (update_cli + binary +// download). Node's global fetch hard-codes this at 10s and it is NOT overridable +// via AbortSignal, so a slow TLS/TCP handshake in a constrained/containerised CI +// network (e.g. Kubernetes pods) aborts with UND_ERR_CONNECT_TIMEOUT even though +// the endpoint is healthy (SDK-6152). Routed through a custom undici dispatcher. +// Overridable via env for tuning. +const CLI_CONNECT_TIMEOUT_MS = Number(process.env.BROWSERSTACK_CLI_CONNECT_TIMEOUT_MS) || 60 * 1000 +const CLI_FETCH_MAX_ATTEMPTS = 3 +const CLI_FETCH_RETRY_BASE_DELAY_MS = 500 export class CLIUtils { static automationFrameworkDetail = {} @@ -199,7 +208,9 @@ export class CLIUtils { logger.debug(`Resolved binary path: ${finalBinaryPath}`) return finalBinaryPath } catch (err) { - logger.debug( + // Surface at warn (not debug) so a failed CLI setup is visible instead of + // the run exiting silently with no results (SDK-6152). + logger.warn( `Error in setting up cli path directory, Exception: ${util.format(err)}`, ) } @@ -412,6 +423,39 @@ export class CLIUtils { } } + /** + * fetch wrapper for CLI network calls that (a) raises undici's connect timeout + * above the non-overridable 10s default of global fetch and (b) retries a few + * times with linear backoff on transient connection failures. Both are needed + * for reliability in constrained CI/containerised networks (SDK-6152). + */ + static fetchWithRetry = async ( + input: string, + init: RequestInit, + label: string, + ): Promise => { + let lastError: unknown + for (let attempt = 1; attempt <= CLI_FETCH_MAX_ATTEMPTS; attempt++) { + try { + return await fetch(input, init, { connectTimeoutMs: CLI_CONNECT_TIMEOUT_MS }) + } catch (err) { + lastError = err + const reason = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (err as any)?.cause?.code || (err as any)?.code || (err as Error)?.message + logger.warn( + `${label} attempt ${attempt}/${CLI_FETCH_MAX_ATTEMPTS} failed: ${reason}`, + ) + if (attempt < CLI_FETCH_MAX_ATTEMPTS) { + await new Promise((resolve) => + setTimeout(resolve, CLI_FETCH_RETRY_BASE_DELAY_MS * attempt), + ) + } + } + } + throw lastError + } + static requestToUpdateCLI = async ( queryParams: Record, config: Options.Testrunner, @@ -423,9 +467,10 @@ export class CLIUtils { Authorization: `Basic ${Buffer.from(`${getBrowserStackUser(config)}:${getBrowserStackKey(config)}`).toString('base64')}`, }, } - const response = await fetch( + const response = await this.fetchWithRetry( `${APIUtils.BROWSERSTACK_AUTOMATE_API_URL}/${UPDATED_CLI_ENDPOINT}?${params.toString()}`, requestInit, + 'update_cli request', ) const jsonResponse = await response.json() logger.debug(`response ${JSON.stringify(jsonResponse)}`) @@ -663,9 +708,11 @@ export class CLIUtils { ) let response: Response try { - response = await fetch(binDownloadUrl, { - signal: abortController.signal, - }) + response = await fetch( + binDownloadUrl, + { signal: abortController.signal }, + { connectTimeoutMs: CLI_CONNECT_TIMEOUT_MS }, + ) } finally { clearTimeout(timeout) } diff --git a/packages/browserstack-service/src/config.ts b/packages/browserstack-service/src/config.ts index 394ad0d..563c500 100644 --- a/packages/browserstack-service/src/config.ts +++ b/packages/browserstack-service/src/config.ts @@ -65,9 +65,10 @@ class BrowserStackConfig { options?: BrowserstackConfig & Options.Testrunner, config?: Options.Testrunner, capabilities?: Capabilities.TestrunnerCapabilities, + isBrowserStackInfra?: boolean, ): BrowserStackConfig { if (!this._instance && options && config) { - this._instance = new BrowserStackConfig(options, config, capabilities) + this._instance = new BrowserStackConfig(options, config, capabilities, isBrowserStackInfra) } return this._instance } @@ -94,6 +95,7 @@ class BrowserStackConfig { options: BrowserstackConfig & Options.Testrunner, config: Options.Testrunner, capabilities?: Capabilities.TestrunnerCapabilities, + isBrowserStackInfra: boolean = true, ) { this.framework = config.framework this.userName = config.user @@ -102,8 +104,12 @@ class BrowserStackConfig { this.percy = options.percy || false this.accessibility = options.accessibility this.app = options.app - this.appAutomate = !isUndefined(options.app) || detectAppAutomate(capabilities) - this.automate = !this.appAutomate + // `automate`/`app_automate` describe a BrowserStack-hosted run. When the session + // runs on an external (non-BrowserStack) grid — e.g. Test Observability only, with + // credentials inside testObservabilityOptions — neither should be set, otherwise the + // build is reported with origin `Automate` even though it never touched BrowserStack. + this.appAutomate = isBrowserStackInfra && (!isUndefined(options.app) || detectAppAutomate(capabilities)) + this.automate = isBrowserStackInfra && !this.appAutomate this.buildIdentifier = options.buildIdentifier this.sdkRunID = uuidv4() BStackLogger.info(`BrowserStack service started with id: ${this.sdkRunID}`) diff --git a/packages/browserstack-service/src/constants.ts b/packages/browserstack-service/src/constants.ts index 759b322..6fe5536 100644 --- a/packages/browserstack-service/src/constants.ts +++ b/packages/browserstack-service/src/constants.ts @@ -76,6 +76,20 @@ export const EDS_URL = 'https://eds.browserstack.com' export const SUPPORTED_BROWSERS_FOR_AI = ['chrome', 'microsoftedge', 'firefox'] +export const SUPPORTED_BROWSERS_FOR_ACCESSIBILITY = ['chrome', 'chromefortesting', 'safari'] + +export const MIN_BROWSER_VERSIONS_A11Y = { + chrome: 95, + chromefortesting: 141, + safari: 18.4 +} as const + +export const MIN_BROWSER_VERSIONS_A11Y_NON_BSTACK = { + chrome: 100, + chromefortesting: 141, + safari: 18.4 +} as const + export const TCG_URL = 'https://tcg.browserstack.com' export const TCG_INFO = { diff --git a/packages/browserstack-service/src/fetchWrapper.ts b/packages/browserstack-service/src/fetchWrapper.ts index 9dbb014..f003afe 100644 --- a/packages/browserstack-service/src/fetchWrapper.ts +++ b/packages/browserstack-service/src/fetchWrapper.ts @@ -1,4 +1,6 @@ -import { fetch as undiciFetch, type RequestInit as UndiciRequestInit, ProxyAgent } from 'undici' +import { fetch as undiciFetch, type RequestInit as UndiciRequestInit, ProxyAgent, Agent, type Dispatcher } from 'undici' + +import { getMergedCa } from './caCert.js' export class ResponseError extends Error { public response: Response @@ -8,15 +10,64 @@ export class ResponseError extends Error { } } -export default async function fetchWrap(input: RequestInfo | URL, init?: RequestInit) { - const res = await _fetch(input, init) +/** + * Extra options for {@link _fetch} that cannot be expressed through the standard + * `RequestInit`. + * + * `connectTimeoutMs` overrides undici's connection-establishment timeout. Node's + * global `fetch` hard-codes this to 10s and it is NOT overridable via + * `AbortSignal` (the signal only bounds the overall request, not the socket + * connect). In constrained/containerised CI networks (e.g. Kubernetes pods) a + * TLS/TCP handshake can legitimately take longer than 10s, so global fetch aborts + * with `UND_ERR_CONNECT_TIMEOUT` even though the endpoint is healthy. Supplying + * this option routes the request through a custom undici dispatcher with a larger + * connect timeout. + */ +export interface FetchOptions { + connectTimeoutMs?: number +} + +// Dispatchers are relatively expensive to build and hold a connection pool, so we cache one per +// (proxy, connectTimeout, hasCa) combination instead of creating a new one on every request. +// SDK-5953: when a customer `proxyCaCertificate` is configured, an explicit dispatcher bypasses the +// global one installed in caCert.ts, so the merged CA (system roots + customer cert) must be applied +// here too — on the proxy tunnel's upstream TLS (`requestTls.ca`) and on a direct `Agent` +// (`connect.ca`). `hasCa` is a sufficient cache discriminator since the merged CA is process-stable. +const dispatcherCache = new Map() + +function getDispatcher(proxyUrl: string | undefined, connectTimeoutMs?: number): Dispatcher { + const ca = getMergedCa() + const key = `${proxyUrl ?? 'direct'}:${connectTimeoutMs ?? 'default'}:${ca ? 'ca' : 'noca'}` + let dispatcher = dispatcherCache.get(key) + if (!dispatcher) { + const connect: { timeout?: number, ca?: string[] } = {} + if (connectTimeoutMs) { + connect.timeout = connectTimeoutMs + } + if (proxyUrl) { + // requestTls.ca = upstream (through-tunnel) TLS, which the inspecting proxy re-signs. + dispatcher = new ProxyAgent({ uri: proxyUrl, connect, ...(ca ? { requestTls: { ca } } : {}) }) + } else { + if (ca) { + connect.ca = ca + } + dispatcher = new Agent({ connect }) + } + dispatcherCache.set(key, dispatcher) + } + return dispatcher +} + +export default async function fetchWrap(input: RequestInfo | URL, init?: RequestInit, options?: FetchOptions) { + const res = await _fetch(input, init, options) if (!res.ok) { throw new ResponseError(`Error response from server ${res.status}: ${await res.text()}`, res) } return res } -export function _fetch(input: RequestInfo | URL, init?: RequestInit) { +export function _fetch(input: RequestInfo | URL, init?: RequestInit, options?: FetchOptions) { + const connectTimeoutMs = options?.connectTimeoutMs const proxyUrl = process.env.HTTP_PROXY || process.env.HTTPS_PROXY if (proxyUrl) { const noProxy = process.env.NO_PROXY && process.env.NO_PROXY.trim() @@ -25,11 +76,25 @@ export function _fetch(input: RequestInfo | URL, init?: RequestInit) { const request = new Request(input) const url = new URL(request.url) if (!noProxy.some((str) => url.hostname.endsWith(str))) { + // Always route the proxy tunnel through getDispatcher: an explicit dispatcher bypasses + // the global one, so its upstream TLS must trust the customer CA (SDK-5953). getDispatcher + // also applies connectTimeoutMs when provided and caches the agent per key. + const dispatcher = getDispatcher(proxyUrl, connectTimeoutMs) return undiciFetch( request.url, - { ...(init as UndiciRequestInit), dispatcher: new ProxyAgent(proxyUrl) }, + { ...(init as UndiciRequestInit), dispatcher }, ) as unknown as Promise } } + // Without a proxy, global fetch's connect timeout is fixed at 10s and cannot be + // overridden, so when a caller needs a larger one we go through undici directly + // with a custom Agent (which also carries the custom CA when configured). The default + // path is left untouched — non-proxy fetch already trusts the CA via the global dispatcher. + if (connectTimeoutMs) { + return undiciFetch( + new Request(input).url, + { ...(init as UndiciRequestInit), dispatcher: getDispatcher(undefined, connectTimeoutMs) }, + ) as unknown as Promise + } return fetch(input, init) } diff --git a/packages/browserstack-service/src/launcher.ts b/packages/browserstack-service/src/launcher.ts index a1c2380..8c8c3cf 100644 --- a/packages/browserstack-service/src/launcher.ts +++ b/packages/browserstack-service/src/launcher.ts @@ -33,6 +33,7 @@ import { stopBuildUpstream, getCiInfo, isBStackSession, + isBrowserstackInfra, isUndefined, isAccessibilityAutomationSession, isTrue, @@ -51,6 +52,7 @@ import { import CrashReporter from './crash-reporter.js' import { finalizeOrphanedRuns } from './testOps/openRunsJournal.js' import { BStackLogger } from './bstackLogger.js' +import { configureCaCertificate } from './caCert.js' import { PercyLogger } from './Percy/PercyLogger.js' import type Percy from './Percy/Percy.js' import BrowserStackConfig from './config.js' @@ -88,6 +90,9 @@ export default class BrowserstackLauncherService implements Services.ServiceInst BStackLogger.clearLogFile() PercyLogger.clearLogFile() setupExitHandlers() + // SDK-5953: trust the customer CA (proxyCaCertificate / BROWSERSTACK_EXTRA_CA_CERTS) + // for all outbound HTTPS (undici fetch) before any request fires. Merged with system roots. + configureCaCertificate(this._options) // added to maintain backward compatibility with webdriverIO v5 if (!this._config) { this._config = _options @@ -118,7 +123,11 @@ export default class BrowserstackLauncherService implements Services.ServiceInst process.env.TEST_OBSERVABILITY_BUILD_TAG = process.env.TEST_REPORTING_BUILD_TAG } - this.browserStackConfig = BrowserStackConfig.getInstance(_options, _config, capabilities) + // Pass `capabilities` so per-capability `hostname` overrides are honored too (e.g. a + // multi-remote config where only some capabilities target an external grid), mirroring + // the `shouldAddServiceVersion`/`isBrowserstackInfra` usage elsewhere in this package. + const isBrowserStackInfra = isBrowserstackInfra(_config as BrowserstackConfig & Options.Testrunner, capabilities as Capabilities.BrowserStackCapabilities) + this.browserStackConfig = BrowserStackConfig.getInstance(_options, _config, capabilities, isBrowserStackInfra) BStackLogger.debug(`_options data: ${JSON.stringify(_options)}`) BStackLogger.debug(`webdriver capabilities data: ${JSON.stringify(capabilities)}`) const configCopy = JSON.parse(JSON.stringify(_config)) diff --git a/packages/browserstack-service/src/service.ts b/packages/browserstack-service/src/service.ts index c5f21d1..99281ed 100644 --- a/packages/browserstack-service/src/service.ts +++ b/packages/browserstack-service/src/service.ts @@ -16,6 +16,7 @@ import type { Pickle, Feature, ITestCaseHookParameter, CucumberHook } from './cu import InsightsHandler from './insights-handler.js' import TestReporter from './reporter.js' import { DEFAULT_OPTIONS, NOT_ALLOWED_KEYS_IN_CAPS, PERF_MEASUREMENT_ENV } from './constants.js' +import { configureCaCertificate } from './caCert.js' import CrashReporter from './crash-reporter.js' import AccessibilityHandler from './accessibility-handler.js' import CustomTagsHandler from './custom-tags-handler.js' @@ -91,6 +92,9 @@ export default class BrowserstackService implements Services.ServiceInstance { private _config: Options.Testrunner ) { this._options = { ...DEFAULT_OPTIONS, ...options } + // SDK-5953: trust the customer CA (proxyCaCertificate / BROWSERSTACK_EXTRA_CA_CERTS) + // for all outbound HTTPS (undici fetch) in the worker process. Merged with system roots. + configureCaCertificate(this._options) // added to maintain backward compatibility with webdriverIO v5 if (!this._config) { this._config = this._options diff --git a/packages/browserstack-service/src/types.ts b/packages/browserstack-service/src/types.ts index a41d085..285872a 100644 --- a/packages/browserstack-service/src/types.ts +++ b/packages/browserstack-service/src/types.ts @@ -65,6 +65,13 @@ export interface BrowserstackOptions extends Options.Testrunner { } export interface BrowserstackConfig { + /** + * Absolute path to a CA certificate (PEM, single cert or bundle) to trust for outbound + * HTTPS when behind an SSL-inspecting corporate proxy (Zscaler/Netskope/Forcepoint). + * Overridable via the `BROWSERSTACK_EXTRA_CA_CERTS` env var. Merged with the system + * trust store (never replaces it). + */ + proxyCaCertificate?: string; /** *`buildIdentifier` is a unique id to differentiate every execution that gets appended to * buildName. Choose your buildIdentifier format from the available expressions: diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index 136eebd..30b94e6 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -43,7 +43,10 @@ import { APP_ALLY_ISSUES_SUMMARY_ENDPOINT, APP_ALLY_ISSUES_ENDPOINT, CLI_DEBUG_LOGS_FILE, - WDIO_NAMING_PREFIX + WDIO_NAMING_PREFIX, + MIN_BROWSER_VERSIONS_A11Y, + MIN_BROWSER_VERSIONS_A11Y_NON_BSTACK, + SUPPORTED_BROWSERS_FOR_ACCESSIBILITY } from './constants.js' import CrashReporter from './crash-reporter.js' import { BStackLogger } from './bstackLogger.js' @@ -484,20 +487,44 @@ export const validateCapsWithA11y = (deviceName?: any, platformMeta?: { [key: st return false } - if (platformMeta?.browser_name?.toLowerCase() !== 'chrome') { - BStackLogger.warn('Accessibility Automation will run only on Chrome browsers.') - return false - } + const browserName = platformMeta?.browser_name?.toLowerCase() const browserVersion = platformMeta?.browser_version - if ( !isUndefined(browserVersion) && !(browserVersion === 'latest' || parseFloat(browserVersion + '') > 94)) { - BStackLogger.warn('Accessibility Automation will run only on Chrome browser version greater than 94.') + + const validBrowsers = SUPPORTED_BROWSERS_FOR_ACCESSIBILITY + if (!browserName || !validBrowsers.includes(browserName)) { + BStackLogger.warn(`Accessibility Automation supports Chrome 95+, Chrome for Testing 141+, and Safari 18.4+. Current browser: ${browserName}`) return false } - if (chromeOptions?.args?.includes('--headless')) { - BStackLogger.warn('Accessibility Automation will not run on legacy headless mode. Switch to new headless mode or avoid using headless mode.') - return false + if (browserName === 'chrome' || browserName === 'chromefortesting') { + const minVersion = MIN_BROWSER_VERSIONS_A11Y[browserName as keyof typeof MIN_BROWSER_VERSIONS_A11Y] + if (browserVersion && browserVersion !== 'latest') { + const version = parseInt(browserVersion.toString().split('.')[0] || '0', 10) + if (version < minVersion) { + BStackLogger.warn(`Accessibility Automation requires ${browserName === 'chrome' ? 'Chrome' : 'Chrome for Testing'} version ${minVersion} or higher.`) + return false + } + } + + if (chromeOptions?.args?.includes('--headless')) { + BStackLogger.warn('Accessibility Automation will not run on legacy headless mode. Switch to new headless mode or avoid using headless mode.') + return false + } + } + + // Safari validation + if (browserName === 'safari') { + if (browserVersion && browserVersion !== 'latest') { + const [currentMajor = 0, currentMinor = 0] = browserVersion.toString().split('.').map(Number) + const [requiredMajor = 0, requiredMinor = 0] = MIN_BROWSER_VERSIONS_A11Y.safari.toString().split('.').map(Number) + + if (currentMajor < requiredMajor || (currentMajor === requiredMajor && currentMinor < requiredMinor)) { + BStackLogger.warn(`Accessibility Automation requires Safari version ${MIN_BROWSER_VERSIONS_A11Y.safari} or higher.`) + return false + } + } } + return true } catch (error) { BStackLogger.debug(`Exception in checking capabilities compatibility with Accessibility. Error: ${error}`) @@ -505,18 +532,47 @@ export const validateCapsWithA11y = (deviceName?: any, platformMeta?: { [key: st return false } -export const validateCapsWithNonBstackA11y = (browserName?: string | undefined, browserVersion?:string | undefined ) => { +export const validateCapsWithNonBstackA11y = (browserName?: string | undefined, browserVersion?:string | undefined) => { + try { + const browser = browserName?.toLowerCase() - if (browserName?.toLowerCase() !== 'chrome') { - BStackLogger.warn('Accessibility Automation will run only on Chrome browsers.') - return false - } - if (!isUndefined(browserVersion) && !(browserVersion === 'latest' || parseFloat(browserVersion + '') > 100)) { - BStackLogger.warn('Accessibility Automation will run only on Chrome browser version greater than 100.') - return false - } - return true + // Support Chrome, Chrome for Testing (ChromeForTesting), and Safari on non-BrowserStack infrastructure + const validBrowsers = ['chrome', 'chromefortesting', 'safari'] + if (!browser || !validBrowsers.includes(browser)) { + BStackLogger.warn('Accessibility Automation on non-BrowserStack infrastructure supports Chrome 100+, Chrome for Testing 141+, and Safari 18.4+.') + return false + } + + // Chrome/Chrome for Testing validation + if (browser === 'chrome' || browser === 'chromefortesting') { + const minVersion = MIN_BROWSER_VERSIONS_A11Y_NON_BSTACK[browser as keyof typeof MIN_BROWSER_VERSIONS_A11Y_NON_BSTACK] + if (browserVersion && browserVersion !== 'latest') { + const version = parseInt(browserVersion.toString().split('.')[0] || '0', 10) + if (version < minVersion) { + BStackLogger.warn(`Accessibility Automation requires ${browser === 'chrome' ? 'Chrome' : 'Chrome for Testing'} version ${minVersion}+ on non-BrowserStack infrastructure.`) + return false + } + } + } + + // Safari validation + if (browser === 'safari') { + if (browserVersion && browserVersion !== 'latest') { + const [currentMajor = 0, currentMinor = 0] = browserVersion.toString().split('.').map(Number) + const [requiredMajor = 0, requiredMinor = 0] = MIN_BROWSER_VERSIONS_A11Y_NON_BSTACK.safari.toString().split('.').map(Number) + if (currentMajor < requiredMajor || (currentMajor === requiredMajor && currentMinor < requiredMinor)) { + BStackLogger.warn(`Accessibility Automation requires Safari version ${MIN_BROWSER_VERSIONS_A11Y_NON_BSTACK.safari}+ on non-BrowserStack infrastructure.`) + return false + } + } + } + + return true + } catch (error) { + BStackLogger.debug(`Exception in checking capabilities compatibility with Accessibility. Error: ${error}`) + } + return false } export const shouldScanTestForAccessibility = (suiteTitle: string | undefined, testTitle: string, accessibilityOptions?: { [key: string]: string; }, world?: { [key: string]: unknown; }, isCucumber?: boolean ) => { diff --git a/packages/browserstack-service/tests/cli/cliUtils.test.ts b/packages/browserstack-service/tests/cli/cliUtils.test.ts index 77f1605..0b12350 100644 --- a/packages/browserstack-service/tests/cli/cliUtils.test.ts +++ b/packages/browserstack-service/tests/cli/cliUtils.test.ts @@ -11,6 +11,17 @@ import PerformanceTester from '../../src/instrumentation/performance/performance import { EVENTS as PerformanceEvents } from '../../src/instrumentation/performance/constants.js' import type { Options } from '@wdio/types' import type { BrowserstackConfig, BrowserstackOptions } from '../../src/types.js' +import APIUtils from '../../src/cli/apiUtils.js' +import type * as FetchWrapperModule from '../../src/fetchWrapper.js' + +// CLI network calls go through fetchWrapper._fetch (which uses a custom undici +// dispatcher for the connect-timeout override), not the global fetch, so we mock +// that boundary. Other fetchWrapper exports are preserved. +const mockFetch = vi.hoisted(() => vi.fn()) +vi.mock('../../src/fetchWrapper.js', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, _fetch: mockFetch } +}) const bstackLoggerSpy = vi.spyOn(bstackLogger.BStackLogger, 'logToFile') bstackLoggerSpy.mockImplementation(() => {}) @@ -522,7 +533,7 @@ describe('CLIUtils', () => { vi.resetAllMocks() // Mock fetch to return a mock response - global.fetch = vi.fn().mockResolvedValue({ + mockFetch.mockResolvedValue({ json: vi.fn().mockResolvedValue({ status: 'success' }) }) }) @@ -538,30 +549,33 @@ describe('CLIUtils', () => { } const mockJsonResponse = { updated_cli_version: '2.0.0' } - global.fetch = vi.fn().mockResolvedValue({ + mockFetch.mockResolvedValue({ json: vi.fn().mockResolvedValue(mockJsonResponse) }) await CLIUtils.requestToUpdateCLI(queryParams, mockConfig) - expect(global.fetch).toHaveBeenCalledWith( + expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining('param1=value1'), expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ Authorization: expect.stringContaining('Basic') }) - }) + }), + // connect-timeout override is always supplied for CLI calls (SDK-6152) + expect.objectContaining({ connectTimeoutMs: expect.any(Number) }) ) - expect(global.fetch).toHaveBeenCalledWith( + expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining('param2=value2'), + expect.any(Object), expect.any(Object) ) }) it('returns response from fetch', async () => { const mockResponse = { updated_cli_version: '2.0.0' } - global.fetch = vi.fn().mockResolvedValue({ + mockFetch.mockResolvedValue({ json: vi.fn().mockResolvedValue(mockResponse) }) @@ -570,9 +584,9 @@ describe('CLIUtils', () => { expect(result).toEqual(mockResponse) }) - it('handles errors from fetch', async () => { + it('handles errors from fetch after exhausting retries', async () => { const mockError = new Error('Network error') - global.fetch = vi.fn().mockRejectedValue(mockError) + mockFetch.mockRejectedValue(mockError) await expect(CLIUtils.requestToUpdateCLI({}, mockConfig)) .rejects @@ -580,6 +594,48 @@ describe('CLIUtils', () => { }) }) + describe('fetchWithRetry', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it('passes the CLI connect-timeout override through to _fetch', async () => { + const res = { ok: true } as unknown as Response + mockFetch.mockResolvedValue(res) + + const result = await CLIUtils.fetchWithRetry('https://example.com', { method: 'GET' }, 'test') + + expect(result).toBe(res) + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com', + { method: 'GET' }, + expect.objectContaining({ connectTimeoutMs: expect.any(Number) }) + ) + }) + + it('retries on transient failure and then succeeds', async () => { + const res = { ok: true } as unknown as Response + mockFetch + .mockRejectedValueOnce(new Error('connect fail')) + .mockResolvedValueOnce(res) + + const result = await CLIUtils.fetchWithRetry('https://example.com', {}, 'test') + + expect(result).toBe(res) + expect(mockFetch).toHaveBeenCalledTimes(2) + }) + + it('throws the last error after exhausting all attempts', async () => { + mockFetch.mockRejectedValue(new Error('always fails')) + + await expect(CLIUtils.fetchWithRetry('https://example.com', {}, 'test')) + .rejects + .toThrow('always fails') + expect(mockFetch).toHaveBeenCalledTimes(3) + }) + }) + describe('runShellCommand', () => { it('resolves with stdout for successful command', async () => { const result = await CLIUtils.runShellCommand('echo test') diff --git a/packages/browserstack-service/tests/config.test.ts b/packages/browserstack-service/tests/config.test.ts index c245657..8b43875 100644 --- a/packages/browserstack-service/tests/config.test.ts +++ b/packages/browserstack-service/tests/config.test.ts @@ -55,3 +55,33 @@ describe('BrowserStackConfig appAutomate detection', () => { expect(cfg.appAutomate).toBe(true) }) }) + +describe('BrowserStackConfig isBrowserStackInfra gating', () => { + beforeEach(() => { + (BrowserStackConfig as any)._instance = undefined + }) + + it('marks neither automate nor app_automate for a web run on an external (non-BrowserStack) grid', () => { + const cfg = new BrowserStackConfig(baseOptions, baseConfig, [ + { browserName: 'chrome' }, + ] as any, false) + expect(cfg.automate).toBe(false) + expect(cfg.appAutomate).toBe(false) + }) + + it('does not mark app_automate from app caps when not on BrowserStack infra', () => { + const cfg = new BrowserStackConfig(baseOptions, baseConfig, [ + { platformName: 'iOS', 'appium:app': 'bs://xyz' }, + ] as any, false) + expect(cfg.appAutomate).toBe(false) + expect(cfg.automate).toBe(false) + }) + + it('keeps automate true for a web run on BrowserStack infra', () => { + const cfg = new BrowserStackConfig(baseOptions, baseConfig, [ + { browserName: 'chrome' }, + ] as any, true) + expect(cfg.automate).toBe(true) + expect(cfg.appAutomate).toBe(false) + }) +}) diff --git a/packages/browserstack-service/tests/util.test.ts b/packages/browserstack-service/tests/util.test.ts index 1260156..1673204 100644 --- a/packages/browserstack-service/tests/util.test.ts +++ b/packages/browserstack-service/tests/util.test.ts @@ -1252,17 +1252,17 @@ describe('validateCapsWithA11y', () => { .toContain('Accessibility Automation will run only on Desktop browsers.') }) - it('returns false if browser is not chrome', async () => { + it('returns false if browser is not supported', async () => { const platformMeta = { - 'browser_name': 'safari' + 'browser_name': 'firefox' } expect(validateCapsWithA11y(undefined, platformMeta)).toEqual(false) expect(logInfoMock.mock.calls[0][0]) - .toContain('Accessibility Automation will run only on Chrome browsers.') + .toContain('Accessibility Automation supports Chrome 95+, Chrome for Testing 141+, and Safari 18.4+') }) - it('returns false if browser version is lesser than 94', async () => { + it('returns false if browser version is lesser than 95', async () => { const platformMeta = { 'browser_name': 'chrome', 'browser_version': '90' @@ -1270,7 +1270,7 @@ describe('validateCapsWithA11y', () => { expect(validateCapsWithA11y(undefined, platformMeta)).toEqual(false) expect(logInfoMock.mock.calls[0][0]) - .toContain('Accessibility Automation will run only on Chrome browser version greater than 94.') + .toContain('Accessibility Automation requires Chrome version 95 or higher') }) it('returns false if browser version is lesser than 94', async () => { @@ -1296,6 +1296,50 @@ describe('validateCapsWithA11y', () => { expect(validateCapsWithA11y(undefined, platformMeta, chromeOptions)).toEqual(true) }) + + it('returns true for Safari 18.4+', async () => { + const platformMeta = { + 'browser_name': 'safari', + 'browser_version': '18.4' + } + expect(validateCapsWithA11y(undefined, platformMeta)).toEqual(true) + }) + + it('returns true for Safari latest', async () => { + const platformMeta = { + 'browser_name': 'safari', + 'browser_version': 'latest' + } + expect(validateCapsWithA11y(undefined, platformMeta)).toEqual(true) + }) + + it('returns false for Safari < 18.4', async () => { + const platformMeta = { + 'browser_name': 'safari', + 'browser_version': '16.0' + } + expect(validateCapsWithA11y(undefined, platformMeta)).toEqual(false) + expect(logInfoMock.mock.calls[0][0]) + .toContain('Safari version 18.4 or higher') + }) + + it('returns true for ChromeForTesting 141+', async () => { + const platformMeta = { + 'browser_name': 'ChromeForTesting', + 'browser_version': '141' + } + expect(validateCapsWithA11y(undefined, platformMeta)).toEqual(true) + }) + + it('returns false for ChromeForTesting < 141', async () => { + const platformMeta = { + 'browser_name': 'ChromeForTesting', + 'browser_version': '140' + } + expect(validateCapsWithA11y(undefined, platformMeta)).toEqual(false) + expect(logInfoMock.mock.calls[0][0]) + .toContain('Accessibility Automation requires Chrome for Testing version 141 or higher') + }) }) describe('validateCapsWithNonBstackA11y', () => { @@ -1304,14 +1348,38 @@ describe('validateCapsWithNonBstackA11y', () => { logInfoMock = vi.spyOn(log, 'warn') }) - it('returns false if browser is not chrome', async () => { + it('returns true for safari 18.4+', async () => { + expect(validateCapsWithNonBstackA11y('safari', '18.4')).toEqual(true) + }) + + it('returns true for safari latest', async () => { + expect(validateCapsWithNonBstackA11y('safari', 'latest')).toEqual(true) + }) + + it('returns false for safari < 18.4', async () => { + expect(validateCapsWithNonBstackA11y('safari', '16.0')).toEqual(false) + expect(logInfoMock.mock.calls[0][0]) + .toContain('Safari version 18.4+') + }) + + it('returns true for ChromeForTesting 141+', async () => { + expect(validateCapsWithNonBstackA11y('ChromeForTesting', '141')).toEqual(true) + }) + + it('returns false for ChromeForTesting < 141', async () => { + expect(validateCapsWithNonBstackA11y('ChromeForTesting', '140')).toEqual(false) + expect(logInfoMock.mock.calls[0][0]) + .toContain('Accessibility Automation requires Chrome for Testing version 141+') + }) + + it('returns false if browser is not supported', async () => { - const browserName = 'safari' + const browserName = 'firefox' const browserVersion = 'latest' expect(validateCapsWithNonBstackA11y(browserName, browserVersion)).toEqual(false) expect(logInfoMock.mock.calls[0][0]) - .toContain('Accessibility Automation will run only on Chrome browsers.') + .toContain('Accessibility Automation on non-BrowserStack infrastructure supports Chrome 100+, Chrome for Testing 141+, and Safari 18.4+') }) it('returns false if browser version is lesser than 100', async () => { @@ -1321,7 +1389,7 @@ describe('validateCapsWithNonBstackA11y', () => { expect(validateCapsWithNonBstackA11y(browserName, browserVersion)).toEqual(false) expect(logInfoMock.mock.calls[0][0]) - .toContain('Accessibility Automation will run only on Chrome browser version greater than 100.') + .toContain('Accessibility Automation requires Chrome version 100+') }) it('returns true if validation done', async () => {