From fa4c1d37df3f4c652776f190270e68193e198c5d Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Mon, 27 May 2024 22:01:48 -0400 Subject: [PATCH] Close #118: Support relative paths --- MedallionShell.Tests/GeneralTest.cs | 41 +++++++++++++++++++++++++++++ MedallionShell.sln | 11 +++++--- MedallionShell/Shell.cs | 30 ++++++++++++++++++++- 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/MedallionShell.Tests/GeneralTest.cs b/MedallionShell.Tests/GeneralTest.cs index 4ed5af6..7601fba 100644 --- a/MedallionShell.Tests/GeneralTest.cs +++ b/MedallionShell.Tests/GeneralTest.cs @@ -589,6 +589,47 @@ public void TestProcessKeepsWritingAfterOutputIsClosed() command.Result.Success.ShouldEqual(true); } + [Platform("Win", Reason = "Tests Windows-specific paths")] + [TestCase(@"C:\Program Files\dotnet\dotnet.exe", @".\dotnet.exe", @"C:\Program Files\dotnet")] + [TestCase(@"C:\Program Files\dotnet\dotnet.exe", @".\dotnet.exe", @"C:\Program Files\dotnet\")] + [TestCase(@"C:\Program Files\dotnet\dotnet.exe", "dotnet.exe", @"C:\Program Files\dotnet")] + [TestCase(@"C:\Program Files\dotnet\dotnet.exe", "dotnet.exe", @"C:\Program Files\dotnet\")] + [TestCase(@"C:\Program Files\dotnet\dotnet.exe", @"dotnet\dotnet.exe", @"C:\Program Files")] + [TestCase(@"C:\Program Files\dotnet\dotnet.exe", @"Program Files\dotnet\dotnet.exe", @"C:\")] + [TestCase(@"C:\Program Files\nodejs\npm.cmd", @".\npm.cmd", @"C:\Program Files\nodejs")] + public void TestRelativePathsOnWindows(string fullyQualifiedExecutablePath, string relativeExecutablePath, string workingDirectory) => + TestRelativePaths(fullyQualifiedExecutablePath, relativeExecutablePath, workingDirectory); + +#if !NETFRAMEWORK + [Platform("Unix", Reason = "Tests Unix-specific paths")] + [TestCase("/usr/bin/dotnet", "./dotnet", "/usr/bin")] + [TestCase("/usr/bin/dotnet", "./dotnet", "/usr/bin/")] + [TestCase("/usr/bin/dotnet", "dotnet", "/usr/bin")] + [TestCase("/usr/bin/dotnet", "dotnet", "/usr/bin/")] + [TestCase("/usr/bin/dotnet", "bin/dotnet", "/usr")] + [TestCase("/usr/bin/dotnet", "user/bin/dotnet", "/")] + public void TestRelativePathsOnUnix(string fullyQualifiedExecutablePath, string relativeExecutablePath, string workingDirectory) => + TestRelativePaths(fullyQualifiedExecutablePath, relativeExecutablePath, workingDirectory); +#endif + + private static void TestRelativePaths(string fullyQualifiedExecutablePath, string relativeExecutablePath, string workingDirectory) + { + var expectedVersion = TestShell.Run(fullyQualifiedExecutablePath, ["--version"]) + .StandardOutput + .ReadToEnd(); + + Assert.That(expectedVersion, Does.Match(@"\d.\d.\d")); + + Assert.That( + TestShell.Run( + relativeExecutablePath, + ["--version"], + o => o.WorkingDirectory(workingDirectory)) + .StandardOutput + .ReadToEnd(), + Is.EqualTo(expectedVersion)); + } + [Test] [Obsolete("Tests obsolete code")] public async Task TestCustomCommandLineSyntaxIsUsed() diff --git a/MedallionShell.sln b/MedallionShell.sln index 8e39a4c..1ea97ad 100644 --- a/MedallionShell.sln +++ b/MedallionShell.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26430.6 +# Visual Studio Version 17 +VisualStudioVersion = 17.9.34902.65 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MedallionShell", "MedallionShell\MedallionShell.csproj", "{15AF2EC0-F7B2-4206-B92A-DD1F3DC25F30}" EndProject @@ -9,7 +9,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MedallionShell.Tests", "Med EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SampleCommand", "SampleCommand\SampleCommand.csproj", "{1F78EB31-414F-4C1E-893B-C281F6B4FFC0}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MedallionShell.ProcessSignaler", "MedallionShell.ProcessSignaler\MedallionShell.ProcessSignaler.csproj", "{63CEF80E-6E45-4CB2-9830-10ADDF44757A}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MedallionShell.ProcessSignaler", "MedallionShell.ProcessSignaler\MedallionShell.ProcessSignaler.csproj", "{63CEF80E-6E45-4CB2-9830-10ADDF44757A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{0F0605C3-2439-444D-9033-40A95CC95922}" + ProjectSection(SolutionItems) = preProject + appveyor.yml = appveyor.yml + EndProjectSection EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/MedallionShell/Shell.cs b/MedallionShell/Shell.cs index 95f80e1..d3f544e 100644 --- a/MedallionShell/Shell.cs +++ b/MedallionShell/Shell.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.IO; using System.Linq; using System.Text; using System.Threading; @@ -38,10 +39,26 @@ public Command Run(string executable, IEnumerable? arguments = null, Act var finalOptions = this.GetOptions(options); + var fullyQualifiedExecutablePath = +#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1 + Path.IsPathFullyQualified(executable) +#else + IsPathFullyQualified(executable) +#endif + ? executable + : Path.Combine( + finalOptions.WorkingDirectoryPath +#if NETSTANDARD1_3 + ?? Directory.GetCurrentDirectory(), +#else + ?? Environment.CurrentDirectory, +#endif + executable); + var processStartInfo = new ProcessStartInfo { CreateNoWindow = true, - FileName = executable, + FileName = fullyQualifiedExecutablePath, RedirectStandardError = true, RedirectStandardInput = true, RedirectStandardOutput = true, @@ -204,6 +221,7 @@ internal Options() internal TimeSpan ProcessTimeout { get; private set; } internal Encoding? ProcessStreamEncoding { get; private set; } internal CancellationToken ProcessCancellationToken { get; private set; } + internal string? WorkingDirectoryPath { get; private set; } #region ---- Builder methods ---- /// @@ -267,6 +285,7 @@ public Options Command(Func initializer) /// . public Options WorkingDirectory(string path) { + this.WorkingDirectoryPath = path; return this.StartInfo(psi => psi.WorkingDirectory = path); } @@ -378,5 +397,14 @@ private static bool IsIgnorableAttachingException(Exception exception) return exception is ArgumentException // process has already exited or ID is invalid || exception is InvalidOperationException; // process exited after its creation but before taking its handle } + +#if !NETCOREAPP2_1_OR_GREATER && !NETSTANDARD2_1 + // Path.IsPathFullyQualified is defined only on .NET Core 2.1+, so use the code from https://stackoverflow.com/a/49883481 + private static bool IsPathFullyQualified(string path) + { + var root = Path.GetPathRoot(path); + return root.StartsWith(@"\\") || (root.EndsWith(@"\") && root != @"\"); + } +#endif } }