diff --git a/Xamarin.MacDev/EnvironmentChecker.cs b/Xamarin.MacDev/EnvironmentChecker.cs new file mode 100644 index 0000000..022ca07 --- /dev/null +++ b/Xamarin.MacDev/EnvironmentChecker.cs @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using Xamarin.MacDev.Models; + +#nullable enable + +namespace Xamarin.MacDev; + +/// +/// Performs a comprehensive check of the Apple development environment. +/// Aggregates results from , +/// , and . +/// +public class EnvironmentChecker { + + static readonly string XcrunPath = "/usr/bin/xcrun"; + + readonly ICustomLogger log; + + public EnvironmentChecker (ICustomLogger log) + { + this.log = log ?? throw new ArgumentNullException (nameof (log)); + } + + /// + /// Runs a full environment check and returns the results. + /// + public EnvironmentCheckResult Check () + { + var result = new EnvironmentCheckResult (); + + result.Xcode = GetBestXcode (); + + if (result.Xcode is not null) { + log.LogInfo ("Xcode {0} found at '{1}'.", result.Xcode.Version, result.Xcode.Path); + + if (IsXcodeLicenseAccepted ()) + log.LogInfo ("Xcode license is accepted."); + else + log.LogInfo ("Xcode license may not be accepted. Run 'sudo xcodebuild -license accept'."); + + result.Platforms = GetPlatforms (result.Xcode.Path); + } else { + log.LogInfo ("No Xcode installation found."); + } + + try { + result.CommandLineTools = CheckCommandLineTools (); + } catch (Exception ex) { + log.LogInfo ("Could not check Command Line Tools: {0}", ex.Message); + } + + try { + result.Runtimes = ListRuntimes (); + } catch (Exception ex) { + log.LogInfo ("Could not check runtimes: {0}", ex.Message); + } + + result.DeriveStatus (); + + log.LogInfo ("Environment check complete. Status: {0}.", result.Status); + return result; + } + + /// + /// Returns the best available Xcode installation, or null if none found. + /// + protected virtual XcodeInfo? GetBestXcode () + { + var xcodeManager = new XcodeManager (log); + return xcodeManager.GetBest (); + } + + /// + /// Checks Command Line Tools installation status. + /// + protected virtual CommandLineToolsInfo CheckCommandLineTools () + { + var clt = new CommandLineTools (log); + return clt.Check (); + } + + /// + /// Lists available simulator runtimes. + /// + protected virtual List ListRuntimes () + { + var runtimeService = new RuntimeService (log); + return runtimeService.List (availableOnly: true); + } + + /// + /// Checks whether the Xcode license has been accepted by running + /// xcrun xcodebuild -license check. + /// + public virtual bool IsXcodeLicenseAccepted () + { + try { + var (exitCode, _, _) = ProcessUtils.Exec (XcrunPath, "xcodebuild", "-license", "check"); + return exitCode == 0; + } catch (System.ComponentModel.Win32Exception) { + return false; + } catch (InvalidOperationException) { + return false; + } + } + + /// + /// Runs xcrun xcodebuild -runFirstLaunch to ensure packages are installed. + /// Returns true if the command succeeded. + /// + public bool RunFirstLaunch () + { + try { + log.LogInfo ("Running xcodebuild -runFirstLaunch..."); + var (exitCode, _, stderr) = ProcessUtils.Exec (XcrunPath, "xcodebuild", "-runFirstLaunch"); + if (exitCode != 0) { + log.LogInfo ("xcodebuild -runFirstLaunch failed (exit {0}): {1}", exitCode, stderr.Trim ()); + return false; + } + + log.LogInfo ("xcodebuild -runFirstLaunch completed successfully."); + return true; + } catch (System.ComponentModel.Win32Exception ex) { + log.LogInfo ("Could not run xcodebuild: {0}", ex.Message); + return false; + } catch (InvalidOperationException ex) { + log.LogInfo ("Could not run xcodebuild: {0}", ex.Message); + return false; + } + } + + /// + /// Gets the list of available platform SDK directories in the Xcode bundle. + /// + protected virtual List GetPlatforms (string xcodePath) + { + var platformsDir = Path.Combine (xcodePath, "Contents", "Developer", "Platforms"); + + if (!Directory.Exists (platformsDir)) + return new List (); + + try { + var directoryNames = new List (); + foreach (var dir in Directory.GetDirectories (platformsDir, "*.platform")) + directoryNames.Add (Path.GetFileNameWithoutExtension (dir)); + + return MapDirectoryNamesToPlatforms (directoryNames); + } catch (UnauthorizedAccessException ex) { + log.LogInfo ("Could not read platforms directory: {0}", ex.Message); + return new List (); + } + } + + /// + /// Maps a list of Apple platform directory names (e.g. "iPhoneOS", "MacOSX") + /// to deduplicated friendly names (e.g. "iOS", "macOS"). + /// + public static List MapDirectoryNamesToPlatforms (IEnumerable directoryNames) + { + var platforms = new List (); + foreach (var name in directoryNames) { + var friendly = MapPlatformName (name); + if (!platforms.Contains (friendly)) + platforms.Add (friendly); + } + return platforms; + } + + /// + /// Maps Apple platform directory names to friendly names. + /// + public static string MapPlatformName (string sdkName) + { + switch (sdkName) { + case "iPhoneOS": + case "iPhoneSimulator": + return "iOS"; + case "AppleTVOS": + case "AppleTVSimulator": + return "tvOS"; + case "WatchOS": + case "WatchSimulator": + return "watchOS"; + case "XROS": + case "XRSimulator": + return "visionOS"; + case "MacOSX": + return "macOS"; + default: + return sdkName; + } + } +} diff --git a/tests/EnvironmentCheckerTests.cs b/tests/EnvironmentCheckerTests.cs new file mode 100644 index 0000000..a0b1c48 --- /dev/null +++ b/tests/EnvironmentCheckerTests.cs @@ -0,0 +1,259 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using NUnit.Framework; +using Xamarin.MacDev; +using Xamarin.MacDev.Models; + +#nullable enable + +namespace tests; + +[TestFixture] +public class EnvironmentCheckerTests { + + /// + /// Subclass that overrides external dependencies so Check() can be + /// unit-tested without invoking Xcode, xcode-select, or simctl. + /// + class TestableEnvironmentChecker : EnvironmentChecker { + + public XcodeInfo? XcodeResult { get; set; } + public CommandLineToolsInfo CltResult { get; set; } = new CommandLineToolsInfo (); + public List RuntimesResult { get; set; } = new List (); + public bool LicenseAccepted { get; set; } = true; + public List PlatformsResult { get; set; } = new List (); + public bool ThrowOnClt { get; set; } + public bool ThrowOnRuntimes { get; set; } + + public TestableEnvironmentChecker () : base (ConsoleLogger.Instance) { } + + protected override XcodeInfo? GetBestXcode () => XcodeResult; + + protected override CommandLineToolsInfo CheckCommandLineTools () + { + if (ThrowOnClt) + throw new InvalidOperationException ("simulated CLT failure"); + return CltResult; + } + + protected override List ListRuntimes () + { + if (ThrowOnRuntimes) + throw new InvalidOperationException ("simulated runtime failure"); + return RuntimesResult; + } + + public override bool IsXcodeLicenseAccepted () => LicenseAccepted; + + protected override List GetPlatforms (string xcodePath) => PlatformsResult; + } + + // ── Constructor ── + + [Test] + public void Constructor_ThrowsOnNullLogger () + { + Assert.Throws (() => new EnvironmentChecker (null!)); + } + + // ── Check() aggregation tests ── + + [Test] + public void Check_NoXcode_ReturnsMissing () + { + var checker = new TestableEnvironmentChecker { + XcodeResult = null, + CltResult = new CommandLineToolsInfo { IsInstalled = true }, + }; + var result = checker.Check (); + Assert.That (result.Xcode, Is.Null); + Assert.That (result.Status, Is.EqualTo (EnvironmentStatus.Missing)); + } + + [Test] + public void Check_XcodeAndClt_NoRuntimes_ReturnsPartial () + { + var checker = new TestableEnvironmentChecker { + XcodeResult = new XcodeInfo { Path = "/Applications/Xcode.app", Version = new Version (16, 2) }, + CltResult = new CommandLineToolsInfo { IsInstalled = true }, + RuntimesResult = new List (), + PlatformsResult = new List { "iOS", "macOS" }, + }; + var result = checker.Check (); + Assert.That (result.Status, Is.EqualTo (EnvironmentStatus.Partial)); + Assert.That (result.Xcode, Is.Not.Null); + Assert.That (result.CommandLineTools.IsInstalled, Is.True); + Assert.That (result.Platforms, Has.Count.EqualTo (2)); + } + + [Test] + public void Check_EverythingPresent_ReturnsOk () + { + var checker = new TestableEnvironmentChecker { + XcodeResult = new XcodeInfo { Path = "/Applications/Xcode.app", Version = new Version (16, 2) }, + CltResult = new CommandLineToolsInfo { IsInstalled = true }, + RuntimesResult = new List { + new SimulatorRuntimeInfo { Platform = "iOS", Version = "18.2", IsAvailable = true }, + }, + PlatformsResult = new List { "iOS", "macOS" }, + }; + var result = checker.Check (); + Assert.That (result.Status, Is.EqualTo (EnvironmentStatus.Ok)); + } + + [Test] + public void Check_CltThrows_DoesNotCrash_ReturnsMissing () + { + var checker = new TestableEnvironmentChecker { + XcodeResult = new XcodeInfo { Path = "/Applications/Xcode.app", Version = new Version (16, 2) }, + ThrowOnClt = true, + RuntimesResult = new List { + new SimulatorRuntimeInfo { Platform = "iOS", Version = "18.2", IsAvailable = true }, + }, + }; + var result = checker.Check (); + Assert.That (result.CommandLineTools.IsInstalled, Is.False); + Assert.That (result.Status, Is.EqualTo (EnvironmentStatus.Missing)); + } + + [Test] + public void Check_RuntimesThrows_DoesNotCrash () + { + var checker = new TestableEnvironmentChecker { + XcodeResult = new XcodeInfo { Path = "/Applications/Xcode.app", Version = new Version (16, 2) }, + CltResult = new CommandLineToolsInfo { IsInstalled = true }, + ThrowOnRuntimes = true, + }; + var result = checker.Check (); + Assert.That (result.Runtimes, Is.Empty); + Assert.That (result.Status, Is.EqualTo (EnvironmentStatus.Partial)); + } + + [Test] + public void Check_NoClt_ReturnsMissing () + { + var checker = new TestableEnvironmentChecker { + XcodeResult = new XcodeInfo { Path = "/Applications/Xcode.app", Version = new Version (16, 2) }, + CltResult = new CommandLineToolsInfo { IsInstalled = false }, + RuntimesResult = new List { + new SimulatorRuntimeInfo { Platform = "iOS", Version = "18.2", IsAvailable = true }, + }, + }; + var result = checker.Check (); + Assert.That (result.Status, Is.EqualTo (EnvironmentStatus.Missing)); + } + + [Test] + public void Check_NoXcode_SkipsPlatformsAndLicense () + { + var checker = new TestableEnvironmentChecker { + XcodeResult = null, + PlatformsResult = new List { "iOS" }, + }; + var result = checker.Check (); + Assert.That (result.Platforms, Is.Empty); + } + + // ── Smoke tests (macOS only) ── + + [Test] + [Platform ("MacOsX")] + public void Check_DoesNotThrow () + { + var checker = new EnvironmentChecker (ConsoleLogger.Instance); + Assert.DoesNotThrow (() => checker.Check ()); + } + + [Test] + [Platform ("MacOsX")] + public void Check_ReturnsValidResult () + { + var checker = new EnvironmentChecker (ConsoleLogger.Instance); + var result = checker.Check (); + Assert.That (result, Is.Not.Null); + Assert.That (result.Status, Is.AnyOf (EnvironmentStatus.Ok, EnvironmentStatus.Partial, EnvironmentStatus.Missing)); + } + + [Test] + [Platform ("MacOsX")] + public void IsXcodeLicenseAccepted_DoesNotThrow () + { + var checker = new EnvironmentChecker (ConsoleLogger.Instance); + Assert.DoesNotThrow (() => checker.IsXcodeLicenseAccepted ()); + } + + [Test] + [Platform ("MacOsX")] + public void RunFirstLaunch_DoesNotThrow () + { + var checker = new EnvironmentChecker (ConsoleLogger.Instance); + Assert.DoesNotThrow (() => checker.RunFirstLaunch ()); + } + + // ── MapDirectoryNamesToPlatforms ── + + [Test] + public void MapDirectoryNamesToPlatforms_DeduplicatesIOS () + { + var result = EnvironmentChecker.MapDirectoryNamesToPlatforms ( + new [] { "iPhoneOS", "iPhoneSimulator", "MacOSX" }); + Assert.That (result, Is.EqualTo (new List { "iOS", "macOS" })); + } + + [Test] + public void MapDirectoryNamesToPlatforms_AllApplePlatforms () + { + var result = EnvironmentChecker.MapDirectoryNamesToPlatforms ( + new [] { "iPhoneOS", "iPhoneSimulator", "AppleTVOS", "AppleTVSimulator", + "WatchOS", "WatchSimulator", "XROS", "XRSimulator", "MacOSX" }); + Assert.That (result, Is.EqualTo (new List { "iOS", "tvOS", "watchOS", "visionOS", "macOS" })); + } + + [Test] + public void MapDirectoryNamesToPlatforms_Empty () + { + var result = EnvironmentChecker.MapDirectoryNamesToPlatforms (Array.Empty ()); + Assert.That (result, Is.Empty); + } + + [Test] + public void MapDirectoryNamesToPlatforms_UnknownPassedThrough () + { + var result = EnvironmentChecker.MapDirectoryNamesToPlatforms ( + new [] { "DriverKit", "MacOSX" }); + Assert.That (result, Is.EqualTo (new List { "DriverKit", "macOS" })); + } + + // ── MapPlatformName ── + + [TestCase ("iPhoneOS", "iOS")] + [TestCase ("iPhoneSimulator", "iOS")] + [TestCase ("AppleTVOS", "tvOS")] + [TestCase ("AppleTVSimulator", "tvOS")] + [TestCase ("WatchOS", "watchOS")] + [TestCase ("WatchSimulator", "watchOS")] + [TestCase ("XROS", "visionOS")] + [TestCase ("XRSimulator", "visionOS")] + [TestCase ("MacOSX", "macOS")] + [TestCase ("UnknownPlatform", "UnknownPlatform")] + public void MapPlatformName_MapsCorrectly (string input, string expected) + { + Assert.That (EnvironmentChecker.MapPlatformName (input), Is.EqualTo (expected)); + } + + [Test] + public void MapPlatformName_ReturnsSameForEmpty () + { + Assert.That (EnvironmentChecker.MapPlatformName (""), Is.EqualTo ("")); + } + + [Test] + public void MapPlatformName_IsCaseSensitive () + { + Assert.That (EnvironmentChecker.MapPlatformName ("iphoneos"), Is.EqualTo ("iphoneos")); + Assert.That (EnvironmentChecker.MapPlatformName ("MACOSX"), Is.EqualTo ("MACOSX")); + } +}