Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e19eebf
feat(browserstack-service): route wdio jasmine to the CLI flow (SDK-7…
AdityaHirapara Sep 26, 2026
3ae9e25
feat(browserstack-service): add WdioJasmineTestFramework and reporter…
AdityaHirapara Sep 26, 2026
362ee0e
fix(browserstack-service): route jasmine reporter events to TestHub o…
AdityaHirapara Sep 26, 2026
1a7d7c7
feat(browserstack-service): jasmine shared dispatch gates and HTTP lo…
AdityaHirapara Sep 26, 2026
6b687de
feat(browserstack-service): jasmine Automate session verdict on CLI (…
AdityaHirapara Sep 26, 2026
4c3c6ea
fix(browserstack-service): strip SDK-only session-name options from c…
AdityaHirapara Sep 26, 2026
4343a4c
fix(browserstack-service): apply jasmine sessionNameFormat on CLI (SD…
AdityaHirapara Sep 26, 2026
f97f8d9
feat(browserstack-service): jasmine accessibility gate on CLI (SDK-7772)
AdityaHirapara Sep 26, 2026
ddc719d
fix(browserstack-service): name the current jasmine test for Percy on…
AdityaHirapara Sep 26, 2026
d54cfc1
fix(browserstack-service): forward the CLI-arg test plan id to the bi…
AdityaHirapara Sep 28, 2026
bcea0e9
fix(browserstack-service): stop forwarding the raw test plan id arg t…
AdityaHirapara Sep 28, 2026
d8f56c1
test(cli): fold skipTestHub marker cases into the TestHubModule suite…
AdityaHirapara Sep 29, 2026
977dbfe
test: fold jasmine CLI cases into the existing cli, reporter, service…
AdityaHirapara Sep 30, 2026
05be96a
refactor(cli): give the jasmine framework one entry point and move it…
AdityaHirapara Oct 7, 2026
7d13ba0
refactor(cli): drop the jasmine framework's #iso helper for optional-…
AdityaHirapara Oct 7, 2026
8bec24d
feat(cli): annotate each mocha and jasmine test from AutomateModule (…
AdityaHirapara Oct 7, 2026
d3c4062
refactor(cli): capture WebDriver command logs in TestHubModule (SDK-7…
AdityaHirapara Oct 7, 2026
7bc0373
fix(cli): attribute WebDriver command logs as legacy did for jasmine …
AdityaHirapara Oct 8, 2026
7f5f899
fix(cli): read mocha's open hook from the started-hooks map for comma…
AdityaHirapara Oct 8, 2026
1929968
feat(cli): report HTTP command logs for mocha and cucumber (SDK-7772)
AdityaHirapara Oct 8, 2026
926cfce
fix(cli): drop mocha command logs from a test's start-up, as legacy d…
AdityaHirapara Oct 8, 2026
0eb69dc
fix(cli): keep mocha command logs outside a hook on the last test tha…
AdityaHirapara Oct 8, 2026
3ec705d
refactor(cli): drop the HTTP command-log opt-in now that every framew…
AdityaHirapara Oct 8, 2026
72ed189
refactor(cli): mark only the live jasmine session at worker end (SDK-…
AdityaHirapara Oct 9, 2026
034f81e
refactor(cli): stop dropping mocha command logs from a test's start-u…
AdityaHirapara Oct 9, 2026
2fa8769
Merge branch 'main' into SDK-7772/wdio-jasmine-platformisation
AdityaHirapara Oct 10, 2026
cb75567
chore(changeset): auto-generate from PR template (minor)
github-actions[bot] Oct 10, 2026
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
7 changes: 7 additions & 0 deletions .changeset/pr-244.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@wdio/browserstack-service": minor
---

- WebdriverIO with Jasmine (v9) now runs on the BrowserStack CLI: Test Observability, Accessibility, Automate/App Automate session status and naming, and Percy.
- HTTP command logs now appear in Test Observability for WebdriverIO Mocha and Cucumber, and Mocha sessions show a per-test annotation again.
- Fixed `--browserstack.testManagementOptions.testPlanId <id>` passed on the command line not reaching Test Management.
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
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ export default class WdioCucumberTestFramework extends TestFramework {

/** The hook currently open on this worker — started and not yet finished. */
private openHook: { key: string, hookId: string } | null = null
// WebDriver command logs attach only once a scenario has started, as legacy's current-test gate did.
private scenarioStarted = false

constructor(testFrameworks: string[], testFrameworkVersions: Record<string, string>, binSessionId: string) {
super(testFrameworks, testFrameworkVersions, binSessionId)
Expand Down Expand Up @@ -222,6 +224,13 @@ export default class WdioCucumberTestFramework extends TestFramework {
logger.debug(`WdioCucumberTestFramework.trackEvent: testFrameworkState=${testFrameworkState} hookState=${hookState}`)
await super.trackEvent(testFrameworkState, hookState, args)

if (testFrameworkState === TestFrameworkState.TEST && hookState === HookState.PRE) {
this.scenarioStarted = true
} else if (testFrameworkState === TestFrameworkState.LOG && args.commandLog === true && !this.scenarioStarted) {
logger.debug('trackEvent: command log before the first scenario dropped')
return
}

const instance = this.resolveInstance(testFrameworkState, hookState)
if (!instance) {
// Console output emitted before the first scenario (or before BeforeAll) has nothing
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ export default class WdioMochaTestFramework extends TestFramework {
static KEY_HOOK_LAST_STARTED = 'test_hook_last_started'
static KEY_HOOK_LAST_FINISHED = 'test_hook_last_finished'

// WebDriver command logs follow the hook or test that started last, even after it finished, as legacy's
// current test did: a hook names its hook run, a test (null) the test.
private commandLogHook: Record<string, unknown> | null = null
// The last test that ran: a skip report moves the tracked instance to the skipped test, never this.
private commandLogTest: TestFrameworkInstance | null = null

/**
* Constructor for the TestFramework
* @param {Array} testFrameworks - List of Test frameworks
Expand All @@ -55,7 +61,7 @@ export default class WdioMochaTestFramework extends TestFramework {
logger.info(`trackEvent: testFrameworkState=${testFrameworkState} hookState=${hookState}`)
await super.trackEvent(testFrameworkState, hookState, args)

const instance = this.resolveInstance(testFrameworkState, hookState, args)
const instance = this.commandLogTestInstance(testFrameworkState, hookState, args) ?? this.resolveInstance(testFrameworkState, hookState, args)
if (instance === null) {
logger.error(`trackEvent: instance not found for testFrameworkState=${testFrameworkState} hookState=${hookState}`)
return
Expand Down Expand Up @@ -92,7 +98,7 @@ export default class WdioMochaTestFramework extends TestFramework {
} else if (testFrameworkState === TestFrameworkState.LOG) {
const logEntry = args.logEntry as Record<string, unknown>
logEntry.uuid = TestFramework.getState(instance, TestFrameworkConstants.KEY_HOOK_ID)
this.loadLogEntries(instance, testFrameworkState, hookState, logEntry)
this.loadLogEntries(instance, testFrameworkState, hookState, logEntry, args.commandLog === true)
} else if (testFrameworkState === TestFrameworkState.LOG_REPORT && hookState === HookState.POST) {
logger.info('trackEvent: load test results')
this.loadTestResult(instance, args)
Expand All @@ -105,6 +111,12 @@ export default class WdioMochaTestFramework extends TestFramework {
// test_hooks_started forever (never popped).
if (CLIUtils.matchHookRegex(testFrameworkState.toString().split('.')[1])) {
await this.trackHookEvents(instance, testFrameworkState, hookState, args)
if (hookState === HookState.PRE) {
this.commandLogHook = WdioMochaTestFramework.openHook(instance) ?? null
}
} else if (testFrameworkState === TestFrameworkState.TEST && hookState === HookState.PRE && args.skipReport !== true) {
this.commandLogHook = null
this.commandLogTest = instance
}
logger.debug(`trackEvent: tracked instance data=${JSON.stringify(Object.fromEntries(instance.getAllData()))}`)
} catch (error) {
Expand Down Expand Up @@ -266,7 +278,7 @@ export default class WdioMochaTestFramework extends TestFramework {
* @param hookState HookState
* @param args Additional arguments (level, message, etc.)
*/
loadLogEntries(instance: TestFrameworkInstance, testFrameworkState: State, hookState: State, logEntry: Record<string, unknown>) {
loadLogEntries(instance: TestFrameworkInstance, testFrameworkState: State, hookState: State, logEntry: Record<string, unknown>, commandLog = false) {
const logRecord: Record<string, unknown> = {}
const { level, message, timestamp, kind } = logEntry

Expand All @@ -280,6 +292,14 @@ export default class WdioMochaTestFramework extends TestFramework {
logRecord.level = level
logRecord.timestamp = timestamp

// The instance is already in LOG here, so a WebDriver command log names its hook itself: the binary
// keys an entry to a hook run only by a hook state.
const commandLogHook = commandLog ? this.commandLogHook : null
if (commandLogHook) {
logRecord[TestFrameworkConstants.KEY_HOOK_ID] = commandLogHook[TestFrameworkConstants.KEY_HOOK_ID]
logRecord.testFrameworkState = commandLogHook.key
}

// Attach to the suitable hook
const lastActiveHook = WdioMochaTestFramework.lastActiveHook(instance, WdioMochaTestFramework.KEY_HOOK_LAST_STARTED)
if (lastActiveHook) {
Expand All @@ -297,6 +317,22 @@ export default class WdioMochaTestFramework extends TestFramework {
})
}

/** A command log outside any hook goes to the last test that ran, in LOG state, as console logs do. */
private commandLogTestInstance(testFrameworkState: State, hookState: State, args: Record<string, unknown>) {
if (testFrameworkState !== TestFrameworkState.LOG || args.commandLog !== true || this.commandLogHook || !this.commandLogTest) {
return null
}
this.updateInstanceState(this.commandLogTest, testFrameworkState, hookState)
return this.commandLogTest
}

/** The hook started last and not yet finished: a hook's POST pops it from the started list. */
static openHook(instance: TestFrameworkInstance): Record<string, unknown> | undefined {
const key = TestFramework.getState(instance, WdioMochaTestFramework.KEY_HOOK_LAST_STARTED) as string | undefined
const started = TestFramework.getState(instance, TestFrameworkConstants.KEY_HOOKS_STARTED) as Map<string, Record<string, unknown>[]> | undefined
return key && started instanceof Map ? started.get(key)?.at(-1) : undefined
}

/**
* Get the last active hook for the given instance and hook key.
* @param instance TestFrameworkInstance
Expand Down
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
14 changes: 12 additions & 2 deletions 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 @@ -49,7 +50,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 @@ -179,7 +180,12 @@ export class BrowserstackCLI {
this.modules[ObservabilityModule.MODULE_NAME] = new ObservabilityModule(startBinResponse.observability)
}

this.modules[TestHubModule.MODULE_NAME] = new TestHubModule(startBinResponse.testhub)
const testHubModule = new TestHubModule(startBinResponse.testhub)
this.modules[TestHubModule.MODULE_NAME] = testHubModule
testHubModule.setTestFramework(this.testFramework)
if (this.testFramework instanceof WdioJasmineTestFramework) {
this.testFramework.setTestHubModule(testHubModule)
}

// Custom-tag (multi Test-Case-ID) tagging rides the per-test event_json
// to TestHub, so it is gated on the testhub pipeline being active.
Expand Down Expand Up @@ -568,6 +574,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
Loading
Loading