Skip to content

Commit ff0b9dd

Browse files
authored
Merge pull request #68 from tech-sushant/espresso
2 parents 766f4e8 + 97859ed commit ff0b9dd

File tree

3 files changed

+280
-10
lines changed

3 files changed

+280
-10
lines changed

src/tools/appautomate-utils/appautomate.ts

Lines changed: 130 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import config from "../../config.js";
44
import FormData from "form-data";
55
import { customFuzzySearch } from "../../lib/fuzzy.js";
66

7+
const auth = {
8+
username: config.browserstackUsername,
9+
password: config.browserstackAccessKey,
10+
};
11+
712
interface Device {
813
device: string;
914
display_name: string;
@@ -138,13 +143,8 @@ export async function uploadApp(appPath: string): Promise<string> {
138143
"https://api-cloud.browserstack.com/app-automate/upload",
139144
formData,
140145
{
141-
headers: {
142-
...formData.getHeaders(),
143-
},
144-
auth: {
145-
username: config.browserstackUsername,
146-
password: config.browserstackAccessKey,
147-
},
146+
headers: formData.getHeaders(),
147+
auth,
148148
},
149149
);
150150

@@ -154,3 +154,126 @@ export async function uploadApp(appPath: string): Promise<string> {
154154
throw new Error(`Failed to upload app: ${response.data}`);
155155
}
156156
}
157+
158+
// Helper to upload a file to a given BrowserStack endpoint and return a specific property from the response.
159+
async function uploadFileToBrowserStack(
160+
filePath: string,
161+
endpoint: string,
162+
responseKey: string,
163+
): Promise<string> {
164+
if (!fs.existsSync(filePath)) {
165+
throw new Error(`File not found at path: ${filePath}`);
166+
}
167+
168+
const formData = new FormData();
169+
formData.append("file", fs.createReadStream(filePath));
170+
171+
const response = await axios.post(endpoint, formData, {
172+
headers: formData.getHeaders(),
173+
auth,
174+
});
175+
176+
if (response.data[responseKey]) {
177+
return response.data[responseKey];
178+
}
179+
180+
throw new Error(`Failed to upload file: ${JSON.stringify(response.data)}`);
181+
}
182+
183+
//Uploads an Android app (.apk or .aab) to BrowserStack Espresso endpoint and returns the app_url
184+
export async function uploadEspressoApp(appPath: string): Promise<string> {
185+
return uploadFileToBrowserStack(
186+
appPath,
187+
"https://api-cloud.browserstack.com/app-automate/espresso/v2/app",
188+
"app_url",
189+
);
190+
}
191+
192+
//Uploads an Espresso test suite (.apk) to BrowserStack and returns the test_suite_url
193+
export async function uploadEspressoTestSuite(
194+
testSuitePath: string,
195+
): Promise<string> {
196+
return uploadFileToBrowserStack(
197+
testSuitePath,
198+
"https://api-cloud.browserstack.com/app-automate/espresso/v2/test-suite",
199+
"test_suite_url",
200+
);
201+
}
202+
203+
//Uploads an iOS app (.ipa) to BrowserStack XCUITest endpoint and returns the app_url
204+
export async function uploadXcuiApp(appPath: string): Promise<string> {
205+
return uploadFileToBrowserStack(
206+
appPath,
207+
"https://api-cloud.browserstack.com/app-automate/xcuitest/v2/app",
208+
"app_url",
209+
);
210+
}
211+
212+
//Uploads an XCUITest test suite (.zip) to BrowserStack and returns the test_suite_url
213+
export async function uploadXcuiTestSuite(
214+
testSuitePath: string,
215+
): Promise<string> {
216+
return uploadFileToBrowserStack(
217+
testSuitePath,
218+
"https://api-cloud.browserstack.com/app-automate/xcuitest/v2/test-suite",
219+
"test_suite_url",
220+
);
221+
}
222+
223+
// Triggers an Espresso test run on BrowserStack and returns the build_id
224+
export async function triggerEspressoBuild(
225+
app_url: string,
226+
test_suite_url: string,
227+
devices: string[],
228+
project: string,
229+
): Promise<string> {
230+
const response = await axios.post(
231+
"https://api-cloud.browserstack.com/app-automate/espresso/v2/build",
232+
{
233+
app: app_url,
234+
testSuite: test_suite_url,
235+
devices,
236+
project,
237+
},
238+
{
239+
auth,
240+
},
241+
);
242+
243+
if (response.data.build_id) {
244+
return response.data.build_id;
245+
}
246+
247+
throw new Error(
248+
`Failed to trigger Espresso build: ${JSON.stringify(response.data)}`,
249+
);
250+
}
251+
252+
// Triggers an XCUITest run on BrowserStack and returns the build_id
253+
export async function triggerXcuiBuild(
254+
app_url: string,
255+
test_suite_url: string,
256+
devices: string[],
257+
project: string,
258+
): Promise<string> {
259+
const response = await axios.post(
260+
"https://api-cloud.browserstack.com/app-automate/xcuitest/v2/build",
261+
{
262+
app: app_url,
263+
testSuite: test_suite_url,
264+
devices,
265+
project,
266+
},
267+
{
268+
auth,
269+
},
270+
);
271+
272+
if (response.data.build_id) {
273+
return response.data.build_id;
274+
}
275+
276+
throw new Error(
277+
`Failed to trigger XCUITest build: ${JSON.stringify(response.data)}`,
278+
);
279+
}

