Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ The same generator also ships as an MCP server — `npx -y -p nativeapptemplate-
- `NATIVEAPPTEMPLATE_BRIDGE_DRY_RUN=1` — log what would be written to `~/.gradle/gradle.properties` instead of writing. Useful before granting the bridge write access to your user-global gradle.
- `NATIVEAPPTEMPLATE_AGENT_ANTHROPIC_KEY` — dedicated workspace key, see [Security](#security).
- `NATIVEAPPTEMPLATE_ADB_SERIAL` — the `adb -s` target when more than one Android device/emulator is attached. Without it the agent prefers an `emulator-*` serial, then the first device. Run `adb devices` to list serials.
- `NATIVEAPPTEMPLATE_MOBILE_IOS_DEVICE` / `NATIVEAPPTEMPLATE_MOBILE_ANDROID_DEVICE` — pin the device `mobile-mcp` drives in Stage 2 (device id or exact name) when several are booted.
- `NATIVEAPPTEMPLATE_MOBILE_IOS_DEVICE` / `NATIVEAPPTEMPLATE_MOBILE_ANDROID_DEVICE` — pin the device `mobile-mcp` drives in Stage 2 (device id or exact name) when several are booted. Without the Android one, Stage 2 walks the device the app was installed on: `NATIVEAPPTEMPLATE_ADB_SERIAL`, else an `emulator-*`, else the first device.
- `NATIVEAPPTEMPLATE_IOS_DESTINATION` — the `xcodebuild -destination` for build mode (default `platform=iOS Simulator,name=iPhone 17 Pro,OS=26.2`). Set it if you don't have that simulator, e.g. `platform=iOS Simulator,name=iPhone 16`.
- `NATIVEAPPTEMPLATE_API_SCHEME` — `http` or `https` for the generated apps' API URL (read by the env bridge alongside the substrate Rails `.env` HOST/PORT).
- `NATIVEAPPTEMPLATE_FORMAT=off` — skip the formatters the agent runs on each generated app after the rename: `bin/rubocop -a` (Rails, under `mise exec` when mise is installed, after `bundle install` if needed), `./gradlew spotlessApply` (Android) and `swiftformat <Product>` (iOS app sources, as the template's `make format` does; skipped when `swiftformat` isn't on PATH). The rename changes identifier lengths and import order, which the templates' lint configs flag although the builds pass. Anything a formatter can't autocorrect shows in the validation report's "Formatters" section and in `tmp/trace/<platform>.log`; it doesn't change the verdict. On by default; Spotless adds a Gradle run (seconds warm, a minute or two cold).
Expand Down
16 changes: 14 additions & 2 deletions src/validation/stage2-judge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,8 +287,15 @@ export async function selectDevice(
client: MobileClient,
platform: "ios" | "android",
): Promise<string | undefined> {
const overrideKey =
// Without a Stage 2 override, Android walks the device the app was
// installed on (selectAdbTarget in launch.ts): with a phone attached too,
// the first Android device listed is not necessarily that one.
const stage2Key =
platform === "ios" ? "NATIVEAPPTEMPLATE_MOBILE_IOS_DEVICE" : "NATIVEAPPTEMPLATE_MOBILE_ANDROID_DEVICE";
const overrideKey =
platform === "android" && !process.env[stage2Key] && process.env["NATIVEAPPTEMPLATE_ADB_SERIAL"]
? "NATIVEAPPTEMPLATE_ADB_SERIAL"
: stage2Key;
const override = process.env[overrideKey];

let devices: readonly ScreenElement[];
Expand Down Expand Up @@ -327,7 +334,12 @@ export async function selectDevice(
return `no mobile-mcp devices available (boot the ${platform} sim/emulator first)`;
}

const match = devices.find((d) => devicePlatformMatches(d, platform));
const candidates = devices.filter((d) => devicePlatformMatches(d, platform));
// Same preference as selectAdbTarget, which picked the device the app was
// installed on: an emulator before a phone
const match =
(platform === "android" ? candidates.find((d) => deviceNameOf(d)?.startsWith("emulator-")) : undefined) ??
candidates[0];
if (!match) {
const names = devices.map((d) => deviceNameOf(d) ?? "<unnamed>").join(", ");
return `no mobile-mcp device matched platform=${platform} (saw: ${names}). Override with ${overrideKey}=<device-name-or-id>.`;
Expand Down
61 changes: 61 additions & 0 deletions tests/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { chmodSync } from "node:fs";
// override set it themselves.
delete process.env['NATIVEAPPTEMPLATE_MOBILE_IOS_DEVICE'];
delete process.env['NATIVEAPPTEMPLATE_MOBILE_ANDROID_DEVICE'];
delete process.env['NATIVEAPPTEMPLATE_ADB_SERIAL'];

// rename.rb is a Ruby subprocess; CI runs the suite in stub mode with no
// Ruby step, so guard the one test that shells out to it.
Expand Down Expand Up @@ -1633,6 +1634,66 @@ test("selectDevice resolves the IOS override (display name -> canonical id) and
await fake.close();
});

// Found on 2026-10-29: with a phone and an emulator attached and only
// NATIVEAPPTEMPLATE_ADB_SERIAL=emulator-5554 set, the app was installed on the
// emulator but Stage 2 walked the phone (the first Android device listed) and
// failed on another app's screen. The walk has to use the device the app was
// installed on.
test("selectDevice walks the Android device the app was installed on when no Stage 2 override is set", async () => {
const { attachMobileClient } = await import("../src/mobile.js");
const { selectDevice } = await import("../src/validation/stage2-judge.js");
const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js");
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
const { z } = await import("zod");

const fake = new McpServer({ name: "fake-mobile-mcp", version: "0.0.0" });
fake.registerTool(
"mobile_list_available_devices",
{ description: "fake", inputSchema: {} },
async () => ({
content: [{
type: "text",
text: JSON.stringify({ devices: [
{ id: "1C081FDF600CMG", name: "Pixel 6", platform: "android", type: "real" },
{ id: "emulator-5554", name: "sdk_gphone64_arm64", platform: "android", type: "emulator" },
] }),
}],
}),
);
const calls: { device?: unknown }[] = [];
fake.registerTool(
"mobile_take_screenshot",
{ description: "fake", inputSchema: { device: z.string() } },
async (args) => { calls.push(args); return { content: [{ type: "image", data: "AAAA", mimeType: "image/png" }] }; },
);

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: "smoke", version: "0.0.0" });
await Promise.all([fake.connect(serverTransport), client.connect(clientTransport)]);
const mobile = attachMobileClient(client);

// The serial names the phone here, so only the fallback (not the emulator
// preference below) can pick it
process.env['NATIVEAPPTEMPLATE_ADB_SERIAL'] = "1C081FDF600CMG";
try {
assert.equal(await selectDevice(mobile, "android"), undefined);
await mobile.takeScreenshot();
assert.equal(calls[0]?.device, "1C081FDF600CMG", "the device NATIVEAPPTEMPLATE_ADB_SERIAL installed on");
} finally {
delete process.env['NATIVEAPPTEMPLATE_ADB_SERIAL'];
}

// Without NATIVEAPPTEMPLATE_ADB_SERIAL the install prefers an emulator-*
// serial (selectAdbTarget in launch.ts), so the walk has to as well
assert.equal(await selectDevice(mobile, "android"), undefined);
await mobile.takeScreenshot();
assert.equal(calls[1]?.device, "emulator-5554", "the emulator selectAdbTarget installs on, not the phone listed first");

await mobile.close();
await fake.close();
});

test("createMcpServer registers generate_app and routes through dispatch", async () => {
const { createMcpServer } = await import("../src/mcp.js");
const { InMemoryTransport } = await import(
Expand Down
Loading