diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 32b29dd094f..b472edfe8ea 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -150,6 +150,21 @@ try { } ``` +## Testing + +**Modifying project files in tests:** Never use `File.WriteAllText()` directly to update project source files. Instead, use the `Xamarin.ProjectTools` infrastructure: + +```csharp +// 1. Update the in-memory content +proj.MainActivity = proj.MainActivity.Replace ("old text", "new text"); +// 2. Bump the timestamp so UpdateProjectFiles knows it changed +proj.Touch ("MainActivity.cs"); +// 3. Write to disk (doNotCleanupOnUpdate preserves other files, saveProject: false skips .csproj regeneration) +builder.Save (proj, doNotCleanupOnUpdate: true, saveProject: false); +``` + +This pattern ensures proper encoding, timestamps, and file attributes are handled correctly. The `Touch` + `Save` pattern is used throughout the test suite for incremental builds and file modifications. + ## Error Patterns - **MSBuild Errors:** `XA####` (errors), `XA####` (warnings), `APT####` (Android tools) - **Logging:** Use `Log.LogError`, `Log.LogWarning` with error codes and context diff --git a/src/Microsoft.Android.Run/Microsoft.Android.Run.csproj b/src/Microsoft.Android.Run/Microsoft.Android.Run.csproj index 364ff7732a4..df2d125fd43 100644 --- a/src/Microsoft.Android.Run/Microsoft.Android.Run.csproj +++ b/src/Microsoft.Android.Run/Microsoft.Android.Run.csproj @@ -11,6 +11,7 @@ enable portable Major + false diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.After.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.After.targets index 9cb93fb2e06..01a64d12687 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.After.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.After.targets @@ -33,4 +33,5 @@ This file is imported *after* the Microsoft.NET.Sdk/Sdk.targets. + 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 14edfd0270a..8d266e6b253 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 @@ -25,6 +25,7 @@ This file contains targets specific for Android application projects. <_AndroidComputeRunArgumentsDependsOn Condition=" '$(_AndroidComputeRunArgumentsDependsOn)' == '' and $([MSBuild]::VersionGreaterThanOrEquals($(NetCoreSdkVersion), 10.0.300)) "> _ResolveMonoAndroidSdks; _GetAndroidPackageName; + _AndroidAdbToolPath; <_AndroidComputeRunArgumentsDependsOn Condition=" '$(_AndroidComputeRunArgumentsDependsOn)' == '' "> Install; @@ -52,6 +53,15 @@ This file contains targets specific for Android application projects. + + + <_AdbToolPath>$(AdbToolExe) + <_AdbToolPath Condition=" '$(_AdbToolPath)' == '' and $([MSBuild]::IsOSPlatform('windows')) ">adb.exe + <_AdbToolPath Condition=" '$(_AdbToolPath)' == '' and !$([MSBuild]::IsOSPlatform('windows')) ">adb + <_AdbToolPath>$([System.IO.Path]::Combine ('$(AdbToolPath)', '$(_AdbToolPath)')) + + + @@ -61,10 +71,6 @@ This file contains targets specific for Android application projects. - <_AdbToolPath>$(AdbToolExe) - <_AdbToolPath Condition=" '$(_AdbToolPath)' == '' and $([MSBuild]::IsOSPlatform('windows')) ">adb.exe - <_AdbToolPath Condition=" '$(_AdbToolPath)' == '' and !$([MSBuild]::IsOSPlatform('windows')) ">adb - <_AdbToolPath>$([System.IO.Path]::Combine ('$(AdbToolPath)', '$(_AdbToolPath)')) $(MSBuildProjectDirectory) 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 9cc9b0e77b7..4c816f08a44 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 @@ -73,7 +73,7 @@ _ResolveAssemblies MSBuild target. - + <_RIDs Include="$(RuntimeIdentifier)" Condition=" '$(RuntimeIdentifiers)' == '' " /> <_RIDs Include="$(RuntimeIdentifiers)" Condition=" '$(RuntimeIdentifiers)' != '' " /> @@ -119,6 +119,7 @@ _ResolveAssemblies MSBuild target. + diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.BuildOrder.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.BuildOrder.targets index 039bc54f78a..9bb66ab4073 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.BuildOrder.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.BuildOrder.targets @@ -99,6 +99,7 @@ properties that determine build ordering. SignAndroidPackage; _DeployApk; _DeployAppBundle; + _AndroidConfigureAdbReverse; AndroidPrepareForBuild; @@ -127,12 +128,16 @@ properties that determine build ordering. $(_MinimalSignAndroidPackageDependsOn); + _GenerateEnvironmentFiles; _Upload; + _AndroidConfigureAdbReverse; $(_MinimalSignAndroidPackageDependsOn); + _GenerateEnvironmentFiles; _DeployApk; _DeployAppBundle; + _AndroidConfigureAdbReverse; diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.HotReload.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.HotReload.targets new file mode 100644 index 00000000000..0e887dcf6c6 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.HotReload.targets @@ -0,0 +1,84 @@ + + + + + + + + + + <_AndroidHotReloadAgentAssemblyPath>@(RuntimeEnvironmentVariable->WithMetadataValue('Identity', 'DOTNET_STARTUP_HOOKS')->'%(Value)'->Exists()) + + + + + <_AndroidHotReloadAgentAssemblyName>$([System.IO.Path]::GetFileNameWithoutExtension('$(_AndroidHotReloadAgentAssemblyPath)')) + + + + + + + + + + + + + + + + + + <_AndroidWebSocketEndpoint>@(RuntimeEnvironmentVariable->WithMetadataValue('Identity', 'DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT')->'%(Value)') + + + + + <_AndroidWebSocketPort>$([System.UriBuilder]::new('$(_AndroidWebSocketEndpoint)').Port) + + <_AndroidWebSocketPort Condition=" '$(_AndroidWebSocketPort)' == '-1' "> + + + + + + + + diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.ProjectCapabilities.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.ProjectCapabilities.targets index fef1c290dfb..502c2460fe7 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.ProjectCapabilities.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.ProjectCapabilities.targets @@ -17,6 +17,7 @@ Docs about @(ProjectCapability): + diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs index 46fbc3ecae4..a3f0ae63fe8 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs @@ -31,7 +31,7 @@ public DotNetCLI (string projectOrSolution) /// /// command arguments /// A started Process instance. Caller is responsible for disposing. - protected Process ExecuteProcess (params string [] args) + protected Process ExecuteProcess (string [] args, string workingDirectory = null) { var p = new Process (); p.StartInfo.FileName = Path.Combine (TestEnvironment.DotNetPreviewDirectory, "dotnet"); @@ -40,6 +40,9 @@ protected Process ExecuteProcess (params string [] args) p.StartInfo.UseShellExecute = false; p.StartInfo.RedirectStandardOutput = true; p.StartInfo.RedirectStandardError = true; + if (!string.IsNullOrEmpty (workingDirectory)) { + p.StartInfo.WorkingDirectory = workingDirectory; + } p.StartInfo.SetEnvironmentVariable ("DOTNET_MULTILEVEL_LOOKUP", "0"); p.StartInfo.SetEnvironmentVariable ("PATH", TestEnvironment.DotNetPreviewDirectory + Path.PathSeparator + Environment.GetEnvironmentVariable ("PATH")); if (TestEnvironment.UseLocalBuildOutput) { @@ -172,6 +175,29 @@ public Process StartRun (bool waitForExit = true, string [] parameters = null) return ExecuteProcess (arguments.ToArray ()); } + /// + /// Starts `dotnet watch` and returns a running Process that can be monitored and killed. + /// This is used for hot reload testing where dotnet-watch builds, deploys, and watches for file changes. + /// + /// Additional arguments to pass to `dotnet watch`. + /// A running Process instance. Caller is responsible for disposing. + public Process StartWatch (string [] parameters = null) + { + var arguments = new List { + "watch", + "--project", $"\"{projectOrSolution}\"", + "--non-interactive", + "--verbose", + "--verbosity", "diag", + "-bl", + }; + if (parameters != null) { + arguments.AddRange (parameters); + } + + return ExecuteProcess (arguments.ToArray (), workingDirectory: ProjectDirectory); + } + public IEnumerable LastBuildOutput { get { if (!string.IsNullOrEmpty (BuildLogFile) && File.Exists (BuildLogFile)) { diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/ProjectBuilder.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/ProjectBuilder.cs index f0345a776e2..db607aa6475 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/ProjectBuilder.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/ProjectBuilder.cs @@ -74,9 +74,14 @@ protected override void Dispose (bool disposing) Cleanup (); } - bool built_before; bool last_build_result; + /// + /// Indicates whether the project has been built at least once. + /// Set to true after Build(), or manually when using external build tools (e.g. DotNetCLI.StartWatch). + /// + public bool BuiltBefore { get; set; } + /// /// Gets the build output from the last build operation. /// @@ -97,7 +102,7 @@ public void Save (XamarinProject project, bool doNotCleanupOnUpdate = false, boo { var files = project.Save (saveProject); - if (!built_before) { + if (!BuiltBefore) { if (project.ShouldPopulate) { if (Directory.Exists (ProjectDirectory)) { FileSystemUtils.SetDirectoryWriteable (ProjectDirectory); @@ -130,7 +135,7 @@ public bool Build (XamarinProject project, bool doNotCleanupOnUpdate = false, st Output = project.CreateBuildOutput (this); bool result = BuildInternal (Path.Combine (ProjectDirectory, project.ProjectFilePath), Target, parameters, environmentVariables, restore: project.ShouldRestorePackageReferences, binlogName: Path.GetFileNameWithoutExtension (BuildLogFile)); - built_before = true; + BuiltBefore = true; if (CleanupAfterSuccessfulBuild) Cleanup (); @@ -194,7 +199,7 @@ public void Cleanup () //logs if (!last_build_result) return; - built_before = false; + BuiltBefore = false; var projectDirectory = Path.Combine (XABuildPaths.TestOutputDirectory, ProjectDirectory); if (Directory.Exists (projectDirectory)) { diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets index f01393f6565..7a55d177bed 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets @@ -1773,7 +1773,7 @@ because xbuild doesn't support framework reference assemblies. - + <_GeneratedAndroidEnvironment Include="mono.enable_assembly_preload=0" Condition=" '$(AndroidEnablePreloadAssemblies)' != 'True' " /> <_GeneratedAndroidEnvironment Include="DOTNET_MODIFIABLE_ASSEMBLIES=Debug" Condition=" '$(AndroidIncludeDebugSymbols)' == 'true' and '$(AndroidUseInterpreter)' == 'true' " /> diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index 11be3075d1c..eeaa4f94702 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -198,6 +198,121 @@ public void DotNetRunWithDeviceParameter () Assert.IsTrue (foundMessage, $"Expected message '{logcatMessage}' was not found in output. See {logPath} for details."); } + [Test] + public void DotNetWatchHotReload () + { + const string initialMessage = "DOTNET_WATCH_INITIAL_12345"; + const string hotReloadMessage = "DOTNET_WATCH_HOT_RELOAD_APPLIED"; + + var proj = new XamarinAndroidApplicationProject (); + proj.SetRuntime (AndroidRuntime.CoreCLR); // CoreCLR only for now, as MonoVM requires: https://github.com/dotnet/runtime/commit/c8e2a6110c69601540c25f2099053505fa088b9e + + // Enable hot reload log messages from the delta client + proj.OtherBuildItems.Add (new BuildItem ("AndroidEnvironment", "env.txt") { + TextContent = () => "HOTRELOAD_DELTA_CLIENT_LOG_MESSAGES=[HotReload]", + }); + + // Add a Console.WriteLine that will appear in logcat + proj.MainActivity = proj.DefaultMainActivity.Replace ( + "//${AFTER_ONCREATE}", + $"Console.WriteLine (\"{initialMessage}\");"); + + // Add a MetadataUpdateHandler that logs when hot reload is applied + proj.Sources.Add (new BuildItem.Source ("HotReloadService.cs") { + TextContent = () => +@"using System; + +[assembly: System.Reflection.Metadata.MetadataUpdateHandlerAttribute (typeof (UnderTest.HotReloadService))] + +namespace UnderTest +{ + public static class HotReloadService + { + internal static void ClearCache (Type[]? types) { } + internal static void UpdateApplication (Type[]? types) + { + Console.WriteLine (""" + hotReloadMessage + @"""); + } + } +} +" + }); + + using var builder = CreateApkBuilder (); + builder.Save (proj); + + var dotnet = new DotNetCLI (Path.Combine (Root, builder.ProjectDirectory, proj.ProjectFilePath)); + + // Start dotnet watch which will build, deploy, and watch for changes + using var process = dotnet.StartWatch (); + + var locker = new Lock (); + var output = new StringBuilder (); + var initialMessageEvent = new ManualResetEventSlim (); + var hotReloadAppliedEvent = new ManualResetEventSlim (); + + process.OutputDataReceived += (sender, e) => { + if (e.Data != null) { + lock (locker) { + output.AppendLine (e.Data); + if (e.Data.Contains (initialMessage)) { + initialMessageEvent.Set (); + } + if (e.Data.Contains (hotReloadMessage)) { + hotReloadAppliedEvent.Set (); + } + } + } + }; + process.ErrorDataReceived += (sender, e) => { + if (e.Data != null) { + lock (locker) { + output.AppendLine ($"STDERR: {e.Data}"); + if (e.Data.Contains (hotReloadMessage)) { + hotReloadAppliedEvent.Set (); + } + } + } + }; + + process.BeginOutputReadLine (); + process.BeginErrorReadLine (); + + string logPath = Path.Combine (Root, builder.ProjectDirectory, "dotnet-watch-output.log"); + + try { + // Wait for the initial message to appear (app launched and running) + Assert.IsTrue (initialMessageEvent.Wait (TimeSpan.FromMinutes (5)), + $"Initial message '{initialMessage}' was not found in output. See {logPath} for details."); + + // Give dotnet watch time to finish post-deploy setup and start its file watcher. + // There is no explicit "ready" signal from dotnet watch after deploy completes. + Thread.Sleep (5000); + + // Modify the source file to trigger hot reload + proj.MainActivity = proj.MainActivity.Replace ( + $"Console.WriteLine (\"{initialMessage}\");", + $"Console.WriteLine (\"{initialMessage}\");\n\t\t\tConsole.WriteLine (\"MODIFIED_LINE\");"); + proj.Touch ("MainActivity.cs"); + builder.BuiltBefore = true; // dotnet watch will build, not builder.Build() + builder.Save (proj, doNotCleanupOnUpdate: true, saveProject: false); + + // Wait for hot reload to apply (MetadataUpdateHandler fires Console.WriteLine) + Assert.IsTrue (hotReloadAppliedEvent.Wait (TimeSpan.FromMinutes (2)), + $"Hot reload message '{hotReloadMessage}' was not found in output. See {logPath} for details."); + } finally { + // Kill the process + if (!process.HasExited) { + process.Kill (entireProcessTree: true); + process.WaitForExit (); + } + + // Write the output to a log file for debugging + File.WriteAllText (logPath, output.ToString ()); + TestContext.AddTestAttachment (logPath); + } + } + [Test] [TestCase (true)] [TestCase (false)]