Skip to content
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
99 changes: 99 additions & 0 deletions .github/workflows/ios-simulator.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
name: iOS Simulator Tests

on:
pull_request:
branches:
- main
push:
branches:
- main
- codex/fix-android-mobilecli-device-id
workflow_dispatch:

permissions:
contents: read

jobs:
ios-simulator:
runs-on: macos-14
timeout-minutes: 30

steps:
- uses: actions/checkout@v5

- name: Use Node.js 24
uses: actions/setup-node@v5
with:
node-version: 24
cache: npm

- name: Install dependencies
run: npm ci --ignore-scripts

- name: Select an available iOS simulator
shell: bash
run: |
set -euo pipefail

xcode-select -p
xcodebuild -version
xcrun simctl list runtimes
xcrun simctl list devicetypes | head -n 20

runtime="$(xcrun simctl list runtimes | awk \
'/^iOS 18/ && $0 !~ /unavailable/ { match($0, /com\.apple\.CoreSimulator\.SimRuntime\.iOS-[^ ]+/); if (RSTART) { print substr($0, RSTART, RLENGTH); exit } }')"
if [ -z "$runtime" ]; then
echo "No iOS 18 runtime found; using the newest installed iOS runtime"
runtime="$(xcrun simctl list runtimes | awk \
'/^iOS / && $0 !~ /unavailable/ { match($0, /com\.apple\.CoreSimulator\.SimRuntime\.iOS-[^ ]+/); if (RSTART) { print substr($0, RSTART, RLENGTH); exit } }')"
fi

if [[ "$runtime" == *"SimRuntime.iOS-18-"* ]]; then
device_type="$(xcrun simctl list devicetypes | awk -F '[()]' \
'/^iPhone 16 \(/ { print $2; exit }')"
else
device_type="$(xcrun simctl list devicetypes | awk -F '[()]' \
'/^iPhone 15 \(/ { print $2; exit }')"
fi

test -n "$runtime" || { echo "No available iOS runtime found"; exit 1; }
test -n "$device_type" || { echo "No available iPhone simulator type found"; exit 1; }

udid="$(xcrun simctl create mobile-mcp-ci "$device_type" "$runtime")"
echo "SIMULATOR_UDID=$udid" >> "$GITHUB_ENV"
echo "Using $device_type on $runtime ($udid)"

- name: Boot simulator
run: |
set -euo pipefail
xcrun simctl boot "$SIMULATOR_UDID"
xcrun simctl bootstatus "$SIMULATOR_UDID" -b

- name: Verify mobilecli can discover the simulator
run: |
set -euo pipefail
./node_modules/.bin/mobilecli devices --platform ios --type simulator
./node_modules/.bin/mobilecli devices --platform ios --type simulator \
| grep -F "$SIMULATOR_UDID"

- name: Install the Mobile MCP device agent
run: |
set -euo pipefail
./node_modules/.bin/mobilecli agent install \
--device "$SIMULATOR_UDID" \
--insecure-storage

- name: Run iOS simulator tests
run: npm run test:ios-simulator

- name: Collect simulator diagnostics
if: failure()
run: |
xcrun simctl list devices
./node_modules/.bin/mobilecli devices --platform ios --type simulator || true

- name: Clean up simulator
if: always()
run: |
xcrun simctl shutdown "$SIMULATOR_UDID" || true
xcrun simctl delete "$SIMULATOR_UDID" || true
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"lint": "eslint .",
"fixlint": "eslint . --fix",
"test": "c8 playwright test",
"test:ios-simulator": "c8 playwright test test/iphone-simulator.ts",
"watch": "tsc --watch",
"clean": "rm -rf lib",
"prepare": "husky"
Expand Down
14 changes: 13 additions & 1 deletion src/android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,16 @@ interface UiAutomatorXml {
};
}

/**
* Returns the platform-specific ADB executable filename.
*
* @param platform - Node.js platform identifier.
* @returns The ADB filename for the platform.
*/
const adbExecutableName = (platform: NodeJS.Platform): string => platform === "win32" ? "adb.exe" : "adb";

