Skip to content
Merged
64 changes: 64 additions & 0 deletions src/lib/fuzzy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// 1. Compute Levenshtein distance between two strings
function levenshtein(a: string, b: string): number {
const dp: number[][] = Array(a.length + 1)
.fill(0)
.map(() => Array(b.length + 1).fill(0));
for (let i = 0; i <= a.length; i++) dp[i][0] = i;
for (let j = 0; j <= b.length; j++) dp[0][j] = j;

for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
dp[i][j] = Math.min(
dp[i - 1][j] + 1, // deletion
dp[i][j - 1] + 1, // insertion
dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1), // substitution
);
}
}
return dp[a.length][b.length];
}

// 2. Score one item against the query (normalized score 0–1)
function scoreItem<T>(
item: T,
keys: Array<keyof T | string>,
queryTokens: string[],
): number {
let best = Infinity;
for (const key of keys) {
const field = String(item[key as keyof T] ?? "").toLowerCase();
const fieldTokens = field.split(/\s+/);
const tokenScores = queryTokens.map((qt) => {
const minNormalized = Math.min(
...fieldTokens.map((ft) => {
const rawDist = levenshtein(ft, qt);
const maxLen = Math.max(ft.length, qt.length);
return maxLen === 0 ? 0 : rawDist / maxLen; // normalized 0–1
}),
);
return minNormalized;
});
const avg = tokenScores.reduce((a, b) => a + b, 0) / tokenScores.length;
best = Math.min(best, avg);
}
return best;
}

// 3. The search entrypoint
export function customFuzzySearch<T>(
list: T[],
keys: Array<keyof T | string>,
query: string,
limit: number = 5,
maxDistance: number = 0.6,
): T[] {
const q = query.toLowerCase().trim();
const queryTokens = q.split(/\s+/);

return list
.map((item) => ({ item, score: scoreItem(item, keys, queryTokens) }))
.filter((x) => x.score <= maxDistance)
.sort((a, b) => a.score - b.score)
.slice(0, limit)
.map((x) => x.item);
}
31 changes: 31 additions & 0 deletions src/tools/applive-utils/device-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import fs from "fs";
import os from "os";
import path from "path";

const CACHE_DIR = path.join(os.homedir(), ".browserstack", "app_live_cache");
const CACHE_FILE = path.join(CACHE_DIR, "app_live.json");
const TTL_MS = 24 * 60 * 60 * 1000; // 1 day

/**
* Fetches and caches the App Live devices JSON with a 1-day TTL.
*/
export async function getAppLiveData(): Promise<any> {
if (!fs.existsSync(CACHE_DIR)) {
fs.mkdirSync(CACHE_DIR, { recursive: true });
}
if (fs.existsSync(CACHE_FILE)) {
const stats = fs.statSync(CACHE_FILE);
if (Date.now() - stats.mtimeMs < TTL_MS) {
return JSON.parse(fs.readFileSync(CACHE_FILE, "utf8"));
}
}
const response = await fetch(
"https://www.browserstack.com/list-of-browsers-and-platforms/app_live.json",
);
if (!response.ok) {
throw new Error(`Failed to fetch app live list: ${response.statusText}`);
}
const data = await response.json();
fs.writeFileSync(CACHE_FILE, JSON.stringify(data), "utf8");
return data;
}
19 changes: 19 additions & 0 deletions src/tools/applive-utils/fuzzy-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { customFuzzySearch } from "../../lib/fuzzy";
import { DeviceEntry } from "./start-session";

/**
* Fuzzy searches App Live device entries by name.
*/
export async function fuzzySearchDevices(
devices: DeviceEntry[],
query: string,
limit: number = 5,
): Promise<DeviceEntry[]> {
const top_match = customFuzzySearch(
devices,
["device", "display_name"],
query,
limit,
);
return top_match;
}
223 changes: 197 additions & 26 deletions src/tools/applive-utils/start-session.ts
Original file line number Diff line number Diff line change
@@ -1,72 +1,243 @@
import childProcess from "child_process";
import logger from "../../logger";
import { getAppLiveData } from "./device-cache";
import { fuzzySearchDevices } from "./fuzzy-search";
import { sanitizeUrlParam } from "../../lib/utils";
import { uploadApp } from "./upload-app";

