Skip to content

feat: add support for running AppAutomate tests on BrowserStack #68

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 81 additions & 7 deletions src/tools/appautomate-utils/appautomate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import config from "../../config.js";
import FormData from "form-data";
import { customFuzzySearch } from "../../lib/fuzzy.js";

const auth = {
username: config.browserstackUsername,
password: config.browserstackAccessKey,
};

interface Device {
device: string;
display_name: string;
Expand Down Expand Up @@ -138,13 +143,8 @@ export async function uploadApp(appPath: string): Promise<string> {
"https://api-cloud.browserstack.com/app-automate/upload",
formData,
{
headers: {
...formData.getHeaders(),
},
auth: {
username: config.browserstackUsername,
password: config.browserstackAccessKey,
},
headers: formData.getHeaders(),
auth,
},
);

Expand All @@ -154,3 +154,77 @@ export async function uploadApp(appPath: string): Promise<string> {
throw new Error(`Failed to upload app: ${response.data}`);
}
}

// Helper to upload a file to a given BrowserStack endpoint and return a specific property from the response.
async function uploadFileToBrowserStack(
filePath: string,
endpoint: string,
responseKey: string,
): Promise<string> {
if (!fs.existsSync(filePath)) {
throw new Error(`File not found at path: ${filePath}`);
}

const formData = new FormData();
formData.append("file", fs.createReadStream(filePath));

const response = await axios.post(endpoint, formData, {
headers: formData.getHeaders(),
auth,
});

if (response.data[responseKey]) {
return response.data[responseKey];
}

throw new Error(`Failed to upload file: ${JSON.stringify(response.data)}`);
}

//Uploads an Android app (.apk or .aab) to BrowserStack Espresso endpoint and returns the app_url
export async function uploadEspressoApp(appPath: string): Promise<string> {
return uploadFileToBrowserStack(
appPath,
"https://api-cloud.browserstack.com/app-automate/espresso/v2/app",
"app_url",
);
}

//Uploads an Espresso test suite (.apk) to BrowserStack and returns the test_suite_url
export async function uploadEspressoTestSuite(
testSuitePath: string,
): Promise<string> {
return uploadFileToBrowserStack(
testSuitePath,
"https://api-cloud.browserstack.com/app-automate/espresso/v2/test-suite",
"test_suite_url",
);
}

// Triggers an Espresso test run on BrowserStack and returns the build_id
export async function triggerEspressoBuild(
app_url: string,
test_suite_url: string,
devices: string[],
project: string,
): Promise<string> {
const response = await axios.post(
"https://api-cloud.browserstack.com/app-automate/espresso/v2/build",
{
app: app_url,
testSuite: test_suite_url,
devices,
project,
},
{
auth,
},
);

if (response.data.build_id) {
return response.data.build_id;
}

throw new Error(
`Failed to trigger Espresso build: ${JSON.stringify(response.data)}`,
);
}
5 changes: 5 additions & 0 deletions src/tools/appautomate-utils/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export enum AppTestPlatform {
ESPRESSO = "espresso",
APPIUM = "appium",
XCUITEST = "xcuitest",
}
105 changes: 102 additions & 3 deletions src/tools/appautomate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import config from "../config.js";
import { trackMCP } from "../lib/instrumentation.js";
import { maybeCompressBase64 } from "../lib/utils.js";
import { remote } from "webdriverio";
import { AppTestPlatform } from "./appautomate-utils/types.js";

import {
getDevicesAndBrowsers,
Expand All @@ -18,6 +19,9 @@ import {
resolveVersion,
validateArgs,
uploadApp,
uploadEspressoApp,
uploadEspressoTestSuite,
triggerEspressoBuild,
} from "./appautomate-utils/appautomate.js";

// Types
Expand Down Expand Up @@ -136,9 +140,50 @@ async function takeAppScreenshot(args: {
}
}

/**
* Registers the `takeAppScreenshot` tool with the MCP server.
*/
//Runs AppAutomate tests on BrowserStack by uploading app and test suite, then triggering a test run.
async function runAppTestsOnBrowserStack(args: {
appPath: string;
testSuitePath: string;
devices: string[];
project: string;
detectedAutomationFramework: string;
}): Promise<CallToolResult> {
switch (args.detectedAutomationFramework) {
case AppTestPlatform.ESPRESSO: {
try {
const app_url = await uploadEspressoApp(args.appPath);
const test_suite_url = await uploadEspressoTestSuite(
args.testSuitePath,
);
const build_id = await triggerEspressoBuild(
app_url,
test_suite_url,
args.devices,
args.project,
);

return {
content: [
{
type: "text",
text: `✅ Test run started successfully!\n\n🔧 Build ID: ${build_id}\n🔗 View your build: https://app-automate.browserstack.com/builds/${build_id}`,
},
],
};
} catch (err) {
logger.error("Error running App Automate test", err);
throw err;
}
}

default:
throw new Error(
`Unsupported automation framework: ${args.detectedAutomationFramework}`,
);
}
}

// Registers automation tools with the MCP server.
export default function addAppAutomationTools(server: McpServer) {
server.tool(
"takeAppScreenshot",
Expand Down Expand Up @@ -182,4 +227,58 @@ export default function addAppAutomationTools(server: McpServer) {
}
},
);

server.tool(
"runAppTestsOnBrowserStack",
"Run AppAutomate tests on BrowserStack by uploading app and test suite, then triggering a test run.",
{
appPath: z
.string()
.describe("Path to the .apk or .aab file for your app."),
testSuitePath: z
.string()
.describe("Path to the Espresso test suite .apk file."),
devices: z
.array(z.string())
.describe(
"List of devices to run the test on, e.g., ['Samsung Galaxy S20-10.0', 'Google Pixel 3-9.0'].",
),
project: z
.string()
.optional()
.default("Espresso Test")
.describe("Project name for organizing test runs on BrowserStack."),
detectedAutomationFramework: z
.string()
.describe(
"The automation framework used in the project, such as 'espresso' or 'appium'.",
),
},
async (args) => {
try {
trackMCP(
"runAppTestsOnBrowserStack",
server.server.getClientVersion()!,
);
return await runAppTestsOnBrowserStack(args);
} catch (error) {
trackMCP(
"runAppTestsOnBrowserStack",
server.server.getClientVersion()!,
error,
);
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
return {
content: [
{
type: "text",
text: `Error running App Automate test: ${errorMessage}`
},
],
isError: true,
};
}
},
);
}