diff --git a/src/lib/device-cache.ts b/src/lib/device-cache.ts index 65ce7d96..32066c3c 100644 --- a/src/lib/device-cache.ts +++ b/src/lib/device-cache.ts @@ -13,6 +13,8 @@ export enum BrowserStackProducts { LIVE = "live", APP_LIVE = "app_live", APP_AUTOMATE = "app_automate", + SELENIUM_AUTOMATE = "selenium_automate", + PLAYWRIGHT_AUTOMATE = "playwright_automate", } const URLS: Record = { @@ -22,6 +24,10 @@ const URLS: Record = { "https://www.browserstack.com/list-of-browsers-and-platforms/app_live.json", [BrowserStackProducts.APP_AUTOMATE]: "https://www.browserstack.com/list-of-browsers-and-platforms/app_automate.json", + [BrowserStackProducts.SELENIUM_AUTOMATE]: + "https://www.browserstack.com/list-of-browsers-and-platforms/automate.json", + [BrowserStackProducts.PLAYWRIGHT_AUTOMATE]: + "https://www.browserstack.com/list-of-browsers-and-platforms/playwright.json", }; /** diff --git a/src/lib/version-resolver.ts b/src/lib/version-resolver.ts index 25564e97..1530ae01 100644 --- a/src/lib/version-resolver.ts +++ b/src/lib/version-resolver.ts @@ -6,6 +6,7 @@ export function resolveVersion(requested: string, available: string[]): string { // strip duplicates & sort const uniq = Array.from(new Set(available)); + // pick min/max if (requested === "latest" || requested === "oldest") { // try numeric @@ -21,29 +22,48 @@ export function resolveVersion(requested: string, available: string[]): string { return requested === "latest" ? lex[lex.length - 1] : lex[0]; } - // exact? + // exact match? if (uniq.includes(requested)) { return requested; } - // try closest numeric + // Try major version matching (e.g., "14" matches "14.0", "14.1", etc.) const reqNum = parseFloat(requested); - const nums = uniq - .map((v) => ({ v, n: parseFloat(v) })) - .filter((x) => !isNaN(x.n)); - if (!isNaN(reqNum) && nums.length) { - let best = nums[0], - bestDiff = Math.abs(nums[0].n - reqNum); - for (const x of nums) { - const d = Math.abs(x.n - reqNum); - if (d < bestDiff) { - best = x; - bestDiff = d; + if (!isNaN(reqNum)) { + const majorVersionMatches = uniq.filter((v) => { + const vNum = parseFloat(v); + return !isNaN(vNum) && Math.floor(vNum) === Math.floor(reqNum); + }); + + if (majorVersionMatches.length > 0) { + // If multiple matches, prefer the most common format or latest + const exactMatch = majorVersionMatches.find( + (v) => v === `${Math.floor(reqNum)}.0`, + ); + if (exactMatch) { + return exactMatch; } + // Return the first match (usually the most common format) + return majorVersionMatches[0]; + } + } + + // Fuzzy matching: find the closest version + const reqNumForFuzzy = parseFloat(requested); + if (!isNaN(reqNumForFuzzy)) { + const numericVersions = uniq + .map((v) => ({ v, n: parseFloat(v) })) + .filter((x) => !isNaN(x.n)) + .sort( + (a, b) => + Math.abs(a.n - reqNumForFuzzy) - Math.abs(b.n - reqNumForFuzzy), + ); + + if (numericVersions.length > 0) { + return numericVersions[0].v; } - return best.v; } - // final fallback + // Fallback: return the first available version return uniq[0]; } diff --git a/src/tools/appautomate-utils/appium-sdk/config-generator.ts b/src/tools/appautomate-utils/appium-sdk/config-generator.ts index bec4f667..959cf2d2 100644 --- a/src/tools/appautomate-utils/appium-sdk/config-generator.ts +++ b/src/tools/appautomate-utils/appium-sdk/config-generator.ts @@ -1,45 +1,39 @@ -// Configuration utilities for BrowserStack App SDK import { APP_DEVICE_CONFIGS, AppSDKSupportedTestingFrameworkEnum, DEFAULT_APP_PATH, createStep, } from "./index.js"; +import { ValidatedEnvironment } from "../../sdk-utils/common/device-validator.js"; export function generateAppBrowserStackYMLInstructions( - platforms: string[], + config: { + validatedEnvironments?: ValidatedEnvironment[]; + platforms?: string[]; + testingFramework?: string; + projectName?: string; + }, username: string, accessKey: string, appPath: string = DEFAULT_APP_PATH, - testingFramework: string, ): string { if ( - testingFramework === AppSDKSupportedTestingFrameworkEnum.nightwatch || - testingFramework === AppSDKSupportedTestingFrameworkEnum.webdriverio || - testingFramework === AppSDKSupportedTestingFrameworkEnum.cucumberRuby + config.testingFramework === + AppSDKSupportedTestingFrameworkEnum.nightwatch || + config.testingFramework === + AppSDKSupportedTestingFrameworkEnum.webdriverio || + config.testingFramework === AppSDKSupportedTestingFrameworkEnum.cucumberRuby ) { return ""; } - // Generate platform and device configurations - const platformConfigs = platforms - .map((platform) => { - const devices = - APP_DEVICE_CONFIGS[platform as keyof typeof APP_DEVICE_CONFIGS]; - if (!devices) return ""; + const platformConfigs = generatePlatformConfigs(config); - return devices - .map( - (device) => ` - platformName: ${platform} - deviceName: ${device.deviceName} - platformVersion: "${device.platformVersion}"`, - ) - .join("\n"); - }) - .filter(Boolean) - .join("\n"); + const projectName = config.projectName || "BrowserStack Sample"; + const buildName = config.projectName + ? `${config.projectName}-AppAutomate-Build` + : "bstack-demo"; - // Construct YAML content const configContent = `\`\`\`yaml userName: ${username} accessKey: ${accessKey} @@ -48,8 +42,9 @@ platforms: ${platformConfigs} parallelsPerPlatform: 1 browserstackLocal: true -buildName: bstack-demo -projectName: BrowserStack Sample +// TODO: replace projectName and buildName according to actual project +projectName: ${projectName} +buildName: ${buildName} debug: true networkLogs: true percy: false @@ -63,11 +58,46 @@ accessibility: false - Set \`browserstackLocal: true\` if you need to test with local/staging servers - Adjust \`parallelsPerPlatform\` based on your subscription limits`; - // Return formatted step for instructions - return createStep( - "Update browserstack.yml file with App Automate configuration:", - `Create or update the browserstack.yml file in your project root with the following content: + const stepTitle = + "Update browserstack.yml file with App Automate configuration:"; + + const stepDescription = `Create or update the browserstack.yml file in your project root with the following content: + ${configContent}`; + + return createStep(stepTitle, stepDescription); +} + +function generatePlatformConfigs(config: { + validatedEnvironments?: ValidatedEnvironment[]; + platforms?: string[]; +}): string { + if (config.validatedEnvironments && config.validatedEnvironments.length > 0) { + return config.validatedEnvironments + .filter((env) => env.platform === "android" || env.platform === "ios") + .map((env) => { + return ` - platformName: ${env.platform} + deviceName: "${env.deviceName}" + platformVersion: "${env.osVersion}"`; + }) + .join("\n"); + } else if (config.platforms && config.platforms.length > 0) { + return config.platforms + .map((platform) => { + const devices = + APP_DEVICE_CONFIGS[platform as keyof typeof APP_DEVICE_CONFIGS]; + if (!devices) return ""; + + return devices + .map( + (device) => ` - platformName: ${platform} + deviceName: ${device.deviceName} + platformVersion: "${device.platformVersion}"`, + ) + .join("\n"); + }) + .filter(Boolean) + .join("\n"); + } -${configContent}`, - ); + return ""; } diff --git a/src/tools/appautomate-utils/appium-sdk/constants.ts b/src/tools/appautomate-utils/appium-sdk/constants.ts index c75872dd..5b9a2494 100644 --- a/src/tools/appautomate-utils/appium-sdk/constants.ts +++ b/src/tools/appautomate-utils/appium-sdk/constants.ts @@ -49,10 +49,35 @@ export const SETUP_APP_AUTOMATE_SCHEMA = { "The programming language used in the project. Supports Java and C#. Example: 'java', 'csharp'", ), - desiredPlatforms: z - .array(z.nativeEnum(AppSDKSupportedPlatformEnum)) + devices: z + .array( + z.union([ + // Android: [android, deviceName, osVersion] + z.tuple([ + z + .literal(AppSDKSupportedPlatformEnum.android) + .describe("Platform identifier: 'android'"), + z + .string() + .describe( + "Device name, e.g. 'Samsung Galaxy S24', 'Google Pixel 8'", + ), + z.string().describe("Android version, e.g. '14', '16', 'latest'"), + ]), + // iOS: [ios, deviceName, osVersion] + z.tuple([ + z + .literal(AppSDKSupportedPlatformEnum.ios) + .describe("Platform identifier: 'ios'"), + z.string().describe("Device name, e.g. 'iPhone 15', 'iPhone 14 Pro'"), + z.string().describe("iOS version, e.g. '17', '16', 'latest'"), + ]), + ]), + ) + .max(3) + .default([]) .describe( - "The mobile platforms the user wants to test on. Always ask this to the user, do not try to infer this. Example: ['android', 'ios']", + "Tuples describing target mobile devices. Add device only when user asks explicitly for it. Defaults to [] . Example: [['android', 'Samsung Galaxy S24', '14'], ['ios', 'iPhone 15', '17']]", ), appPath: z diff --git a/src/tools/appautomate-utils/appium-sdk/handler.ts b/src/tools/appautomate-utils/appium-sdk/handler.ts index 405b203d..0a24358a 100644 --- a/src/tools/appautomate-utils/appium-sdk/handler.ts +++ b/src/tools/appautomate-utils/appium-sdk/handler.ts @@ -2,6 +2,8 @@ import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { BrowserStackConfig } from "../../../lib/types.js"; import { getBrowserStackAuth } from "../../../lib/get-auth.js"; +import { validateAppAutomateDevices } from "../../sdk-utils/common/device-validator.js"; + import { getAppUploadInstruction, validateSupportforAppAutomate, @@ -36,13 +38,25 @@ export async function setupAppAutomateHandler( const testingFramework = input.detectedTestingFramework as AppSDKSupportedTestingFramework; const language = input.detectedLanguage as AppSDKSupportedLanguage; - const platforms = (input.desiredPlatforms as string[]) ?? ["android"]; + const inputDevices = (input.devices as Array>) ?? []; const appPath = input.appPath as string; const framework = input.detectedFramework as SupportedFramework; //Validating if supported framework or not validateSupportforAppAutomate(framework, language, testingFramework); + // Use default mobile devices when array is empty + const devices = + inputDevices.length === 0 + ? [["android", "Samsung Galaxy S24", "latest"]] + : inputDevices; + + // Validate devices against real BrowserStack device data + const validatedEnvironments = await validateAppAutomateDevices(devices); + + // Extract platforms for backward compatibility (if needed) + const platforms = validatedEnvironments.map((env) => env.platform); + // Step 1: Generate SDK setup command const sdkCommand = getAppSDKPrefixCommand( language, @@ -58,11 +72,15 @@ export async function setupAppAutomateHandler( // Step 2: Generate browserstack.yml configuration const configInstructions = generateAppBrowserStackYMLInstructions( - platforms, + { + validatedEnvironments, + platforms, + testingFramework, + projectName: input.project as string, + }, username, accessKey, appPath, - testingFramework, ); if (configInstructions) { diff --git a/src/tools/appautomate-utils/native-execution/constants.ts b/src/tools/appautomate-utils/native-execution/constants.ts index 722db385..9239a432 100644 --- a/src/tools/appautomate-utils/native-execution/constants.ts +++ b/src/tools/appautomate-utils/native-execution/constants.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { AppTestPlatform } from "./types.js"; +import { AppSDKSupportedPlatformEnum } from "../appium-sdk/types.js"; export const RUN_APP_AUTOMATE_DESCRIPTION = `Execute pre-built native mobile test suites (Espresso for Android, XCUITest for iOS) by direct upload to BrowserStack. ONLY for compiled .apk/.ipa test files. This is NOT for SDK integration or Appium tests. For Appium-based testing with SDK setup, use 'setupBrowserStackAppAutomateTests' instead.`; @@ -29,9 +30,34 @@ export const RUN_APP_AUTOMATE_SCHEMA = { "If in other directory, provide existing test file path", ), devices: z - .array(z.string()) + .array( + z.union([ + // Android: [android, deviceName, osVersion] + z.tuple([ + z + .literal(AppSDKSupportedPlatformEnum.android) + .describe("Platform identifier: 'android'"), + z + .string() + .describe( + "Device name, e.g. 'Samsung Galaxy S24', 'Google Pixel 8'", + ), + z.string().describe("Android version, e.g. '14', '16', 'latest'"), + ]), + // iOS: [ios, deviceName, osVersion] + z.tuple([ + z + .literal(AppSDKSupportedPlatformEnum.ios) + .describe("Platform identifier: 'ios'"), + z.string().describe("Device name, e.g. 'iPhone 15', 'iPhone 14 Pro'"), + z.string().describe("iOS version, e.g. '17', '16', 'latest'"), + ]), + ]), + ) + .max(3) + .default([]) .describe( - "List of devices to run the test on, e.g., ['Samsung Galaxy S20-10.0', 'iPhone 12 Pro-16.0'].", + "Tuples describing target mobile devices. Add device only when user asks explicitly for it. Defaults to [] . Example: [['android', 'Samsung Galaxy S24', '14'], ['ios', 'iPhone 15', '17']]", ), project: z .string() diff --git a/src/tools/appautomate.ts b/src/tools/appautomate.ts index d13f8a46..29a7d57a 100644 --- a/src/tools/appautomate.ts +++ b/src/tools/appautomate.ts @@ -9,6 +9,7 @@ import { maybeCompressBase64 } from "../lib/utils.js"; import { remote } from "webdriverio"; import { AppTestPlatform } from "./appautomate-utils/native-execution/types.js"; import { setupAppAutomateHandler } from "./appautomate-utils/appium-sdk/handler.js"; +import { validateAppAutomateDevices } from "./sdk-utils/common/device-validator.js"; import { SETUP_APP_AUTOMATE_DESCRIPTION, @@ -175,7 +176,7 @@ async function runAppTestsOnBrowserStack( testSuitePath?: string; browserstackAppUrl?: string; browserstackTestSuiteUrl?: string; - devices: string[]; + devices: Array>; project: string; detectedAutomationFramework: string; }, @@ -193,6 +194,9 @@ async function runAppTestsOnBrowserStack( ); } + // Validate devices against real BrowserStack device data + await validateAppAutomateDevices(args.devices); + switch (args.detectedAutomationFramework) { case AppTestPlatform.ESPRESSO: { try { @@ -219,10 +223,16 @@ async function runAppTestsOnBrowserStack( logger.info(`Test suite uploaded. URL: ${test_suite_url}`); } + // Convert array format to string format for Espresso + const deviceStrings = args.devices.map((device) => { + const [, deviceName, osVersion] = device; + return `${deviceName}-${osVersion}`; + }); + const build_id = await triggerEspressoBuild( app_url, test_suite_url, - args.devices, + deviceStrings, args.project, ); @@ -264,10 +274,16 @@ async function runAppTestsOnBrowserStack( logger.info(`Test suite uploaded. URL: ${test_suite_url}`); } + // Convert array format to string format for XCUITest + const deviceStrings = args.devices.map((device) => { + const [, deviceName, osVersion] = device; + return `${deviceName}-${osVersion}`; + }); + const build_id = await triggerXcuiBuild( app_url, test_suite_url, - args.devices, + deviceStrings, args.project, config, ); diff --git a/src/tools/sdk-utils/bstack/configUtils.ts b/src/tools/sdk-utils/bstack/configUtils.ts index 587abd33..ac2b1a99 100644 --- a/src/tools/sdk-utils/bstack/configUtils.ts +++ b/src/tools/sdk-utils/bstack/configUtils.ts @@ -1,42 +1,48 @@ -/** - * Utilities for generating BrowserStack configuration files. - */ - -export function generateBrowserStackYMLInstructions( - desiredPlatforms: string[], - enablePercy: boolean = false, - projectName: string, -) { +import { ValidatedEnvironment } from "../common/device-validator.js"; + +export function generateBrowserStackYMLInstructions(config: { + validatedEnvironments?: ValidatedEnvironment[]; + platforms?: string[]; + enablePercy?: boolean; + projectName: string; +}): string { + const enablePercy = config.enablePercy || false; + const projectName = config.projectName || "BrowserStack Automate Build"; + + // Generate platform configurations using the utility function + const platformConfigs = generatePlatformConfigs(config); + + const stepTitle = + "Create a browserstack.yml file in the project root with your validated device configurations:"; + + const buildName = `${projectName}-Build`; + let ymlContent = ` # ====================== # BrowserStack Reporting # ====================== -# A single name for your project to organize all your tests. This is required for Percy. -projectName: ${projectName} + # TODO: Replace these sample values with your actual project details -buildName: Sample-Build +projectName: ${projectName} +buildName: ${buildName} # ======================================= # Platforms (Browsers / Devices to test) -# ======================================= +# =======================================`; + + ymlContent += ` # Platforms object contains all the browser / device combinations you want to test on. -# Generate this on the basis of the following platforms requested by the user: -# Requested platforms: ${desiredPlatforms} platforms: - - os: Windows - osVersion: 11 - browserName: chrome - browserVersion: latest - +${platformConfigs}`; + + ymlContent += ` + # ======================= # Parallels per Platform # ======================= # The number of parallel threads to be used for each platform set. # BrowserStack's SDK runner will select the best strategy based on the configured value -# -# Example 1 - If you have configured 3 platforms and set \`parallelsPerPlatform\` as 2, a total of 6 (2 * 3) parallel threads will be used on BrowserStack -# -# Example 2 - If you have configured 1 platform and set \`parallelsPerPlatform\` as 5, a total of 5 (1 * 5) parallel threads will be used on BrowserStack +# The number of parallel threads to be used for each platform set. parallelsPerPlatform: 1 # ================= @@ -62,11 +68,65 @@ testObservability: true # For Test Observability`; percy: true percyCaptureMode: manual`; } + return ` ---STEP--- -Create a browserstack.yml file in the project root. The file should be in the following format: +${stepTitle} \`\`\`yaml${ymlContent} \`\`\` \n`; } + +function generatePlatformConfigs(config: { + validatedEnvironments?: ValidatedEnvironment[]; + platforms?: string[]; +}): string { + if (config.validatedEnvironments && config.validatedEnvironments.length > 0) { + // Generate platforms array from validated environments + const platforms = config.validatedEnvironments.map((env) => { + if (env.platform === "windows" || env.platform === "macos") { + // Desktop configuration + return { + os: env.platform === "windows" ? "Windows" : "OS X", + osVersion: env.osVersion, + browserName: env.browser, + browserVersion: env.browserVersion || "latest", + }; + } else { + // Mobile configuration (android/ios) + return { + deviceName: env.deviceName, + osVersion: env.osVersion, + browserName: env.browser, + }; + } + }); + + // Convert platforms to YAML format + return platforms + .map((platform) => { + if (platform.deviceName) { + // Mobile platform + return ` - deviceName: "${platform.deviceName}" + osVersion: "${platform.osVersion}" + browserName: ${platform.browserName}`; + } else { + // Desktop platform + return ` - os: ${platform.os} + osVersion: "${platform.osVersion}" + browserName: ${platform.browserName} + browserVersion: ${platform.browserVersion}`; + } + }) + .join("\n"); + } else if (config.platforms && config.platforms.length > 0) { + // Fallback to default platforms configuration + return ` - os: Windows + osVersion: 11 + browserName: chrome + browserVersion: latest`; + } + + return ""; +} diff --git a/src/tools/sdk-utils/bstack/sdkHandler.ts b/src/tools/sdk-utils/bstack/sdkHandler.ts index 23957d7b..22f00488 100644 --- a/src/tools/sdk-utils/bstack/sdkHandler.ts +++ b/src/tools/sdk-utils/bstack/sdkHandler.ts @@ -6,21 +6,32 @@ import { getSDKPrefixCommand } from "./commands.js"; import { generateBrowserStackYMLInstructions } from "./configUtils.js"; import { getInstructionsForProjectConfiguration } from "../common/instructionUtils.js"; import { BrowserStackConfig } from "../../../lib/types.js"; +import { validateDevices } from "../common/device-validator.js"; import { SDKSupportedBrowserAutomationFramework, SDKSupportedTestingFramework, SDKSupportedLanguage, } from "../common/types.js"; -export function runBstackSDKOnly( +export async function runBstackSDKOnly( input: RunTestsOnBrowserStackInput, config: BrowserStackConfig, isPercyAutomate = false, -): RunTestsInstructionResult { +): Promise { const steps: RunTestsStep[] = []; const authString = getBrowserStackAuth(config); const [username, accessKey] = authString.split(":"); + // Validate devices against real BrowserStack device data + const tupleTargets = (input as any).devices as + | Array> + | undefined; + + const validatedEnvironments = await validateDevices( + tupleTargets || [], + input.detectedBrowserAutomationFramework, + ); + // Handle frameworks with unique setup instructions that don't use browserstack.yml if ( input.detectedBrowserAutomationFramework === "cypress" || @@ -75,11 +86,11 @@ export function runBstackSDKOnly( }); } - const ymlInstructions = generateBrowserStackYMLInstructions( - input.desiredPlatforms as string[], - false, - input.projectName, - ); + const ymlInstructions = generateBrowserStackYMLInstructions({ + validatedEnvironments, + enablePercy: false, + projectName: input.projectName, + }); if (ymlInstructions) { steps.push({ diff --git a/src/tools/sdk-utils/common/device-validator.ts b/src/tools/sdk-utils/common/device-validator.ts new file mode 100644 index 00000000..fe097cc4 --- /dev/null +++ b/src/tools/sdk-utils/common/device-validator.ts @@ -0,0 +1,631 @@ +import { + getDevicesAndBrowsers, + BrowserStackProducts, +} from "../../../lib/device-cache.js"; +import { resolveVersion } from "../../../lib/version-resolver.js"; +import { customFuzzySearch } from "../../../lib/fuzzy.js"; +import { SDKSupportedBrowserAutomationFrameworkEnum } from "./types.js"; + +// ============================================================================ +// SHARED TYPES AND INTERFACES +// ============================================================================ + +// Type definitions for better type safety +export interface DesktopBrowserEntry { + os: string; + os_version: string; + browser: string; + browser_version: string; +} + +export interface MobileDeviceEntry { + os: "android" | "ios"; + os_version: string; + display_name: string; + browsers?: Array<{ + browser: string; + display_name?: string; + }>; +} + +export interface ValidatedEnvironment { + platform: string; + osVersion: string; + browser?: string; + browserVersion?: string; + deviceName?: string; + notes?: string; +} + +// Raw data interfaces for API responses +interface RawDesktopPlatform { + os: string; + os_version: string; + browsers: Array<{ + browser: string; + browser_version: string; + }>; +} + +interface RawMobileGroup { + os: "android" | "ios"; + devices: Array<{ + os_version: string; + display_name: string; + browser?: string; + browsers?: Array<{ + browser: string; + display_name?: string; + }>; + }>; +} + +interface RawDeviceData { + desktop?: RawDesktopPlatform[]; + mobile?: RawMobileGroup[]; +} + +const DEFAULTS = { + windows: { browser: "chrome" }, + macos: { browser: "safari" }, + android: { device: "Samsung Galaxy S24", browser: "chrome" }, + ios: { device: "iPhone 15", browser: "safari" }, +} as const; + +// Performance optimization: Indexed maps for faster lookups +interface DesktopIndex { + byOS: Map; + byOSVersion: Map; + byBrowser: Map; + nested: Map>>; +} + +interface MobileIndex { + byPlatform: Map; + byDeviceName: Map; + byOSVersion: Map; +} + +// ============================================================================ +// AUTOMATE SECTION (Desktop + Mobile for BrowserStack SDK) +// ============================================================================ + +// Helper functions to build device entries and eliminate duplication +function buildDesktopEntries( + automateData: RawDeviceData, +): DesktopBrowserEntry[] { + if (!automateData.desktop) { + return []; + } + + return automateData.desktop.flatMap((platform: RawDesktopPlatform) => + platform.browsers.map((browser) => ({ + os: platform.os, + os_version: platform.os_version, + browser: browser.browser, + browser_version: browser.browser_version, + })), + ); +} + +function buildMobileEntries( + appAutomateData: RawDeviceData, + platform: "android" | "ios", +): MobileDeviceEntry[] { + if (!appAutomateData.mobile) { + return []; + } + + return appAutomateData.mobile + .filter((group: RawMobileGroup) => group.os === platform) + .flatMap((group: RawMobileGroup) => + group.devices.map((device) => ({ + os: group.os, + os_version: device.os_version, + display_name: device.display_name, + browsers: device.browsers || [ + { + browser: + device.browser || (platform === "android" ? "chrome" : "safari"), + }, + ], + })), + ); +} + +// Performance optimization: Create indexed maps for faster lookups +function createDesktopIndex(entries: DesktopBrowserEntry[]): DesktopIndex { + const byOS = new Map(); + const byOSVersion = new Map(); + const byBrowser = new Map(); + const nested = new Map< + string, + Map> + >(); + + for (const entry of entries) { + // Index by OS + if (!byOS.has(entry.os)) { + byOS.set(entry.os, []); + } + byOS.get(entry.os)!.push(entry); + + // Index by OS version + if (!byOSVersion.has(entry.os_version)) { + byOSVersion.set(entry.os_version, []); + } + byOSVersion.get(entry.os_version)!.push(entry); + + // Index by browser + if (!byBrowser.has(entry.browser)) { + byBrowser.set(entry.browser, []); + } + byBrowser.get(entry.browser)!.push(entry); + + // Build nested index: Map>> + if (!nested.has(entry.os)) { + nested.set(entry.os, new Map()); + } + const osMap = nested.get(entry.os)!; + + if (!osMap.has(entry.os_version)) { + osMap.set(entry.os_version, new Map()); + } + const osVersionMap = osMap.get(entry.os_version)!; + + if (!osVersionMap.has(entry.browser)) { + osVersionMap.set(entry.browser, []); + } + osVersionMap.get(entry.browser)!.push(entry); + } + + return { byOS, byOSVersion, byBrowser, nested }; +} + +function createMobileIndex(entries: MobileDeviceEntry[]): MobileIndex { + const byPlatform = new Map(); + const byDeviceName = new Map(); + const byOSVersion = new Map(); + + for (const entry of entries) { + // Index by platform + if (!byPlatform.has(entry.os)) { + byPlatform.set(entry.os, []); + } + byPlatform.get(entry.os)!.push(entry); + + // Index by device name (case-insensitive) + const deviceKey = entry.display_name.toLowerCase(); + if (!byDeviceName.has(deviceKey)) { + byDeviceName.set(deviceKey, []); + } + byDeviceName.get(deviceKey)!.push(entry); + + // Index by OS version + if (!byOSVersion.has(entry.os_version)) { + byOSVersion.set(entry.os_version, []); + } + byOSVersion.get(entry.os_version)!.push(entry); + } + + return { byPlatform, byDeviceName, byOSVersion }; +} + +export async function validateDevices( + devices: Array>, + framework?: string, +): Promise { + const validatedEnvironments: ValidatedEnvironment[] = []; + + if (!devices || devices.length === 0) { + // Use centralized default fallback + return [ + { + platform: "windows", + osVersion: "11", + browser: DEFAULTS.windows.browser, + browserVersion: "latest", + }, + ]; + } + + // Determine what data we need to fetch + const needsDesktop = devices.some((env) => + ["windows", "macos"].includes((env[0] || "").toLowerCase()), + ); + const needsMobile = devices.some((env) => + ["android", "ios"].includes((env[0] || "").toLowerCase()), + ); + + // Fetch data using framework-specific endpoint for both desktop and mobile + let deviceData: RawDeviceData | null = null; + + try { + if (needsDesktop || needsMobile) { + if (framework === SDKSupportedBrowserAutomationFrameworkEnum.playwright) { + deviceData = (await getDevicesAndBrowsers( + BrowserStackProducts.PLAYWRIGHT_AUTOMATE, + )) as RawDeviceData; + } else { + deviceData = (await getDevicesAndBrowsers( + BrowserStackProducts.SELENIUM_AUTOMATE, + )) as RawDeviceData; + } + } + } catch (error) { + throw new Error( + `Failed to fetch device data: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + // Preprocess data into indexed maps for better performance + let desktopIndex: DesktopIndex | null = null; + let androidIndex: MobileIndex | null = null; + let iosIndex: MobileIndex | null = null; + + if (needsDesktop && deviceData) { + const desktopEntries = buildDesktopEntries(deviceData); + desktopIndex = createDesktopIndex(desktopEntries); + } + + if (needsMobile && deviceData) { + const androidEntries = buildMobileEntries(deviceData, "android"); + const iosEntries = buildMobileEntries(deviceData, "ios"); + androidIndex = createMobileIndex(androidEntries); + iosIndex = createMobileIndex(iosEntries); + } + + for (const env of devices) { + const discriminator = (env[0] || "").toLowerCase(); + let validatedEnv: ValidatedEnvironment; + + if (discriminator === "windows") { + validatedEnv = validateDesktopEnvironment( + env, + desktopIndex!, + "windows", + DEFAULTS.windows.browser, + ); + } else if (discriminator === "macos") { + validatedEnv = validateDesktopEnvironment( + env, + desktopIndex!, + "macos", + DEFAULTS.macos.browser, + ); + } else if (discriminator === "android") { + validatedEnv = validateMobileEnvironment( + env, + androidIndex!, + "android", + DEFAULTS.android.device, + DEFAULTS.android.browser, + ); + } else if (discriminator === "ios") { + validatedEnv = validateMobileEnvironment( + env, + iosIndex!, + "ios", + DEFAULTS.ios.device, + DEFAULTS.ios.browser, + ); + } else { + throw new Error(`Unsupported platform: ${discriminator}`); + } + + validatedEnvironments.push(validatedEnv); + } + + return validatedEnvironments; +} + +// Optimized desktop validation using nested indexed maps for O(1) lookups +function validateDesktopEnvironment( + env: string[], + index: DesktopIndex, + platform: "windows" | "macos", + defaultBrowser: string, +): ValidatedEnvironment { + const [, osVersion, browser, browserVersion] = env; + + const osKey = platform === "windows" ? "Windows" : "OS X"; + + // Use nested index for O(1) lookup instead of filtering + const osMap = index.nested.get(osKey); + if (!osMap) { + throw new Error(`No ${platform} devices available`); + } + + // Get available OS versions for this platform + const availableOSVersions = Array.from(osMap.keys()); + + const validatedOSVersion = resolveVersion( + osVersion || "latest", + availableOSVersions, + ); + + // Use nested index for O(1) lookup + const osVersionMap = osMap.get(validatedOSVersion); + if (!osVersionMap) { + throw new Error( + `OS version "${validatedOSVersion}" not available for ${platform}`, + ); + } + + // Get available browsers for this OS version + const availableBrowsers = Array.from(osVersionMap.keys()); + const validatedBrowser = validateBrowserExact( + browser || defaultBrowser, + availableBrowsers, + ); + + // Use nested index for O(1) lookup + const browserEntries = osVersionMap.get(validatedBrowser); + if (!browserEntries || browserEntries.length === 0) { + throw new Error( + `Browser "${validatedBrowser}" not available for ${platform} ${validatedOSVersion}`, + ); + } + + const availableBrowserVersions = [ + ...new Set(browserEntries.map((e) => e.browser_version)), + ] as string[]; + const validatedBrowserVersion = resolveVersion( + browserVersion || "latest", + availableBrowserVersions, + ); + + return { + platform, + osVersion: validatedOSVersion, + browser: validatedBrowser, + browserVersion: validatedBrowserVersion, + }; +} + +// Optimized mobile validation using indexed maps +function validateMobileEnvironment( + env: string[], + index: MobileIndex, + platform: "android" | "ios", + defaultDevice: string, + defaultBrowser: string, +): ValidatedEnvironment { + const [, deviceName, osVersion, browser] = env; + + const platformEntries = index.byPlatform.get(platform) || []; + if (platformEntries.length === 0) { + throw new Error(`No ${platform} devices available`); + } + + // Use fuzzy search only for device names (as suggested in feedback) + const deviceMatches = customFuzzySearch( + platformEntries, + ["display_name"], + deviceName || defaultDevice, + 5, + ); + if (deviceMatches.length === 0) { + throw new Error( + `No ${platform} devices matching "${deviceName}". Available devices: ${platformEntries + .map((d) => d.display_name || "unknown") + .slice(0, 5) + .join(", ")}`, + ); + } + + // Try to find exact match first + const exactMatch = deviceMatches.find( + (m) => m.display_name.toLowerCase() === (deviceName || "").toLowerCase(), + ); + + // If no exact match, throw error instead of using fuzzy match + if (!exactMatch) { + const suggestions = deviceMatches.map((m) => m.display_name).join(", "); + throw new Error( + `Device "${deviceName}" not found exactly for ${platform}. Available similar devices: ${suggestions}. Please use the exact device name.`, + ); + } + + // Use index for faster filtering + const deviceKey = exactMatch.display_name.toLowerCase(); + const deviceFiltered = index.byDeviceName.get(deviceKey) || []; + + const availableOSVersions = [ + ...new Set(deviceFiltered.map((d) => d.os_version)), + ] as string[]; + const validatedOSVersion = resolveVersion( + osVersion || "latest", + availableOSVersions, + ); + + // Use index for faster filtering + const osVersionEntries = index.byOSVersion.get(validatedOSVersion) || []; + const osFiltered = osVersionEntries.filter( + (d) => d.display_name.toLowerCase() === deviceKey, + ); + + // Validate browser if provided - use exact matching for browsers + let validatedBrowser = browser || defaultBrowser; + if (browser && osFiltered.length > 0) { + // Extract browsers more carefully - handle different possible structures + const availableBrowsers = [ + ...new Set( + osFiltered.flatMap((d) => { + if (d.browsers && Array.isArray(d.browsers)) { + // If browsers is an array of objects with browser property + return d.browsers + .map((b) => { + // Use display_name for user-friendly browser names, fallback to browser field + return b.display_name || b.browser; + }) + .filter(Boolean); + } + // For mobile devices, provide default browsers if none found + return platform === "android" ? ["chrome"] : ["safari"]; + }), + ), + ].filter(Boolean) as string[]; + + if (availableBrowsers.length > 0) { + try { + validatedBrowser = validateBrowserExact(browser, availableBrowsers); + } catch (error) { + // Add more context to browser validation errors + throw new Error( + `Failed to validate browser "${browser}" for ${platform} device "${exactMatch.display_name}" on OS version "${validatedOSVersion}". ${error instanceof Error ? error.message : String(error)}`, + ); + } + } else { + // For mobile, if no specific browsers found, just use the requested browser + // as most mobile devices support standard browsers + validatedBrowser = browser || defaultBrowser; + } + } + + return { + platform, + osVersion: validatedOSVersion, + deviceName: exactMatch.display_name, + browser: validatedBrowser, + }; +} + +// ============================================================================ +// APP AUTOMATE SECTION (Mobile devices for App Automate) +// ============================================================================ + +export async function validateAppAutomateDevices( + devices: Array>, +): Promise { + const validatedDevices: ValidatedEnvironment[] = []; + + if (!devices || devices.length === 0) { + // Use centralized default fallback + return [ + { + platform: "android", + osVersion: "latest", + deviceName: DEFAULTS.android.device, + }, + ]; + } + + let appAutomateData: RawDeviceData; + + try { + // Fetch app automate device data + appAutomateData = (await getDevicesAndBrowsers( + BrowserStackProducts.APP_AUTOMATE, + )) as RawDeviceData; + } catch (error) { + // Only wrap fetch-related errors + throw new Error( + `Failed to fetch device data: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + for (const device of devices) { + // Parse device array in format ["android", "Device Name", "OS Version"] + const [platform, deviceName, osVersion] = device; + + // Find matching device in the data + let validatedDevice: ValidatedEnvironment | null = null; + + if (!appAutomateData.mobile) { + throw new Error("No mobile device data available"); + } + + // Filter by platform first + const platformGroup = appAutomateData.mobile.find( + (group) => group.os === platform.toLowerCase(), + ); + + if (!platformGroup) { + throw new Error(`Platform "${platform}" not supported for App Automate`); + } + + const platformDevices = platformGroup.devices; + + // Find exact device name match (case-insensitive) + const exactMatch = platformDevices.find( + (d) => d.display_name.toLowerCase() === deviceName.toLowerCase(), + ); + + if (exactMatch) { + // Check if the OS version is available for this device + const deviceVersions = platformDevices + .filter((d) => d.display_name === exactMatch.display_name) + .map((d) => d.os_version); + + const validatedOSVersion = resolveVersion( + osVersion || "latest", + deviceVersions, + ); + + validatedDevice = { + platform: platformGroup.os, + osVersion: validatedOSVersion, + deviceName: exactMatch.display_name, + }; + } + + if (!validatedDevice) { + // If no exact match found, suggest similar devices from the SAME platform only + const platformDevicesForSearch = platformDevices.map((d) => ({ + ...d, + platform: platformGroup.os, + })); + + // Try fuzzy search with a more lenient threshold + const deviceMatches = customFuzzySearch( + platformDevicesForSearch, + ["display_name"], + deviceName, + 5, + 0.8, // More lenient threshold + ); + + const suggestions = deviceMatches + .map((m) => `${m.display_name}`) + .join(", "); + + // If no fuzzy matches, show some available devices as fallback + const fallbackDevices = platformDevicesForSearch + .slice(0, 5) + .map((d) => d.display_name) + .join(", "); + + const errorMessage = suggestions + ? `Device "${deviceName}" not found for platform "${platform}".\nAvailable similar devices: ${suggestions}` + : `Device "${deviceName}" not found for platform "${platform}".\nAvailable devices: ${fallbackDevices}`; + + throw new Error(errorMessage); + } + + validatedDevices.push(validatedDevice); + } + + return validatedDevices; +} + +// ============================================================================ +// SHARED UTILITY FUNCTIONS +// ============================================================================ + +// Exact browser validation (preferred for structured fields) +function validateBrowserExact( + requestedBrowser: string, + availableBrowsers: string[], +): string { + const exactMatch = availableBrowsers.find( + (b) => b.toLowerCase() === requestedBrowser.toLowerCase(), + ); + if (exactMatch) { + return exactMatch; + } + + throw new Error( + `Browser "${requestedBrowser}" not found. Available options: ${availableBrowsers.join(", ")}`, + ); +} diff --git a/src/tools/sdk-utils/common/schema.ts b/src/tools/sdk-utils/common/schema.ts index 4f2bd8d2..d433f358 100644 --- a/src/tools/sdk-utils/common/schema.ts +++ b/src/tools/sdk-utils/common/schema.ts @@ -6,6 +6,22 @@ import { SDKSupportedLanguageEnum, } from "./types.js"; +// Platform enums for better validation +export const PlatformEnum = { + WINDOWS: "windows", + MACOS: "macos", + ANDROID: "android", + IOS: "ios", +} as const; + +export const WindowsPlatformEnum = { + WINDOWS: "windows", +} as const; + +export const MacOSPlatformEnum = { + MACOS: "macos", +} as const; + export const SetUpPercyParamsShape = { projectName: z.string().describe("A unique name for your Percy project."), detectedLanguage: z.nativeEnum(SDKSupportedLanguageEnum), @@ -34,12 +50,60 @@ export const RunTestsOnBrowserStackParamsShape = { SDKSupportedBrowserAutomationFrameworkEnum, ), detectedTestingFramework: z.nativeEnum(SDKSupportedTestingFrameworkEnum), - desiredPlatforms: z - .array(z.enum(["windows", "macos", "android", "ios"])) - .describe("An array of platforms to run tests on."), + devices: z + .array( + z.union([ + // Windows: [windows, osVersion, browser, browserVersion] + z.tuple([ + z + .nativeEnum(WindowsPlatformEnum) + .describe("Platform identifier: 'windows'"), + z.string().describe("Windows version, e.g. '10', '11'"), + z.string().describe("Browser name, e.g. 'chrome', 'firefox', 'edge'"), + z + .string() + .describe("Browser version, e.g. '132', 'latest', 'oldest'"), + ]), + // Android: [android, name, model, osVersion, browser] + z.tuple([ + z + .literal(PlatformEnum.ANDROID) + .describe("Platform identifier: 'android'"), + z + .string() + .describe( + "Device name, e.g. 'Samsung Galaxy S24', 'Google Pixel 8'", + ), + z.string().describe("Android version, e.g. '14', '16', 'latest'"), + z.string().describe("Browser name, e.g. 'chrome', 'samsung browser'"), + ]), + // iOS: [ios, name, model, osVersion, browser] + z.tuple([ + z.literal(PlatformEnum.IOS).describe("Platform identifier: 'ios'"), + z.string().describe("Device name, e.g. 'iPhone 12 Pro'"), + z.string().describe("iOS version, e.g. '17', 'latest'"), + z.string().describe("Browser name, typically 'safari'"), + ]), + // macOS: [mac|macos, name, model, browser, browserVersion] + z.tuple([ + z + .nativeEnum(MacOSPlatformEnum) + .describe("Platform identifier: 'mac' or 'macos'"), + z.string().describe("macOS version name, e.g. 'Sequoia', 'Ventura'"), + z.string().describe("Browser name, e.g. 'safari', 'chrome'"), + z.string().describe("Browser version, e.g. 'latest'"), + ]), + ]), + ) + .max(3) + .default([]) + .describe( + "Preferred tuples of target devices.Add device only when user asks explicitly for it. Defaults to [] . Example: [['windows', '11', 'chrome', 'latest']]", + ), }; export const SetUpPercySchema = z.object(SetUpPercyParamsShape); + export const RunTestsOnBrowserStackSchema = z.object( RunTestsOnBrowserStackParamsShape, ); diff --git a/src/tools/sdk-utils/common/utils.ts b/src/tools/sdk-utils/common/utils.ts index d21cbc9e..0bb433af 100644 --- a/src/tools/sdk-utils/common/utils.ts +++ b/src/tools/sdk-utils/common/utils.ts @@ -110,8 +110,10 @@ export function getBootstrapFailedMessage( error: unknown, context: { config: unknown; percyMode?: string; sdkVersion?: string }, ): string { + const error_message = + error instanceof Error ? error.message : "unknown error"; return `Failed to bootstrap project with BrowserStack SDK. -Error: ${error} +Error: ${error_message} Percy Mode: ${context.percyMode ?? "automate"} SDK Version: ${context.sdkVersion ?? "N/A"} Please open an issue on GitHub if the problem persists.`; diff --git a/src/tools/sdk-utils/handler.ts b/src/tools/sdk-utils/handler.ts index 01a119f0..ec33e3d7 100644 --- a/src/tools/sdk-utils/handler.ts +++ b/src/tools/sdk-utils/handler.ts @@ -22,15 +22,9 @@ export async function runTestsOnBrowserStackHandler( rawInput: unknown, config: BrowserStackConfig, ): Promise { - try { - const input = RunTestsOnBrowserStackSchema.parse(rawInput); - - // Only handle BrowserStack SDK setup for functional/integration tests. - const result = runBstackSDKOnly(input, config); - return await formatToolResult(result); - } catch (error) { - throw new Error(getBootstrapFailedMessage(error, { config })); - } + const input = RunTestsOnBrowserStackSchema.parse(rawInput); + const result = await runBstackSDKOnly(input, config); + return await formatToolResult(result); } export async function setUpPercyHandler( @@ -75,7 +69,7 @@ export async function setUpPercyHandler( const percyWithBrowserstackSDKResult = runPercyWithBrowserstackSDK( { ...percyInput, - desiredPlatforms: [], + devices: [], }, config, ); @@ -113,9 +107,9 @@ export async function setUpPercyHandler( detectedBrowserAutomationFramework: input.detectedBrowserAutomationFramework, detectedTestingFramework: input.detectedTestingFramework, - desiredPlatforms: [], + devices: [], }; - const sdkResult = runBstackSDKOnly(sdkInput, config, true); + const sdkResult = await runBstackSDKOnly(sdkInput, config, true); // Percy Automate instructions const percyToken = await fetchPercyToken( input.projectName, diff --git a/src/tools/sdk-utils/percy-bstack/handler.ts b/src/tools/sdk-utils/percy-bstack/handler.ts index e0711779..989c17a4 100644 --- a/src/tools/sdk-utils/percy-bstack/handler.ts +++ b/src/tools/sdk-utils/percy-bstack/handler.ts @@ -106,11 +106,14 @@ export function runPercyWithBrowserstackSDK( }); } - const ymlInstructions = generateBrowserStackYMLInstructions( - input.desiredPlatforms as string[], - true, - input.projectName, - ); + const ymlInstructions = generateBrowserStackYMLInstructions({ + platforms: + ((input as any).devices as string[][] | undefined)?.map((t) => + t.join(" "), + ) || [], + enablePercy: true, + projectName: input.projectName, + }); if (ymlInstructions) { steps.push({