diff --git a/README.md b/README.md index 2bbc3e2..a5af6df 100644 --- a/README.md +++ b/README.md @@ -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 ` (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/.log`; it doesn't change the verdict. On by default; Spotless adds a Gradle run (seconds warm, a minute or two cold). diff --git a/src/validation/stage2-judge.ts b/src/validation/stage2-judge.ts index 48c8953..e0bd746 100644 --- a/src/validation/stage2-judge.ts +++ b/src/validation/stage2-judge.ts @@ -287,8 +287,15 @@ export async function selectDevice( client: MobileClient, platform: "ios" | "android", ): Promise { - 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[]; @@ -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) ?? "").join(", "); return `no mobile-mcp device matched platform=${platform} (saw: ${names}). Override with ${overrideKey}=.`; diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index 5a463bc..f7c8579 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -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. @@ -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(