-
Notifications
You must be signed in to change notification settings - Fork 18
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
Merged
pulkitsharma07
merged 8 commits into
browserstack:main
from
ruturaj-browserstack:app-live-device-name-validation
May 2, 2025
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
ebc15b1
Feat: Implement fuzzy search and caching for App Live devices
ruturaj-browserstack 9c3ad66
Fix: Simplify error message for no device matches in startSession fun…
ruturaj-browserstack b723573
Fix: Improve error message for multiple device matches in startSessio…
ruturaj-browserstack c2f0670
Refactor: Remove Fuse.js dependency and enhance fuzzy search implemen…
ruturaj-browserstack e88da45
Refactor: Enhance startSession function with improved device filterin…
ruturaj-browserstack c497507
Feat: Add iOS app live session testing and improve app download logic
ruturaj-browserstack 33e2ed9
Fix: Add security comment to openBrowser function to address child pr…
ruturaj-browserstack a264bb2
Fix: Remove unnecessary line
ruturaj-browserstack File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.