From 413ea79e909f3cf5821e5a6453b2b15c72b349bc Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 29 Apr 2026 14:07:06 -0700 Subject: [PATCH] Add CLI tests for GVFS, GVFS.Mount, and FastFetch verbs Add GVFS.CommandLine.Tests project with unit tests for command-line argument parsing across all three executables. Tests validate verb dispatch, argument handling, and error output without requiring a mounted GVFS enlistment. Separate test project (not merged into GVFS.UnitTests) because it references all three Exe projects (GVFS, GVFS.Mount, FastFetch). Wire into CI via RunUnitTests.bat so CLI tests run alongside existing unit tests on every PR build. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS.sln | 6 + GVFS/FastFetch/FastFetchVerb.cs | 2 +- GVFS/FastFetch/InternalsVisibleTo.cs | 3 + GVFS/FastFetch/Program.cs | 7 +- .../FastFetchCliTests.cs | 237 ++++++++++ .../GVFS.CommandLine.Tests.csproj | 21 + .../GvfsMainCliTests.cs | 427 ++++++++++++++++++ .../GvfsMountCliTests.cs | 235 ++++++++++ GVFS/GVFS.CommandLine.Tests/Program.cs | 9 + GVFS/GVFS.Mount/InternalsVisibleTo.cs | 3 + GVFS/GVFS.Mount/Program.cs | 7 +- GVFS/GVFS/InternalsVisibleTo.cs | 3 +- scripts/RunUnitTests.bat | 1 + 13 files changed, 957 insertions(+), 4 deletions(-) create mode 100644 GVFS/FastFetch/InternalsVisibleTo.cs create mode 100644 GVFS/GVFS.CommandLine.Tests/FastFetchCliTests.cs create mode 100644 GVFS/GVFS.CommandLine.Tests/GVFS.CommandLine.Tests.csproj create mode 100644 GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs create mode 100644 GVFS/GVFS.CommandLine.Tests/GvfsMountCliTests.cs create mode 100644 GVFS/GVFS.CommandLine.Tests/Program.cs create mode 100644 GVFS/GVFS.Mount/InternalsVisibleTo.cs diff --git a/GVFS.sln b/GVFS.sln index a3dc2e111..0bc5735c3 100644 --- a/GVFS.sln +++ b/GVFS.sln @@ -45,6 +45,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GVFS.Payload", "GVFS\GVFS.P EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GVFS.Installers", "GVFS\GVFS.Installers\GVFS.Installers.csproj", "{258FEAC0-5E2D-408A-9652-9E9653219F3B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GVFS.CommandLine.Tests", "GVFS\GVFS.CommandLine.Tests\GVFS.CommandLine.Tests.csproj", "{4D201963-957A-436A-8E43-79A63FB84B94}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 @@ -135,6 +137,10 @@ Global {258FEAC0-5E2D-408A-9652-9E9653219F3B}.Debug|x64.Build.0 = Debug|Any CPU {258FEAC0-5E2D-408A-9652-9E9653219F3B}.Release|x64.ActiveCfg = Release|Any CPU {258FEAC0-5E2D-408A-9652-9E9653219F3B}.Release|x64.Build.0 = Release|Any CPU + {4D201963-957A-436A-8E43-79A63FB84B94}.Debug|x64.ActiveCfg = Debug|Any CPU + {4D201963-957A-436A-8E43-79A63FB84B94}.Debug|x64.Build.0 = Debug|Any CPU + {4D201963-957A-436A-8E43-79A63FB84B94}.Release|x64.ActiveCfg = Release|Any CPU + {4D201963-957A-436A-8E43-79A63FB84B94}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/GVFS/FastFetch/FastFetchVerb.cs b/GVFS/FastFetch/FastFetchVerb.cs index e4b14b485..27c5b1c4f 100644 --- a/GVFS/FastFetch/FastFetchVerb.cs +++ b/GVFS/FastFetch/FastFetchVerb.cs @@ -122,7 +122,7 @@ public static RootCommand BuildRootCommand() }; rootCommand.Add(foldersListOption); - Option allowIndexMetadataOption = new Option("--Allow-index-metadata-update-from-working-tree") { Description = "When specified, index metadata is updated from disk if not already in the index." }; + Option allowIndexMetadataOption = new Option("--allow-index-metadata-update-from-working-tree") { Description = "When specified, index metadata is updated from disk if not already in the index." }; rootCommand.Add(allowIndexMetadataOption); Option verboseOption = new Option("--verbose") { Description = "Show all outputs on the console in addition to writing them to a log file" }; diff --git a/GVFS/FastFetch/InternalsVisibleTo.cs b/GVFS/FastFetch/InternalsVisibleTo.cs new file mode 100644 index 000000000..200018c1f --- /dev/null +++ b/GVFS/FastFetch/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("GVFS.CommandLine.Tests")] diff --git a/GVFS/FastFetch/Program.cs b/GVFS/FastFetch/Program.cs index 11417b324..b82ad97a4 100644 --- a/GVFS/FastFetch/Program.cs +++ b/GVFS/FastFetch/Program.cs @@ -1,6 +1,9 @@ using System.CommandLine; +using System.Runtime.CompilerServices; using GVFS.PlatformLoader; +[assembly: InternalsVisibleTo("GVFS.CommandLine.Tests")] + namespace FastFetch { public class Program @@ -8,8 +11,10 @@ public class Program public static void Main(string[] args) { GVFSPlatformLoader.Initialize(); - RootCommand rootCommand = FastFetchVerb.BuildRootCommand(); + RootCommand rootCommand = BuildRootCommand(); rootCommand.Parse(args).Invoke(); } + + internal static RootCommand BuildRootCommand() => FastFetchVerb.BuildRootCommand(); } } diff --git a/GVFS/GVFS.CommandLine.Tests/FastFetchCliTests.cs b/GVFS/GVFS.CommandLine.Tests/FastFetchCliTests.cs new file mode 100644 index 000000000..67a811f22 --- /dev/null +++ b/GVFS/GVFS.CommandLine.Tests/FastFetchCliTests.cs @@ -0,0 +1,237 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Linq; +using NUnit.Framework; + +namespace GVFS.CommandLine.Tests +{ + /// + /// Tests that FastFetch CLI parsing matches the original CommandLineParser behavior. + /// Verifies short aliases, defaults, and option names are backward-compatible. + /// + [TestFixture] + public class FastFetchCliTests + { + private RootCommand rootCommand; + + [SetUp] + public void SetUp() + { + rootCommand = FastFetch.Program.BuildRootCommand(); + } + + #region Short Aliases + + [Test] + public void CommitOption_HasShortAlias_C() + { + var opt = FindOption("--commit"); + Assert.That(opt, Is.Not.Null, "Expected --commit option to exist"); + Assert.That(opt.Aliases, Does.Contain("-c"), "Expected -c short alias for --commit"); + } + + [Test] + public void BranchOption_HasShortAlias_B() + { + var opt = FindOption("--branch"); + Assert.That(opt, Is.Not.Null, "Expected --branch option to exist"); + Assert.That(opt.Aliases, Does.Contain("-b"), "Expected -b short alias for --branch"); + } + + [Test] + public void MaxRetriesOption_HasShortAlias_R() + { + var opt = FindOption("--max-retries"); + Assert.That(opt, Is.Not.Null, "Expected --max-retries option to exist"); + Assert.That(opt.Aliases, Does.Contain("-r"), "Expected -r short alias for --max-retries"); + } + + [TestCase("-c", "abc123")] + [TestCase("-b", "main")] + [TestCase("-r", "5")] + public void ShortAliases_ParseCorrectly(string alias, string value) + { + var parseResult = rootCommand.Parse(new[] { alias, value }); + Assert.That(parseResult.Errors, Is.Empty, $"Parsing '{alias} {value}' should produce no errors"); + } + + #endregion + + #region Default Values + + [Test] + public void ChunkSize_DefaultsTo4000() + { + var parseResult = rootCommand.Parse(System.Array.Empty()); + var opt = FindOption("--chunk-size"); + Assert.That(opt, Is.Not.Null); + Assert.That(parseResult.GetValue(opt), Is.EqualTo(4000), + "ChunkSize should default to 4000 when not specified"); + } + + [Test] + public void MaxRetries_DefaultsTo10() + { + var parseResult = rootCommand.Parse(System.Array.Empty()); + var opt = FindOption("--max-retries"); + Assert.That(opt, Is.Not.Null); + Assert.That(parseResult.GetValue(opt), Is.EqualTo(10), + "MaxRetries should default to 10 when not specified"); + } + + [Test] + public void Folders_DefaultsToEmptyString() + { + var parseResult = rootCommand.Parse(System.Array.Empty()); + var opt = FindOption("--folders"); + Assert.That(opt, Is.Not.Null); + Assert.That(parseResult.GetValue(opt), Is.EqualTo(""), + "Folders should default to empty string when not specified"); + } + + [Test] + public void FoldersList_DefaultsToEmptyString() + { + var parseResult = rootCommand.Parse(System.Array.Empty()); + var opt = FindOption("--folders-list"); + Assert.That(opt, Is.Not.Null); + Assert.That(parseResult.GetValue(opt), Is.EqualTo(""), + "FoldersList should default to empty string when not specified"); + } + + [Test] + public void BooleanOptions_DefaultToFalse() + { + var parseResult = rootCommand.Parse(System.Array.Empty()); + + var checkout = FindOption("--checkout"); + var forceCheckout = FindOption("--force-checkout"); + var verbose = FindOption("--verbose"); + var allowIndexMetadata = FindOption("--allow-index-metadata-update-from-working-tree"); + + Assert.Multiple(() => + { + Assert.That(parseResult.GetValue(checkout), Is.False, "--checkout should default to false"); + Assert.That(parseResult.GetValue(forceCheckout), Is.False, "--force-checkout should default to false"); + Assert.That(parseResult.GetValue(verbose), Is.False, "--verbose should default to false"); + Assert.That(parseResult.GetValue(allowIndexMetadata), Is.False, "--allow-index-metadata-update-from-working-tree should default to false"); + }); + } + + [Test] + public void IntThreadOptions_DefaultToZero() + { + var parseResult = rootCommand.Parse(System.Array.Empty()); + + var search = FindOption("--search-thread-count"); + var download = FindOption("--download-thread-count"); + var index = FindOption("--index-thread-count"); + var checkoutThread = FindOption("--checkout-thread-count"); + + Assert.Multiple(() => + { + Assert.That(parseResult.GetValue(search), Is.EqualTo(0)); + Assert.That(parseResult.GetValue(download), Is.EqualTo(0)); + Assert.That(parseResult.GetValue(index), Is.EqualTo(0)); + Assert.That(parseResult.GetValue(checkoutThread), Is.EqualTo(0)); + }); + } + + #endregion + + #region Explicit Value Parsing + + [Test] + public void ChunkSize_ExplicitValue_Overrides() + { + var parseResult = rootCommand.Parse(new[] { "--chunk-size", "8000" }); + var opt = FindOption("--chunk-size"); + Assert.That(parseResult.GetValue(opt), Is.EqualTo(8000)); + } + + [Test] + public void MaxRetries_ExplicitValue_Overrides() + { + var parseResult = rootCommand.Parse(new[] { "--max-retries", "3" }); + var opt = FindOption("--max-retries"); + Assert.That(parseResult.GetValue(opt), Is.EqualTo(3)); + } + + [Test] + public void CommitAndBranch_ParseWithShortAliases() + { + var parseResult = rootCommand.Parse(new[] { "-c", "abc123", "-b", "feature/test" }); + var commitOpt = FindOption("--commit"); + var branchOpt = FindOption("--branch"); + Assert.Multiple(() => + { + Assert.That(parseResult.GetValue(commitOpt), Is.EqualTo("abc123")); + Assert.That(parseResult.GetValue(branchOpt), Is.EqualTo("feature/test")); + }); + } + + [Test] + public void AllStringOptions_ParseCorrectly() + { + var parseResult = rootCommand.Parse(new[] + { + "--commit", "abc123", + "--branch", "main", + "--cache-server-url", "https://cache.example.com", + "--git-path", @"C:\Program Files\Git\bin\git.exe", + "--folders", "src;lib", + "--folders-list", @"C:\folders.txt", + "--parent-activity-id", "12345678-1234-1234-1234-123456789012" + }); + + Assert.That(parseResult.Errors, Is.Empty, "All string options should parse without errors"); + } + + [Test] + public void MaxRetries_ShortAlias_R_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "-r", "5" }); + var opt = FindOption("--max-retries"); + Assert.That(parseResult.GetValue(opt), Is.EqualTo(5)); + } + + #endregion + + #region All Expected Options Exist + + [Test] + public void AllExpectedOptions_Exist() + { + var expectedOptions = new[] + { + "--commit", "--branch", "--cache-server-url", "--chunk-size", + "--checkout", "--force-checkout", "--search-thread-count", + "--download-thread-count", "--index-thread-count", "--checkout-thread-count", + "--max-retries", "--git-path", "--folders", "--folders-list", + "--allow-index-metadata-update-from-working-tree", "--verbose", + "--parent-activity-id" + }; + + foreach (var optName in expectedOptions) + { + Assert.That(FindOption(optName), Is.Not.Null, $"Expected option {optName} to exist"); + } + } + + #endregion + + #region Helpers + + private Option FindOption(string name) + { + return rootCommand.Options.FirstOrDefault(o => o.Name == name || o.Aliases.Contains(name)); + } + + private Option FindOption(string name) + { + return rootCommand.Options.FirstOrDefault(o => o.Name == name || o.Aliases.Contains(name)) as Option; + } + + #endregion + } +} diff --git a/GVFS/GVFS.CommandLine.Tests/GVFS.CommandLine.Tests.csproj b/GVFS/GVFS.CommandLine.Tests/GVFS.CommandLine.Tests.csproj new file mode 100644 index 000000000..21929b438 --- /dev/null +++ b/GVFS/GVFS.CommandLine.Tests/GVFS.CommandLine.Tests.csproj @@ -0,0 +1,21 @@ + + + + net471 + true + Exe + + + + + + + + + + + + + + + diff --git a/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs b/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs new file mode 100644 index 000000000..4eb360808 --- /dev/null +++ b/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs @@ -0,0 +1,427 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Linq; +using NUnit.Framework; + +namespace GVFS.CommandLine.Tests +{ + /// + /// Tests that GVFS main CLI parsing matches the original CommandLineParser behavior. + /// Verifies all verb subcommands, short aliases, and option compatibility. + /// + /// + /// System.CommandLine 2.0.3 note: Option.Name holds the primary name (e.g. "--list"), + /// while Option.Aliases only contains SHORT aliases added via Aliases.Add() (e.g. "-l"). + /// All lookups must check both Name and Aliases to find an option by any of its names. + /// + [TestFixture] + public class GvfsMainCliTests + { + private RootCommand rootCommand; + + [SetUp] + public void SetUp() + { + rootCommand = GVFS.Program.BuildRootCommand(); + } + + #region All Subcommands Exist + + [TestCase("cache-server")] + [TestCase("clone")] + [TestCase("config")] + [TestCase("dehydrate")] + [TestCase("diagnose")] + [TestCase("health")] + [TestCase("log")] + [TestCase("mount")] + [TestCase("prefetch")] + [TestCase("repair")] + [TestCase("service")] + [TestCase("sparse")] + [TestCase("status")] + [TestCase("unmount")] + [TestCase("upgrade")] + [TestCase("version")] + public void Subcommand_Exists(string name) + { + var cmd = rootCommand.Subcommands.FirstOrDefault(c => c.Name == name); + Assert.That(cmd, Is.Not.Null, $"Expected subcommand '{name}' to exist"); + } + + #endregion + + #region Clone Short Aliases + + [Test] + public void Clone_BranchOption_HasShortAlias_B() + { + var opt = FindOptionOnCommand("clone", "--branch"); + Assert.That(opt, Is.Not.Null, "Expected --branch option on clone"); + Assert.That(opt.Aliases, Does.Contain("-b"), "Expected -b short alias for clone --branch"); + } + + [Test] + public void Clone_ParsesWithShortAlias() + { + var parseResult = rootCommand.Parse(new[] { "clone", "https://example.com/repo", "-b", "main" }); + Assert.That(parseResult.Errors, Is.Empty, "clone with -b should parse without errors"); + } + + #endregion + + #region Config Short Aliases + + [Test] + public void Config_ListOption_HasShortAlias_L() + { + var opt = FindOptionOnCommand("config", "--list"); + Assert.That(opt, Is.Not.Null, "Expected --list option on config"); + Assert.That(opt.Aliases, Does.Contain("-l"), "Expected -l short alias for config --list"); + } + + [Test] + public void Config_DeleteOption_HasShortAlias_D() + { + var opt = FindOptionOnCommand("config", "--delete"); + Assert.That(opt, Is.Not.Null, "Expected --delete option on config"); + Assert.That(opt.Aliases, Does.Contain("-d"), "Expected -d short alias for config --delete"); + } + + #endregion + + #region Health Short Aliases + + [Test] + public void Health_DisplayCountOption_HasName_N() + { + var opt = FindOptionOnCommand("health", "-n"); + Assert.That(opt, Is.Not.Null, "Expected -n option on health command"); + } + + [Test] + public void Health_DirectoryOption_HasShortAlias_D() + { + var opt = FindOptionOnCommand("health", "--directory"); + Assert.That(opt, Is.Not.Null, "Expected --directory option on health"); + Assert.That(opt.Aliases, Does.Contain("-d"), "Expected -d short alias for health --directory"); + } + + [Test] + public void Health_StatusOption_HasShortAlias_S() + { + var opt = FindOptionOnCommand("health", "--status"); + Assert.That(opt, Is.Not.Null, "Expected --status option on health"); + Assert.That(opt.Aliases, Does.Contain("-s"), "Expected -s short alias for health --status"); + } + + #endregion + + #region Mount Short Aliases + + [Test] + public void Mount_VerbosityOption_HasShortAlias_V() + { + var opt = FindOptionOnCommand("mount", "--verbosity"); + Assert.That(opt, Is.Not.Null, "Expected --verbosity option on mount"); + Assert.That(opt.Aliases, Does.Contain("-v"), "Expected -v short alias for mount --verbosity"); + } + + [Test] + public void Mount_KeywordsOption_HasShortAlias_K() + { + var opt = FindOptionOnCommand("mount", "--keywords"); + Assert.That(opt, Is.Not.Null, "Expected --keywords option on mount"); + Assert.That(opt.Aliases, Does.Contain("-k"), "Expected -k short alias for mount --keywords"); + } + + #endregion + + #region Prefetch Short Aliases + + [Test] + public void Prefetch_CommitsOption_HasShortAlias_C() + { + var opt = FindOptionOnCommand("prefetch", "--commits"); + Assert.That(opt, Is.Not.Null, "Expected --commits option on prefetch"); + Assert.That(opt.Aliases, Does.Contain("-c"), "Expected -c short alias for prefetch --commits"); + } + + #endregion + + #region Sparse Short Aliases (7 aliases) + + [TestCase("--set", "-s")] + [TestCase("--file", "-f")] + [TestCase("--add", "-a")] + [TestCase("--remove", "-r")] + [TestCase("--list", "-l")] + [TestCase("--prune", "-p")] + [TestCase("--disable", "-d")] + public void Sparse_Option_HasShortAlias(string longName, string shortAlias) + { + var opt = FindOptionOnCommand("sparse", longName); + Assert.That(opt, Is.Not.Null, $"Expected {longName} option on sparse"); + Assert.That(opt.Aliases, Does.Contain(shortAlias), + $"Expected {shortAlias} short alias for sparse {longName}"); + } + + #endregion + + #region String Defaults (null-coalesce guards) + + [Test] + public void Dehydrate_Folders_DefaultsToNullOrEmpty() + { + // Original had Default = "". Now we guard with ?? "" in the action. + // From parse result, the default for unset string is null. + // The null-coalesce guard ensures the verb receives "" not null. + var opt = FindOptionOnCommand("dehydrate", "--folders"); + Assert.That(opt, Is.Not.Null, "Expected --folders option on dehydrate"); + } + + [Test] + public void Prefetch_StringOptions_Exist() + { + var expectedOptions = new[] { "--files", "--folders", "--folders-list", "--files-list" }; + + foreach (var optName in expectedOptions) + { + var opt = FindOptionOnCommand("prefetch", optName); + Assert.That(opt, Is.Not.Null, $"Expected {optName} option on prefetch"); + } + } + + [Test] + public void Sparse_StringOptions_Exist() + { + var expectedOptions = new[] { "--set", "--file", "--add", "--remove" }; + + foreach (var optName in expectedOptions) + { + var opt = FindOptionOnCommand("sparse", optName); + Assert.That(opt, Is.Not.Null, $"Expected {optName} option on sparse"); + } + } + + #endregion + + #region Full Command Parsing + + [Test] + public void Clone_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] + { + "clone", "https://example.com/repo", @"C:\Users\test\repo", + "--cache-server-url", "https://cache.test", + "-b", "develop", + "--single-branch", + "--no-mount", + "--no-prefetch" + }); + Assert.That(parseResult.Errors, Is.Empty, "Full clone command should parse without errors"); + } + + [Test] + public void Mount_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] + { + "mount", @"C:\Users\test\repo", + "-v", "Warning", + "-k", "Network" + }); + Assert.That(parseResult.Errors, Is.Empty, "Full mount command should parse without errors"); + } + + [Test] + public void Prefetch_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] + { + "prefetch", + "--folders", "src;lib", + "--files", "*.cs;*.h", + "-c", + "--verbose" + }); + Assert.That(parseResult.Errors, Is.Empty, "Full prefetch command should parse without errors"); + } + + [Test] + public void Sparse_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] + { + "sparse", + "-s", "src;lib;tests", + "-l" + }); + Assert.That(parseResult.Errors, Is.Empty, "Full sparse command should parse without errors"); + } + + [Test] + public void Health_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] + { + "health", + "-n", "20", + "-d", @"src\components", + "-s" + }); + Assert.That(parseResult.Errors, Is.Empty, "Full health command should parse without errors"); + } + + [Test] + public void Dehydrate_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] + { + "dehydrate", + "--confirm", + "--folders", "src/old;temp" + }); + Assert.That(parseResult.Errors, Is.Empty, "Full dehydrate command with --confirm --folders should parse without errors"); + } + + [Test] + public void Service_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "service", "--list-mounted" }); + Assert.That(parseResult.Errors, Is.Empty); + } + + [Test] + public void Upgrade_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "upgrade", "--confirm" }); + Assert.That(parseResult.Errors, Is.Empty); + } + + [Test] + public void Unmount_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "unmount" }); + Assert.That(parseResult.Errors, Is.Empty); + } + + [Test] + public void Config_FullCommandLine_List_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "config", "-l" }); + Assert.That(parseResult.Errors, Is.Empty, "config -l should parse without errors"); + } + + [Test] + public void Config_FullCommandLine_SetKeyValue_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "config", "mykey", "myvalue" }); + Assert.That(parseResult.Errors, Is.Empty, "config key value should parse without errors"); + } + + [Test] + public void Config_FullCommandLine_Delete_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "config", "-d", "mykey" }); + Assert.That(parseResult.Errors, Is.Empty, "config -d key should parse without errors"); + } + + [Test] + public void Repair_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "repair", "--confirm" }); + Assert.That(parseResult.Errors, Is.Empty); + } + + #endregion + + #region Option Existence per Verb (complete verification) + + [Test] + public void Clone_HasAllExpectedOptions() + { + var expected = new[] { "--cache-server-url", "--branch", "--single-branch", "--no-mount", "--no-prefetch", "--local-cache-path" }; + foreach (var optName in expected) + { + Assert.That(FindOptionOnCommand("clone", optName), Is.Not.Null, + $"clone should have {optName} option"); + } + } + + [Test] + public void Dehydrate_HasAllExpectedOptions() + { + var expected = new[] { "--confirm", "--no-status", "--folders" }; + foreach (var optName in expected) + { + Assert.That(FindOptionOnCommand("dehydrate", optName), Is.Not.Null, + $"dehydrate should have {optName} option"); + } + } + + [Test] + public void Prefetch_HasAllExpectedOptions() + { + var expected = new[] { "--files", "--folders", "--folders-list", "--stdin-files-list", + "--stdin-folders-list", "--files-list", "--hydrate", "--commits", "--verbose" }; + foreach (var optName in expected) + { + Assert.That(FindOptionOnCommand("prefetch", optName), Is.Not.Null, + $"prefetch should have {optName} option"); + } + } + + [Test] + public void Service_HasAllExpectedOptions() + { + var expected = new[] { "--mount-all", "--unmount-all", "--list-mounted" }; + foreach (var optName in expected) + { + Assert.That(FindOptionOnCommand("service", optName), Is.Not.Null, + $"service should have {optName} option"); + } + } + + [Test] + public void Upgrade_HasAllExpectedOptions() + { + var expected = new[] { "--confirm", "--dry-run", "--no-verify" }; + foreach (var optName in expected) + { + Assert.That(FindOptionOnCommand("upgrade", optName), Is.Not.Null, + $"upgrade should have {optName} option"); + } + } + + [Test] + public void Unmount_HasSkipLockOption() + { + Assert.That(FindOptionOnCommand("unmount", "--skip-wait-for-lock"), Is.Not.Null, + "unmount should have --skip-wait-for-lock option"); + } + + #endregion + + #region Helpers + + private Command FindSubcommand(string name) + { + return rootCommand.Subcommands.FirstOrDefault(c => c.Name == name) + ?? throw new System.Exception($"Subcommand '{name}' not found"); + } + + /// + /// Find an option on a subcommand by checking both Name and Aliases. + /// System.CommandLine 2.0.3: Name holds the primary name, Aliases holds only short aliases. + /// + private Option FindOptionOnCommand(string subcommandName, string optionName) + { + var cmd = FindSubcommand(subcommandName); + return cmd.Options.FirstOrDefault(o => o.Name == optionName || o.Aliases.Contains(optionName)); + } + + #endregion + } +} diff --git a/GVFS/GVFS.CommandLine.Tests/GvfsMountCliTests.cs b/GVFS/GVFS.CommandLine.Tests/GvfsMountCliTests.cs new file mode 100644 index 000000000..c352b6429 --- /dev/null +++ b/GVFS/GVFS.CommandLine.Tests/GvfsMountCliTests.cs @@ -0,0 +1,235 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Linq; +using NUnit.Framework; + +namespace GVFS.CommandLine.Tests +{ + /// + /// Tests that GVFS.Mount CLI parsing matches the original CommandLineParser behavior. + /// Verifies defaults (not aliases — this is an internal tool called with long names). + /// + [TestFixture] + public class GvfsMountCliTests + { + private RootCommand rootCommand; + + [SetUp] + public void SetUp() + { + rootCommand = GVFS.Mount.Program.BuildRootCommand(); + } + + #region Default Values — Critical (these were previously broken) + + [Test] + public void Verbosity_DefaultsToInformational() + { + var parseResult = rootCommand.Parse(new[] { "C:\\repo" }); + var opt = FindOption("--verbosity"); + Assert.That(opt, Is.Not.Null); + Assert.That(parseResult.GetValue(opt), Is.EqualTo("Informational"), + "Verbosity should default to 'Informational' when not specified"); + } + + [Test] + public void Keywords_DefaultsToAny() + { + var parseResult = rootCommand.Parse(new[] { "C:\\repo" }); + var opt = FindOption("--keywords"); + Assert.That(opt, Is.Not.Null); + Assert.That(parseResult.GetValue(opt), Is.EqualTo("Any"), + "Keywords should default to 'Any' when not specified"); + } + + [Test] + public void StartedByService_DefaultsToFalse() + { + var parseResult = rootCommand.Parse(new[] { "C:\\repo" }); + var opt = FindOption("--StartedByService"); + Assert.That(opt, Is.Not.Null); + Assert.That(parseResult.GetValue(opt), Is.EqualTo("false"), + "StartedByService should default to 'false' when not specified"); + } + + #endregion + + #region Defaults Are Not Aliases + + [Test] + public void Informational_IsNotAnAlias() + { + var opt = FindOption("--verbosity"); + Assert.That(opt, Is.Not.Null); + Assert.That(opt.Aliases, Does.Not.Contain("Informational"), + "'Informational' should NOT be an alias for --verbosity"); + } + + [Test] + public void Any_IsNotAnAlias() + { + var opt = FindOption("--keywords"); + Assert.That(opt, Is.Not.Null); + Assert.That(opt.Aliases, Does.Not.Contain("Any"), + "'Any' should NOT be an alias for --keywords"); + } + + [Test] + public void False_IsNotAnAlias() + { + var opt = FindOption("--StartedByService"); + Assert.That(opt, Is.Not.Null); + Assert.That(opt.Aliases, Does.Not.Contain("false"), + "'false' should NOT be an alias for --StartedByService"); + } + + #endregion + + #region Explicit Value Parsing + + [Test] + public void Verbosity_ExplicitValue_Overrides() + { + var parseResult = rootCommand.Parse(new[] { "C:\\repo", "--verbosity", "Verbose" }); + var opt = FindOption("--verbosity"); + Assert.That(parseResult.GetValue(opt), Is.EqualTo("Verbose")); + } + + [Test] + public void Keywords_ExplicitValue_Overrides() + { + var parseResult = rootCommand.Parse(new[] { "C:\\repo", "--keywords", "Network" }); + var opt = FindOption("--keywords"); + Assert.That(parseResult.GetValue(opt), Is.EqualTo("Network")); + } + + [Test] + public void StartedByService_ExplicitValue_Overrides() + { + var parseResult = rootCommand.Parse(new[] { "C:\\repo", "--StartedByService", "true" }); + var opt = FindOption("--StartedByService"); + Assert.That(parseResult.GetValue(opt), Is.EqualTo("true")); + } + + [Test] + public void DebugWindow_DefaultsFalse() + { + var parseResult = rootCommand.Parse(new[] { "C:\\repo" }); + var opt = FindOption("--debug-window"); + Assert.That(parseResult.GetValue(opt), Is.False); + } + + [Test] + public void StartedByVerb_DefaultsFalse() + { + var parseResult = rootCommand.Parse(new[] { "C:\\repo" }); + var opt = FindOption("--StartedByVerb"); + Assert.That(parseResult.GetValue(opt), Is.False); + } + + #endregion + + #region Argument Parsing + + [Test] + public void EnlistmentRootPath_IsParsed() + { + var parseResult = rootCommand.Parse(new[] { @"C:\Users\test\repo" }); + var arg = rootCommand.Arguments.FirstOrDefault(a => a.Name == "enlistment-root-path"); + Assert.That(arg, Is.Not.Null); + Assert.That(parseResult.GetValue((Argument)arg), Is.EqualTo(@"C:\Users\test\repo")); + } + + #endregion + + #region Full Command Line (matches how MountVerb launches GVFS.Mount.exe) + + [Test] + public void MountVerbCommandLine_ParsesCorrectly() + { + // MountVerb constructs: GVFS.Mount --verbosity Informational --keywords Any --StartedByVerb + var parseResult = rootCommand.Parse(new[] + { + @"C:\Users\test\repo", + "--verbosity", "Informational", + "--keywords", "Any", + "--StartedByVerb" + }); + + Assert.That(parseResult.Errors, Is.Empty, "MountVerb-style command line should parse without errors"); + + var verbOpt = FindOption("--verbosity"); + var kwOpt = FindOption("--keywords"); + var verbStartedOpt = FindOption("--StartedByVerb"); + + Assert.Multiple(() => + { + Assert.That(parseResult.GetValue(verbOpt), Is.EqualTo("Informational")); + Assert.That(parseResult.GetValue(kwOpt), Is.EqualTo("Any")); + Assert.That(parseResult.GetValue(verbStartedOpt), Is.True); + }); + } + + [Test] + public void ServiceStartedCommandLine_ParsesCorrectly() + { + // MountVerb constructs when started by service: + // GVFS.Mount --verbosity Warning --keywords Network --StartedByService true + var parseResult = rootCommand.Parse(new[] + { + @"C:\Users\test\repo", + "--verbosity", "Warning", + "--keywords", "Network", + "--StartedByService", "true" + }); + + Assert.That(parseResult.Errors, Is.Empty); + + var verbOpt = FindOption("--verbosity"); + var kwOpt = FindOption("--keywords"); + var svcOpt = FindOption("--StartedByService"); + + Assert.Multiple(() => + { + Assert.That(parseResult.GetValue(verbOpt), Is.EqualTo("Warning")); + Assert.That(parseResult.GetValue(kwOpt), Is.EqualTo("Network")); + Assert.That(parseResult.GetValue(svcOpt), Is.EqualTo("true")); + }); + } + + #endregion + + #region All Expected Options Exist + + [Test] + public void AllExpectedOptions_Exist() + { + var expectedOptions = new[] + { + "--verbosity", "--keywords", "--debug-window", + "--StartedByService", "--StartedByVerb" + }; + + foreach (var optName in expectedOptions) + { + Assert.That(FindOption(optName), Is.Not.Null, $"Expected option {optName} to exist"); + } + } + + #endregion + + #region Helpers + + private Option FindOption(string name) + { + return rootCommand.Options.FirstOrDefault(o => o.Name == name || o.Aliases.Contains(name)); + } + + private Option FindOption(string name) + { + return rootCommand.Options.FirstOrDefault(o => o.Name == name || o.Aliases.Contains(name)) as Option; + } + + #endregion + } +} diff --git a/GVFS/GVFS.CommandLine.Tests/Program.cs b/GVFS/GVFS.CommandLine.Tests/Program.cs new file mode 100644 index 000000000..d30bfea11 --- /dev/null +++ b/GVFS/GVFS.CommandLine.Tests/Program.cs @@ -0,0 +1,9 @@ +using NUnitLite; + +namespace GVFS.CommandLine.Tests +{ + public class Program + { + public static int Main(string[] args) => new AutoRun().Execute(args); + } +} diff --git a/GVFS/GVFS.Mount/InternalsVisibleTo.cs b/GVFS/GVFS.Mount/InternalsVisibleTo.cs new file mode 100644 index 000000000..200018c1f --- /dev/null +++ b/GVFS/GVFS.Mount/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("GVFS.CommandLine.Tests")] diff --git a/GVFS/GVFS.Mount/Program.cs b/GVFS/GVFS.Mount/Program.cs index 12f61c807..96584e113 100644 --- a/GVFS/GVFS.Mount/Program.cs +++ b/GVFS/GVFS.Mount/Program.cs @@ -1,7 +1,10 @@ using System.CommandLine; +using System.Runtime.CompilerServices; using GVFS.PlatformLoader; using System; +[assembly: InternalsVisibleTo("GVFS.CommandLine.Tests")] + namespace GVFS.Mount { public class Program @@ -11,7 +14,7 @@ public static void Main(string[] args) GVFSPlatformLoader.Initialize(); try { - RootCommand rootCommand = InProcessMountVerb.BuildRootCommand(); + RootCommand rootCommand = BuildRootCommand(); rootCommand.Parse(args).Invoke(); } catch (MountAbortedException e) @@ -20,5 +23,7 @@ public static void Main(string[] args) Environment.Exit((int)e.Verb.ReturnCode); } } + + internal static RootCommand BuildRootCommand() => InProcessMountVerb.BuildRootCommand(); } } diff --git a/GVFS/GVFS/InternalsVisibleTo.cs b/GVFS/GVFS/InternalsVisibleTo.cs index 0ba48d81b..248b151be 100644 --- a/GVFS/GVFS/InternalsVisibleTo.cs +++ b/GVFS/GVFS/InternalsVisibleTo.cs @@ -1,3 +1,4 @@ -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("GVFS.UnitTests")] +[assembly: InternalsVisibleTo("GVFS.CommandLine.Tests")] diff --git a/scripts/RunUnitTests.bat b/scripts/RunUnitTests.bat index 3424825ca..8ae14aa44 100644 --- a/scripts/RunUnitTests.bat +++ b/scripts/RunUnitTests.bat @@ -6,5 +6,6 @@ IF "%1"=="" (SET "CONFIGURATION=Debug") ELSE (SET "CONFIGURATION=%1") SET RESULT=0 %VFS_OUTDIR%\GVFS.UnitTests\bin\%CONFIGURATION%\net471\win-x64\GVFS.UnitTests.exe || SET RESULT=1 +%VFS_OUTDIR%\GVFS.CommandLine.Tests\bin\%CONFIGURATION%\net471\win-x64\GVFS.CommandLine.Tests.exe || SET RESULT=1 EXIT /b %RESULT%