Skip to content
Merged
23 changes: 23 additions & 0 deletions src/lib/version-keyword.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { z } from "zod";

/**
* Version fields (OS, browser, platform) accept a version number or one of two keywords,
* "latest" and "oldest", and the code downstream compares against those exact strings.
* Models and users say the same thing in other words — "newest", "most recent",
* "earliest", "first", "Latest" — so the mapping lives here, in the schema, rather than in
* description prose a model may or may not follow.
*/
const LATEST = new Set(["latest", "newest", "most recent", "most-recent"]);
const OLDEST = new Set(["oldest", "earliest", "first"]);

export function normalizeVersionKeyword(value: string): string {
const key = value.trim().toLowerCase().replace(/\s+/g, " ");
if (LATEST.has(key)) return "latest";
if (OLDEST.has(key)) return "oldest";
return value.trim();
}

/** A version string whose keyword synonyms are normalised to "latest" / "oldest". */
export function versionString() {
return z.string().transform(normalizeVersionKeyword);
}
29 changes: 12 additions & 17 deletions src/tools/accessibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1624,7 +1624,7 @@ export default function addAccessibilityTools(
.enum(Object.keys(WCAG_VERSIONS) as [keyof typeof WCAG_VERSIONS])
.optional()
.describe(
'WCAG version and level to check against, e.g. "wcag22aa", or "rgaa41" for RGAA 4.1 (default "wcag21aa").',
'WCAG level, e.g. "wcag22aa" or "rgaa41" (default "wcag21aa").',
),
bestPractices: z
.boolean()
Expand Down Expand Up @@ -1747,8 +1747,7 @@ export default function addAccessibilityTools(

tools.getAccessibilityAuthConfig = server.tool(
"getAccessibilityAuthConfig",
"Retrieve an existing authentication configuration by ID." +
NEEDS_A11Y_CONFIG_ID,
"Get an accessibility auth config by ID." + NEEDS_A11Y_CONFIG_ID,
{
configId: z.number().describe("ID of the auth configuration to retrieve"),
},
Expand All @@ -1773,26 +1772,26 @@ export default function addAccessibilityTools(
.string()
.optional()
.describe(
"Website Scanner scan id: from startAccessibilityScan or list_website_scanner_scans (searchCapability).",
"Website Scanner scan id (startAccessibilityScan or searchCapability).",
),
scanRunId: z
.string()
.optional()
.describe(
"Website Scanner run id: from startAccessibilityScan, or list_website_scanner_scan_runs (searchCapability).",
"Website Scanner run id (startAccessibilityScan or searchCapability).",
),
buildId: z
.string()
.regex(/^[a-z0-9]{40}$/, "40 lowercase letters and digits")
.optional()
.describe(
"Web or App Accessibility build id: the SDK's testhub id, a link's thBuildId, or one listed via searchCapability.",
"Web/App Accessibility build id: SDK testhub id or a link's thBuildId.",
),
workflowReportId: z
.string()
.optional()
.describe(
"Workflow Analyzer report id: from its link, or list_workflow_analyzer_reports (searchCapability).",
"Workflow Analyzer report id, from its link or searchCapability.",
),
appReportId: z
.string()
Expand All @@ -1802,38 +1801,34 @@ export default function addAccessibilityTools(
)
.optional()
.describe(
"Manual App Accessibility report id (a UUID), from its /report/ link or a report listed via searchCapability.",
"Manual App Accessibility report UUID, from its /report/ link.",
),
reportUrl: z
.string()
.optional()
.describe(
"A web or app report dashboard link (scan, build, manual report), a scanner share link, or an (App) Automate link.",
"Any web/app accessibility report, scanner share, or Automate link.",
),
cursor: z
.number()
.optional()
.describe(
"Character offset for pagination (default: 0); with compareWith, how many issues are already listed.",
"Pagination offset (default 0); with compareWith, issues already listed.",
),
pageUrl: z
.string()
.optional()
.describe(
"Only the issues on this page URL, counted as the dashboard's page view counts them.",
),
.describe("Only the issues on this page URL."),
compareWith: z
.string()
.optional()
.describe(
'"previous", or an older scanRunId of the same scan, or an earlier app build\'s id or link; lists new and resolved issues.',
'"previous", an older scanRunId, or an earlier app build id/link; lists new and resolved issues.',
),
exportTaskId: z
.string()
.optional()
.describe(
"Continue a CSV export that an earlier call said is still being generated.",
),
.describe("Continue a CSV export that is still being generated."),
},
{
title: "Fetch Accessibility Issues",
Expand Down
22 changes: 7 additions & 15 deletions src/tools/appautomate-utils/appium-sdk/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,40 +47,34 @@ export const MobileDeviceSchema = z.object({

// Tool description and schema for setupBrowserStackAppAutomateTests
export const SETUP_APP_AUTOMATE_DESCRIPTION =
"Set up BrowserStack App Automate SDK integration for Appium-based mobile app testing. ONLY for Appium based framework . This tool configures SDK for various languages with appium. For pre-built Espresso or XCUITest test suites, use 'runAppTestsOnBrowserStack' instead.";
"Set up the App Automate SDK for Appium tests. For pre-built Espresso or XCUITest suites use runAppTestsOnBrowserStack.";

export const SETUP_APP_AUTOMATE_SCHEMA = {
detectedFramework: z
.nativeEnum(AppSDKSupportedFrameworkEnum)
.describe(
"The mobile automation framework configured in the project. Example: 'appium'",
),
.describe("Mobile automation framework, e.g. 'appium'."),

detectedTestingFramework: z
.nativeEnum(AppSDKSupportedTestingFrameworkEnum)
.describe(
"The testing framework used in the project. Be precise with framework selection Example: 'testng', 'behave', 'pytest', 'robot'",
"Test framework in the project, e.g. testng, behave, pytest, robot.",
),

detectedLanguage: z
.nativeEnum(AppSDKSupportedLanguageEnum)
.describe(
"The programming language used in the project. Supports Java and C#. Example: 'java', 'csharp'",
),
.describe("Project language, e.g. 'java', 'csharp'."),

devices: z
.array(MobileDeviceSchema)
.max(3)
.default([])
.describe(
"Tuples describing target mobile devices. Add device only when user asks explicitly for it. Defaults to [] . Example: [['android', 'Samsung Galaxy S24', '14'], ['ios', 'iPhone 15', '17']]",
"Only devices the user asked for, e.g. [['android', 'Samsung Galaxy S24', '14']].",
),

appPath: z
.string()
.describe(
"Path to the mobile app file (.apk for Android, .ipa for iOS). Can be a local file path or a BrowserStack app URL (bs://)",
),
.describe("Local .apk/.ipa path or a BrowserStack app URL (bs://)."),
project: z
.string()
.optional()
Expand All @@ -90,7 +84,5 @@ export const SETUP_APP_AUTOMATE_SCHEMA = {
.boolean()
.optional()
.default(false)
.describe(
"Run App Accessibility (WCAG) checks on the screens the tests touch; Android 11+ or iOS 14+.",
),
.describe("Run App Accessibility (WCAG) checks; Android 11+ or iOS 14+."),
};
24 changes: 4 additions & 20 deletions src/tools/appautomate-utils/native-execution/constants.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from "zod";
import { AppTestPlatform } from "./types.js";

export const RUN_APP_AUTOMATE_DESCRIPTION = `Execute pre-built native mobile test suites (Espresso for Android, XCUITest for iOS) by direct upload to BrowserStack. ONLY for compiled .apk/.ipa test files. This is NOT for SDK integration or Appium tests. For Appium-based testing with SDK setup, use 'setupBrowserStackAppAutomateTests' instead.`;
export const RUN_APP_AUTOMATE_DESCRIPTION = `Run pre-built Espresso (Android) or XCUITest (iOS) suites on BrowserStack by upload. Not for Appium: use setupBrowserStackAppAutomateTests.`;

export const MobileDeviceSchema = z.object({
platform: z
Expand All @@ -19,35 +19,19 @@ export const RUN_APP_AUTOMATE_SCHEMA = {
appPath: z
.string()
.describe(
"Path to your application file:\n" +
"If in development IDE directory:\n" +
"• For Android: 'gradle assembleDebug'\n" +
"• For iOS:\n" +
" xcodebuild clean -scheme YOUR_SCHEME && \\\n" +
" xcodebuild archive -scheme YOUR_SCHEME -configuration Release -archivePath build/app.xcarchive && \\\n" +
" xcodebuild -exportArchive -archivePath build/app.xcarchive -exportPath build/ipa -exportOptionsPlist exportOptions.plist\n\n" +
"If in other directory, provide existing app path.\n" +
"The resolved file must be located inside the directory set in MCP_UPLOAD_BASE_DIR.",
"Built .apk/.ipa inside MCP_UPLOAD_BASE_DIR (gradle assembleDebug, or xcodebuild archive and export).",
),
testSuitePath: z
.string()
.describe(
"Path to your test suite file:\n" +
"If in development IDE directory:\n" +
"• For Android: 'gradle assembleAndroidTest'\n" +
"• For iOS:\n" +
" xcodebuild test-without-building -scheme YOUR_SCHEME -destination 'generic/platform=iOS' && \\\n" +
" cd ~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug-iphonesimulator/ && \\\n" +
" zip -r Tests.zip *.xctestrun *-Runner.app\n\n" +
"If in other directory, provide existing test file path.\n" +
"The resolved file must be located inside the directory set in MCP_UPLOAD_BASE_DIR.",
"Built suite inside MCP_UPLOAD_BASE_DIR (gradle assembleAndroidTest, or zipped .xctestrun + Runner.app).",
),
devices: z
.array(MobileDeviceSchema)
.max(3)
.default([])
.describe(
"Tuples describing target mobile devices. Add device only when user asks explicitly for it. Defaults to [] . Example: [['android', 'Samsung Galaxy S24', '14'], ['ios', 'iPhone 15', '17']]",
"Only devices the user asked for, e.g. [['android', 'Samsung Galaxy S24', '14']].",
),
project: z
.string()
Expand Down
19 changes: 7 additions & 12 deletions src/tools/appautomate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { remote } from "webdriverio";
import { AppTestPlatform } from "./appautomate-utils/native-execution/types.js";
import { setupAppAutomateHandler } from "./appautomate-utils/appium-sdk/handler.js";
import { validateAppAutomateDevices } from "./sdk-utils/common/device-validator.js";
import { versionString } from "../lib/version-keyword.js";

import {
SETUP_APP_AUTOMATE_DESCRIPTION,
Expand Down Expand Up @@ -380,26 +381,20 @@ export default function addAppAutomationTools(

tools.takeAppScreenshot = server.tool(
"takeAppScreenshot",
"Use this tool to take a screenshot of an app running on a BrowserStack device. This is useful for visual testing and debugging.",
"Take a screenshot of an app running on a BrowserStack device.",
{
desiredPhone: z
.string()
.describe(
"The full name of the device to run the app on. Example: 'iPhone 12 Pro' or 'Samsung Galaxy S20'. Always ask the user for the device they want to use.",
),
desiredPlatformVersion: z
.string()
.describe(
"The platform version to run the app on. Use 'latest' or 'oldest' for dynamic resolution.",
),
.describe("Exact device name, e.g. 'iPhone 15 Pro Max'. Ask the user."),
desiredPlatformVersion: versionString().describe(
"Version number, or 'latest' / 'oldest'.",
),
desiredPlatform: z
.enum([Platform.ANDROID, Platform.IOS])
.describe("Platform to run the app on. Either 'android' or 'ios'."),
appPath: z
.string()
.describe(
"The path to the .apk or .ipa file. Required for app installation. Must be located inside the directory set in MCP_UPLOAD_BASE_DIR.",
),
.describe("Local .apk/.ipa path inside MCP_UPLOAD_BASE_DIR."),
},
{
title: "Take App Screenshot",
Expand Down
13 changes: 6 additions & 7 deletions src/tools/applive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import logger from "../logger.js";
import { trackMCP } from "../lib/instrumentation.js";
import { BrowserStackConfig } from "../lib/types.js";
import { isEnabled as capabilityRegistryEnabled } from "./capability-registry/config.js";
import { versionString } from "../lib/version-keyword.js";

/**
* Launches an App Live Session on BrowserStack.
Expand Down Expand Up @@ -99,13 +100,11 @@ export default function addAppLiveTools(
desiredPhone: z
.string()
.describe(
"The full name of the device to run the app on. Example: 'iPhone 12 Pro' or 'Samsung Galaxy S20' or 'Google Pixel 6'. Always ask the user for the device they want to use, do not assume it. ",
),
desiredPlatformVersion: z
.string()
.describe(
"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'.",
"Exact device name, e.g. 'iPhone 15 Pro Max'. Ask the user; never assume.",
),
desiredPlatformVersion: versionString().describe(
"Version number, or 'latest' / 'oldest'.",
),
desiredPlatform: z
.enum(["android", "ios"])
.describe(
Expand All @@ -114,7 +113,7 @@ export default function addAppLiveTools(
appPath: z
.string()
.describe(
"The path to the .ipa or .apk file to install on the device. Always ask the user for the app path, do not assume it. Must be located inside the directory set in MCP_UPLOAD_BASE_DIR.",
"Local .apk/.ipa path inside MCP_UPLOAD_BASE_DIR. Ask the user; never assume.",
),
},
{
Expand Down
23 changes: 8 additions & 15 deletions src/tools/ask-browserstack/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,19 +133,13 @@ const DESCRIPTION =
// tool that is not enabled for the account cannot do the job at all — so "there is a
// per-account gate, fall back to the individual tools" is the most useful thing to say
// first. Parenthesised so it reads as a status note, not as the tool's purpose.
"(Alpha, limited availability. Enabled per account and per product; if it is not enabled " +
"the call returns an entitlement error, nothing runs, and you should complete the task " +
"with the individual tools instead. To request access, the user should contact their " +
"BrowserStack account owner.) " +
"Use this when no other BrowserStack tool here fits the task, or when the ones you tried " +
"did not get you there. Prefer a specific tool whenever one fits: it is faster and more " +
"predictable than handing the job to an agent. " +
"Otherwise, describe what you want in plain language and BrowserStack's agent decides " +
"which calls to make, then returns its answer plus the steps it took. Anything that would " +
"change data pauses and asks you to confirm it first; deletes are refused outright. " +
"Put that confirmation to the user and answer with what THEY say — never decide on " +
"their behalf. If it cannot be put to anyone, the run is read-only and everything it " +
"wanted to change comes back in `needs_approval` instead. One task per call.";
"(Alpha, limited availability. If not enabled it returns an entitlement error; complete " +
"the task with the individual tools instead.) Use this when no other BrowserStack tool " +
"here fits, or when the ones you tried did not get you there. Prefer a specific tool " +
"whenever one fits. Describe the task in plain language. A data change asks you to " +
"confirm first: put it to the user, never decide for them; deletes are refused " +
"outright. If no one can confirm, the run is read-only and changes come back in " +
"`needs_approval`. One task per call.";

/**
* `isError` marks a call that FAILED, not one that was refused.
Expand Down Expand Up @@ -551,8 +545,7 @@ export function addAskBrowserStackAITool(
product: z
.enum(PRODUCTS)
.describe(
"Which product to work in: tm (Test Management), a11y (Accessibility), " +
"tra (Test Reporting & Analytics).",
"Product: tm (Test Management), a11y (Accessibility), tra (Test Reporting & Analytics).",
),
// OPTIONAL, because a resume does not carry one. `next_step` tells the model to
// call again "with exactly these params" — `product`, `run_id`, `perm_id`,
Expand Down
Loading
Loading