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
6 changes: 6 additions & 0 deletions .changeset/pr-193.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@wdio/browserstack-service": minor
---

- Added `browser.uploadAttachment(filePath)` (also available as `browser.uploadMedia`) so
- Made BrowserStack session bootstrap tolerant of an incomplete configuration response.
6 changes: 6 additions & 0 deletions .changeset/sdk-7138-upload-attachment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@wdio/browserstack-service": minor
---

- Added `browser.uploadAttachment(filePath)` (also available as `browser.uploadMedia`) so WebdriverIO tests can attach files to a test, hook, or build in Test Reporting — the same capability the Java, Python and Node SDKs already offer. Pass `{ buildAttachment: true }` to attach to the build instead of the current test.
- Made BrowserStack session bootstrap tolerant of an incomplete configuration response. Previously an empty or partial response aborted the whole bootstrap, which silently disabled every BrowserStack feature for that run — including custom tags and Test Reporting — and could leave the build with no test results.
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@
declare namespace WebdriverIO {
interface Browser {
setCustomTags: (key: string, value: string) => Promise<void>
uploadAttachment: (filePath: string, options?: { buildAttachment?: boolean }) => Promise<void>
uploadMedia: (filePath: string, options?: { buildAttachment?: boolean }) => Promise<void>
}

interface MultiRemoteBrowser {
setCustomTags: (key: string, value: string) => Promise<void>
uploadAttachment: (filePath: string, options?: { buildAttachment?: boolean }) => Promise<void>
uploadMedia: (filePath: string, options?: { buildAttachment?: boolean }) => Promise<void>
}
}
49 changes: 38 additions & 11 deletions packages/browserstack-service/src/cli/apiUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,43 @@ export default class APIUtils {
static UPLOAD_LOGS_ADDRESS = 'https://upload-observability.browserstack.com'
static EDS_URL = 'https://eds.browserstack.com'

static updateURLSForGRR(apis: GRRUrls) {
this.FUNNEL_INSTRUMENTATION_URL = `${apis.automate.api}/sdk/v1/event`
this.BROWSERSTACK_AUTOMATE_API_URL = apis.automate.api
this.BROWSERSTACK_AA_API_URL = apis.appAutomate.api
this.BROWSERSTACK_PERCY_API_URL = apis.percy.api
this.BROWSERSTACK_AUTOMATE_API_CLOUD_URL = apis.automate.upload
this.BROWSERSTACK_AA_API_CLOUD_URL = apis.appAutomate.upload
this.APP_ALLY_ENDPOINT = `${apis.appAccessibility.api}/automate`
this.DATA_ENDPOINT = apis.observability.api
this.UPLOAD_LOGS_ADDRESS = apis.observability.upload
this.EDS_URL = apis.edsInstrumentation.api
/**
* Overlay the binary-supplied GRR endpoints onto the public defaults. Every field is
* optional: a degenerate StartBinSession/ConnectBinSession config (auth failure, empty
* payload) used to throw here and abort the whole CLI bootstrap, taking every product
* module with it. Missing entries now just leave the corresponding default in place.
*/
static updateURLSForGRR(apis?: GRRUrls) {
if (!apis) {
return
}
if (apis.automate?.api) {
this.FUNNEL_INSTRUMENTATION_URL = `${apis.automate.api}/sdk/v1/event`
this.BROWSERSTACK_AUTOMATE_API_URL = apis.automate.api
}
if (apis.automate?.upload) {
this.BROWSERSTACK_AUTOMATE_API_CLOUD_URL = apis.automate.upload
}
if (apis.appAutomate?.api) {
this.BROWSERSTACK_AA_API_URL = apis.appAutomate.api
}
if (apis.appAutomate?.upload) {
this.BROWSERSTACK_AA_API_CLOUD_URL = apis.appAutomate.upload
}
if (apis.percy?.api) {
this.BROWSERSTACK_PERCY_API_URL = apis.percy.api
}
if (apis.appAccessibility?.api) {
this.APP_ALLY_ENDPOINT = `${apis.appAccessibility.api}/automate`
}
if (apis.observability?.api) {
this.DATA_ENDPOINT = apis.observability.api
}
if (apis.observability?.upload) {
this.UPLOAD_LOGS_ADDRESS = apis.observability.upload
}
if (apis.edsInstrumentation?.api) {
this.EDS_URL = apis.edsInstrumentation.api
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,6 @@ export const TestFrameworkConstants = {
DEFAULT_HOOK_RESULT : 'pending',
KIND_SCREENSHOT : 'TEST_SCREENSHOT',
KIND_LOG : 'TEST_LOG',
KIND_ATTACHMENT : 'TEST_ATTACHMENT',
HOOK_REGEX : '^(BEFORE_|AFTER_)',
}
5 changes: 5 additions & 0 deletions packages/browserstack-service/src/cli/grpcClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,11 @@ export class GrpcClient {
message: log.message,
timestamp: log.timestamp,
level: log.level,
// Attachment entries carry no message — the binary streams the file
// from filePath when it drains its upload queue.
fileName: log.fileName,
fileSize: log.fileSize,
filePath: log.filePath,
})
logEntries.push(logEntry)
}
Expand Down
13 changes: 13 additions & 0 deletions packages/browserstack-service/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import WdioAutomationFramework from './frameworks/wdioAutomationFramework.js'
import WebdriverIOModule from './modules/webdriverIOModule.js'
import AccessibilityModule from './modules/accessibilityModule.js'
import CustomTagsModule from './modules/customTagsModule.js'
import UploadAttachmentModule from './modules/uploadAttachmentModule.js'
import { isTurboScale, processAccessibilityResponse, shouldAddServiceVersion } from '../util.js'
import ObservabilityModule from './modules/observabilityModule.js'
import type { BrowserstackConfig, BrowserstackOptions, LaunchResponse } from '../types.js'
Expand Down Expand Up @@ -183,6 +184,10 @@ export class BrowserstackCLI {
// to TestHub, so it is gated on the testhub pipeline being active.
this.modules[CustomTagsModule.MODULE_NAME] = new CustomTagsModule()

// Attachments ride a TEST_ATTACHMENT LogCreated event keyed on the test /
// hook uuid, so they are gated on the same pipeline.
this.modules[UploadAttachmentModule.MODULE_NAME] = new UploadAttachmentModule()

if (startBinResponse.accessibility?.success){
process.env[BROWSERSTACK_ACCESSIBILITY] = 'true'
const options = this.options as BrowserstackConfig & BrowserstackOptions
Expand Down Expand Up @@ -528,6 +533,14 @@ export class BrowserstackCLI {
*/
setConfig(response: StartBinSessionResponse) {
try {
// A degenerate bin-session response (auth failure, races on a parallel worker's
// ConnectBinSession) carries an empty config. JSON.parse would throw, leaving
// this.config on its previous value and the error indistinguishable from a
// malformed payload — keep the empty default and say so.
if (!response.config || !response.config.trim()) {
this.logger.warn('setConfig: bin session returned an empty config; continuing with defaults')
return
}
this.config = JSON.parse(response.config)
// Binary now nests apis under config.sessionData; prefer it, fall back to the flat config.apis (SDK-6821 Phase 3)
const sessionData = this.config.sessionData as { apis?: unknown } | undefined
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
/// <reference path="../../@types/bstack-service-types.d.ts" />
import fs from 'node:fs'
import path from 'node:path'
import BaseModule from './baseModule.js'
import { BStackLogger } from '../cliLogger.js'
import TestFramework from '../frameworks/testFramework.js'
import AutomationFramework from '../frameworks/automationFramework.js'
import type AutomationFrameworkInstance from '../instances/automationFrameworkInstance.js'
import type TestFrameworkInstance from '../instances/testFrameworkInstance.js'
import { AutomationFrameworkState } from '../states/automationFrameworkState.js'
import { HookState } from '../states/hookState.js'
import { TestFrameworkConstants } from '../frameworks/constants/testFrameworkConstants.js'
import { CLIUtils } from '../cliUtils.js'
import WdioMochaTestFramework from '../frameworks/wdioMochaTestFramework.js'
import { GrpcClient } from '../grpcClient.js'
import { UPLOAD_ATTACHMENT_ACK_TIMEOUT_MS } from '../../constants.js'
import type { AttachmentLevel, AttachmentOptions } from '../../types.js'

/** Parity with the Java / Python / Node SDKs, which all reject above 100 MB. */
const MAX_ATTACHMENT_SIZE_BYTES = 100 * 1024 * 1024

/**
* UploadAttachmentModule — CLI/gRPC path registration for `browser.uploadAttachment`
* (aliased as `browser.uploadMedia`).
*
* Mirrors CustomTagsModule: registers the browser method in onBeforeExecute()
* (observer-bound to AutomationFrameworkState.CREATE / HookState.POST), instantiated
* from BrowserstackCLI.loadModules() whenever the binary is up.
*
* The file itself is NOT copied. The binary streams it from `filePath` when it drains
* its upload queue, which can be after this process has moved on — so the entry carries
* the caller's own absolute path, and the binary reads it in place. `level` is what the
* binary switches on to pick test_run_uuid / hook_run_uuid / build_run_uuid.
*/
export default class UploadAttachmentModule extends BaseModule {

logger = BStackLogger
name: string
static MODULE_NAME = 'UploadAttachmentModule'

private pendingSends = new Set<Promise<void>>()

constructor() {
super()
this.name = UploadAttachmentModule.MODULE_NAME
AutomationFramework.registerObserver(AutomationFrameworkState.CREATE, HookState.POST, this.onBeforeExecute.bind(this))
}

getModuleName() {
return UploadAttachmentModule.MODULE_NAME
}

async onBeforeExecute() {
try {
const autoInstance: AutomationFrameworkInstance = AutomationFramework.getTrackedInstance()
if (!autoInstance) {
this.logger.debug('UploadAttachmentModule: No tracked automation instance found!')
return
}

const browser = AutomationFramework.getDriver(autoInstance) as WebdriverIO.Browser
if (!browser) {
this.logger.debug('UploadAttachmentModule: No browser instance found for uploadAttachment registration')
return
}

const uploadAttachment = async (filePath: string, options?: AttachmentOptions): Promise<void> => {
try {
await this.recordAttachment(filePath, options)
} catch (error) {
this.logger.warn(`uploadAttachment: error while recording attachment: ${error}`)
}
}

browser.uploadAttachment = uploadAttachment
browser.uploadMedia = uploadAttachment
} catch (error) {
this.logger.error(`Error in UploadAttachmentModule.onBeforeExecute: ${error}`)
}
}

private async recordAttachment(filePath: string, options?: AttachmentOptions) {
if (!filePath || !filePath.trim()) {
this.logger.warn('uploadAttachment: file path is required; ignoring call')
return
}

const resolvedPath = path.resolve(filePath.trim())
let stats: fs.Stats
try {
stats = fs.statSync(resolvedPath)
} catch {
this.logger.warn(`uploadAttachment: file does not exist at ${resolvedPath}; ignoring call`)
return
}

if (!stats.isFile()) {
this.logger.warn(`uploadAttachment: ${resolvedPath} is not a file; ignoring call`)
return
}

if (stats.size > MAX_ATTACHMENT_SIZE_BYTES) {
this.logger.warn(`uploadAttachment: ${resolvedPath} is ${stats.size} bytes, above the ${MAX_ATTACHMENT_SIZE_BYTES}-byte limit; ignoring call`)
return
}

const instance: TestFrameworkInstance = TestFramework.getTrackedInstance()
if (!instance) {
this.logger.debug('uploadAttachment: no tracked test instance; cannot attribute the attachment, ignoring call')
return
}

const target = this.resolveTarget(instance, options)
if (!target) {
this.logger.debug('uploadAttachment: could not resolve a test or hook to attach to; ignoring call')
return
}

this.sendAttachmentEvent(instance, resolvedPath, stats.size, target)
}

/**
* Pick the attachment level and the uuid it hangs off. A build-level attachment still
* needs a uuid on the wire — the binary drops log entries without one before it ever
* reads `level` — so it reuses whichever test/hook uuid is current and the binary
* substitutes the build id downstream.
*/
private resolveTarget(instance: TestFrameworkInstance, options?: AttachmentOptions): { level: AttachmentLevel, uuid: string, testFrameworkState: string } | null {
const testFrameworkState = instance.getCurrentTestState().toString().split('.')[1] ?? ''
const inHook = CLIUtils.matchHookRegex(testFrameworkState)
const hook = inHook ? WdioMochaTestFramework.lastActiveHook(instance, WdioMochaTestFramework.KEY_HOOK_LAST_STARTED) : null
const hookUuid = hook ? hook[TestFrameworkConstants.KEY_HOOK_ID] as string : ''
const testUuid = TestFramework.getState(instance, TestFrameworkConstants.KEY_TEST_UUID) as string

const uuid = hookUuid || testUuid
if (!uuid) {
return null
}

if (options?.buildAttachment) {
return { level: 'BuildLevel', uuid, testFrameworkState }
}
return hookUuid
? { level: 'HookLevel', uuid: hookUuid, testFrameworkState }
: { level: 'TestLevel', uuid: testUuid, testFrameworkState }
}

/**
* Dispatch and return — deliberately NOT awaited by the caller.
*
* uploadAttachment is called from the customer's test body, and the very next statement
* is usually a browser command that the accessibility module wraps with a pre-command
* scan. Awaiting a binary round-trip on that stack was observed to stall the following
* `executeAsync` scan under load (chrome sessions reaped at the framework timeout), so
* the event is written and its ack observed off the caller's stack. The ack carries no
* information the caller can act on: the binary streams the file from `filePath` while
* draining its own upload queue.
*/
private sendAttachmentEvent(
instance: TestFrameworkInstance,
filePath: string,
fileSize: number,
target: { level: AttachmentLevel, uuid: string, testFrameworkState: string }
) {
const testData = instance.getAllData()
const trackedContext = instance.getContext()
const platformIndex = process.env.WDIO_WORKER_ID ? parseInt(process.env.WDIO_WORKER_ID.split('-')[0]) : 0

const ack = GrpcClient.getInstance().logCreatedEvent({
platformIndex,
executionContext: {
hash: trackedContext.getId(),
threadId: trackedContext.getThreadId().toString(),
processId: trackedContext.getProcessId().toString()
},
logs: [{
testFrameworkName: (testData.get(TestFrameworkConstants.KEY_TEST_FRAMEWORK_NAME) as string) || '',
testFrameworkVersion: (testData.get(TestFrameworkConstants.KEY_TEST_FRAMEWORK_VERSION) as string) || '',
testFrameworkState: target.testFrameworkState,
uuid: target.uuid,
kind: TestFrameworkConstants.KIND_ATTACHMENT,
message: new Uint8Array(),
timestamp: new Date().toISOString(),
level: target.level,
fileName: path.basename(filePath),
fileSize,
filePath
}]
})

let timer: NodeJS.Timeout | undefined
const observed = Promise.race([
ack.then(() => 'ok', (error) => `failed: ${error}`),
new Promise<string>((resolve) => {
timer = setTimeout(() => resolve('unacked'), UPLOAD_ATTACHMENT_ACK_TIMEOUT_MS)
})
]).then((outcome) => {
clearTimeout(timer)
if (outcome === 'ok') {
this.logger.debug(`uploadAttachment: sent ${target.level} attachment ${filePath} (${fileSize} bytes) for uuid=${target.uuid}`)
} else if (outcome === 'unacked') {
this.logger.warn(`uploadAttachment: ${filePath} was sent but the binary did not ack within ${UPLOAD_ATTACHMENT_ACK_TIMEOUT_MS}ms`)
} else {
this.logger.warn(`uploadAttachment: could not record ${filePath} — ${outcome}`)
}
})

// Held only so the send is never an unobserved promise; pruned as they settle.
this.pendingSends.add(observed)
observed.finally(() => this.pendingSends.delete(observed))
}
}
5 changes: 5 additions & 0 deletions packages/browserstack-service/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,11 @@ export const STOP_BUILD_ATTEMPT_TIMEOUT_MS = 10000
export const STOP_BUILD_TOTAL_BUDGET_MS = 30000
export const STOP_BUILD_BACKOFF_BASE_MS = 1000

// uploadAttachment is called from inside the customer's test body, so the wait for the
// binary's ack is bounded: the event is already on the wire when the timer fires, and a
// wedged binary must not stall the test that called us.
export const UPLOAD_ATTACHMENT_ACK_TIMEOUT_MS = 10000

// API Endpoint constants
export const UPDATED_CLI_ENDPOINT = 'sdk/v1/update_cli'

Expand Down
4 changes: 3 additions & 1 deletion packages/browserstack-service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ declare global {
performScan: () => Promise<Record<string, unknown> | undefined>,
startA11yScanning: () => Promise<void>,
stopA11yScanning: () => Promise<void>,
setCustomTags: (key: string, value: string) => Promise<void>
setCustomTags: (key: string, value: string) => Promise<void>,
uploadAttachment: (filePath: string, options?: { buildAttachment?: boolean }) => Promise<void>,
uploadMedia: (filePath: string, options?: { buildAttachment?: boolean }) => Promise<void>
}
}
interface State {
Expand Down
7 changes: 7 additions & 0 deletions packages/browserstack-service/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,13 @@ export interface ScreenshotLog extends LogData {
kind: 'TEST_SCREENSHOT'
}

/** Which run the attachment hangs off; the binary switches on this to pick the uuid field. */
export type AttachmentLevel = 'TestLevel' | 'HookLevel' | 'BuildLevel'

export interface AttachmentOptions {
buildAttachment?: boolean
}

export interface LaunchResponse {
jwt: string,
build_hashed_id: string,
Expand Down
Loading