appautomate.ts•8.69 kB
import fs from "fs";
import FormData from "form-data";
import { apiClient } from "../../../lib/apiClient.js";
import { customFuzzySearch } from "../../../lib/fuzzy.js";
import { BrowserStackConfig } from "../../../lib/types.js";
interface Device {
  device: string;
  display_name: string;
  os_version: string;
  real_mobile: boolean;
}
interface UploadResponse {
  app_url: string;
  custom_id?: string;
  shareable_id?: string;
}
/**
 * Finds devices that exactly match the provided display name.
 * Uses fuzzy search first, and then filters for exact case-insensitive match.
 */
export function findMatchingDevice(
  devices: Device[],
  deviceName: string,
): Device[] {
  const matches = customFuzzySearch(devices, ["display_name"], deviceName, 5);
  if (matches.length === 0) {
    const availableDevices = [
      ...new Set(devices.map((d) => d.display_name)),
    ].join(", ");
    throw new Error(
      `No devices found matching "${deviceName}". Available devices: ${availableDevices}`,
    );
  }
  const exactMatches = matches.filter(
    (m) => m.display_name.toLowerCase() === deviceName.toLowerCase(),
  );
  if (exactMatches.length === 0) {
    const suggestions = [...new Set(matches.map((d) => d.display_name))].join(
      ", ",
    );
    throw new Error(
      `Alternative devices found: ${suggestions}. Please select one of these exact device names.`,
    );
  }
  return exactMatches;
}
/**
 * Extracts all unique OS versions from a device list and sorts them.
 */
export function getDeviceVersions(devices: Device[]): string[] {
  return [...new Set(devices.map((d) => d.os_version))].sort();
}
/**
 * Resolves the requested platform version against available versions.
 * Supports 'latest' and 'oldest' as dynamic selectors.
 */
export function resolveVersion(
  versions: string[],
  requestedVersion: string,
): string {
  if (requestedVersion === "latest") {
    return versions[versions.length - 1];
  }
  if (requestedVersion === "oldest") {
    return versions[0];
  }
  const match = versions.find((v) => v === requestedVersion);
  if (!match) {
    throw new Error(
      `Version "${requestedVersion}" not found. Available versions: ${versions.join(", ")}`,
    );
  }
  return match;
}
/**
 * Validates the input arguments for taking app screenshots.
 * Checks for presence and correctness of platform, device, and file types.
 */
export function validateArgs(args: {
  desiredPlatform: string;
  desiredPlatformVersion: string;
  appPath?: string;
  desiredPhone: string;
  browserstackAppUrl?: string;
}): void {
  const {
    desiredPlatform,
    desiredPlatformVersion,
    appPath,
    desiredPhone,
    browserstackAppUrl,
  } = args;
  if (!desiredPlatform || !desiredPhone) {
    throw new Error(
      "Missing required arguments: desiredPlatform and desiredPhone are required",
    );
  }
  if (!desiredPlatformVersion) {
    throw new Error(
      "Missing required arguments: desiredPlatformVersion is required",
    );
  }
  if (!appPath && !browserstackAppUrl) {
    throw new Error("Either appPath or browserstackAppUrl must be provided");
  }
  // Only validate app path format if appPath is provided
  if (appPath) {
    if (desiredPlatform === "android" && !appPath.endsWith(".apk")) {
      throw new Error("You must provide a valid Android app path (.apk).");
    }
    if (desiredPlatform === "ios" && !appPath.endsWith(".ipa")) {
      throw new Error("You must provide a valid iOS app path (.ipa).");
    }
  }
}
/**
 * Uploads an application file to AppAutomate and returns the app URL
 */
