Skip to content

Commit ebc15b1

Browse files
Feat: Implement fuzzy search and caching for App Live devices
1 parent 19ad3ef commit ebc15b1

File tree

8 files changed

+185
-40
lines changed

8 files changed

+185
-40
lines changed

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"browserstack-local": "^1.5.6",
4040
"dotenv": "^16.5.0",
4141
"form-data": "^4.0.2",
42+
"fuse.js": "^7.1.0",
4243
"pino": "^9.6.0",
4344
"pino-pretty": "^13.0.0",
4445
"zod": "^3.24.3"

src/lib/fuzzy.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Creates a configured Fuse instance for token-based fuzzy search.
3+
* @param list Array of items to search
4+
* @param keys Keys in each item to index
5+
* @param options Optional Fuse.js options overrides
6+
*/
7+
export async function createFuzzySearcher<T>(
8+
list: T[],
9+
keys: Array<keyof T | string>,
10+
options?: any,
11+
): Promise<any> {
12+
const { default: Fuse } = await import("fuse.js");
13+
14+
const defaultOptions = {
15+
keys: keys as string[],
16+
threshold: 0.6,
17+
includeScore: true,
18+
useExtendedSearch: false,
19+
tokenize: true,
20+
matchAllTokens: false,
21+
...options,
22+
};
23+
24+
return new Fuse(list, defaultOptions);
25+
}
26+
27+
/**
28+
* Performs a fuzzy token search over any list, with dynamic keys and options.
29+
*/
30+
export async function fuzzySearch<T>(
31+
list: T[],
32+
keys: Array<keyof T | string>,
33+
query: string,
34+
limit: number = 5,
35+
options?: any,
36+
): Promise<T[]> {
37+
const fuse = await createFuzzySearcher(list, keys, options);
38+
return fuse.search(query, { limit }).map((result: any) => result.item);
39+
}
Lines changed: 31 additions & 0 deletions
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+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { fuzzySearch } 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 = await fuzzySearch(
13+
devices,
14+
["device", "display_name"],
15+
query,
16+
limit,
17+
);
18+
console.error("[fuzzySearchDevices] Top match:", top_match);
19+
return top_match;
20+
}

src/tools/applive-utils/start-session.ts

Lines changed: 79 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,100 @@
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+
*/
1226
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-
"+",
27+
const { appPath, desiredPlatform, desiredPhone } = args;
28+
let { desiredPlatformVersion } = args;
29+
30+
const data = await getAppLiveData();
31+
const allDevices: DeviceEntry[] = data.mobile.flatMap((group: any) =>
32+
group.devices.map((dev: any) => ({ ...dev, os: group.os })),
33+
);
34+
35+
// Exact filter by platform and version
36+
if (
37+
desiredPlatformVersion == "latest" ||
38+
desiredPlatformVersion == "oldest"
39+
) {
40+
const filtered = allDevices.filter((d) => d.os === desiredPlatform);
41+
filtered.sort((a, b) => {
42+
const versionA = parseFloat(a.os_version);
43+
const versionB = parseFloat(b.os_version);
44+
return desiredPlatformVersion === "latest"
45+
? versionB - versionA // descending for "latest"
46+
: versionA - versionB; // ascending for specific version
47+
});
48+
49+
const requiredVersion = filtered[0].os_version;
50+
51+
desiredPlatformVersion = requiredVersion;
52+
}
53+
const filtered = allDevices.filter(
54+
(d) => d.os === desiredPlatform && d.os_version === desiredPlatformVersion,
55+
);
56+
57+
// Fuzzy match
58+
const matches = await fuzzySearchDevices(filtered, desiredPhone);
59+
60+
if (matches.length === 0) {
61+
throw new Error(
62+
`No devices found matching "${desiredPhone}" for ${desiredPlatform} ${desiredPlatformVersion} ${JSON.stringify(matches, null, 2)}`,
63+
);
64+
}
65+
const exactMatch = matches.find(
66+
(d) => d.display_name.toLowerCase() === desiredPhone.toLowerCase(),
67+
);
68+
69+
if (exactMatch) {
70+
matches.splice(0, matches.length, exactMatch); // Replace matches with the exact match
71+
} else if (matches.length > 1) {
72+
const names = matches.map((d) => d.display_name).join(", ");
73+
throw new Error(
74+
`Multiple devices found: [${names}]. Select one out of them.`,
75+
);
76+
}
77+
78+
const { app_url } = await uploadApp(appPath);
79+
80+
if (!app_url.match("bs://")) {
81+
throw new Error("The app path is not a valid BrowserStack app URL.");
82+
}
83+
84+
const device = matches[0];
85+
const deviceParam = sanitizeUrlParam(
86+
device.display_name.replace(/\s+/g, "+"),
2687
);
2788

