Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions packages/browserstack-service/src/cli/cliUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
isFalse,
isTurboScale,
shouldAddServiceVersion,
getTestPlanId,
} from '../util.js'
import PerformanceTester from '../instrumentation/performance/performance-tester.js'
import { EVENTS as PerformanceEvents } from '../instrumentation/performance/constants.js'
Expand All @@ -32,7 +33,6 @@ import type { Options, Capabilities } from '@wdio/types'
import type {
BrowserstackConfig,
BrowserstackOptions,
TestManagementOptions,
TestObservabilityOptions,
} from '../types.js'
import { TestFrameworkConstants } from './frameworks/constants/testFrameworkConstants.js'
Expand All @@ -47,7 +47,7 @@ const CLI_DOWNLOAD_TMP_SUFFIX = '.zip'
export class CLIUtils {
static automationFrameworkDetail = {}
static testFrameworkDetail = {}
static CLISupportedFrameworks = ['mocha', 'cucumber']
static CLISupportedFrameworks = ['mocha', 'cucumber', 'jasmine']

static isDevelopmentEnv() {
return process.env.BROWSERSTACK_CLI_ENV === 'development'
Expand Down Expand Up @@ -116,11 +116,7 @@ export class CLIUtils {
)
const observabilityOptions: TestObservabilityOptions =
options.testObservabilityOptions || {}
const testManagementOptions: TestManagementOptions =
options.testManagementOptions || {}
const testPlanId = typeof testManagementOptions.testPlanId === 'string'
? testManagementOptions.testPlanId.trim()
: ''
const testPlanId = (getTestPlanId(options as BrowserstackConfig & Options.Testrunner) || '').trim()
const binconfig: Record<string, unknown> = {
userName: observabilityOptions.user || config.user,
accessKey: observabilityOptions.key || config.key,
Expand Down

Large diffs are not rendered by default.

23 changes: 22 additions & 1 deletion packages/browserstack-service/src/cli/grpcClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,27 @@ const SUMMARY_ANSI = {
warn: { base: '\x1b[33m', emphasis: '\x1b[1;33m' }
}

const TEST_PLAN_ID_ARG = '--browserstack.testManagementOptions.testPlanId'

// The binary parses CLI args as `--key=value` only, so the space form reaches it as `true` and
// overrides the id getBinConfig already resolved; the id travels in the bin config instead.
export function stripTestPlanIdArg(args: string[]): string[] {
const result: string[] = []
for (let i = 0; i < args.length; i++) {
if (args[i] === TEST_PLAN_ID_ARG) {
if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
i++
}
continue
}
if (args[i].startsWith(`${TEST_PLAN_ID_ARG}=`)) {
continue
}
result.push(args[i])
}
return result
}

/**
* GrpcClient - Singleton class for managing gRPC client connections
*
Expand Down Expand Up @@ -180,7 +201,7 @@ export class GrpcClient {
sdkVersion: packageVersion,
pathProject: process.cwd(),
pathConfig: path.resolve(process.cwd(), 'browserstack.yml'),
cliArgs: process.argv.slice(2),
cliArgs: stripTestPlanIdArg(process.argv.slice(2)),
frameworks: [automationFrameworkDetail.name, testFrameworkDetail.name],
frameworkVersions,
language: CLIUtils.getSdkLanguage(),
Expand Down
7 changes: 6 additions & 1 deletion packages/browserstack-service/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { Options } from '@wdio/types'
import TestOpsConfig from '../testOps/testOpsConfig.js'
import WdioMochaTestFramework from './frameworks/wdioMochaTestFramework.js'
import WdioCucumberTestFramework from './frameworks/wdioCucumberTestFramework.js'
import WdioJasmineTestFramework from './frameworks/wdioJasmineTestFramework.js'
import WdioAutomationFramework from './frameworks/wdioAutomationFramework.js'
import WebdriverIOModule from './modules/webdriverIOModule.js'
import AccessibilityModule from './modules/accessibilityModule.js'
Expand Down Expand Up @@ -48,7 +49,7 @@ export class BrowserstackCLI {
modulesLoaded = false
binSessionId: string | null = null
modules: Record<string, BaseModule> = {}
testFramework: WdioMochaTestFramework|WdioCucumberTestFramework|null = null
testFramework: WdioMochaTestFramework|WdioCucumberTestFramework|WdioJasmineTestFramework|null = null
cliParams: Record<string, string> | null = null
automationFramework: WdioAutomationFramework|null = null
SDK_CLI_BIN_PATH: string | null = null
Expand Down Expand Up @@ -555,6 +556,10 @@ export class BrowserstackCLI {
this.testFramework = new WdioCucumberTestFramework([testFrameworkDetail.name], testFrameworkDetail.version, this.binSessionId as string)
return
}
if (testFrameworkDetail.name.toLowerCase() === 'webdriverio-jasmine') {
this.testFramework = new WdioJasmineTestFramework([testFrameworkDetail.name], testFrameworkDetail.version, this.binSessionId as string)
return
}
// An unmatched name leaves testFramework null, and every CLI event then no-ops with no
// error of any kind. Name it so the silence is diagnosable.
this.logger.error(`setupTestFramework: no CLI test framework registered for name=${testFrameworkDetail.name}; test events will not be tracked`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ export default class AccessibilityModule extends BaseModule {
// on suite and test titles, and in this window neither exists yet. onBeforeTest
// re-computes the per-test gate, tags included, so this only affects the window.
const preTestSessionId = this.currentSessionId()
if (this.autoScanning && preTestSessionId !== undefined && preTestSessionId !== null) {
if (this.autoScanning && this.supportsPreTestWindow() && preTestSessionId !== undefined && preTestSessionId !== null) {
this.accessibilityMap.set(preTestSessionId, true)
this.logger.debug('Accessibility scan gate opened ahead of the first test')
}
Expand Down Expand Up @@ -338,9 +338,11 @@ export default class AccessibilityModule extends BaseModule {
try {
this.logger.debug('Accessibility before test hook. Starting accessibility scan for this test case.')
const suiteTitle = (typeof args.suiteTitle === 'string' ? args.suiteTitle : '') || ''
const test = (args.test && typeof args.test === 'object' ? args.test as { title?: string } : {}) || {}
const test = (args.test && typeof args.test === 'object' ? args.test as { title?: string, description?: string } : {}) || {}
// jasmine specs carry their name in `description` (`title` is unset), as on the classic handler
const testTitle = test.title ?? test.description

this.currentTestName = test.title || null
this.currentTestName = testTitle || null
this.testContextSeen = true
const autoInstance: AutomationFrameworkInstance = AutomationFramework.getTrackedInstance()
const testInstance: TestFrameworkInstance = TestFramework.getTrackedInstance()
Expand All @@ -353,7 +355,7 @@ export default class AccessibilityModule extends BaseModule {
// only ever populated on the cucumber path, so mocha and jasmine keep the exact 3-arg
// behaviour — both extra args arrive undefined/false and the tag branch is not taken.
const world = args.world as { [key: string]: unknown } | undefined
const shouldScanTest = this.autoScanning && shouldScanTestForAccessibility(suiteTitle, test.title || '', accessibilityOptions as Record<string, string> | undefined, world, Boolean(world)) && this.accessibility
const shouldScanTest = this.autoScanning && shouldScanTestForAccessibility(suiteTitle, testTitle || '', accessibilityOptions as Record<string, string> | undefined, world, Boolean(world)) && this.accessibility

this.accessibilityMap.set(sessionId, shouldScanTest)

Expand Down Expand Up @@ -537,6 +539,12 @@ export default class AccessibilityModule extends BaseModule {
return false
}

// Legacy jasmine opened no scan window before its first spec (classic PRE_TEST_SCAN_FRAMEWORKS)
private supportsPreTestWindow(): boolean {
const frameworks = BrowserstackCLI.getInstance().getTestFramework()?.getTestFrameworks() ?? []
return !frameworks.some((name) => String(name).toLowerCase().includes('jasmine'))
}

// See the classic handler: one definition, used at every scan site.
private get hasNoParent(): boolean {
return !this.currentHookRunUuid && !this.testContextSeen
Expand Down
78 changes: 71 additions & 7 deletions packages/browserstack-service/src/cli/modules/automateModule.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import BaseModule from './baseModule.js'
import WdioJasmineTestFramework from '../frameworks/wdioJasmineTestFramework.js'
import { BrowserstackCLI } from '../index.js'
import { BStackLogger } from '../cliLogger.js'
import TestFramework from '../frameworks/testFramework.js'
import { TestFrameworkState } from '../states/testFrameworkState.js'
Expand Down Expand Up @@ -88,9 +90,10 @@ export default class AutomateModule extends BaseModule {
}

let name = suiteTitle
if (testContextOptions.sessionNameFormat) {
const sessionNameFormat = this.sessionNameFormatFor(instace, testContextOptions)
if (sessionNameFormat) {
const caps = AutomationFramework.getState(autoInstance, AutomationFrameworkConstants.KEY_CAPABILITIES)
name = testContextOptions.sessionNameFormat(
name = sessionNameFormat(
this.browserStackConfig,
caps,
suiteTitle,
Expand Down Expand Up @@ -181,9 +184,10 @@ export default class AutomateModule extends BaseModule {
}

let name = suiteTitle
if (testContextOptions.sessionNameFormat) {
const sessionNameFormat = this.sessionNameFormatFor(instace, testContextOptions)
if (sessionNameFormat) {
const caps = AutomationFramework.getState(autoInstance, AutomationFrameworkConstants.KEY_CAPABILITIES)
name = testContextOptions.sessionNameFormat(
name = sessionNameFormat(
this.browserStackConfig,
caps,
suiteTitle,
Expand Down Expand Up @@ -324,6 +328,22 @@ export default class AutomateModule extends BaseModule {
}
}

/**
* The binary's config echo cannot carry a function, so `sessionNameFormat` arrives empty. Jasmine
* takes it from the worker's own service options, as legacy did.
*/
private sessionNameFormatFor(instance: TestFrameworkInstance, testContextOptions: TestContextOptions) {
if (testContextOptions.sessionNameFormat) {
return testContextOptions.sessionNameFormat
}
const frameworkName = String(TestFramework.getState(instance, TestFrameworkConstants.KEY_TEST_FRAMEWORK_NAME) || '')
if (!frameworkName.toLowerCase().includes('jasmine')) {
return undefined
}
const format = (BrowserstackCLI.getInstance().options as { sessionNameFormat?: unknown })?.sessionNameFormat
return typeof format === 'function' ? format as TestContextOptions['sessionNameFormat'] : undefined
}

private isCucumberInstance(instance: TestFrameworkInstance): boolean {
const frameworkName = String(TestFramework.getState(instance, TestFrameworkConstants.KEY_TEST_FRAMEWORK_NAME) || '')
return frameworkName.toLowerCase().includes('cucumber')
Expand All @@ -348,6 +368,16 @@ export default class AutomateModule extends BaseModule {
const accessKey = this.config.accessKey as string
const testContextOptions = this.config.testContextOptions as TestContextOptions

// The binary's config echo carries `testObservabilityOptions` empty, so read the worker's own service options
const serviceOptions = BrowserstackCLI.getInstance().options as { testObservabilityOptions?: { ignoreHooksStatus?: boolean } }
const ignoreHooksStatus = serviceOptions?.testObservabilityOptions?.ignoreHooksStatus === true
const liveSessionId = this.liveSessionId()
if (WdioJasmineTestFramework.sessionVerdict(liveSessionId, ignoreHooksStatus) !== undefined) {
await this.markJasmineSessions(liveSessionId, ignoreHooksStatus)
this.sessionMap.clear()
return
}

for (const [sessionId, sessionData] of this.sessionMap.entries()) {
try {
const failedTests = Array.from(sessionData.testResults.values()).filter(test => test.status === 'failed')
Expand Down Expand Up @@ -389,6 +419,39 @@ export default class AutomateModule extends BaseModule {
this.sessionMap.clear()
}

private liveSessionId(): string {
const autoInstance = AutomationFramework.getTrackedInstance()
return autoInstance ? String(AutomationFramework.getState(autoInstance, AutomationFrameworkConstants.KEY_FRAMEWORK_SESSION_ID) || '') : ''
}

/**
* Jasmine: legacy `service.after()` marked only the live session, with the worker's status, the last
* name, and the test and hook failure reasons. A session nothing registered (a beforeAll failed before
* any spec ran) is still marked, and a reloaded one is left to the mark `onReload` already sent.
*/
private async markJasmineSessions(liveSessionId: string, ignoreHooksStatus: boolean) {
const testContextOptions = this.config.testContextOptions as TestContextOptions
const auth = { user: this.config.userName as string, key: this.config.accessKey as string }
const sessionIds = new Set(this.sessionMap.keys())
if (liveSessionId && isBrowserstackSession(AutomationFramework.getDriver(AutomationFramework.getTrackedInstance()) as WebdriverIO.Browser)) {
sessionIds.add(liveSessionId)
}

for (const sessionId of sessionIds) {
try {
await this.flushSessionName(sessionId)
const verdict = WdioJasmineTestFramework.sessionVerdict(sessionId, ignoreHooksStatus)
if (!verdict || testContextOptions.skipSessionStatus) {
continue
}
const name = testContextOptions.skipSessionName ? undefined : this.sessionMap.get(sessionId)?.lastTestName || undefined
await this.markSessionStatus(sessionId, verdict.status, verdict.reason, auth, name)
} catch (error) {
this.logger.error(`Failed to process session ${sessionId}: ${error}`)
}
}
}

// An App Automate session is identified by the service-level app / skipAppOverride flag,
// OR an app supplied only via the appium:app / appium:options.app capability — which the
// service-level config (this.config) does not carry. Mirrors accessibilityModule.isAppAutomateSession.
Expand Down Expand Up @@ -474,17 +537,18 @@ export default class AutomateModule extends BaseModule {
)(sessionId, sessionName, config)
}

async markSessionStatus(sessionId: string, sessionStatus: 'passed' | 'failed', sessionErrorMessage: string | undefined, config: { user: string; key: string; }): Promise<void> {
async markSessionStatus(sessionId: string, sessionStatus: 'passed' | 'failed', sessionErrorMessage: string | undefined, config: { user: string; key: string; }, sessionName?: string): Promise<void> {
return await PerformanceTester.measureWrapper(
PERFORMANCE_SDK_EVENTS.AUTOMATE_EVENTS.SESSION_STATUS,
async (sessionId: string, sessionStatus: 'passed' | 'failed', sessionErrorMessage: string | undefined, config: { user: string; key: string; }) => {
async (sessionId: string, sessionStatus: 'passed' | 'failed', sessionErrorMessage: string | undefined, config: { user: string; key: string; }, sessionName?: string) => {
try {
const auth = Buffer.from(`${config.user}:${config.key}`).toString('base64')
const { url: sessionStatusApiUrl, method, product } = this.resolveSessionApi(sessionId)
this.logger.info(`Marking session status for ${product}`)

const body = {
status: sessionStatus,
...(sessionName ? { name: sessionName } : {}),
...(sessionErrorMessage ? { reason: sessionErrorMessage } : {})
}

Expand All @@ -504,7 +568,7 @@ export default class AutomateModule extends BaseModule {
this.logger.error(`Failed to update session status on BrowserStack: ${err}`)
}
}
)(sessionId, sessionStatus, sessionErrorMessage, config)
)(sessionId, sessionStatus, sessionErrorMessage, config, sessionName)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ export default class TestHubModule extends BaseModule {
}

onBeforeTest(args: Record<string, unknown>) {
// Jasmine reports to TestHub from its reporter feed; its module-only dispatches carry this marker
if (args.skipTestHub === true) {
return
}
this.logger.debug('onBeforeTest: Called after test hook from cli configured module!!!')
const autoInstance = AutomationFramework.getTrackedInstance() as AutomationFrameworkInstance
const instances = [autoInstance]
Expand All @@ -90,6 +94,9 @@ export default class TestHubModule extends BaseModule {
}

onAllTestEvents(args: Record<string, unknown>) {
if (args.skipTestHub === true) {
return
}
this.logger.debug('onAllTestEvents: Called after all test events from cli configured module!!!')
const instance = args.instance as TestFrameworkInstance
const testState = instance.getCurrentTestState()
Expand Down
2 changes: 1 addition & 1 deletion packages/browserstack-service/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export const DEFAULT_WAIT_TIMEOUT_FOR_PENDING_UPLOADS = 5000 // 5s
export const DEFAULT_WAIT_INTERVAL_FOR_PENDING_UPLOADS = 100 // 100ms
export const BSTACK_SERVICE_VERSION = bstackServiceVersion

export const NOT_ALLOWED_KEYS_IN_CAPS = ['includeTagsInTestingScope', 'excludeTagsInTestingScope', 'testManagementOptions', 'skipAppOverride', 'preferScenarioName']
export const NOT_ALLOWED_KEYS_IN_CAPS = ['includeTagsInTestingScope', 'excludeTagsInTestingScope', 'testManagementOptions', 'skipAppOverride', 'preferScenarioName', 'sessionNamePrependTopLevelSuiteTitle', 'sessionNameOmitTestTitle']
export const BROWSERSTACK_TEST_PLAN_ID = 'BROWSERSTACK_TEST_PLAN_ID'

export const LOGS_FILE = 'logs/bstack-wdio-service.log'
Expand Down
28 changes: 21 additions & 7 deletions packages/browserstack-service/src/insights-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -812,7 +812,8 @@ class _InsightsHandler {
logEntry: {
kind: TestFrameworkConstants.KIND_SCREENSHOT,
message: result.value,
timestamp: new Date().toISOString()
timestamp: new Date().toISOString(),
...(this._framework === 'jasmine' ? { test_run_uuid: testMeta.uuid } : {})
}
})
: this.listener.onScreenshot([{
Expand All @@ -829,16 +830,29 @@ class _InsightsHandler {
}

// log http request
const httpResponse = {
path: requestData.endpoint,
method: requestData.method,
body,
response: result
}
// Only jasmine registers `command` on the CLI flow, and the legacy listener is inert there
if (BrowserstackCLI.getInstance().isRunning()) {
await BrowserstackCLI.getInstance().getTestFramework()?.trackEvent(TestFrameworkState.LOG, HookState.POST, {
logEntry: {
kind: 'HTTP',
message: JSON.stringify(httpResponse),
timestamp: new Date().toISOString(),
test_run_uuid: testMeta.uuid
}
})
return
}
this.listener.logCreated([{
test_run_uuid: testMeta.uuid,
timestamp: new Date().toISOString(),
kind: 'HTTP',
http_response: {
path: requestData.endpoint,
method: requestData.method,
body,
response: result
}
http_response: httpResponse
}]
)
}
Expand Down
Loading
Loading