Skip to content

Commit bc1aada

Browse files
Feat: Implement fuzzy search and caching for App Live devices (#9)
* Feat: Implement fuzzy search and caching for App Live devices * Fix: Simplify error message for no device matches in startSession function * Fix: Improve error message for multiple device matches in startSession function * Refactor: Remove Fuse.js dependency and enhance fuzzy search implementation * Refactor: Enhance startSession function with improved device filtering and validation * Feat: Add iOS app live session testing and improve app download logic * Fix: Add security comment to openBrowser function to address child process detection * Fix: Remove unnecessary line
1 parent a6bda7d commit bc1aada

File tree

7 files changed

+344
-51
lines changed

7 files changed

+344
-51
lines changed

src/lib/fuzzy.ts

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// 1. Compute Levenshtein distance between two strings
2+
function levenshtein(a: string, b: string): number {
3+
const dp: number[][] = Array(a.length + 1)
4+
.fill(0)
5+
.map(() => Array(b.length + 1).fill(0));
6+
for (let i = 0; i <= a.length; i++) dp[i][0] = i;
7+
for (let j = 0; j <= b.length; j++) dp[0][j] = j;
8+
9+
for (let i = 1; i <= a.length; i++) {
10+
for (let j = 1; j <= b.length; j++) {
11+
dp[i][j] = Math.min(
12+
dp[i - 1][j] + 1, // deletion
13+
dp[i][j - 1] + 1, // insertion
14+
dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1), // substitution
15+
);
16+
}
17+
}
18+
return dp[a.length][b.length];
19+
}
20+
21+
// 2. Score one item against the query (normalized score 0–1)
22+
function scoreItem<T>(
23+
item: T,
24+
keys: Array<keyof T | string>,
25+
queryTokens: string[],
26+
): number {
27+
let best = Infinity;
28+
for (const key of keys) {
29+
const field = String(item[key as keyof T] ?? "").toLowerCase();
30+
const fieldTokens = field.split(/\s+/);
31+
const tokenScores = queryTokens.map((qt) => {
32+
const minNormalized = Math.min(
33+
...fieldTokens.map((ft) => {
34+
const rawDist = levenshtein(ft, qt);
35+
const maxLen = Math.max(ft.length, qt.length);
36+
return maxLen === 0 ? 0 : rawDist / maxLen; // normalized 0–1
37+
}),
38+
);
39+
return minNormalized;
40+
});
41+
const avg = tokenScores.reduce((a, b) => a + b, 0) / tokenScores.length;
42+
best = Math.min(best, avg);
43+
}
44+
return best;
45+
}
46+
47+
// 3. The search entrypoint
48+
export function customFuzzySearch<T>(
49+
list: T[],
50+
keys: Array<keyof T | string>,
51+
query: string,
52+
limit: number = 5,
53+
maxDistance: number = 0.6,
54+
): T[] {
55+
const q = query.toLowerCase().trim();
56+
const queryTokens = q.split(/\s+/);
57+
58+
return list
59+
.map((item) => ({ item, score: scoreItem(item, keys, queryTokens) }))
60+
.filter((x) => x.score <= maxDistance)
61+
.sort((a, b) => a.score - b.score)
62+
.slice(0, limit)
63+
.map((x) => x.item);
64+
}
+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import fs from "fs";
2+
import os from "os";
3+
import path from "path";
4+
5+
const CACHE_DIR = path.join(os.homedir(), ".browserstack", "app_live_cache");
6+
const CACHE_FILE = path.join(CACHE_DIR, "app_live.json");
7+
const TTL_MS = 24 * 60 * 60 * 1000; // 1 day
8+
9+
/**
10+
* Fetches and caches the App Live devices JSON with a 1-day TTL.
11+
*/
12+
export async function getAppLiveData(): Promise<any> {
13+
if (!fs.existsSync(CACHE_DIR)) {
14+
fs.mkdirSync(CACHE_DIR, { recursive: true });
15+
}
16+
if (fs.existsSync(CACHE_FILE)) {
17+
const stats = fs.statSync(CACHE_FILE);
18+
if (Date.now() - stats.mtimeMs < TTL_MS) {
19+
return JSON.parse(fs.readFileSync(CACHE_FILE, "utf8"));
20+
}
21+
}
22+
const response = await fetch(
23+
"https://www.browserstack.com/list-of-browsers-and-platforms/app_live.json",
24+
);
25+
if (!response.ok) {
26+
throw new Error(`Failed to fetch app live list: ${response.statusText}`);
27+
}
28+
const data = await response.json();
29+
fs.writeFileSync(CACHE_FILE, JSON.stringify(data), "utf8");
30+
return data;
31+
}
+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { customFuzzySearch } from "../../lib/fuzzy";
2+
import { DeviceEntry } from "./start-session";
3+
4+
/**
5+
* Fuzzy searches App Live device entries by name.
6+
*/
7+
export async function fuzzySearchDevices(
8+
devices: DeviceEntry[],
9+
query: string,
10+
limit: number = 5,
11+
): Promise<DeviceEntry[]> {
12+
const top_match = customFuzzySearch(
13+
devices,
14+
["device", "display_name"],
15+
query,
16+
limit,
17+
);
18+
return top_match;
19+
}
+197-26
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,243 @@
11
import childProcess from "child_process";
22
import logger from "../../logger";
3+
import { getAppLiveData } from "./device-cache";
4+
import { fuzzySearchDevices } from "./fuzzy-search";
35
import { sanitizeUrlParam } from "../../lib/utils";
6+
import { uploadApp } from "./upload-app";
7+
8+
export interface DeviceEntry {
9+
device: string;
10+
display_name: string;
11+
os: string;
12+
os_version: string;
13+
real_mobile: boolean;
14+
}
415