28-
// Construct URL with encoded parameters
2989
const params = new URLSearchParams({
30-
os: sanitizedArgs.desiredPlatform,
31-
os_version: sanitizedArgs.desiredPlatformVersion,
32-
app_hashed_id: appHashedId || "",
90+
os: desiredPlatform,
91+
os_version: desiredPlatformVersion,
92+
app_hashed_id: app_url.split("bs://").pop() || "",
3393
scale_to_fit: "true",
3494
speed: "1",
3595
start: "true",
3696
});
37-
38-
const launchUrl = `https://app-live.browserstack.com/dashboard#${params.toString()}&device=${desiredPhoneWithSpaces}`;
97+
const launchUrl = `https://app-live.browserstack.com/dashboard#${params.toString()}&device=${deviceParam}`;
3998

4099
try {
41100
// Use platform-specific commands with proper escaping

src/tools/applive.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
import { z } from "zod";
33
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
44
import fs from "fs";
5-
import { uploadApp } from "./applive-utils/upload-app";
65
import { startSession } from "./applive-utils/start-session";
76
import logger from "../logger";
87

@@ -45,14 +44,8 @@ export async function startAppLiveSession(args: {
4544
throw new Error("The app path does not exist or is not readable.");
4645
}
4746

48-
const { app_url } = await uploadApp(args.appPath);
49-
50-
if (!app_url.match("bs://")) {
51-
throw new Error("The app path is not a valid BrowserStack app URL.");
52-
}
53-
5447
const launchUrl = await startSession({
55-
appUrl: app_url,
48+
appPath: args.appPath,
5649
desiredPlatform: args.desiredPlatform as "android" | "ios",
5750
desiredPhone: args.desiredPhone,
5851
desiredPlatformVersion: args.desiredPlatformVersion,
@@ -81,7 +74,7 @@ export default function addAppLiveTools(server: McpServer) {
8174
desiredPlatformVersion: z
8275
.string()
8376
.describe(
84-
"The platform version to run the app on. Example: '12.0' for Android devices or '16.0' for iOS devices",
77+
"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'.",
8578
),
8679
desiredPlatform: z
8780
.enum(["android", "ios"])

tests/tools/applive.test.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,23 +43,20 @@ describe('startAppLiveSession', () => {
4343
it('should successfully start an Android app live session', async () => {
4444
const result = await startAppLiveSession(validAndroidArgs);
4545

46-
expect(uploadApp).toHaveBeenCalledWith(validAndroidArgs.appPath);
4746
expect(startSession).toHaveBeenCalledWith({
48-
appUrl: 'bs://123456',
47+
appPath: '/path/to/app.apk',
4948
desiredPlatform: 'android',
5049
desiredPhone: validAndroidArgs.desiredPhone,
5150
desiredPlatformVersion: validAndroidArgs.desiredPlatformVersion
5251
});
5352
expect(result.content[0].text).toContain('Successfully started a session');
54-
expect(result.content[0].text).toContain('https://app-live.browserstack.com/123456');
5553
});
5654

5755
it('should successfully start an iOS app live session', async () => {
5856
const result = await startAppLiveSession(validiOSArgs);
5957

60-
expect(uploadApp).toHaveBeenCalledWith(validiOSArgs.appPath);
6158
expect(startSession).toHaveBeenCalledWith({
62-
appUrl: 'bs://123456',
59+
appPath: '/path/to/app.ipa',
6360
desiredPlatform: 'ios',
6461
desiredPhone: validiOSArgs.desiredPhone,
6562
desiredPlatformVersion: validiOSArgs.desiredPlatformVersion
@@ -106,13 +103,8 @@ describe('startAppLiveSession', () => {
106103
expect(logger.error).toHaveBeenCalled();
107104
});
108105

109-
it('should handle upload failure', async () => {
110-
(uploadApp as jest.Mock).mockRejectedValue(new Error('Upload failed'));
111-
await expect(startAppLiveSession(validAndroidArgs)).rejects.toThrow('Upload failed');
112-
});
113-
114106
it('should handle session start failure', async () => {
115107
(startSession as jest.Mock).mockRejectedValue(new Error('Session start failed'));
116108
await expect(startAppLiveSession(validAndroidArgs)).rejects.toThrow('Session start failed');
117109
});
118-
});
110+
});

0 commit comments

Comments
 (0)