const getAdbPath = (): string => {
const exeName = process.env.platform === "win32" ? "adb.exe" : "adb";
const exeName = adbExecutableName(process.platform);
if (process.env.ANDROID_HOME) {
return path.join(process.env.ANDROID_HOME, "platform-tools", exeName);
}
Expand All @@ -54,6 +62,10 @@ const getAdbPath = (): string => {
return exeName;
};

export const __testInternals = {
adbExecutableName,
};

const BUTTON_MAP: Record<Button, string> = {
"BACK": "KEYCODE_BACK",
"HOME": "KEYCODE_HOME",
Expand Down
131 changes: 128 additions & 3 deletions src/mobilecli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,37 @@ export interface MobilecliDevicesResponse {

const TIMEOUT = 30000;
const MAX_BUFFER_SIZE = 1024 * 1024 * 8;
const SCREEN_RECORDING_STARTED = "Screen recording has started";

/**
* Normalizes a device name for comparison across ADB and mobilecli.
*
* @param value - Device ID or display name.
* @returns A lowercase alphanumeric comparison key.
*/
function normalizeDeviceName(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
}

/**
* Converts a display name into the AVD identifier format used by mobilecli.
*
* @param value - Android emulator display name.
* @returns A sanitized AVD identifier.
*/
function mobilecliAvdId(value: string): string {
return value.trim().replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
}

/**
* Determines whether a device ID is an ADB emulator serial.
*
* @param deviceId - Device identifier to classify.
* @returns Whether the identifier matches the emulator port format.
*/
export function isAdbEmulatorId(deviceId: string): boolean {
return /^emulator-\d+$/.test(deviceId);
}

export class Mobilecli {
private path: string | null = null;
Expand All @@ -76,10 +107,69 @@ export class Mobilecli {
return execFileSync(path, args, { encoding: "utf8" }).toString().trim();
}

public spawnCommand(args: string[]): ChildProcess {
/**
* Starts screen recording and resolves after mobilecli reports readiness.
* Rejects on spawn errors, early exits, or startup timeout.
*
* @param args - Arguments passed to the mobilecli process.
* @returns The running process after recording startup is confirmed.
*/
public startScreenRecording(args: string[]): Promise<ChildProcess> {
const binaryPath = this.getPath();
return spawn(binaryPath, args, {
stdio: ["ignore", "ignore", "ignore"],
const child = spawn(binaryPath, args, {
stdio: ["ignore", "ignore", "pipe"],
});
const stderr = child.stderr!;

return new Promise((resolve, reject) => {
let output = "";
let settled = false;
const timer = setTimeout(() => {
child.kill();
fail(new Error("Timed out waiting for mobilecli to start screen recording"));
}, TIMEOUT);

/**
* Rejects an unsettled startup and removes its readiness listener.
*
* @param startupError - Error reported to the caller.
* @returns Nothing.
*/
function fail(startupError: Error): void {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
stderr.off("data", onData);
reject(startupError);
}
/**
* Resolves startup when stderr contains the recording-ready sentinel.
*
* @param chunk - Latest stderr data from mobilecli.
* @returns Nothing.
*/
function onData(chunk: Buffer): void {
output = (output + chunk.toString()).slice(-MAX_BUFFER_SIZE);
if (!settled && output.includes(SCREEN_RECORDING_STARTED)) {
settled = true;
clearTimeout(timer);
stderr.off("data", onData);
stderr.resume();
resolve(child);
}
}

stderr.on("data", onData);
child.once("error", fail);
child.once("exit", (code, signal) => {
if (settled) {
return;
}
const status = signal ? `signal ${signal}` : `code ${code ?? "unknown"}`;
fail(new Error(output.trim() || `mobilecli exited before screen recording started (${status})`));
});
});
}

Expand Down Expand Up @@ -168,6 +258,41 @@ export class Mobilecli {
return JSON.parse(output.toString().trim()) as MobilecliCrashGetResponse;
}

/**
* Resolves an ADB emulator serial to the corresponding mobilecli AVD ID.
*
* @param deviceId - ADB device serial.
* @param deviceName - Android device or AVD display name.
* @returns The identifier accepted by mobilecli.
*/
resolveAndroidDeviceId(deviceId: string, deviceName: string): string {
if (!isAdbEmulatorId(deviceId)) {
return deviceId;
}

const response = this.getDevices({ platform: "android" });
const devices = response.data?.devices ?? [];
const exactMatch = devices.find(device => device.id === deviceId);
if (exactMatch) {
return exactMatch.id;
}

const normalizedName = normalizeDeviceName(deviceName);
if (!normalizedName) {
return deviceId;
}
const nameMatch = devices.find(device =>
normalizeDeviceName(device.id) === normalizedName ||
normalizeDeviceName(device.name) === normalizedName
);
if (nameMatch) {
return nameMatch.id;
}

const avdId = mobilecliAvdId(deviceName);
return avdId || deviceId;
}

agentStatus(deviceId: string): MobilecliAgentStatusResponse {
const output = this.executeCommand(["agent", "status", "--device", deviceId]);
return JSON.parse(output) as MobilecliAgentStatusResponse;
Expand Down
29 changes: 24 additions & 5 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { ActionableError, Robot } from "./robot";
import { IosManager, IosRobot } from "./ios";
import { PNG } from "./png";
import { isScalingAvailable, Image } from "./image-utils";
import { Mobilecli } from "./mobilecli";
import { isAdbEmulatorId, Mobilecli } from "./mobilecli";
import { MobileDevice } from "./mobile-device";
import { validateOutputPath, validateFileExtension } from "./utils";

Expand Down Expand Up @@ -163,6 +163,24 @@ export const createMcpServer = (): McpServer => {
}
};

/**
* Resolves emulator serials while leaving physical and iOS IDs unchanged.
*
* @param deviceId - Device identifier supplied to an MCP tool.
* @returns The identifier accepted by mobilecli.
*/
const getMobilecliDeviceId = (deviceId: string): string => {
if (!isAdbEmulatorId(deviceId)) {
return deviceId;
}
const androidDevice = new AndroidDeviceManager()
.getConnectedDevicesWithDetails()
.find(device => device.deviceId === deviceId);
return androidDevice
? mobilecli.resolveAndroidDeviceId(deviceId, androidDevice.name)
: deviceId;
};

const getRobotFromDevice = (deviceId: string): Robot => {

// from now on, we must have mobilecli working
Expand Down Expand Up @@ -752,12 +770,13 @@ export const createMcpServer = (): McpServer => {

const outputPath = output || path.join(os.tmpdir(), `screen-recording-${Date.now()}.mp4`);

const args = ["screenrecord", "--device", device, "--output", outputPath, "--silent"];
const mobilecliDevice = getMobilecliDeviceId(device);
const args = ["screenrecord", "--device", mobilecliDevice, "--output", outputPath];
if (timeLimit !== undefined) {
args.push("--time-limit", String(timeLimit));
}

const child = mobilecli.spawnCommand(args);
const child = await mobilecli.startScreenRecording(args);

const cleanup = () => {
activeRecordings.delete(device);
Expand Down Expand Up @@ -830,7 +849,7 @@ export const createMcpServer = (): McpServer => {
{ readOnlyHint: true },
async ({ device }) => {
ensureMobilecliAvailable();
const response = mobilecli.crashesList(device);
const response = mobilecli.crashesList(getMobilecliDeviceId(device));
return JSON.stringify(response.data);
}
);
Expand All @@ -846,7 +865,7 @@ export const createMcpServer = (): McpServer => {
{ readOnlyHint: true },
async ({ device, id }) => {
ensureMobilecliAvailable();
const response = mobilecli.crashesGet(device, id);
const response = mobilecli.crashesGet(getMobilecliDeviceId(device), id);
return response.data.content;
}
);
Expand Down
8 changes: 8 additions & 0 deletions test/android-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { expect, test } from "@playwright/test";

import { __testInternals } from "../src/android";

test("uses the Windows adb executable name when ANDROID_HOME is configured", () => {
expect(__testInternals.adbExecutableName("win32")).toBe("adb.exe");
expect(__testInternals.adbExecutableName("linux")).toBe("adb");
});
Loading