Skip to content

Use shared EmulatorRunner from android-tools for BootAndroidEmulator#10949

Open
rmarinho wants to merge 15 commits intomainfrom
dev/rumar/use-shared-emulator-runner
Open

Use shared EmulatorRunner from android-tools for BootAndroidEmulator#10949
rmarinho wants to merge 15 commits intomainfrom
dev/rumar/use-shared-emulator-runner

Conversation

@rmarinho
Copy link
Member

@rmarinho rmarinho commented Mar 16, 2026

Summary

Replaces the 454-line BootAndroidEmulator MSBuild task with a ~180-line thin wrapper that delegates boot logic to EmulatorRunner.BootEmulatorAsync() from Xamarin.Android.Tools.AndroidSdk (shared via the external/xamarin-android-tools submodule).

This is the consumer PR that @jonathanpeppers requested on android-tools#284:

"Is there a dotnet/android PR that tests this API? I think the task is <BootAndroidEmulator/> we could replace existing code and call this instead."

What Changed

BootAndroidEmulator.cs (454 → ~160 lines)

Architecture:

  • Base class changed from AndroidTaskAsyncTask (override RunTaskAsync())
  • Single ExecuteBootAsync() virtual method delegates to EmulatorRunner.BootEmulatorAsync()
  • Error classification uses EmulatorBootErrorKind enum (not string matching)
  • Supports CancellationToken from AsyncTask base

Removed:

  • All process management (Process.Start, async output capture)
  • All polling logic (WaitForEmulatorOnline, WaitForFullBoot, Thread.Sleep)
  • All ADB interaction (IsOnlineAdbDevice, FindRunningEmulatorForAvd, etc.)
  • MonoAndroidHelper.RunProcess calls (6 occurrences)

Error mapping:

EmulatorBootErrorKind Error Code Meaning
LaunchFailed XA0143 Emulator process could not start
Timeout / Cancelled / Unknown XA0145 Boot did not complete

Preserved:

  • Same MSBuild task interface (all inputs/outputs unchanged)
  • Same error codes (XA0143, XA0145)
  • ResolveAdbPath() / ResolveEmulatorPath() logic
  • this.CreateTaskLogger() for MSBuild logging

BootAndroidEmulatorTests.cs (9 tests)

Mock pattern: MockBootAndroidEmulator overrides ExecuteBootAsync() (async, with CancellationToken) to return a canned EmulatorBootResult with ErrorKind set.

Tests call Execute() which is the AsyncTask entry point (internally calls RunTaskAsync).

Test Validates
AlreadyOnlineDevice_PassesThrough Device already online → pass-through
AlreadyOnlinePhysicalDevice_PassesThrough Physical device serial → pass-through
AvdAlreadyRunning_WaitsForFullBoot AVD name → resolved serial
BootEmulator_AppearsAfterPolling New emulator → assigned serial
LaunchFailure_ReturnsError ErrorKind.LaunchFailed → XA0143
BootTimeout_ReturnsError ErrorKind.Timeout → XA0145
MultipleEmulators_FindsCorrectAvd Correct AVD resolution
ToolPaths_ResolvedFromAndroidSdkDirectory Path defaults
ExtraArguments_PassedToOptions Extra args parsed + asserted via LastBootOptions.AdditionalArgs
UnknownError_MapsToXA0145 ErrorKind.Unknown → XA0145

Submodule Update

external/xamarin-android-tools updated to feature/emulator-runner (2bf8d67) which adds:

  • EmulatorBootErrorKind enum (None, LaunchFailed, Timeout, Cancelled, Unknown)
  • EmulatorBootResult.ErrorKind property

API Used

From Xamarin.Android.Tools.AndroidSdk:

EmulatorRunner(string emulatorPath, logger: Action<TraceLevel, string>?)
EmulatorRunner.BootEmulatorAsync(string device, AdbRunner adb, EmulatorBootOptions? options, CancellationToken ct)Task<EmulatorBootResult>

