Skip to content

Commit 5f44d8b

Browse files
Feat: App Screenshot on Real Device (#31)
* Feat: Add App Automate + Appium tool for BrowserStack integration * Add unit tests for appautomate utilities * Update src/tools/appautomate-utils/appautomate.ts Co-authored-by: Pulkit Sharma <[email protected]> * Remove automationName assignment * Fix: Ensure cache retrieval only returns valid device data * Fix : Add image compression utility for base64 screenshots * Refactor: Initialize WebDriverIO directly to streamline app automation * Update package version * Adding sharp to package version --------- Co-authored-by: Pulkit Sharma <[email protected]>
1 parent 8935cee commit 5f44d8b

File tree

11 files changed

+3463
-70
lines changed

11 files changed

+3463
-70
lines changed

package-lock.json

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

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
"form-data": "^4.0.2",
4242
"pino": "^9.6.0",
4343
"pino-pretty": "^13.0.0",
44+
"sharp": "^0.34.1",
45+
"webdriverio": "^9.13.0",
4446
"zod": "^3.24.3"
4547
},
4648
"devDependencies": {

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import addBrowserLiveTools from "./tools/live";
1212
import addAccessibilityTools from "./tools/accessibility";
1313
import addAutomateTools from "./tools/automate";
1414
import addTestManagementTools from "./tools/testmanagement";
15+
import addAppAutomationTools from "./tools/appautomate";
1516
import { trackMCP } from "./lib/instrumentation";
1617

1718
function registerTools(server: McpServer) {
@@ -22,6 +23,7 @@ function registerTools(server: McpServer) {
2223
addAccessibilityTools(server);
2324
addAutomateTools(server);
2425
addTestManagementTools(server);
26+
addAppAutomationTools(server);
2527
}
2628

2729
// Create an MCP server

src/lib/device-cache.ts

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,26 @@ const CACHE_DIR = path.join(os.homedir(), ".browserstack", "combined_cache");
66
const CACHE_FILE = path.join(CACHE_DIR, "data.json");
77
const TTL_MS = 24 * 60 * 60 * 1000; // 1 day
88

9-
const URLS = {
10-
live: "https://www.browserstack.com/list-of-browsers-and-platforms/live.json",
11-
app_live:
9+
export enum BrowserStackProducts {
10+
LIVE = "live",
11+
APP_LIVE = "app_live",
12+
APP_AUTOMATE = "app_automate",
13+
}
14+
15+
const URLS: Record<BrowserStackProducts, string> = {
16+
[BrowserStackProducts.LIVE]:
17+
"https://www.browserstack.com/list-of-browsers-and-platforms/live.json",
18+
[BrowserStackProducts.APP_LIVE]:
1219
"https://www.browserstack.com/list-of-browsers-and-platforms/app_live.json",
20+
[BrowserStackProducts.APP_AUTOMATE]:
21+
"https://www.browserstack.com/list-of-browsers-and-platforms/app_automate.json",
1322
};
1423

1524
/**
16-
* Fetches and caches both BrowserStack datasets (live + app_live) with a shared TTL.
25+
* Fetches and caches BrowserStack datasets (live + app_live + app_automate) with a shared TTL.
1726
*/
1827
export async function getDevicesAndBrowsers(
19-
type: "live" | "app_live",
28+
type: BrowserStackProducts,
2029
): Promise<any> {
2130
if (!fs.existsSync(CACHE_DIR)) {
2231
fs.mkdirSync(CACHE_DIR, { recursive: true });
@@ -29,31 +38,29 @@ export async function getDevicesAndBrowsers(
2938
if (Date.now() - stats.mtimeMs < TTL_MS) {
3039
try {
3140
cache = JSON.parse(fs.readFileSync(CACHE_FILE, "utf8"));
32-
return cache[type];
41+
if (cache[type]) {
42+
return cache[type];
43+
}
3344
} catch (error) {
3445
console.error("Error parsing cache file:", error);
3546
// Continue with fetching fresh data
3647
}
3748
}
3849
}
3950

40-
const [liveRes, appLiveRes] = await Promise.all([
41-
fetch(URLS.live),
42-
fetch(URLS.app_live),
43-
]);
51+
const liveRes = await fetch(URLS[type]);
4452

45-
if (!liveRes.ok || !appLiveRes.ok) {
53+
if (!liveRes.ok) {
4654
throw new Error(
47-
`Failed to fetch configuration from BrowserStack : live=${liveRes.statusText}, app_live=${appLiveRes.statusText}`,
55+
`Failed to fetch configuration from BrowserStack : ${type}=${liveRes.statusText}`,
4856
);
4957
}
5058

51-
const [liveData, appLiveData] = await Promise.all([
52-
liveRes.json(),
53-
appLiveRes.json(),
54-
]);
59+
const data = await liveRes.json();
5560

56-
cache = { live: liveData, app_live: appLiveData };
61+
cache = {
62+
[type]: data,
63+
};
5764
fs.writeFileSync(CACHE_FILE, JSON.stringify(cache), "utf8");
5865

5966
return cache[type];

src/lib/utils.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import sharp from "sharp";
2+
13
export function sanitizeUrlParam(param: string): string {
24
// Remove any characters that could be used for command injection
35
return param.replace(/[;&|`$(){}[\]<>]/g, "");
@@ -24,3 +26,24 @@ export interface HarEntry {
2426
serverIPAddress?: string;
2527
time?: number;
2628
}
29+
30+
/**
31+
* Compresses a base64 image intelligently to keep it under 1 MB if needed.
32+
*/
33+
export async function maybeCompressBase64(base64: string): Promise<string> {
34+
const buffer = Buffer.from(base64, "base64");
35+
36+
if (buffer.length <= 1048576) {
37+
return base64;
38+
}
39+
40+
const sizeRatio = 1048576 / buffer.length;
41+
const estimatedQuality = Math.floor(sizeRatio * 100);
42+
const quality = Math.min(95, Math.max(30, estimatedQuality));
43+
44+
const compressedBuffer = await sharp(buffer)
45+
.png({ quality })
46+
.toBuffer();
47+
48+
return compressedBuffer.toString("base64");
49+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import fs from "fs";
2+
import axios from "axios";
3+
import config from "../../config";
4+
import FormData from "form-data";
5+
import { customFuzzySearch } from "../../lib/fuzzy";
6+
7+
interface Device {
8+
device: string;
9+
display_name: string;
10+
os_version: string;
11+
real_mobile: boolean;
12+
}
13+
14+
interface UploadResponse {
15+
app_url: string;
16+
custom_id?: string;
17+
shareable_id?: string;
18+
}
19+
20+
/**
21+
* Finds devices that exactly match the provided display name.
22+
* Uses fuzzy search first, and then filters for exact case-insensitive match.
23+
*/
24+
export function findMatchingDevice(
25+
devices: Device[],
26+
deviceName: string,
27+
): Device[] {
28+
const matches = customFuzzySearch(devices, ["display_name"], deviceName, 5);
29+
30+
if (matches.length === 0) {
31+
const availableDevices = [
32+
...new Set(devices.map((d) => d.display_name)),
33+
].join(", ");
34+
throw new Error(
35+
`No devices found matching "${deviceName}". Available devices: ${availableDevices}`,
36+
);
37+
}
38+
39+
const exactMatches = matches.filter(
40+
(m) => m.display_name.toLowerCase() === deviceName.toLowerCase(),
41+
);
42+
43+
if (exactMatches.length === 0) {
44+
const suggestions = [...new Set(matches.map((d) => d.display_name))].join(
45+
", ",
46+
);
47+
throw new Error(
48+
`Alternative devices found: ${suggestions}. Please select one of these exact device names.`,
49+
);
50+
}
51+
return exactMatches;
52+
}
53+
54+
/**
55+
* Extracts all unique OS versions from a device list and sorts them.
56+
*/
57+
export function getDeviceVersions(devices: Device[]): string[] {
58+
return [...new Set(devices.map((d) => d.os_version))].sort();
59+
}
60+
61+
/**
62+
* Resolves the requested platform version against available versions.
63+
* Supports 'latest' and 'oldest' as dynamic selectors.
64+
*/
65+
export function resolveVersion(
66+
versions: string[],
67+
requestedVersion: string,
68+
): string {
69+
if (requestedVersion === "latest") {
70+
return versions[versions.length - 1];
71+
}
72+
73+
if (requestedVersion === "oldest") {
74+
return versions[0];
75+
}
76+
77+
const match = versions.find((v) => v === requestedVersion);
78+
if (!match) {
79+
throw new Error(
80+
`Version "${requestedVersion}" not found. Available versions: ${versions.join(", ")}`,
81+
);
82+
}
83+
return match;
84+
}
85+
86+
/**
87+
* Validates the input arguments for taking app screenshots.
88+
* Checks for presence and correctness of platform, device, and file types.
89+
*/
90+
export function validateArgs(args: {
91+
desiredPlatform: string;
92+
desiredPlatformVersion: string;
93+
appPath: string;
94+
desiredPhone: string;
95+
}): void {
96+
const { desiredPlatform, desiredPlatformVersion, appPath, desiredPhone } =
97+
args;
98+
99+
if (!desiredPlatform || !desiredPhone) {
100+
throw new Error(
101+
"Missing required arguments: desiredPlatform and desiredPhone are required",
102+
);
103+
}
104+
105+
if (!desiredPlatformVersion) {
106+
throw new Error(
107+
"Missing required arguments: desiredPlatformVersion is required",
108+
);
109+
}
110+
111+
if (!appPath) {
112+
throw new Error("You must provide an appPath.");
113+
}
114+
115+
if (desiredPlatform === "android" && !appPath.endsWith(".apk")) {
116+
throw new Error("You must provide a valid Android app path (.apk).");
117+
}
118+
119+
if (desiredPlatform === "ios" && !appPath.endsWith(".ipa")) {
120+
throw new Error("You must provide a valid iOS app path (.ipa).");
121+
}
122+
}
123+
124+
/**
125+
* Uploads an application file to AppAutomate and returns the app URL
126+
*/
127+
export async function uploadApp(appPath: string): Promise<string> {
128+
const filePath = appPath;
129+
130+
if (!fs.existsSync(filePath)) {
131+
throw new Error(`File not found at path: ${filePath}`);
132+
}
133+
134+
const formData = new FormData();
135+
formData.append("file", fs.createReadStream(filePath));
136+
137+
const response = await axios.post<UploadResponse>(
138+
"https://api-cloud.browserstack.com/app-automate/upload",
139+
formData,
140+
{
141+
headers: {
142+
...formData.getHeaders(),
143+
},
144+
auth: {
145+
username: config.browserstackUsername,
146+
password: config.browserstackAccessKey,
147+
},
148+
},
149+
);
150+
151+
if (response.data.app_url) {
152+
return response.data.app_url;
153+
} else {
154+
throw new Error(`Failed to upload app: ${response.data}`);
155+
}
156+
}

0 commit comments

Comments
 (0)