From cc229fe30b92ca680102bf2a76a3d085db51f240 Mon Sep 17 00:00:00 2001 From: Aakash Hotchandani Date: Fri, 17 Jul 2026 14:14:28 +0530 Subject: [PATCH 1/2] =?UTF-8?q?feat(v8):=20port=20missing=20monorepo=20par?= =?UTF-8?q?ity=20commits=20=E2=80=94=20test-plan/grpc-size/exit-signal/log?= =?UTF-8?q?-redaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports 4 upstream webdriverio/webdriverio v8 commits to the standalone v8 line: - #15231 test management (testPlanId) support + CLI-only caps stripping + build-start errors - #15217 raise gRPC send/receive message size limit to 20 MB - #15146 terminate CLI process with SIGINT (not SIGKILL) on Unix - #15200 redact credentials from CLI log output Co-Authored-By: Claude Opus 4.8 (1M context) --- .../port-v8-missing-monorepo-commits.md | 10 ++++ .../browserstack-service/src/cli/cliLogger.ts | 36 ++++++++---- .../browserstack-service/src/cli/cliUtils.ts | 12 +++- .../src/cli/grpcClient.ts | 15 +++-- .../browserstack-service/src/cli/index.ts | 58 +++++++++++++++++++ .../browserstack-service/src/constants.ts | 3 +- .../browserstack-service/src/exitHandler.ts | 4 +- packages/browserstack-service/src/launcher.ts | 37 ++++++++++++ packages/browserstack-service/src/service.ts | 9 ++- packages/browserstack-service/src/types.ts | 9 +++ packages/browserstack-service/src/util.ts | 27 ++++++++- 11 files changed, 198 insertions(+), 22 deletions(-) create mode 100644 .changeset/port-v8-missing-monorepo-commits.md diff --git a/.changeset/port-v8-missing-monorepo-commits.md b/.changeset/port-v8-missing-monorepo-commits.md new file mode 100644 index 0000000..66f3275 --- /dev/null +++ b/.changeset/port-v8-missing-monorepo-commits.md @@ -0,0 +1,10 @@ +--- +"@wdio/browserstack-service": patch +--- + +Port missing upstream monorepo (webdriverio/webdriverio) v8 commits to keep the standalone v8 line at parity: + +- webdriverio/webdriverio#15231 — Test Management: add `testManagementOptions.testPlanId` support (env `BROWSERSTACK_TEST_PLAN_ID` / `--browserstack.testManagementOptions.testPlanId` CLI arg / config), forward `test_management.test_plan_id` in the build-start request, strip CLI-only caps (`NOT_ALLOWED_KEYS_IN_CAPS`, incl. `testManagementOptions`) before hitting the hub, and surface build-start errors. +- webdriverio/webdriverio#15217 — gRPC: raise the send/receive message size limit to 20 MB to accommodate large extension payloads. +- webdriverio/webdriverio#15146 — Exit handling: terminate the CLI process with `SIGINT` instead of `SIGKILL` on Unix. +- webdriverio/webdriverio#15200 — Logging: redact credentials (username/accesskey/user/key) from CLI log output before writing to file and console. diff --git a/packages/browserstack-service/src/cli/cliLogger.ts b/packages/browserstack-service/src/cli/cliLogger.ts index 0941c2c..168f835 100644 --- a/packages/browserstack-service/src/cli/cliLogger.ts +++ b/packages/browserstack-service/src/cli/cliLogger.ts @@ -14,14 +14,21 @@ export class BStackLogger { public static logFolderPath = path.join(process.cwd(), 'logs') private static logFileStream: fs.WriteStream | null + private static redactCredentials(logMessage: string): string { + return logMessage + .replace(/([\\'"]*(?:username|accesskey|user|key)[\\'"]*\s*[:=]\s*[\\'"]*)([^\\'"\s,}]+)/gi, '$1') + .replace(/([?&](?:username|access_key|accesskey|user|key)=)([^&#\s]+)/gi, '$1') + } + static logToFile(logMessage: string, logLevel: string) { try { + const redactedMessage = this.redactCredentials(logMessage) if (!this.logFileStream) { this.ensureLogsFolder() this.logFileStream = fs.createWriteStream(this.logFilePath, { flags: 'a' }) } if (this.logFileStream && this.logFileStream.writable) { - this.logFileStream.write(this.formatLog(logMessage, logLevel)) + this.logFileStream.write(this.formatLog(redactedMessage, logLevel)) } } catch (error) { log.debug(`Failed to log to file. Error ${error}`) @@ -33,32 +40,37 @@ export class BStackLogger { } public static info(message: string) { - this.logToFile(message, 'info') - log.info(message) + const redactedMessage = this.redactCredentials(message) + this.logToFile(redactedMessage, 'info') + log.info(redactedMessage) } public static error(message: string) { - this.logToFile(message, 'error') - log.error(message) + const redactedMessage = this.redactCredentials(message) + this.logToFile(redactedMessage, 'error') + log.error(redactedMessage) } public static debug(message: string, param?: unknown) { - this.logToFile(message, 'debug') + const redactedMessage = this.redactCredentials(message) + this.logToFile(redactedMessage, 'debug') if (param) { - log.debug(message, param) + log.debug(redactedMessage, param) } else { - log.debug(message) + log.debug(redactedMessage) } } public static warn(message: string) { - this.logToFile(message, 'warn') - log.warn(message) + const redactedMessage = this.redactCredentials(message) + this.logToFile(redactedMessage, 'warn') + log.warn(redactedMessage) } public static trace(message: string) { - this.logToFile(message, 'trace') - log.trace(message) + const redactedMessage = this.redactCredentials(message) + this.logToFile(redactedMessage, 'trace') + log.trace(redactedMessage) } public static clearLogger() { diff --git a/packages/browserstack-service/src/cli/cliUtils.ts b/packages/browserstack-service/src/cli/cliUtils.ts index 65b6758..423ea91 100644 --- a/packages/browserstack-service/src/cli/cliUtils.ts +++ b/packages/browserstack-service/src/cli/cliUtils.ts @@ -32,7 +32,7 @@ import { EVENTS as PerformanceEvents } from '../instrumentation/performance/cons import { BStackLogger as logger } from './cliLogger.js' import { UPDATED_CLI_ENDPOINT } from '../constants.js' import type { Options, Capabilities } from '@wdio/types' -import type { BrowserstackConfig, BrowserstackOptions, TestObservabilityOptions } from '../types.js' +import type { BrowserstackConfig, BrowserstackOptions, TestManagementOptions, TestObservabilityOptions } from '../types.js' import { TestFrameworkConstants } from './frameworks/constants/testFrameworkConstants.js' import APIUtils from './apiUtils.js' @@ -63,6 +63,7 @@ export class CLIUtils { modifiedOpts.browserStackLocalOptions = modifiedOpts.opts delete modifiedOpts.opts } + delete modifiedOpts.testManagementOptions modifiedOpts.testContextOptions = { skipSessionName: isFalse(modifiedOpts.setSessionName), @@ -88,6 +89,10 @@ export class CLIUtils { const isNonBstackA11y = isTurboScale(options) || !shouldAddServiceVersion(config as Options.Testrunner, options.testObservability) const observabilityOptions: TestObservabilityOptions = options.testObservabilityOptions || {} + const testManagementOptions: TestManagementOptions = options.testManagementOptions || {} + const testPlanId = typeof testManagementOptions.testPlanId === 'string' + ? testManagementOptions.testPlanId.trim() + : '' const binconfig: Record = { userName: observabilityOptions.user || config.user, accessKey: observabilityOptions.key || config.key, @@ -100,6 +105,11 @@ export class CLIUtils { binconfig.buildName = observabilityOptions.buildName || binconfig.buildName binconfig.projectName = observabilityOptions.projectName || binconfig.projectName binconfig.buildTag = this.getObservabilityBuildTags(observabilityOptions, buildTag) || [] + if (testPlanId.length > 0) { + binconfig.testManagementOptions = { + testPlanId + } + } let caps = capabilities if (capabilities && !Array.isArray(capabilities)) { diff --git a/packages/browserstack-service/src/cli/grpcClient.ts b/packages/browserstack-service/src/cli/grpcClient.ts index 96cbcec..2cc109a 100644 --- a/packages/browserstack-service/src/cli/grpcClient.ts +++ b/packages/browserstack-service/src/cli/grpcClient.ts @@ -45,6 +45,9 @@ import PerformanceTester from '../instrumentation/performance/performance-tester import * as PERFORMANCE_SDK_EVENTS from '../instrumentation/performance/constants.js' import { BStackLogger } from './cliLogger.js' +// Increased from default 4 MB to accommodate large extension payloads +const GRPC_MESSAGE_LIMIT = 20 * 1024 * 1024 // 20 MB in bytes + /** * GrpcClient - Singleton class for managing gRPC client connections * @@ -122,20 +125,24 @@ export class GrpcClient { throw new Error('Unable to determine gRPC server listen address') } + const channelOptions = { + 'grpc.keepalive_time_ms': 10000, + 'grpc.max_send_message_length': GRPC_MESSAGE_LIMIT, + 'grpc.max_receive_message_length': GRPC_MESSAGE_LIMIT, + } + // Create a channel this.channel = new grpcChannel( listenAddress, grpcCredentials.createInsecure(), - { - 'grpc.keepalive_time_ms': 10000 - } + channelOptions ) // Create a client using the channel this.client = new SDKClient( listenAddress, grpcCredentials.createInsecure(), - {} + channelOptions ) this.logger.info(`Connected to gRPC server at ${listenAddress}`) diff --git a/packages/browserstack-service/src/cli/index.ts b/packages/browserstack-service/src/cli/index.ts index a91c89d..6b8b447 100644 --- a/packages/browserstack-service/src/cli/index.ts +++ b/packages/browserstack-service/src/cli/index.ts @@ -137,6 +137,7 @@ export class BrowserstackCLI { this.modules[AutomateModule.MODULE_NAME] = new AutomateModule(this.browserstackConfig as Options.Testrunner) if (startBinResponse.testhub) { + this.logBuildStartErrors(startBinResponse.testhub) process.env[TESTOPS_BUILD_COMPLETED_ENV] = 'true' if (startBinResponse.testhub.jwt) { process.env[BROWSERSTACK_TESTHUB_JWT] = startBinResponse.testhub.jwt @@ -170,6 +171,63 @@ export class BrowserstackCLI { this.configureModules() } + private logBuildStartErrors(testhub: unknown) { + if (process.env.WDIO_WORKER_ID) { + return + } + + try { + const rawErrors = (testhub as { errors?: unknown } | null)?.errors + const parsedErrors = this.parseBuildStartErrors(rawErrors) + + for (const [errorKey, errorDetails] of Object.entries(parsedErrors)) { + const errorMessage = errorDetails?.message + if (!errorMessage) { + continue + } + + const formattedMessage = `[Build] ${errorKey}: ${errorMessage}` + if (errorDetails.type === 'info') { + BStackLogger.info(formattedMessage) + } else if (errorDetails.type === 'warn') { + BStackLogger.warn(formattedMessage) + } else { + BStackLogger.error(formattedMessage) + } + } + } catch (error) { + BStackLogger.debug(`Failed to log build-start errors: ${util.format(error)}`) + } + } + + private parseBuildStartErrors(rawErrors: unknown): Record { + if (!rawErrors) { + return {} + } + + try { + if (typeof rawErrors === 'string') { + return JSON.parse(rawErrors) as Record + } + + if (Buffer.isBuffer(rawErrors)) { + return JSON.parse(rawErrors.toString('utf8')) as Record + } + + if (rawErrors instanceof Uint8Array) { + return JSON.parse(Buffer.from(rawErrors).toString('utf8')) as Record + } + + if (typeof rawErrors === 'object') { + return rawErrors as Record + } + } catch (error) { + BStackLogger.debug(`Failed to parse build-start errors from CLI response: ${util.format(error)}`) + } + + return {} + } + /** * Configure modules * @returns {Promise} diff --git a/packages/browserstack-service/src/constants.ts b/packages/browserstack-service/src/constants.ts index fefa814..48f53f3 100644 --- a/packages/browserstack-service/src/constants.ts +++ b/packages/browserstack-service/src/constants.ts @@ -47,7 +47,8 @@ export const BSTACK_SERVICE_VERSION = bstackServiceVersion export const UPDATED_CLI_ENDPOINT = 'sdk/v1/update_cli' export const CLI_STOP_TIMEOUT = 3000 -export const NOT_ALLOWED_KEYS_IN_CAPS = ['includeTagsInTestingScope', 'excludeTagsInTestingScope'] +export const NOT_ALLOWED_KEYS_IN_CAPS = ['includeTagsInTestingScope', 'excludeTagsInTestingScope', 'testManagementOptions'] +export const BROWSERSTACK_TEST_PLAN_ID = 'BROWSERSTACK_TEST_PLAN_ID' export const LOGS_FILE = 'logs/bstack-wdio-service.log' export const CLI_DEBUG_LOGS_FILE = 'log/sdk-cli-debug.log' diff --git a/packages/browserstack-service/src/exitHandler.ts b/packages/browserstack-service/src/exitHandler.ts index 76b8cf8..2039a1a 100644 --- a/packages/browserstack-service/src/exitHandler.ts +++ b/packages/browserstack-service/src/exitHandler.ts @@ -41,8 +41,8 @@ export function setupExitHandlers() { cliProcess.kill('SIGTERM') BStackLogger.debug('CLI process terminated successfully with SIGTERM (Windows)') } else { - cliProcess.kill('SIGKILL') - BStackLogger.debug('CLI process terminated successfully with SIGKILL (Unix)') + cliProcess.kill('SIGINT') + BStackLogger.debug('CLI process terminated successfully with SIGINT (Unix)') } } catch (processError) { BStackLogger.debug(`CLI process termination error: ${processError}`) diff --git a/packages/browserstack-service/src/launcher.ts b/packages/browserstack-service/src/launcher.ts index 28e789b..77eee01 100644 --- a/packages/browserstack-service/src/launcher.ts +++ b/packages/browserstack-service/src/launcher.ts @@ -423,6 +423,8 @@ export default class BrowserstackLauncherService implements Services.ServiceInst this._updateObjectTypeCaps(capabilities, 'accessibilityOptions', {}) } + this._removeCliOnlyCapabilityOptions(capabilities) + if (shouldSetupPercy) { try { const bestPlatformPercyCaps = getBestPlatformForPercySnapshot(capabilities) @@ -752,6 +754,41 @@ export default class BrowserstackLauncherService implements Services.ServiceInst })() } + private _removeCliOnlyCapabilityOptions(capabilities?: Capabilities.RemoteCapabilities) { + if (!capabilities || typeof capabilities !== 'object') { + return + } + + const strip = (capability: WebdriverIO.Capabilities) => { + const capabilityRecord = capability as Record + const bstackOptions = capabilityRecord['bstack:options'] as Record | undefined + if (bstackOptions && typeof bstackOptions === 'object') { + NOT_ALLOWED_KEYS_IN_CAPS.forEach(key => delete bstackOptions[key]) + } + NOT_ALLOWED_KEYS_IN_CAPS.forEach(key => delete capabilityRecord[`browserstack.${key}`]) + + const alwaysMatch = (capability as WebdriverIO.Capabilities & { alwaysMatch?: WebdriverIO.Capabilities }).alwaysMatch + if (alwaysMatch && typeof alwaysMatch === 'object') { + strip(alwaysMatch) + } + } + + if (Array.isArray(capabilities)) { + capabilities + .flatMap((c) => { + if (Object.values(c).length > 0 && Object.values(c).every(c => typeof c === 'object' && c.capabilities)) { + return Object.values(c).map((o) => o.capabilities) as WebdriverIO.Capabilities[] + } + return c as WebdriverIO.Capabilities + }) + .forEach(strip) + } else { + Object.entries(capabilities as Capabilities.MultiRemoteCapabilities).forEach(([, caps]) => { + strip(caps.capabilities as WebdriverIO.Capabilities) + }) + } + } + _updateObjectTypeCaps(capabilities?: Capabilities.RemoteCapabilities, capType?: string, value?: { [key: string]: any }) { try { if (Array.isArray(capabilities)) { diff --git a/packages/browserstack-service/src/service.ts b/packages/browserstack-service/src/service.ts index 4a85c19..363ccba 100644 --- a/packages/browserstack-service/src/service.ts +++ b/packages/browserstack-service/src/service.ts @@ -18,7 +18,7 @@ import type { BrowserstackConfig, BrowserstackOptions, MultiRemoteAction, Sessio import type { Pickle, Feature, ITestCaseHookParameter, CucumberHook } from './cucumber-types.js' import InsightsHandler from './insights-handler.js' import TestReporter from './reporter.js' -import { DEFAULT_OPTIONS, PERF_MEASUREMENT_ENV } from './constants.js' +import { DEFAULT_OPTIONS, NOT_ALLOWED_KEYS_IN_CAPS, PERF_MEASUREMENT_ENV } from './constants.js' import CrashReporter from './crash-reporter.js' import AccessibilityHandler from './accessibility-handler.js' import { BStackLogger } from './bstackLogger.js' @@ -156,6 +156,13 @@ export default class BrowserstackService implements Services.ServiceInstance { const instance = AutomationFramework.getTrackedInstance() as AutomationFrameworkInstance const caps = AutomationFramework.getState(instance, AutomationFrameworkConstants.KEY_CAPABILITIES) Object.assign(capabilities, caps) + + // Strip CLI-only options that BrowserStack hub doesn't accept + const bstackOptions = (capabilities as Record)['bstack:options'] as Record | undefined + if (bstackOptions && typeof bstackOptions === 'object') { + NOT_ALLOWED_KEYS_IN_CAPS.forEach(key => delete bstackOptions[key]) + } + NOT_ALLOWED_KEYS_IN_CAPS.forEach(key => delete (capabilities as Record)[`browserstack.${key}`]) } } catch (err) { BStackLogger.error(`Error while connecting to Browserstack CLI: ${err}`) diff --git a/packages/browserstack-service/src/types.ts b/packages/browserstack-service/src/types.ts index 35e377a..a4ce3b2 100644 --- a/packages/browserstack-service/src/types.ts +++ b/packages/browserstack-service/src/types.ts @@ -57,6 +57,10 @@ export interface TestReportingOptions { key?: string } +export interface TestManagementOptions { + testPlanId?: string, +} + export interface RunSmartSelectionOptions { enabled?: boolean, mode?: string, @@ -105,6 +109,11 @@ export interface BrowserstackConfig { * For e.g. buildName, projectName, BrowserStack access credentials, etc. */ testReportingOptions?: TestReportingOptions; + /** + * Set the Test Management related config options under this key. + * Currently supports testPlanId. + */ + testManagementOptions?: TestManagementOptions; /** * Set this to true to enable BrowserStack Percy which will take screenshots * and snapshots for your tests run on Browserstack diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index 900b720..2e8ca78 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -39,6 +39,7 @@ import { BROWSERSTACK_TESTHUB_JWT, BROWSERSTACK_OBSERVABILITY, BROWSERSTACK_TEST_REPORTING, + BROWSERSTACK_TEST_PLAN_ID, BROWSERSTACK_ACCESSIBILITY, MAX_GIT_META_DATA_SIZE_IN_BYTES, GIT_META_DATA_TRUNCATED, @@ -407,7 +408,10 @@ export const launchTestSession = PerformanceTester.measureWrapper(PERFORMANCE_SD }, product_map: getProductMapForBuildStartCall(bStackConfig, accessibilityAutomation), config: {}, - test_orchestration: OrchestrationUtils.getInstance(config)?.getBuildStartData() || {} + test_orchestration: OrchestrationUtils.getInstance(config)?.getBuildStartData() || {}, + test_management: { + test_plan_id: getTestPlanId(options) + } } if (accessibilityAutomation && (isTurboScale(options) || data.browserstackAutomation === false)){ @@ -434,6 +438,7 @@ export const launchTestSession = PerformanceTester.measureWrapper(PERFORMANCE_SD }).json() delete data?.accessibility?.settings?.includeEncodedExtension BStackLogger.debug(`[Start_Build] Success response: ${JSON.stringify(response)}`) + BStackLogger.debug(`Test Plan Id sent in request: ${getTestPlanId(options)}`) process.env[TESTOPS_BUILD_COMPLETED_ENV] = 'true' if (response.jwt) { process.env[BROWSERSTACK_TESTHUB_JWT] = response.jwt @@ -1335,6 +1340,26 @@ export function getObservabilityBuildTags(options: BrowserstackConfig & Options. return [] } +export function getTestPlanId(options: BrowserstackConfig & Options.Testrunner): string | undefined { + if (process.env[BROWSERSTACK_TEST_PLAN_ID]) { + return process.env[BROWSERSTACK_TEST_PLAN_ID] + } + const CLI_ARG = '--browserstack.testManagementOptions.testPlanId' + const argIndex = process.argv.indexOf(CLI_ARG) + if (argIndex !== -1 && process.argv[argIndex + 1]) { + return process.argv[argIndex + 1] + } + const argWithEquals = process.argv.find((arg) => arg.startsWith(`${CLI_ARG}=`)) + if (argWithEquals) { + return argWithEquals.split('=')[1] + } + const testPlanId = options.testManagementOptions?.testPlanId + if (typeof testPlanId === 'string' && testPlanId.trim().length > 0) { + return testPlanId.trim() + } + return undefined +} + export function getBrowserStackUser(config: Options.Testrunner) { if (process.env.BROWSERSTACK_USERNAME) { return process.env.BROWSERSTACK_USERNAME From f27144bbb132bbe612d56699ec7891d7a258de76 Mon Sep 17 00:00:00 2001 From: Aakash Hotchandani Date: Fri, 17 Jul 2026 14:37:42 +0530 Subject: [PATCH 2/2] test(v8): port unit tests for the ported parity commits (#15231, #15200) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/cli/cliLogger.test.ts | 134 ++++++++++++++++++ .../tests/cli/cliUtils.test.ts | 100 +++++++++---- .../tests/cli/index.test.ts | 26 +++- .../tests/launcher.test.ts | 50 +++++++ .../browserstack-service/tests/util.test.ts | 98 ++++++++++++- 5 files changed, 377 insertions(+), 31 deletions(-) create mode 100644 packages/browserstack-service/tests/cli/cliLogger.test.ts diff --git a/packages/browserstack-service/tests/cli/cliLogger.test.ts b/packages/browserstack-service/tests/cli/cliLogger.test.ts new file mode 100644 index 0000000..979d8b8 --- /dev/null +++ b/packages/browserstack-service/tests/cli/cliLogger.test.ts @@ -0,0 +1,134 @@ +import path from 'node:path' +import fs from 'node:fs' +import { describe, expect, it, vi, beforeEach } from 'vitest' + +import logger from '@wdio/logger' +import { BStackLogger } from '../../src/cli/cliLogger.js' + +const log = logger('test') + +vi.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger'))) +vi.mock('node:fs', () => ({ + default: { + readFileSync: vi.fn().mockReturnValue('1234\nsomepath'), + existsSync: vi.fn(), + truncateSync: vi.fn(), + mkdirSync: vi.fn(), + createWriteStream: vi.fn().mockReturnValue({ write: vi.fn(), writable: true, end: vi.fn() }), + } +})) + +describe('cliLogger - redactCredentials', () => { + beforeEach(() => { + vi.spyOn(fs, 'existsSync').mockReturnValue(true) + }) + + describe('JSON-style key:value redaction', () => { + it('should redact userName with double quotes', () => { + const logInfoMock = vi.spyOn(log, 'info') + BStackLogger.info('config: {"userName": "myUser123"}') + expect(logInfoMock).toHaveBeenCalledWith('config: {"userName": ""}') + }) + + it('should redact accessKey with double quotes', () => { + const logInfoMock = vi.spyOn(log, 'info') + BStackLogger.info('config: {"accessKey": "secretKey456"}') + expect(logInfoMock).toHaveBeenCalledWith('config: {"accessKey": ""}') + }) + + it('should redact user and key fields', () => { + const logInfoMock = vi.spyOn(log, 'info') + BStackLogger.info('config: {"user": "myUser", "key": "myKey"}') + expect(logInfoMock).toHaveBeenCalledWith('config: {"user": "", "key": ""}') + }) + + it('should redact username with single quotes', () => { + const logInfoMock = vi.spyOn(log, 'info') + BStackLogger.info("config: {'username': 'myUser123'}") + expect(logInfoMock).toHaveBeenCalledWith("config: {'username': ''}") + }) + + it('should redact multiple credentials in the same message', () => { + const logInfoMock = vi.spyOn(log, 'info') + BStackLogger.info('{"username": "user1", "accessKey": "key1"}') + expect(logInfoMock).toHaveBeenCalledWith('{"username": "", "accessKey": ""}') + }) + }) + + describe('key=value format redaction', () => { + it('should redact username=value', () => { + const logInfoMock = vi.spyOn(log, 'info') + BStackLogger.info('config: username=myUser123, other=value') + expect(logInfoMock).toHaveBeenCalledWith('config: username=, other=value') + }) + + it('should redact accesskey case-insensitively', () => { + const logInfoMock = vi.spyOn(log, 'info') + BStackLogger.info('config: ACCESSKEY=secretKey456, other=value') + expect(logInfoMock).toHaveBeenCalledWith('config: ACCESSKEY=, other=value') + }) + + it('should redact key: value without quotes', () => { + const logInfoMock = vi.spyOn(log, 'info') + BStackLogger.info('username: myUser123, done') + expect(logInfoMock).toHaveBeenCalledWith('username: , done') + }) + }) + + describe('URL query parameter redaction', () => { + it('should redact credential at end of URL', () => { + const logInfoMock = vi.spyOn(log, 'info') + BStackLogger.info('url: https://hub.example.com?other=val&username=myUser') + expect(logInfoMock).toHaveBeenCalledWith('url: https://hub.example.com?other=val&username=') + }) + }) + + describe('no false positives', () => { + it('should not modify messages without credentials', () => { + const logInfoMock = vi.spyOn(log, 'info') + BStackLogger.info('No sensitive data here') + expect(logInfoMock).toHaveBeenCalledWith('No sensitive data here') + }) + + it('should not modify empty messages', () => { + const logInfoMock = vi.spyOn(log, 'info') + BStackLogger.info('') + expect(logInfoMock).toHaveBeenCalledWith('') + }) + }) + + describe('redaction across all log levels', () => { + const msg = '{"username": "user1", "accessKey": "key1"}' + const expected = '{"username": "", "accessKey": ""}' + + it('should redact in info', () => { + const mock = vi.spyOn(log, 'info') + BStackLogger.info(msg) + expect(mock).toHaveBeenCalledWith(expected) + }) + + it('should redact in error', () => { + const mock = vi.spyOn(log, 'error') + BStackLogger.error(msg) + expect(mock).toHaveBeenCalledWith(expected) + }) + + it('should redact in debug', () => { + const mock = vi.spyOn(log, 'debug') + BStackLogger.debug(msg) + expect(mock).toHaveBeenCalledWith(expected) + }) + + it('should redact in warn', () => { + const mock = vi.spyOn(log, 'warn') + BStackLogger.warn(msg) + expect(mock).toHaveBeenCalledWith(expected) + }) + + it('should redact in trace', () => { + const mock = vi.spyOn(log, 'trace') + BStackLogger.trace(msg) + expect(mock).toHaveBeenCalledWith(expected) + }) + }) +}) diff --git a/packages/browserstack-service/tests/cli/cliUtils.test.ts b/packages/browserstack-service/tests/cli/cliUtils.test.ts index e8fe26d..32bab84 100644 --- a/packages/browserstack-service/tests/cli/cliUtils.test.ts +++ b/packages/browserstack-service/tests/cli/cliUtils.test.ts @@ -4,17 +4,18 @@ import path from 'node:path' import type { ZipFile } from 'yauzl' import yauzl from 'yauzl' import os from 'node:os' -import * as bstackLogger from '../../src/bstackLogger.js' +import * as cliLogger from '../../src/cli/cliLogger.js' +import * as utilModule from '../../src/util.js' import { CLIUtils } from '../../src/cli/cliUtils.js' import PerformanceTester from '../../src/instrumentation/performance/performance-tester.js' import { EVENTS as PerformanceEvents } from '../../src/instrumentation/performance/constants.js' import type { Options } from '@wdio/types' -import { nodeRequest } from '../../src/util.js' +import type { BrowserstackConfig, BrowserstackOptions } from '../../src/types.js' import APIUtils from '../../src/cli/apiUtils.js' -const bstackLoggerSpy = vi.spyOn(bstackLogger.BStackLogger, 'logToFile') -bstackLoggerSpy.mockImplementation(() => {}) +const cliLoggerSpy = vi.spyOn(cliLogger.BStackLogger, 'logToFile') +cliLoggerSpy.mockImplementation(() => {}) // vi.mock('../../src/util.js', () => ({ // isNullOrEmpty: vi.fn() @@ -72,12 +73,13 @@ describe('CLIUtils', () => { } } as Record } as Options.Testrunner + const createBrowserstackOptions = (overrides: Record = {}) => overrides as unknown as BrowserstackConfig & BrowserstackOptions it('returns stringified config with basic options', () => { const capabilities = [ { browserName: 'chrome' } ] - const options = {} + const options = createBrowserstackOptions() const result = CLIUtils.getBinConfig(mockConfig, capabilities, options) const parsed = JSON.parse(result) @@ -106,7 +108,7 @@ describe('CLIUtils', () => { { browserName: 'chrome' }, { browserName: 'firefox' } ] - const options = {} + const options = createBrowserstackOptions() const result = CLIUtils.getBinConfig(mockConfig, capabilities, options) const parsed = JSON.parse(result) @@ -126,7 +128,7 @@ describe('CLIUtils', () => { osVersion: '10' } }] - const options = {} + const options = createBrowserstackOptions() const result = CLIUtils.getBinConfig(mockConfig, capabilities, options) const parsed = JSON.parse(result) @@ -142,11 +144,11 @@ describe('CLIUtils', () => { const capabilities = [ { browserName: 'chrome' } ] - const options = { + const options = createBrowserstackOptions({ opts: { localIdentifier: 'test123' } - } + }) const result = CLIUtils.getBinConfig(mockConfig, capabilities, options) const parsed = JSON.parse(result) @@ -164,9 +166,9 @@ describe('CLIUtils', () => { buildName: 'cap-build' } }] - const options = { + const options = createBrowserstackOptions({ buildName: 'opt-build' - } + }) const result = CLIUtils.getBinConfig(mockConfig, capabilities, options) const parsed = JSON.parse(result) @@ -174,6 +176,54 @@ describe('CLIUtils', () => { expect(parsed.buildName).toBe('common-build') expect(parsed.platforms[0]).not.toHaveProperty('buildName') }) + + it('includes testManagementOptions when testPlanId is provided', () => { + const capabilities = [ + { browserName: 'chrome' } + ] + const options = createBrowserstackOptions({ + testManagementOptions: { + testPlanId: 'tm-plan-123' + } + }) + + const result = CLIUtils.getBinConfig(mockConfig, capabilities, options) + const parsed = JSON.parse(result) + + expect(parsed.testManagementOptions).toEqual({ + testPlanId: 'tm-plan-123' + }) + }) + + it('omits empty testManagementOptions and strips unrelated keys', () => { + const capabilities = [ + { browserName: 'chrome' } + ] + const emptyPlanOptions = createBrowserstackOptions({ + testManagementOptions: { + testPlanId: ' ', + ignoredKey: 'ignored' + } + }) + const validPlanOptions = createBrowserstackOptions({ + testManagementOptions: { + testPlanId: ' tm-plan-456 ', + ignoredKey: 'ignored' + } + }) + + const emptyPlanResult = CLIUtils.getBinConfig(mockConfig, capabilities, emptyPlanOptions) + + const emptyPlanParsed = JSON.parse(emptyPlanResult) + expect(emptyPlanParsed.testManagementOptions).toBeUndefined() + + const validPlanResult = CLIUtils.getBinConfig(mockConfig, capabilities, validPlanOptions) + + const validPlanParsed = JSON.parse(validPlanResult) + expect(validPlanParsed.testManagementOptions).toEqual({ + testPlanId: 'tm-plan-456' + }) + }) }) describe('getSdkVersion', () => { @@ -453,19 +503,15 @@ describe('CLIUtils', () => { user: 'testuser', key: 'testkey' } as Options.Testrunner + let nodeRequestSpy: ReturnType + let getBrowserStackUserSpy: ReturnType + let getBrowserStackKeySpy: ReturnType beforeEach(() => { vi.resetAllMocks() - vi.mock('../../src/util.js', async () => { - // Remove the type annotation from importActual - const actual = await vi.importActual('../../src/util.js') - return { - ...actual, - nodeRequest: vi.fn().mockResolvedValue({ status: 'success' }), - getBrowserStackUser: vi.fn().mockReturnValue('testuser'), - getBrowserStackKey: vi.fn().mockReturnValue('testkey'), - } - }) + nodeRequestSpy = vi.spyOn(utilModule, 'nodeRequest').mockResolvedValue({ status: 'success' } as any) + getBrowserStackUserSpy = vi.spyOn(utilModule, 'getBrowserStackUser').mockReturnValue('testuser') + getBrowserStackKeySpy = vi.spyOn(utilModule, 'getBrowserStackKey').mockReturnValue('testkey') }) afterEach(() => { @@ -478,27 +524,29 @@ describe('CLIUtils', () => { param2: 'value2' } - vi.mocked(nodeRequest).mockResolvedValue({}) + nodeRequestSpy.mockResolvedValue({} as any) await CLIUtils.requestToUpdateCLI(queryParams, mockConfig) - expect(nodeRequest).toHaveBeenCalledWith( + expect(nodeRequestSpy).toHaveBeenCalledWith( 'GET', expect.stringContaining('param1=value1'), expect.any(Object), APIUtils.BROWSERSTACK_AUTOMATE_API_URL ) - expect(nodeRequest).toHaveBeenCalledWith( + expect(nodeRequestSpy).toHaveBeenCalledWith( 'GET', expect.stringContaining('param2=value2'), expect.any(Object), APIUtils.BROWSERSTACK_AUTOMATE_API_URL ) + expect(getBrowserStackUserSpy).toHaveBeenCalledWith(mockConfig) + expect(getBrowserStackKeySpy).toHaveBeenCalledWith(mockConfig) }) it('returns response from nodeRequest', async () => { const mockResponse = { updated_cli_version: '2.0.0' } - vi.mocked(nodeRequest).mockResolvedValue(mockResponse) + nodeRequestSpy.mockResolvedValue(mockResponse as any) const result = await CLIUtils.requestToUpdateCLI({}, mockConfig) @@ -507,7 +555,7 @@ describe('CLIUtils', () => { it('handles errors from nodeRequest', async () => { const mockError = new Error('Network error') - vi.mocked(nodeRequest).mockRejectedValue(mockError) + nodeRequestSpy.mockRejectedValue(mockError) await expect(CLIUtils.requestToUpdateCLI({}, mockConfig)) .rejects diff --git a/packages/browserstack-service/tests/cli/index.test.ts b/packages/browserstack-service/tests/cli/index.test.ts index 0470902..5a1a9fa 100644 --- a/packages/browserstack-service/tests/cli/index.test.ts +++ b/packages/browserstack-service/tests/cli/index.test.ts @@ -5,7 +5,7 @@ import { spawn } from 'node:child_process' import { GrpcClient } from '../../src/cli/grpcClient.js' import { BrowserstackCLI } from '../../src/cli/index.js' -import * as bstackLogger from '../../src/bstackLogger.js' +import * as cliLogger from '../../src/cli/cliLogger.js' import { CLIUtils } from '../../src/cli/cliUtils.js' import TestHubModule from '../../src/cli/modules/testHubModule.js' @@ -86,8 +86,8 @@ vi.mock('../../src/cli/grpcClient.js', () => ({ } })) -const bstackLoggerSpy = vi.spyOn(bstackLogger.BStackLogger, 'logToFile') -bstackLoggerSpy.mockImplementation(() => {}) +const cliLoggerSpy = vi.spyOn(cliLogger.BStackLogger, 'logToFile') +cliLoggerSpy.mockImplementation(() => {}) // Mock APIs structure for testing const mockApis = { @@ -438,6 +438,26 @@ describe('BrowserstackCLI', () => { expect(browserstackCLI.getConfig()).toEqual(mockConfig) }) + + it('logs build-start errors for the main process', () => { + const errorSpy = vi.spyOn(cliLogger.BStackLogger, 'error').mockImplementation(() => {}) + try { + mockStartBinResponse.testhub = { + errors: Buffer.from(JSON.stringify({ + PLAN_ID_INVALID: { + message: 'The provided Test Plan ID or format is invalid. Build created without association.', + type: 'error' + } + })) + } + + browserstackCLI.loadModules(mockStartBinResponse) + + expect(errorSpy).toHaveBeenCalledWith('[Build] PLAN_ID_INVALID: The provided Test Plan ID or format is invalid. Build created without association.') + } finally { + errorSpy.mockRestore() + } + }) }) describe('isRunning', () => { diff --git a/packages/browserstack-service/tests/launcher.test.ts b/packages/browserstack-service/tests/launcher.test.ts index dc40385..f7ce74a 100644 --- a/packages/browserstack-service/tests/launcher.test.ts +++ b/packages/browserstack-service/tests/launcher.test.ts @@ -1000,6 +1000,56 @@ describe('_updateObjectTypeCaps', () => { }) }) +describe('_removeCliOnlyCapabilityOptions', () => { + const options: BrowserstackConfig = {} + const config = { + user: 'foobaruser', + key: '12345678901234567890', + capabilities: [] + } + + it('should remove testManagementOptions from bstack:options in caps array', () => { + const caps: any = [{ 'bstack:options': { testManagementOptions: { testPlanId: 'tp-1' }, buildName: 'my-build' } }] + const service = new BrowserstackLauncher(options as any, caps, config) + + service['_removeCliOnlyCapabilityOptions'](caps) + expect(caps[0]['bstack:options'].testManagementOptions).toBeUndefined() + expect(caps[0]['bstack:options'].buildName).toBe('my-build') + }) + + it('should remove testManagementOptions from bstack:options in multiremote caps', () => { + const caps: any = { chromeBrowser: { capabilities: { 'bstack:options': { testManagementOptions: { testPlanId: 'tp-1' } } } } } + const service = new BrowserstackLauncher(options as any, caps, config) + + service['_removeCliOnlyCapabilityOptions'](caps) + expect(caps.chromeBrowser.capabilities['bstack:options'].testManagementOptions).toBeUndefined() + }) + + it('should remove legacy browserstack.testManagementOptions from caps array', () => { + const caps: any = [{ 'browserstack.testManagementOptions': { testPlanId: 'tp-1' } }] + const service = new BrowserstackLauncher(options as any, caps, config) + + service['_removeCliOnlyCapabilityOptions'](caps) + expect(caps[0]['browserstack.testManagementOptions']).toBeUndefined() + }) + + it('should handle caps array without testManagementOptions gracefully', () => { + const caps: any = [{ 'bstack:options': { buildName: 'my-build' } }] + const service = new BrowserstackLauncher(options as any, caps, config) + + expect(() => service['_removeCliOnlyCapabilityOptions'](caps)).not.toThrow() + expect(caps[0]['bstack:options'].buildName).toBe('my-build') + }) + + it('should handle alwaysMatch caps', () => { + const caps: any = [{ alwaysMatch: { 'bstack:options': { testManagementOptions: { testPlanId: 'tp-1' } } } }] + const service = new BrowserstackLauncher(options as any, caps, config) + + service['_removeCliOnlyCapabilityOptions'](caps) + expect(caps[0].alwaysMatch['bstack:options'].testManagementOptions).toBeUndefined() + }) +}) + describe('_validateApp', () => { const caps: any = [{}] const config = { diff --git a/packages/browserstack-service/tests/util.test.ts b/packages/browserstack-service/tests/util.test.ts index 84891eb..2b507b4 100644 --- a/packages/browserstack-service/tests/util.test.ts +++ b/packages/browserstack-service/tests/util.test.ts @@ -56,10 +56,11 @@ import { getAppA11yResultsSummary, mergeDeep, mergeChromeOptions, - isMultiRemoteCaps + isMultiRemoteCaps, + getTestPlanId, } from '../src/util.js' import * as bstackLogger from '../src/bstackLogger.js' -import { BROWSERSTACK_OBSERVABILITY, TESTOPS_BUILD_COMPLETED_ENV, BROWSERSTACK_TESTHUB_JWT, BROWSERSTACK_ACCESSIBILITY } from '../src/constants.js' +import { BROWSERSTACK_OBSERVABILITY, TESTOPS_BUILD_COMPLETED_ENV, BROWSERSTACK_TESTHUB_JWT, BROWSERSTACK_ACCESSIBILITY, BROWSERSTACK_TEST_PLAN_ID } from '../src/constants.js' import * as testHubUtils from '../src/testHub/utils.js' import type { Options } from '@wdio/types' @@ -791,6 +792,43 @@ describe('launchTestSession', () => { expect(result).toEqual(mockResponse) }) + it('includes test_management with testPlanId from options in build start payload', async () => { + const mockResponse = { build_hashed_id: 'build_id', jwt: 'jwt' } + mockedGot.post = vi.fn().mockReturnValue({ + json: () => Promise.resolve(mockResponse), + } as any) + vi.spyOn(testHubUtils, 'getProductMapForBuildStartCall').mockReturnValue({}) + + await launchTestSession({ framework: 'framework', testManagementOptions: { testPlanId: 'tp-123' } } as any, {}, {}, {}) + const [, reqOptions] = (mockedGot.post as any).mock.calls[0] + expect(reqOptions.json.test_management).toEqual({ test_plan_id: 'tp-123' }) + }) + + it('includes test_management with testPlanId from env var in build start payload', async () => { + process.env[BROWSERSTACK_TEST_PLAN_ID] = 'tp-env-456' + const mockResponse = { build_hashed_id: 'build_id', jwt: 'jwt' } + mockedGot.post = vi.fn().mockReturnValue({ + json: () => Promise.resolve(mockResponse), + } as any) + vi.spyOn(testHubUtils, 'getProductMapForBuildStartCall').mockReturnValue({}) + + await launchTestSession({ framework: 'framework' } as any, {}, {}, {}) + const [, reqOptions] = (mockedGot.post as any).mock.calls[0] + expect(reqOptions.json.test_management).toEqual({ test_plan_id: 'tp-env-456' }) + delete process.env[BROWSERSTACK_TEST_PLAN_ID] + }) + + it('includes test_management with undefined testPlanId when not set', async () => { + const mockResponse = { build_hashed_id: 'build_id', jwt: 'jwt' } + mockedGot.post = vi.fn().mockReturnValue({ + json: () => Promise.resolve(mockResponse), + } as any) + vi.spyOn(testHubUtils, 'getProductMapForBuildStartCall').mockReturnValue({}) + + await launchTestSession({ framework: 'framework' } as any, {}, {}, {}) + const [, reqOptions] = (mockedGot.post as any).mock.calls[0] + expect(reqOptions.json.test_management).toEqual({ test_plan_id: undefined }) + }) }) describe('getLogTag', () => { @@ -957,6 +995,62 @@ describe('getObservabilityBuildTags', () => { }) }) +describe('getTestPlanId', () => { + const CLI_ARG = '--browserstack.testManagementOptions.testPlanId' + + afterEach(() => { + delete process.env[BROWSERSTACK_TEST_PLAN_ID] + // restore argv to original state + process.argv = process.argv.filter((arg) => !arg.startsWith(CLI_ARG)) + }) + + it('returns testPlanId from env var', () => { + process.env[BROWSERSTACK_TEST_PLAN_ID] = 'tp-env-123' + expect(getTestPlanId({} as any)).toEqual('tp-env-123') + }) + + it('env var takes priority over CLI arg and options', () => { + process.env[BROWSERSTACK_TEST_PLAN_ID] = 'tp-env-123' + process.argv.push(CLI_ARG, 'tp-cli-789') + expect(getTestPlanId({ testManagementOptions: { testPlanId: 'tp-opts-456' } } as any)).toEqual('tp-env-123') + }) + + it('returns testPlanId from CLI arg (space-separated)', () => { + process.argv.push(CLI_ARG, 'tp-cli-789') + expect(getTestPlanId({} as any)).toEqual('tp-cli-789') + }) + + it('returns testPlanId from CLI arg (equals-separated)', () => { + process.argv.push(`${CLI_ARG}=tp-cli-equals`) + expect(getTestPlanId({} as any)).toEqual('tp-cli-equals') + }) + + it('CLI arg takes priority over options', () => { + process.argv.push(CLI_ARG, 'tp-cli-789') + expect(getTestPlanId({ testManagementOptions: { testPlanId: 'tp-opts-456' } } as any)).toEqual('tp-cli-789') + }) + + it('returns testPlanId from testManagementOptions when env var and CLI arg are not set', () => { + expect(getTestPlanId({ testManagementOptions: { testPlanId: 'tp-opts-456' } } as any)).toEqual('tp-opts-456') + }) + + it('trims whitespace from testManagementOptions testPlanId', () => { + expect(getTestPlanId({ testManagementOptions: { testPlanId: ' tp-opts-456 ' } } as any)).toEqual('tp-opts-456') + }) + + it('returns undefined when testPlanId is empty string', () => { + expect(getTestPlanId({ testManagementOptions: { testPlanId: ' ' } } as any)).toBeUndefined() + }) + + it('returns undefined when testManagementOptions is not set', () => { + expect(getTestPlanId({} as any)).toBeUndefined() + }) + + it('returns undefined when testManagementOptions.testPlanId is not a string', () => { + expect(getTestPlanId({ testManagementOptions: { testPlanId: 123 } } as any)).toBeUndefined() + }) +}) + describe('o11yErrorHandler', () => { let spy: any beforeEach(() => {