record EmulatorBootResult {
  bool Success; string? Serial; string? ErrorMessage;
  EmulatorBootErrorKind ErrorKind;  // NEW — structured error classification
}

enum EmulatorBootErrorKind { None, LaunchFailed, Timeout, Cancelled, Unknown }

Review Feedback Addressed

  • @jonathanpeppers: Convert to AsyncTask (RunTaskAsync) ✅
  • @jonathanpeppers + copilot: Use enum instead of string matching ✅ (EmulatorBootErrorKind)
  • copilot: Collapse identical else-if/else branches ✅ (now a clean switch)
  • copilot: Assert capturedArgs in ExtraArguments test ✅ (LastBootOptions.AdditionalArgs)

Dependencies

@rmarinho rmarinho marked this pull request as ready for review March 16, 2026 20:24
Copilot AI review requested due to automatic review settings March 16, 2026 20:24
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Replaces the BootAndroidEmulator MSBuild task's inline process management with a thin wrapper around EmulatorRunner.BootEmulatorAsync() from the shared xamarin-android-tools library.

Changes:

  • Replaced ~270 lines of process/polling logic with delegation to EmulatorRunner/AdbRunner
  • Simplified test mock to override a single ExecuteBoot() method returning canned EmulatorBootResult
  • Updated external/xamarin-android-tools submodule to the feature/emulator-runner branch

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
BootAndroidEmulator.cs Replaced inline boot logic with EmulatorRunner.BootEmulatorAsync() delegation
BootAndroidEmulatorTests.cs Simplified mock and updated tests for new pattern
external/xamarin-android-tools Submodule update to include EmulatorRunner API

You can also share your feedback on Copilot code review. Take the survey.

rmarinho added a commit to dotnet/android-tools that referenced this pull request Mar 18, 2026
Add structured error classification enum (None, LaunchFailed, Timeout,
Cancelled, Unknown) so consumers can switch on ErrorKind instead of
parsing ErrorMessage strings. Set ErrorKind on all BootEmulatorAsync
return paths.

Addresses review feedback from dotnet/android#10949.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
jonathanpeppers pushed a commit to dotnet/android-tools that referenced this pull request Mar 19, 2026
## EmulatorRunner: High-level emulator lifecycle management

Adds `EmulatorRunner` — a managed wrapper over the Android SDK `emulator` CLI binary, following the same pattern as `AdbRunner` and `AvdManagerRunner`.

### API Surface

| Method | Description |
|--------|-------------|
| `LaunchEmulator(avdName, options?)` | Fire-and-forget: starts an emulator process and returns the `Process` handle. Caller owns the process lifetime. Validates `avdName` is non-empty. |
| `BootEmulatorAsync(avdName, adb, options?, token?)` | Full lifecycle: checks if device is already online → checks if emulator process is running → launches emulator → polls `adb devices` until boot completes or timeout. Returns `EmulatorBootResult` with status and serial. Disposes Process handle on success (emulator keeps running). |
| `ListAvdNamesAsync(token?)` | Lists available AVD names via `emulator -list-avds`. Checks exit code for failures. |

### Key Design Decisions

- **Naming**: `LaunchEmulator` (fire-and-forget) vs `BootEmulatorAsync` (full lifecycle) — clear verb distinction matching the emulator domain
- **Kept `EmulatorRunner` name** (not `AvdRunner`) — follows convention of naming runners after their CLI binary (`emulator` → `EmulatorRunner`, `adb` → `AdbRunner`)
- **Process handle management**: `LaunchEmulator` returns `Process` (caller-owned); `BootEmulatorAsync` disposes handle on success (emulator keeps running as detached process), kills+disposes on failure/timeout
- **Pipe draining**: `LaunchEmulator` calls `BeginOutputReadLine()`/`BeginErrorReadLine()` after `Start()` to prevent OS pipe buffer deadlock
- **`TryKillProcess`**: Instance method, uses typed `catch (Exception ex)` with logger for diagnostics, uses `Kill(entireProcessTree: true)` on .NET 5+