src/tools/appautomate-utils/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export enum AppTestPlatform {
2+
ESPRESSO = "espresso",
3+
APPIUM = "appium",
4+
XCUITEST = "xcuitest",
5+
}

src/tools/appautomate.ts

Lines changed: 145 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import config from "../config.js";
66
import { trackMCP } from "../lib/instrumentation.js";
77
import { maybeCompressBase64 } from "../lib/utils.js";
88
import { remote } from "webdriverio";
9+
import { AppTestPlatform } from "./appautomate-utils/types.js";
910

1011
import {
1112
getDevicesAndBrowsers,
@@ -18,6 +19,12 @@ import {
1819
resolveVersion,
1920
validateArgs,
2021
uploadApp,
22+
uploadEspressoApp,
23+
uploadEspressoTestSuite,
24+
triggerEspressoBuild,
25+
uploadXcuiApp,
26+
uploadXcuiTestSuite,
27+
triggerXcuiBuild,
2128
} from "./appautomate-utils/appautomate.js";
2229

2330
// Types
@@ -143,9 +150,72 @@ async function takeAppScreenshot(args: {
143150
}
144151
}
145152

146-
/**
147-
* Registers the `takeAppScreenshot` tool with the MCP server.
148-
*/
153+
//Runs AppAutomate tests on BrowserStack by uploading app and test suite, then triggering a test run.
154+
async function runAppTestsOnBrowserStack(args: {
155+
appPath: string;
156+
testSuitePath: string;
157+
devices: string[];
158+
project: string;
159+
detectedAutomationFramework: string;
160+
}): Promise<CallToolResult> {
161+
switch (args.detectedAutomationFramework) {
162+
case AppTestPlatform.ESPRESSO: {
163+
try {
164+
const app_url = await uploadEspressoApp(args.appPath);
165+
const test_suite_url = await uploadEspressoTestSuite(
166+
args.testSuitePath,
167+
);
168+
const build_id = await triggerEspressoBuild(
169+
app_url,
170+
test_suite_url,
171+
args.devices,
172+
args.project,
173+
);
174+
175+
return {
176+
content: [
177+
{
178+
type: "text",
179+
text: `✅ Espresso run started successfully!\n\n🔧 Build ID: ${build_id}\n🔗 View your build: https://app-automate.browserstack.com/builds/${build_id}`,
180+
},
181+
],
182+
};
183+
} catch (err) {
184+
logger.error("Error running App Automate test", err);
185+
throw err;
186+
}
187+
}
188+
case AppTestPlatform.XCUITEST: {
189+
try {
190+
const app_url = await uploadXcuiApp(args.appPath);
191+
const test_suite_url = await uploadXcuiTestSuite(args.testSuitePath);
192+
const build_id = await triggerXcuiBuild(
193+
app_url,
194+
test_suite_url,
195+
args.devices,
196+
args.project,
197+
);
198+
return {
199+
content: [
200+
{
201+
type: "text",
202+
text: `✅ XCUITest run started successfully!\n\n🔧 Build ID: ${build_id}\n🔗 View your build: https://app-automate.browserstack.com/builds/${build_id}`,
203+
},
204+
],
205+
};
206+
} catch (err) {
207+
logger.error("Error running XCUITest App Automate test", err);
208+
throw err;
209+
}
210+
}
211+
default:
212+
throw new Error(
213+
`Unsupported automation framework: ${args.detectedAutomationFramework}. If you need support for this framework, please open an issue at Github`,
214+
);
215+
}
216+
}
217+
218+
// Registers automation tools with the MCP server.
149219
export default function addAppAutomationTools(server: McpServer) {
150220
server.tool(
151221
"takeAppScreenshot",
@@ -189,4 +259,76 @@ export default function addAppAutomationTools(server: McpServer) {
189259
}
190260
},
191261
);
262+
263+
server.tool(
264+
"runAppTestsOnBrowserStack",
265+
"Run AppAutomate tests on BrowserStack by uploading app and test suite. If running from Android Studio or Xcode, the tool will help export app and test files automatically. For other environments, you'll need to provide the paths to your pre-built app and test files.",
266+
{
267+
appPath: z
268+
.string()
269+
.describe(
270+
"Path to your application file:\n" +
271+
"If in development IDE directory:\n" +
272+
"• For Android: 'gradle assembleDebug'\n" +
273+
"• For iOS:\n" +
274+
" xcodebuild clean -scheme YOUR_SCHEME && \\\n" +
275+
" xcodebuild archive -scheme YOUR_SCHEME -configuration Release -archivePath build/app.xcarchive && \\\n" +
276+
" xcodebuild -exportArchive -archivePath build/app.xcarchive -exportPath build/ipa -exportOptionsPlist exportOptions.plist\n\n" +
277+
"If in other directory, provide existing app path"
278+
),
279+
testSuitePath: z
280+
.string()
281+
.describe(
282+
"Path to your test suite file:\n" +
283+
"If in development IDE directory:\n" +
284+
"• For Android: 'gradle assembleAndroidTest'\n" +
285+
"• For iOS:\n" +
286+
" xcodebuild test-without-building -scheme YOUR_SCHEME -destination 'generic/platform=iOS' && \\\n" +
287+
" cd ~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug-iphonesimulator/ && \\\n" +
288+
" zip -r Tests.zip *.xctestrun *-Runner.app\n\n" +
289+
"If in other directory, provide existing test file path"
290+
),
291+
devices: z
292+
.array(z.string())
293+
.describe(
294+
"List of devices to run the test on, e.g., ['Samsung Galaxy S20-10.0', 'iPhone 12 Pro-16.0'].",
295+
),
296+
project: z
297+
.string()
298+
.optional()
299+
.default("BStack-AppAutomate-Suite")
300+
.describe("Project name for organizing test runs on BrowserStack."),
301+
detectedAutomationFramework: z
302+
.string()
303+
.describe(
304+
"The automation framework used in the project, such as 'espresso' (Android) or 'xcuitest' (iOS).",
305+
),
306+
},
307+
async (args) => {
308+
try {
309+
trackMCP(
310+
"runAppTestsOnBrowserStack",
311+
server.server.getClientVersion()!,
312+
);
313+
return await runAppTestsOnBrowserStack(args);
314+
} catch (error) {
315+
trackMCP(
316+
"runAppTestsOnBrowserStack",
317+
server.server.getClientVersion()!,
318+
error,
319+
);
320+
const errorMessage =
321+
error instanceof Error ? error.message : "Unknown error";
322+
return {
323+
content: [
324+
{
325+
type: "text",
326+
text: `Error running App Automate test: ${errorMessage}`,
327+
},
328+
],
329+
isError: true,
330+
};
331+
}
332+
},
333+
);
192334
}

0 commit comments

Comments
 (0)