Skip to content

Feat: Implement fuzzy search and caching for App Live devices #9

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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