516
interface StartSessionArgs {
6-
appUrl: string;
17+
appPath: string;
718
desiredPlatform: "android" | "ios";
819
desiredPhone: string;
920
desiredPlatformVersion: string;
1021
}
1122

23+
/**
24+
* Starts an App Live session after filtering, fuzzy matching, and launching.
25+
* @param args - The arguments for starting the session.
26+
* @returns The launch URL for the session.
27+
* @throws Will throw an error if no devices are found or if the app URL is invalid.
28+
*/
1229
export async function startSession(args: StartSessionArgs): Promise<string> {
13-
// Sanitize all input parameters
14-
const sanitizedArgs = {
15-
appUrl: sanitizeUrlParam(args.appUrl),
16-
desiredPlatform: sanitizeUrlParam(args.desiredPlatform),
17-
desiredPhone: sanitizeUrlParam(args.desiredPhone),
18-
desiredPlatformVersion: sanitizeUrlParam(args.desiredPlatformVersion),
19-
};
20-
21-
// Get app hash ID and format phone name
22-
const appHashedId = sanitizedArgs.appUrl.split("bs://").pop();
23-
const desiredPhoneWithSpaces = sanitizedArgs.desiredPhone.replace(
24-
/\s+/g,
25-
"+",
30+
const { appPath, desiredPlatform, desiredPhone } = args;
31+
let { desiredPlatformVersion } = args;
32+
33+
const data = await getAppLiveData();
34+
const allDevices: DeviceEntry[] = data.mobile.flatMap((group: any) =>
35+
group.devices.map((dev: any) => ({ ...dev, os: group.os })),
36+
);
37+
38+
desiredPlatformVersion = resolvePlatformVersion(
39+
allDevices,
40+
desiredPlatform,
41+
desiredPlatformVersion,
42+
);
43+
44+
const filteredDevices = filterDevicesByPlatformAndVersion(
45+
allDevices,
46+
desiredPlatform,
47+
desiredPlatformVersion,
48+
);
49+
50+
const matches = await fuzzySearchDevices(filteredDevices, desiredPhone);
51+
52+
const selectedDevice = validateAndSelectDevice(
53+
matches,
54+
desiredPhone,
55+
desiredPlatform,
56+
desiredPlatformVersion,
57+
);
58+
59+
const { app_url } = await uploadApp(appPath);
60+
61+
validateAppUrl(app_url);
62+
63+
const launchUrl = constructLaunchUrl(
64+
app_url,
65+
selectedDevice,
66+
desiredPlatform,
67+
desiredPlatformVersion,
68+
);
69+
70+
openBrowser(launchUrl);
71+
72+
return launchUrl;
73+
}
74+
75+
/**
76+
* Resolves the platform version based on the desired platform and version.
77+
* @param allDevices - The list of all devices.
78+
* @param desiredPlatform - The desired platform (android or ios).
79+
* @param desiredPlatformVersion - The desired platform version.
80+
* @returns The resolved platform version.
81+
* @throws Will throw an error if the platform version is not valid.
82+
*/
83+
function resolvePlatformVersion(
84+
allDevices: DeviceEntry[],
85+
desiredPlatform: string,
86+
desiredPlatformVersion: string,
87+
): string {
88+
if (
89+
desiredPlatformVersion === "latest" ||
90+
desiredPlatformVersion === "oldest"
91+
) {
92+
const filtered = allDevices.filter((d) => d.os === desiredPlatform);
93+
filtered.sort((a, b) => {
94+
const versionA = parseFloat(a.os_version);
95+
const versionB = parseFloat(b.os_version);
96+
return desiredPlatformVersion === "latest"
97+
? versionB - versionA
98+
: versionA - versionB;
99+
});
100+
101+
return filtered[0].os_version;
102+
}
103+
return desiredPlatformVersion;
104+
}
105+
106+
/**
107+
* Filters devices based on the desired platform and version.
108+
* @param allDevices - The list of all devices.
109+
* @param desiredPlatform - The desired platform (android or ios).
110+
* @param desiredPlatformVersion - The desired platform version.
111+
* @returns The filtered list of devices.
112+
* @throws Will throw an error if the platform version is not valid.
113+
*/
114+
function filterDevicesByPlatformAndVersion(
115+
allDevices: DeviceEntry[],
116+
desiredPlatform: string,
117+
desiredPlatformVersion: string,
118+
): DeviceEntry[] {
119+
return allDevices.filter((d) => {
120+
if (d.os !== desiredPlatform) return false;
121+
122+
try {
123+
const versionA = parseFloat(d.os_version);
124+
const versionB = parseFloat(desiredPlatformVersion);
125+
return versionA === versionB;
126+
} catch {
127+
return d.os_version === desiredPlatformVersion;
128+
}
129+
});
130+
}
131+
132+
/**
133+
* Validates the selected device and handles multiple matches.
134+
* @param matches - The list of device matches.
135+
* @param desiredPhone - The desired phone name.
136+
* @param desiredPlatform - The desired platform (android or ios).
137+
* @param desiredPlatformVersion - The desired platform version.
138+
* @returns The selected device entry.
139+
*/
140+
function validateAndSelectDevice(
141+
matches: DeviceEntry[],
142+
desiredPhone: string,
143+
desiredPlatform: string,
144+
desiredPlatformVersion: string,
145+
): DeviceEntry {
146+
if (matches.length === 0) {
147+
throw new Error(
148+
`No devices found matching "${desiredPhone}" for ${desiredPlatform} ${desiredPlatformVersion}`,
149+
);
150+
}
151+
152+
const exactMatch = matches.find(
153+
(d) => d.display_name.toLowerCase() === desiredPhone.toLowerCase(),
154+
);
155+
156+
if (exactMatch) {
157+
return exactMatch;
158+
} else if (matches.length >= 1) {
159+
const names = matches.map((d) => d.display_name).join(", ");
160+
const error_message =
161+
matches.length === 1
162+
? `Alternative device found: ${names}. Would you like to use it?`
163+
: `Multiple devices found: ${names}. Please select one.`;
164+
throw new Error(`${error_message}`);
165+
}
166+
167+
return matches[0];
168+
}
169+
170+
/**
171+
* Validates the app URL.
172+
* @param appUrl - The app URL to validate.
173+
* @throws Will throw an error if the app URL is not valid.
174+
*/
175+
function validateAppUrl(appUrl: string): void {
176+
if (!appUrl.match("bs://")) {
177+
throw new Error("The app path is not a valid BrowserStack app URL.");
178+
}
179+
}
180+
181+
/**
182+
* Constructs the launch URL for the App Live session.
183+
* @param appUrl - The app URL.
184+
* @param device - The selected device entry.
185+
* @param desiredPlatform - The desired platform (android or ios).
186+
* @param desiredPlatformVersion - The desired platform version.
187+
* @returns The constructed launch URL.
188+
*/
189+
function constructLaunchUrl(
190+
appUrl: string,
191+
device: DeviceEntry,
192+
desiredPlatform: string,
193+
desiredPlatformVersion: string,
194+
): string {
195+
const deviceParam = sanitizeUrlParam(
196+
device.display_name.replace(/\s+/g, "+"),
26197
);
27198

28-
// Construct URL with encoded parameters
29199
const params = new URLSearchParams({
30-
os: sanitizedArgs.desiredPlatform,
31-
os_version: sanitizedArgs.desiredPlatformVersion,
32-
app_hashed_id: appHashedId || "",
200+
os: desiredPlatform,
201+
os_version: desiredPlatformVersion,
202+
app_hashed_id: appUrl.split("bs://").pop() || "",
33203
scale_to_fit: "true",
34204
speed: "1",
35205
start: "true",
36206
});
37207

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

211+
/**
212+
* Opens the launch URL in the default browser.
213+
* @param launchUrl - The URL to open.
214+
* @throws Will throw an error if the browser fails to open.
215+
*/
216+
function openBrowser(launchUrl: string): void {
40217
try {
41-
// Use platform-specific commands with proper escaping
42218
const command =
43219
process.platform === "darwin"
44220
? ["open", launchUrl]
45221
: process.platform === "win32"
46222
? ["cmd", "/c", "start", launchUrl]
47223
: ["xdg-open", launchUrl];
48-
224+
49225
// nosemgrep:javascript.lang.security.detect-child-process.detect-child-process
50226
const child = childProcess.spawn(command[0], command.slice(1), {
51227
stdio: "ignore",
52228
detached: true,
53229
});
54230

55-
// Handle process errors
56231
child.on("error", (error) => {
57232
logger.error(
58233
`Failed to open browser automatically: ${error}. Please open this URL manually: ${launchUrl}`,
59234
);
60235
});
61236

62-
// Unref the child process to allow the parent to exit
63237
child.unref();
64-
65-
return launchUrl;
66238
} catch (error) {
67239
logger.error(
68240
`Failed to open browser automatically: ${error}. Please open this URL manually: ${launchUrl}`,
69241
);
70-
return launchUrl;
71242
}
72243
}

0 commit comments

Comments
 (0)