From f69fe50c57f2be2dc071354364ee539caa5c69b2 Mon Sep 17 00:00:00 2001 From: Wayne Xiong Date: Sat, 18 Jul 2026 23:59:59 -0700 Subject: [PATCH] feat: support app launch arguments and feature flags Add an optional launchArgs parameter to mobile_launch_app while preserving existing launch behavior when arguments are absent. - pass argument pairs through Android intent extras, go-ios, and simctl - keep mobilecli for normal simulator launches and use simctl only for non-empty launch arguments - validate inputs before side effects, reject NUL values, and quote Android remote-shell values - cover Android, iOS, simulator, and MCP launch boundaries Relates to #370 --- README.md | 2 +- src/android.ts | 64 +++++- src/ios.ts | 28 ++- src/iphone-simulator.ts | 14 +- src/mobile-device.ts | 10 +- src/robot.ts | 12 +- src/server.ts | 24 ++- src/utils.ts | 16 ++ test/launch-args.test.ts | 421 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 573 insertions(+), 18 deletions(-) create mode 100644 test/launch-args.test.ts diff --git a/README.md b/README.md index 97cc6f7b..3d16b0e9 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ How we help to scale mobile automation: ### App Management - **`mobile_list_apps`** - List all installed apps on the device -- **`mobile_launch_app`** - Launch an app using its package name +- **`mobile_launch_app`** - Launch an app using its package name, optionally with a locale and launch arguments (e.g. feature flags) - **`mobile_terminate_app`** - Stop and terminate a running app - **`mobile_install_app`** - Install an app from file (.apk, .ipa, .app, .zip) - **`mobile_uninstall_app`** - Uninstall an app using bundle ID or package name diff --git a/src/android.ts b/src/android.ts index 2fc45feb..43195e3b 100644 --- a/src/android.ts +++ b/src/android.ts @@ -5,7 +5,7 @@ import { existsSync } from "node:fs"; import * as xml from "fast-xml-parser"; import { ActionableError, Button, InstalledApp, Robot, ScreenElement, ScreenElementRect, ScreenSize, SwipeDirection, Orientation } from "./robot"; -import { validatePackageName, validateLocale } from "./utils"; +import { validatePackageName, validateLocale, validateLaunchArgs } from "./utils"; export interface AndroidDevice { deviceId: string; @@ -145,11 +145,18 @@ export class AndroidRobot implements Robot { .map(line => line.substring("package:".length)); } - public async launchApp(packageName: string, locale?: string): Promise { + public async launchApp(packageName: string, locale?: string, launchArgs?: Record): Promise { validatePackageName(packageName); if (locale) { validateLocale(locale); + } + + if (launchArgs && Object.keys(launchArgs).length > 0) { + validateLaunchArgs(launchArgs); + } + + if (locale) { try { this.silentAdb("shell", "cmd", "locale", "set-app-locales", packageName, "--locales", locale); } catch (error) { @@ -157,6 +164,13 @@ export class AndroidRobot implements Robot { } } + if (launchArgs && Object.keys(launchArgs).length > 0) { + // launch arguments require `am start` with an explicit component, so resolve + // the launcher activity and pass the extras as string extras (--es). + await this.launchWithExtras(packageName, launchArgs); + return; + } + try { this.silentAdb("shell", "monkey", "-p", packageName, "-c", "android.intent.category.LAUNCHER", "1"); } catch (error) { @@ -164,6 +178,52 @@ export class AndroidRobot implements Robot { } } + private resolveLauncherActivity(packageName: string): string { + let text: string; + try { + text = this.adb("shell", "cmd", "package", "resolve-activity", "--brief", "-c", "android.intent.category.LAUNCHER", packageName).toString(); + } catch (error) { + throw new ActionableError(`Failed resolving a launchable activity for package "${packageName}": ${this.commandErrorDetails(error)}`); + } + + const componentPattern = /^[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*\/[a-zA-Z0-9_.$]+$/; + const components = text + .split("\n") + .map(line => line.trim()) + .filter(line => componentPattern.test(line)); + + if (components.length !== 1) { + throw new ActionableError(`Could not resolve a launchable activity for package "${packageName}", please make sure it exists`); + } + + return components[0]; + } + + private async launchWithExtras(packageName: string, launchArgs: Record): Promise { + const component = this.resolveLauncherActivity(packageName); + const args = ["shell", "am", "start", "-n", component]; + for (const [key, value] of Object.entries(launchArgs)) { + args.push("--es", key, this.quoteShellArgument(value)); + } + + try { + this.silentAdb(...args); + } catch (error) { + throw new ActionableError(`Failed launching app with package name "${packageName}": ${this.commandErrorDetails(error)}`); + } + } + + private quoteShellArgument(text: string): string { + return `'${text.replace(/'/g, "'\\''")}'`; + } + + private commandErrorDetails(error: unknown): string { + const commandError = error as { stdout?: Buffer | string; stderr?: Buffer | string; message?: string }; + const stdout = commandError.stdout?.toString() || ""; + const stderr = commandError.stderr?.toString() || ""; + return (stdout + stderr).trim() || commandError.message || String(error); + } + public async listRunningProcesses(): Promise { return this.adb("shell", "ps", "-e") .toString() diff --git a/src/ios.ts b/src/ios.ts index 382b6d97..278df973 100644 --- a/src/ios.ts +++ b/src/ios.ts @@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process"; import { WebDriverAgent } from "./webdriver-agent"; import { ActionableError, Button, InstalledApp, Robot, ScreenSize, SwipeDirection, ScreenElement, Orientation } from "./robot"; -import { validatePackageName, validateLocale } from "./utils"; +import { validatePackageName, validateLocale, validateLaunchArgs } from "./utils"; const WDA_PORT = 8100; const IOS_TUNNEL_PORT = 60105; @@ -138,15 +138,33 @@ export class IosRobot implements Robot { }); } - public async launchApp(packageName: string, locale?: string): Promise { + public async launchApp(packageName: string, locale?: string, launchArgs?: Record): Promise { validatePackageName(packageName); + + if (locale) { + validateLocale(locale); + } + + if (launchArgs && Object.keys(launchArgs).length > 0) { + validateLaunchArgs(launchArgs); + } + await this.assertTunnelRunning(); const args = ["launch", packageName]; if (locale) { - validateLocale(locale); const locales = locale.split(",").map(l => l.trim()); - args.push("-AppleLanguages", `(${locales.join(", ")})`); - args.push("-AppleLocale", locales[0]); + args.push("--arg=-AppleLanguages", `--arg=(${locales.join(", ")})`); + args.push("--arg=-AppleLocale", `--arg=${locales[0]}`); + } + + if (launchArgs && Object.keys(launchArgs).length > 0) { + // go-ios passes launch arguments through the repeatable `--arg` flag (see + // `ios launch [--arg=]...`). Each `-key value` pair becomes two + // array elements, mirroring Xcode's "Arguments Passed On Launch". Requires the + // Developer Disk Image mounted, like other go-ios instruments features. + for (const [key, value] of Object.entries(launchArgs)) { + args.push(`--arg=-${key}`, `--arg=${value}`); + } } await this.ios(...args); diff --git a/src/iphone-simulator.ts b/src/iphone-simulator.ts index ef95f0e2..bfa5a92f 100644 --- a/src/iphone-simulator.ts +++ b/src/iphone-simulator.ts @@ -6,7 +6,7 @@ import { join, basename, extname } from "node:path"; import { trace } from "./logger"; import { WebDriverAgent } from "./webdriver-agent"; import { ActionableError, Button, InstalledApp, Robot, ScreenElement, ScreenSize, SwipeDirection, Orientation } from "./robot"; -import { validatePackageName, validateLocale } from "./utils"; +import { validatePackageName, validateLocale, validateLaunchArgs } from "./utils"; export interface Simulator { name: string; @@ -102,7 +102,7 @@ export class Simctl implements Robot { // alternative: this.simctl("openurl", this.simulatorUuid, url); } - public async launchApp(packageName: string, locale?: string) { + public async launchApp(packageName: string, locale?: string, launchArgs?: Record) { validatePackageName(packageName); const args = ["launch", this.simulatorUuid, packageName]; if (locale) { @@ -112,6 +112,16 @@ export class Simctl implements Robot { args.push("-AppleLocale", locales[0]); } + if (launchArgs && Object.keys(launchArgs).length > 0) { + // simctl passes trailing arguments to the app as launch arguments, identical + // to Xcode's "Arguments Passed On Launch". `-key value` pairs are parsed into + // UserDefaults by the OS, so no app changes are needed to read them. + validateLaunchArgs(launchArgs); + for (const [key, value] of Object.entries(launchArgs)) { + args.push(`-${key}`, value); + } + } + this.simctl(...args); } diff --git a/src/mobile-device.ts b/src/mobile-device.ts index b0babaa5..94126928 100644 --- a/src/mobile-device.ts +++ b/src/mobile-device.ts @@ -1,4 +1,5 @@ import { Mobilecli } from "./mobilecli"; +import { Simctl } from "./iphone-simulator"; import { Button, InstalledApp, Orientation, Robot, ScreenElement, ScreenSize, SwipeDirection } from "./robot"; interface InstalledAppsResponse { @@ -62,9 +63,11 @@ interface OrientationResponse { export class MobileDevice implements Robot { private mobilecli: Mobilecli; + private simctl: Simctl; public constructor(private deviceId: string) { this.mobilecli = new Mobilecli(); + this.simctl = new Simctl(deviceId); } private runCommand(args: string[]): string { @@ -149,12 +152,17 @@ export class MobileDevice implements Robot { })) as InstalledApp[]; } - public async launchApp(packageName: string, locale?: string): Promise { + public async launchApp(packageName: string, locale?: string, launchArgs?: Record): Promise { const args = ["apps", "launch", packageName]; if (locale) { args.push("--locale", locale); } + if (launchArgs && Object.keys(launchArgs).length > 0) { + await this.simctl.launchApp(packageName, locale, launchArgs); + return; + } + this.runCommand(args); } diff --git a/src/robot.ts b/src/robot.ts index 59bb344d..de0924ef 100644 --- a/src/robot.ts +++ b/src/robot.ts @@ -75,8 +75,16 @@ export interface Robot { /** * Launch an app. - */ - launchApp(packageName: string, locale?: string): Promise; + * + * @param packageName The package name (Android) or bundle id (iOS) of the app. + * @param locale Optional comma-separated BCP 47 locale tags to launch with. + * @param launchArgs Optional key/value pairs passed to the app at launch. On iOS + * these become launch arguments (`-key value`), parsed into `UserDefaults` — the + * same mechanism as Xcode's "Arguments Passed On Launch". On Android they become + * string intent extras (`am start --es key value`), which the app must read from + * its launch intent. + */ + launchApp(packageName: string, locale?: string, launchArgs?: Record): Promise; /** * Terminate an app. If app was already terminated (or non existent) then this diff --git a/src/server.ts b/src/server.ts index 178de1e0..d151e768 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,7 +14,7 @@ import { PNG } from "./png"; import { isScalingAvailable, Image } from "./image-utils"; import { Mobilecli } from "./mobilecli"; import { MobileDevice } from "./mobile-device"; -import { validateOutputPath, validateFileExtension } from "./utils"; +import { validateOutputPath, validateFileExtension, validatePackageName, validateLocale, validateLaunchArgs } from "./utils"; const ALLOWED_SCREENSHOT_EXTENSIONS = [".png", ".jpg", ".jpeg"]; const ALLOWED_RECORDING_EXTENSIONS = [".mp4"]; @@ -38,12 +38,16 @@ interface ActiveRecording { startedAt: number; } +export interface McpServerDependencies { + getRobotFromDevice?: (deviceId: string) => Robot; +} + export const getAgentVersion = (): string => { const json = require("../package.json"); return json.version; }; -export const createMcpServer = (): McpServer => { +export const createMcpServer = (dependencies: McpServerDependencies = {}): McpServer => { const server = new McpServer({ name: "mobile-mcp", @@ -163,7 +167,7 @@ export const createMcpServer = (): McpServer => { } }; - const getRobotFromDevice = (deviceId: string): Robot => { + const defaultGetRobotFromDevice = (deviceId: string): Robot => { // from now on, we must have mobilecli working ensureMobilecliAvailable(); @@ -213,6 +217,7 @@ export const createMcpServer = (): McpServer => { throw new ActionableError(`Device "${deviceId}" not found. Use the mobile_list_available_devices tool to see available devices.`); }; + const getRobotFromDevice = dependencies.getRobotFromDevice || defaultGetRobotFromDevice; tool( "mobile_list_available_devices", @@ -361,11 +366,20 @@ export const createMcpServer = (): McpServer => { device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), packageName: z.string().describe("The package name of the app to launch"), locale: z.string().optional().describe("Comma-separated BCP 47 locale tags to launch the app with (e.g., fr-FR,en-GB)"), + launchArgs: z.record(z.string(), z.string()).optional().describe("Key/value pairs passed to the app at launch. On iOS these become launch arguments (readable via UserDefaults, like Xcode's \"Arguments Passed On Launch\"); on Android they become string intent extras the app reads from its launch intent."), }, { destructiveHint: true }, - async ({ device, packageName, locale }) => { + async ({ device, packageName, locale, launchArgs }) => { + validatePackageName(packageName); + if (locale) { + validateLocale(locale); + } + if (launchArgs) { + validateLaunchArgs(launchArgs); + } + const robot = getRobotFromDevice(device); - await robot.launchApp(packageName, locale); + await robot.launchApp(packageName, locale, launchArgs); return `Launched app ${packageName}`; } ); diff --git a/src/utils.ts b/src/utils.ts index dcc430dc..d52ebf8e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -18,6 +18,22 @@ export function validateLocale(locale: string): void { } } +export function validateLaunchArgs(launchArgs: Record): void { + for (const [key, value] of Object.entries(launchArgs)) { + // keys map to iOS launch argument flags and Android extra keys, so keep them + // to a conservative identifier-like charset. + if (!/^[a-zA-Z0-9._-]+$/.test(key)) { + throw new ActionableError(`Invalid launch argument key: "${key}"`); + } + + // Node child processes reject NUL in argv. Platform-specific command + // boundaries are responsible for preserving all other string content. + if (value.includes("\0")) { + throw new ActionableError(`Invalid launch argument value for "${key}": "${value}"`); + } + } +} + function resolveRoot(root: string): string { const resolved = path.resolve(root); diff --git a/test/launch-args.test.ts b/test/launch-args.test.ts new file mode 100644 index 00000000..26fb4345 --- /dev/null +++ b/test/launch-args.test.ts @@ -0,0 +1,421 @@ +import { test, expect } from "@playwright/test"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { execFileSync } from "node:child_process"; + +import { AndroidRobot } from "../src/android"; +import { IosRobot } from "../src/ios"; +import { Simctl } from "../src/iphone-simulator"; +import { MobileDevice } from "../src/mobile-device"; +import { validateLaunchArgs } from "../src/utils"; +import { ActionableError, Robot } from "../src/robot"; +import { createMcpServer } from "../src/server"; + +test.describe("launch arguments", () => { + + test.describe("validateLaunchArgs", () => { + test("should accept identifier-like keys and safe values", () => { + expect(() => validateLaunchArgs({ "FeatureXEnabled": "true", "api.base-url": "https://staging.example.com/v1" })).not.toThrow(); + }); + + test("should accept an empty map", () => { + expect(() => validateLaunchArgs({})).not.toThrow(); + }); + + test("should reject keys with shell metacharacters", () => { + expect(() => validateLaunchArgs({ "bad key": "value" })).toThrow(ActionableError); + expect(() => validateLaunchArgs({ "key;rm": "value" })).toThrow(ActionableError); + }); + + test("should accept arbitrary non-NUL string values", () => { + const values = [ + "", + "value with spaces", + "it's enabled", + "{\"enabled\":true}", + "https://example.test?a=1&b=2", + "$(literal); `text`", + ]; + + for (const value of values) { + expect(() => validateLaunchArgs({ key: value })).not.toThrow(); + } + }); + + test("should reject NUL values that child processes cannot accept", () => { + expect(() => validateLaunchArgs({ key: "before\0after" })).toThrow(ActionableError); + }); + }); + + test.describe("AndroidRobot.launchApp", () => { + // mock adb/silentAdb so these run without a device. + const createRobot = (): { robot: AndroidRobot; calls: string[][] } => { + const robot = new AndroidRobot("mock-device"); + const calls: string[][] = []; + const record = (...args: string[]): Buffer => { + calls.push(args); + // resolve-activity output for the launcher activity lookup. + if (args.includes("resolve-activity")) { + return Buffer.from("com.example.app/.MainActivity\n"); + } + + return Buffer.from(""); + }; + + robot.adb = record; + robot.silentAdb = record; + return { robot, calls }; + }; + + test("should use monkey when no launch args are supplied", async () => { + const { robot, calls } = createRobot(); + await robot.launchApp("com.example.app"); + expect(calls).toEqual([ + ["shell", "monkey", "-p", "com.example.app", "-c", "android.intent.category.LAUNCHER", "1"], + ]); + }); + + test("should use monkey when an empty launch args map is supplied", async () => { + const { robot, calls } = createRobot(); + await robot.launchApp("com.example.app", undefined, {}); + expect(calls).toEqual([ + ["shell", "monkey", "-p", "com.example.app", "-c", "android.intent.category.LAUNCHER", "1"], + ]); + }); + + test("should use am start with resolved activity and extras when launch args are supplied", async () => { + const { robot, calls } = createRobot(); + await robot.launchApp("com.example.app", undefined, { FeatureXEnabled: "true", server: "staging" }); + + expect(calls).toEqual([ + ["shell", "cmd", "package", "resolve-activity", "--brief", "-c", "android.intent.category.LAUNCHER", "com.example.app"], + [ + "shell", "am", "start", "-n", "com.example.app/.MainActivity", + "--es", "FeatureXEnabled", "'true'", + "--es", "server", "'staging'", + ], + ]); + }); + + test("should quote every extra as one remote-shell argument", async () => { + const { robot, calls } = createRobot(); + await robot.launchApp("com.example.app", undefined, { + spaces: "value with spaces", + apostrophe: "it's enabled", + json: "{\"enabled\":true}", + empty: "", + url: "https://example.test?a=1&b=2", + literal: "$(literal); `text`", + }); + + expect(calls[1]).toEqual([ + "shell", "am", "start", "-n", "com.example.app/.MainActivity", + "--es", "spaces", "'value with spaces'", + "--es", "apostrophe", "'it'\\''s enabled'", + "--es", "json", "'{\"enabled\":true}'", + "--es", "empty", "''", + "--es", "url", "'https://example.test?a=1&b=2'", + "--es", "literal", "'$(literal); `text`'", + ]); + }); + + test("should preserve launch values through a POSIX shell boundary", async () => { + const values = { + newline: "first\nsecond", + tab: "left\tright", + backslash: String.raw`C:\path\file`, + unicode: "\u4F60\u597D\u4E16\u754C", + }; + const { robot, calls } = createRobot(); + await robot.launchApp("com.example.app", undefined, values); + + const launchCommand = calls[1]; + for (let index = 5; index < launchCommand.length; index += 3) { + const key = launchCommand[index + 1] as keyof typeof values; + const quotedValue = launchCommand[index + 2]; + const output = execFileSync("/bin/sh", ["-c", `printf %s ${quotedValue}`], { encoding: "utf8" }); + expect(output).toBe(values[key]); + } + }); + + test("should validate all inputs before changing locale", async () => { + const { robot, calls } = createRobot(); + await expect(robot.launchApp("com.example.app", "fr-FR", { "bad key": "value" })).rejects.toThrow(ActionableError); + expect(calls).toEqual([]); + }); + + test("should reject NUL launch args before shelling out", async () => { + const { robot, calls } = createRobot(); + await expect(robot.launchApp("com.example.app", undefined, { key: "before\0after" })).rejects.toThrow(ActionableError); + expect(calls).toEqual([]); + }); + + test("should reject malformed launcher resolution output", async () => { + const { robot, calls } = createRobot(); + robot.adb = (...args: string[]): Buffer => { + calls.push(args); + return Buffer.from("diagnostic: see /tmp/device.log\n"); + }; + + await expect(robot.launchApp("com.example.app", undefined, { key: "value" })) + .rejects.toThrow("Could not resolve a launchable activity"); + expect(calls).toHaveLength(1); + }); + + test("should surface launcher resolution command diagnostics", async () => { + const { robot, calls } = createRobot(); + robot.adb = (...args: string[]): Buffer => { + calls.push(args); + throw Object.assign(new Error("resolve failed"), { + stderr: Buffer.from("device offline"), + }); + }; + + await expect(robot.launchApp("com.example.app", undefined, { key: "value" })) + .rejects.toThrow("device offline"); + expect(calls).toHaveLength(1); + }); + + test("should surface am start command diagnostics", async () => { + const { robot, calls } = createRobot(); + robot.silentAdb = (...args: string[]): Buffer => { + calls.push(args); + throw Object.assign(new Error("launch failed"), { + stderr: Buffer.from("permission denied"), + }); + }; + + await expect(robot.launchApp("com.example.app", undefined, { key: "value" })) + .rejects.toThrow("permission denied"); + }); + }); + + test.describe("IosRobot.launchApp", () => { + const createRobot = (): { + robot: IosRobot; + calls: string[][]; + getTunnelChecks: () => number; + } => { + const robot = new IosRobot("physical-device"); + const calls: string[][] = []; + let tunnelChecks = 0; + const internals = robot as unknown as { + assertTunnelRunning: () => Promise; + ios: (...args: string[]) => Promise; + }; + internals.assertTunnelRunning = async () => { + tunnelChecks += 1; + }; + internals.ios = async (...args: string[]) => { + calls.push(args); + return ""; + }; + return { robot, calls, getTunnelChecks: () => tunnelChecks }; + }; + + test("should pass locale and raw launch values through go-ios argv", async () => { + const { robot, calls } = createRobot(); + await robot.launchApp("com.example.app", "fr-FR,en-GB", { + feature: "it's enabled", + config: "{\"url\":\"https://example.test?a=1&b=2\"}", + }); + + expect(calls).toEqual([[ + "launch", "com.example.app", + "--arg=-AppleLanguages", "--arg=(fr-FR, en-GB)", + "--arg=-AppleLocale", "--arg=fr-FR", + "--arg=-feature", "--arg=it's enabled", + "--arg=-config", "--arg={\"url\":\"https://example.test?a=1&b=2\"}", + ]]); + }); + + test("should validate launch args before checking the device tunnel", async () => { + const { robot, calls, getTunnelChecks } = createRobot(); + await expect(robot.launchApp("com.example.app", undefined, { "bad key": "value" })).rejects.toThrow(ActionableError); + expect(getTunnelChecks()).toBe(0); + expect(calls).toEqual([]); + }); + }); + + test.describe("Simctl.launchApp", () => { + test("should pass locale and raw launch values through simctl argv", async () => { + const simulator = new Simctl("simulator-id"); + const calls: string[][] = []; + const internals = simulator as unknown as { + simctl: (...args: string[]) => Buffer; + }; + internals.simctl = (...args: string[]) => { + calls.push(args); + return Buffer.from(""); + }; + + await simulator.launchApp("com.example.app", "fr-FR,en-GB", { + feature: "it's enabled", + config: "{\"url\":\"https://example.test?a=1&b=2\"}", + }); + + expect(calls).toEqual([[ + "launch", "simulator-id", "com.example.app", + "-AppleLanguages", "(fr-FR, en-GB)", + "-AppleLocale", "fr-FR", + "-feature", "it's enabled", + "-config", "{\"url\":\"https://example.test?a=1&b=2\"}", + ]]); + }); + }); + + test.describe("MobileDevice.launchApp", () => { + const createDevice = (): { + device: MobileDevice; + mobilecliCalls: string[][]; + simctlCalls: Array<[string, string | undefined, Record | undefined]>; + } => { + const device = new MobileDevice("simulator-id"); + const mobilecliCalls: string[][] = []; + const simctlCalls: Array<[string, string | undefined, Record | undefined]> = []; + const internals = device as unknown as { + mobilecli: { executeCommand: (args: string[]) => string }; + simctl: { launchApp: (packageName: string, locale?: string, launchArgs?: Record) => Promise }; + }; + internals.mobilecli = { + executeCommand: (args: string[]) => { + mobilecliCalls.push(args); + return ""; + }, + }; + internals.simctl = { + launchApp: async (packageName, locale, launchArgs) => { + simctlCalls.push([packageName, locale, launchArgs]); + }, + }; + return { device, mobilecliCalls, simctlCalls }; + }; + + test("should retain mobilecli for launches without arguments", async () => { + const { device, mobilecliCalls, simctlCalls } = createDevice(); + await device.launchApp("com.example.app", "fr-FR"); + expect(mobilecliCalls).toEqual([ + ["apps", "launch", "com.example.app", "--locale", "fr-FR", "--device", "simulator-id"], + ]); + expect(simctlCalls).toEqual([]); + }); + + test("should retain mobilecli for an empty argument map", async () => { + const { device, mobilecliCalls, simctlCalls } = createDevice(); + await device.launchApp("com.example.app", undefined, {}); + expect(mobilecliCalls).toEqual([ + ["apps", "launch", "com.example.app", "--device", "simulator-id"], + ]); + expect(simctlCalls).toEqual([]); + }); + + test("should delegate non-empty launch arguments to Simctl", async () => { + const { device, mobilecliCalls, simctlCalls } = createDevice(); + const launchArgs = { feature: "it's enabled" }; + await device.launchApp("com.example.app", "fr-FR", launchArgs); + expect(simctlCalls).toEqual([ + ["com.example.app", "fr-FR", launchArgs], + ]); + expect(mobilecliCalls).toEqual([]); + }); + }); + + test("should accept and forward launch arguments through the MCP boundary", async () => { + const launchCalls: Array<[string, string | undefined, Record | undefined]> = []; + const resolvedDevices: string[] = []; + const robot = { + launchApp: async (packageName: string, locale?: string, launchArgs?: Record) => { + launchCalls.push([packageName, locale, launchArgs]); + }, + } as unknown as Robot; + const previousTelemetry = process.env.MOBILEMCP_DISABLE_TELEMETRY; + process.env.MOBILEMCP_DISABLE_TELEMETRY = "1"; + const server = createMcpServer({ + getRobotFromDevice: device => { + resolvedDevices.push(device); + return robot; + }, + }); + const client = new Client({ name: "launch-args-test", version: "1.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "mobile_launch_app", + arguments: { + device: "simulator-id", + packageName: "com.example.app", + locale: "fr-FR", + launchArgs: { + feature: "it's enabled", + config: "{\"url\":\"https://example.test?a=1&b=2\"}", + }, + }, + }); + + expect(resolvedDevices).toEqual(["simulator-id"]); + expect(launchCalls).toEqual([[ + "com.example.app", + "fr-FR", + { + feature: "it's enabled", + config: "{\"url\":\"https://example.test?a=1&b=2\"}", + }, + ]]); + expect(result.content).toEqual([ + { type: "text", text: "Launched app com.example.app" }, + ]); + } finally { + await client.close(); + await server.close(); + if (previousTelemetry === undefined) { + delete process.env.MOBILEMCP_DISABLE_TELEMETRY; + } else { + process.env.MOBILEMCP_DISABLE_TELEMETRY = previousTelemetry; + } + } + }); + + test("should reject invalid launch arguments before resolving the device", async () => { + let resolverCalls = 0; + const previousTelemetry = process.env.MOBILEMCP_DISABLE_TELEMETRY; + process.env.MOBILEMCP_DISABLE_TELEMETRY = "1"; + const server = createMcpServer({ + getRobotFromDevice: () => { + resolverCalls += 1; + throw new Error("device resolver should not be called"); + }, + }); + const client = new Client({ name: "launch-args-validation-test", version: "1.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "mobile_launch_app", + arguments: { + device: "simulator-id", + packageName: "com.example.app", + launchArgs: { "bad key": "value" }, + }, + }); + + expect(resolverCalls).toBe(0); + expect(result.content).toEqual([{ + type: "text", + text: "Invalid launch argument key: \"bad key\". Please fix the issue and try again.", + }]); + } finally { + await client.close(); + await server.close(); + if (previousTelemetry === undefined) { + delete process.env.MOBILEMCP_DISABLE_TELEMETRY; + } else { + process.env.MOBILEMCP_DISABLE_TELEMETRY = previousTelemetry; + } + } + }); +});