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 changelog.d/790.added.md
Original file line number Diff line number Diff line change
@@ -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)
14 changes: 14 additions & 0 deletions docs/KNOWN_ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,20 @@ 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, 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`).
Expand Down
10 changes: 10 additions & 0 deletions docs/dart/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. `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

- *"No Dart SDK found"* — put `dart` or `flutter` on PATH, or set `DART_SDK` / `FLUTTER_ROOT`;
Expand Down
16 changes: 16 additions & 0 deletions docs/dart/spike-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 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.
6 changes: 6 additions & 0 deletions tests/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ 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 <id>` 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 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
Expand Down
113 changes: 113 additions & 0 deletions tests/e2e/dart-example-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,119 @@ 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;
// 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; out: string } {
const exe = adbPath();
if (!exe) return { status: null, stdout: '', out: '' };
const r = spawnSync(exe, args, { encoding: 'utf8', windowsHide: true, timeout: timeoutMs });
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;

/**
* 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 <id>` (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);
// 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 = emulators.find((id) => online.has(id)) ?? null;
return cachedEmulator;
}

/**
* 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);
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 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;
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', '--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');
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;
}

export const FLUTTER_EXAMPLES = {
main: path.join(FLUTTER_PROBE_DIR, 'lib', 'main.dart'),
widgetTest: path.join(FLUTTER_PROBE_DIR, 'test', 'widget_test.dart'),
Expand Down
Loading
Loading