### AdbRunner Enhancements (in this PR)

- Added optional `Action? logger` parameter to constructor
- `RunShellCommandAsync(serial, command, ct)` — single-string shell command (⚠️ device shell interprets it — documented in XML doc)
- `RunShellCommandAsync(serial, command, args, ct)` — **NEW**: structured overload that passes args as separate tokens, bypassing device shell interpretation via `exec()`. Safer for dynamic input.
- `GetShellPropertyAsync` returns first non-empty line (for `getprop` queries)
- Shell methods log stderr via logger on non-zero exit codes
- Fixed RS0026/RS0027: only the most-params overload has optional `CancellationToken`
- **AVD name detection fix**: `GetEmulatorAvdNameAsync` now falls back to `adb shell getprop ro.boot.qemu.avd_name` when `adb emu avd name` returns empty (observed returning empty on some adb/emulator v36 combinations)

### Models

- `EmulatorBootOptions` — configurable timeout (default 120s), poll interval (default 2s), cold boot, extra args (`IEnumerable?`)
- `EmulatorBootResult` — immutable `record` with `init`-only properties: `Status` (enum), `Serial`, `Message`. Statuses: `Success`, `AlreadyRunning`, `Timeout`, `Error`

### Bug Fix: AVD Name Detection on Emulator v36+

The `adb emu avd name` console command can return **empty output** on some adb/emulator version combinations (observed with adb v36). This caused `BootEmulatorAsync` to never match the running emulator by AVD name, resulting in a perpetual polling loop and eventual timeout.

**Root cause**: `GetEmulatorAvdNameAsync` relied solely on `adb -s <serial> emu avd name`. On some adb/emulator version combinations this command silently returns empty output (exit code 0, no content). The exact cause is unclear but the `getprop` fallback provides reliable AVD name resolution regardless.

**Fix**: Added fallback to `adb shell getprop ro.boot.qemu.avd_name`, which reads the boot property set by the emulator kernel. This property is always available via the standard adb shell interface and does not depend on the emulator console protocol.

**Verified**: `BootEmulatorAsync` now completes in ~3s (was timing out at 120s) on emulator v36.4.9 with API 36 image.

### Consumer PR

