From f1c4f4a13893b7dc2d1c3f47d17abe8cd507dbd8 Mon Sep 17 00:00:00 2001 From: Hitesh Parmar <10hiteshparmar@gmail.com> Date: Fri, 30 Oct 2026 18:19:42 +0530 Subject: [PATCH 1/4] PTAAA-881: remove extension-based self-healing and the ai-sdk-node dependency v8 port of #315, which landed the same removal on main (wdio v9). The security fix has to ship in every supported major, and v8 carries the identical vulnerable path pinned to @browserstack/ai-sdk-node 1.5.17. The client-side healing path downloaded the AI browser extension via BrowserstackHealing.init() and loaded it into the local browser, either through goog:chromeOptions.extensions or browser.installAddOn(). That extension ships a window.postMessage handler that forwards a page-controlled endpoint and bearer to a privileged background fetch, giving any visited page an SSRF primitive against the machine running the test. The path is deprecated and only ever ran off BrowserStack infra, so it is removed rather than hardened. Removes AiHandler and its two entry points, the healing branch of the funnel instrumentation, the capability helpers and constants left without callers, the self-heal performance events, and the @browserstack/ai-sdk-node dependency. Self-healing on BrowserStack infra is unaffected: it is driven by the browserstack.selfHeal capability, resolved hub-side, and never went through this code. BrowserstackConfig.selfHeal is deliberately kept, and an off-infra run that sets it now gets a one-time warning instead of silently losing healing. Typecheck 12 -> 9 errors (3 removed, 0 added; the rest pre-existing). Tests 738 -> 728 passing with 0 failures either side; the 10-test drop is the deleted healing suites. Co-Authored-By: Claude Opus 5 (1M context) --- package-lock.json | 174 +--- packages/browserstack-service/package.json | 1 - .../browserstack-service/src/ai-handler.ts | 234 ------ .../browserstack-service/src/constants.ts | 12 - .../instrumentation/funnelInstrumentation.ts | 80 -- .../instrumentation/performance/constants.ts | 10 - packages/browserstack-service/src/launcher.ts | 27 +- packages/browserstack-service/src/service.ts | 12 - packages/browserstack-service/src/util.ts | 41 - .../tests/ai-handler.test.ts | 750 ------------------ .../funnelInstrumentation.test.ts | 282 ------- 11 files changed, 16 insertions(+), 1607 deletions(-) delete mode 100644 packages/browserstack-service/src/ai-handler.ts delete mode 100644 packages/browserstack-service/tests/ai-handler.test.ts diff --git a/package-lock.json b/package-lock.json index 383349fb..e271b082 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,30 +51,6 @@ "node": ">=6.9.0" } }, - "node_modules/@browserstack/ai-sdk-node": { - "version": "1.5.17", - "resolved": "https://registry.npmjs.org/@browserstack/ai-sdk-node/-/ai-sdk-node-1.5.17.tgz", - "integrity": "sha512-odjnFulpBeF64UGHA+bIxkIcALYvEPznTl4U0hRT1AFfn4FqT+4wQdPBYnSnlc2XWTedv4zCDvbp4AFrtKXHEw==", - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "axios": "^1.7.4", - "uuid": "9.0.1" - } - }, - "node_modules/@browserstack/ai-sdk-node/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@bufbuild/buf": { "version": "1.70.0", "resolved": "https://registry.npmjs.org/@bufbuild/buf/-/buf-1.70.0.tgz", @@ -2487,24 +2463,6 @@ "node": ">=0.12.0" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", - "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, "node_modules/b4a": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", @@ -2856,6 +2814,7 @@ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", + "peer": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" @@ -3104,18 +3063,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { "version": "9.5.0", "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", @@ -3478,15 +3425,6 @@ "node": ">= 14" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/detect-indent": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", @@ -3577,6 +3515,7 @@ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", + "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -3750,6 +3689,7 @@ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" } @@ -3759,6 +3699,7 @@ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" } @@ -3768,6 +3709,7 @@ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", + "peer": true, "dependencies": { "es-errors": "^1.3.0" }, @@ -3775,21 +3717,6 @@ "node": ">= 0.4" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -4281,26 +4208,6 @@ "node": ">=8" } }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -4317,22 +4224,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/form-data-encoder": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", @@ -4516,6 +4407,7 @@ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -4613,6 +4505,7 @@ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", + "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", @@ -4649,6 +4542,7 @@ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", + "peer": true, "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -4779,6 +4673,7 @@ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" }, @@ -4862,21 +4757,7 @@ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, + "peer": true, "engines": { "node": ">= 0.4" }, @@ -4889,6 +4770,7 @@ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", + "peer": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -5948,6 +5830,7 @@ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" } @@ -5982,27 +5865,6 @@ "node": ">=8.6" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mimic-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", @@ -6897,15 +6759,6 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -8923,10 +8776,9 @@ }, "packages/browserstack-service": { "name": "@wdio/browserstack-service", - "version": "8.51.1", + "version": "8.55.0", "license": "MIT", "dependencies": { - "@browserstack/ai-sdk-node": "1.5.17", "@bufbuild/protobuf": "^2.5.2", "@grpc/grpc-js": "1.13.3", "@percy/appium-app": "^2.0.1", diff --git a/packages/browserstack-service/package.json b/packages/browserstack-service/package.json index decd2f91..d61d615a 100644 --- a/packages/browserstack-service/package.json +++ b/packages/browserstack-service/package.json @@ -46,7 +46,6 @@ "prepack": "npm run build" }, "dependencies": { - "@browserstack/ai-sdk-node": "1.5.17", "@bufbuild/protobuf": "^2.5.2", "@grpc/grpc-js": "1.13.3", "@percy/appium-app": "^2.0.1", diff --git a/packages/browserstack-service/src/ai-handler.ts b/packages/browserstack-service/src/ai-handler.ts deleted file mode 100644 index 372a9fc3..00000000 --- a/packages/browserstack-service/src/ai-handler.ts +++ /dev/null @@ -1,234 +0,0 @@ -import path from 'node:path' -import fs from 'node:fs' -import url from 'node:url' -import aiSDK from '@browserstack/ai-sdk-node' -import { BStackLogger } from './bstackLogger.js' -import { TCG_URL, TCG_INFO, SUPPORTED_BROWSERS_FOR_AI, BSTACK_SERVICE_VERSION, BSTACK_TCG_AUTH_RESULT } from './constants.js' -import { handleHealingInstrumentation } from './instrumentation/funnelInstrumentation.js' - -import type { Capabilities } from '@wdio/types' -import type BrowserStackConfig from './config.js' -import type { Options } from '@wdio/types' -import type { BrowserstackHealing } from '@browserstack/ai-sdk-node' -import { getBrowserStackUserAndKey, isBrowserstackInfra } from './util.js' -import type { BrowserstackOptions } from './types.js' -import PerformanceTester from './instrumentation/performance/performance-tester.js' -import * as PERFORMANCE_SDK_EVENTS from './instrumentation/performance/constants.js' - -class AiHandler { - authResult: BrowserstackHealing.InitSuccessResponse | BrowserstackHealing.InitErrorResponse - wdioBstackVersion: string - constructor() { - this.authResult = JSON.parse(process.env[BSTACK_TCG_AUTH_RESULT] || '{}') - this.wdioBstackVersion = BSTACK_SERVICE_VERSION - } - - async authenticateUser(user: string, key: string) { - return await aiSDK.BrowserstackHealing.init(key, user, TCG_URL, this.wdioBstackVersion) - } - - updateCaps( - authResult: BrowserstackHealing.InitSuccessResponse | BrowserstackHealing.InitErrorResponse, - options: BrowserstackOptions, - caps: Array | Capabilities.RemoteCapability - ) { - const installExtCondition = authResult.isAuthenticated === true && (authResult.defaultLogDataEnabled === true || options.selfHeal === true) - if (installExtCondition){ - if (Array.isArray(caps)) { - const newCaps= aiSDK.BrowserstackHealing.initializeCapabilities(caps[0]) - caps[0] = newCaps - } else if (typeof caps === 'object') { - caps = aiSDK.BrowserstackHealing.initializeCapabilities(caps) - } - } else if (options.selfHeal === true) { - const healingWarnMessage = (authResult as aiSDK.BrowserstackHealing.InitErrorResponse).message - BStackLogger.warn(`Healing Auth failed. Disabling healing for this session. Reason: ${healingWarnMessage}`) - } - - return caps - } - - async setToken(sessionId: string, sessionToken: string){ - await aiSDK.BrowserstackHealing.setToken(sessionId, sessionToken, TCG_URL) - } - - async installFirefoxExtension(browser: WebdriverIO.Browser){ - const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) - const extensionPath = path.resolve(__dirname, aiSDK.BrowserstackHealing.getFirefoxAddonPath()) - const extFile = fs.readFileSync(extensionPath) - await browser.installAddOn(extFile.toString('base64'), true) - } - - async handleHealing(orginalFunc: (arg0: string, arg1: string) => any, using: string, value: string, browser: WebdriverIO.Browser, options: BrowserstackOptions){ - const sessionId = browser.sessionId - - // a utility function to escape single and double quotes - const escapeString = (str: string) => str.replace(/'/g, "\\'").replace(/"/g, '\\"') - - const tcgDetails = escapeString(JSON.stringify({ - region: TCG_INFO.tcgRegion, - tcgUrls: { - [TCG_INFO.tcgRegion]: { - endpoint: TCG_INFO.tcgUrl.split('://')[1] - } - } - })) - - const locatorType = escapeString(using) - const locatorValue = escapeString(value) - - this.authResult = this.authResult as BrowserstackHealing.InitSuccessResponse - - try { - const result = await orginalFunc(using, value) - if (!result.error) { - const script = await aiSDK.BrowserstackHealing.logData(locatorType, locatorValue, undefined, undefined, this.authResult.groupId, sessionId, undefined, tcgDetails) - if (script) { - await browser.execute(script) - } - return result - } - if (options.selfHeal === true && this.authResult.isHealingEnabled) { - BStackLogger.info('findElement failed, trying to heal') - PerformanceTester.start(PERFORMANCE_SDK_EVENTS.AI_EVENTS.SELF_HEAL_STEP) - const script = await aiSDK.BrowserstackHealing.healFailure(locatorType, locatorValue, undefined, undefined, this.authResult.userId, this.authResult.groupId, sessionId, undefined, undefined, this.authResult.isGroupAIEnabled, tcgDetails) - if (script) { - await browser.execute(script) - PerformanceTester.start(PERFORMANCE_SDK_EVENTS.AI_EVENTS.SELF_HEAL_GET_RESULT) - const tcgData = await aiSDK.BrowserstackHealing.pollResult(TCG_URL, sessionId, this.authResult.sessionToken) - PerformanceTester.end(PERFORMANCE_SDK_EVENTS.AI_EVENTS.SELF_HEAL_GET_RESULT) - if (tcgData && tcgData.selector && tcgData.value){ - const healedResult = await orginalFunc(tcgData.selector, tcgData.value) - BStackLogger.info('Healing worked, element found: ' + tcgData.selector + ': ' + tcgData.value) - PerformanceTester.end(PERFORMANCE_SDK_EVENTS.AI_EVENTS.SELF_HEAL_STEP) - return healedResult.error ? result : healedResult - } - PerformanceTester.end(PERFORMANCE_SDK_EVENTS.AI_EVENTS.SELF_HEAL_STEP, false, 'No healed result found') - } else { - PerformanceTester.end(PERFORMANCE_SDK_EVENTS.AI_EVENTS.SELF_HEAL_STEP, false, 'No healing script generated') - } - } - } catch (err) { - PerformanceTester.end(PERFORMANCE_SDK_EVENTS.AI_EVENTS.SELF_HEAL_STEP, false, String(err)) - if (options.selfHeal === true) { - BStackLogger.warn('Something went wrong while healing. Disabling healing for this command') - } else { - BStackLogger.warn('Error in findElement: ' + err + 'using: ' + using + 'value: ' + value) - } - } - return await orginalFunc(using, value) - } - - addMultiRemoteCaps ( - authResult: BrowserstackHealing.InitSuccessResponse | BrowserstackHealing.InitErrorResponse, - config: Options.Testrunner, - browserStackConfig: BrowserStackConfig, - options: BrowserstackOptions, - caps: any, - browser: string - ) { - if ( caps[browser].capabilities && - !(isBrowserstackInfra(caps[browser])) && - SUPPORTED_BROWSERS_FOR_AI.includes(caps[browser]?.capabilities?.browserName?.toLowerCase()) - ) { - const innerConfig = getBrowserStackUserAndKey(config, options) - if (innerConfig?.user && innerConfig.key) { - handleHealingInstrumentation(authResult, browserStackConfig, options.selfHeal) - caps[browser].capabilities = this.updateCaps(authResult, options, caps[browser].capabilities) - } - } - } - - handleMultiRemoteSetup( - authResult: BrowserstackHealing.InitSuccessResponse | BrowserstackHealing.InitErrorResponse, - config: Options.Testrunner, - browserStackConfig: BrowserStackConfig, - options: BrowserstackOptions, - caps: any, - ) { - const browserNames = Object.keys(caps) - for (let i = 0; i < browserNames.length; i++) { - const browser = browserNames[i] - this.addMultiRemoteCaps(authResult, config, browserStackConfig, options, caps, browser) - } - } - - async setup( - config: Options.Testrunner, - browserStackConfig: BrowserStackConfig, - options: BrowserstackOptions, - caps: any, - isMultiremote: boolean - ) { - try { - const innerConfig = getBrowserStackUserAndKey(config, options) - if (innerConfig?.user && innerConfig.key) { - const authResult = await this.authenticateUser(innerConfig.user, innerConfig.key) - process.env[BSTACK_TCG_AUTH_RESULT] = JSON.stringify(authResult) - if (!isMultiremote && SUPPORTED_BROWSERS_FOR_AI.includes(caps?.browserName?.toLowerCase())) { - - handleHealingInstrumentation(authResult, browserStackConfig, options.selfHeal) - this.updateCaps(authResult, options, caps) - - } else if (isMultiremote) { - this.handleMultiRemoteSetup(authResult, config, browserStackConfig, options, caps) - } - } - - } catch (err) { - if (options.selfHeal === true) { - BStackLogger.warn(`Error while initiliazing Browserstack healing Extension ${err}`) - } - } - - return caps - } - - async handleSelfHeal(options: BrowserstackOptions, browser: WebdriverIO.Browser) { - - if (SUPPORTED_BROWSERS_FOR_AI.includes((browser.capabilities as Capabilities.BrowserStackCapabilities)?.browserName?.toLowerCase() as string)) { - const authInfo = this.authResult as BrowserstackHealing.InitSuccessResponse - - if (Object.keys(authInfo).length === 0 && options.selfHeal === true) { - BStackLogger.debug('TCG Auth result is empty') - return - } - - const { isAuthenticated, sessionToken, defaultLogDataEnabled } = authInfo - - if (isAuthenticated && (defaultLogDataEnabled === true || options.selfHeal === true)) { - await this.setToken(browser.sessionId, sessionToken) - - if ((browser.capabilities as Capabilities.BrowserStackCapabilities).browserName === 'firefox') { - await this.installFirefoxExtension(browser) - } - - browser.overwriteCommand('findElement' as any, async (orginalFunc: (arg0: string, arg1: string) => any, using: string, value: string) => { - return await this.handleHealing(orginalFunc, using, value, browser, options) - }) - } - } - } - - async selfHeal(options: BrowserstackOptions, caps: Capabilities.RemoteCapability, browser: WebdriverIO.Browser) { - try { - - const multiRemoteBrowsers = Object.keys(caps).filter(e => Object.keys(browser).includes(e)) - if (multiRemoteBrowsers.length > 0) { - for (let i = 0; i < multiRemoteBrowsers.length; i++) { - const remoteBrowser = (browser as any)[multiRemoteBrowsers[i]] - await this.handleSelfHeal(options, remoteBrowser) - } - } else { - await this.handleSelfHeal(options, browser) - } - - } catch (err) { - if (options.selfHeal === true) { - BStackLogger.warn(`Error while setting up self-healing: ${err}. Disabling healing for this session.`) - } - } - } -} - -export default new AiHandler() diff --git a/packages/browserstack-service/src/constants.ts b/packages/browserstack-service/src/constants.ts index 21fc7331..091660f7 100644 --- a/packages/browserstack-service/src/constants.ts +++ b/packages/browserstack-service/src/constants.ts @@ -82,23 +82,11 @@ export const FUNNEL_INSTRUMENTATION_URL = 'https://api.browserstack.com/sdk/v1/e export const EDS_URL = 'https://eds.browserstack.com' -export const SUPPORTED_BROWSERS_FOR_AI = ['chrome', 'microsoftedge', 'firefox'] - -export const TCG_URL = 'https://tcg.browserstack.com' - -export const TCG_INFO = { - tcgRegion: 'use', - tcgUrl: TCG_URL, -} - // Env variables - Define all the env variable constants over here // To store the JWT token returned the session launch export const BROWSERSTACK_TESTHUB_JWT = 'BROWSERSTACK_TESTHUB_JWT' -// To store tcg auth result for selfHealing feature: -export const BSTACK_TCG_AUTH_RESULT = 'BSTACK_TCG_AUTH_RESULT' - // To store the setting of whether to send screenshots or not export const TESTOPS_SCREENSHOT_ENV = 'BS_TESTOPS_ALLOW_SCREENSHOTS' diff --git a/packages/browserstack-service/src/instrumentation/funnelInstrumentation.ts b/packages/browserstack-service/src/instrumentation/funnelInstrumentation.ts index 8bb90d3c..39631c2f 100644 --- a/packages/browserstack-service/src/instrumentation/funnelInstrumentation.ts +++ b/packages/browserstack-service/src/instrumentation/funnelInstrumentation.ts @@ -10,7 +10,6 @@ import type BrowserStackConfig from '../config.js' import { BSTACK_A11Y_POLLING_TIMEOUT, BSTACK_SERVICE_VERSION, WDIO_NAMING_PREFIX } from '../constants.js' import { getDataFromWorkers, removeWorkersDataDir } from '../data-store.js' import { getProductMap } from '../testHub/utils.js' -import type { BrowserstackHealing } from '@browserstack/ai-sdk-node' import APIUtils from '../cli/apiUtils.js' import PerformanceTester from './performance/performance-tester.js' import { EVENTS } from './performance/constants.js' @@ -196,82 +195,3 @@ const sendEvent = { tcgtInitSuccessful: (config: BrowserStackConfig) => fireFunnelTestEvent('SDKTestTcgtInitSuccessful', config), initFailed: (config: BrowserStackConfig) => fireFunnelTestEvent('SDKTestInitFailedResponse', config), } - -function handleUpgradeRequired(isSelfHealEnabled: boolean | undefined) { - if (isSelfHealEnabled) { - BStackLogger.warn('Please upgrade Browserstack Service to the latest version to use the self-healing feature.') - } -} - -function handleAuthenticationFailure(status: number, config: BrowserStackConfig, isSelfHealEnabled: boolean | undefined) { - if (status >= 500) { - if (isSelfHealEnabled) { - BStackLogger.warn('Something went wrong. Disabling healing for this session. Please try again later.') - } - sendEvent.tcgDown(config) - } else { - if (isSelfHealEnabled) { - BStackLogger.warn('Authentication Failed. Disabling Healing for this session.') - } - sendEvent.tcgAuthFailure(config) - } -} - -function handleAuthenticationSuccess( - isHealingEnabledForUser: boolean, - userId: string, - config: BrowserStackConfig, - isSelfHealEnabled: boolean | undefined -) { - if (!isHealingEnabledForUser && isSelfHealEnabled) { - BStackLogger.warn('Healing is not enabled for your group, please contact the admin') - } else if (userId && isHealingEnabledForUser) { - sendEvent.tcgtInitSuccessful(config) - } -} - -function handleInitializationFailure(status: number, config: BrowserStackConfig, isSelfHealEnabled: boolean | undefined) { - if (status >= 400) { - sendEvent.initFailed(config) - } else if (!status && isSelfHealEnabled) { - sendEvent.invalidTcgAuth(config) - } - - if (isSelfHealEnabled) { - BStackLogger.warn('Authentication Failed. Healing will be disabled for this session.') - } -} - -export function handleHealingInstrumentation( - authResult: BrowserstackHealing.InitErrorResponse | BrowserstackHealing.InitSuccessResponse, - config: BrowserStackConfig, - isSelfHealEnabled: boolean | undefined, -) { - try { - - const { message, isAuthenticated, status, userId, groupId, isHealingEnabled: isHealingEnabledForUser } = authResult as any - - if (message === 'Upgrade required') { - handleUpgradeRequired(isSelfHealEnabled) - return - } - - if (!isAuthenticated) { - handleAuthenticationFailure(status, config, isSelfHealEnabled) - return - } - - if (isAuthenticated && userId && groupId) { - handleAuthenticationSuccess(isHealingEnabledForUser, userId, config, isSelfHealEnabled) - return - } - - if (status >= 400 || !status) { - handleInitializationFailure(status, config, isSelfHealEnabled) - return - } - - } catch (err) { - BStackLogger.debug('Error in handling healing instrumentation: ' + err) - } -} diff --git a/packages/browserstack-service/src/instrumentation/performance/constants.ts b/packages/browserstack-service/src/instrumentation/performance/constants.ts index 2ebc1e35..4be7be1f 100644 --- a/packages/browserstack-service/src/instrumentation/performance/constants.ts +++ b/packages/browserstack-service/src/instrumentation/performance/constants.ts @@ -33,8 +33,6 @@ export const EVENTS = { SDK_AUTOMATION_FRAMEWORK_STOP: 'sdk:automationFrameworkStop', SDK_ACCESSIBILITY_CONFIG: 'sdk:accessibilityConfig', SDK_OBSERVABILITY_CONFIG: 'sdk:observabilityConfig', - SDK_AI_SELF_HEAL_STEP: 'sdk:aiSelfHealStep', - SDK_AI_SELF_HEAL_GET_RESULT: 'sdk:aiSelfHealGetResult', SDK_TEST_FRAMEWORK_EVENT: 'sdk:testFrameworkEvent', SDK_TEST_SESSION_EVENT: 'sdk:testSessionEvent', SDK_CLI_LOG_CREATED_EVENT: 'sdk:cli:logCreatedEvent', @@ -151,14 +149,6 @@ export const CONFIG_EVENTS = { OBSERVABILITY: EVENTS.SDK_OBSERVABILITY_CONFIG } -/** - * AI self-healing events - */ -export const AI_EVENTS = { - SELF_HEAL_STEP: EVENTS.SDK_AI_SELF_HEAL_STEP, - SELF_HEAL_GET_RESULT: EVENTS.SDK_AI_SELF_HEAL_GET_RESULT -} - /** * Event dispatcher events for test framework and session tracking */ diff --git a/packages/browserstack-service/src/launcher.ts b/packages/browserstack-service/src/launcher.ts index 9ea27483..a51cf23b 100644 --- a/packages/browserstack-service/src/launcher.ts +++ b/packages/browserstack-service/src/launcher.ts @@ -39,7 +39,6 @@ import { getBrowserStackKey, uploadLogs, ObjectsAreEqual, - isValidCapsForHealing, getBooleanValueFromString, validateCapsWithNonBstackA11y, mergeChromeOptions, @@ -58,7 +57,6 @@ import type Percy from './Percy/Percy.js' import { sendStart, sendFinish } from './instrumentation/funnelInstrumentation.js' import BrowserStackConfig from './config.js' import { setupExitHandlers } from './exitHandler.js' -import AiHandler from './ai-handler.js' import TestOpsConfig from './testOps/testOpsConfig.js' import PerformanceTester from './instrumentation/performance/performance-tester.js' import * as PERFORMANCE_SDK_EVENTS from './instrumentation/performance/constants.js' @@ -370,28 +368,9 @@ export default class BrowserstackLauncherService implements Services.ServiceInst PerformanceTester.end(PERFORMANCE_SDK_EVENTS.FRAMEWORK_EVENTS.INIT) - // Setting up healing for those sessions where we don't add the service version capability as it indicates that the session is not being run on BrowserStack - if (!shouldAddServiceVersion(this._config, this._options.testObservability, capabilities as Capabilities.BrowserStackCapabilities)) { - try { - if ((capabilities as Capabilities.BrowserStackCapabilities).browserName) { - capabilities = await AiHandler.setup(this._config, this.browserStackConfig, this._options, capabilities, false) - } else if ( Array.isArray(capabilities)){ - - for (let i = 0; i < capabilities.length; i++) { - if ((capabilities[i] as Capabilities.BrowserStackCapabilities).browserName) { - capabilities[i] = await AiHandler.setup(this._config, this.browserStackConfig, this._options, capabilities[i], false) - } - } - - } else if (isValidCapsForHealing(capabilities as any)) { - // setting up healing in case capabilities.xyz.capabilities.browserName where xyz can be anything: - capabilities = await AiHandler.setup(this._config, this.browserStackConfig, this._options, capabilities, true) - } - } catch (err) { - if (this._options.selfHeal === true) { - BStackLogger.warn(`Error while setting up Browserstack healing Extension ${err}. Disabling healing for this session.`) - } - } + // The client-side healing extension this option drove off-infra has been removed; on BrowserStack, healing comes from the browserstack.selfHeal capability instead. + if (this._options.selfHeal === true && !shouldAddServiceVersion(this._config, this._options.testObservability, capabilities as Capabilities.BrowserStackCapabilities)) { + BStackLogger.warn('selfHeal has no effect for sessions not running on BrowserStack infrastructure. Self-healing on BrowserStack is driven by the browserstack.selfHeal capability.') } /** diff --git a/packages/browserstack-service/src/service.ts b/packages/browserstack-service/src/service.ts index 1a93ffd1..475b5632 100644 --- a/packages/browserstack-service/src/service.ts +++ b/packages/browserstack-service/src/service.ts @@ -29,7 +29,6 @@ import Listener from './testOps/listener.js' import { saveWorkerData } from './data-store.js' import UsageStats from './testOps/usageStats.js' import { shouldProcessEventForTesthub } from './testHub/utils.js' -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 { EVENTS } from './instrumentation/performance/constants.js' @@ -195,17 +194,6 @@ export default class BrowserstackService implements Services.ServiceInstance { this._browser = browser ? browser : globalThis.browser PerformanceTester.browser = this._browser - // Healing Support: - if (!shouldAddServiceVersion(this._config, this._options.testObservability, caps as any)) { - try { - await AiHandler.selfHeal(this._options, caps, this._browser) - } catch (err) { - if (this._options.selfHeal === true) { - BStackLogger.warn(`Error while setting up self-healing: ${err}. Disabling healing for this session.`) - } - } - } - // Ensure capabilities are not null in case of multiremote if (this._isAppAutomate()) { diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index eb9bd607..4baaaa51 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -1238,30 +1238,6 @@ export function isBrowserstackInfra(config: BrowserstackConfig & Options.Testrun return true } -export function getBrowserStackUserAndKey(config: Options.Testrunner, options: Options.Testrunner) { - - // Fallback 1: Env variables - // Fallback 2: Service variables in wdio.conf.js (that are received inside options object) - const envOrServiceVariables = { - user: getBrowserStackUser(options), - key: getBrowserStackKey(options) - } - if (isBStackSession(envOrServiceVariables as any)) { - return envOrServiceVariables - } - - // Fallback 3: Service variables in testObservabilityOptions object - // Fallback 4: Service variables in the top level config object - const o11yVariables = { - user: getObservabilityUser(options, config), - key: getObservabilityKey(options, config) - } - if (isBStackSession(o11yVariables as any)) { - return o11yVariables - } - -} - export function shouldAddServiceVersion(config: Options.Testrunner, testObservability?: boolean, caps?: Capabilities.BrowserStackCapabilities): boolean { if ((config.services && config.services.toString().includes('chromedriver') && testObservability !== false) || !isBrowserstackInfra(config, caps)) { return false @@ -1730,23 +1706,6 @@ export function getObservabilityProduct(options: (BrowserstackConfig & Options.T : (isTurboScale(options) ? 'turboscale' : 'automate') } -export const hasBrowserName = (cap: Options.Testrunner): boolean => { - if (!cap || !cap.capabilities) { - return false - } - const browserStackCapabilities = cap.capabilities as Capabilities.BrowserStackCapabilities - return browserStackCapabilities.browserName !== undefined -} - -export const isValidCapsForHealing = (caps: { [key: string]: Options.Testrunner }): boolean => { - - // Get all capability values - const capValues = Object.values(caps) - - // Check if there are any capabilities and if at least one has a browser name - return capValues.length > 0 && capValues.some(hasBrowserName) -} - type PollingResult = { data: any; headers: Record; diff --git a/packages/browserstack-service/tests/ai-handler.test.ts b/packages/browserstack-service/tests/ai-handler.test.ts deleted file mode 100644 index d65f4bf5..00000000 --- a/packages/browserstack-service/tests/ai-handler.test.ts +++ /dev/null @@ -1,750 +0,0 @@ -/// -import path from 'node:path' - -import { describe, expect, it, vi, beforeEach } from 'vitest' -import aiSDK from '@browserstack/ai-sdk-node' - -import AiHandler from '../src/ai-handler.js' -import * as bstackLogger from '../src/bstackLogger.js' -import * as funnelInstrumentation from '../src/instrumentation/funnelInstrumentation.js' -import type { Capabilities } from '@wdio/types' -import { TCG_URL } from '../src/constants.js' - -// Mock only the external dependency -vi.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger'))) -vi.mock('@browserstack/ai-sdk-node') -vi.useFakeTimers().setSystemTime(new Date('2020-01-01')) -vi.mock('uuid', () => ({ v4: () => '123456789' })) -vi.mock('node:fs', () => ({ - default: { - readFileSync: vi.fn().mockReturnValue(Buffer.from('extension-content')) - } -})) - -const bstackLoggerSpy = vi.spyOn(bstackLogger.BStackLogger, 'logToFile') -bstackLoggerSpy.mockImplementation(() => {}) - -describe('AiHandler', () => { - let config: any - let browser: any - - beforeEach(() => { - vi.resetModules() - config = { - user: 'foobaruser', - key: '12345678901234567890', - selfHeal: true // Default to true - } - - browser = { - sessionId: 'test-session-id', - execute: vi.fn(), - installAddOn: vi.fn(), - overwriteCommand: vi.fn(), - capabilities: { - browserName: 'chrome' - } - } - }) - - describe('authenticateUser', () => { - it('should authenticate user', async () => { - const authResponse = { - message: 'Authentication successful', - isAuthenticated: true, - defaultLogDataEnabled: true, - isHealingEnabled: true, - sessionToken: 'test-token', - groupId: 123123, - userId: 342423, - isGroupAIEnabled: true, - } - - const initSpy = vi.spyOn(aiSDK.BrowserstackHealing, 'init') - .mockReturnValue(Promise.resolve(authResponse) as any) - - const result = await AiHandler.authenticateUser(config.user, config.key) - - expect(initSpy).toHaveBeenCalledTimes(1) - expect(initSpy).toHaveBeenCalledWith( - config.key, - config.user, - 'https://tcg.browserstack.com', - expect.any(String) - ) - expect(result).toEqual(authResponse) - }) - }) - - describe('updateCaps', () => { - it('should add the AI extension to capabilities', async () => { - const authResult = { - isAuthenticated: true, - defaultLogDataEnabled: true, - } as any - - const caps = { - 'goog:chromeOptions': {} - } - const mockExtension = 'mock-extension' - - vi.spyOn(aiSDK.BrowserstackHealing, 'initializeCapabilities') - .mockReturnValue({ ...caps, 'goog:chromeOptions': { extensions: [mockExtension] } }) - - const updatedCaps = await AiHandler.updateCaps(authResult, config, caps) as any - - expect(updatedCaps['goog:chromeOptions'].extensions).toEqual([mockExtension]) - }) - - it('should not update capabilities if authentication failed', async () => { - const authResult = { - isAuthenticated: false, - message: 'Authentication failed' - } as any - - const caps = { browserName: 'chrome' } - const updatedCaps = await AiHandler.updateCaps(authResult, config, caps) - - expect(updatedCaps).toEqual(caps) - }) - - it('should not update capabilities if selfHeal is false', async () => { - const authResult = { - isAuthenticated: false, - message: 'Authentication failed' - } as any - - config.selfHeal = false - - const caps = { browserName: 'chrome' } - const updatedCaps = await AiHandler.updateCaps(authResult, config, caps) - - expect(updatedCaps).toEqual(caps) - }) - - it('should handle array of capabilities', async () => { - const authResult = { - isAuthenticated: true, - defaultLogDataEnabled: true, - } as any - - const caps = [{ browserName: 'chrome' }] - const mockExtension = 'mock-extension' - - vi.spyOn(aiSDK.BrowserstackHealing, 'initializeCapabilities') - .mockReturnValue({ browserName: 'chrome', 'goog:chromeOptions': { extensions: [mockExtension] } }) - - const updatedCaps = await AiHandler.updateCaps(authResult, config, caps) as any - - expect(updatedCaps[0]['goog:chromeOptions'].extensions).toEqual([mockExtension]) - }) - - it('should handle mixed array and object capabilities', async () => { - const authResult = { - isAuthenticated: true, - defaultLogDataEnabled: true, - isHealingEnabled: true, - } as any - - const capsArray = [{ - browserName: 'chrome', - 'goog:chromeOptions': {} - }] - - const capsObject = { - browserName: 'firefox', - 'moz:firefoxOptions': {} - } - - const mockChromeExtension = 'mock-chrome-extension' - const mockFirefoxExtension = 'mock-firefox-extension' - - const initializeCapabilitiesSpy = vi.spyOn(aiSDK.BrowserstackHealing, 'initializeCapabilities') - .mockReturnValueOnce({ - browserName: 'chrome', - 'goog:chromeOptions': { extensions: [mockChromeExtension] } - }) - .mockReturnValueOnce({ - browserName: 'firefox', - 'moz:firefoxOptions': { extensions: [mockFirefoxExtension] } - }) - - const updatedCapsArray = await AiHandler.updateCaps(authResult, config, capsArray) as Array - const updatedCapsObject = await AiHandler.updateCaps(authResult, config, capsObject) - - expect(initializeCapabilitiesSpy).toHaveBeenCalledTimes(2) - - expect(initializeCapabilitiesSpy).toHaveBeenNthCalledWith(2, capsObject) - - expect(updatedCapsArray).toBeInstanceOf(Array) - expect(updatedCapsArray[0]).toEqual({ - browserName: 'chrome', - 'goog:chromeOptions': { extensions: [mockChromeExtension] } - }) - - expect(updatedCapsObject).toEqual({ - browserName: 'firefox', - 'moz:firefoxOptions': { extensions: [mockFirefoxExtension] } - }) - }) - - it('should update caps if selfHeal is true but defaultLogDataEnabled is false', async () => { - const authResult = { - isAuthenticated: true, - defaultLogDataEnabled: false, - } as any - - const caps = { browserName: 'chrome' } - const updatedCaps = await AiHandler.updateCaps(authResult, config, caps) - - expect(updatedCaps).not.toEqual(caps) - }) - }) - - describe('handleHealing', () => { - it('should attempt healing if findElement fails', async () => { - const originalFunc = vi.fn().mockReturnValueOnce({ error: 'no such element' }) - .mockReturnValueOnce({}) - - const healFailureResponse = { script: 'healing-script' } - const pollResultResponse = { selector: 'css selector', value: '.healed-element' } - - AiHandler['authResult'] = { - isAuthenticated: true, - isHealingEnabled: true, - sessionToken: 'test-session-token', - groupId: 123123, - userId: 342423, - isGroupAIEnabled: true - } as any - - vi.spyOn(aiSDK.BrowserstackHealing, 'healFailure') - .mockResolvedValue(healFailureResponse.script as string) - vi.spyOn(aiSDK.BrowserstackHealing, 'pollResult') - .mockResolvedValue(pollResultResponse as any) - vi.spyOn(aiSDK.BrowserstackHealing, 'logData') - .mockResolvedValue('logging-script' as string) - - const result = await AiHandler.handleHealing(originalFunc, 'id', 'some-id', browser, config) - - expect(aiSDK.BrowserstackHealing.healFailure).toHaveBeenCalledTimes(1) - expect(aiSDK.BrowserstackHealing.pollResult).toHaveBeenCalledTimes(1) - expect(originalFunc).toHaveBeenCalledTimes(2) - expect(browser.execute).toHaveBeenCalledWith('healing-script') - expect(result).toEqual({}) - }) - - it('should attempt logging if findElement successfully runs', async () => { - const originalFunc = vi.fn().mockReturnValueOnce({ element: 'mock-element' }) - .mockReturnValueOnce({}) - - AiHandler['authResult'] = { - isAuthenticated: true, - isHealingEnabled: true, - sessionToken: 'test-session-token', - groupId: 123123, - userId: 342423, - isGroupAIEnabled: true - } as any - - vi.spyOn(aiSDK.BrowserstackHealing, 'logData') - .mockResolvedValue('logging-script' as any) - - const result = await AiHandler.handleHealing(originalFunc, 'id', 'some-id', browser, config) - - expect(originalFunc).toHaveBeenCalledTimes(1) - expect(browser.execute).toHaveBeenCalledWith('logging-script') - expect(result).toEqual({ 'element': 'mock-element' }) - }) - - it('should call originalFunc if there is an error in try block', async () => { - const originalFunc = vi.fn().mockImplementationOnce(() => { - throw new Error('Some error occurred.') - }) - - AiHandler['authResult'] = { - isAuthenticated: true, - isHealingEnabled: true, - sessionToken: 'test-session-token', - groupId: 123123, - userId: 342423, - isGroupAIEnabled: true - } as any - - const result = await AiHandler.handleHealing(originalFunc, 'id', 'some-id', browser, config) - - expect(originalFunc).toHaveBeenCalledTimes(2) - expect(result).toEqual(undefined) - }) - - it('should return original error if selfHeal is false', async () => { - const originalFunc = vi.fn().mockImplementationOnce(() => { - throw new Error('Some error occurred.') - }) - - config.selfHeal = false - - const warnSpy = vi.spyOn(bstackLogger.BStackLogger, 'warn') - - const result = await AiHandler.handleHealing(originalFunc, 'id', 'some-id', browser, config) - - expect(originalFunc).toHaveBeenCalledTimes(2) - expect(warnSpy).toHaveBeenCalledTimes(1) - expect(result).toEqual(undefined) - }) - - it('should return original result error if healed element is also missing', async () => { - - const originalFunc = vi.fn().mockReturnValueOnce({ error: 'no such element' }) - .mockReturnValueOnce({ error: 'no such element' }) - - const healFailureResponse = { script: 'healing-script' } - const pollResultResponse = { selector: 'css selector', value: '.healed-element' } - - AiHandler['authResult'] = { - isAuthenticated: true, - isHealingEnabled: true, - sessionToken: 'test-session-token', - groupId: 123123, - userId: 342423, - isGroupAIEnabled: true - } as any - - vi.spyOn(aiSDK.BrowserstackHealing, 'healFailure') - .mockResolvedValue(healFailureResponse.script as string) - vi.spyOn(aiSDK.BrowserstackHealing, 'pollResult') - .mockResolvedValue(pollResultResponse as any) - vi.spyOn(aiSDK.BrowserstackHealing, 'logData') - .mockResolvedValue('logging-script' as string) - - const result = await AiHandler.handleHealing(originalFunc, 'id', 'some-id', browser, config) - - expect(aiSDK.BrowserstackHealing.healFailure).toHaveBeenCalledTimes(1) - expect(aiSDK.BrowserstackHealing.pollResult).toHaveBeenCalledTimes(1) - expect(originalFunc).toHaveBeenCalledTimes(2) - expect(browser.execute).toHaveBeenCalledWith('healing-script') - expect(result).toEqual({ error: 'no such element' }) - }) - }) - - describe('setup', () => { - it('should authenticate user and update capabilities for supported browser', async () => { - const caps = { browserName: 'chrome' } - const mockAuthResult = { - isAuthenticated: true, - sessionToken: 'mock-session-token', - defaultLogDataEnabled: true, - isHealingEnabled: true, - groupId: 123123, - userId: 342423, - isGroupAIEnabled: true, - } - - const authenticateUserSpy = vi.spyOn(AiHandler, 'authenticateUser') - .mockResolvedValue(mockAuthResult as any) - const handleHealingInstrumentationSpy = vi.spyOn(funnelInstrumentation, 'handleHealingInstrumentation') - const updateCapsSpy = vi.spyOn(AiHandler, 'updateCaps') - .mockResolvedValue({ ...caps, 'goog:chromeOptions': { extensions: ['mock-extension'] } }) - - const mockExtension = 'mock-extension' - vi.spyOn(aiSDK.BrowserstackHealing, 'initializeCapabilities') - .mockReturnValue({ ...caps, 'goog:chromeOptions': { extensions: [mockExtension] } }) - - const emptyObj = {} as any - await AiHandler.setup(config, emptyObj, emptyObj, caps, false) - - expect(authenticateUserSpy).toHaveBeenCalledTimes(1) - expect(handleHealingInstrumentationSpy).toHaveBeenCalledTimes(1) - expect(updateCapsSpy).toHaveBeenCalledTimes(1) - }) - - it('should skip setup if accessKey is not present', async () => { - config.key = '' - const caps = { browserName: 'chrome' } - - const authenticateUserSpy = vi.spyOn(AiHandler, 'authenticateUser') - const handleHealingInstrumentationSpy = vi.spyOn(funnelInstrumentation, 'handleHealingInstrumentation') - const updateCapsSpy = vi.spyOn(AiHandler, 'updateCaps') - - const emptyObj = {} as any - vi.stubEnv('BROWSERSTACK_ACCESS_KEY', '') - const updatedCaps = await AiHandler.setup(config, emptyObj, emptyObj, caps, false) - expect(authenticateUserSpy).not.toHaveBeenCalled() - expect(handleHealingInstrumentationSpy).not.toHaveBeenCalled() - expect(updateCapsSpy).not.toHaveBeenCalled() - expect(updatedCaps).toEqual(caps) // Expect caps to remain unchanged - }) - - it('should skip setup if userName is not present', async () => { - config.user = '' - const caps = { browserName: 'chrome' } - - const authenticateUserSpy = vi.spyOn(AiHandler, 'authenticateUser') - const handleHealingInstrumentationSpy = vi.spyOn(funnelInstrumentation, 'handleHealingInstrumentation') - const updateCapsSpy = vi.spyOn(AiHandler, 'updateCaps') - - const emptyObj = {} as any - vi.stubEnv('BROWSERSTACK_USERNAME', '') - const updatedCaps = await AiHandler.setup(config, emptyObj, emptyObj, caps, false) - expect(authenticateUserSpy).not.toHaveBeenCalled() - expect(handleHealingInstrumentationSpy).not.toHaveBeenCalled() - expect(updateCapsSpy).not.toHaveBeenCalled() - expect(updatedCaps).toEqual(caps) // Expect caps to remain unchanged - }) - - it('should handle errors in setup', async () => { - const caps = { browserName: 'chrome' } - const authenticateUserSpy = vi.spyOn(AiHandler, 'authenticateUser') - .mockRejectedValue(new Error('Authentication failed')) - - const warnSpy = vi.spyOn(bstackLogger.BStackLogger, 'warn') - - const emptyObj = {} as any - const options = { selfHeal: true } as any - const updatedCaps = await AiHandler.setup(config, emptyObj, options, caps, false) - - expect(authenticateUserSpy).toHaveBeenCalledTimes(1) - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Error while initiliazing Browserstack healing Extension')) - expect(updatedCaps).toEqual(caps) - }) - }) - - describe('selfHeal', () => { - it('should set token, install extension for Firefox', async () => { - const caps = { browserName: 'firefox' } as Capabilities.RemoteCapability - AiHandler['authResult'] = { - isAuthenticated: true, - sessionToken: 'mock-session-token', - defaultLogDataEnabled: true, - isHealingEnabled: true - } as any - - const setTokenSpy = vi.spyOn(AiHandler, 'setToken') - const installFirefoxExtensionSpy = vi.spyOn(AiHandler, 'installFirefoxExtension') - - browser.capabilities = caps - await AiHandler.selfHeal(config, caps, browser) - - expect(setTokenSpy).toHaveBeenCalledTimes(1) - expect(setTokenSpy).toHaveBeenCalledWith(browser.sessionId, 'mock-session-token') - expect(installFirefoxExtensionSpy).toHaveBeenCalledTimes(1) - expect(installFirefoxExtensionSpy).toHaveBeenCalledWith(browser) - }) - - it('should set token for Chrome', async () => { - const caps = { browserName: 'chrome' } as Capabilities.RemoteCapability - AiHandler['authResult'] = { - isAuthenticated: true, - sessionToken: 'mock-session-token', - defaultLogDataEnabled: true, - isHealingEnabled: true - } as any - - const setTokenSpy = vi.spyOn(AiHandler, 'setToken') - - browser.capabilities = caps - await AiHandler.selfHeal(config, caps, browser) - - expect(setTokenSpy).toHaveBeenCalledTimes(1) - expect(setTokenSpy).toHaveBeenCalledWith(browser.sessionId, 'mock-session-token') - }) - - it('should skip self-healing if authResult is empty', async () => { - const caps = { browserName: 'chrome' } as Capabilities.RemoteCapability - AiHandler['authResult'] = {} as any - - const setTokenSpy = vi.spyOn(AiHandler, 'setToken') - - await AiHandler.selfHeal(config, caps, browser) - - expect(setTokenSpy).not.toHaveBeenCalled() - }) - - it('should call overwriteCommand for Chrome', async () => { - const caps = { browserName: 'chrome' } as Capabilities.RemoteCapability - AiHandler['authResult'] = { - isAuthenticated: true, - sessionToken: 'mock-session-token', - defaultLogDataEnabled: true, - isHealingEnabled: true - } as any - - const setTokenSpy = vi.spyOn(AiHandler, 'setToken') - const overwriteCommandSpy = vi.spyOn(browser, 'overwriteCommand') - - vi.spyOn(aiSDK.BrowserstackHealing, 'logData') - .mockResolvedValue('logging-script') - - browser.capabilities = caps - await AiHandler.selfHeal(config, caps, browser) - - expect(setTokenSpy).toHaveBeenCalledTimes(1) - expect(setTokenSpy).toHaveBeenCalledWith(browser.sessionId, 'mock-session-token') - expect(overwriteCommandSpy).toHaveBeenCalledTimes(1) - expect(overwriteCommandSpy).toHaveBeenCalledWith('findElement', expect.any(Function)) - }) - - it('should call overwriteCommand for Edge', async () => { - const caps = { browserName: 'microsoftedge' } as Capabilities.RemoteCapability - AiHandler['authResult'] = { - isAuthenticated: true, - sessionToken: 'mock-session-token', - defaultLogDataEnabled: true, - isHealingEnabled: true - } as any - - const setTokenSpy = vi.spyOn(AiHandler, 'setToken') - const overwriteCommandSpy = vi.spyOn(browser, 'overwriteCommand') - - vi.spyOn(aiSDK.BrowserstackHealing, 'logData') - .mockResolvedValue('logging-script') - - browser.capabilities = caps - await AiHandler.selfHeal(config, caps, browser) - - expect(setTokenSpy).toHaveBeenCalledTimes(1) - expect(setTokenSpy).toHaveBeenCalledWith(browser.sessionId, 'mock-session-token') - expect(overwriteCommandSpy).toHaveBeenCalledTimes(1) - expect(overwriteCommandSpy).toHaveBeenCalledWith('findElement', expect.any(Function)) - }) - - it('should skip selfHeal for unsupported browser', async () => { - const caps = { browserName: 'safari' } as Capabilities.RemoteCapability - - const setTokenSpy = vi.spyOn(AiHandler, 'setToken') - const installFirefoxExtensionSpy = vi.spyOn(AiHandler, 'installFirefoxExtension') - const overwriteCommandSpy = vi.spyOn(browser, 'overwriteCommand') - - browser.capabilities = caps - await AiHandler.selfHeal(config, caps, browser) - - expect(setTokenSpy).not.toHaveBeenCalled() - expect(installFirefoxExtensionSpy).not.toHaveBeenCalled() - expect(overwriteCommandSpy).not.toHaveBeenCalled() - }) - - it('should handle error in selfHeal function', async () => { - const caps = { browserName: 'chrome' } as Capabilities.RemoteCapability - AiHandler['authResult'] = { - isAuthenticated: true, - sessionToken: 'mock-session-token', - defaultLogDataEnabled: true, - isHealingEnabled: true - } as any - - const setTokenSpy = vi.spyOn(AiHandler, 'setToken').mockImplementationOnce(() => { - throw new Error('Some error occurred in setToken') - }) - - const warnSpy = vi.spyOn(bstackLogger.BStackLogger, 'warn') - - config.selfHeal = true - await AiHandler.selfHeal(config, caps, browser) - - expect(setTokenSpy).toHaveBeenCalledTimes(1) - expect(warnSpy).toHaveBeenCalledWith('Error while setting up self-healing: Error: Some error occurred in setToken. Disabling healing for this session.') - }) - - it('should not set token if isAuthenticated is false', async () => { - const caps = { browserName: 'chrome' } as Capabilities.RemoteCapability - AiHandler['authResult'] = { - isAuthenticated: false, - sessionToken: 'mock-session-token', - } as any - - const setTokenSpy = vi.spyOn(AiHandler, 'setToken') - - browser.capabilities = caps - await AiHandler.selfHeal(config, caps, browser) - - expect(setTokenSpy).not.toHaveBeenCalled() - }) - - it('should not overwrite findElement command if defaultLogDataEnabled and selfHeal are false', async () => { - const caps = { browserName: 'chrome' } as Capabilities.RemoteCapability - config.selfHeal = false - AiHandler['authResult'] = { - isAuthenticated: true, - sessionToken: 'mock-session-token', - defaultLogDataEnabled: false, - } as any - - const overwriteCommandSpy = vi.spyOn(browser, 'overwriteCommand') - - browser.capabilities = caps - await AiHandler.selfHeal(config, caps, browser) - - expect(overwriteCommandSpy).not.toHaveBeenCalled() - }) - }) - - describe('setToken', () => { - it('should call setToken with correct parameters', async () => { - const setTokenSpy = vi.spyOn(aiSDK.BrowserstackHealing, 'setToken') - .mockResolvedValue(undefined) - - await AiHandler.setToken('test-session-id', 'test-token') - - expect(setTokenSpy).toHaveBeenCalledWith('test-session-id', 'test-token', TCG_URL) - }) - }) - - describe('handle multi remote browser session', () => { - let config: any - let browserMock: any - let caps: any - const mockAuthResult = { - isAuthenticated: true, - sessionToken: 'mock-session-token', - defaultLogDataEnabled: true, - isHealingEnabled: true, - groupId: 123123, - userId: 342423, - isGroupAIEnabled: true, - } - beforeEach(() => { - config = { - capabilities: { - myChromeBrowser: { - hostname: 'localhost', - port: 4444, - protocol: 'http', - capabilities: { - browserName: 'chrome', - 'goog:chromeOptions': {} - } - }, - myFirefoxBrowser: { - hostname: 'localhost', - port: 4444, - protocol: 'http', - capabilities: { - browserName: 'firefox', - 'moz:firefoxOptions': {} - } - } - }, - maxInstances: 15, - user: 'foobaruser', - key: '12345678901234567890', - selfHeal: true - } - - caps = { - myChromeBrowser: { - hostname: 'localhost', - port: 4444, - protocol: 'http', - capabilities: { - browserName: 'chrome', - 'goog:chromeOptions': {} - } - }, - myFirefoxBrowser: { - hostname: 'localhost', - port: 4444, - protocol: 'http', - capabilities: { - browserName: 'firefox', - 'moz:firefoxOptions': {} - } - } - } - - browserMock = { - myChromeBrowser: { - sessionId: 'chrome-session-id', - capabilities: { browserName: 'chrome' }, - overwriteCommand: vi.fn(), - }, - myFirefoxBrowser: { - sessionId: 'firefox-session-id', - capabilities: { browserName: 'firefox' }, - overwriteCommand: vi.fn(), - installAddOn: vi.fn() - } - } as any - }) - - it('should setup capabilities for multiremote session not running on BrowserStack', async () => { - const mockExtension = 'mock-extension' - - const handleHealingInstrumentationSpy = vi.spyOn(funnelInstrumentation, 'handleHealingInstrumentation') - const updateCapsSpy = vi.spyOn(AiHandler, 'updateCaps') - .mockResolvedValue({ 'goog:chromeOptions': { extensions: [mockExtension] } }) - - vi.spyOn(aiSDK.BrowserstackHealing, 'initializeCapabilities') - .mockReturnValue({ 'goog:chromeOptions': { extensions: [mockExtension] } }) - - const emptyObj = {} as any - AiHandler.handleMultiRemoteSetup(mockAuthResult, config, emptyObj, emptyObj, config.capabilities) as any - expect(handleHealingInstrumentationSpy).toHaveBeenCalledTimes(2) - expect(updateCapsSpy).toHaveBeenCalledTimes(2) - }) - - it('should call handleSelfHeal for each browser in multiremote setup', async () => { - AiHandler['authResult'] = { - isAuthenticated: true, - sessionToken: 'mock-session-token', - defaultLogDataEnabled: true, - isHealingEnabled: true - } as any - - const setTokenSpy = vi.spyOn(AiHandler, 'setToken') - const installFirefoxExtensionSpy = vi.spyOn(AiHandler, 'installFirefoxExtension') - const handleSelfHealSpy = vi.spyOn(AiHandler, 'handleSelfHeal') - - await AiHandler.selfHeal(config, caps, browserMock) - - expect(handleSelfHealSpy).toHaveBeenCalledTimes(2) - expect(handleSelfHealSpy).toHaveBeenCalledWith(config, browserMock.myChromeBrowser) - expect(handleSelfHealSpy).toHaveBeenCalledWith(config, browserMock.myFirefoxBrowser) - expect(setTokenSpy).toHaveBeenCalledWith('chrome-session-id', 'mock-session-token') - expect(setTokenSpy).toHaveBeenCalledWith('firefox-session-id', 'mock-session-token') - expect(installFirefoxExtensionSpy).toHaveBeenCalledTimes(1) - expect(installFirefoxExtensionSpy).toHaveBeenCalledWith(browserMock.myFirefoxBrowser) - }) - - it('should skip setup for multiremote session if accessKey is not present', async () => { - config.key = '' - const authenticateUserSpy = vi.spyOn(AiHandler, 'authenticateUser') - .mockResolvedValue(mockAuthResult as any) - const handleHealingInstrumentationSpy = vi.spyOn(funnelInstrumentation, 'handleHealingInstrumentation') - const updateCapsSpy = vi.spyOn(AiHandler, 'updateCaps') - - const emptyObj = {} as any - vi.stubEnv('BROWSERSTACK_ACCESS_KEY', '') - const authResult = { - isAuthenticated: true, - sessionToken: 'mock-session-token', - defaultLogDataEnabled: true, - isHealingEnabled: true - } as any - AiHandler.handleMultiRemoteSetup(authResult, config, emptyObj, emptyObj, config.capabilities) as any - expect(authenticateUserSpy).not.toHaveBeenCalled() - expect(handleHealingInstrumentationSpy).not.toHaveBeenCalled() - expect(updateCapsSpy).not.toHaveBeenCalled() - }) - - it('should handle multiremote setup', async () => { - const caps = { - browserA: { - capabilities: { browserName: 'chrome' } - }, - browserB: { - capabilities: { browserName: 'firefox' } - } - } as any - - const handleMultiRemoteSetupSpy = vi.spyOn(AiHandler, 'handleMultiRemoteSetup') - .mockResolvedValue(caps) - - const emptyObj = {} as any - const updatedCaps = await AiHandler.setup(config, emptyObj, emptyObj, caps, true) - - expect(handleMultiRemoteSetupSpy).toHaveBeenCalledTimes(1) - expect(updatedCaps).toEqual(caps) - - }) - }) -}) diff --git a/packages/browserstack-service/tests/instrumentation/funnelInstrumentation.test.ts b/packages/browserstack-service/tests/instrumentation/funnelInstrumentation.test.ts index fe476a64..78c2585a 100644 --- a/packages/browserstack-service/tests/instrumentation/funnelInstrumentation.test.ts +++ b/packages/browserstack-service/tests/instrumentation/funnelInstrumentation.test.ts @@ -5,7 +5,6 @@ import fs from 'node:fs' import got from 'got' import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { FUNNEL_INSTRUMENTATION_URL } from '../../src/constants.js' -import type { BrowserstackHealing } from '@browserstack/ai-sdk-node' vi.mock('got', () => ({ default: { @@ -183,285 +182,4 @@ describe('funnelInstrumentation', () => { json: data })) }) - - describe('Healing instrumentation', () => { - - it('should display upgrade required warning', async () => { - const authResult = { message: 'Upgrade required' } as BrowserstackHealing.InitErrorResponse - FunnelTestEvent.handleHealingInstrumentation(authResult, config as any, true) - expect(got.post).not.toHaveBeenCalled() - }) - - it('should send server error event when isAuthenticated is false and status is 5xx', async () => { - const authResult = { isAuthenticated: false, status: 500 } as BrowserstackHealing.InitErrorResponse - FunnelTestEvent.handleHealingInstrumentation(authResult, config as any, true) - - expect(got.post).toHaveBeenCalledWith(FUNNEL_INSTRUMENTATION_URL, expect.objectContaining({ - headers: expect.any(Object), - username: config.userName, - password: config.accessKey, - json: { - userName: '[REDACTED]', - accessKey: '[REDACTED]', - event_type: 'SDKTestTcgDownResponse', - detectedFramework: 'WebdriverIO-framework', - event_properties: { - language_framework: 'WebdriverIO_framework', - referrer: expect.stringContaining('WebdriverIO-'), - language: 'WebdriverIO', - languageVersion: process.version, - buildName: config.buildName, - buildIdentifier: config.buildIdentifier, - os: expect.any(String), - hostname: expect.any(String), - productMap: { - 'observability': true, - 'accessibility': true, - 'percy': true, - 'automate': true, - 'app_automate': false - }, - product: expect.arrayContaining(['observability', 'automate', 'percy', 'accessibility']), - framework: 'framework', - isCLIEnabled: false - } - } - })) - }) - - it('should send authentication failure event when isAuthenticated is false and status is 4xx', async () => { - const authResult = { isAuthenticated: false, status: 401 } as BrowserstackHealing.InitErrorResponse - FunnelTestEvent.handleHealingInstrumentation(authResult, config as any, true) - - expect(got.post).toHaveBeenCalledWith(FUNNEL_INSTRUMENTATION_URL, expect.objectContaining({ - headers: expect.any(Object), - username: config.userName, - password: config.accessKey, - json: { - userName: '[REDACTED]', - accessKey: '[REDACTED]', - event_type: 'SDKTestTcgAuthFailure', - detectedFramework: 'WebdriverIO-framework', - event_properties: { - language_framework: 'WebdriverIO_framework', - referrer: expect.stringContaining('WebdriverIO-'), - language: 'WebdriverIO', - languageVersion: process.version, - buildName: config.buildName, - buildIdentifier: config.buildIdentifier, - os: expect.any(String), - hostname: expect.any(String), - productMap: { - 'observability': true, - 'accessibility': true, - 'percy': true, - 'automate': true, - 'app_automate': false - }, - product: expect.arrayContaining(['observability', 'automate', 'percy', 'accessibility']), - framework: 'framework', - isCLIEnabled: false - } - } - })) - }) - - it('should send initialization success event when userId is present', async () => { - const authResult = { - isAuthenticated: true, - userId: 123456, - groupId: 234567, - sessionToken: 'session-token', - isGroupAIEnabled: true, - defaultLogDataEnabled: true, - isHealingEnabled: true - } as BrowserstackHealing.InitSuccessResponse - - FunnelTestEvent.handleHealingInstrumentation(authResult, config as any, true) - - expect(got.post).toHaveBeenCalledWith(FUNNEL_INSTRUMENTATION_URL, expect.objectContaining({ - headers: expect.any(Object), - username: config.userName, - password: config.accessKey, - json: { - userName: '[REDACTED]', - accessKey: '[REDACTED]', - event_type: 'SDKTestTcgtInitSuccessful', - detectedFramework: 'WebdriverIO-framework', - event_properties: { - language_framework: 'WebdriverIO_framework', - referrer: expect.stringContaining('WebdriverIO-'), - language: 'WebdriverIO', - languageVersion: process.version, - buildName: config.buildName, - buildIdentifier: config.buildIdentifier, - os: expect.any(String), - hostname: expect.any(String), - productMap: { - 'observability': true, - 'accessibility': true, - 'percy': true, - 'automate': true, - 'app_automate': false - }, - product: expect.arrayContaining(['observability', 'automate', 'percy', 'accessibility']), - framework: 'framework', - isCLIEnabled: false - } - } - })) - }) - - it('should send initialization failed event if status is 4xx', async () => { - const authResult = { - isAuthenticated: true, - status: 400, - message: 'Request failed with status code 400' - } as any - - FunnelTestEvent.handleHealingInstrumentation(authResult, config as any, true) - - expect(got.post).toHaveBeenCalledWith(FUNNEL_INSTRUMENTATION_URL, expect.objectContaining({ - headers: expect.any(Object), - username: config.userName, - password: config.accessKey, - json: { - userName: '[REDACTED]', - accessKey: '[REDACTED]', - event_type: 'SDKTestInitFailedResponse', - detectedFramework: 'WebdriverIO-framework', - event_properties: { - language_framework: 'WebdriverIO_framework', - referrer: expect.stringContaining('WebdriverIO-'), - language: 'WebdriverIO', - languageVersion: process.version, - buildName: config.buildName, - buildIdentifier: config.buildIdentifier, - os: expect.any(String), - hostname: expect.any(String), - productMap: { - 'observability': true, - 'accessibility': true, - 'percy': true, - 'automate': true, - 'app_automate': false - }, - product: expect.arrayContaining(['observability', 'automate', 'percy', 'accessibility']), - framework: 'framework', - isCLIEnabled: false - } - } - })) - }) - - it('should send invalid TCG auth with user impact event if status is invalid', async () => { - const authResult = { - isAuthenticated: true - } as any - - const bstackConfig = { - ...config, - selfHeal: true - } - - FunnelTestEvent.handleHealingInstrumentation(authResult, bstackConfig as any, true) - - expect(got.post).toHaveBeenCalledWith(FUNNEL_INSTRUMENTATION_URL, expect.objectContaining({ - headers: expect.any(Object), - username: config.userName, - password: config.accessKey, - json: { - userName: '[REDACTED]', - accessKey: '[REDACTED]', - event_type: 'SDKTestInvalidTcgAuthResponseWithUserImpact', - detectedFramework: 'WebdriverIO-framework', - event_properties: { - language_framework: 'WebdriverIO_framework', - referrer: expect.stringContaining('WebdriverIO-'), - language: 'WebdriverIO', - languageVersion: process.version, - buildName: config.buildName, - buildIdentifier: config.buildIdentifier, - os: expect.any(String), - hostname: expect.any(String), - productMap: { - 'observability': true, - 'accessibility': true, - 'percy': true, - 'automate': true, - 'app_automate': false - }, - product: expect.arrayContaining(['observability', 'automate', 'percy', 'accessibility']), - framework: 'framework', - isCLIEnabled: false - } - } - })) - }) - - it('should not send user impact event if selfHeal is not enabled by user', async () => { - const authResult = { - isAuthenticated: true - } as any - - FunnelTestEvent.handleHealingInstrumentation(authResult, config as any, false) - - expect(got.post).not.toHaveBeenCalled() - }) - - it('should handle exceptions during healing instrumentation', async () => { - // Create an authResult that throws an error when 'userId' is accessed - const authResult = { - isAuthenticated: true, - get userId() { - throw new Error('Property access error') - } - } as any - - const debugSpy = vi.spyOn(BStackLogger, 'debug') - - FunnelTestEvent.handleHealingInstrumentation(authResult, config as any, true) - - expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('Error in handling healing instrumentation:')) - expect(got.post).not.toHaveBeenCalled() // Ensure no event is sent - }) - - it('should handle healing not enabled for user', async () => { - const authResult = { - isAuthenticated: true, - userId: 123456, - groupId: 234567, - sessionToken: 'session-token', - isGroupAIEnabled: true, - defaultLogDataEnabled: true, - isHealingEnabled: false - } as BrowserstackHealing.InitSuccessResponse - - FunnelTestEvent.handleHealingInstrumentation(authResult, config as any, false) - - expect(got.post).not.toHaveBeenCalled() - }) - - it('should handle healing disabled for the group', async () => { - const authResult = { - isAuthenticated: true, - userId: 123456, - groupId: 234567, - sessionToken: 'session-token', - isGroupAIEnabled: true, - defaultLogDataEnabled: true, - isHealingEnabled: false - } as BrowserstackHealing.InitSuccessResponse - - const bstackConfig = { - ...config, - selfHeal: true - } - - FunnelTestEvent.handleHealingInstrumentation(authResult, bstackConfig as any, true) - - expect(got.post).not.toHaveBeenCalled() - expect(BStackLogger.warn).toHaveBeenCalledWith('Healing is not enabled for your group, please contact the admin') - }) - }) }) From 3d8ba8c139bdf0acce579ec05ae29ec6631377cc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:50:30 +0000 Subject: [PATCH 2/4] chore(changeset): auto-generate from PR template (patch) --- .changeset/pr-322.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/pr-322.md diff --git a/.changeset/pr-322.md b/.changeset/pr-322.md new file mode 100644 index 00000000..9102db68 --- /dev/null +++ b/.changeset/pr-322.md @@ -0,0 +1,5 @@ +--- +"@wdio/browserstack-service": patch +--- + +- Removed the deprecated client-side self-healing extension. Self-healing on BrowserStack continues to work unchanged via the `browserstack.selfHeal` capability. From 0385057e0d3ccf8156c31196e70d142211143252 Mon Sep 17 00:00:00 2001 From: Hitesh Parmar <10hiteshparmar@gmail.com> Date: Fri, 30 Oct 2026 19:10:16 +0530 Subject: [PATCH 3/4] PTAAA-881: drop the orphaned sendEvent map and fix the off-infra check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendEvent was only read by the healing instrumentation helpers removed with AiHandler, so it is dead once they go — the same no-unused-vars failure this file hit on main. The new selfHeal warning gated on !shouldAddServiceVersion(...), which also returns false for a chromedriver service with observability left on. A genuine BrowserStack session could therefore be told it was not running on BrowserStack infrastructure. Gate on isBrowserstackInfra() instead, which is the condition the message actually describes. shouldAddServiceVersion stays for its four other callers. Typecheck unchanged at 9 pre-existing errors; tests unchanged at 728 passing / 0 failures. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/instrumentation/funnelInstrumentation.ts | 7 ------- packages/browserstack-service/src/launcher.ts | 3 ++- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/browserstack-service/src/instrumentation/funnelInstrumentation.ts b/packages/browserstack-service/src/instrumentation/funnelInstrumentation.ts index 39631c2f..e08e3953 100644 --- a/packages/browserstack-service/src/instrumentation/funnelInstrumentation.ts +++ b/packages/browserstack-service/src/instrumentation/funnelInstrumentation.ts @@ -188,10 +188,3 @@ function getReferrer(framework?: string) { return `${fullName}/${BSTACK_SERVICE_VERSION}` } -const sendEvent = { - tcgDown: (config: BrowserStackConfig) => fireFunnelTestEvent('SDKTestTcgDownResponse', config), - invalidTcgAuth: (config: BrowserStackConfig) => fireFunnelTestEvent('SDKTestInvalidTcgAuthResponseWithUserImpact', config), - tcgAuthFailure: (config: BrowserStackConfig) => fireFunnelTestEvent('SDKTestTcgAuthFailure', config), - tcgtInitSuccessful: (config: BrowserStackConfig) => fireFunnelTestEvent('SDKTestTcgtInitSuccessful', config), - initFailed: (config: BrowserStackConfig) => fireFunnelTestEvent('SDKTestInitFailedResponse', config), -} diff --git a/packages/browserstack-service/src/launcher.ts b/packages/browserstack-service/src/launcher.ts index a51cf23b..c502e2e9 100644 --- a/packages/browserstack-service/src/launcher.ts +++ b/packages/browserstack-service/src/launcher.ts @@ -29,6 +29,7 @@ import { import { launchTestSession, shouldAddServiceVersion, + isBrowserstackInfra, stopBuildUpstream, getCiInfo, isBStackSession, @@ -369,7 +370,7 @@ export default class BrowserstackLauncherService implements Services.ServiceInst PerformanceTester.end(PERFORMANCE_SDK_EVENTS.FRAMEWORK_EVENTS.INIT) // The client-side healing extension this option drove off-infra has been removed; on BrowserStack, healing comes from the browserstack.selfHeal capability instead. - if (this._options.selfHeal === true && !shouldAddServiceVersion(this._config, this._options.testObservability, capabilities as Capabilities.BrowserStackCapabilities)) { + if (this._options.selfHeal === true && !isBrowserstackInfra(this._config, capabilities as Capabilities.BrowserStackCapabilities)) { BStackLogger.warn('selfHeal has no effect for sessions not running on BrowserStack infrastructure. Self-healing on BrowserStack is driven by the browserstack.selfHeal capability.') } From ca92a0cb1a7be3975d73f38c86b2cc49e3d193a6 Mon Sep 17 00:00:00 2001 From: Hitesh Parmar <10hiteshparmar@gmail.com> Date: Fri, 30 Oct 2026 19:20:23 +0530 Subject: [PATCH 4/4] PTAAA-881: drop the orphaned shouldAddServiceVersion import in service.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its only call site was the healing block removed in this PR; the call went and the import stayed. Same no-unused-vars shape as the sendEvent map. Swept the named imports of every file this PR touches — this was the only one left dangling. Typecheck unchanged at 9 pre-existing errors; tests unchanged at 728 passing / 0 failures. Co-Authored-By: Claude Opus 5 (1M context) --- packages/browserstack-service/src/service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/browserstack-service/src/service.ts b/packages/browserstack-service/src/service.ts index 475b5632..d05ec24b 100644 --- a/packages/browserstack-service/src/service.ts +++ b/packages/browserstack-service/src/service.ts @@ -9,7 +9,6 @@ import { getParentSuiteName, isBrowserstackSession, patchConsoleLogs, - shouldAddServiceVersion, isTrue, normalizeTestReportingConfig, normalizeTestReportingEnvVariables,