//#define VERBOSE_COMPARISON using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using Xamarin.Utils; using NUnit.Framework; #nullable enable namespace Xamarin.Tests { public static class DotNet { static string? dotnet_executable; public static string Executable { get { if (dotnet_executable is null) { dotnet_executable = Configuration.GetVariable ("DOTNET", null); if (string.IsNullOrEmpty (dotnet_executable)) throw new Exception ($"Could not find the dotnet executable."); if (!File.Exists (dotnet_executable)) throw new FileNotFoundException ($"The dotnet executable '{dotnet_executable}' does not exist."); } return dotnet_executable; } } public static ExecutionResult AssertPack (string project, Dictionary? properties = null) { return Execute ("pack", project, properties, true); } public static ExecutionResult AssertPackFailure (string project, Dictionary? properties = null) { var rv = Execute ("pack", project, properties, false); Assert.AreNotEqual (0, rv.ExitCode, "Unexpected success"); return rv; } public static ExecutionResult AssertPublish (string project, Dictionary? properties = null) { return Execute ("publish", project, properties, true); } public static ExecutionResult AssertPublishFailure (string project, Dictionary? properties = null) { var rv = Execute ("publish", project, properties, false); Assert.AreNotEqual (0, rv.ExitCode, "Unexpected success"); return rv; } public static ExecutionResult AssertRestore (string project, Dictionary? properties = null) { return Execute ("restore", project, properties, true); } public static ExecutionResult Restore (string project, Dictionary? properties = null) { return Execute ("restore", project, properties, false); } public static ExecutionResult AssertBuild (string project, Dictionary? properties = null) { return Execute ("build", project, properties, true); } public static ExecutionResult AssertBuildFailure (string project, Dictionary? properties = null) { var rv = Execute ("build", project, properties, false); Assert.AreNotEqual (0, rv.ExitCode, "Unexpected success"); return rv; } public static ExecutionResult Build (string project, Dictionary? properties = null) { return Execute ("build", project, properties, false); } public static ExecutionResult AssertNew (string outputDirectory, string template, string? name = null, string? language = null) { Directory.CreateDirectory (outputDirectory); var args = new List (); args.Add ("new"); args.Add (template); if (!string.IsNullOrEmpty (name)) { args.Add ("--name"); args.Add (name!); } if (!string.IsNullOrEmpty (language)) { args.Add ("--language"); args.Add (language); } var env = new Dictionary (); env ["MSBuildSDKsPath"] = null; env ["MSBUILD_EXE_PATH"] = null; var output = new StringBuilder (); var rv = Execution.RunWithStringBuildersAsync (Executable, args, env, output, output, Console.Out, workingDirectory: outputDirectory, timeout: TimeSpan.FromMinutes (10)).Result; if (rv.ExitCode != 0) { Console.WriteLine ($"'{Executable} {StringUtils.FormatArguments (args)}' failed with exit code {rv.ExitCode}."); Console.WriteLine (output); Assert.AreEqual (0, rv.ExitCode, $"Exit code: {Executable} {StringUtils.FormatArguments (args)}"); } return new ExecutionResult (output, output, rv.ExitCode); } public static ExecutionResult Execute (string verb, string project, Dictionary? properties, bool assert_success = true, string? target = null) { if (!File.Exists (project)) throw new FileNotFoundException ($"The project file '{project}' does not exist."); verb = verb.ToLowerInvariant (); switch (verb) { case "clean": case "build": case "pack": case "publish": case "restore": var args = new List (); args.Add (verb); args.Add (project); if (properties is not null) { Dictionary? generatedProps = null; foreach (var prop in properties) { if (prop.Value.IndexOfAny (new char [] { ';' }) >= 0) { // https://github.com/dotnet/msbuild/issues/471 // Escaping the semi colon like the issue suggests at one point doesn't work, because in // that case MSBuild won't split the string into its parts for tasks that take a string[]. // This means that a task that takes a "string[] RuntimeIdentifiers" will get an array with // a single element, where that single element is the whole RuntimeIdentifiers string. // Example task: https://github.com/dotnet/sdk/blob/ffca47e9a36652da2e7041360f2201a2ba197194/src/Tasks/Microsoft.NET.Build.Tasks/ProcessFrameworkReferences.cs#L45 // args.Add ($"/p:{prop.Key}=\"{prop.Value}\""); // Setting a property with a semicolon from the command line doesn't work anymore. // Ref: https://github.com/dotnet/sdk/issues/27059#issuecomment-1219319513 // So write these properties in a file instead. This is a behavioural difference, because // they'll be project-specific instead of global, but I don't see a better workaround. if (generatedProps is null) generatedProps = new Dictionary (); generatedProps.Add (prop.Key, prop.Value); } else { args.Add ($"/p:{prop.Key}={prop.Value}"); } } if (generatedProps is not null) { var sb = new StringBuilder (); sb.AppendLine (""); sb.AppendLine (""); sb.AppendLine ("\t"); foreach (var prop in generatedProps) { sb.AppendLine ($"\t\t<{prop.Key}>{prop.Value}"); } sb.AppendLine ("\t"); sb.AppendLine (""); var generatedProjectFile = Path.Combine (Cache.CreateTemporaryDirectory (), "GeneratedProjectFile.props"); File.WriteAllText (generatedProjectFile, sb.ToString ()); args.Add ($"/p:GeneratedProjectFile={generatedProjectFile}"); } } if (!string.IsNullOrEmpty (target)) args.Add ("/t:" + target); var binlogPath = Path.Combine (Path.GetDirectoryName (project)!, $"log-{verb}-{DateTime.Now:yyyyMMdd_HHmmss}.binlog"); args.Add ($"/bl:{binlogPath}"); Console.WriteLine ($"Binlog: {binlogPath}"); var env = new Dictionary (); env ["MSBuildSDKsPath"] = null; env ["MSBUILD_EXE_PATH"] = null; var output = new StringBuilder (); var rv = Execution.RunWithStringBuildersAsync (Executable, args, env, output, output, Console.Out, workingDirectory: Path.GetDirectoryName (project), timeout: TimeSpan.FromMinutes (10)).Result; if (assert_success && rv.ExitCode != 0) { var outputStr = output.ToString (); Console.WriteLine ($"'{Executable} {StringUtils.FormatArguments (args)}' failed with exit code {rv.ExitCode}."); Console.WriteLine (outputStr); Assert.AreEqual (0, rv.ExitCode, $"Exit code: {Executable} {StringUtils.FormatArguments (args)}"); } return new ExecutionResult (output, output, rv.ExitCode) { BinLogPath = binlogPath, }; default: throw new NotImplementedException ($"Unknown dotnet action: '{verb}'"); } } public static void CompareApps (string old_app, string new_app) { #if VERBOSE_COMPARISON Console.WriteLine ($"Comparing:"); Console.WriteLine ($" {old_app}"); Console.WriteLine ($" {new_app}"); #endif var all_old_files = Directory.GetFiles (old_app, "*.*", SearchOption.AllDirectories).Select ((v) => v.Substring (old_app.Length + 1)); var all_new_files = Directory.GetFiles (new_app, "*.*", SearchOption.AllDirectories).Select ((v) => v.Substring (new_app.Length + 1)); var filter = new Func, IEnumerable> ((lst) => { return lst.Where (v => { var extension = Path.GetExtension (v); switch (extension) { case ".exe": case ".dll": case ".pdb": // the set of BCL assemblies is quite different return false; case ".dylib": // ignore dylibs, they're not the same return false; } var filename = Path.GetFileName (v); switch (filename) { case "icudt.dat": return false; // ICU data file only present on .NET case "runtime-options.plist": return false; // the .NET runtime will deal with selecting the http handler, no need for us to do anything case "runtimeconfig.bin": return false; // this file is present for .NET apps, but not legacy apps. case "embedded.mobileprovision": case "CodeResources": return false; // sometimes we don't sign in .NET when we do in legacy (if there's an empty Entitlements.plist file) } var components = v.Split ('/'); if (components.Any (v => v.EndsWith (".framework", StringComparison.Ordinal))) { return false; // This is Mono.framework, which is waiting for https://github.com/dotnet/runtime/issues/42846 } return true; }); }); var old_files = filter (all_old_files); var new_files = filter (all_new_files); var extra_old_files = old_files.Except (new_files); var extra_new_files = new_files.Except (old_files); #if VERBOSE_COMPARISON Console.WriteLine ("Files in old app:"); foreach (var f in all_old_files.OrderBy (v => v)) Console.WriteLine ($"\t{f}"); Console.WriteLine ("Files in new app:"); foreach (var f in all_new_files.OrderBy (v => v)) Console.WriteLine ($"\t{f}"); Console.WriteLine ("Files in old app (filtered):"); foreach (var f in old_files.OrderBy (v => v)) Console.WriteLine ($"\t{f}"); Console.WriteLine ("Files in new app (filtered):"); foreach (var f in new_files.OrderBy (v => v)) Console.WriteLine ($"\t{f}"); if (extra_new_files.Any ()) { Console.WriteLine ("Extra dotnet files:"); foreach (var f in extra_new_files) Console.WriteLine ($" {f}"); } if (extra_old_files.Any ()) { Console.WriteLine ("Missing dotnet files:"); foreach (var f in extra_old_files) Console.WriteLine ($" {f}"); } // Print out a size comparison. A size difference does not fail the test, because some size differences are normal. Console.WriteLine ("Size comparison:"); foreach (var file in new_files) { var new_size = new FileInfo (Path.Combine (new_app, file)).Length; var old_size = new FileInfo (Path.Combine (old_app, file)).Length; if (new_size == old_size) continue; var diff = new_size - old_size; Console.WriteLine ($"\t{file}: {old_size} bytes -> {new_size} bytes. Diff: {diff}"); } var total_old = all_old_files.Select (v => new FileInfo (Path.Combine (old_app, v)).Length).Sum (); var total_new = all_new_files.Select (v => new FileInfo (Path.Combine (new_app, v)).Length).Sum (); var total_diff = total_new - total_old; Console.WriteLine (); Console.WriteLine ($"\tOld app size: {total_old} bytes = {total_old / 1024.0:0.0} KB = {total_old / (1024.0 * 1024.0):0.0} MB"); Console.WriteLine ($"\tNew app size: {total_new} bytes = {total_new / 1024.0:0.0} KB = {total_new / (1024.0 * 1024.0):0.0} MB"); Console.WriteLine ($"\tSize comparison complete, total size change: {total_diff} bytes = {total_diff / 1024.0:0.0} KB = {total_diff / (1024.0 * 1024.0):0.0} MB"); #endif Assert.That (extra_new_files, Is.Empty, "Extra dotnet files"); Assert.That (extra_old_files, Is.Empty, "Missing dotnet files"); } } public class ExecutionResult { public StringBuilder StandardOutput; public StringBuilder StandardError; public int ExitCode; public bool TimedOut; public string BinLogPath; public ExecutionResult (StringBuilder stdout, StringBuilder stderr, int exitCode) { StandardOutput = stdout; StandardError = stderr; ExitCode = exitCode; BinLogPath = string.Empty; } } }