diff --git a/.github/CSharpExpert.agent.md b/.github/CSharpExpert.agent.md index 14230f6..315b027 100644 --- a/.github/CSharpExpert.agent.md +++ b/.github/CSharpExpert.agent.md @@ -14,7 +14,7 @@ secure, readable, and maintainable code that follows .NET conventions and existi - **C# Version**: `latest` (per project files) - **Nullable**: Enabled - **Purpose**: Apple SDK discovery, provisioning profiles, plist parsing, Xcode integration -- **Tests**: NUnit (UnitTests project) +- **Tests**: NUnit (tests project) When invoked: @@ -49,7 +49,7 @@ When invoked: ## Build and Test - Build: `dotnet build Xamarin.MacDev.sln` -- Tests: `dotnet test UnitTests/UnitTests.csproj` +- Tests: `dotnet test tests/tests.csproj` - Pack: `dotnet pack Xamarin.MacDev/Xamarin.MacDev.csproj` ## Async Best Practices diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f2a5291..ebf6b52 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -14,7 +14,7 @@ provisioning, plist parsing, and related developer utilities. - C# Language Version: `latest` (per project files) - Nullable reference types are enabled - SDK-style projects built with `dotnet build` (or `make`) -- Tests: `UnitTests` project using **NUnit** +- Tests: `tests` project using **NUnit** ## Guidance @@ -23,10 +23,32 @@ provisioning, plist parsing, and related developer utilities. - Avoid APIs that are unavailable in `netstandard2.0` when used in shared code. - Do not edit auto-generated files (`// ` or `*.g.cs`). +## Code Style + +- Use tabs for indentation (size 4). +- Use `namespace Xamarin.MacDev {` (brace on same line) with a **blank line after the opening brace**. +- One class per file. +- Keep classes under ~700 lines; extract helpers/parsers into separate files when needed. +- Private/internal fields: `camelCase`. Types/methods/properties: `PascalCase`. +- Space before method call parentheses: `Method ()`, not `Method()`. +- License header on new files: `// Copyright (c) Microsoft Corporation.` / `// Licensed under the MIT License.` +- Add `#nullable enable` at the top of new files. + ## Error Handling - Validate inputs at public boundaries using `ArgumentNullException.ThrowIfNull` and `string.IsNullOrWhiteSpace`. - Use precise exception types and avoid catch-and-swallow patterns. +- Catch specific exceptions (`catch (UnauthorizedAccessException)`) — never bare `catch (Exception)`. + +## Resource Management + +- Use `using` on **all** disposables — including `StringWriter`, `Process`, `StreamReader`, etc. +- Prefer the Null Object Pattern (e.g. `static readonly NullProgress`) over null-checking everywhere. + +## .NET SDK Patterns + +- Prefer `Path.GetRandomFileName()` for temp file names. +- Use `File.Create(path, bufferSize, FileOptions.DeleteOnClose)` when appropriate for transient files. ## Documentation @@ -35,3 +57,4 @@ provisioning, plist parsing, and related developer utilities. ## Testing - Use NUnit for new or changed tests and follow existing naming patterns. +- Every new utility method **must** have test coverage — no exceptions. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ba329f..f08ece2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,11 +44,11 @@ jobs: - name: Test (Windows) if: runner.os == 'Windows' - run: dotnet test UnitTests/UnitTests.csproj -c Release -l "console;verbosity=detailed" -l trx --results-directory ${{ github.workspace }}/artifacts/test-results + run: dotnet test tests/tests.csproj -c Release -l "console;verbosity=detailed" -l trx --results-directory ${{ github.workspace }}/artifacts/test-results - name: Test (macOS) if: runner.os == 'macOS' - run: dotnet test UnitTests/UnitTests.csproj -c Release -f net10.0 -l "console;verbosity=detailed" -l trx --results-directory ${{ github.workspace }}/artifacts/test-results + run: dotnet test tests/tests.csproj -c Release -f net10.0 -l "console;verbosity=detailed" -l trx --results-directory ${{ github.workspace }}/artifacts/test-results - name: Calculate Version shell: bash diff --git a/Xamarin.MacDev.sln b/Xamarin.MacDev.sln index f39eaf1..a13e53b 100644 --- a/Xamarin.MacDev.sln +++ b/Xamarin.MacDev.sln @@ -3,7 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 2012 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Xamarin.MacDev", "Xamarin.MacDev\Xamarin.MacDev.csproj", "{CC3D9353-20C4-467A-8522-A9DED6F0C753}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "UnitTests\UnitTests.csproj", "{39DBAAF8-57A5-49A3-9E9A-11B545906AED}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "tests", "tests\tests.csproj", "{39DBAAF8-57A5-49A3-9E9A-11B545906AED}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/Xamarin.MacDev/Models/CommandLineToolsInfo.cs b/Xamarin.MacDev/Models/CommandLineToolsInfo.cs new file mode 100644 index 0000000..58b2241 --- /dev/null +++ b/Xamarin.MacDev/Models/CommandLineToolsInfo.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +namespace Xamarin.MacDev.Models { + + /// + /// Information about Xcode Command Line Tools installation. + /// + public class CommandLineToolsInfo { + /// Whether the Command Line Tools are installed. + public bool IsInstalled { get; set; } + + /// The Command Line Tools version string (e.g. "16.2.0.0.1.1733547573"), or null if not installed. + public string? Version { get; set; } + + /// The Command Line Tools install path (e.g. "/Library/Developer/CommandLineTools"), or null if not installed. + public string? Path { get; set; } + + public override string ToString () => IsInstalled ? $"Command Line Tools {Version} at {Path}" : "Command Line Tools not installed"; + } +} diff --git a/Xamarin.MacDev/Models/EnvironmentCheckResult.cs b/Xamarin.MacDev/Models/EnvironmentCheckResult.cs new file mode 100644 index 0000000..e00ab1d --- /dev/null +++ b/Xamarin.MacDev/Models/EnvironmentCheckResult.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; + +#nullable enable + +namespace Xamarin.MacDev.Models { + + /// + /// Overall status of the Apple development environment. + /// + public enum EnvironmentStatus { + /// All required components are present. + Ok, + /// Some optional components are missing. + Partial, + /// Required components are missing. + Missing, + } + + /// + /// Result of a comprehensive Apple environment check. + /// + public class EnvironmentCheckResult { + /// Information about the active Xcode installation, or null if none found. + public XcodeInfo? Xcode { get; set; } + + /// Information about the Command Line Tools. + public CommandLineToolsInfo CommandLineTools { get; set; } = new CommandLineToolsInfo (); + + /// Installed simulator runtimes. + public List Runtimes { get; set; } = new List (); + + /// Enabled development platforms (e.g. "iOS", "macOS"). + public List Platforms { get; set; } = new List (); + + /// Overall environment status. + public EnvironmentStatus Status { get; set; } = EnvironmentStatus.Missing; + + /// + /// Derives the from the current state of the environment. + /// + public void DeriveStatus () + { + if (Xcode is null || !CommandLineTools.IsInstalled) { + Status = EnvironmentStatus.Missing; + return; + } + + if (Runtimes.Count == 0) { + Status = EnvironmentStatus.Partial; + return; + } + + Status = EnvironmentStatus.Ok; + } + } +} diff --git a/Xamarin.MacDev/Models/SimulatorDeviceInfo.cs b/Xamarin.MacDev/Models/SimulatorDeviceInfo.cs new file mode 100644 index 0000000..2f521ea --- /dev/null +++ b/Xamarin.MacDev/Models/SimulatorDeviceInfo.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +namespace Xamarin.MacDev.Models { + + /// + /// Information about a simulator device from xcrun simctl. + /// + public class SimulatorDeviceInfo { + /// The simulator display name (e.g. "iPhone 16 Pro"). + public string Name { get; set; } = ""; + + /// The simulator UDID. + public string Udid { get; set; } = ""; + + /// The device state (e.g. "Shutdown", "Booted"). + public string State { get; set; } = ""; + + /// The runtime identifier (e.g. "com.apple.CoreSimulator.SimRuntime.iOS-18-2"). + public string RuntimeIdentifier { get; set; } = ""; + + /// The device type identifier (e.g. "com.apple.CoreSimulator.SimDeviceType.iPhone-16-Pro"). + public string DeviceTypeIdentifier { get; set; } = ""; + + /// Whether this simulator is available. + public bool IsAvailable { get; set; } + + public bool IsBooted => State == "Booted"; + + public override string ToString () => $"{Name} ({Udid}) [{State}]"; + } +} diff --git a/Xamarin.MacDev/Models/SimulatorRuntimeInfo.cs b/Xamarin.MacDev/Models/SimulatorRuntimeInfo.cs new file mode 100644 index 0000000..414f1b9 --- /dev/null +++ b/Xamarin.MacDev/Models/SimulatorRuntimeInfo.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +namespace Xamarin.MacDev.Models { + + /// + /// Information about a simulator runtime from xcrun simctl. + /// + public class SimulatorRuntimeInfo { + /// The platform name (e.g. "iOS", "tvOS", "watchOS", "visionOS"). + public string Platform { get; set; } = ""; + + /// The runtime version (e.g. "18.2"). + public string Version { get; set; } = ""; + + /// The build version (e.g. "22C150"). + public string BuildVersion { get; set; } = ""; + + /// The runtime identifier (e.g. "com.apple.CoreSimulator.SimRuntime.iOS-18-2"). + public string Identifier { get; set; } = ""; + + /// The display name (e.g. "iOS 18.2"). + public string Name { get; set; } = ""; + + /// Whether this runtime is available for use. + public bool IsAvailable { get; set; } + + /// Whether this runtime is bundled with Xcode (vs downloaded separately). + public bool IsBundled { get; set; } + + public override string ToString () => $"{Name} ({Identifier})"; + } +} diff --git a/Xamarin.MacDev/Models/XcodeInfo.cs b/Xamarin.MacDev/Models/XcodeInfo.cs new file mode 100644 index 0000000..56d3db7 --- /dev/null +++ b/Xamarin.MacDev/Models/XcodeInfo.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; + +#nullable enable + +namespace Xamarin.MacDev.Models { + + /// + /// Information about an Xcode installation. + /// + public class XcodeInfo { + /// The path to the Xcode.app bundle (e.g. /Applications/Xcode.app). + public string Path { get; set; } = ""; + + /// The Xcode version (e.g. 16.2). + public Version Version { get; set; } = new Version (0, 0); + + /// The Xcode build number (e.g. 16C5032a). + public string Build { get; set; } = ""; + + /// The DTXcode value from the version plist. + public string DTXcode { get; set; } = ""; + + /// Whether this is the currently selected Xcode (via xcode-select). + public bool IsSelected { get; set; } + + /// Whether the Xcode path is or contains a symlink. + public bool IsSymlink { get; set; } + + public override string ToString () => $"{Path} ({Version}, {Build})"; + } +} diff --git a/Xamarin.MacDev/ProcessUtils.cs b/Xamarin.MacDev/ProcessUtils.cs new file mode 100644 index 0000000..828a490 --- /dev/null +++ b/Xamarin.MacDev/ProcessUtils.cs @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +#nullable enable + +namespace Xamarin.MacDev { + + /// + /// Static helper for running external processes with async stdout/stderr capture + /// and cancellation support. Inspired by dotnet/android-tools ProcessUtils. + /// + public static class ProcessUtils { + + /// + /// Starts a process and asynchronously streams its stdout/stderr to the provided writers. + /// Returns the process exit code. + /// + public static async Task StartProcess (ProcessStartInfo psi, TextWriter? stdout, TextWriter? stderr, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested (); + psi.UseShellExecute = false; + psi.RedirectStandardOutput |= stdout is not null; + psi.RedirectStandardError |= stderr is not null; + + // Provide sinks when redirection is on but no writer was supplied + if (psi.RedirectStandardOutput && stdout is null) + stdout = TextWriter.Null; + if (psi.RedirectStandardError && stderr is null) + stderr = TextWriter.Null; + + var process = new Process { + StartInfo = psi, + EnableRaisingEvents = true, + }; + + Task output = Task.FromResult (true); + Task error = Task.FromResult (true); + var exitDone = new TaskCompletionSource (); + process.Exited += (o, e) => exitDone.TrySetResult (true); + + using (process) { + process.Start (); + + // Guard against race where process exits before Exited handler fires + if (process.HasExited) + exitDone.TrySetResult (true); + + using (cancellationToken.Register (() => KillProcess (process))) { + if (psi.RedirectStandardOutput) + output = ReadStreamAsync (process.StandardOutput, TextWriter.Synchronized (stdout!)); + + if (psi.RedirectStandardError) + error = ReadStreamAsync (process.StandardError, TextWriter.Synchronized (stderr!)); + + await Task.WhenAll (output, error, exitDone.Task).ConfigureAwait (false); + } + + cancellationToken.ThrowIfCancellationRequested (); + return process.ExitCode; + } + } + + /// + /// Runs an executable and returns its stdout as a string. + /// Throws if the process returns a non-zero exit code. + /// + public static async Task RunAsync (string executable, CancellationToken cancellationToken, params string [] arguments) + { + using (var stdout = new StringWriter ()) + using (var stderr = new StringWriter ()) { + var psi = CreateProcessStartInfo (executable, arguments); + + var exitCode = await StartProcess (psi, stdout, stderr, cancellationToken).ConfigureAwait (false); + + if (exitCode != 0) { + var errorOutput = stderr.ToString ().Trim (); + var stdoutOutput = stdout.ToString ().Trim (); + var message = !string.IsNullOrEmpty (errorOutput) ? errorOutput : stdoutOutput; + if (string.IsNullOrEmpty (message)) + message = $"'{Path.GetFileName (executable)}' returned exit code {exitCode}"; + + throw new InvalidOperationException (message); + } + + return stdout.ToString (); + } + } + + /// + /// Runs an executable and returns its stdout as a string. + /// Throws if the process returns a non-zero exit code. + /// + public static Task RunAsync (string executable, params string [] arguments) + { + return RunAsync (executable, CancellationToken.None, arguments); + } + + /// + /// Runs an executable and returns its stdout as a trimmed string, or null if the process fails. + /// Does not throw on non-zero exit codes. + /// + public static async Task TryRunAsync (string executable, CancellationToken cancellationToken, params string [] arguments) + { + using (var stdout = new StringWriter ()) + using (var stderr = new StringWriter ()) { + var psi = CreateProcessStartInfo (executable, arguments); + + try { + var exitCode = await StartProcess (psi, stdout, stderr, cancellationToken).ConfigureAwait (false); + if (exitCode != 0) + return null; + + return stdout.ToString ().Trim (); + } catch (OperationCanceledException) { + throw; + } catch (System.ComponentModel.Win32Exception) { + return null; + } catch (InvalidOperationException) { + return null; + } + } + } + + /// + /// Runs an executable and returns its stdout as a trimmed string, or null if the process fails. + /// Does not throw on non-zero exit codes. + /// + public static Task TryRunAsync (string executable, params string [] arguments) + { + return TryRunAsync (executable, CancellationToken.None, arguments); + } + + /// + /// Synchronous convenience wrapper. + /// Runs an executable and returns stdout/stderr and the exit code. + /// + public static (int exitCode, string stdout, string stderr) Exec (string executable, params string [] arguments) + { + var psi = CreateProcessStartInfo (executable, arguments); + + using (var stdout = new StringWriter ()) + using (var stderr = new StringWriter ()) { + var exitCode = StartProcess (psi, stdout, stderr).GetAwaiter ().GetResult (); + + return (exitCode, stdout.ToString (), stderr.ToString ()); + } + } + + static ProcessStartInfo CreateProcessStartInfo (string executable, string [] arguments) + { + var psi = new ProcessStartInfo (executable) { + CreateNoWindow = true, + }; + +#if NETSTANDARD2_0 + psi.Arguments = QuoteArguments (arguments); +#else + foreach (var arg in arguments) + psi.ArgumentList.Add (arg); +#endif + + return psi; + } + +#if NETSTANDARD2_0 + static string QuoteArguments (string [] arguments) + { + if (arguments.Length == 0) + return string.Empty; + + var sb = new System.Text.StringBuilder (); + for (int i = 0; i < arguments.Length; i++) { + if (i > 0) + sb.Append (' '); + + var arg = arguments [i]; + if (arg.Length > 0 && arg.IndexOfAny (new [] { ' ', '\t', '"' }) < 0) { + sb.Append (arg); + } else { + sb.Append ('"'); + sb.Append (arg.Replace ("\"", "\\\"")); + sb.Append ('"'); + } + } + return sb.ToString (); + } +#endif + + static void KillProcess (Process p) + { + try { + p.Kill (); + } catch (InvalidOperationException) { + // Process may have already exited + } catch (System.ComponentModel.Win32Exception) { + // Process cannot be terminated (e.g. access denied) + } + } + + static async Task ReadStreamAsync (StreamReader stream, TextWriter destination) + { + int read; + var buffer = new char [4096]; + while ((read = await stream.ReadAsync (buffer, 0, buffer.Length).ConfigureAwait (false)) > 0) + destination.Write (buffer, 0, read); + } + } +} diff --git a/Xamarin.MacDev/XcodeLocator.cs b/Xamarin.MacDev/XcodeLocator.cs index 5cc6026..ec4ab66 100644 --- a/Xamarin.MacDev/XcodeLocator.cs +++ b/Xamarin.MacDev/XcodeLocator.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; @@ -256,14 +255,11 @@ public static bool TryGetSystemXcode (ICustomLogger log, [NotNullWhen (true)] ou } try { - using var process = new Process (); - process.StartInfo.FileName = xcodeSelect; - process.StartInfo.Arguments = "--print-path"; - process.StartInfo.RedirectStandardOutput = true; - process.StartInfo.UseShellExecute = false; - process.Start (); - var stdout = process.StandardOutput.ReadToEnd (); - process.WaitForExit (); + var (exitCode, stdout, _) = ProcessUtils.Exec (xcodeSelect, "--print-path"); + if (exitCode != 0) { + log.LogInfo ("'xcode-select -p' returned exit code {0}.", exitCode); + return false; + } stdout = stdout.Trim (); if (Directory.Exists (stdout)) { diff --git a/azure-pipelines.yaml b/azure-pipelines.yaml index 54e34ec..e811034 100644 --- a/azure-pipelines.yaml +++ b/azure-pipelines.yaml @@ -47,7 +47,7 @@ jobs: displayName: Run Tests (Windows) inputs: command: test - projects: UnitTests/UnitTests.csproj + projects: tests/tests.csproj publishTestResults: false arguments: -c Release --logger "console;verbosity=detailed" --logger trx --results-directory $(Build.ArtifactStagingDirectory)/test-results condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) @@ -64,7 +64,7 @@ jobs: displayName: Run Tests (macOS) inputs: command: test - projects: UnitTests/UnitTests.csproj + projects: tests/tests.csproj publishTestResults: false arguments: -c Release -f net10.0 --logger "console;verbosity=detailed" --logger trx --results-directory $(Build.ArtifactStagingDirectory)/test-results condition: and(succeeded(), eq(variables['Agent.OS'], 'Darwin')) diff --git a/tests/EnvironmentCheckResultTests.cs b/tests/EnvironmentCheckResultTests.cs new file mode 100644 index 0000000..a062467 --- /dev/null +++ b/tests/EnvironmentCheckResultTests.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; + +using NUnit.Framework; + +using Xamarin.MacDev.Models; + +namespace Tests { + + [TestFixture] + public class EnvironmentCheckResultTests { + + [Test] + public void DeriveStatus_Missing_WhenNoXcode () + { + var result = new EnvironmentCheckResult { + Xcode = null, + CommandLineTools = new CommandLineToolsInfo { IsInstalled = true }, + }; + result.DeriveStatus (); + Assert.That (result.Status, Is.EqualTo (EnvironmentStatus.Missing)); + } + + [Test] + public void DeriveStatus_Missing_WhenNoClt () + { + var result = new EnvironmentCheckResult { + Xcode = new XcodeInfo { Path = "/Applications/Xcode.app", Version = new Version (16, 2) }, + CommandLineTools = new CommandLineToolsInfo { IsInstalled = false }, + }; + result.DeriveStatus (); + Assert.That (result.Status, Is.EqualTo (EnvironmentStatus.Missing)); + } + + [Test] + public void DeriveStatus_Partial_WhenNoRuntimes () + { + var result = new EnvironmentCheckResult { + Xcode = new XcodeInfo { Path = "/Applications/Xcode.app", Version = new Version (16, 2) }, + CommandLineTools = new CommandLineToolsInfo { IsInstalled = true }, + Runtimes = new List (), + }; + result.DeriveStatus (); + Assert.That (result.Status, Is.EqualTo (EnvironmentStatus.Partial)); + } + + [Test] + public void DeriveStatus_Ok_WhenEverythingPresent () + { + var result = new EnvironmentCheckResult { + Xcode = new XcodeInfo { Path = "/Applications/Xcode.app", Version = new Version (16, 2) }, + CommandLineTools = new CommandLineToolsInfo { IsInstalled = true }, + Runtimes = new List { + new SimulatorRuntimeInfo { Platform = "iOS", Version = "18.2", IsAvailable = true } + }, + }; + result.DeriveStatus (); + Assert.That (result.Status, Is.EqualTo (EnvironmentStatus.Ok)); + } + + [Test] + public void DefaultStatus_IsMissing () + { + var result = new EnvironmentCheckResult (); + Assert.That (result.Status, Is.EqualTo (EnvironmentStatus.Missing)); + } + + [Test] + public void SimulatorDeviceInfo_IsBooted () + { + var device = new SimulatorDeviceInfo { State = "Booted" }; + Assert.That (device.IsBooted, Is.True); + + device.State = "Shutdown"; + Assert.That (device.IsBooted, Is.False); + } + + [Test] + public void SimulatorRuntimeInfo_ToString () + { + var runtime = new SimulatorRuntimeInfo { + Name = "iOS 18.2", + Identifier = "com.apple.CoreSimulator.SimRuntime.iOS-18-2", + }; + Assert.That (runtime.ToString (), Does.Contain ("iOS 18.2")); + } + + [Test] + public void CommandLineToolsInfo_ToString () + { + var clt = new CommandLineToolsInfo { IsInstalled = true, Version = "16.2.0", Path = "/Library/Developer/CommandLineTools" }; + Assert.That (clt.ToString (), Does.Contain ("16.2.0")); + + var missing = new CommandLineToolsInfo { IsInstalled = false }; + Assert.That (missing.ToString (), Does.Contain ("not installed")); + } + } +} diff --git a/UnitTests/PListObjectTests.cs b/tests/PListObjectTests.cs similarity index 96% rename from UnitTests/PListObjectTests.cs rename to tests/PListObjectTests.cs index 5204a0c..8b81195 100644 --- a/UnitTests/PListObjectTests.cs +++ b/tests/PListObjectTests.cs @@ -31,7 +31,7 @@ using NUnit.Framework; using Xamarin.MacDev; -namespace UnitTests { +namespace Tests { [TestFixture] public class PListObjectTests { static readonly KeyValuePair [] IntegerKeyValuePairs = new KeyValuePair [] { @@ -61,7 +61,7 @@ public void TestIntegerDeserialization (string fileName) { PDictionary plist; - using (var stream = GetType ().Assembly.GetManifestResourceStream ($"UnitTests.TestData.PropertyLists.{fileName}")) + using (var stream = GetType ().Assembly.GetManifestResourceStream ($"tests.TestData.PropertyLists.{fileName}")) plist = (PDictionary) PObject.FromStream (stream); Assert.That (plist.Count, Is.EqualTo (IntegerKeyValuePairs.Length)); @@ -85,7 +85,7 @@ public void TestIntegerXmlSerialization () var output = plist.ToXml (); string expected; - using (var stream = GetType ().Assembly.GetManifestResourceStream ("UnitTests.TestData.PropertyLists.xml-integers.plist")) { + using (var stream = GetType ().Assembly.GetManifestResourceStream ("tests.TestData.PropertyLists.xml-integers.plist")) { var buffer = new byte [stream.Length]; #if NET7_0_OR_GREATER stream.ReadExactly (buffer, 0, buffer.Length); @@ -129,7 +129,7 @@ public void TestStrings () { PDictionary plist; - using (var stream = GetType ().Assembly.GetManifestResourceStream ($"UnitTests.TestData.PropertyLists.strings.plist")) + using (var stream = GetType ().Assembly.GetManifestResourceStream ($"tests.TestData.PropertyLists.strings.plist")) plist = (PDictionary) PObject.FromStream (stream); Assert.That (plist.Count, Is.EqualTo (2)); diff --git a/tests/ProcessUtilsTests.cs b/tests/ProcessUtilsTests.cs new file mode 100644 index 0000000..f7c9457 --- /dev/null +++ b/tests/ProcessUtilsTests.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; + +using Xamarin.MacDev; + +namespace Tests { + + [TestFixture] + public class ProcessUtilsTests { + + static bool IsWindows => RuntimeInformation.IsOSPlatform (OSPlatform.Windows); + static string ShellExe => IsWindows ? "cmd.exe" : "/bin/sh"; + + static string [] EchoArgs (string text) + { + return IsWindows + ? new [] { "/c", $"echo {text}" } + : new [] { "-c", $"echo {text}" }; + } + + static string [] ExitArgs (int code) + { + return IsWindows + ? new [] { "/c", $"exit {code}" } + : new [] { "-c", $"exit {code}" }; + } + + [Test] + public async Task RunAsync_ReturnsStdout () + { + var result = await ProcessUtils.RunAsync (ShellExe, EchoArgs ("hello world")); + Assert.That (result.Trim (), Is.EqualTo ("hello world")); + } + + [Test] + public void RunAsync_ThrowsOnNonZeroExitCode () + { + Assert.ThrowsAsync (async () => { + await ProcessUtils.RunAsync (ShellExe, ExitArgs (42)); + }); + } + + [Test] + public async Task TryRunAsync_ReturnsStdoutOnSuccess () + { + var result = await ProcessUtils.TryRunAsync (ShellExe, EchoArgs ("hello")); + Assert.That (result?.Trim (), Is.EqualTo ("hello")); + } + + [Test] + public async Task TryRunAsync_ReturnsNullOnFailure () + { + var result = await ProcessUtils.TryRunAsync (ShellExe, ExitArgs (1)); + Assert.That (result, Is.Null); + } + + [Test] + public async Task TryRunAsync_ReturnsNullForMissingExecutable () + { + var missingPath = IsWindows ? @"C:\nonexistent\binary.exe" : "/nonexistent/binary"; + var result = await ProcessUtils.TryRunAsync (missingPath); + Assert.That (result, Is.Null); + } + + [Test] + public async Task StartProcess_CapturesStdoutAndStderr () + { + var shellArgs = IsWindows ? "/c echo out& echo err >&2" : "-c \"echo out; echo err >&2\""; + using (var stdout = new StringWriter ()) + using (var stderr = new StringWriter ()) { + var psi = new System.Diagnostics.ProcessStartInfo (ShellExe, shellArgs) { + CreateNoWindow = true, + }; + + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr); + Assert.That (exitCode, Is.EqualTo (0)); + Assert.That (stdout.ToString ().Trim (), Is.EqualTo ("out")); + Assert.That (stderr.ToString ().Trim (), Is.EqualTo ("err")); + } + } + + [Test] + public void StartProcess_RespectsCancellation () + { + using (var cts = new CancellationTokenSource ()) { + cts.Cancel (); + + var sleepExe = IsWindows ? "timeout" : "/bin/sleep"; + Assert.ThrowsAsync (async () => { + await ProcessUtils.RunAsync (sleepExe, cts.Token, IsWindows ? "/t" : "60", IsWindows ? "60" : ""); + }); + } + } + + [Test] + public void Exec_ReturnsExitCodeAndOutput () + { + var (exitCode, stdout, stderr) = ProcessUtils.Exec (ShellExe, EchoArgs ("sync test")); + Assert.That (exitCode, Is.EqualTo (0)); + Assert.That (stdout.Trim (), Is.EqualTo ("sync test")); + Assert.That (stderr, Is.Empty); + } + + [Test] + public void Exec_ReturnsNonZeroExitCode () + { + var (exitCode, _, _) = ProcessUtils.Exec (ShellExe, ExitArgs (7)); + Assert.That (exitCode, Is.EqualTo (7)); + } + } +} diff --git a/UnitTests/TestData/PropertyLists/binary-integers.plist b/tests/TestData/PropertyLists/binary-integers.plist similarity index 100% rename from UnitTests/TestData/PropertyLists/binary-integers.plist rename to tests/TestData/PropertyLists/binary-integers.plist diff --git a/UnitTests/TestData/PropertyLists/strings.plist b/tests/TestData/PropertyLists/strings.plist similarity index 100% rename from UnitTests/TestData/PropertyLists/strings.plist rename to tests/TestData/PropertyLists/strings.plist diff --git a/UnitTests/TestData/PropertyLists/xml-integers.plist b/tests/TestData/PropertyLists/xml-integers.plist similarity index 100% rename from UnitTests/TestData/PropertyLists/xml-integers.plist rename to tests/TestData/PropertyLists/xml-integers.plist diff --git a/UnitTests/TestData/Provisioning Profiles/29cbf4b4-a170-4c74-a29a-64ecd55b102e.mobileprovision b/tests/TestData/Provisioning Profiles/29cbf4b4-a170-4c74-a29a-64ecd55b102e.mobileprovision similarity index 100% rename from UnitTests/TestData/Provisioning Profiles/29cbf4b4-a170-4c74-a29a-64ecd55b102e.mobileprovision rename to tests/TestData/Provisioning Profiles/29cbf4b4-a170-4c74-a29a-64ecd55b102e.mobileprovision diff --git a/UnitTests/TestData/Provisioning Profiles/7079f389-6ff4-4290-bf76-c8a222947616.mobileprovision b/tests/TestData/Provisioning Profiles/7079f389-6ff4-4290-bf76-c8a222947616.mobileprovision similarity index 100% rename from UnitTests/TestData/Provisioning Profiles/7079f389-6ff4-4290-bf76-c8a222947616.mobileprovision rename to tests/TestData/Provisioning Profiles/7079f389-6ff4-4290-bf76-c8a222947616.mobileprovision diff --git a/UnitTests/TestHelper.cs b/tests/TestHelper.cs similarity index 88% rename from UnitTests/TestHelper.cs rename to tests/TestHelper.cs index 9af1a70..e3509ab 100644 --- a/UnitTests/TestHelper.cs +++ b/tests/TestHelper.cs @@ -28,7 +28,7 @@ using NUnit.Framework; -namespace UnitTests { +namespace Tests { [SetUpFixture] static class TestHelper { public static readonly string ProjectDir; @@ -52,8 +52,8 @@ static TestHelper () var dir = Path.GetDirectoryName (codeBase); - while (!string.Equals (Path.GetFileName (dir), "UnitTests", StringComparison.Ordinal)) { - var candidate = Path.Combine (dir, "UnitTests"); + while (!string.Equals (Path.GetFileName (dir), "tests", StringComparison.Ordinal)) { + var candidate = Path.Combine (dir, "tests"); if (Directory.Exists (candidate)) { dir = candidate; break; @@ -61,7 +61,7 @@ static TestHelper () var parent = Path.GetDirectoryName (dir); if (string.IsNullOrEmpty (parent)) - throw new DirectoryNotFoundException ($"Unable to locate UnitTests directory from '{codeBase}'."); + throw new DirectoryNotFoundException ($"Unable to locate tests directory from '{codeBase}'."); dir = parent; } diff --git a/UnitTests/TestMobileProvisionIndex.cs b/tests/TestMobileProvisionIndex.cs similarity index 99% rename from UnitTests/TestMobileProvisionIndex.cs rename to tests/TestMobileProvisionIndex.cs index 20f74ad..52b0047 100644 --- a/UnitTests/TestMobileProvisionIndex.cs +++ b/tests/TestMobileProvisionIndex.cs @@ -30,7 +30,7 @@ using Xamarin.MacDev; -namespace UnitTests { +namespace Tests { [TestFixture] public class TestMobileProvisionIndex { static readonly string [] ProfileDirectories; diff --git a/tests/XcodeInfoTests.cs b/tests/XcodeInfoTests.cs new file mode 100644 index 0000000..c6d2fc0 --- /dev/null +++ b/tests/XcodeInfoTests.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; + +using NUnit.Framework; + +using Xamarin.MacDev.Models; + +namespace Tests { + + [TestFixture] + public class XcodeInfoTests { + + [Test] + public void DefaultValues () + { + var info = new XcodeInfo (); + Assert.That (info.Path, Is.EqualTo ("")); + Assert.That (info.Version, Is.EqualTo (new Version (0, 0))); + Assert.That (info.Build, Is.EqualTo ("")); + Assert.That (info.DTXcode, Is.EqualTo ("")); + Assert.That (info.IsSelected, Is.False); + Assert.That (info.IsSymlink, Is.False); + } + + [Test] + public void ToString_IncludesPathAndVersion () + { + var info = new XcodeInfo { + Path = "/Applications/Xcode.app", + Version = new Version (16, 2), + Build = "16C5032a", + }; + Assert.That (info.ToString (), Does.Contain ("/Applications/Xcode.app")); + Assert.That (info.ToString (), Does.Contain ("16.2")); + Assert.That (info.ToString (), Does.Contain ("16C5032a")); + } + } +} diff --git a/UnitTests/UnitTests.csproj b/tests/tests.csproj similarity index 100% rename from UnitTests/UnitTests.csproj rename to tests/tests.csproj