export interface DeviceEntry {
device: string;
display_name: string;
os: string;
os_version: string;
real_mobile: boolean;
}

interface StartSessionArgs {
appUrl: string;
appPath: string;
desiredPlatform: "android" | "ios";
desiredPhone: string;
desiredPlatformVersion: string;
}

/**
* Starts an App Live session after filtering, fuzzy matching, and launching.
* @param args - The arguments for starting the session.
* @returns The launch URL for the session.
* @throws Will throw an error if no devices are found or if the app URL is invalid.
*/
export async function startSession(args: StartSessionArgs): Promise<string> {
// Sanitize all input parameters
const sanitizedArgs = {
appUrl: sanitizeUrlParam(args.appUrl),
desiredPlatform: sanitizeUrlParam(args.desiredPlatform),
desiredPhone: sanitizeUrlParam(args.desiredPhone),
desiredPlatformVersion: sanitizeUrlParam(args.desiredPlatformVersion),
};

// Get app hash ID and format phone name
const appHashedId = sanitizedArgs.appUrl.split("bs://").pop();
const desiredPhoneWithSpaces = sanitizedArgs.desiredPhone.replace(
/\s+/g,
"+",
const { appPath, desiredPlatform, desiredPhone } = args;
let { desiredPlatformVersion } = args;

const data = await getAppLiveData();
const allDevices: DeviceEntry[] = data.mobile.flatMap((group: any) =>
group.devices.map((dev: any) => ({ ...dev, os: group.os })),
);

desiredPlatformVersion = resolvePlatformVersion(
allDevices,
desiredPlatform,
desiredPlatformVersion,
);

const filteredDevices = filterDevicesByPlatformAndVersion(
allDevices,
desiredPlatform,
desiredPlatformVersion,
);

const matches = await fuzzySearchDevices(filteredDevices, desiredPhone);

const selectedDevice = validateAndSelectDevice(
matches,
desiredPhone,
desiredPlatform,
desiredPlatformVersion,
);

const { app_url } = await uploadApp(appPath);

validateAppUrl(app_url);

const launchUrl = constructLaunchUrl(
app_url,
selectedDevice,
desiredPlatform,
desiredPlatformVersion,
);

openBrowser(launchUrl);

return launchUrl;
}

/**
* Resolves the platform version based on the desired platform and version.
* @param allDevices - The list of all devices.
* @param desiredPlatform - The desired platform (android or ios).
* @param desiredPlatformVersion - The desired platform version.
* @returns The resolved platform version.
* @throws Will throw an error if the platform version is not valid.
*/
function resolvePlatformVersion(
allDevices: DeviceEntry[],
desiredPlatform: string,
desiredPlatformVersion: string,
): string {
if (
desiredPlatformVersion === "latest" ||
desiredPlatformVersion === "oldest"
) {
const filtered = allDevices.filter((d) => d.os === desiredPlatform);
filtered.sort((a, b) => {
const versionA = parseFloat(a.os_version);
const versionB = parseFloat(b.os_version);
return desiredPlatformVersion === "latest"
? versionB - versionA
: versionA - versionB;
});

return filtered[0].os_version;
}
return desiredPlatformVersion;
}

/**
* Filters devices based on the desired platform and version.
* @param allDevices - The list of all devices.
* @param desiredPlatform - The desired platform (android or ios).
* @param desiredPlatformVersion - The desired platform version.
* @returns The filtered list of devices.
* @throws Will throw an error if the platform version is not valid.
*/
function filterDevicesByPlatformAndVersion(
allDevices: DeviceEntry[],
desiredPlatform: string,
desiredPlatformVersion: string,
): DeviceEntry[] {
return allDevices.filter((d) => {
if (d.os !== desiredPlatform) return false;

try {
const versionA = parseFloat(d.os_version);
const versionB = parseFloat(desiredPlatformVersion);
return versionA === versionB;
} catch {
return d.os_version === desiredPlatformVersion;
}
});
}

/**
* Validates the selected device and handles multiple matches.
* @param matches - The list of device matches.
* @param desiredPhone - The desired phone name.
* @param desiredPlatform - The desired platform (android or ios).
* @param desiredPlatformVersion - The desired platform version.
* @returns The selected device entry.
*/
function validateAndSelectDevice(
matches: DeviceEntry[],
desiredPhone: string,
desiredPlatform: string,
desiredPlatformVersion: string,
): DeviceEntry {
if (matches.length === 0) {
throw new Error(
`No devices found matching "${desiredPhone}" for ${desiredPlatform} ${desiredPlatformVersion}`,
);
}

const exactMatch = matches.find(
(d) => d.display_name.toLowerCase() === desiredPhone.toLowerCase(),
);

if (exactMatch) {
return exactMatch;
} else if (matches.length >= 1) {
const names = matches.map((d) => d.display_name).join(", ");
const error_message =
matches.length === 1
? `Alternative device found: ${names}. Would you like to use it?`
: `Multiple devices found: ${names}. Please select one.`;
throw new Error(`${error_message}`);
}

return matches[0];
}

/**
* Validates the app URL.
* @param appUrl - The app URL to validate.
* @throws Will throw an error if the app URL is not valid.
*/
function validateAppUrl(appUrl: string): void {
if (!appUrl.match("bs://")) {
throw new Error("The app path is not a valid BrowserStack app URL.");
}
}

/**
* Constructs the launch URL for the App Live session.
* @param appUrl - The app URL.
* @param device - The selected device entry.
* @param desiredPlatform - The desired platform (android or ios).
* @param desiredPlatformVersion - The desired platform version.
* @returns The constructed launch URL.
*/
function constructLaunchUrl(
appUrl: string,
device: DeviceEntry,
desiredPlatform: string,
desiredPlatformVersion: string,
): string {
const deviceParam = sanitizeUrlParam(
device.display_name.replace(/\s+/g, "+"),
);

// Construct URL with encoded parameters
const params = new URLSearchParams({
os: sanitizedArgs.desiredPlatform,
os_version: sanitizedArgs.desiredPlatformVersion,
app_hashed_id: appHashedId || "",
os: desiredPlatform,
os_version: desiredPlatformVersion,
app_hashed_id: appUrl.split("bs://").pop() || "",
scale_to_fit: "true",
speed: "1",
start: "true",
});

const launchUrl = `https://app-live.browserstack.com/dashboard#${params.toString()}&device=${desiredPhoneWithSpaces}`;
return `https://app-live.browserstack.com/dashboard#${params.toString()}&device=${deviceParam}`;
}

/**
* Opens the launch URL in the default browser.
* @param launchUrl - The URL to open.
* @throws Will throw an error if the browser fails to open.
*/
function openBrowser(launchUrl: string): void {
try {
// Use platform-specific commands with proper escaping
const command =
process.platform === "darwin"
? ["open", launchUrl]
: process.platform === "win32"
? ["cmd", "/c", "start", launchUrl]
: ["xdg-open", launchUrl];

// nosemgrep:javascript.lang.security.detect-child-process.detect-child-process
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dont remove this

const child = childProcess.spawn(command[0], command.slice(1), {
stdio: "ignore",
detached: true,
});

// Handle process errors
child.on("error", (error) => {
logger.error(
`Failed to open browser automatically: ${error}. Please open this URL manually: ${launchUrl}`,
);
});

// Unref the child process to allow the parent to exit
child.unref();

return launchUrl;
} catch (error) {
logger.error(
`Failed to open browser automatically: ${error}. Please open this URL manually: ${launchUrl}`,
);
return launchUrl;
}
}
Loading