From 1c4738e11c727307a99209c3a368a3cf5d2ef9a2 Mon Sep 17 00:00:00 2001 From: JF Date: Fri, 9 Oct 2026 12:24:02 -0400 Subject: [PATCH 1/2] feat(dart): Flutter on an Android emulator, measured end to end (#790) M3 of #790. `deviceId: "emulator-5554"` already reached `flutter run -d` through M1's wiring; this proves it through the server and pins it: - tests/e2e/mcp-server-smoke-flutter-android.test.ts: `flutter run` on the emulator with a breakpoint in build() (widget state, continue keeps it running, close terminates the app) and an integration_test on the emulator whose tap hits the app's breakpoint. Opt-in by booting an emulator; self-skips without Flutter, adb or an online emulator, and with MCP_SKIP_FLUTTER_ANDROID=1. Never runs on CI. - tests/e2e/dart-example-utils.ts: adb discovery, the online-emulator gate, prepareFlutterAndroid (flutter create --platforms=android, one debug APK build, adb install -r -t) and resetAndroidApp (force-stop + forward --remove-all before every launch). Measured: the first run after a fresh install once ended without a stop; the next was fine. - Docs: the guide's Android numbers (build() breakpoint ~21 s, integration_test ~28 s warm; a cold Gradle build is minutes and belongs to the first build), KNOWN_ISSUES (emulator attach does not connect, #882; stop the previous instance first), the spike notes' M3 table, the e2e README, the changelog fragment. Co-Authored-By: Claude Fable 5.1 --- changelog.d/790.added.md | 2 +- docs/KNOWN_ISSUES.md | 10 + docs/dart/README.md | 10 + docs/dart/spike-notes.md | 16 ++ tests/e2e/README.md | 5 + tests/e2e/dart-example-utils.ts | 74 ++++++++ .../mcp-server-smoke-flutter-android.test.ts | 174 ++++++++++++++++++ 7 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/mcp-server-smoke-flutter-android.test.ts diff --git a/changelog.d/790.added.md b/changelog.d/790.added.md index c48bfc35..135ec116 100644 --- a/changelog.d/790.added.md +++ b/changelog.d/790.added.md @@ -1 +1 @@ -**Dart and Flutter debugging** via the SDK's own debug adapters (`dart debug_adapter`, `flutter debug-adapter`) behind a TCP-to-stdio bridge: Dart programs and `dart test`, Flutter widget and integration tests and `flutter run` on a desktop device (`deviceId`), attach by VM-service URI; the Docker image carries the Dart SDK (#790) +**Dart and Flutter debugging** via the SDK's own debug adapters (`dart debug_adapter`, `flutter debug-adapter`) behind a TCP-to-stdio bridge: Dart programs and `dart test`, Flutter widget and integration tests and `flutter run` on a desktop device or an Android emulator (`deviceId`), attach by VM-service URI; the Docker image carries the Dart SDK (#790) diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index fbbf52fd..77d7d2d4 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -108,6 +108,16 @@ Dart 3.13 and Flutter 3.47 — and none of them is a session error. adapter sends `terminated` but no `exited` event, so `exitCode` is unknown in `list_debug_sessions`; the test results (`✓ name`) are in `get_output`. The Dart CLI adapter does report `exited` (255 for an uncaught exception). +- **Attaching to an app already running on an Android emulator does not connect.** The + VM-service URI another `flutter run --machine` reports is host-forwarded through that tool's + own adb forward; `attach_to_process` with it reaches `flutter.appStart` and then waits on + "Connecting to the VM Service is taking longer than expected" (measured, 60 s). The same + recipe works for desktop apps and Dart programs. Launch the app through mcp-debugger instead; + #882 tracks the `--device-vmservice-port` / `--host-vmservice-port` / `--no-dds` measurements. +- **On an Android emulator, stop the previous app instance before the next launch.** A live + instance being replaced by the next install, or a stale adb port forward, made a launch hang + or end without a stop (measured). `adb shell am force-stop ` and + `adb forward --remove-all` first; the e2e helper does both, and installs the APK up front. - **`dart test` fails under `%LOCALAPPDATA%\Temp` with the winget SDK (Windows).** package:test spawns its frontend server through a cwd-relative SDK path that does not resolve from there (`The system cannot find the file specified … dartaotruntime.exe`). diff --git a/docs/dart/README.md b/docs/dart/README.md index 8f3780e5..5780ceae 100644 --- a/docs/dart/README.md +++ b/docs/dart/README.md @@ -134,6 +134,16 @@ timeout that allows the build. A `flutter test` or `flutter run` session ends `s an exit code (the adapter sends `terminated` only); the `✓ name` lines in `get_output` say how the tests went. +Android emulator (`deviceId: "emulator-5554"`, the AVD booted first; measured the same way with +`tests/e2e/mcp-server-smoke-flutter-android.test.ts`, Gradle warm): a `build()` breakpoint under +`flutter run` in ~21 s, an `integration_test` breakpoint in ~28 s with the app's breakpoint hit by +the test's tap right after; a cold Gradle build on a fresh machine takes minutes and belongs to +the first build, not to the debugger. Stop the previous app instance (`adb shell am force-stop +`) and clear forwards (`adb forward --remove-all`) before launching again; the e2e helper +does so, and installs the debug APK up front because the first run after a fresh install once +ended without stopping. Attaching to an app another `flutter run` started on the emulator does +not connect yet (#882; see `docs/KNOWN_ISSUES.md`). + ## Troubleshooting - *"No Dart SDK found"* — put `dart` or `flutter` on PATH, or set `DART_SDK` / `FLUTTER_ROOT`; diff --git a/docs/dart/spike-notes.md b/docs/dart/spike-notes.md index 5819f82b..84bcb159 100644 --- a/docs/dart/spike-notes.md +++ b/docs/dart/spike-notes.md @@ -346,3 +346,19 @@ Measured through `dist/index.js` with `tests/e2e/mcp-server-smoke-flutter.test.t adapter's `✓ increments` / `✓ device increment` lines and the app's `counter=1` are in `get_output`. `close_debug_session` on a running `flutter run` terminates the app and the adapter exits by itself. + +## Android emulator through the server (M3, 2026-10-09) + +Measured through `dist/index.js` with `tests/e2e/mcp-server-smoke-flutter-android.test.ts` +(AVD `mcp_api35`, API 35, booted headless; Gradle warm; the debug APK installed up front): + +| Case | Result | +|---|---| +| `flutter run -d emulator-5554`, breakpoint in `build()` | `pending: true` at 2.5 s; the breakpoint at **21.3 s**; `_ProbeAppState.build`, `counter` → `0`; `continue` keeps it running; `close_debug_session` terminates the app | +| the same, first launch right after a fresh APK install | ended at 27.9 s with "The program ended without reporting an exit code" and no stop (once; the next launch was fine) — hence the up-front install and the force-stop before every launch | +| `integration_test` on the emulator | test breakpoint at **28.3 s**, the app's `increment()` breakpoint at **28.7 s** on the test's tap, `counter` → `0`, `✓ device increment`, end `stopped` at 31.6 s | + +- `flutter build apk --debug` on the fresh example with warm Gradle caches: **45 s**. +- Before each launch: `adb shell am force-stop com.example.flutter_probe`, `adb forward --remove-all`. +- The proxy log records output events by shape only (issue #852): to read the device's output + after the fact, `get_output` before the session closes. diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 82ada9e3..77f5e554 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -85,6 +85,11 @@ This directory contains end-to-end smoke tests that verify the MCP debugger serv `flutter-host` lane sets it and runs the widget-test cases only; `scripts/check-flutter-e2e-report.mjs` requires them). The helper generates the probe's platform folders with `flutter create` on first use +- `mcp-server-smoke-flutter-android.test.ts` runs `flutter run` and the `integration_test` on a + booted Android emulator (`flutter emulators --launch ` is the opt-in; it self-skips without + Flutter, adb or an online emulator, and with `MCP_SKIP_FLUTTER_ANDROID=1`). The helper generates + `android/`, builds the debug APK once, installs it, and stops the app and clears adb forwards + before each launch. Never runs on CI ### 13. `mcp-server-smoke-javascript-sse.test.ts` - Tests JavaScript adapter over SSE transport diff --git a/tests/e2e/dart-example-utils.ts b/tests/e2e/dart-example-utils.ts index 2364bde9..ea8e6d51 100644 --- a/tests/e2e/dart-example-utils.ts +++ b/tests/e2e/dart-example-utils.ts @@ -201,6 +201,80 @@ export function flutterDesktopDeviceId(): string | null { return flutterDeviceIds().includes(want) ? want : null; } +// ---- Android ------------------------------------------------------------------------------------ + +export const FLUTTER_PROBE_PACKAGE = 'com.example.flutter_probe'; + +/** `adb` from ANDROID_SDK_ROOT / ANDROID_HOME / PATH; null when none. */ +export function adbPath(): string | null { + const exe = process.platform === 'win32' ? 'adb.exe' : 'adb'; + for (const key of ['ANDROID_SDK_ROOT', 'ANDROID_HOME']) { + const root = process.env[key]; + if (root && existsSync(path.join(root, 'platform-tools', exe))) return path.join(root, 'platform-tools', exe); + } + const onPath = which.sync('adb', { nothrow: true }); + if (onPath) return onPath; + if (process.platform === 'win32' && process.env.LOCALAPPDATA) { + const candidate = path.join(process.env.LOCALAPPDATA, 'Android', 'Sdk', 'platform-tools', exe); + if (existsSync(candidate)) return candidate; + } + return null; +} + +function adb(args: string[], timeoutMs = 120_000): { status: number | null; stdout: string } { + const exe = adbPath(); + if (!exe) return { status: null, stdout: '' }; + const r = spawnSync(exe, args, { encoding: 'utf8', windowsHide: true, timeout: timeoutMs }); + return { status: r.status, stdout: r.stdout ?? '' }; +} + +let cachedEmulator: string | null | undefined; + +/** + * An Android emulator that is booted (`adb devices` says `device`, not `offline`) and that + * `flutter devices` lists; null otherwise, and with `MCP_SKIP_FLUTTER_ANDROID=1`. Booting one is + * the opt-in: `flutter emulators --launch ` (headless flags in docs/dart/spike-notes.md). + */ +export function flutterEmulatorDeviceId(): string | null { + if (cachedEmulator !== undefined) return cachedEmulator; + if (process.env.MCP_SKIP_FLUTTER_ANDROID === '1' || !adbPath()) return (cachedEmulator = null); + const online = new Set( + adb(['devices']).stdout.split(/\r?\n/).map((l) => l.trim().split(/\s+/)).filter((p) => p.length === 2 && p[1] === 'device').map((p) => p[0]) + ); + cachedEmulator = flutterDeviceIds().find((id) => id.startsWith('emulator-') && online.has(id)) ?? null; + return cachedEmulator; +} + +/** Stop the probe app and drop stale port forwards: a live previous instance or a stale forward makes the next launch hang (measured). */ +export function resetAndroidApp(deviceId: string): void { + adb(['-s', deviceId, 'shell', 'am', 'force-stop', FLUTTER_PROBE_PACKAGE], 30_000); + adb(['forward', '--remove-all'], 30_000); +} + +let androidPrepared = false; + +/** + * Generate the probe's `android/` folder, build the debug APK once and install it on the device, + * so the launches under test never pay the first install (the first `flutter run` after a fresh + * install ended without a stop once, measured; the next one was fine). Warm Gradle: ~45 s. + */ +export function prepareFlutterAndroid(deviceId: string): void { + if (androidPrepared) return; + prepareFlutterProbe(); + const root = findFlutterRootSync(); + if (!root) throw new Error('No Flutter SDK found for the examples'); + if (!existsSync(path.join(FLUTTER_PROBE_DIR, 'android'))) { + const r = runFlutter(root, ['create', '--platforms=android', '--project-name', 'flutter_probe', '.'], FLUTTER_PROBE_DIR); + if (r.status !== 0) throw new Error(`flutter create --platforms=android failed: ${r.out}`); + } + const built = runFlutter(root, ['build', 'apk', '--debug'], FLUTTER_PROBE_DIR, 900_000); + if (built.status !== 0) throw new Error(`flutter build apk --debug failed: ${built.out}`); + const apk = path.join(FLUTTER_PROBE_DIR, 'build', 'app', 'outputs', 'flutter-apk', 'app-debug.apk'); + const installed = adb(['-s', deviceId, 'install', '-r', '-t', apk], 180_000); + if (installed.status !== 0) throw new Error(`adb install failed: ${installed.stdout}`); + androidPrepared = true; +} + export const FLUTTER_EXAMPLES = { main: path.join(FLUTTER_PROBE_DIR, 'lib', 'main.dart'), widgetTest: path.join(FLUTTER_PROBE_DIR, 'test', 'widget_test.dart'), diff --git a/tests/e2e/mcp-server-smoke-flutter-android.test.ts b/tests/e2e/mcp-server-smoke-flutter-android.test.ts new file mode 100644 index 00000000..46dfdbce --- /dev/null +++ b/tests/e2e/mcp-server-smoke-flutter-android.test.ts @@ -0,0 +1,174 @@ +/** + * Flutter on an Android emulator through the MCP server (issue #790, M3): `flutter run -d + * emulator-NNNN` with a breakpoint in `build()`, and an `integration_test` on the emulator whose + * tap hits the app's breakpoint. Measured through the real server on 2026-10-09 (Flutter 3.47.7, + * Windows, AVD `mcp_api35` booted headless, Gradle warm): the `build()` breakpoint in ~21 s, the + * integration test's breakpoints in ~28 s; the first `flutter run` after a fresh APK install once + * ended without a stop, so the helper installs the APK up front and stops the app and clears + * port forwards before every launch. + * + * Opt-in by booting an emulator (`flutter emulators --launch `); self-skips without Flutter, + * adb, an online emulator, or with `MCP_SKIP_FLUTTER_ANDROID=1`. CI never runs it. + */ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { existsSync } from 'fs'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { parseSdkToolResult, callToolSafely } from './smoke-test-utils.js'; +import { skipIfSpawnBlocked } from '../test-utils/helpers/adapter-spawn.js'; +import { + FLUTTER_EXAMPLES, FLUTTER_PROBE_DIR, bpLine, findFlutterRootSync, flutterEmulatorDeviceId, hasFlutterToolchain, + prepareFlutterAndroid, resetAndroidApp, +} from './dart-example-utils.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const ROOT = path.resolve(__dirname, '../..'); + +const EMULATOR = hasFlutterToolchain() ? flutterEmulatorDeviceId() : null; + +interface Frame { id?: number; file?: string; name?: string; line?: number } + +describe.skipIf(!EMULATOR)(`MCP Server Flutter Android Smoke Test @requires-flutter @requires-emulator (${EMULATOR ?? 'no emulator online'})`, () => { + let mcpClient: Client | null = null; + let transport: StdioClientTransport | null = null; + let sessionId: string | null = null; + + beforeAll(async () => { + const distEntry = path.join(ROOT, 'dist', 'index.js'); + if (!existsSync(distEntry)) { + throw new Error(`Debug MCP dist build missing at ${distEntry}. Run "pnpm build" before executing tests.`); + } + prepareFlutterAndroid(EMULATOR!); + transport = new StdioClientTransport({ + command: process.execPath, + args: [distEntry, '--log-level', 'info'], + env: { ...process.env, NODE_ENV: 'test', FLUTTER_ROOT: findFlutterRootSync() ?? '' } + }); + mcpClient = new Client({ name: 'flutter-android-smoke-test-client', version: '1.0.0' }, { capabilities: {} }); + await mcpClient.connect(transport); + }, 1_200_000); + + afterEach(async () => { + if (sessionId && mcpClient) { + await callToolSafely(mcpClient, 'close_debug_session', { sessionId }); + sessionId = null; + } + }); + + afterAll(async () => { + await mcpClient?.close(); + mcpClient = null; + await transport?.close(); + transport = null; + }); + + const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + + async function call(name: string, args: Record): Promise> { + return parseSdkToolResult(await mcpClient!.callTool({ name, arguments: { sessionId, ...args } })) as Record; + } + + async function frames(): Promise { + const res = await call('get_stack_trace', { includeInternals: false }); + return (res.stackFrames ?? []) as Frame[]; + } + + async function getSession() { + const res = parseSdkToolResult(await mcpClient!.callTool({ name: 'list_debug_sessions', arguments: {} })); + const sessions = (res.sessions ?? []) as Array<{ id: string; state?: string; exitCode?: number; lastStop?: { reason?: string } }>; + return sessions.find(s => s.id === sessionId); + } + + async function pollState(want: string, timeoutMs: number) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const snap = await getSession(); + if (snap?.state === want) return snap; + await wait(500); + } + return undefined; + } + + async function evaluate(expression: string): Promise { + return String((await call('evaluate_expression', { expression })).result); + } + + async function output(): Promise { + const res = await callToolSafely(mcpClient!, 'get_output', { sessionId }); + const entries = ((res as { entries?: Array<{ text?: string; output?: string }> }).entries ?? []); + return entries.map(e => e.text ?? e.output ?? '').join(''); + } + + async function startOrSkip(ctx: { skip: (reason?: string) => void }, args: Record) { + resetAndroidApp(EMULATOR!); + const res = await call('start_debugging', args); + if (!res.success) { + skipIfSpawnBlocked(ctx as never, res, 'Flutter'); + throw new Error(`start_debugging failed: ${JSON.stringify(res, null, 2)}`); + } + return res; + } + + async function firstStop(launched: Record, timeoutMs: number) { + if (launched.state === 'paused') return (await getSession())!; + const waited = await call('wait_for_stop', { timeout: timeoutMs }); + expect(waited.state, `first stop (wait_for_stop answered ${JSON.stringify(waited)}; output: ${(await output()).slice(-600)})`).toBe('paused'); + return (await getSession())!; + } + + async function createSession(name: string) { + sessionId = (await call('create_debug_session', { language: 'dart', name })).sessionId as string; + expect(sessionId).toBeDefined(); + } + + it('flutter run on the emulator: breaks in build(), reads the widget state, keeps running after continue', async (ctx) => { + const line = bpLine(FLUTTER_EXAMPLES.main, 'BUILD'); + await createSession('flutter-android'); + expect((await call('set_breakpoint', { file: FLUTTER_EXAMPLES.main, line })).success).toBe(true); + const launched = await startOrSkip(ctx, { + scriptPath: FLUTTER_EXAMPLES.main, dapLaunchArgs: { stopOnEntry: false, cwd: FLUTTER_PROBE_DIR, deviceId: EMULATOR }, + }); + const paused = await firstStop(launched, 300000); + expect(paused.lastStop?.reason).toBe('breakpoint'); + const top = (await frames())[0]; + expect(top.name).toBe('_ProbeAppState.build'); + expect(top.line).toBe(line); + expect(await evaluate('counter')).toBe('0'); + expect(await output()).toContain('Connected to the VM Service.'); + + await callToolSafely(mcpClient!, 'continue_execution', { sessionId }); + await wait(1500); + expect((await getSession())?.state).toBe('running'); + expect((await callToolSafely(mcpClient!, 'close_debug_session', { sessionId })).success).toBe(true); + sessionId = null; + }, 600000); + + it('integration test on the emulator: the test\'s tap hits the app\'s breakpoint, and the run finishes', async (ctx) => { + const testLine = bpLine(FLUTTER_EXAMPLES.integrationTest, 'INTEGRATION'); + const appLine = bpLine(FLUTTER_EXAMPLES.main, 'INCREMENT'); + await createSession('flutter-android-integration'); + expect((await call('set_breakpoint', { file: FLUTTER_EXAMPLES.integrationTest, line: testLine })).success).toBe(true); + expect((await call('set_breakpoint', { file: FLUTTER_EXAMPLES.main, line: appLine })).success).toBe(true); + const launched = await startOrSkip(ctx, { + scriptPath: FLUTTER_EXAMPLES.integrationTest, dapLaunchArgs: { stopOnEntry: false, cwd: FLUTTER_PROBE_DIR, deviceId: EMULATOR }, + }); + const atTest = await firstStop(launched, 300000); + expect(atTest.lastStop?.reason).toBe('breakpoint'); + expect((await frames())[0].line).toBe(testLine); + + await callToolSafely(mcpClient!, 'continue_execution', { sessionId }); + const atApp = await call('wait_for_stop', { timeout: 120000 }); + expect(atApp.state, JSON.stringify(atApp)).toBe('paused'); + const top = (await frames())[0]; + expect(top.file?.toLowerCase()).toBe(FLUTTER_EXAMPLES.main.toLowerCase()); + expect(top.line).toBe(appLine); + expect(await evaluate('counter')).toBe('0'); + + await callToolSafely(mcpClient!, 'continue_execution', { sessionId }); + expect(await pollState('stopped', 180000), 'the integration test should finish').toBeDefined(); + expect(await output()).toContain('✓ device increment'); + }, 900000); +}); From 96f7576ae105e25185055f1be945c262f82da710 Mon Sep 17 00:00:00 2001 From: JF Date: Fri, 9 Oct 2026 12:48:37 -0400 Subject: [PATCH 2/2] test(dart): narrow the Android e2e helper to what the Flutter tool does not already do (#790) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the scoped review of #883: - `adb forward --remove-all` is host-wide (killforward-all drops every device's listeners), so it took other tools' forwards with it. The helper now removes only this emulator's entries from `adb forward --list`. - `flutter run` force-stops, builds and installs on every launch (android_device.dart startApp → stopApp, installApp), and `flutter build apk --debug` builds three ABIs while `flutter run` builds the device's, so the warm-up used to be replaced by the first launch. The prebuild now targets the device's ABI (ro.product.cpu.abi); the docstring, guide, KNOWN_ISSUES and spike notes say what it buys — a warm Gradle cache and the first install — instead of describing it as a fix, and the once-seen "ended without a stop" is labelled as seen once. - `adb` errors now carry stderr (install failures report their reason), and an incompatible installed APK is uninstalled once and retried, the way flutter_tools does. - adb discovery covers Android Studio's default SDK paths on macOS and Linux; the emulator gate consults Flutter's device list before starting an adb server. - Both Flutter suites wait for a stop in `wait_for_stop` slices of at most 45 s: the MCP client caps a request at 60 s, and one long server-side wait failed on the client side when a build kept the first stop away for 63 s (seen on the emulator's integration case). Co-Authored-By: Claude Fable 5.1 --- docs/KNOWN_ISSUES.md | 12 ++-- docs/dart/README.md | 10 +-- docs/dart/spike-notes.md | 4 +- tests/e2e/README.md | 5 +- tests/e2e/dart-example-utils.ts | 69 +++++++++++++++---- .../mcp-server-smoke-flutter-android.test.ts | 33 ++++++--- tests/e2e/mcp-server-smoke-flutter.test.ts | 25 +++++-- 7 files changed, 117 insertions(+), 41 deletions(-) diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index 77d7d2d4..5d4d29ef 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -114,10 +114,14 @@ Dart 3.13 and Flutter 3.47 — and none of them is a session error. "Connecting to the VM Service is taking longer than expected" (measured, 60 s). The same recipe works for desktop apps and Dart programs. Launch the app through mcp-debugger instead; #882 tracks the `--device-vmservice-port` / `--host-vmservice-port` / `--no-dds` measurements. -- **On an Android emulator, stop the previous app instance before the next launch.** A live - instance being replaced by the next install, or a stale adb port forward, made a launch hang - or end without a stop (measured). `adb shell am force-stop ` and - `adb forward --remove-all` first; the e2e helper does both, and installs the APK up front. +- **On an Android emulator, the first launch after a fresh install may end without a stop.** + Seen once: the first `flutter run` right after the debug APK was first installed ended with + "The program ended without reporting an exit code" instead of pausing; the next launch was + fine. `flutter run` stops and reinstalls the app itself on every launch, so there is nothing + to do by hand; relaunch. The e2e helper warms the Gradle build and does the first install + before the timed cases. A cold Gradle build on a fresh machine (minutes) belongs to the first + build, not to the debugger: the launch answers `pending: true` and `wait_for_stop` collects + the stop. - **`dart test` fails under `%LOCALAPPDATA%\Temp` with the winget SDK (Windows).** package:test spawns its frontend server through a cwd-relative SDK path that does not resolve from there (`The system cannot find the file specified … dartaotruntime.exe`). diff --git a/docs/dart/README.md b/docs/dart/README.md index 5780ceae..e2e2d0ae 100644 --- a/docs/dart/README.md +++ b/docs/dart/README.md @@ -138,11 +138,11 @@ Android emulator (`deviceId: "emulator-5554"`, the AVD booted first; measured th `tests/e2e/mcp-server-smoke-flutter-android.test.ts`, Gradle warm): a `build()` breakpoint under `flutter run` in ~21 s, an `integration_test` breakpoint in ~28 s with the app's breakpoint hit by the test's tap right after; a cold Gradle build on a fresh machine takes minutes and belongs to -the first build, not to the debugger. Stop the previous app instance (`adb shell am force-stop -`) and clear forwards (`adb forward --remove-all`) before launching again; the e2e helper -does so, and installs the debug APK up front because the first run after a fresh install once -ended without stopping. Attaching to an app another `flutter run` started on the emulator does -not connect yet (#882; see `docs/KNOWN_ISSUES.md`). +the first build, not to the debugger. `flutter run` stops and reinstalls the app itself on every +launch; the e2e helper only warms the build for the device's ABI and does the first install +ahead of the timed cases, because the first run right after a fresh install once ended without +stopping (relaunch if that happens). Attaching to an app another `flutter run` started on the +emulator does not connect yet (#882; see `docs/KNOWN_ISSUES.md`). ## Troubleshooting diff --git a/docs/dart/spike-notes.md b/docs/dart/spike-notes.md index 84bcb159..bc687147 100644 --- a/docs/dart/spike-notes.md +++ b/docs/dart/spike-notes.md @@ -355,10 +355,10 @@ Measured through `dist/index.js` with `tests/e2e/mcp-server-smoke-flutter-androi | Case | Result | |---|---| | `flutter run -d emulator-5554`, breakpoint in `build()` | `pending: true` at 2.5 s; the breakpoint at **21.3 s**; `_ProbeAppState.build`, `counter` → `0`; `continue` keeps it running; `close_debug_session` terminates the app | -| the same, first launch right after a fresh APK install | ended at 27.9 s with "The program ended without reporting an exit code" and no stop (once; the next launch was fine) — hence the up-front install and the force-stop before every launch | +| the same, first launch right after a fresh APK install | ended at 27.9 s with "The program ended without reporting an exit code" and no stop (once; the next launch was fine) — hence the helper's warm prebuild for the device's ABI and first install ahead of the timed cases; `flutter run` itself stops and reinstalls the app every time | | `integration_test` on the emulator | test breakpoint at **28.3 s**, the app's `increment()` breakpoint at **28.7 s** on the test's tap, `counter` → `0`, `✓ device increment`, end `stopped` at 31.6 s | - `flutter build apk --debug` on the fresh example with warm Gradle caches: **45 s**. -- Before each launch: `adb shell am force-stop com.example.flutter_probe`, `adb forward --remove-all`. +- Before each launch the helper force-stops a leftover instance and removes this emulator's own adb forwards (from `adb forward --list`; `adb forward --remove-all` is host-wide). Whether a stale forward ever caused the "device offline" failure is not established — the force-stop may be all that mattered. - The proxy log records output events by shape only (issue #852): to read the device's output after the fact, `get_output` before the session closes. diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 77f5e554..71464cce 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -88,8 +88,9 @@ This directory contains end-to-end smoke tests that verify the MCP debugger serv - `mcp-server-smoke-flutter-android.test.ts` runs `flutter run` and the `integration_test` on a booted Android emulator (`flutter emulators --launch ` is the opt-in; it self-skips without Flutter, adb or an online emulator, and with `MCP_SKIP_FLUTTER_ANDROID=1`). The helper generates - `android/`, builds the debug APK once, installs it, and stops the app and clears adb forwards - before each launch. Never runs on CI + `android/`, builds the debug APK once for the device's ABI and installs it (a warm build; `flutter + run` stops and reinstalls on its own anyway), and before each launch stops a leftover instance + and removes that emulator's own adb forwards. Never runs on CI ### 13. `mcp-server-smoke-javascript-sse.test.ts` - Tests JavaScript adapter over SSE transport diff --git a/tests/e2e/dart-example-utils.ts b/tests/e2e/dart-example-utils.ts index ea8e6d51..c885b95b 100644 --- a/tests/e2e/dart-example-utils.ts +++ b/tests/e2e/dart-example-utils.ts @@ -214,18 +214,27 @@ export function adbPath(): string | null { } const onPath = which.sync('adb', { nothrow: true }); if (onPath) return onPath; - if (process.platform === 'win32' && process.env.LOCALAPPDATA) { - const candidate = path.join(process.env.LOCALAPPDATA, 'Android', 'Sdk', 'platform-tools', exe); + // Android Studio's default SDK locations, the ones flutter_tools probes too. + const home = process.env.HOME ?? process.env.USERPROFILE ?? ''; + const defaults = process.platform === 'win32' + ? [path.join(process.env.LOCALAPPDATA ?? '', 'Android', 'Sdk')] + : process.platform === 'darwin' + ? [path.join(home, 'Library', 'Android', 'sdk')] + : [path.join(home, 'Android', 'Sdk')]; + for (const sdk of defaults) { + const candidate = path.join(sdk, 'platform-tools', exe); if (existsSync(candidate)) return candidate; } return null; } -function adb(args: string[], timeoutMs = 120_000): { status: number | null; stdout: string } { +function adb(args: string[], timeoutMs = 120_000): { status: number | null; stdout: string; out: string } { const exe = adbPath(); - if (!exe) return { status: null, stdout: '' }; + if (!exe) return { status: null, stdout: '', out: '' }; const r = spawnSync(exe, args, { encoding: 'utf8', windowsHide: true, timeout: timeoutMs }); - return { status: r.status, stdout: r.stdout ?? '' }; + const stdout = r.stdout ?? ''; + // adb reports install failures on stderr (`adb: failed to install …`). + return { status: r.status, stdout, out: `${stdout}${r.stderr ?? ''}` }; } let cachedEmulator: string | null | undefined; @@ -238,25 +247,47 @@ let cachedEmulator: string | null | undefined; export function flutterEmulatorDeviceId(): string | null { if (cachedEmulator !== undefined) return cachedEmulator; if (process.env.MCP_SKIP_FLUTTER_ANDROID === '1' || !adbPath()) return (cachedEmulator = null); + // Flutter's own list first: no emulator there means no adb server to start for nothing. + const emulators = flutterDeviceIds().filter((id) => id.startsWith('emulator-')); + if (emulators.length === 0) return (cachedEmulator = null); const online = new Set( adb(['devices']).stdout.split(/\r?\n/).map((l) => l.trim().split(/\s+/)).filter((p) => p.length === 2 && p[1] === 'device').map((p) => p[0]) ); - cachedEmulator = flutterDeviceIds().find((id) => id.startsWith('emulator-') && online.has(id)) ?? null; + cachedEmulator = emulators.find((id) => online.has(id)) ?? null; return cachedEmulator; } -/** Stop the probe app and drop stale port forwards: a live previous instance or a stale forward makes the next launch hang (measured). */ +/** + * Before a launch: stop the probe app and drop the port forwards that belong to this emulator. + * `flutter run` force-stops and reinstalls on its own (android_device.dart `startApp`), so the + * stop only matters for an instance a previous test left behind; the forwards are removed one by + * one from `adb forward --list` because `adb forward --remove-all` is host-wide and would take + * other tools' forwards with it. + */ export function resetAndroidApp(deviceId: string): void { adb(['-s', deviceId, 'shell', 'am', 'force-stop', FLUTTER_PROBE_PACKAGE], 30_000); - adb(['forward', '--remove-all'], 30_000); + for (const line of adb(['forward', '--list'], 30_000).stdout.split(/\r?\n/)) { + const [serial, local] = line.trim().split(/\s+/); + if (serial === deviceId && local) adb(['-s', deviceId, 'forward', '--remove', local], 30_000); + } } let androidPrepared = false; +/** The `--target-platform` matching the device's ABI, so the warm-up builds what `flutter run` will run. */ +function androidTargetPlatform(deviceId: string): string { + const abi = adb(['-s', deviceId, 'shell', 'getprop', 'ro.product.cpu.abi'], 30_000).stdout.trim(); + if (abi.startsWith('arm64')) return 'android-arm64'; + if (abi.startsWith('armeabi')) return 'android-arm'; + return 'android-x64'; +} + /** - * Generate the probe's `android/` folder, build the debug APK once and install it on the device, - * so the launches under test never pay the first install (the first `flutter run` after a fresh - * install ended without a stop once, measured; the next one was fine). Warm Gradle: ~45 s. + * Generate the probe's `android/` folder, build the debug APK for the device's ABI once and + * install it. `flutter run` stops, builds and installs on its own every time, so what this buys + * the timed launches is a warm Gradle and dependency cache (the spike measured 425 s cold, 6–45 s + * warm) and the first-ever `pm install` out of the way; the first `flutter run` after a fresh + * install was seen to end without a stop once. A stale `android/` is not regenerated: delete it. */ export function prepareFlutterAndroid(deviceId: string): void { if (androidPrepared) return; @@ -267,11 +298,19 @@ export function prepareFlutterAndroid(deviceId: string): void { const r = runFlutter(root, ['create', '--platforms=android', '--project-name', 'flutter_probe', '.'], FLUTTER_PROBE_DIR); if (r.status !== 0) throw new Error(`flutter create --platforms=android failed: ${r.out}`); } - const built = runFlutter(root, ['build', 'apk', '--debug'], FLUTTER_PROBE_DIR, 900_000); - if (built.status !== 0) throw new Error(`flutter build apk --debug failed: ${built.out}`); + const built = runFlutter(root, ['build', 'apk', '--debug', '--target-platform', androidTargetPlatform(deviceId)], FLUTTER_PROBE_DIR, 900_000); + if (built.status !== 0) { + throw new Error(`flutter build apk --debug failed (a stale examples/dart/flutter_probe/android can be deleted and regenerated): ${built.out}`); + } const apk = path.join(FLUTTER_PROBE_DIR, 'build', 'app', 'outputs', 'flutter-apk', 'app-debug.apk'); - const installed = adb(['-s', deviceId, 'install', '-r', '-t', apk], 180_000); - if (installed.status !== 0) throw new Error(`adb install failed: ${installed.stdout}`); + let installed = adb(['-s', deviceId, 'install', '-r', '-t', apk], 180_000); + if (installed.status !== 0) { + // An older signature or version on the device (INSTALL_FAILED_UPDATE_INCOMPATIBLE): the way + // flutter_tools handles it too — uninstall once and retry. + adb(['-s', deviceId, 'uninstall', FLUTTER_PROBE_PACKAGE], 60_000); + installed = adb(['-s', deviceId, 'install', '-r', '-t', apk], 180_000); + if (installed.status !== 0) throw new Error(`adb install failed: ${installed.out}`); + } androidPrepared = true; } diff --git a/tests/e2e/mcp-server-smoke-flutter-android.test.ts b/tests/e2e/mcp-server-smoke-flutter-android.test.ts index 46dfdbce..92f3763c 100644 --- a/tests/e2e/mcp-server-smoke-flutter-android.test.ts +++ b/tests/e2e/mcp-server-smoke-flutter-android.test.ts @@ -3,9 +3,11 @@ * emulator-NNNN` with a breakpoint in `build()`, and an `integration_test` on the emulator whose * tap hits the app's breakpoint. Measured through the real server on 2026-10-09 (Flutter 3.47.7, * Windows, AVD `mcp_api35` booted headless, Gradle warm): the `build()` breakpoint in ~21 s, the - * integration test's breakpoints in ~28 s; the first `flutter run` after a fresh APK install once - * ended without a stop, so the helper installs the APK up front and stops the app and clears - * port forwards before every launch. + * integration test's breakpoints in ~28 s. `flutter run` stops, builds and installs the app on + * its own every launch; the helper warms the Gradle build for the device's ABI and does the + * first install ahead of the timed cases (the first run right after a fresh install once ended + * without a stop), and before each launch stops a leftover instance and removes this emulator's + * own adb forwards. * * Opt-in by booting an emulator (`flutter emulators --launch `); self-skips without Flutter, * adb, an online emulator, or with `MCP_SKIP_FLUTTER_ANDROID=1`. CI never runs it. @@ -112,11 +114,27 @@ describe.skipIf(!EMULATOR)(`MCP Server Flutter Android Smoke Test @requires-flut return res; } + /** + * `wait_for_stop` in slices under the MCP client's 60 s request cap: a Gradle build can keep + * the first stop away for longer than that (seen: 63 s), and a single long server-side wait + * then fails on the client side, not the debugger's. + */ + async function waitForPause(timeoutMs: number, what: string) { + const deadline = Date.now() + timeoutMs; + for (;;) { + const slice = Math.min(45_000, Math.max(1_000, deadline - Date.now())); + const waited = await call('wait_for_stop', { timeout: slice }); + if (waited.state === 'paused') return (await getSession())!; + const stillRunning = waited.pending === true || waited.state === 'running'; + if (!stillRunning || Date.now() >= deadline) { + throw new Error(`${what}: wait_for_stop answered ${JSON.stringify(waited)}; output tail: ${(await output()).slice(-600)}`); + } + } + } + async function firstStop(launched: Record, timeoutMs: number) { if (launched.state === 'paused') return (await getSession())!; - const waited = await call('wait_for_stop', { timeout: timeoutMs }); - expect(waited.state, `first stop (wait_for_stop answered ${JSON.stringify(waited)}; output: ${(await output()).slice(-600)})`).toBe('paused'); - return (await getSession())!; + return waitForPause(timeoutMs, 'first stop'); } async function createSession(name: string) { @@ -160,8 +178,7 @@ describe.skipIf(!EMULATOR)(`MCP Server Flutter Android Smoke Test @requires-flut expect((await frames())[0].line).toBe(testLine); await callToolSafely(mcpClient!, 'continue_execution', { sessionId }); - const atApp = await call('wait_for_stop', { timeout: 120000 }); - expect(atApp.state, JSON.stringify(atApp)).toBe('paused'); + await waitForPause(120000, "the app's breakpoint on the test's tap"); const top = (await frames())[0]; expect(top.file?.toLowerCase()).toBe(FLUTTER_EXAMPLES.main.toLowerCase()); expect(top.line).toBe(appLine); diff --git a/tests/e2e/mcp-server-smoke-flutter.test.ts b/tests/e2e/mcp-server-smoke-flutter.test.ts index 62fcd7c9..36c63ca7 100644 --- a/tests/e2e/mcp-server-smoke-flutter.test.ts +++ b/tests/e2e/mcp-server-smoke-flutter.test.ts @@ -120,12 +120,28 @@ describe.skipIf(SKIP_FLUTTER)('MCP Server Flutter Debugging Smoke Test @requires return res; } + /** + * `wait_for_stop` in slices under the MCP client's 60 s request cap: a cold compile or a + * runner build can keep the first stop away for longer than that, and a single long + * server-side wait then fails on the client side, not the debugger's. + */ + async function waitForPause(timeoutMs: number, what: string) { + const deadline = Date.now() + timeoutMs; + for (;;) { + const slice = Math.min(45_000, Math.max(1_000, deadline - Date.now())); + const waited = await call('wait_for_stop', { timeout: slice }); + if (waited.state === 'paused') return (await getSession())!; + const stillRunning = waited.pending === true || waited.state === 'running'; + if (!stillRunning || Date.now() >= deadline) { + throw new Error(`${what}: wait_for_stop answered ${JSON.stringify(waited)}; output tail: ${(await output()).slice(-600)}`); + } + } + } + /** The launch answers `pending` while the test VM compiles; the first stop is what matters. */ async function firstStop(launched: Record, timeoutMs: number) { if (launched.state === 'paused') return (await getSession())!; - const waited = await call('wait_for_stop', { timeout: timeoutMs }); - expect(waited.state, `first stop (wait_for_stop answered ${JSON.stringify(waited)})`).toBe('paused'); - return (await getSession())!; + return waitForPause(timeoutMs, 'first stop'); } async function createSession(name: string) { @@ -219,8 +235,7 @@ describe.skipIf(SKIP_FLUTTER)('MCP Server Flutter Debugging Smoke Test @requires expect((await frames())[0].line).toBe(testLine); await callToolSafely(mcpClient!, 'continue_execution', { sessionId }); - const atApp = await call('wait_for_stop', { timeout: 60000 }); - expect(atApp.state, JSON.stringify(atApp)).toBe('paused'); + await waitForPause(60000, "the app's breakpoint on the test's tap"); const top = (await frames())[0]; expect(top.file?.toLowerCase()).toBe(FLUTTER_EXAMPLES.main.toLowerCase()); expect(top.line).toBe(appLine);