From 4eb12b3a044ee29ab7af9d30e1bb0b433fe628e1 Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Mon, 17 Aug 2026 18:32:08 +0530 Subject: [PATCH 1/4] feat(browserstack-service): add browser.uploadAttachment / uploadMedia (SDK-7138) WebdriverIO had no way to attach a file to a test, hook or build in Test Reporting. Every sibling SDK ships one (BrowserStack.uploadAttachment in Java, driver.upload_attachment in Python, page.uploadAttachment in Node), and the binary's webdriverio language module already handles TEST_ATTACHMENT LogCreated entries end to end -- only the service-side entry point was missing, so driver.uploadMedia(...) threw "is not a function" and killed the customer's hook. UploadAttachmentModule registers the command the same way CustomTagsModule registers setCustomTags: on AutomationFrameworkState.CREATE / HookState.POST, instantiated from loadModules() when the testhub pipeline is up. It resolves the level (Test / Hook / Build) plus the uuid it hangs off, and emits one TEST_ATTACHMENT LogCreated entry. The file is not copied -- the binary streams it from filePath while draining its upload queue, which can outlive this process. grpcClient.logCreatedEvent was dropping fileName / fileSize / filePath on the floor even though the proto and generated types already carry them; without that the binary has nothing to stream. Also hardens CLI bootstrap against a degenerate bin-session response, observed on parallel workers alongside this bug: an empty config made JSON.parse throw in setConfig, and updateURLSForGRR then dereferenced the undefined config and threw out of loadModules. That aborted the entire bootstrap, so no module loaded -- custom tags, observability and the rest silently went away and the build recorded no test results. Both sites now degrade to defaults instead. Verified against the SDK-7138 reproduction (wdio_mocha upload-media/custom-tags spec, @wdio/browserstack-service built from main): uploadMedia and uploadAttachment both register, before-all/after-all hooks and the first test run clean where they previously died in "before all". --- .changeset/sdk-7138-upload-attachment.md | 6 + .../src/@types/bstack-service-types.d.ts | 4 + .../browserstack-service/src/cli/apiUtils.ts | 49 +++-- .../constants/testFrameworkConstants.ts | 1 + .../src/cli/grpcClient.ts | 5 + .../browserstack-service/src/cli/index.ts | 13 ++ .../src/cli/modules/uploadAttachmentModule.ts | 179 ++++++++++++++++++ packages/browserstack-service/src/index.ts | 4 +- packages/browserstack-service/src/types.ts | 7 + .../tests/cli/apiUtils.test.ts | 63 ++++++ .../modules/uploadAttachmentModule.test.ts | 174 +++++++++++++++++ 11 files changed, 493 insertions(+), 12 deletions(-) create mode 100644 .changeset/sdk-7138-upload-attachment.md create mode 100644 packages/browserstack-service/src/cli/modules/uploadAttachmentModule.ts create mode 100644 packages/browserstack-service/tests/cli/apiUtils.test.ts create mode 100644 packages/browserstack-service/tests/cli/modules/uploadAttachmentModule.test.ts diff --git a/.changeset/sdk-7138-upload-attachment.md b/.changeset/sdk-7138-upload-attachment.md new file mode 100644 index 0000000..94faa9f --- /dev/null +++ b/.changeset/sdk-7138-upload-attachment.md @@ -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. diff --git a/packages/browserstack-service/src/@types/bstack-service-types.d.ts b/packages/browserstack-service/src/@types/bstack-service-types.d.ts index 1ae813f..a4aff1a 100644 --- a/packages/browserstack-service/src/@types/bstack-service-types.d.ts +++ b/packages/browserstack-service/src/@types/bstack-service-types.d.ts @@ -8,9 +8,13 @@ declare namespace WebdriverIO { interface Browser { setCustomTags: (key: string, value: string) => Promise + uploadAttachment: (filePath: string, options?: { buildAttachment?: boolean }) => Promise + uploadMedia: (filePath: string, options?: { buildAttachment?: boolean }) => Promise } interface MultiRemoteBrowser { setCustomTags: (key: string, value: string) => Promise + uploadAttachment: (filePath: string, options?: { buildAttachment?: boolean }) => Promise + uploadMedia: (filePath: string, options?: { buildAttachment?: boolean }) => Promise } } diff --git a/packages/browserstack-service/src/cli/apiUtils.ts b/packages/browserstack-service/src/cli/apiUtils.ts index 9eabcae..ecff81e 100644 --- a/packages/browserstack-service/src/cli/apiUtils.ts +++ b/packages/browserstack-service/src/cli/apiUtils.ts @@ -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 + } } } diff --git a/packages/browserstack-service/src/cli/frameworks/constants/testFrameworkConstants.ts b/packages/browserstack-service/src/cli/frameworks/constants/testFrameworkConstants.ts index 594fb63..e78f671 100644 --- a/packages/browserstack-service/src/cli/frameworks/constants/testFrameworkConstants.ts +++ b/packages/browserstack-service/src/cli/frameworks/constants/testFrameworkConstants.ts @@ -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_)', } diff --git a/packages/browserstack-service/src/cli/grpcClient.ts b/packages/browserstack-service/src/cli/grpcClient.ts index 5124318..0a10f7e 100644 --- a/packages/browserstack-service/src/cli/grpcClient.ts +++ b/packages/browserstack-service/src/cli/grpcClient.ts @@ -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) } diff --git a/packages/browserstack-service/src/cli/index.ts b/packages/browserstack-service/src/cli/index.ts index b1b3cc3..056053d 100644 --- a/packages/browserstack-service/src/cli/index.ts +++ b/packages/browserstack-service/src/cli/index.ts @@ -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' @@ -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 @@ -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 diff --git a/packages/browserstack-service/src/cli/modules/uploadAttachmentModule.ts b/packages/browserstack-service/src/cli/modules/uploadAttachmentModule.ts new file mode 100644 index 0000000..016bf97 --- /dev/null +++ b/packages/browserstack-service/src/cli/modules/uploadAttachmentModule.ts @@ -0,0 +1,179 @@ +/// +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 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' + + 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 => { + 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 + } + + await 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 } + } + + private async 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 + + await 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 + }] + }) + + this.logger.debug(`uploadAttachment: sent ${target.level} attachment ${filePath} (${fileSize} bytes) for uuid=${target.uuid}`) + } +} diff --git a/packages/browserstack-service/src/index.ts b/packages/browserstack-service/src/index.ts index 8d9d50f..4721065 100644 --- a/packages/browserstack-service/src/index.ts +++ b/packages/browserstack-service/src/index.ts @@ -27,7 +27,9 @@ declare global { performScan: () => Promise | undefined>, startA11yScanning: () => Promise, stopA11yScanning: () => Promise, - setCustomTags: (key: string, value: string) => Promise + setCustomTags: (key: string, value: string) => Promise, + uploadAttachment: (filePath: string, options?: { buildAttachment?: boolean }) => Promise, + uploadMedia: (filePath: string, options?: { buildAttachment?: boolean }) => Promise } } interface State { diff --git a/packages/browserstack-service/src/types.ts b/packages/browserstack-service/src/types.ts index 588e5ee..e0fd88b 100644 --- a/packages/browserstack-service/src/types.ts +++ b/packages/browserstack-service/src/types.ts @@ -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, diff --git a/packages/browserstack-service/tests/cli/apiUtils.test.ts b/packages/browserstack-service/tests/cli/apiUtils.test.ts new file mode 100644 index 0000000..ac386ff --- /dev/null +++ b/packages/browserstack-service/tests/cli/apiUtils.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, beforeEach } from 'vitest' + +import APIUtils from '../../src/cli/apiUtils.js' + +const DEFAULTS = { + FUNNEL_INSTRUMENTATION_URL: 'https://api.browserstack.com/sdk/v1/event', + BROWSERSTACK_AUTOMATE_API_URL: 'https://api.browserstack.com', + BROWSERSTACK_AA_API_URL: 'https://api.browserstack.com', + BROWSERSTACK_PERCY_API_URL: 'https://api.browserstack.com', + BROWSERSTACK_AUTOMATE_API_CLOUD_URL: 'https://api-cloud.browserstack.com', + BROWSERSTACK_AA_API_CLOUD_URL: 'https://api-cloud.browserstack.com', + APP_ALLY_ENDPOINT: 'https://app-accessibility.browserstack.com/automate', + DATA_ENDPOINT: 'https://collector-observability.browserstack.com', + UPLOAD_LOGS_ADDRESS: 'https://upload-observability.browserstack.com', + EDS_URL: 'https://eds.browserstack.com' +} as const + +describe('APIUtils.updateURLSForGRR', () => { + beforeEach(() => { + Object.assign(APIUtils, DEFAULTS) + }) + + it('overlays every endpoint from a complete GRR config', () => { + APIUtils.updateURLSForGRR({ + automate: { api: 'https://grr-automate', upload: 'https://grr-automate-upload' }, + appAutomate: { api: 'https://grr-aa', upload: 'https://grr-aa-upload' }, + percy: { api: 'https://grr-percy' }, + appAccessibility: { api: 'https://grr-app-a11y' }, + observability: { api: 'https://grr-o11y', upload: 'https://grr-o11y-upload' }, + edsInstrumentation: { api: 'https://grr-eds' } + } as never) + + expect(APIUtils.FUNNEL_INSTRUMENTATION_URL).toBe('https://grr-automate/sdk/v1/event') + expect(APIUtils.BROWSERSTACK_AUTOMATE_API_URL).toBe('https://grr-automate') + expect(APIUtils.BROWSERSTACK_AUTOMATE_API_CLOUD_URL).toBe('https://grr-automate-upload') + expect(APIUtils.BROWSERSTACK_AA_API_URL).toBe('https://grr-aa') + expect(APIUtils.BROWSERSTACK_AA_API_CLOUD_URL).toBe('https://grr-aa-upload') + expect(APIUtils.BROWSERSTACK_PERCY_API_URL).toBe('https://grr-percy') + expect(APIUtils.APP_ALLY_ENDPOINT).toBe('https://grr-app-a11y/automate') + expect(APIUtils.DATA_ENDPOINT).toBe('https://grr-o11y') + expect(APIUtils.UPLOAD_LOGS_ADDRESS).toBe('https://grr-o11y-upload') + expect(APIUtils.EDS_URL).toBe('https://grr-eds') + }) + + // SDK-7138: a degenerate bin-session config used to throw here and abort the whole + // CLI bootstrap, taking every product module down with it. + it.each([ + ['undefined', undefined], + ['an empty object', {}] + ])('keeps the public defaults and does not throw for %s', (_label, apis) => { + expect(() => APIUtils.updateURLSForGRR(apis as never)).not.toThrow() + expect(APIUtils.BROWSERSTACK_AUTOMATE_API_URL).toBe(DEFAULTS.BROWSERSTACK_AUTOMATE_API_URL) + expect(APIUtils.DATA_ENDPOINT).toBe(DEFAULTS.DATA_ENDPOINT) + }) + + it('applies the entries a partial config does carry and leaves the rest default', () => { + APIUtils.updateURLSForGRR({ observability: { api: 'https://grr-o11y' } } as never) + + expect(APIUtils.DATA_ENDPOINT).toBe('https://grr-o11y') + expect(APIUtils.UPLOAD_LOGS_ADDRESS).toBe(DEFAULTS.UPLOAD_LOGS_ADDRESS) + expect(APIUtils.BROWSERSTACK_AUTOMATE_API_URL).toBe(DEFAULTS.BROWSERSTACK_AUTOMATE_API_URL) + }) +}) diff --git a/packages/browserstack-service/tests/cli/modules/uploadAttachmentModule.test.ts b/packages/browserstack-service/tests/cli/modules/uploadAttachmentModule.test.ts new file mode 100644 index 0000000..65990e5 --- /dev/null +++ b/packages/browserstack-service/tests/cli/modules/uploadAttachmentModule.test.ts @@ -0,0 +1,174 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' + +vi.mock('../../../src/cli/frameworks/testFramework.js', () => ({ + default: class MockTestFramework { + static registerObserver = vi.fn() + static getTrackedInstance = vi.fn() + static getState = vi.fn() + } +})) + +vi.mock('../../../src/cli/frameworks/automationFramework.js', () => ({ + default: class MockAutomationFramework { + static registerObserver = vi.fn() + static getTrackedInstance = vi.fn() + static getDriver = vi.fn() + static getState = vi.fn() + } +})) + +const logCreatedEvent = vi.fn().mockResolvedValue({ success: true }) +vi.mock('../../../src/cli/grpcClient.js', () => ({ + GrpcClient: { + getInstance: vi.fn(() => ({ logCreatedEvent })) + } +})) + +import UploadAttachmentModule from '../../../src/cli/modules/uploadAttachmentModule.js' +import TestFramework from '../../../src/cli/frameworks/testFramework.js' +import AutomationFramework from '../../../src/cli/frameworks/automationFramework.js' +import WdioMochaTestFramework from '../../../src/cli/frameworks/wdioMochaTestFramework.js' +import { TestFrameworkConstants } from '../../../src/cli/frameworks/constants/testFrameworkConstants.js' + +const TEST_UUID = 'test-uuid-1' +const HOOK_UUID = 'hook-uuid-1' + +function makeInstance(testState: string) { + const data = new Map([ + [TestFrameworkConstants.KEY_TEST_UUID, TEST_UUID], + [TestFrameworkConstants.KEY_TEST_FRAMEWORK_NAME, 'webdriverio-mocha'], + [TestFrameworkConstants.KEY_TEST_FRAMEWORK_VERSION, '8.0.0'] + ]) + return { + getAllData: () => data, + getCurrentTestState: () => ({ toString: () => `TestFrameworkState.${testState}` }), + getContext: () => ({ + getId: () => 'ctx-1', + getThreadId: () => 1, + getProcessId: () => 2 + }) + } +} + +describe('UploadAttachmentModule', () => { + let attachmentPath: string + let tmpDir: string + let browser: Record + + beforeEach(() => { + vi.clearAllMocks() + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bstack-attachment-test-')) + attachmentPath = path.join(tmpDir, 'media.txt') + fs.writeFileSync(attachmentPath, 'hello') + + browser = {} + vi.mocked(AutomationFramework.getTrackedInstance).mockReturnValue({} as never) + vi.mocked(AutomationFramework.getDriver).mockReturnValue(browser) + vi.mocked(TestFramework.getTrackedInstance).mockReturnValue(makeInstance('TEST') as never) + vi.mocked(TestFramework.getState).mockImplementation((instance, key) => instance.getAllData().get(key)) + }) + + afterEach(() => { + // fs.statSync / lastActiveHook are spied per-test; without this they leak and the + // next test passes for the wrong reason. + vi.restoreAllMocks() + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + async function register() { + const module = new UploadAttachmentModule() + await module.onBeforeExecute() + return module + } + + it('registers uploadAttachment and the uploadMedia alias on the browser', async () => { + await register() + expect(typeof browser.uploadAttachment).toBe('function') + expect(typeof browser.uploadMedia).toBe('function') + expect(browser.uploadMedia).toBe(browser.uploadAttachment) + }) + + it('sends a TestLevel TEST_ATTACHMENT log entry keyed on the test uuid', async () => { + await register() + await (browser.uploadMedia as (p: string) => Promise)(attachmentPath) + + expect(logCreatedEvent).toHaveBeenCalledTimes(1) + const [log] = logCreatedEvent.mock.calls[0][0].logs + expect(log).toMatchObject({ + kind: 'TEST_ATTACHMENT', + level: 'TestLevel', + uuid: TEST_UUID, + fileName: 'media.txt', + fileSize: 5, + filePath: attachmentPath + }) + }) + + it('attributes the attachment to the active hook when inside one', async () => { + vi.spyOn(WdioMochaTestFramework, 'lastActiveHook').mockReturnValue({ + [TestFrameworkConstants.KEY_HOOK_ID]: HOOK_UUID + }) + vi.mocked(TestFramework.getTrackedInstance).mockReturnValue(makeInstance('BEFORE_ALL') as never) + + await register() + await (browser.uploadAttachment as (p: string) => Promise)(attachmentPath) + + const [log] = logCreatedEvent.mock.calls[0][0].logs + expect(log.level).toBe('HookLevel') + expect(log.uuid).toBe(HOOK_UUID) + }) + + it('marks the entry BuildLevel when buildAttachment is set', async () => { + await register() + await (browser.uploadAttachment as (p: string, o?: Record) => Promise)( + attachmentPath, { buildAttachment: true } + ) + + const [log] = logCreatedEvent.mock.calls[0][0].logs + expect(log.level).toBe('BuildLevel') + }) + + it('resolves a relative path against the process cwd', async () => { + const relative = path.relative(process.cwd(), attachmentPath) + await register() + await (browser.uploadAttachment as (p: string) => Promise)(relative) + + const [log] = logCreatedEvent.mock.calls[0][0].logs + expect(log.filePath).toBe(attachmentPath) + }) + + it.each([ + ['an empty path', ''], + ['a missing file', '/definitely/not/here.txt'] + ])('ignores %s without throwing', async (_label, input) => { + await register() + await expect( + (browser.uploadAttachment as (p: string) => Promise)(input) + ).resolves.toBeUndefined() + expect(logCreatedEvent).not.toHaveBeenCalled() + }) + + it('ignores a file above the 100 MB limit', async () => { + vi.spyOn(fs, 'statSync').mockReturnValue({ + isFile: () => true, + size: 101 * 1024 * 1024 + } as never) + + await register() + await (browser.uploadAttachment as (p: string) => Promise)(attachmentPath) + expect(logCreatedEvent).not.toHaveBeenCalled() + }) + + it('does not throw when there is no tracked test to attribute to', async () => { + await register() + vi.mocked(TestFramework.getTrackedInstance).mockReturnValue(undefined as never) + + await expect( + (browser.uploadAttachment as (p: string) => Promise)(attachmentPath) + ).resolves.toBeUndefined() + expect(logCreatedEvent).not.toHaveBeenCalled() + }) +}) From ee0a40aecbbe14b217a7632b18721ca67f0e1956 Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Mon, 17 Aug 2026 21:32:07 +0530 Subject: [PATCH 2/4] fix(browserstack-service): bound the uploadAttachment ack wait (SDK-7138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uploadAttachment runs inside the customer's test body and awaited the binary's LogCreated ack with no bound, so a wedged binary would stall the calling test until the framework's own timeout fired. Race the ack against a 10s budget: the event is already on the wire when the timer wins, so nothing is dropped. Also re-arm the logCreatedEvent mock per test — afterEach's restoreAllMocks drops the implementation, so every test after the first was getting a non-promise back from the ack. --- .../src/cli/modules/uploadAttachmentModule.ts | 23 +++++++++++++++-- .../browserstack-service/src/constants.ts | 5 ++++ .../modules/uploadAttachmentModule.test.ts | 25 +++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/browserstack-service/src/cli/modules/uploadAttachmentModule.ts b/packages/browserstack-service/src/cli/modules/uploadAttachmentModule.ts index 016bf97..28d181d 100644 --- a/packages/browserstack-service/src/cli/modules/uploadAttachmentModule.ts +++ b/packages/browserstack-service/src/cli/modules/uploadAttachmentModule.ts @@ -13,6 +13,7 @@ import { TestFrameworkConstants } from '../frameworks/constants/testFrameworkCon 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. */ @@ -152,7 +153,7 @@ export default class UploadAttachmentModule extends BaseModule { const trackedContext = instance.getContext() const platformIndex = process.env.WDIO_WORKER_ID ? parseInt(process.env.WDIO_WORKER_ID.split('-')[0]) : 0 - await GrpcClient.getInstance().logCreatedEvent({ + const ack = GrpcClient.getInstance().logCreatedEvent({ platformIndex, executionContext: { hash: trackedContext.getId(), @@ -174,6 +175,24 @@ export default class UploadAttachmentModule extends BaseModule { }] }) - this.logger.debug(`uploadAttachment: sent ${target.level} attachment ${filePath} (${fileSize} bytes) for uuid=${target.uuid}`) + // This runs inside the customer's test body, so only the ack is raced — the event + // is already written by the time the timer can fire. Mapping the rejection into the + // race keeps a late gRPC error from surfacing as an unhandled rejection. + let timer: NodeJS.Timeout | undefined + const outcome = await Promise.race([ + ack.then(() => 'ok', (error) => `failed: ${error}`), + new Promise((resolve) => { + timer = setTimeout(() => resolve('unacked'), UPLOAD_ATTACHMENT_ACK_TIMEOUT_MS) + }) + ]) + 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; not waiting further`) + } else { + this.logger.warn(`uploadAttachment: could not record ${filePath} — ${outcome}`) + } } } diff --git a/packages/browserstack-service/src/constants.ts b/packages/browserstack-service/src/constants.ts index c64bfa8..9680b8f 100644 --- a/packages/browserstack-service/src/constants.ts +++ b/packages/browserstack-service/src/constants.ts @@ -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' diff --git a/packages/browserstack-service/tests/cli/modules/uploadAttachmentModule.test.ts b/packages/browserstack-service/tests/cli/modules/uploadAttachmentModule.test.ts index 65990e5..341aad5 100644 --- a/packages/browserstack-service/tests/cli/modules/uploadAttachmentModule.test.ts +++ b/packages/browserstack-service/tests/cli/modules/uploadAttachmentModule.test.ts @@ -32,6 +32,7 @@ import TestFramework from '../../../src/cli/frameworks/testFramework.js' import AutomationFramework from '../../../src/cli/frameworks/automationFramework.js' import WdioMochaTestFramework from '../../../src/cli/frameworks/wdioMochaTestFramework.js' import { TestFrameworkConstants } from '../../../src/cli/frameworks/constants/testFrameworkConstants.js' +import { UPLOAD_ATTACHMENT_ACK_TIMEOUT_MS } from '../../../src/constants.js' const TEST_UUID = 'test-uuid-1' const HOOK_UUID = 'hook-uuid-1' @@ -60,6 +61,9 @@ describe('UploadAttachmentModule', () => { beforeEach(() => { vi.clearAllMocks() + // afterEach's restoreAllMocks drops the implementation too, so re-arm it here — + // otherwise every test after the first gets a non-promise back from the ack. + logCreatedEvent.mockResolvedValue({ success: true }) tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bstack-attachment-test-')) attachmentPath = path.join(tmpDir, 'media.txt') fs.writeFileSync(attachmentPath, 'hello') @@ -75,6 +79,7 @@ describe('UploadAttachmentModule', () => { // fs.statSync / lastActiveHook are spied per-test; without this they leak and the // next test passes for the wrong reason. vi.restoreAllMocks() + vi.useRealTimers() fs.rmSync(tmpDir, { recursive: true, force: true }) }) @@ -162,6 +167,26 @@ describe('UploadAttachmentModule', () => { expect(logCreatedEvent).not.toHaveBeenCalled() }) + it('returns to the caller when the binary never acks the event', async () => { + vi.useFakeTimers() + logCreatedEvent.mockReturnValueOnce(new Promise(() => {})) + + await register() + const call = (browser.uploadAttachment as (p: string) => Promise)(attachmentPath) + await vi.advanceTimersByTimeAsync(UPLOAD_ATTACHMENT_ACK_TIMEOUT_MS) + + await expect(call).resolves.toBeUndefined() + }) + + it('does not throw when the ack rejects', async () => { + logCreatedEvent.mockRejectedValueOnce(new Error('gRPC channel closed')) + + await register() + await expect( + (browser.uploadAttachment as (p: string) => Promise)(attachmentPath) + ).resolves.toBeUndefined() + }) + it('does not throw when there is no tracked test to attribute to', async () => { await register() vi.mocked(TestFramework.getTrackedInstance).mockReturnValue(undefined as never) From 37f672df1d1130586513129f69f48e3524da9cc5 Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Mon, 17 Aug 2026 22:07:59 +0530 Subject: [PATCH 3/4] fix(browserstack-service): dispatch the attachment event off the caller's stack (SDK-7138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uploadAttachment is called from the customer's test body and the next statement is usually a browser command that the accessibility module wraps with a pre-command scan. Awaiting the binary round-trip on that stack stalled the following executeAsync scan under load: chrome sessions issued the scan and then no further WebDriver request, until the framework timeout fired and the hub reaped the session (reproduced 4/4 in BStackAutomation at logLevel warn; absent 2/2 with the uploadMedia calls removed). The ack carries nothing the caller can act on — the binary streams the file from filePath while draining its own upload queue — so the event is written and its ack observed off-stack, still bounded so a wedged binary cannot leak a pending timer. --- .../src/cli/modules/uploadAttachmentModule.ts | 46 ++++++++++++------- .../modules/uploadAttachmentModule.test.ts | 12 +++-- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/packages/browserstack-service/src/cli/modules/uploadAttachmentModule.ts b/packages/browserstack-service/src/cli/modules/uploadAttachmentModule.ts index 28d181d..d8a12ec 100644 --- a/packages/browserstack-service/src/cli/modules/uploadAttachmentModule.ts +++ b/packages/browserstack-service/src/cli/modules/uploadAttachmentModule.ts @@ -38,6 +38,8 @@ export default class UploadAttachmentModule extends BaseModule { name: string static MODULE_NAME = 'UploadAttachmentModule' + private pendingSends = new Set>() + constructor() { super() this.name = UploadAttachmentModule.MODULE_NAME @@ -114,7 +116,7 @@ export default class UploadAttachmentModule extends BaseModule { return } - await this.sendAttachmentEvent(instance, resolvedPath, stats.size, target) + this.sendAttachmentEvent(instance, resolvedPath, stats.size, target) } /** @@ -143,7 +145,18 @@ export default class UploadAttachmentModule extends BaseModule { : { level: 'TestLevel', uuid: testUuid, testFrameworkState } } - private async sendAttachmentEvent( + /** + * 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, @@ -175,24 +188,25 @@ export default class UploadAttachmentModule extends BaseModule { }] }) - // This runs inside the customer's test body, so only the ack is raced — the event - // is already written by the time the timer can fire. Mapping the rejection into the - // race keeps a late gRPC error from surfacing as an unhandled rejection. let timer: NodeJS.Timeout | undefined - const outcome = await Promise.race([ + const observed = Promise.race([ ack.then(() => 'ok', (error) => `failed: ${error}`), new Promise((resolve) => { timer = setTimeout(() => resolve('unacked'), UPLOAD_ATTACHMENT_ACK_TIMEOUT_MS) }) - ]) - 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; not waiting further`) - } else { - this.logger.warn(`uploadAttachment: could not record ${filePath} — ${outcome}`) - } + ]).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)) } } diff --git a/packages/browserstack-service/tests/cli/modules/uploadAttachmentModule.test.ts b/packages/browserstack-service/tests/cli/modules/uploadAttachmentModule.test.ts index 341aad5..608c931 100644 --- a/packages/browserstack-service/tests/cli/modules/uploadAttachmentModule.test.ts +++ b/packages/browserstack-service/tests/cli/modules/uploadAttachmentModule.test.ts @@ -167,15 +167,19 @@ describe('UploadAttachmentModule', () => { expect(logCreatedEvent).not.toHaveBeenCalled() }) - it('returns to the caller when the binary never acks the event', async () => { + it('returns to the caller without waiting for the binary to ack', async () => { vi.useFakeTimers() logCreatedEvent.mockReturnValueOnce(new Promise(() => {})) await register() - const call = (browser.uploadAttachment as (p: string) => Promise)(attachmentPath) - await vi.advanceTimersByTimeAsync(UPLOAD_ATTACHMENT_ACK_TIMEOUT_MS) + // No timer advance: the caller must not be blocked on the never-settling ack. + await expect( + (browser.uploadAttachment as (p: string) => Promise)(attachmentPath) + ).resolves.toBeUndefined() + expect(logCreatedEvent).toHaveBeenCalledTimes(1) - await expect(call).resolves.toBeUndefined() + // Drain the ack budget so the pending timer does not leak into the next test. + await vi.advanceTimersByTimeAsync(UPLOAD_ATTACHMENT_ACK_TIMEOUT_MS) }) it('does not throw when the ack rejects', async () => { From 817d036bf938fec2a9088882510d0b5236270ccb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:11:20 +0000 Subject: [PATCH 4/4] chore(changeset): auto-generate from PR template (minor) --- .changeset/pr-193.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/pr-193.md diff --git a/.changeset/pr-193.md b/.changeset/pr-193.md new file mode 100644 index 0000000..d43106a --- /dev/null +++ b/.changeset/pr-193.md @@ -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.