device-validator.ts•19.1 kB
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<string, DesktopBrowserEntry[]>;
  byOSVersion: Map<string, DesktopBrowserEntry[]>;
  byBrowser: Map<string, DesktopBrowserEntry[]>;
  nested: Map<string, Map<string, Map<string, DesktopBrowserEntry[]>>>;
}
interface MobileIndex {
  byPlatform: Map<string, MobileDeviceEntry[]>;
  byDeviceName: Map<string, MobileDeviceEntry[]>;
  byOSVersion: Map<string, MobileDeviceEntry[]>;
}
// ============================================================================
// 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<string, DesktopBrowserEntry[]>();
  const byOSVersion = new Map<string, DesktopBrowserEntry[]>();
  const byBrowser = new Map<string, DesktopBrowserEntry[]>();
  const nested = new Map<
    string,
    Map<string, Map<string, DesktopBrowserEntry[]>>
  >();
  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<os, Map<os_version, Map<browser, DesktopBrowserEntry[]>>>
    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<string, MobileDeviceEntry[]>();
  const byDeviceName = new Map<string, MobileDeviceEntry[]>();
  const byOSVersion = new Map<string, MobileDeviceEntry[]>();
  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<Array<string>>,
  framework?: string,
): Promise<ValidatedEnvironment[]> {
  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);
  }
  if (framework === SDKSupportedBrowserAutomationFrameworkEnum.playwright) {
    validatedEnvironments.forEach((env) => {
      if (env.browser) {
        env.browser = env.browser.toLowerCase();
      }
    });
  }
  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<Array<string>>,
): Promise<ValidatedEnvironment[]> {
  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(", ")}`,
  );
}