export async function uploadApp(
  appPath: string,
  username: string,
  password: string,
): Promise<string> {
  const filePath = appPath;
  if (!fs.existsSync(filePath)) {
    throw new Error(`File not found at path: ${filePath}`);
  }
  const formData = new FormData();
  formData.append("file", fs.createReadStream(filePath));
  const response = await apiClient.post<UploadResponse>({
    url: "https://api-cloud.browserstack.com/app-automate/upload",
    headers: {
      ...formData.getHeaders(),
      Authorization:
        "Basic " + Buffer.from(`${username}:${password}`).toString("base64"),
    },
    body: formData,
  });
  if (response.data.app_url) {
    return response.data.app_url;
  } else {
    throw new Error(`Failed to upload app: ${JSON.stringify(response.data)}`);
  }
}
// Helper to upload a file to a given BrowserStack endpoint and return a specific property from the response.
async function uploadFileToBrowserStack(
  filePath: string,
  endpoint: string,
  responseKey: string,
  config: BrowserStackConfig,
): Promise<string> {
  if (!fs.existsSync(filePath)) {
    throw new Error(`File not found at path: ${filePath}`);
  }
  const formData = new FormData();
  formData.append("file", fs.createReadStream(filePath));
  const authHeader =
    "Basic " +
    Buffer.from(
      `${config["browserstack-username"]}:${config["browserstack-access-key"]}`,
    ).toString("base64");
  const response = await apiClient.post({
    url: endpoint,
    headers: {
      ...formData.getHeaders(),
      Authorization: authHeader,
    },
    body: formData,
  });
  if (response.data[responseKey]) {
    return response.data[responseKey];
  }
  throw new Error(`Failed to upload file: ${JSON.stringify(response.data)}`);
}
//Uploads an Android app (.apk or .aab) to BrowserStack Espresso endpoint and returns the app_url
export async function uploadEspressoApp(
  appPath: string,
  config: BrowserStackConfig,
): Promise<string> {
  return uploadFileToBrowserStack(
    appPath,
    "https://api-cloud.browserstack.com/app-automate/espresso/v2/app",
    "app_url",
    config,
  );
}
//Uploads an Espresso test suite (.apk) to BrowserStack and returns the test_suite_url
export async function uploadEspressoTestSuite(
  testSuitePath: string,
  config: BrowserStackConfig,
): Promise<string> {
  return uploadFileToBrowserStack(
    testSuitePath,
    "https://api-cloud.browserstack.com/app-automate/espresso/v2/test-suite",
    "test_suite_url",
    config,
  );
}
//Uploads an iOS app (.ipa) to BrowserStack XCUITest endpoint and returns the app_url
export async function uploadXcuiApp(
  appPath: string,
  config: BrowserStackConfig,
): Promise<string> {
  return uploadFileToBrowserStack(
    appPath,
    "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/app",
    "app_url",
    config,
  );
}
//Uploads an XCUITest test suite (.zip) to BrowserStack and returns the test_suite_url
export async function uploadXcuiTestSuite(
  testSuitePath: string,
  config: BrowserStackConfig,
): Promise<string> {
  return uploadFileToBrowserStack(
    testSuitePath,
    "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/test-suite",
    "test_suite_url",
    config,
  );
}
// Triggers an Espresso test run on BrowserStack and returns the build_id
export async function triggerEspressoBuild(
  app_url: string,
  test_suite_url: string,
  devices: string[],
  project: string,
): Promise<string> {
  const auth = {
    username: process.env.BROWSERSTACK_USERNAME || "",
    password: process.env.BROWSERSTACK_ACCESS_KEY || "",
  };
  const response = await apiClient.post({
    url: "https://api-cloud.browserstack.com/app-automate/espresso/v2/build",
    headers: {
      Authorization:
        "Basic " +
        Buffer.from(`${auth.username}:${auth.password}`).toString("base64"),
      "Content-Type": "application/json",
    },
    body: {
      app: app_url,
      testSuite: test_suite_url,
      devices,
      project,
    },
  });
  if (response.data.build_id) {
    return response.data.build_id;
  }
  throw new Error(
    `Failed to trigger Espresso build: ${JSON.stringify(response.data)}`,
  );
}
// Triggers an XCUITest run on BrowserStack and returns the build_id
export async function triggerXcuiBuild(
  app_url: string,
  test_suite_url: string,
  devices: string[],
  project: string,
  config: BrowserStackConfig,
): Promise<string> {
  const auth = {
    username: config["browserstack-username"],
    password: config["browserstack-access-key"],
  };
  const response = await apiClient.post({
    url: "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/build",
    headers: {
      Authorization:
        "Basic " +
        Buffer.from(`${auth.username}:${auth.password}`).toString("base64"),
      "Content-Type": "application/json",
    },
    body: {
      app: app_url,
      testSuite: test_suite_url,
      devices,
      project,
    },
  });
  if (response.data.build_id) {
    return response.data.build_id;
  }
  throw new Error(
    `Failed to trigger XCUITest build: ${JSON.stringify(response.data)}`,
  );
}