diff --git a/src/lib/fuzzy.ts b/src/lib/fuzzy.ts new file mode 100644 index 0000000..43ac023 --- /dev/null +++ b/src/lib/fuzzy.ts @@ -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( + 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; +} + +// 3. The search entrypoint +export function customFuzzySearch( + list: T[], + keys: Array, + 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); +} 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..f460fc3 --- /dev/null +++ b/src/tools/applive-utils/fuzzy-search.ts @@ -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 { + const top_match = customFuzzySearch( + devices, + ["device", "display_name"], + query, + limit, + ); + return top_match; +} diff --git a/src/tools/applive-utils/start-session.ts b/src/tools/applive-utils/start-session.ts index 603b6b7..cc7d534 100644 --- a/src/tools/applive-utils/start-session.ts +++ b/src/tools/applive-utils/start-session.ts @@ -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 { - // 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 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; } } 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/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 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 +});