diff --git a/src/Microsoft.Android.Run/AdbHelper.cs b/src/Microsoft.Android.Run/AdbHelper.cs new file mode 100644 index 00000000000..b34656a9563 --- /dev/null +++ b/src/Microsoft.Android.Run/AdbHelper.cs @@ -0,0 +1,32 @@ +using System.Diagnostics; +using Xamarin.Android.Tools; + +static class AdbHelper +{ + public static ProcessStartInfo CreateStartInfo (string adbPath, string? adbTarget, string arguments) + { + var fullArguments = string.IsNullOrEmpty (adbTarget) ? arguments : $"{adbTarget} {arguments}"; + return new ProcessStartInfo { + FileName = adbPath, + Arguments = fullArguments, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + } + + public static async Task<(int ExitCode, string Output, string Error)> RunAsync (string adbPath, string? adbTarget, string arguments, CancellationToken cancellationToken, bool verbose = false) + { + var psi = CreateStartInfo (adbPath, adbTarget, arguments); + + if (verbose) + Console.WriteLine ($"Running: adb {psi.Arguments}"); + + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken); + + return (exitCode, stdout.ToString (), stderr.ToString ()); + } +} diff --git a/src/Microsoft.Android.Run/Program.cs b/src/Microsoft.Android.Run/Program.cs index 6eda1c6020b..55b0b679a4e 100644 --- a/src/Microsoft.Android.Run/Program.cs +++ b/src/Microsoft.Android.Run/Program.cs @@ -10,6 +10,7 @@ string? package = null; string? activity = null; string? deviceUserId = null; +string? instrumentation = null; bool verbose = false; int? logcatPid = null; Process? logcatProcess = null; @@ -17,7 +18,7 @@ string? logcatArgs = null; try { - return Run (args); + return await RunAsync (args); } catch (Exception ex) { Console.Error.WriteLine ($"Error: {ex.Message}"); if (verbose) @@ -25,7 +26,7 @@ return 1; } -int Run (string[] args) +async Task RunAsync (string[] args) { bool showHelp = false; bool showVersion = false; @@ -47,11 +48,15 @@ int Run (string[] args) "The Android application {PACKAGE} name (e.g., com.example.myapp). Required.", v => package = v }, { "c|activity=", - "The {ACTIVITY} class name to launch. Required.", + "The {ACTIVITY} class name to launch. Required unless --instrument is used.", v => activity = v }, { "user=", "The Android device {USER_ID} to launch the activity under (e.g., 10 for a work profile).", v => deviceUserId = v }, + { "i|instrument=", + "The instrumentation {RUNNER} class name (e.g., com.example.myapp.TestInstrumentation). " + + "When specified, runs 'am instrument' instead of 'am start'.", + v => instrumentation = v }, { "v|verbose", "Enable verbose output for debugging.", v => verbose = v != null }, @@ -95,9 +100,9 @@ int Run (string[] args) options.WriteOptionDescriptions (Console.Out); Console.WriteLine (); Console.WriteLine ("Examples:"); - Console.WriteLine ($" {Name} -p com.example.myapp"); Console.WriteLine ($" {Name} -p com.example.myapp -c com.example.myapp.MainActivity"); - Console.WriteLine ($" {Name} --adb /path/to/adb -p com.example.myapp"); + Console.WriteLine ($" {Name} -p com.example.myapp -i com.example.myapp.TestInstrumentation"); + Console.WriteLine ($" {Name} --adb /path/to/adb -p com.example.myapp -c com.example.myapp.MainActivity"); Console.WriteLine (); Console.WriteLine ("Press Ctrl+C while running to stop the Android application and exit."); return 0; @@ -109,8 +114,16 @@ int Run (string[] args) return 1; } - if (string.IsNullOrEmpty (activity)) { - Console.Error.WriteLine ("Error: --activity is required."); + bool isInstrumentMode = !string.IsNullOrEmpty (instrumentation); + + if (!isInstrumentMode && string.IsNullOrEmpty (activity)) { + Console.Error.WriteLine ("Error: --activity or --instrument is required."); + Console.Error.WriteLine ($"Try '{Name} --help' for more information."); + return 1; + } + + if (isInstrumentMode && !string.IsNullOrEmpty (activity)) { + Console.Error.WriteLine ("Error: --activity and --instrument cannot be used together."); Console.Error.WriteLine ($"Try '{Name} --help' for more information."); return 1; } @@ -129,6 +142,8 @@ int Run (string[] args) return 1; } + Debug.Assert (adbPath != null, "adbPath should be non-null after validation"); + if (verbose) { Console.WriteLine ($"Using adb: {adbPath}"); if (!string.IsNullOrEmpty (adbTarget)) @@ -136,13 +151,18 @@ int Run (string[] args) Console.WriteLine ($"Package: {package}"); if (!string.IsNullOrEmpty (activity)) Console.WriteLine ($"Activity: {activity}"); + if (isInstrumentMode) + Console.WriteLine ($"Instrumentation runner: {instrumentation}"); } // Set up Ctrl+C handler Console.CancelKeyPress += OnCancelKeyPress; try { - return RunApp (); + if (isInstrumentMode) + return await RunInstrumentationAsync (); + + return await RunAppAsync (); } finally { Console.CancelKeyPress -= OnCancelKeyPress; cts.Dispose (); @@ -157,8 +177,8 @@ void OnCancelKeyPress (object? sender, ConsoleCancelEventArgs e) cts.Cancel (); - // Force-stop the app - StopApp (); + // Force-stop the app (fire-and-forget in cancel handler) + _ = StopAppAsync (); // Kill logcat process if running try { @@ -171,14 +191,80 @@ void OnCancelKeyPress (object? sender, ConsoleCancelEventArgs e) } } -int RunApp () +async Task RunInstrumentationAsync () +{ + // Build the am instrument command + var userArg = string.IsNullOrEmpty (deviceUserId) ? "" : $" --user {deviceUserId}"; + var cmdArgs = $"shell am instrument -w{userArg} {package}/{instrumentation}"; + + if (verbose) + Console.WriteLine ($"Running instrumentation: adb {cmdArgs}"); + + // Run instrumentation with streaming output + var psi = AdbHelper.CreateStartInfo (adbPath, adbTarget, cmdArgs); + using var instrumentProcess = new Process { StartInfo = psi }; + + var locker = new Lock (); + + instrumentProcess.OutputDataReceived += (s, e) => { + if (e.Data != null) + lock (locker) + Console.WriteLine (e.Data); + }; + + instrumentProcess.ErrorDataReceived += (s, e) => { + if (e.Data != null) + lock (locker) + Console.Error.WriteLine (e.Data); + }; + + instrumentProcess.Start (); + instrumentProcess.BeginOutputReadLine (); + instrumentProcess.BeginErrorReadLine (); + + // Also start logcat in the background for additional debug output + logcatPid = await GetAppPidAsync (); + if (logcatPid != null) + StartLogcat (); + + // Wait for instrumentation to complete or Ctrl+C + try { + while (!instrumentProcess.HasExited && !cts.Token.IsCancellationRequested) + await Task.Delay (250, cts.Token).ConfigureAwait (ConfigureAwaitOptions.SuppressThrowing); + + if (cts.Token.IsCancellationRequested) { + try { instrumentProcess.Kill (); } catch { } + return 1; + } + + instrumentProcess.WaitForExit (); + } finally { + // Clean up logcat + try { + if (logcatProcess != null && !logcatProcess.HasExited) { + logcatProcess.Kill (); + logcatProcess.WaitForExit (1000); + } + } catch { } + } + + // Check exit status + if (instrumentProcess.ExitCode != 0) { + Console.Error.WriteLine ($"Error: adb instrument exited with code {instrumentProcess.ExitCode}"); + return 1; + } + + return 0; +} + +async Task RunAppAsync () { // 1. Start the app - if (!StartApp ()) + if (!await StartAppAsync ()) return 1; // 2. Get the PID - logcatPid = GetAppPid (); + logcatPid = await GetAppPidAsync (); if (logcatPid == null) { Console.Error.WriteLine ("Error: App started but could not retrieve PID. The app may have crashed."); return 1; @@ -191,16 +277,16 @@ int RunApp () StartLogcat (); // 4. Wait for app to exit or Ctrl+C - WaitForAppExit (); + await WaitForAppExitAsync (); return 0; } -bool StartApp () +async Task StartAppAsync () { var userArg = string.IsNullOrEmpty (deviceUserId) ? "" : $" --user {deviceUserId}"; var cmdArgs = $"shell am start -S -W{userArg} -n \"{package}/{activity}\""; - var (exitCode, output, error) = RunAdb (cmdArgs); + var (exitCode, output, error) = await AdbHelper.RunAsync (adbPath, adbTarget, cmdArgs, cts.Token, verbose); if (exitCode != 0) { Console.Error.WriteLine ($"Error: Failed to start app: {error}"); return false; @@ -212,10 +298,10 @@ bool StartApp () return true; } -int? GetAppPid () +async Task GetAppPidAsync () { var cmdArgs = $"shell pidof {package}"; - var (exitCode, output, error) = RunAdb (cmdArgs); + var (exitCode, output, error) = await AdbHelper.RunAsync (adbPath, adbTarget, cmdArgs, cts.Token, verbose); if (exitCode != 0 || string.IsNullOrWhiteSpace (output)) return null; @@ -235,20 +321,12 @@ void StartLogcat () if (!string.IsNullOrEmpty (logcatArgs)) logcatArguments += $" {logcatArgs}"; - var fullArguments = string.IsNullOrEmpty (adbTarget) ? logcatArguments : $"{adbTarget} {logcatArguments}"; + var psi = AdbHelper.CreateStartInfo (adbPath, adbTarget, logcatArguments); if (verbose) - Console.WriteLine ($"Running: adb {fullArguments}"); + Console.WriteLine ($"Running: adb {psi.Arguments}"); var locker = new Lock(); - var psi = new ProcessStartInfo { - FileName = adbPath, - Arguments = fullArguments, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; logcatProcess = new Process { StartInfo = psi }; @@ -269,11 +347,11 @@ void StartLogcat () logcatProcess.BeginErrorReadLine (); } -void WaitForAppExit () +async Task WaitForAppExitAsync () { while (!cts!.Token.IsCancellationRequested) { // Check if app is still running - var pid = GetAppPid (); + var pid = await GetAppPidAsync (); if (pid == null || pid != logcatPid) { if (verbose) Console.WriteLine ("App has exited."); @@ -287,7 +365,7 @@ void WaitForAppExit () break; } - Thread.Sleep (1000); + await Task.Delay (1000, cts.Token).ConfigureAwait (ConfigureAwaitOptions.SuppressThrowing); } // Clean up logcat process @@ -302,13 +380,13 @@ void WaitForAppExit () } } -void StopApp () +async Task StopAppAsync () { if (string.IsNullOrEmpty (package) || string.IsNullOrEmpty (adbPath)) return; var userArg = string.IsNullOrEmpty (deviceUserId) ? "" : $" --user {deviceUserId}"; - RunAdb ($"shell am force-stop{userArg} {package}"); + await AdbHelper.RunAsync (adbPath, adbTarget, $"shell am force-stop{userArg} {package}", CancellationToken.None, verbose); } string? FindAdbPath () @@ -332,35 +410,6 @@ void StopApp () return null; } -(int ExitCode, string Output, string Error) RunAdb (string arguments) -{ - var fullArguments = string.IsNullOrEmpty (adbTarget) ? arguments : $"{adbTarget} {arguments}"; - - if (verbose) - Console.WriteLine ($"Running: adb {fullArguments}"); - - var psi = new ProcessStartInfo { - FileName = adbPath, - Arguments = fullArguments, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - - using var process = Process.Start (psi); - if (process == null) - return (-1, "", "Failed to start process"); - - // Read both streams asynchronously to avoid potential deadlock - var outputTask = process.StandardOutput.ReadToEndAsync (); - var errorTask = process.StandardError.ReadToEndAsync (); - - process.WaitForExit (); - - return (process.ExitCode, outputTask.Result, errorTask.Result); -} - (string? Version, string? Commit) GetVersionInfo () { try { diff --git a/src/Microsoft.Android.Templates/androidtest/.template.config/template.json b/src/Microsoft.Android.Templates/androidtest/.template.config/template.json new file mode 100644 index 00000000000..413ec8d1d8a --- /dev/null +++ b/src/Microsoft.Android.Templates/androidtest/.template.config/template.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "Microsoft", + "classifications": [ "Android", "Mobile", "Test" ], + "identity": "Microsoft.Android.AndroidTest", + "name": "Android Test Project", + "description": "A project for creating a .NET for Android test project using MSTest", + "shortName": "androidtest", + "tags": { + "language": "C#", + "type": "project" + }, + "sourceName": "AndroidTest1", + "preferNameDirectory": true, + "primaryOutputs": [ + { "path": "AndroidTest1.csproj" } + ], + "symbols": { + "packageName": { + "type": "parameter", + "description": "Overrides the package name in the AndroidManifest.xml", + "datatype": "string", + "replaces": "com.companyname.AndroidTest1" + }, + "supportedOSVersion": { + "type": "parameter", + "description": "Overrides $(SupportedOSPlatformVersion) in the project", + "datatype": "string", + "replaces": "SUPPORTED_OS_PLATFORM_VERSION", + "defaultValue": "24" + } + }, + "defaultName": "AndroidTest1" +} diff --git a/src/Microsoft.Android.Templates/androidtest/AndroidManifest.xml b/src/Microsoft.Android.Templates/androidtest/AndroidManifest.xml new file mode 100644 index 00000000000..c2166bb216e --- /dev/null +++ b/src/Microsoft.Android.Templates/androidtest/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/Microsoft.Android.Templates/androidtest/AndroidTest1.csproj b/src/Microsoft.Android.Templates/androidtest/AndroidTest1.csproj new file mode 100644 index 00000000000..1e03e352219 --- /dev/null +++ b/src/Microsoft.Android.Templates/androidtest/AndroidTest1.csproj @@ -0,0 +1,27 @@ + + + net11.0-android + SUPPORTED_OS_PLATFORM_VERSION + AndroidTest1 + Exe + enable + enable + true + com.companyname.AndroidTest1 + 1 + 1.0 + + full + + + + + + + + + + diff --git a/src/Microsoft.Android.Templates/androidtest/Test1.cs b/src/Microsoft.Android.Templates/androidtest/Test1.cs new file mode 100644 index 00000000000..97ca260a405 --- /dev/null +++ b/src/Microsoft.Android.Templates/androidtest/Test1.cs @@ -0,0 +1,22 @@ +namespace AndroidTest1; + +[TestClass] +public sealed class Test1 +{ + [TestMethod] + public void TestMethod1() + { + } + + [TestMethod] + public void TestMethod2() + { + Assert.Fail("This test is expected to fail"); + } + + [TestMethod] + public void TestMethod3() + { + Assert.Inconclusive("This test is expected to be skipped"); + } +} diff --git a/src/Microsoft.Android.Templates/androidtest/TestInstrumentation.cs b/src/Microsoft.Android.Templates/androidtest/TestInstrumentation.cs new file mode 100644 index 00000000000..a50eb7424f7 --- /dev/null +++ b/src/Microsoft.Android.Templates/androidtest/TestInstrumentation.cs @@ -0,0 +1,104 @@ +using Android.Runtime; +using Microsoft.Testing.Extensions; +using Microsoft.Testing.Platform.Builder; +using Microsoft.Testing.Platform.Extensions; +using Microsoft.Testing.Platform.Extensions.Messages; + +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] + +namespace AndroidTest1; + +[Instrumentation(Name = "com.companyname.AndroidTest1.TestInstrumentation")] +public class TestInstrumentation : Instrumentation +{ + protected TestInstrumentation(IntPtr handle, JniHandleOwnership ownership) + : base(handle, ownership) { } + + public override void OnCreate(Bundle? arguments) + { + base.OnCreate(arguments); + Start(); + } + + public override void OnStart() + { + base.OnStart(); + + Task.Run(async () => + { + var consumer = new ResultConsumer(this); + var bundle = new Bundle(); + try + { + var writeablePath = Application.Context.GetExternalFilesDir(null)?.AbsolutePath ?? Path.GetTempPath(); + var resultsPath = Path.Combine(writeablePath, "TestResults"); + var builder = await TestApplication.CreateBuilderAsync([ + "--results-directory", resultsPath, + "--report-trx" + ]); + builder.AddMSTest(() => [GetType().Assembly]); + builder.AddTrxReportProvider(); + builder.TestHost.AddDataConsumer(_ => consumer); + + using ITestApplication app = await builder.BuildAsync(); + await app.RunAsync(); + + bundle.PutInt("passed", consumer.Passed); + bundle.PutInt("failed", consumer.Failed); + bundle.PutInt("skipped", consumer.Skipped); + bundle.PutString("resultsPath", consumer.TrxReportPath); + Finish(Result.Ok, bundle); + } + catch (Exception ex) + { + bundle.PutString("error", ex.ToString()); + Finish(Result.Canceled, bundle); + } + }); + } + + class ResultConsumer(Instrumentation instrumentation) : IDataConsumer + { + public int Passed, Failed, Skipped; + public string? TrxReportPath; + + public string Uid => nameof(ResultConsumer); + public string DisplayName => nameof(ResultConsumer); + public string Description => ""; + public string Version => "1.0"; + public Task IsEnabledAsync() => Task.FromResult(true); + + public Type[] DataTypesConsumed => [typeof(TestNodeUpdateMessage), typeof(SessionFileArtifact)]; + + public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken) + { + if (value is SessionFileArtifact artifact) + { + TrxReportPath = artifact.FileInfo.FullName; + } + else if (value is TestNodeUpdateMessage { TestNode: var node }) + { + var state = node.Properties.SingleOrDefault(); + string? outcome = state switch + { + PassedTestNodeStateProperty => "passed", + FailedTestNodeStateProperty or ErrorTestNodeStateProperty + or TimeoutTestNodeStateProperty or CancelledTestNodeStateProperty => "failed", + SkippedTestNodeStateProperty => "skipped", + _ => null + }; + if (outcome is null) + return Task.CompletedTask; + + _ = outcome switch { "passed" => Passed++, "failed" => Failed++, _ => Skipped++ }; + + var id = node.Properties.SingleOrDefault(); + var b = new Bundle(); + b.PutString("test", id is not null ? $"{id.Namespace}.{id.TypeName}.{id.MethodName}" : node.DisplayName); + b.PutString("outcome", outcome); + instrumentation.SendStatus(0, b); + } + return Task.CompletedTask; + } + } +} diff --git a/src/Microsoft.Android.Templates/androidtest/global.json b/src/Microsoft.Android.Templates/androidtest/global.json new file mode 100644 index 00000000000..3140116df39 --- /dev/null +++ b/src/Microsoft.Android.Templates/androidtest/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets index a4c9d65f801..80029893456 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets @@ -9,6 +9,7 @@ This file contains targets specific for Android application projects. + @@ -90,10 +91,15 @@ This file contains targets specific for Android application projects. BeforeTargets="ComputeRunArguments" DependsOnTargets="$(_AndroidComputeRunArgumentsDependsOn)"> + + + <_AdbToolPath>$(AdbToolExe) <_AdbToolPath Condition=" '$(_AdbToolPath)' == '' and $([MSBuild]::IsOSPlatform('windows')) ">adb.exe @@ -107,8 +113,10 @@ This file contains targets specific for Android application projects. <_AndroidRunLogcatArgs Condition=" '$(_AndroidRunLogcatArgs)' == '' ">monodroid-assembly:S <_AndroidRunAdbTargetArg Condition=" '$(AdbTarget)' != '' ">--adb-target "$(AdbTarget)" <_AndroidRunUserArg Condition=" '$(AndroidDeviceUserId)' != '' ">--user "$(AndroidDeviceUserId)" + <_AndroidRunInstrumentArg Condition=" '$(AndroidInstrumentation)' != '' ">--instrument "$(AndroidInstrumentation)" + <_AndroidRunActivityArg Condition=" '$(AndroidInstrumentation)' == '' ">--activity "$(AndroidLaunchActivity)" dotnet - exec "$(_AndroidRunPath)" --adb "$(_AdbToolPath)" $(_AndroidRunAdbTargetArg) --package "$(_AndroidPackage)" --activity "$(AndroidLaunchActivity)" --logcat-args "$(_AndroidRunLogcatArgs)" $(_AndroidRunUserArg) $(_AndroidRunExtraArgs) + exec "$(_AndroidRunPath)" --adb "$(_AdbToolPath)" $(_AndroidRunAdbTargetArg) --package "$(_AndroidPackage)" $(_AndroidRunActivityArg) $(_AndroidRunInstrumentArg) --logcat-args "$(_AndroidRunLogcatArgs)" $(_AndroidRunUserArg) $(_AndroidRunExtraArgs) diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.AssemblyResolution.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.AssemblyResolution.targets index 6b627177c5e..04c6c74543a 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.AssemblyResolution.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.AssemblyResolution.targets @@ -40,7 +40,24 @@ _ResolveAssemblies MSBuild target. DependsOnTargets="BuildOnlySettings;_FixupIntermediateAssembly;ResolveReferences;ComputeFilesToPublish;$(_RunAotMaybe)" Returns="@(ResolvedFileToPublish)"> - + + + <_MissingAssemblies Include="@(_SourceItemsToCopyToPublishDirectory)" + Condition=" '%(Extension)' == '.dll' or '%(Extension)' == '.pdb' " /> + <_MissingAssemblies Remove="@(ResolvedFileToPublish)" /> + + <_MissingAssemblies Remove="@(_MissingAssemblies)" /> + /// Finds the first <instrumentation> element in an AndroidManifest.xml + /// and returns its android:name attribute value. + /// + public class GetAndroidInstrumentationName : AndroidTask + { + public override string TaskPrefix => "GAIN"; + + [Required] + public string ManifestFile { get; set; } = ""; + + [Output] + public string? InstrumentationName { get; set; } + + public override bool RunTask () + { + using var reader = XmlReader.Create (ManifestFile); + while (reader.Read ()) { + if (reader.NodeType == XmlNodeType.Element && reader.LocalName == "instrumentation") { + InstrumentationName = reader.GetAttribute ("name", ManifestDocument.AndroidXmlNamespace.ToString ()); + if (InstrumentationName.IsNullOrEmpty ()) { + Log.LogError ("The element is missing the android:name attribute."); + return false; + } + return !Log.HasLoggedErrors; + } + } + + Log.LogError ("No element found in AndroidManifest.xml."); + return false; + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/MonoAndroidHelper.cs b/src/Xamarin.Android.Build.Tasks/Utilities/MonoAndroidHelper.cs index 4163bcb6276..028cf0d1f2f 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/MonoAndroidHelper.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/MonoAndroidHelper.cs @@ -353,6 +353,9 @@ public static bool HasMonoAndroidReference (ITaskItem assembly) return true; using var pe = new PEReader (File.OpenRead (assembly.ItemSpec)); + if (!pe.HasMetadata) { + return false; // this is a native Windows .dll, not a .NET assembly + } var reader = pe.GetMetadataReader (); return HasMonoAndroidReference (reader); } diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index dc66f41e443..43d6fbbfbd8 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -1727,5 +1727,81 @@ public void StartAndroidActivityRespectsAndroidDeviceUserId () StringAssertEx.ContainsRegex (@"am start.*--user 0", builder.LastBuildOutput, "The 'am start' command should contain '--user 0' when AndroidDeviceUserId is set."); } + + [Test] + public void DotNetNewAndroidTest () + { + var templateName = TestName; + var projectDirectory = Path.Combine (Root, "temp", templateName); + if (Directory.Exists (projectDirectory)) + Directory.Delete (projectDirectory, true); + + TestOutputDirectories [TestContext.CurrentContext.Test.ID] = projectDirectory; + var dotnet = new DotNetCLI (Path.Combine (projectDirectory, $"{templateName}.csproj")); + Assert.IsTrue (dotnet.New ("androidtest"), "`dotnet new androidtest` should succeed"); + + // Build and assert 0 warnings + Assert.IsTrue (dotnet.Build (), "`dotnet build` should succeed"); + dotnet.AssertHasNoWarnings (); + + // Run instrumentation via `dotnet run` and capture output + using var process = dotnet.StartRun (waitForExit: true); + + var locker = new Lock (); + var output = new StringBuilder (); + var processExited = new ManualResetEventSlim (false); + + process.OutputDataReceived += (sender, e) => { + if (e.Data != null) + lock (locker) + output.AppendLine (e.Data); + }; + process.ErrorDataReceived += (sender, e) => { + if (e.Data != null) + lock (locker) + output.AppendLine ($"STDERR: {e.Data}"); + }; + + process.BeginOutputReadLine (); + process.BeginErrorReadLine (); + + // Wait for the process to complete (instrumentation should finish on its own) + bool completed = process.WaitForExit ((int) TimeSpan.FromMinutes (5).TotalMilliseconds); + if (!completed) { + try { process.Kill (entireProcessTree: true); } catch { } + } + + // Write the output to a log file for debugging + string logPath = Path.Combine (projectDirectory, "dotnet-run-output.log"); + File.WriteAllText (logPath, output.ToString ()); + TestContext.AddTestAttachment (logPath); + + Assert.IsTrue (completed, $"`dotnet run` did not complete in time. See {logPath} for details."); + + // Parse INSTRUMENTATION_RESULT lines from output + var outputText = output.ToString (); + int passed = ParseInstrumentationResult (outputText, "passed"); + int failed = ParseInstrumentationResult (outputText, "failed"); + int skipped = ParseInstrumentationResult (outputText, "skipped"); + + Assert.AreEqual (1, passed, $"Expected 1 passed test, got {passed}. See {logPath} for details."); + Assert.AreEqual (1, failed, $"Expected 1 failed test, got {failed}. See {logPath} for details."); + Assert.AreEqual (1, skipped, $"Expected 1 skipped test, got {skipped}. See {logPath} for details."); + } + + static int ParseInstrumentationResult (string output, string key) + { + // Parses lines like: INSTRUMENTATION_RESULT: passed=1 + var prefix = $"INSTRUMENTATION_RESULT: {key}="; + foreach (var rawLine in output.Split ('\n')) { + var line = rawLine.Trim (); + if (line.StartsWith (prefix, StringComparison.Ordinal)) { + var valueStr = line.Substring (prefix.Length).Trim (); + if (int.TryParse (valueStr, out int value)) + return value; + } + } + return -1; + } } }