From ebc15b1019915dd71e9e1321e11208539824ed31 Mon Sep 17 00:00:00 2001 From: rututaj-browserstack Date: Wed, 30 Apr 2025 20:13:44 +0530 Subject: [PATCH 1/8] Feat: Implement fuzzy search and caching for App Live devices --- package-lock.json | 10 +++ package.json | 1 + src/lib/fuzzy.ts | 39 ++++++++++ src/tools/applive-utils/device-cache.ts | 31 ++++++++ src/tools/applive-utils/fuzzy-search.ts | 20 +++++ src/tools/applive-utils/start-session.ts | 99 +++++++++++++++++++----- src/tools/applive.ts | 11 +-- tests/tools/applive.test.ts | 14 +--- 8 files changed, 185 insertions(+), 40 deletions(-) create mode 100644 src/lib/fuzzy.ts create mode 100644 src/tools/applive-utils/device-cache.ts create mode 100644 src/tools/applive-utils/fuzzy-search.ts diff --git a/package-lock.json b/package-lock.json index f60dad9..e38c122 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "browserstack-local": "^1.5.6", "dotenv": "^16.5.0", "form-data": "^4.0.2", + "fuse.js": "^7.1.0", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "zod": "^3.24.3" @@ -3807,6 +3808,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz", + "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", diff --git a/package.json b/package.json index f9791e3..801f2c1 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "browserstack-local": "^1.5.6", "dotenv": "^16.5.0", "form-data": "^4.0.2", + "fuse.js": "^7.1.0", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "zod": "^3.24.3" diff --git a/src/lib/fuzzy.ts b/src/lib/fuzzy.ts new file mode 100644 index 0000000..ae65994 --- /dev/null +++ b/src/lib/fuzzy.ts @@ -0,0 +1,39 @@ +/** + * Creates a configured Fuse instance for token-based fuzzy search. + * @param list Array of items to search + * @param keys Keys in each item to index + * @param options Optional Fuse.js options overrides + */ +export async function createFuzzySearcher( + list: T[], + keys: Array, + options?: any, +): Promise { + const { default: Fuse } = await import("fuse.js"); + + const defaultOptions = { + keys: keys as string[], + threshold: 0.6, + includeScore: true, + useExtendedSearch: false, + tokenize: true, + matchAllTokens: false, + ...options, + }; + + return new Fuse(list, defaultOptions); +} + +/** + * Performs a fuzzy token search over any list, with dynamic keys and options. + */ +export async function fuzzySearch( + list: T[], + keys: Array, + query: string, + limit: number = 5, + options?: any, +): Promise { + const fuse = await createFuzzySearcher(list, keys, options); + return fuse.search(query, { limit }).map((result: any) => result.item); +} diff --git a/src/tools/applive-utils/device-cache.ts b/src/tools/applive-utils/device-cache.ts new file mode 100644 index 0000000..1b11418 --- /dev/null +++ b/src/tools/applive-utils/device-cache.ts @@ -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 { + 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; +} diff --git a/src/tools/applive-utils/fuzzy-search.ts b/src/tools/applive-utils/fuzzy-search.ts new file mode 100644 index 0000000..03d2dc7 --- /dev/null +++ b/src/tools/applive-utils/fuzzy-search.ts @@ -0,0 +1,20 @@ +import { fuzzySearch } 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 { + const top_match = await fuzzySearch( + devices, + ["device", "display_name"], + query, + limit, + ); + console.error("[fuzzySearchDevices] Top match:", top_match); + return top_match; +} diff --git a/src/tools/applive-utils/start-session.ts b/src/tools/applive-utils/start-session.ts index 603b6b7..9090652 100644 --- a/src/tools/applive-utils/start-session.ts +++ b/src/tools/applive-utils/start-session.ts @@ -1,41 +1,100 @@ 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. + */ export async function startSession(args: StartSessionArgs): Promise { - // 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 })), + ); + + // Exact filter by platform and version + 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 // descending for "latest" + : versionA - versionB; // ascending for specific version + }); + + const requiredVersion = filtered[0].os_version; + + desiredPlatformVersion = requiredVersion; + } + const filtered = allDevices.filter( + (d) => d.os === desiredPlatform && d.os_version === desiredPlatformVersion, + ); + + // Fuzzy match + const matches = await fuzzySearchDevices(filtered, desiredPhone); + + if (matches.length === 0) { + throw new Error( + `No devices found matching "${desiredPhone}" for ${desiredPlatform} ${desiredPlatformVersion} ${JSON.stringify(matches, null, 2)}`, + ); + } + const exactMatch = matches.find( + (d) => d.display_name.toLowerCase() === desiredPhone.toLowerCase(), + ); + + if (exactMatch) { + matches.splice(0, matches.length, exactMatch); // Replace matches with the exact match + } else if (matches.length > 1) { + const names = matches.map((d) => d.display_name).join(", "); + throw new Error( + `Multiple devices found: [${names}]. Select one out of them.`, + ); + } + + const { app_url } = await uploadApp(appPath); + + if (!app_url.match("bs://")) { + throw new Error("The app path is not a valid BrowserStack app URL."); + } + + const device = matches[0]; + 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: app_url.split("bs://").pop() || "", scale_to_fit: "true", speed: "1", start: "true", }); - - const launchUrl = `https://app-live.browserstack.com/dashboard#${params.toString()}&device=${desiredPhoneWithSpaces}`; + const launchUrl = `https://app-live.browserstack.com/dashboard#${params.toString()}&device=${deviceParam}`; try { // Use platform-specific commands with proper escaping diff --git a/src/tools/applive.ts b/src/tools/applive.ts index 1e3c6ca..a7db8be 100644 --- a/src/tools/applive.ts +++ b/src/tools/applive.ts @@ -2,7 +2,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import fs from "fs"; -import { uploadApp } from "./applive-utils/upload-app"; import { startSession } from "./applive-utils/start-session"; import logger from "../logger"; @@ -45,14 +44,8 @@ export async function startAppLiveSession(args: { throw new Error("The app path does not exist or is not readable."); } - const { app_url } = await uploadApp(args.appPath); - - if (!app_url.match("bs://")) { - throw new Error("The app path is not a valid BrowserStack app URL."); - } - const launchUrl = await startSession({ - appUrl: app_url, + appPath: args.appPath, desiredPlatform: args.desiredPlatform as "android" | "ios", desiredPhone: args.desiredPhone, desiredPlatformVersion: args.desiredPlatformVersion, @@ -81,7 +74,7 @@ export default function addAppLiveTools(server: McpServer) { desiredPlatformVersion: z .string() .describe( - "The platform version to run the app on. Example: '12.0' for Android devices or '16.0' for iOS devices", + "Specifies the platform version to run the app on. For example, use '12.0' for Android or '16.0' for iOS. If the user says 'latest', 'newest', or similar, normalize it to 'latest'. Likewise, convert terms like 'earliest' or 'oldest' to 'oldest'.", ), desiredPlatform: z .enum(["android", "ios"]) diff --git a/tests/tools/applive.test.ts b/tests/tools/applive.test.ts index a4d6d46..f36d730 100644 --- a/tests/tools/applive.test.ts +++ b/tests/tools/applive.test.ts @@ -43,23 +43,20 @@ describe('startAppLiveSession', () => { it('should successfully start an Android app live session', async () => { const result = await startAppLiveSession(validAndroidArgs); - expect(uploadApp).toHaveBeenCalledWith(validAndroidArgs.appPath); expect(startSession).toHaveBeenCalledWith({ - appUrl: 'bs://123456', + appPath: '/path/to/app.apk', desiredPlatform: 'android', desiredPhone: validAndroidArgs.desiredPhone, desiredPlatformVersion: validAndroidArgs.desiredPlatformVersion }); expect(result.content[0].text).toContain('Successfully started a session'); - expect(result.content[0].text).toContain('https://app-live.browserstack.com/123456'); }); it('should successfully start an iOS app live session', async () => { const result = await startAppLiveSession(validiOSArgs); - expect(uploadApp).toHaveBeenCalledWith(validiOSArgs.appPath); expect(startSession).toHaveBeenCalledWith({ - appUrl: 'bs://123456', + appPath: '/path/to/app.ipa', desiredPlatform: 'ios', desiredPhone: validiOSArgs.desiredPhone, desiredPlatformVersion: validiOSArgs.desiredPlatformVersion @@ -106,13 +103,8 @@ describe('startAppLiveSession', () => { expect(logger.error).toHaveBeenCalled(); }); - it('should handle upload failure', async () => { - (uploadApp as jest.Mock).mockRejectedValue(new Error('Upload failed')); - await expect(startAppLiveSession(validAndroidArgs)).rejects.toThrow('Upload failed'); - }); - it('should handle session start failure', async () => { (startSession as jest.Mock).mockRejectedValue(new Error('Session start failed')); await expect(startAppLiveSession(validAndroidArgs)).rejects.toThrow('Session start failed'); }); -}); \ No newline at end of file +}); From 9c3ad66de6f8defd94e5617428c96a8a97515135 Mon Sep 17 00:00:00 2001 From: rututaj-browserstack Date: Fri, 2 May 2025 12:23:52 +0530 Subject: [PATCH 2/8] Fix: Simplify error message for no device matches in startSession function --- src/tools/applive-utils/start-session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/applive-utils/start-session.ts b/src/tools/applive-utils/start-session.ts index 9090652..daf8a97 100644 --- a/src/tools/applive-utils/start-session.ts +++ b/src/tools/applive-utils/start-session.ts @@ -59,7 +59,7 @@ export async function startSession(args: StartSessionArgs): Promise { if (matches.length === 0) { throw new Error( - `No devices found matching "${desiredPhone}" for ${desiredPlatform} ${desiredPlatformVersion} ${JSON.stringify(matches, null, 2)}`, + `No devices found matching "${desiredPhone}" for ${desiredPlatform} ${desiredPlatformVersion}`, ); } const exactMatch = matches.find( From b72357359d0b6d421af6ba7e5dc63a2895f1113a Mon Sep 17 00:00:00 2001 From: rututaj-browserstack Date: Fri, 2 May 2025 12:32:29 +0530 Subject: [PATCH 3/8] Fix: Improve error message for multiple device matches in startSession function --- src/tools/applive-utils/start-session.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/applive-utils/start-session.ts b/src/tools/applive-utils/start-session.ts index daf8a97..d836720 100644 --- a/src/tools/applive-utils/start-session.ts +++ b/src/tools/applive-utils/start-session.ts @@ -68,10 +68,10 @@ export async function startSession(args: StartSessionArgs): Promise { if (exactMatch) { matches.splice(0, matches.length, exactMatch); // Replace matches with the exact match - } else if (matches.length > 1) { + } else if (matches.length >= 1) { const names = matches.map((d) => d.display_name).join(", "); throw new Error( - `Multiple devices found: [${names}]. Select one out of them.`, + `Multiple/Alternative devices found: [${names}]. Select one out of them.`, ); } From c2f067043d52c6eaacc23356c7ffa3ef9b4f25ad Mon Sep 17 00:00:00 2001 From: rututaj-browserstack Date: Fri, 2 May 2025 13:46:14 +0530 Subject: [PATCH 4/8] Refactor: Remove Fuse.js dependency and enhance fuzzy search implementation --- package-lock.json | 10 --- package.json | 1 - src/lib/fuzzy.ts | 85 +++++++++++++++--------- src/tools/applive-utils/fuzzy-search.ts | 5 +- src/tools/applive-utils/start-session.ts | 24 +++++-- 5 files changed, 75 insertions(+), 50 deletions(-) diff --git a/package-lock.json b/package-lock.json index e38c122..f60dad9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "browserstack-local": "^1.5.6", "dotenv": "^16.5.0", "form-data": "^4.0.2", - "fuse.js": "^7.1.0", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "zod": "^3.24.3" @@ -3808,15 +3807,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fuse.js": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz", - "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=10" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", diff --git a/package.json b/package.json index 801f2c1..f9791e3 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "browserstack-local": "^1.5.6", "dotenv": "^16.5.0", "form-data": "^4.0.2", - "fuse.js": "^7.1.0", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "zod": "^3.24.3" diff --git a/src/lib/fuzzy.ts b/src/lib/fuzzy.ts index ae65994..43ac023 100644 --- a/src/lib/fuzzy.ts +++ b/src/lib/fuzzy.ts @@ -1,39 +1,64 @@ -/** - * Creates a configured Fuse instance for token-based fuzzy search. - * @param list Array of items to search - * @param keys Keys in each item to index - * @param options Optional Fuse.js options overrides - */ -export async function createFuzzySearcher( - list: T[], - keys: Array, - options?: any, -): Promise { - const { default: Fuse } = await import("fuse.js"); +// 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; - const defaultOptions = { - keys: keys as string[], - threshold: 0.6, - includeScore: true, - useExtendedSearch: false, - tokenize: true, - matchAllTokens: false, - ...options, - }; + 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]; +} - return new Fuse(list, defaultOptions); +// 2. Score one item against the query (normalized score 0–1) +function scoreItem( + item: T, + keys: Array, + 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; } -/** - * Performs a fuzzy token search over any list, with dynamic keys and options. - */ -export async function fuzzySearch( +// 3. The search entrypoint +export function customFuzzySearch( list: T[], keys: Array, query: string, limit: number = 5, - options?: any, -): Promise { - const fuse = await createFuzzySearcher(list, keys, options); - return fuse.search(query, { limit }).map((result: any) => result.item); + 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); } diff --git a/src/tools/applive-utils/fuzzy-search.ts b/src/tools/applive-utils/fuzzy-search.ts index 03d2dc7..f460fc3 100644 --- a/src/tools/applive-utils/fuzzy-search.ts +++ b/src/tools/applive-utils/fuzzy-search.ts @@ -1,4 +1,4 @@ -import { fuzzySearch } from "../../lib/fuzzy"; +import { customFuzzySearch } from "../../lib/fuzzy"; import { DeviceEntry } from "./start-session"; /** @@ -9,12 +9,11 @@ export async function fuzzySearchDevices( query: string, limit: number = 5, ): Promise { - const top_match = await fuzzySearch( + const top_match = customFuzzySearch( devices, ["device", "display_name"], query, limit, ); - console.error("[fuzzySearchDevices] Top match:", top_match); return top_match; } diff --git a/src/tools/applive-utils/start-session.ts b/src/tools/applive-utils/start-session.ts index d836720..83f3223 100644 --- a/src/tools/applive-utils/start-session.ts +++ b/src/tools/applive-utils/start-session.ts @@ -50,9 +50,19 @@ export async function startSession(args: StartSessionArgs): Promise { desiredPlatformVersion = requiredVersion; } - const filtered = allDevices.filter( - (d) => d.os === desiredPlatform && d.os_version === desiredPlatformVersion, - ); + const filtered = allDevices.filter((d) => { + if (d.os !== desiredPlatform) return false; + + // Attempt to compare as floats + try { + const versionA = parseFloat(d.os_version); + const versionB = parseFloat(desiredPlatformVersion); + return versionA === versionB; + } catch { + // Fallback to exact string match if parsing fails + return d.os_version === desiredPlatformVersion; + } + }); // Fuzzy match const matches = await fuzzySearchDevices(filtered, desiredPhone); @@ -70,9 +80,11 @@ export async function startSession(args: StartSessionArgs): Promise { matches.splice(0, matches.length, exactMatch); // Replace matches with the exact match } else if (matches.length >= 1) { const names = matches.map((d) => d.display_name).join(", "); - throw new Error( - `Multiple/Alternative devices found: [${names}]. Select one out of them.`, - ); + 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}`); } const { app_url } = await uploadApp(appPath); From e88da457bc6356b0fda6eef15e4d57d2965e87c7 Mon Sep 17 00:00:00 2001 From: rututaj-browserstack Date: Fri, 2 May 2025 17:30:04 +0530 Subject: [PATCH 5/8] Refactor: Enhance startSession function with improved device filtering and validation --- src/tools/applive-utils/start-session.ts | 153 +++++++++++++++++++---- 1 file changed, 126 insertions(+), 27 deletions(-) diff --git a/src/tools/applive-utils/start-session.ts b/src/tools/applive-utils/start-session.ts index 83f3223..84357be 100644 --- a/src/tools/applive-utils/start-session.ts +++ b/src/tools/applive-utils/start-session.ts @@ -22,6 +22,9 @@ interface StartSessionArgs { /** * 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 { const { appPath, desiredPlatform, desiredPhone } = args; @@ -32,52 +35,126 @@ export async function startSession(args: StartSessionArgs): Promise { group.devices.map((dev: any) => ({ ...dev, os: group.os })), ); - // Exact filter by platform and version + 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" + 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 // descending for "latest" - : versionA - versionB; // ascending for specific version + ? versionB - versionA + : versionA - versionB; }); - const requiredVersion = filtered[0].os_version; - - desiredPlatformVersion = requiredVersion; + return filtered[0].os_version; } - const filtered = allDevices.filter((d) => { + 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; - // Attempt to compare as floats try { const versionA = parseFloat(d.os_version); const versionB = parseFloat(desiredPlatformVersion); return versionA === versionB; } catch { - // Fallback to exact string match if parsing fails return d.os_version === desiredPlatformVersion; } }); +} - // Fuzzy match - const matches = await fuzzySearchDevices(filtered, desiredPhone); - +/** + * 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) { - matches.splice(0, matches.length, exactMatch); // Replace matches with the exact match + return exactMatch; } else if (matches.length >= 1) { const names = matches.map((d) => d.display_name).join(", "); const error_message = @@ -87,13 +164,34 @@ export async function startSession(args: StartSessionArgs): Promise { throw new Error(`${error_message}`); } - const { app_url } = await uploadApp(appPath); + return matches[0]; +} - if (!app_url.match("bs://")) { +/** + * 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."); } +} - const device = matches[0]; +/** + * 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, "+"), ); @@ -101,15 +199,22 @@ export async function startSession(args: StartSessionArgs): Promise { const params = new URLSearchParams({ os: desiredPlatform, os_version: desiredPlatformVersion, - app_hashed_id: app_url.split("bs://").pop() || "", + 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=${deviceParam}`; + 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] @@ -117,27 +222,21 @@ export async function startSession(args: StartSessionArgs): Promise { ? ["cmd", "/c", "start", launchUrl] : ["xdg-open", launchUrl]; - // nosemgrep:javascript.lang.security.detect-child-process.detect-child-process 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; } } From c497507220d7900b9f0e1c04baba02bfc9962242 Mon Sep 17 00:00:00 2001 From: rututaj-browserstack Date: Fri, 2 May 2025 18:29:19 +0530 Subject: [PATCH 6/8] Feat: Add iOS app live session testing and improve app download logic --- tests/manual/manual_tests.sh | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/tests/manual/manual_tests.sh b/tests/manual/manual_tests.sh index d1d92ef..695e713 100755 --- a/tests/manual/manual_tests.sh +++ b/tests/manual/manual_tests.sh @@ -11,11 +11,18 @@ function download_apps() { # Skip if Calculator.apk already exists if [ -f "Calculator.apk" ]; then echo "Calculator.apk already exists, skipping download" - return + else + local android_app_url="https://www.browserstack.com/app-automate/sample-apps/android/Calculator.apk" + curl -o Calculator.apk $android_app_url fi - local app_url="https://www.browserstack.com/app-automate/sample-apps/android/Calculator.apk" - curl -o Calculator.apk $app_url + # Skip if BrowserStack-SampleApp.ipa already exists + if [ -f "BrowserStack-SampleApp.ipa" ]; then + echo "BrowserStack-SampleApp.ipa already exists, skipping download" + else + local ios_app_url="https://www.browserstack.com/app-automate/sample-apps/ios/BrowserStack-SampleApp.ipa" + curl -o BrowserStack-SampleApp.ipa $ios_app_url + fi } function ensure_deps() { @@ -86,6 +93,9 @@ function testO11YValidBuild() { function testAppLive() { echo "Testing App Live..." testAppLiveAndroid + # # echo "Sleeping for 5 seconds before starting iOS test..." + # # sleep 5 + # testAppLiveiOS } function testAppLiveAndroid() { @@ -101,6 +111,19 @@ function testAppLiveAndroid() { exit 1 fi } +function testAppLiveiOS() { + realpath=$(realpath "./BrowserStack-SampleApp.ipa") + response=$(npx @modelcontextprotocol/inspector -e BROWSERSTACK_USERNAME=$_BROWSERSTACK_USERNAME -e BROWSERSTACK_ACCESS_KEY=$_BROWSERSTACK_ACCESS_KEY --cli node dist/index.js --method tools/call --tool-name runAppLiveSession --tool-arg desiredPlatform='ios' --tool-arg desiredPlatformVersion='17.0' --tool-arg appPath="$realpath" --tool-arg desiredPhone='iPhone 15 Pro Max') + + echo "Response was: $response" + if echo "$response" | grep -q "Successfully started a session"; then + log_success "Successfully started iOS app live session" + else + log_failure "Failed to start iOS app live session" + echo "Response was: $response" + exit 1 + fi +} function testBrowserLive() { echo "Testing Browser Live..." @@ -170,8 +193,8 @@ echo -e "\n\t Starting manual tests...\n\n" # testO11Y # sleep 5 -# testAppLive +testAppLive # sleep 5 # testBrowserLive sleep 5 -testAccessibility +# testAccessibility From 33e2ed9018deb0a75cc9db90799e11dbacec3ec8 Mon Sep 17 00:00:00 2001 From: rututaj-browserstack Date: Fri, 2 May 2025 18:31:20 +0530 Subject: [PATCH 7/8] Fix: Add security comment to openBrowser function to address child process detection --- src/tools/applive-utils/start-session.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tools/applive-utils/start-session.ts b/src/tools/applive-utils/start-session.ts index 84357be..571fc3c 100644 --- a/src/tools/applive-utils/start-session.ts +++ b/src/tools/applive-utils/start-session.ts @@ -221,6 +221,8 @@ function openBrowser(launchUrl: string): void { : process.platform === "win32" ? ["cmd", "/c", "start", launchUrl] : ["xdg-open", launchUrl]; + + // nosemgrep:javascript.lang.security.detect-child-process.detect-child-process const child = childProcess.spawn(command[0], command.slice(1), { stdio: "ignore", From a264bb2c574bba7e238151dc182f82a51aa57797 Mon Sep 17 00:00:00 2001 From: rututaj-browserstack Date: Fri, 2 May 2025 18:36:05 +0530 Subject: [PATCH 8/8] Fix: Remove unnecessary line --- src/tools/applive-utils/start-session.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tools/applive-utils/start-session.ts b/src/tools/applive-utils/start-session.ts index 571fc3c..cc7d534 100644 --- a/src/tools/applive-utils/start-session.ts +++ b/src/tools/applive-utils/start-session.ts @@ -223,7 +223,6 @@ function openBrowser(launchUrl: string): void { : ["xdg-open", launchUrl]; // nosemgrep:javascript.lang.security.detect-child-process.detect-child-process - const child = childProcess.spawn(command[0], command.slice(1), { stdio: "ignore", detached: true,