- dotnet/android [#10949](dotnet/android#10949) — replaces `BootAndroidEmulator` MSBuild task (~454 lines) with a ~180-line wrapper delegating to `EmulatorRunner.BootEmulatorAsync()`

### Tests (24 EmulatorRunner + 9 AdbRunner = 33 total)

**EmulatorRunner (24)**:
- Parse `emulator -list-avds` output (empty, single, multiple, blank lines, Windows newlines) — 4 tests
- Constructor validation (null/empty/whitespace tool path) — 3 tests
- `LaunchEmulator` argument validation (null, empty, whitespace AVD name) — 3 tests
- `BootEmulatorAsync` lifecycle: already online device, already running AVD, successful boot after polling, timeout, launch failure, cancellation token — 6 tests
- `BootEmulatorAsync` validation: invalid timeout, invalid poll interval, null AdbRunner, empty device name — 4 tests
- Ported from dotnet/android `BootAndroidEmulatorTests`: physical device passthrough, AdditionalArgs forwarding, ColdBoot flag, cancellation abort — 4 tests

**AdbRunner (9)**:
- `FirstNonEmptyLine` parsing (null, empty, whitespace, single value, multiline, mixed) — 9 tests

### Review Feedback Addressed

- ✅ `LaunchEmulator` validates `avdName` parameter (throws `ArgumentException`)
- ✅ `LaunchEmulator` drains stdout/stderr pipes via `BeginOutputReadLine()`/`BeginErrorReadLine()`
- ✅ `RunShellCommandAsync` returns full stdout (not just first line)
- ✅ Added structured `RunShellCommandAsync` overload (no shell interpretation)
- ✅ Added 12 new unit tests (LaunchEmulator validation + FirstNonEmptyLine parsing)
- ✅ Shell methods log stderr via logger on failure
- ✅ Removed TOCTOU `HasExited` guard from `TryKillProcess`
- ✅ Process handle disposed on successful boot (no handle leak)
- ✅ `ListAvdNamesAsync` checks exit code
- ✅ `TryKillProcess` uses typed `catch (Exception ex)` with logging
- ✅ `RunShellCommandAsync` XML doc warns about shell interpretation
- ✅ Fixed RS0026/RS0027 PublicAPI analyzer warnings
- ✅ `EmulatorBootResult` uses `init`-only properties (immutable record)
- ✅ Ported 6 additional tests from dotnet/android `BootAndroidEmulatorTests`
- ✅ Fixed AVD name detection for emulator v36+ (getprop fallback)

### Fix EmulatorRunner early exit detection and macOS fork handling ###

Add early process exit detection in BootEmulatorAsync boot polling loop.
Previously, if the emulator failed immediately (e.g., insufficient disk
space, missing AVD), the full 300s timeout was wasted before reporting.

On macOS, the emulator binary forks the real QEMU process and the parent
exits with code 0 immediately. Only non-zero exit codes are treated as
immediate failures; exit code 0 continues polling since the real emulator
runs as a separate process.

Context: dotnet/android#10965

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@rmarinho rmarinho force-pushed the dev/rumar/use-shared-emulator-runner branch 2 times, most recently from a3c7eda to ae9719a Compare March 19, 2026 14:54
@rmarinho rmarinho requested a review from Copilot March 19, 2026 15:31
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors the BootAndroidEmulator MSBuild task to delegate emulator boot behavior to the shared EmulatorRunner.BootEmulatorAsync() API from Xamarin.Android.Tools.AndroidSdk, and updates the unit tests accordingly.

Changes:

  • Replaced the previous in-task emulator/ADB/process polling logic with a thin AsyncTask wrapper calling EmulatorRunner.BootEmulatorAsync().
  • Added parsing of EmulatorExtraArguments into EmulatorBootOptions.AdditionalArgs.
  • Updated tests to mock ExecuteBootAsync() and validate error mapping and options passing; updated the external/xamarin-android-tools submodule revision.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs Converts task to AsyncTask and delegates boot flow to EmulatorRunner, adding structured error handling and extra-args parsing.
src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs Refactors tests to mock ExecuteBootAsync() and validate new boot/result and argument parsing behavior.
external/xamarin-android-tools Updates submodule to a commit that provides EmulatorBootErrorKind and related result surface.

You can also share your feedback on Copilot code review. Take the survey.

@rmarinho rmarinho requested a review from Copilot March 19, 2026 18:55
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors the BootAndroidEmulator MSBuild task to delegate emulator boot behavior to the shared EmulatorRunner.BootEmulatorAsync() implementation in Xamarin.Android.Tools.AndroidSdk, reducing duplicated process/ADB polling logic.

Changes:

  • Replaces the existing synchronous boot/poll code with an AsyncTask wrapper around EmulatorRunner.BootEmulatorAsync().
  • Updates error handling/logging to use EmulatorBootErrorKind and revises XA0144 resource text.
  • Reworks unit tests to mock ExecuteBootAsync() and add coverage for extra-argument parsing and new error mappings.

Reviewed changes

Copilot reviewed 4 out of 5 changed files in this pull request and generated 3 comments.

File Description
src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs Converts task to AsyncTask, delegates boot to EmulatorRunner, adds structured error mapping and extra-arg parsing.
src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs Rewrites tests to mock the delegated boot method and validate error mapping + argument parsing.
src/Xamarin.Android.Build.Tasks/Properties/Resources.resx Updates XA0144 to reflect structured error reporting rather than process exit code.
external/xamarin-android-tools Updates submodule commit to pick up the new emulator-runner API surface.
Files not reviewed (1)
  • src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs: Language not supported
Comments suppressed due to low confidence (1)

external/xamarin-android-tools:1

  • The PR description references updating the submodule to commit 2bf8d67, but the diff updates it to 2bea0eb6…. Please update the description (or the submodule pointer) so reviewers can reliably correlate the consumed android-tools changes.

Comment on lines +126 to 138
switch (result.ErrorKind) {
case EmulatorBootErrorKind.LaunchFailed:
LogCodedError ("XA0143", Properties.Resources.XA0143, Device, result.ErrorMessage ?? "Unknown launch error");
break;
case EmulatorBootErrorKind.Cancelled:
throw new OperationCanceledException ($"Emulator boot for '{Device}' was cancelled.");
case EmulatorBootErrorKind.Timeout:
LogCodedError ("XA0145", Properties.Resources.XA0145, Device, BootTimeoutSeconds);
break;
default:
LogCodedError ("XA0144", Properties.Resources.XA0144, Device, result.ErrorKind, result.ErrorMessage ?? "Unknown error");
break;
}
Comment on lines +172 to 188
for (int i = 0; i < extraArgs.Length; i++) {
char c = extraArgs [i];

if (c == '\\' && i + 1 < extraArgs.Length && extraArgs [i + 1] == '"') {
current.Append ('"');
i++;
} else if (c == '"') {
inQuotes = !inQuotes;
} else if (c == ' ' && !inQuotes) {
if (current.Length > 0) {
args.Add (current.ToString ());
current.Clear ();
}
} else {
current.Append (c);
}
emulatorProcess.OutputDataReceived -= EmulatorOutputDataReceived;
emulatorProcess.ErrorDataReceived -= EmulatorErrorDataReceived;
}
Comment on lines +277 to +292
[Test]
public void UnknownError_MapsToXA0144 ()
{
var task = CreateTask ("Pixel_6_API_33");
task.BootResult = new EmulatorBootResult {
Success = false,
ErrorKind = EmulatorBootErrorKind.Unknown,
ErrorMessage = "Some unexpected error occurred",
};

Assert.IsFalse (task.Execute (), "Task should fail");
var error = errors.FirstOrDefault (e => e.Code == "XA0144");
Assert.IsNotNull (error, "Unknown errors should map to XA0144");
StringAssert.Contains ("Unknown", error.Message, "Error kind should be included in the message");
StringAssert.Contains ("Some unexpected error occurred", error.Message, "Error message should be included");
}
rmarinho and others added 10 commits March 19, 2026 22:45
Replace the 454-line BootAndroidEmulator implementation with a thin
~180-line wrapper that delegates to EmulatorRunner.BootEmulatorAsync()
from Xamarin.Android.Tools.AndroidSdk.

Key changes:
- Remove all process management, polling, and boot detection logic
- Delegate to EmulatorRunner.BootEmulatorAsync() for the full 3-phase
  boot: check online → check AVD running → launch + poll + wait
- Map EmulatorBootResult errors to existing XA0143/XA0145 error codes
- Virtual ExecuteBoot() method for clean test mocking
- Update submodule to feature/emulator-runner (d8ee2d5)

Tests updated from 9 to 10 (added ExtraArguments and UnknownError tests)
using simplified mock pattern — MockBootAndroidEmulator overrides
ExecuteBoot() to return canned EmulatorBootResult values.

Depends on: dotnet/android-tools#284

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Changed base class from AndroidTask to AsyncTask
- Override RunTaskAsync() instead of RunTask()
- ExecuteBoot → ExecuteBootAsync with CancellationToken parameter
- Replace string-matching error classification with switch on ErrorKind enum
- Update tests for async pattern (Execute() instead of RunTask())
- Add LastBootOptions capture + assertion for ExtraArguments test
- Set ErrorKind on test BootResult data (LaunchFailed, Timeout, Unknown)
- Update submodule to feature/emulator-runner with EmulatorBootErrorKind

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The file uses List<string> but was missing the using directive, causing
CS0246 on the netstandard2.0 target.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fully qualify Task.FromResult to resolve CS0104 ambiguity between
Microsoft.Build.Utilities.Task and System.Threading.Tasks.Task.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
EmulatorRunner is now on main after PR #284 was merged. Update
submodule from feature/emulator-runner to main.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- ParseExtraArguments now handles double-quoted segments so values
  with embedded spaces (e.g. -skin "Nexus 5X") are preserved as
  single tokens
- Remove RunTaskSynchronously wrapper, call task.Execute() directly
- Add ExtraArguments_QuotedValuesPreserved test

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extract LastBootOptions into local variables to avoid the banned

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Picks up the getprop-first AVD name detection from android-tools #312.
AdbRunner.GetEmulatorAvdNameAsync now tries ro.boot.qemu.avd_name
before falling back to emu avd name, fixing empty results on some
emulator versions. Also skips offline emulators in ListDevicesAsync.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…d handling, tool path assertions

- Guard against null/empty Serial on success path (→ XA0145)
- Handle EmulatorBootErrorKind.Cancelled explicitly (log message, no error)
- Handle Timeout explicitly (separate from default)
- Include ErrorMessage in diagnostics for Unknown errors
- Capture LastAdbPath/LastEmulatorPath in mock, assert in ToolPaths test
- Add tests: Cancelled_DoesNotLogError, Success_NullSerial_ReturnsError

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Cancellation now throws OperationCanceledException instead of
  silently succeeding, so MSBuild properly stops the build.
- Validate BootTimeoutSeconds > 0 before constructing options.
- Guard against Success=true with null serial (maps to XA0143).
- Use AsyncTask thread-safe LogCodedError/LogMessage helpers
  instead of Log.* from async context.
- Remove orphaned XA0144 error code from Resources.resx/Designer.cs
  (no longer emitted since EmulatorRunner handles exit codes).
- Document that ParseExtraArguments does not support escaped quotes.
- Update tests: Cancelled_FailsTheBuild, InvalidTimeout_ReturnsError,
  Success_NullSerial now expects XA0143.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
rmarinho and others added 4 commits March 19, 2026 22:45
Update XA0144 message format to accept the ErrorMessage from
EmulatorRunner directly. The default switch case (Unknown and
future error kinds) now uses XA0144 with the full error details
instead of the misleading timeout message XA0145.

Error code mapping:
- XA0143: Launch failed (couldn't start emulator)
- XA0144: Unexpected exit/error (process exited, unknown errors)
- XA0145: Boot timeout (didn't finish in time)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Handle backslash-escaped quotes (\" → ") in the argument
tokenizer, following the same pattern as ProcessArgumentBuilder
in android-platform-support. This allows values like:
  -prop "persist.sys.timezone=\"America/New_York\""

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Per review feedback from @jonathanpeppers, XA0144 now includes both
the EmulatorBootErrorKind (e.g. Unknown) and the ErrorMessage from the
emulator runner, giving more context for debugging.

Format: "The Android emulator for AVD '{0}' failed with error '{1}': {2}"

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…description

- Cancellation: log informational message instead of throwing
  OperationCanceledException (avoids noisy stack traces in MSBuild)
- ParseExtraArguments: use char.IsWhiteSpace() instead of literal
  space to handle tabs/newlines from MSBuild properties
- Updated PR description: Unknown → XA0144 (not XA0145)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@rmarinho rmarinho force-pushed the dev/rumar/use-shared-emulator-runner branch from 4c30fc8 to 1d16034 Compare March 19, 2026 22:45
The test expected paths ending with 'platform-tools/adb' and
'emulator/emulator', but on Windows they have '.exe' extensions.
Accept both variants.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants