diff --git a/.changeset/pr-193.md b/.changeset/pr-193.md new file mode 100644 index 00000000..7ee6bf7c --- /dev/null +++ b/.changeset/pr-193.md @@ -0,0 +1,6 @@ +--- +"@wdio/browserstack-service": patch +--- + +- 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 1ae813fc..a4aff1a8 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 9eabcae7..a21f1a15 100644 --- a/packages/browserstack-service/src/cli/apiUtils.ts +++ b/packages/browserstack-service/src/cli/apiUtils.ts @@ -1,3 +1,5 @@ +import { BStackLogger } from './cliLogger.js' + export default class APIUtils { static FUNNEL_INSTRUMENTATION_URL = 'https://api.browserstack.com/sdk/v1/event' static BROWSERSTACK_AUTOMATE_API_URL = 'https://api.browserstack.com' @@ -10,16 +12,54 @@ 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) { + BStackLogger.debug('updateURLSForGRR: no apis in the bin-session config; keeping default endpoints') + return + } + const missing = [ + ['automate.api', apis.automate?.api], ['automate.upload', apis.automate?.upload], + ['appAutomate.api', apis.appAutomate?.api], ['appAutomate.upload', apis.appAutomate?.upload], + ['percy.api', apis.percy?.api], ['appAccessibility.api', apis.appAccessibility?.api], + ['observability.api', apis.observability?.api], ['observability.upload', apis.observability?.upload], + ['edsInstrumentation.api', apis.edsInstrumentation?.api] + ].filter(([, value]) => !value).map(([name]) => name) + if (missing.length) { + BStackLogger.debug(`updateURLSForGRR: keeping default endpoints for ${missing.join(', ')}`) + } + 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 594fb639..e78f6717 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/index.ts b/packages/browserstack-service/src/cli/index.ts index 444ec6d7..91c813c4 100644 --- a/packages/browserstack-service/src/cli/index.ts +++ b/packages/browserstack-service/src/cli/index.ts @@ -21,6 +21,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' @@ -184,6 +185,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 @@ -529,6 +534,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 00000000..6e3fbce8 --- /dev/null +++ b/packages/browserstack-service/src/cli/modules/uploadAttachmentModule.ts @@ -0,0 +1,288 @@ +/// +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 +const UPLOADED_ATTACHMENTS_PREFIX = 'UploadedAttachments-wdio-' + +/** + * 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 binary streams the file from `filePath` only when it drains its upload queue, so the + * file is snapshotted first: a caller that overwrites or deletes its file right after the call + * would otherwise attach the wrong content or nothing. Snapshots go to + * `UploadedAttachments-wdio-///` under the writable dir: + * per worker, so parallel workers never pick the same name, and per wdio run, so the Python + * and Java SDKs (`UploadedAttachments-/`) and other runs on the host never read or delete + * them. The launcher removes this run's folder in onComplete. `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>() + + 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 + } + + const snapshotPath = UploadAttachmentModule.snapshot(resolvedPath, target.level) + if (!snapshotPath) { + this.logger.warn(`uploadAttachment: could not snapshot ${resolvedPath}; ignoring call`) + return + } + + this.sendAttachmentEvent(instance, snapshotPath, stats.size, target, UploadAttachmentModule.platformIndex()) + } + + private static platformIndex() { + return process.env.WDIO_WORKER_ID ? parseInt(process.env.WDIO_WORKER_ID.split('-')[0]) : 0 + } + + private static runAttachmentsDir(): string | null { + const root = CLIUtils.getWritableDir() + const runId = process.env.BROWSERSTACK_CLI_BIN_SESSION_ID + return root && runId ? path.join(root, `${UPLOADED_ATTACHMENTS_PREFIX}${runId}`) : null + } + + private static snapshot(sourcePath: string, level: AttachmentLevel): string | null { + try { + const runDir = UploadAttachmentModule.runAttachmentsDir() + if (!runDir) { + return null + } + const targetDir = path.join(runDir, String(process.pid), level) + fs.mkdirSync(targetDir, { recursive: true }) + const ext = path.extname(sourcePath) + const base = path.basename(sourcePath, ext) + for (let counter = 0; ; counter++) { + const targetPath = path.join(targetDir, counter ? `${base}${counter}${ext}` : `${base}${ext}`) + try { + fs.copyFileSync(sourcePath, targetPath, fs.constants.COPYFILE_EXCL) + return targetPath + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw error + } + } + } + } catch (error) { + BStackLogger.debug(`uploadAttachment: snapshot of ${sourcePath} failed: ${error}`) + return null + } + } + + /** + * Keeps uploadAttachment/uploadMedia callable when Test Reporting is inactive (no testhub, + * classic path, CLI down), as the other SDKs do. onBeforeExecute replaces it when the + * binary is up, and an already-registered implementation is left alone. + */ + static installNoopFallback(browser?: WebdriverIO.Browser) { + if (!browser || typeof browser.uploadAttachment === 'function') { + return + } + const noopUploadAttachment = async (filePath: string) => { + BStackLogger.debug(`uploadAttachment: Test Reporting is not active; ${filePath} was not uploaded`) + } + browser.uploadAttachment = noopUploadAttachment + browser.uploadMedia = noopUploadAttachment + } + + static cleanupUploadedAttachments() { + try { + const runDir = UploadAttachmentModule.runAttachmentsDir() + if (runDir) { + fs.rmSync(runDir, { recursive: true, force: true }) + } + } catch (error) { + BStackLogger.debug(`uploadAttachment: cleanup of attachment snapshots failed: ${error}`) + } + } + + /** + * 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 }, + platformIndex: number + ) { + const testData = instance.getAllData() + const trackedContext = instance.getContext() + + 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((resolve) => { + timer = setTimeout(() => resolve('unacked'), UPLOAD_ATTACHMENT_ACK_TIMEOUT_MS) + timer.unref() + }) + ]).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/src/constants.ts b/packages/browserstack-service/src/constants.ts index ae000252..fedba7b6 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/src/index.ts b/packages/browserstack-service/src/index.ts index 8d9d50f9..47210657 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/launcher.ts b/packages/browserstack-service/src/launcher.ts index 07538037..732fc927 100644 --- a/packages/browserstack-service/src/launcher.ts +++ b/packages/browserstack-service/src/launcher.ts @@ -64,6 +64,7 @@ import { sendFinish, sendStart } from './instrumentation/funnelInstrumentation.j import AiHandler from './ai-handler.js' import PerformanceTester from './instrumentation/performance/performance-tester.js' import * as PERFORMANCE_SDK_EVENTS from './instrumentation/performance/constants.js' +import UploadAttachmentModule from './cli/modules/uploadAttachmentModule.js' import { BrowserstackCLI } from './cli/index.js' import { CLIUtils } from './cli/cliUtils.js' import accessibilityScripts from './scripts/accessibility-scripts.js' @@ -661,6 +662,7 @@ export default class BrowserstackLauncherService implements Services.ServiceInst BStackLogger.error(`Error while stopping CLI ${err}`) PerformanceTester.end(PERFORMANCE_SDK_EVENTS.FRAMEWORK_EVENTS.STOP, false, format(err)) } + UploadAttachmentModule.cleanupUploadedAttachments() if (process.env[BROWSERSTACK_OBSERVABILITY] && process.env[BROWSERSTACK_TESTHUB_UUID]) { console.log(`\nVisit https://automation.browserstack.com/builds/${process.env[BROWSERSTACK_TESTHUB_UUID]} to view build report, insights, and many more debugging information all at one place!\n`) } diff --git a/packages/browserstack-service/src/service.ts b/packages/browserstack-service/src/service.ts index e262f5ca..8854e15c 100644 --- a/packages/browserstack-service/src/service.ts +++ b/packages/browserstack-service/src/service.ts @@ -23,6 +23,7 @@ import AccessibilityHandler from './accessibility-handler.js' import CustomTagsHandler from './custom-tags-handler.js' import { classifyMochaHookTitle, setCurrentMochaHookWindow } from './customTags.js' import type TestHubModule from './cli/modules/testHubModule.js' +import UploadAttachmentModule from './cli/modules/uploadAttachmentModule.js' import { BStackLogger } from './bstackLogger.js' import PercyHandler from './Percy/Percy-Handler.js' import Listener from './testOps/listener.js' @@ -230,6 +231,8 @@ export default class BrowserstackService implements Services.ServiceInstance { this._browser = browser ? browser : globalThis.browser PerformanceTester.browser = this._browser + UploadAttachmentModule.installNoopFallback(this._browser) + // Healing Support: if (!isBrowserstackSession(this._browser)) { try { diff --git a/packages/browserstack-service/src/types.ts b/packages/browserstack-service/src/types.ts index 588e5ee3..e0fd88b4 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 00000000..ac386ffb --- /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 00000000..3a3232de --- /dev/null +++ b/packages/browserstack-service/tests/cli/modules/uploadAttachmentModule.test.ts @@ -0,0 +1,331 @@ +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' +import { UPLOAD_ATTACHMENT_ACK_TIMEOUT_MS } from '../../../src/constants.js' +import { BStackLogger } from '../../../src/cli/cliLogger.js' +import { CLIUtils } from '../../../src/cli/cliUtils.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 writableDir: string + const BIN_SESSION_ID = 'bin-session-1' + const runDirOf = (runId = BIN_SESSION_ID) => path.join(writableDir, `UploadedAttachments-wdio-${runId}`) + const snapshotOf = (level: string, name = 'media.txt', pid = process.pid) => path.join(runDirOf(), String(pid), level, name) + let browser: Record + let previousBinSessionId: string | undefined + + 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') + writableDir = path.join(tmpDir, 'writable') + vi.spyOn(CLIUtils, 'getWritableDir').mockReturnValue(writableDir) + previousBinSessionId = process.env.BROWSERSTACK_CLI_BIN_SESSION_ID + process.env.BROWSERSTACK_CLI_BIN_SESSION_ID = BIN_SESSION_ID + + 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() + vi.useRealTimers() + if (previousBinSessionId === undefined) { + delete process.env.BROWSERSTACK_CLI_BIN_SESSION_ID + } else { + process.env.BROWSERSTACK_CLI_BIN_SESSION_ID = previousBinSessionId + } + 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: snapshotOf('TestLevel') + }) + }) + + 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(snapshotOf('TestLevel')) + expect(fs.readFileSync(log.filePath, 'utf8')).toBe('hello') + }) + + it('uploads a snapshot, so overwriting the source afterwards does not change the attachment', async () => { + await register() + await (browser.uploadAttachment as (p: string) => Promise)(attachmentPath) + fs.writeFileSync(attachmentPath, 'changed') + + const [log] = logCreatedEvent.mock.calls[0][0].logs + expect(log.filePath).not.toBe(attachmentPath) + expect(fs.readFileSync(log.filePath, 'utf8')).toBe('hello') + }) + + it('keeps an earlier snapshot of the same file name', async () => { + await register() + await (browser.uploadAttachment as (p: string) => Promise)(attachmentPath) + fs.writeFileSync(attachmentPath, 'second') + await (browser.uploadAttachment as (p: string) => Promise)(attachmentPath) + + expect(logCreatedEvent.mock.calls[1][0].logs[0].filePath).toBe(snapshotOf('TestLevel', 'media1.txt')) + expect(fs.readFileSync(snapshotOf('TestLevel'), 'utf8')).toBe('hello') + expect(fs.readFileSync(snapshotOf('TestLevel', 'media1.txt'), 'utf8')).toBe('second') + }) + + it('snapshots into its own worker folder, apart from a parallel worker with the same file name', async () => { + const otherWorker = snapshotOf('TestLevel', 'media.txt', process.pid + 1) + fs.mkdirSync(path.dirname(otherWorker), { recursive: true }) + fs.writeFileSync(otherWorker, 'other worker') + + await register() + await (browser.uploadAttachment as (p: string) => Promise)(attachmentPath) + + expect(logCreatedEvent.mock.calls[0][0].logs[0].filePath).toBe(snapshotOf('TestLevel')) + expect(fs.readFileSync(snapshotOf('TestLevel'), 'utf8')).toBe('hello') + expect(fs.readFileSync(otherWorker, 'utf8')).toBe('other worker') + }) + + it('takes the next name when another writer claims it between the pick and the copy', async () => { + const realCopy = fs.copyFileSync + let raced = false + vi.spyOn(fs, 'copyFileSync').mockImplementation((src, dest, mode) => { + if (!raced) { + raced = true + fs.writeFileSync(dest, 'racer') + } + return realCopy(src, dest, mode) + }) + + await register() + await (browser.uploadAttachment as (p: string) => Promise)(attachmentPath) + + expect(logCreatedEvent.mock.calls[0][0].logs[0].filePath).toBe(snapshotOf('TestLevel', 'media1.txt')) + expect(fs.readFileSync(snapshotOf('TestLevel'), 'utf8')).toBe('racer') + expect(fs.readFileSync(snapshotOf('TestLevel', 'media1.txt'), 'utf8')).toBe('hello') + }) + + it('does not send without a CLI bin session to key the snapshot folder on', async () => { + delete process.env.BROWSERSTACK_CLI_BIN_SESSION_ID + await register() + await (browser.uploadAttachment as (p: string) => Promise)(attachmentPath) + + expect(logCreatedEvent).not.toHaveBeenCalled() + expect(fs.existsSync(writableDir)).toBe(false) + }) + + it('installNoopFallback makes uploadAttachment/uploadMedia callable without Test Reporting', async () => { + const bare: Record = {} + UploadAttachmentModule.installNoopFallback(bare as never) + + expect(typeof bare.uploadAttachment).toBe('function') + expect(bare.uploadMedia).toBe(bare.uploadAttachment) + await expect((bare.uploadMedia as (p: string) => Promise)(attachmentPath)).resolves.toBeUndefined() + expect(logCreatedEvent).not.toHaveBeenCalled() + }) + + it('installNoopFallback leaves an implementation the module already registered', async () => { + await register() + const registered = browser.uploadAttachment + UploadAttachmentModule.installNoopFallback(browser as never) + expect(browser.uploadAttachment).toBe(registered) + }) + + it('onBeforeExecute replaces the no-op fallback with the real implementation', async () => { + UploadAttachmentModule.installNoopFallback(browser as never) + const fallback = browser.uploadAttachment + await register() + expect(browser.uploadAttachment).not.toBe(fallback) + await (browser.uploadAttachment as (p: string) => Promise)(attachmentPath) + expect(logCreatedEvent).toHaveBeenCalledTimes(1) + }) + + it('cleanupUploadedAttachments removes only this run\'s folder', () => { + fs.mkdirSync(path.join(runDirOf(), String(process.pid), 'TestLevel'), { recursive: true }) + fs.mkdirSync(path.join(runDirOf('other-run'), '123', 'TestLevel'), { recursive: true }) + fs.mkdirSync(path.join(writableDir, 'UploadedAttachments-0', 'HookLevel'), { recursive: true }) + fs.mkdirSync(path.join(writableDir, 'cli'), { recursive: true }) + + UploadAttachmentModule.cleanupUploadedAttachments() + + expect(fs.readdirSync(writableDir).sort()).toEqual(['UploadedAttachments-0', 'UploadedAttachments-wdio-other-run', 'cli']) + }) + + 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 path that exists but is not a regular file', async () => { + vi.spyOn(fs, 'statSync').mockReturnValue({ + isFile: () => false, + size: 0 + } as never) + + await register() + await (browser.uploadAttachment as (p: string) => Promise)(attachmentPath) + 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('returns to the caller without waiting for the binary to ack', async () => { + vi.useFakeTimers() + logCreatedEvent.mockReturnValueOnce(new Promise(() => {})) + + await register() + // 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) + + const warn = vi.spyOn(BStackLogger, 'warn') + await vi.advanceTimersByTimeAsync(UPLOAD_ATTACHMENT_ACK_TIMEOUT_MS) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(`did not ack within ${UPLOAD_ATTACHMENT_ACK_TIMEOUT_MS}ms`)) + }) + + 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) + + await expect( + (browser.uploadAttachment as (p: string) => Promise)(attachmentPath) + ).resolves.toBeUndefined() + expect(logCreatedEvent).not.toHaveBeenCalled() + }) +})