diff --git a/src/PackageUploader.ClientApi.Test/Msixvc2ToolResolverExtensionsTest.cs b/src/PackageUploader.ClientApi.Test/Msixvc2ToolResolverExtensionsTest.cs new file mode 100644 index 00000000..f23c56c5 --- /dev/null +++ b/src/PackageUploader.ClientApi.Test/Msixvc2ToolResolverExtensionsTest.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PackageUploader.ClientApi.Tools; + +namespace PackageUploader.ClientApi.Test; + +/// +/// Verifies that the MSIXVC2 tool resolver can be consumed from a non-UI host (for example the +/// PackageUploader.exe console app) with no UI services registered, and with or without DI at all. +/// +[TestClass] +public class Msixvc2ToolResolverExtensionsTest +{ + [TestMethod] + public void AddMsixvc2ToolResolver_ResolvesFromABareServiceCollection_WithNoLoggingRegistered() + { + // A bare ServiceCollection is strictly harsher than HostApplicationBuilder, which + // pre-registers logging. If this works, a plain host works. + var services = new ServiceCollection(); + services.AddMsixvc2ToolResolver(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + + var resolver = provider.GetRequiredService(); + + Assert.IsNotNull(resolver); + Assert.IsInstanceOfType(resolver); + Assert.IsInstanceOfType(provider.GetRequiredService()); + } + + [TestMethod] + public void AddMsixvc2ToolResolver_ResolvesWhenLoggingIsRegistered() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMsixvc2ToolResolver(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + + Assert.IsNotNull(provider.GetRequiredService()); + } + + [TestMethod] + public void AddMsixvc2ToolResolver_IsIdempotent() + { + var services = new ServiceCollection(); + services.AddMsixvc2ToolResolver(); + services.AddMsixvc2ToolResolver(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + + Assert.AreEqual(1, services.Count(d => d.ServiceType == typeof(IMsixvc2ToolResolver))); + Assert.AreEqual(1, services.Count(d => d.ServiceType == typeof(IToolProbeRunner))); + Assert.AreEqual(1, services.Count(d => d.ServiceType == typeof(IToolPathResolver))); + Assert.IsNotNull(provider.GetRequiredService()); + } + + [TestMethod] + public void AddMsixvc2ToolResolver_RegistersTheSharedToolPathResolver() + { + // The desktop app injects this directly for tools that have nothing to do with MSIXVC2, + // so registering the MSIXVC2 resolver must make discovery available on its own. + var services = new ServiceCollection(); + services.AddMsixvc2ToolResolver(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + + var pathResolver = provider.GetRequiredService(); + + Assert.IsInstanceOfType(pathResolver); + Assert.AreSame(pathResolver, provider.GetRequiredService()); + } + + [TestMethod] + public void AddMsixvc2ToolResolver_HonoursAPreRegisteredToolPathResolver() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddMsixvc2ToolResolver(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + + Assert.IsInstanceOfType(provider.GetRequiredService()); + } + + [TestMethod] + public void AddMsixvc2ToolResolver_HonoursAPreRegisteredProbeRunner() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddMsixvc2ToolResolver(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + + Assert.IsInstanceOfType(provider.GetRequiredService()); + } + + [TestMethod] + public void AddMsixvc2ToolResolver_RegistersTheResolverAsASingleton() + { + var services = new ServiceCollection(); + services.AddMsixvc2ToolResolver(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + + Assert.AreSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + } + + [TestMethod] + public void Msixvc2ToolResolver_IsConstructibleWithNoDependencyInjectionAtAll() + { + // Console hosts that don't build a container can just new it up. + var resolver = new Msixvc2ToolResolver(); + + // Self-discovery must not throw even when no tool is installed on the machine. + var tool = resolver.Resolve(); + + Assert.AreEqual(tool is not null, resolver.IsMsixvc2Supported()); + } + + private sealed class StubProbeRunner : IToolProbeRunner + { + public ToolProbeResult Run(string executablePath, string arguments, TimeSpan timeout) => + ToolProbeResult.Failed; + } + + private sealed class StubToolPathResolver : IToolPathResolver + { + public string Find(string fileName) => null; + } +} diff --git a/src/PackageUploader.ClientApi.Test/Msixvc2ToolResolverGdkDiscoveryTest.cs b/src/PackageUploader.ClientApi.Test/Msixvc2ToolResolverGdkDiscoveryTest.cs new file mode 100644 index 00000000..9434dbfd --- /dev/null +++ b/src/PackageUploader.ClientApi.Test/Msixvc2ToolResolverGdkDiscoveryTest.cs @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PackageUploader.ClientApi.Tools; + +namespace PackageUploader.ClientApi.Test; + +/// +/// Covers the probe and fallback chain over GDK-discovered tools. The GDK ships both MakePkg.exe and +/// makepkg2.exe in its bin directory, so the resolver must probe them in order and pick the capable one. +/// +/// +/// Discovery itself is covered by ToolPathResolverTest. These tests drive the layer above it: +/// the GDK root lookup is seamed and PATH is replaced with a controlled directory, so they never +/// require an installed GDK. +/// +[TestClass] +public class Msixvc2ToolResolverGdkDiscoveryTest +{ + private string _testRoot; + private string _originalPath; + + [TestInitialize] + public void Initialize() + { + _testRoot = Path.Combine(Path.GetTempPath(), "Msixvc2GdkTest_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_testRoot); + + // Discovery consults the app directory and the current directory before the GDK. A stray real + // tool in either would silently pre-empt what these tests are asserting, so refuse to run + // rather than produce a misleading pass or failure. + foreach (var directory in new[] { AppContext.BaseDirectory, Directory.GetCurrentDirectory() }) + { + foreach (var fileName in new[] { "MakePkg.exe", "makepkg2.exe" }) + { + if (File.Exists(Path.Combine(directory, fileName))) + { + Assert.Inconclusive($"{fileName} is present in {directory}, which pre-empts GDK discovery."); + } + } + } + + _originalPath = Environment.GetEnvironmentVariable("PATH"); + Environment.SetEnvironmentVariable("PATH", Path.Combine(_testRoot, "empty-path")); + } + + [TestCleanup] + public void Cleanup() + { + Environment.SetEnvironmentVariable("PATH", _originalPath); + + try + { + Directory.Delete(_testRoot, recursive: true); + } + catch (IOException) + { + // Temp cleanup is best effort. + } + } + + [TestMethod] + public void Resolve_UsesMakePkgFromTheGdkBinDirectory() + { + var gdkRoot = CreateGdkRoot("gdk", "MakePkg.exe"); + var expected = Path.Combine(gdkRoot, "bin", "MakePkg.exe"); + + var resolver = CreateResolver(Succeeds(expected), FakeLocator(gdkRoot)); + + var tool = resolver.Resolve(); + + Assert.IsNotNull(tool); + Assert.AreEqual(expected, tool.ExecutablePath); + Assert.IsFalse(tool.IsMakePkg2Fallback); + } + + [TestMethod] + public void Resolve_FallsBackToMakePkg2FromTheGdkBinDirectory() + { + // Both binaries ship side by side in the GDK; the legacy MakePkg.exe fails the probe and + // makepkg2.exe answers it, which is exactly the shape of a current GDK install. + var gdkRoot = CreateGdkRoot("gdk", "MakePkg.exe", "makepkg2.exe"); + var expected = Path.Combine(gdkRoot, "bin", "makepkg2.exe"); + + var resolver = CreateResolver(Succeeds(expected), FakeLocator(gdkRoot)); + + var tool = resolver.Resolve(); + + Assert.IsNotNull(tool); + Assert.AreEqual(expected, tool.ExecutablePath); + Assert.IsTrue(tool.IsMakePkg2Fallback); + } + + [TestMethod] + public void Resolve_ReturnsNull_WhenNoGdkIsInstalledAndNothingIsOnPath() + { + var resolver = CreateResolver(_ => ToolProbeResult.Failed, FakeLocator()); + + Assert.IsNull(resolver.Resolve()); + Assert.IsFalse(resolver.IsMsixvc2Supported()); + } + + [TestMethod] + public void Resolve_UsesTheInjectedToolPathResolverForDiscovery() + { + // Discovery is delegated, so a host that supplies its own IToolPathResolver controls which + // binaries are probed. This is the seam CHANGE 2's command line adapter inherits. + var stubPath = Path.Combine(_testRoot, "elsewhere", "MakePkg.exe"); + Directory.CreateDirectory(Path.GetDirectoryName(stubPath)); + File.WriteAllText(stubPath, string.Empty); + + var pathResolver = new RecordingToolPathResolver(stubPath); + var resolver = new Msixvc2ToolResolver(null, new FakeProbeRunner(Succeeds(stubPath)), TimeSpan.FromSeconds(1), pathResolver); + + var tool = resolver.Resolve(); + + Assert.IsNotNull(tool); + Assert.AreEqual(stubPath, tool.ExecutablePath); + CollectionAssert.Contains(pathResolver.RequestedFileNames, "MakePkg.exe"); + } + + [TestMethod] + public void Resolve_PrefersAnExplicitPathOverDiscovery() + { + // A non-null hint is authoritative and must suppress discovery entirely, which is how the + // desktop app avoids searching twice for a tool it has already located. + var explicitPath = Path.Combine(_testRoot, "explicit", "makepkg2.exe"); + Directory.CreateDirectory(Path.GetDirectoryName(explicitPath)); + File.WriteAllText(explicitPath, string.Empty); + + var pathResolver = new RecordingToolPathResolver(null); + var resolver = new Msixvc2ToolResolver(null, new FakeProbeRunner(Succeeds(explicitPath)), TimeSpan.FromSeconds(1), pathResolver); + + var tool = resolver.Resolve(string.Empty, explicitPath); + + Assert.IsNotNull(tool); + Assert.AreEqual(explicitPath, tool.ExecutablePath); + Assert.IsTrue(tool.IsMakePkg2Fallback); + Assert.AreEqual(0, pathResolver.RequestedFileNames.Count, "An explicit path must not trigger discovery."); + } + + [TestMethod] + public void Resolver_DoesNotReferenceTheInternalNuGetPackage() + { + // The makepkg2 NuGet feed is internal only, so nothing may point a customer at it. + var assembly = typeof(Msixvc2ToolResolver).Assembly; + + var offendingConstants = assembly + .GetTypes() + .Where(type => type.Namespace == "PackageUploader.ClientApi.Tools") + .SelectMany(type => type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)) + .Where(field => field.IsLiteral && field.FieldType == typeof(string)) + .Select(field => (string)field.GetRawConstantValue()) + .Where(value => value != null && + value.Contains("packaging.tools", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + CollectionAssert.AreEqual(new List(), offendingConstants); + } + + private static Msixvc2ToolResolver CreateResolver(Func probe, IGdkRootLocator locator) => + new(null, new FakeProbeRunner(probe), TimeSpan.FromSeconds(1), new ToolPathResolver(locator)); + + private static Func Succeeds(string supportedPath) => + executablePath => string.Equals(executablePath, supportedPath, StringComparison.OrdinalIgnoreCase) + ? new ToolProbeResult(true, 0) + : new ToolProbeResult(true, 2); + + private static IGdkRootLocator FakeLocator(params string[] roots) => new StubGdkRootLocator(roots); + + private string CreateGdkRoot(string name, params string[] toolFileNames) + { + var root = Path.Combine(_testRoot, name); + var bin = Path.Combine(root, "bin"); + Directory.CreateDirectory(bin); + + foreach (var fileName in toolFileNames) + { + File.WriteAllText(Path.Combine(bin, fileName), string.Empty); + } + + return root; + } + + private sealed class StubGdkRootLocator : IGdkRootLocator + { + private readonly IReadOnlyList _roots; + + public StubGdkRootLocator(IReadOnlyList roots) => _roots = roots; + + public IReadOnlyList GetGdkRoots() => _roots; + } + + /// + /// Returns a fixed path for any request and records what was asked for, so a test can assert + /// whether discovery ran at all. + /// + private sealed class RecordingToolPathResolver : IToolPathResolver + { + private readonly string _result; + + public RecordingToolPathResolver(string result) => _result = result; + + public List RequestedFileNames { get; } = new(); + + public string Find(string fileName) + { + RequestedFileNames.Add(fileName); + return _result; + } + } + + private sealed class FakeProbeRunner : IToolProbeRunner + { + private readonly Func _probe; + + public FakeProbeRunner(Func probe) => _probe = probe; + + public ToolProbeResult Run(string executablePath, string arguments, TimeSpan timeout) + { + Assert.AreEqual("supports uploadsource", arguments); + return _probe(executablePath); + } + } +} diff --git a/src/PackageUploader.ClientApi.Test/ToolPathResolverTest.cs b/src/PackageUploader.ClientApi.Test/ToolPathResolverTest.cs new file mode 100644 index 00000000..4127757a --- /dev/null +++ b/src/PackageUploader.ClientApi.Test/ToolPathResolverTest.cs @@ -0,0 +1,364 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PackageUploader.ClientApi.Tools; + +namespace PackageUploader.ClientApi.Test; + +/// +/// Covers the shared tool discovery used by every host: application directory, current directory, +/// GDK bin, then PATH. +/// +/// +/// These tests never require an installed GDK: the GDK root lookup is seamed, and PATH is replaced +/// with a controlled directory for the duration of each test. +/// +[TestClass] +public class ToolPathResolverTest +{ + private const string ToolFileName = "MakePkg.exe"; + + private string _testRoot; + private string _originalPath; + + [TestInitialize] + public void Initialize() + { + _testRoot = Path.Combine(Path.GetTempPath(), "ToolPathTest_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_testRoot); + + // Discovery consults the app directory and the current directory before the GDK. A stray real + // tool in either would silently pre-empt what these tests are asserting, so refuse to run + // rather than produce a misleading pass or failure. + foreach (var directory in new[] { AppContext.BaseDirectory, Directory.GetCurrentDirectory() }) + { + foreach (var fileName in new[] { "MakePkg.exe", "makepkg2.exe" }) + { + if (File.Exists(Path.Combine(directory, fileName))) + { + Assert.Inconclusive($"{fileName} is present in {directory}, which pre-empts GDK discovery."); + } + } + } + + _originalPath = Environment.GetEnvironmentVariable("PATH"); + Environment.SetEnvironmentVariable("PATH", Path.Combine(_testRoot, "empty-path")); + } + + [TestCleanup] + public void Cleanup() + { + Environment.SetEnvironmentVariable("PATH", _originalPath); + + try + { + Directory.Delete(_testRoot, recursive: true); + } + catch (IOException) + { + // Temp cleanup is best effort. + } + } + + [TestMethod] + public void Find_ReturnsTheToolFromTheGdkBinDirectory() + { + var gdkRoot = CreateGdkRoot("gdk", ToolFileName); + + var found = CreateResolver(gdkRoot).Find(ToolFileName); + + Assert.AreEqual(Path.Combine(gdkRoot, "bin", ToolFileName), found); + } + + [TestMethod] + public void Find_PrefersTheGdkOverPath() + { + // An explicitly installed GDK outranks whatever happens to be on PATH. The UI resolved in this + // order before discovery was shared, and the command line must agree or the two hosts could + // run different binaries on the same machine. + var gdkRoot = CreateGdkRoot("gdk", ToolFileName); + Environment.SetEnvironmentVariable("PATH", CreateDirectoryWith("on-path", ToolFileName)); + + var found = CreateResolver(gdkRoot).Find(ToolFileName); + + Assert.AreEqual(Path.Combine(gdkRoot, "bin", ToolFileName), found); + } + + [TestMethod] + public void Find_FallsThroughToPath_WhenTheGdkBinDirectoryDoesNotHaveTheTool() + { + // A GDK root that exists but predates the tool must not stop the search. + var gdkRoot = CreateGdkRoot("gdk"); + var pathDirectory = CreateDirectoryWith("on-path", ToolFileName); + Environment.SetEnvironmentVariable("PATH", pathDirectory); + + var found = CreateResolver(gdkRoot).Find(ToolFileName); + + Assert.AreEqual(Path.Combine(pathDirectory, ToolFileName), found); + } + + [TestMethod] + public void Find_SearchesEveryGdkRootInOrder() + { + var firstRoot = CreateGdkRoot("gdk-one"); + var secondRoot = CreateGdkRoot("gdk-two", ToolFileName); + + var found = CreateResolver(firstRoot, secondRoot).Find(ToolFileName); + + Assert.AreEqual(Path.Combine(secondRoot, "bin", ToolFileName), found); + } + + [TestMethod] + public void Find_ReturnsNull_WhenNoGdkIsInstalledAndNothingIsOnPath() + { + Assert.IsNull(CreateResolver().Find(ToolFileName)); + } + + [TestMethod] + public void Find_ReturnsNull_WhenTheGdkRootDoesNotExistOnDisk() + { + var found = CreateResolver(Path.Combine(_testRoot, "not-installed")).Find(ToolFileName); + + Assert.IsNull(found); + } + + [TestMethod] + public void Find_ReturnsNull_ForABlankFileName() + { + var resolver = CreateResolver(); + + Assert.IsNull(resolver.Find(null)); + Assert.IsNull(resolver.Find(string.Empty)); + Assert.IsNull(resolver.Find(" ")); + } + + [TestMethod] + public void Find_DoesNotThrow_WhenPathIsMalformed() + { + // A quoted entry, an empty entry, and characters that are invalid in a path must all be + // skipped rather than escaping to the caller. + Environment.SetEnvironmentVariable("PATH", "\"C:\\quoted\";;C:\\bad|entry\0;"); + + Assert.IsNull(CreateResolver().Find(ToolFileName)); + } + + [TestMethod] + public void Find_StillSearchesPath_WhenTheGdkLookupFails() + { + // A failing GDK lookup means "no GDK candidates", not "stop searching". Asserting only that + // Find does not throw would pass just as well if the search had been abandoned, so this + // asserts the stage after the failure still runs. + var pathDirectory = CreateDirectoryWith("on-path", ToolFileName); + Environment.SetEnvironmentVariable("PATH", pathDirectory); + + var found = new ToolPathResolver(new ThrowingGdkRootLocator()).Find(ToolFileName); + + Assert.AreEqual(Path.Combine(pathDirectory, ToolFileName), found); + } + + [TestMethod] + public void Find_ReturnsNull_WhenTheGdkLookupFailsAndNothingIsOnPath() + { + Assert.IsNull(new ToolPathResolver(new ThrowingGdkRootLocator()).Find(ToolFileName)); + } + + [TestMethod] + public void Find_StillSearchesLaterGdkRoots_WhenAnEarlierRootIsUnusable() + { + // A single malformed root must cost only its own candidate. + var goodRoot = CreateGdkRoot("gdk-two", ToolFileName); + + var found = CreateResolver(null, goodRoot).Find(ToolFileName); + + Assert.AreEqual(Path.Combine(goodRoot, "bin", ToolFileName), found); + } + + [TestMethod] + public void Find_StillSearchesPath_WhenTheGdkRootsAreUnusable() + { + var pathDirectory = CreateDirectoryWith("on-path", ToolFileName); + Environment.SetEnvironmentVariable("PATH", pathDirectory); + + var found = CreateResolver(new string[] { null }).Find(ToolFileName); + + Assert.AreEqual(Path.Combine(pathDirectory, ToolFileName), found); + } + + [TestMethod] + public void Find_StillSearchesLaterPathEntries_WhenAnEarlierEntryIsUnusable() + { + // A quoted entry, an empty entry, and invalid path characters must each cost only their own + // candidate. No embedded NUL here: Windows truncates the variable at one, which would drop the + // good entry and make this pass for the wrong reason. + var pathDirectory = CreateDirectoryWith("on-path", ToolFileName); + var malformed = string.Join( + Path.PathSeparator.ToString(), + "\"C:\\quoted\"", + string.Empty, + "C:\\bad|entry", + " ", + pathDirectory); + Environment.SetEnvironmentVariable("PATH", malformed); + + var found = CreateResolver().Find(ToolFileName); + + Assert.AreEqual(Path.Combine(pathDirectory, ToolFileName), found); + } + + [TestMethod] + public void Find_ReturnsNull_WhenTheGdkLocatorReturnsNull() + { + Assert.IsNull(new ToolPathResolver(new StubGdkRootLocator(null)).Find(ToolFileName)); + } + + [TestMethod] + public void Find_PrefersTheCurrentDirectoryOverTheGdk() + { + // By design: copying a tool into the working directory is how a developer pins a hotfixed or + // otherwise specific version in place of the one their installed GDK ships. A uniquely named + // file is used so this never collides with a real tool. + var toolName = "PinnedTool_" + Guid.NewGuid().ToString("N") + ".exe"; + var currentDirectoryTool = Path.Combine(Directory.GetCurrentDirectory(), toolName); + var gdkRoot = CreateGdkRoot("gdk", toolName); + + File.WriteAllText(currentDirectoryTool, string.Empty); + + try + { + var found = CreateResolver(gdkRoot).Find(toolName); + + Assert.AreEqual(currentDirectoryTool, found); + } + finally + { + File.Delete(currentDirectoryTool); + } + } + + [TestMethod] + public void Find_UsesTheRealGdkLookup_WhenConstructedWithoutASeam() + { + // The parameterless constructor is what hosts use. It must work and must not throw whether or + // not this machine has a GDK, so only the absence of an exception is asserted. + var resolver = new ToolPathResolver(); + + resolver.Find("a-tool-that-does-not-exist.exe"); + } + + [TestMethod] + public void GdkRootLocator_PrefersTheEnvironmentVariableOverTheRegistry() + { + var locator = new GdkRootLocator( + _ => @"C:\from-env", + key => key == GdkRootLocator.GdkRegistryKey ? @"C:\from-registry" : null); + + var roots = locator.GetGdkRoots(); + + Assert.AreEqual(@"C:\from-env", roots[0]); + CollectionAssert.Contains(roots.ToList(), @"C:\from-registry"); + } + + [TestMethod] + public void GdkRootLocator_FallsBackToTheRegistry_WhenTheEnvironmentVariableIsNotSet() + { + var locator = new GdkRootLocator( + _ => null, + key => key == GdkRootLocator.GdkRegistryKey ? @"C:\from-registry" : null); + + var roots = locator.GetGdkRoots(); + + Assert.AreEqual(1, roots.Count); + Assert.AreEqual(@"C:\from-registry", roots[0]); + } + + [TestMethod] + public void GdkRootLocator_FallsBackToTheWow6432NodeMirror() + { + var locator = new GdkRootLocator( + _ => null, + key => key == GdkRootLocator.GdkWow6432RegistryKey ? @"C:\from-wow6432" : null); + + var roots = locator.GetGdkRoots(); + + Assert.AreEqual(1, roots.Count); + Assert.AreEqual(@"C:\from-wow6432", roots[0]); + } + + [TestMethod] + public void GdkRootLocator_ReturnsEmpty_WhenNoSourceHasAGdk() + { + var locator = new GdkRootLocator(_ => null, _ => null); + + Assert.AreEqual(0, locator.GetGdkRoots().Count); + } + + [TestMethod] + public void GdkRootLocator_DoesNotThrow_WhenASourceFails() + { + // Access-denied or a malformed value must never escape into tool resolution. + var locator = new GdkRootLocator( + _ => throw new InvalidOperationException("environment blew up"), + _ => throw new UnauthorizedAccessException("registry blew up")); + + Assert.AreEqual(0, locator.GetGdkRoots().Count); + } + + [TestMethod] + public void GdkRootLocator_DefaultSourcesDoNotThrow() + { + // Exercises the real environment and registry readers. On a non-Windows host the registry + // read is skipped by the OperatingSystem.IsWindows() guard rather than throwing. + var roots = new GdkRootLocator().GetGdkRoots(); + + Assert.IsNotNull(roots); + } + + private static ToolPathResolver CreateResolver(params string[] gdkRoots) => + new(new StubGdkRootLocator(gdkRoots)); + + private string CreateGdkRoot(string name, params string[] toolFileNames) + { + var root = Path.Combine(_testRoot, name); + var bin = Path.Combine(root, "bin"); + Directory.CreateDirectory(bin); + + foreach (var fileName in toolFileNames) + { + File.WriteAllText(Path.Combine(bin, fileName), string.Empty); + } + + return root; + } + + private string CreateDirectoryWith(string name, params string[] fileNames) + { + var directory = Path.Combine(_testRoot, name); + Directory.CreateDirectory(directory); + + foreach (var fileName in fileNames) + { + File.WriteAllText(Path.Combine(directory, fileName), string.Empty); + } + + return directory; + } + + private sealed class StubGdkRootLocator : IGdkRootLocator + { + private readonly IReadOnlyList _roots; + + public StubGdkRootLocator(IReadOnlyList roots) => _roots = roots; + + public IReadOnlyList GetGdkRoots() => _roots; + } + + private sealed class ThrowingGdkRootLocator : IGdkRootLocator + { + public IReadOnlyList GetGdkRoots() => throw new UnauthorizedAccessException("locator blew up"); + } +} diff --git a/src/PackageUploader.ClientApi/Tools/GdkRootLocator.cs b/src/PackageUploader.ClientApi/Tools/GdkRootLocator.cs new file mode 100644 index 00000000..555c8e52 --- /dev/null +++ b/src/PackageUploader.ClientApi/Tools/GdkRootLocator.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Microsoft.Win32; + +namespace PackageUploader.ClientApi.Tools; + +/// +/// Supplies the installation roots of the Microsoft GDK, under which packaging tools live in bin. +/// +/// +/// Seamed so can be tested without a GDK installed. This stays internal: +/// only the path-resolution contract is public, not the environment and registry sources behind it. +/// +internal interface IGdkRootLocator +{ + /// + /// Returns candidate GDK installation roots in priority order. + /// + /// + /// The roots, or an empty list when no GDK could be located. Never , never throws. + /// + IReadOnlyList GetGdkRoots(); +} + +/// +/// Locates the GDK via the GameDK environment variable, then the registry. +/// +/// +/// The registry keys match the ones the WPF host has always used, so command line and UI discovery agree. +/// Registry access is Windows-only and is guarded accordingly; on other platforms only the environment +/// variable is consulted. +/// +internal sealed class GdkRootLocator : IGdkRootLocator +{ + internal const string GdkEnvironmentVariableName = "GameDK"; + internal const string GdkInstallPathValueName = "GDKInstallPath"; + internal const string GdkRegistryKey = @"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\GDK\Installed Roots"; + internal const string GdkWow6432RegistryKey = @"HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\GDK\Installed Roots"; + + private readonly Func _environmentVariableReader; + private readonly Func _registryValueReader; + + public GdkRootLocator() + : this(null, null) + { + } + + internal GdkRootLocator(Func environmentVariableReader, Func registryValueReader) + { + _environmentVariableReader = environmentVariableReader ?? Environment.GetEnvironmentVariable; + _registryValueReader = registryValueReader ?? ReadGdkInstallPath; + } + + /// + public IReadOnlyList GetGdkRoots() + { + var roots = new List(3); + + // The environment variable is the cheapest source and is set by the GDK installer. + AddRoot(roots, Read(_environmentVariableReader, GdkEnvironmentVariableName)); + + AddRoot(roots, Read(_registryValueReader, GdkRegistryKey)); + AddRoot(roots, Read(_registryValueReader, GdkWow6432RegistryKey)); + + return roots; + } + + private static string Read(Func reader, string key) + { + try + { + return reader(key); + } + catch (Exception) + { + // A denied or malformed source must never break tool resolution. + return null; + } + } + + private static void AddRoot(List roots, string root) + { + if (!string.IsNullOrWhiteSpace(root) && !roots.Contains(root)) + { + roots.Add(root); + } + } + + private static string ReadGdkInstallPath(string registryKey) + { + if (!OperatingSystem.IsWindows()) + { + return null; + } + + return Registry.GetValue(registryKey, GdkInstallPathValueName, null) as string; + } +} diff --git a/src/PackageUploader.ClientApi/Tools/IMsixvc2ToolResolver.cs b/src/PackageUploader.ClientApi/Tools/IMsixvc2ToolResolver.cs new file mode 100644 index 00000000..ce81f4ac --- /dev/null +++ b/src/PackageUploader.ClientApi/Tools/IMsixvc2ToolResolver.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace PackageUploader.ClientApi.Tools; + +/// +/// Determines which packaging tool, if any, can perform an MSIXVC2 upload. +/// +/// +/// The GDK renamed Makepkg2.exe to MakePkg.exe; the new MakePkg.exe absorbs the +/// makepkg2 capabilities. Resolution order is therefore: +/// +/// MakePkg.exe supports uploadsource (exit code 0 means supported). A legacy +/// MakePkg.exe fails this, which is the discriminator. +/// The standalone makepkg2.exe, which the GDK ships alongside MakePkg.exe, probed +/// the same way. +/// Otherwise MSIXVC2 is unavailable. +/// +/// Implementations must be thread-safe and must not throw for missing or broken tools. +/// +/// Contract: every member reports "no MSIXVC2-capable tool" by returning +/// (or ), never by throwing. Callers branch on the return value and do not +/// need to guard these calls with try/catch. +/// +/// +public interface IMsixvc2ToolResolver +{ + /// + /// Resolves the MSIXVC2-capable tool using self-discovery (application directory, current directory, + /// the installed GDK, and PATH). + /// + /// + /// The resolved tool, or when no MSIXVC2-capable tool is available. + /// Never throws. + /// + Msixvc2Tool Resolve(); + + /// + /// Resolves the MSIXVC2-capable tool, preferring caller-supplied paths before self-discovery. + /// + /// An already-resolved MakePkg.exe path, or to self-discover. + /// An already-resolved makepkg2.exe path, or to self-discover. + /// + /// The resolved tool, or when no MSIXVC2-capable tool is available. + /// Never throws. + /// + Msixvc2Tool Resolve(string makePkgPath, string makePkg2Path); + + /// + /// Convenience wrapper over . + /// + /// + /// when an MSIXVC2-capable tool is available; otherwise . + /// Never throws. + /// + bool IsMsixvc2Supported(); + + /// + /// Convenience wrapper over . + /// + /// + /// when an MSIXVC2-capable tool is available; otherwise . + /// Never throws. + /// + bool IsMsixvc2Supported(string makePkgPath, string makePkg2Path); +} diff --git a/src/PackageUploader.ClientApi/Tools/IToolPathResolver.cs b/src/PackageUploader.ClientApi/Tools/IToolPathResolver.cs new file mode 100644 index 00000000..21655889 --- /dev/null +++ b/src/PackageUploader.ClientApi/Tools/IToolPathResolver.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace PackageUploader.ClientApi.Tools; + +/// +/// Locates a tool that ships alongside the application or with the Microsoft GDK. +/// +/// +/// This is the single discovery implementation shared by every host, so the command line and the +/// desktop app can never disagree about which copy of a tool they will run. +/// +/// Contract: implementations report "not found" by returning , never by +/// throwing. An unreadable directory, a denied registry key, or a malformed PATH entry is skipped +/// and the search continues. +/// +/// +public interface IToolPathResolver +{ + /// + /// Searches for in the application directory, the current directory, + /// the bin directory of any installed GDK, and finally each directory on PATH, in that order. + /// + /// + /// The application and current directories are searched before the GDK by design: copying a tool + /// next to the application or into the working directory is the supported way for a developer to + /// pin a hotfixed or otherwise specific version in place of the one their installed GDK ships. + /// + /// + /// A bare file name such as MakePkg.exe, not a path. The search is by exact name. + /// + /// + /// The full path to the first match, or when the file was not found in any + /// searched location. Never throws. + /// + string Find(string fileName); +} diff --git a/src/PackageUploader.ClientApi/Tools/IToolProbeRunner.cs b/src/PackageUploader.ClientApi/Tools/IToolProbeRunner.cs new file mode 100644 index 00000000..b0eb74f1 --- /dev/null +++ b/src/PackageUploader.ClientApi/Tools/IToolProbeRunner.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Diagnostics; + +namespace PackageUploader.ClientApi.Tools; + +/// +/// Outcome of a capability probe. +/// +/// False when the process could not be started or did not exit within the timeout. +/// Process exit code when is true; otherwise undefined. +public readonly record struct ToolProbeResult(bool Completed, int ExitCode) +{ + public static ToolProbeResult Failed => new(false, -1); + + public bool Succeeded => Completed && ExitCode == 0; +} + +/// +/// Runs a short-lived capability probe. Abstracted so the resolver can be unit tested without spawning processes. +/// +public interface IToolProbeRunner +{ + ToolProbeResult Run(string executablePath, string arguments, TimeSpan timeout); +} + +/// +/// Default that launches the tool with no window and no shell execution. +/// Never throws: process start failures are reported as . +/// +public sealed class ProcessToolProbeRunner : IToolProbeRunner +{ + public ToolProbeResult Run(string executablePath, string arguments, TimeSpan timeout) + { + try + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = executablePath, + Arguments = arguments, + UseShellExecute = false, + RedirectStandardOutput = false, + RedirectStandardError = false, + CreateNoWindow = true, + } + }; + + process.Start(); + + if (process.WaitForExit((int)timeout.TotalMilliseconds)) + { + return new ToolProbeResult(true, process.ExitCode); + } + + try + { + process.Kill(entireProcessTree: true); + } + catch + { + // Best effort. + } + + return ToolProbeResult.Failed; + } + catch + { + return ToolProbeResult.Failed; + } + } +} diff --git a/src/PackageUploader.ClientApi/Tools/Msixvc2Tool.cs b/src/PackageUploader.ClientApi/Tools/Msixvc2Tool.cs new file mode 100644 index 00000000..36e832ac --- /dev/null +++ b/src/PackageUploader.ClientApi/Tools/Msixvc2Tool.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace PackageUploader.ClientApi.Tools; + +/// +/// A packaging tool that has been verified to support MSIXVC2 upload. +/// +/// Full path to the executable to invoke. +/// +/// True when the resolved tool is the standalone makepkg2.exe rather than the current GDK's +/// MakePkg.exe. +/// +public sealed record Msixvc2Tool(string ExecutablePath, bool IsMakePkg2Fallback); diff --git a/src/PackageUploader.ClientApi/Tools/Msixvc2ToolResolver.cs b/src/PackageUploader.ClientApi/Tools/Msixvc2ToolResolver.cs new file mode 100644 index 00000000..3014bfcd --- /dev/null +++ b/src/PackageUploader.ClientApi/Tools/Msixvc2ToolResolver.cs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace PackageUploader.ClientApi.Tools; + +/// +/// Default . +/// +/// +/// Stateless and therefore thread-safe. By design nothing is cached: the tool is re-probed on every +/// call so in-place binary updates (for example a GDK upgrade while the app is running) are picked up. +/// +public sealed class Msixvc2ToolResolver : IMsixvc2ToolResolver +{ + internal const string MakePkgFileName = "MakePkg.exe"; + internal const string MakePkg2FileName = "makepkg2.exe"; + internal const string SupportsUploadSourceArguments = "supports uploadsource"; + + private static readonly TimeSpan DefaultProbeTimeout = TimeSpan.FromSeconds(5); + + private readonly IToolProbeRunner _probeRunner; + private readonly ILogger _logger; + private readonly TimeSpan _probeTimeout; + private readonly IToolPathResolver _toolPathResolver; + + public Msixvc2ToolResolver() + : this(null, null, null) + { + } + + public Msixvc2ToolResolver(ILogger logger) + : this(logger, null, null) + { + } + + public Msixvc2ToolResolver(ILogger logger, IToolProbeRunner probeRunner, TimeSpan? probeTimeout) + : this(logger, probeRunner, probeTimeout, null) + { + } + + /// + /// Discovery is seamed separately from probing so tests can drive either layer in isolation. + /// + internal Msixvc2ToolResolver( + ILogger logger, + IToolProbeRunner probeRunner, + TimeSpan? probeTimeout, + IToolPathResolver toolPathResolver) + { + _logger = logger ?? (ILogger)NullLogger.Instance; + _probeRunner = probeRunner ?? new ProcessToolProbeRunner(); + _probeTimeout = probeTimeout is { } timeout && timeout > TimeSpan.Zero ? timeout : DefaultProbeTimeout; + _toolPathResolver = toolPathResolver ?? new ToolPathResolver(); + } + + /// + public Msixvc2Tool Resolve() => Resolve(null, null); + + /// + /// + /// A non-null argument (including an empty string) is treated as authoritative and disables + /// self-discovery for that tool, so hosts that already resolve paths get deterministic behavior. + /// + /// + /// The resolved tool, or when no MSIXVC2-capable tool is available. + /// Never throws. + /// + public Msixvc2Tool Resolve(string makePkgPath, string makePkg2Path) + { + // 1. The current GDK's MakePkg.exe absorbed the makepkg2 capabilities. + string makePkgCandidate = makePkgPath is null ? Discover(MakePkgFileName) : NormalizeCandidate(makePkgPath); + if (makePkgCandidate is not null && ProbeSupportsUploadSource(makePkgCandidate, MakePkgFileName)) + { + return new Msixvc2Tool(makePkgCandidate, IsMakePkg2Fallback: false); + } + + // 2. Fall back to the standalone makepkg2.exe, which the GDK also ships in its bin directory. + string makePkg2Candidate = makePkg2Path is null ? Discover(MakePkg2FileName) : NormalizeCandidate(makePkg2Path); + if (makePkg2Candidate is not null && ProbeSupportsUploadSource(makePkg2Candidate, MakePkg2FileName)) + { + return new Msixvc2Tool(makePkg2Candidate, IsMakePkg2Fallback: true); + } + + _logger.LogInformation("No MSIXVC2-capable packaging tool was found. MSIXVC2 upload is unavailable."); + return null; + } + + /// + public bool IsMsixvc2Supported() => Resolve() is not null; + + /// + public bool IsMsixvc2Supported(string makePkgPath, string makePkg2Path) => Resolve(makePkgPath, makePkg2Path) is not null; + + /// + /// Accepts a caller-supplied path only when it points at an existing file. + /// + /// The path, or when it is blank or does not exist. + private static string NormalizeCandidate(string path) => + !string.IsNullOrWhiteSpace(path) && File.Exists(path) ? path : null; + + private bool ProbeSupportsUploadSource(string executablePath, string toolDisplayName) + { + ToolProbeResult result = _probeRunner.Run(executablePath, SupportsUploadSourceArguments, _probeTimeout); + + if (!result.Completed) + { + _logger.LogInformation( + "{Tool} uploadsource probe did not complete (missing tool, launch failure, or timeout after {TimeoutSeconds}s) for {Path}.", + toolDisplayName, _probeTimeout.TotalSeconds, executablePath); + return false; + } + + _logger.LogInformation("{Tool} uploadsource probe: {Result} (exit code {ExitCode}) for {Path}.", + toolDisplayName, result.Succeeded ? "supported" : "not supported", result.ExitCode, executablePath); + + return result.Succeeded; + } + + /// + /// Looks for using the shared tool discovery. + /// + /// + /// Discovery lives in so the desktop app and the command line + /// search identically. The GDK ships both MakePkg.exe and makepkg2.exe in its bin + /// directory, so one search serves both. + /// + /// The full path to the tool, or when it was not found. Never throws. + private string Discover(string fileName) => _toolPathResolver.Find(fileName); +} diff --git a/src/PackageUploader.ClientApi/Tools/Msixvc2ToolResolverExtensions.cs b/src/PackageUploader.ClientApi/Tools/Msixvc2ToolResolverExtensions.cs new file mode 100644 index 00000000..04f0b660 --- /dev/null +++ b/src/PackageUploader.ClientApi/Tools/Msixvc2ToolResolverExtensions.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace PackageUploader.ClientApi.Tools; + +public static class Msixvc2ToolResolverExtensions +{ + /// + /// Registers the shared tool path resolver and the MSIXVC2 tool resolver. Safe to call from any + /// host; hosts that do not use DI can simply new Msixvc2ToolResolver() instead. + /// + public static IServiceCollection AddMsixvc2ToolResolver(this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(provider => new Msixvc2ToolResolver( + provider.GetService>(), + provider.GetRequiredService(), + probeTimeout: null, + provider.GetRequiredService())); + + return services; + } +} diff --git a/src/PackageUploader.ClientApi/Tools/ToolPathResolver.cs b/src/PackageUploader.ClientApi/Tools/ToolPathResolver.cs new file mode 100644 index 00000000..5c74888f --- /dev/null +++ b/src/PackageUploader.ClientApi/Tools/ToolPathResolver.cs @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; + +namespace PackageUploader.ClientApi.Tools; + +/// +/// Default . +/// +/// +/// Stateless and therefore thread-safe. Nothing is cached, so a tool that appears or is replaced while +/// the process is running is picked up by the next call. +/// +public sealed class ToolPathResolver : IToolPathResolver +{ + internal const string GdkToolSubdirectory = "bin"; + + private readonly IGdkRootLocator _gdkRootLocator; + + public ToolPathResolver() + : this(null) + { + } + + /// + /// The GDK lookup is seamed rather than exposed: tests substitute it so they never require an + /// installed GDK, but the environment and registry sources stay an implementation detail. + /// + internal ToolPathResolver(IGdkRootLocator gdkRootLocator) + { + _gdkRootLocator = gdkRootLocator ?? new GdkRootLocator(); + } + + /// + public string Find(string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + return null; + } + + // Every stage is guarded on its own, so a source that fails costs only its own candidates and + // never the stages after it: an unreadable working directory must still leave the GDK and PATH + // searchable, and a denied registry key must still leave PATH searchable. + return FindInApplicationDirectory(fileName) + ?? FindInCurrentDirectory(fileName) + ?? FindInGdk(fileName) + ?? FindOnPath(fileName); + } + + /// + /// The application directory and the current directory deliberately outrank the installed GDK. + /// Dropping a tool next to the application or into the working directory is the supported way to + /// pin a hotfix, or a specific version that differs from the one the installed GDK ships, without + /// having to change the GDK installation itself. + /// + private static string FindInApplicationDirectory(string fileName) + { + try + { + return MatchIn(AppContext.BaseDirectory, fileName); + } + catch (Exception) + { + return null; + } + } + + /// + private static string FindInCurrentDirectory(string fileName) + { + try + { + return MatchIn(Directory.GetCurrentDirectory(), fileName); + } + catch (Exception) + { + // The working directory can be denied or deleted out from under the process. + return null; + } + } + + /// + /// An explicitly installed GDK outranks whatever happens to be on PATH, so a machine with several + /// copies of a tool resolves predictably. + /// + private string FindInGdk(string fileName) + { + IReadOnlyList gdkRoots; + + try + { + gdkRoots = _gdkRootLocator.GetGdkRoots(); + } + catch (Exception) + { + // A failed GDK lookup means "no GDK candidates", not "stop searching". + return null; + } + + if (gdkRoots is null) + { + return null; + } + + foreach (string gdkRoot in gdkRoots) + { + string match = MatchIn(gdkRoot, GdkToolSubdirectory, fileName); + if (match is not null) + { + return match; + } + } + + return null; + } + + private static string FindOnPath(string fileName) + { + string[] directories; + + try + { + string pathValue = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrEmpty(pathValue)) + { + return null; + } + + directories = pathValue.Split(Path.PathSeparator); + } + catch (Exception) + { + return null; + } + + foreach (string directory in directories) + { + string match = MatchIn(directory, fileName); + if (match is not null) + { + return match; + } + } + + return null; + } + + private static string MatchIn(string directory, string fileName) => + MatchIn(directory, null, fileName); + + /// + /// Tests a single candidate. Returns the full path when it exists, otherwise . + /// + private static string MatchIn(string directory, string subdirectory, string fileName) + { + try + { + if (string.IsNullOrWhiteSpace(directory)) + { + return null; + } + + string candidate = subdirectory is null + ? Path.Combine(directory, fileName) + : Path.Combine(directory, subdirectory, fileName); + + return File.Exists(candidate) ? candidate : null; + } + catch (Exception) + { + // One unusable candidate - invalid characters, an unreachable share, denied access - must + // not stop the candidates after it from being tried. + return null; + } + } +} diff --git a/src/PackageUploader.UI.Test/ViewModel/ExtractIdInformationFromValidatorLogTest.cs b/src/PackageUploader.UI.Test/ViewModel/ExtractIdInformationFromValidatorLogTest.cs index 40a44aa6..9b5a46c1 100644 --- a/src/PackageUploader.UI.Test/ViewModel/ExtractIdInformationFromValidatorLogTest.cs +++ b/src/PackageUploader.UI.Test/ViewModel/ExtractIdInformationFromValidatorLogTest.cs @@ -3,6 +3,7 @@ using Moq; using PackageUploader.ClientApi; +using PackageUploader.ClientApi.Tools; using PackageUploader.UI.Providers; using PackageUploader.UI.Utility; using PackageUploader.UI.ViewModel; @@ -28,7 +29,7 @@ public TestableValidatorLogViewModel( UploadingProgressPercentageProvider uploadingProgressPercentageProvider, ErrorModelProvider errorModelProvider, string xmlContent) - : base(packageModelProvider, uploaderService, windowService, uploadingProgressPercentageProvider, errorModelProvider, new PathConfigurationProvider()) + : base(packageModelProvider, uploaderService, windowService, uploadingProgressPercentageProvider, errorModelProvider, new PathConfigurationProvider(), new Msixvc2ToolResolver()) { _xmlContent = xmlContent; TestSubValFilePath = Path.GetTempFileName(); diff --git a/src/PackageUploader.UI.Test/ViewModel/GenerateUploaderConfigTest.cs b/src/PackageUploader.UI.Test/ViewModel/GenerateUploaderConfigTest.cs index abb3587f..d1f937bb 100644 --- a/src/PackageUploader.UI.Test/ViewModel/GenerateUploaderConfigTest.cs +++ b/src/PackageUploader.UI.Test/ViewModel/GenerateUploaderConfigTest.cs @@ -3,6 +3,7 @@ using Moq; using PackageUploader.ClientApi; +using PackageUploader.ClientApi.Tools; using PackageUploader.ClientApi.Client.Ingestion.Models; using PackageUploader.UI.Model; using PackageUploader.UI.Providers; @@ -30,7 +31,7 @@ public TestableUploaderConfigViewModel( IWindowService windowService, UploadingProgressPercentageProvider uploadingProgressPercentageProvider, ErrorModelProvider errorModelProvider) - : base(packageModelProvider, uploaderService, windowService, uploadingProgressPercentageProvider, errorModelProvider, new PathConfigurationProvider()) + : base(packageModelProvider, uploaderService, windowService, uploadingProgressPercentageProvider, errorModelProvider, new PathConfigurationProvider(), new Msixvc2ToolResolver()) { } diff --git a/src/PackageUploader.UI.Test/ViewModel/MainPageViewModelTest.cs b/src/PackageUploader.UI.Test/ViewModel/MainPageViewModelTest.cs index 0d50166f..20ea1b89 100644 --- a/src/PackageUploader.UI.Test/ViewModel/MainPageViewModelTest.cs +++ b/src/PackageUploader.UI.Test/ViewModel/MainPageViewModelTest.cs @@ -2,11 +2,17 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using PackageUploader.ClientApi.Client.Ingestion.TokenProvider.Models; +using PackageUploader.ClientApi.Tools; using PackageUploader.UI.Providers; using PackageUploader.UI.Utility; using PackageUploader.UI.View; using PackageUploader.UI.ViewModel; using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; namespace PackageUploader.UI.Test.ViewModel; @@ -18,9 +24,28 @@ public class MainPageViewModelTest private Mock _authenticationService; private Mock _windowService; private Mock> _logger; + private readonly List _tempDirectories = new(); private MainPageViewModel _mainPageViewModel; + [TestCleanup] + public void Cleanup() + { + foreach (var directory in _tempDirectories) + { + try + { + Directory.Delete(directory, recursive: true); + } + catch (IOException) + { + // Temp cleanup is best effort. + } + } + + _tempDirectories.Clear(); + } + [TestInitialize] public void Initialize() { @@ -42,10 +67,93 @@ public void Initialize() _userLoggedInProvider, _authenticationService.Object, _windowService.Object, + new Msixvc2ToolResolver(), + new ToolPathResolver(), _logger.Object ); } + [TestMethod] + public void TestToolPathsComeFromTheSharedResolver() + { + // Tool discovery is shared with the command line so both hosts resolve the same binaries. + // Injecting a stub proves the view model asks for each tool by name and stores what it gets, + // rather than searching for them itself. + var pathConfiguration = new Mock(); + var pathResolver = new StubToolPathResolver + { + Results = + { + ["MakePkg.exe"] = CreateTempFile("MakePkg.exe"), + ["SubmissionValidator.dll"] = CreateTempFile("SubmissionValidator.dll"), + ["makepkg2.exe"] = CreateTempFile("makepkg2.exe"), + } + }; + + var viewModel = new MainPageViewModel( + pathConfiguration.Object, + new UserLoggedInProvider(), + _authenticationService.Object, + _windowService.Object, + new Msixvc2ToolResolver(), + pathResolver, + _logger.Object); + + CollectionAssert.AreEquivalent( + new[] { "MakePkg.exe", "SubmissionValidator.dll", "makepkg2.exe" }, + pathResolver.RequestedFileNames.Distinct().ToArray()); + + Assert.AreEqual(pathResolver.Results["MakePkg.exe"], pathConfiguration.Object.MakePkgPath); + Assert.AreEqual(pathResolver.Results["SubmissionValidator.dll"], pathConfiguration.Object.BaseSubValPath); + Assert.AreEqual(pathResolver.Results["makepkg2.exe"], pathConfiguration.Object.MakePkg2Path); + Assert.IsTrue(viewModel.IsMakePkgEnabled); + } + + [TestMethod] + public void TestMissingToolsLeaveMakePkgDisabled() + { + // The shared resolver reports a miss as null. The view model must treat that as "not found" + // rather than storing it or throwing. + var pathConfiguration = new Mock(); + var pathResolver = new StubToolPathResolver(); + + var viewModel = new MainPageViewModel( + pathConfiguration.Object, + new UserLoggedInProvider(), + _authenticationService.Object, + _windowService.Object, + new Msixvc2ToolResolver(), + pathResolver, + _logger.Object); + + Assert.IsFalse(viewModel.IsMakePkgEnabled); + Assert.IsFalse(string.IsNullOrEmpty(viewModel.MakePkgUnavailableErrorMessage)); + } + + private string CreateTempFile(string fileName) + { + var directory = Path.Combine(Path.GetTempPath(), "MainPageVmTest_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + _tempDirectories.Add(directory); + + var path = Path.Combine(directory, fileName); + File.WriteAllText(path, string.Empty); + return path; + } + + private sealed class StubToolPathResolver : IToolPathResolver + { + public Dictionary Results { get; } = new(); + + public List RequestedFileNames { get; } = new(); + + public string Find(string fileName) + { + RequestedFileNames.Add(fileName); + return Results.TryGetValue(fileName, out var path) ? path : null; + } + } + [TestMethod] public void TestCaptureUserLoggedIn() { @@ -129,4 +237,96 @@ public void TestGetTenantsCommand() _authenticationService.VerifySet(x => x.Tenant = tenant2, Times.Once); } + #region MSIXVC2 capability probe + + private MainPageViewModel CreateViewModel(IMsixvc2ToolResolver resolver) => + new( + _pathConfigurationService.Object, + _userLoggedInProvider, + _authenticationService.Object, + _windowService.Object, + resolver, + new ToolPathResolver(), + _logger.Object); + + [TestMethod] + public async Task Msixvc2Probe_EnablesMsixvc2_WhenAToolIsResolved() + { + var resolver = new Mock(); + resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .Returns(new Msixvc2Tool(@"C:\gdk\MakePkg.exe", IsMakePkg2Fallback: false)); + + var viewModel = CreateViewModel(resolver.Object); + await viewModel.Msixvc2ProbeTask; + + Assert.IsTrue(viewModel.IsMsixvc2Enabled); + Assert.AreEqual(string.Empty, viewModel.Msixvc2UnavailableErrorMessage); + } + + [TestMethod] + public async Task Msixvc2Probe_DisablesMsixvc2AndSetsMessage_WhenNoToolIsResolved() + { + var resolver = new Mock(); + resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .Returns((Msixvc2Tool)null); + + var viewModel = CreateViewModel(resolver.Object); + await viewModel.Msixvc2ProbeTask; + + Assert.IsFalse(viewModel.IsMsixvc2Enabled); + Assert.AreEqual( + PackageUploader.UI.Resources.Strings.MainPage.MakePkg2NotFoundErrorMsg, + viewModel.Msixvc2UnavailableErrorMessage); + } + + [TestMethod] + public async Task Msixvc2Probe_DoesNotBlockTheConstructor() + { + // The probe launches a child process and can block for up to the probe timeout (twice, if + // MakePkg.exe fails and we fall back to makepkg2.exe). It must never run inline on the UI + // thread during construction. + var probeStarted = new ManualResetEventSlim(false); + var releaseProbe = new ManualResetEventSlim(false); + + var resolver = new Mock(); + resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .Returns(() => + { + probeStarted.Set(); + releaseProbe.Wait(TimeSpan.FromSeconds(30)); + return new Msixvc2Tool(@"C:\gdk\MakePkg.exe", IsMakePkg2Fallback: false); + }); + + var stopwatch = Stopwatch.StartNew(); + var viewModel = CreateViewModel(resolver.Object); + stopwatch.Stop(); + + Assert.IsTrue(probeStarted.Wait(TimeSpan.FromSeconds(10)), "The probe should have been started in the background."); + Assert.IsTrue( + stopwatch.Elapsed < TimeSpan.FromSeconds(5), + $"The constructor blocked for {stopwatch.Elapsed.TotalSeconds:F1}s waiting on the capability probe."); + + // The property keeps its safe default until the probe reports back. + Assert.IsFalse(viewModel.IsMsixvc2Enabled); + + releaseProbe.Set(); + await viewModel.Msixvc2ProbeTask; + + Assert.IsTrue(viewModel.IsMsixvc2Enabled); + } + + [TestMethod] + public async Task Msixvc2Probe_DisablesMsixvc2_WhenTheResolverThrows() + { + var resolver = new Mock(); + resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .Throws(new InvalidOperationException("boom")); + + var viewModel = CreateViewModel(resolver.Object); + await viewModel.Msixvc2ProbeTask; + + Assert.IsFalse(viewModel.IsMsixvc2Enabled); + } + + #endregion } diff --git a/src/PackageUploader.UI.Test/ViewModel/Msixvc2UploadViewModelTest.cs b/src/PackageUploader.UI.Test/ViewModel/Msixvc2UploadViewModelTest.cs index 4694cd26..120b3fb5 100644 --- a/src/PackageUploader.UI.Test/ViewModel/Msixvc2UploadViewModelTest.cs +++ b/src/PackageUploader.UI.Test/ViewModel/Msixvc2UploadViewModelTest.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; using Moq; using PackageUploader.ClientApi; +using PackageUploader.ClientApi.Tools; using PackageUploader.UI.Providers; using PackageUploader.UI.Utility; using PackageUploader.UI.View; @@ -20,6 +21,7 @@ public class Msixvc2UploadViewModelTest private ErrorModelProvider _errorModelProvider; private PathConfigurationProvider _pathConfigurationProvider; private PackageModelProvider _packageModelProvider; + private IMsixvc2ToolResolver _msixvc2ToolResolver; private Msixvc2UploadViewModel _viewModel; @@ -32,6 +34,7 @@ public void Setup() _errorModelProvider = new ErrorModelProvider(); _pathConfigurationProvider = new PathConfigurationProvider(); _packageModelProvider = new PackageModelProvider(); + _msixvc2ToolResolver = new Msixvc2ToolResolver(); _viewModel = new Msixvc2UploadViewModel( _mockWindowService.Object, @@ -39,7 +42,8 @@ public void Setup() _mockLogger.Object, _errorModelProvider, _pathConfigurationProvider, - _packageModelProvider + _packageModelProvider, + _msixvc2ToolResolver ); } @@ -503,4 +507,177 @@ public void BuildUploadArguments_FlightPath_IncludesUploadSource() } #endregion + + #region MakePkg.exe / makepkg2.exe resolution order + + private static string WriteTempScript(string name, string content) + { + string path = Path.Combine(Path.GetTempPath(), $"{name}_{Guid.NewGuid():N}.bat"); + File.WriteAllText(path, content); + return path; + } + + [TestMethod] + public void ResolveMsixvc2Tool_PrefersMakePkg_WhenItSupportsUploadSource() + { + string makePkg = WriteTempScript("makepkg_new", "@exit /b 0"); + string makePkg2 = WriteTempScript("makepkg2_new", "@exit /b 0"); + try + { + _pathConfigurationProvider.MakePkgPath = makePkg; + _pathConfigurationProvider.MakePkg2Path = makePkg2; + + var tool = _viewModel.ResolveMsixvc2Tool(); + + Assert.IsNotNull(tool, "New MakePkg.exe supporting the verb must resolve"); + Assert.AreEqual(makePkg, tool.ExecutablePath, "MakePkg.exe must win over makepkg2.exe"); + Assert.IsFalse(tool.IsMakePkg2Fallback, "Must not be flagged as a makepkg2 fallback"); + } + finally + { + File.Delete(makePkg); + File.Delete(makePkg2); + } + } + + [TestMethod] + public void ResolveMsixvc2Tool_FallsBackToMakePkg2_WhenLegacyMakePkgFailsProbe() + { + // Legacy MakePkg.exe doesn't understand "supports uploadsource" and exits non-zero. + string makePkg = WriteTempScript("makepkg_legacy", + "@echo Unrecognized command or argument 'supports'. 1>&2\r\n@exit /b 1"); + string makePkg2 = WriteTempScript("makepkg2_ok", "@exit /b 0"); + try + { + _pathConfigurationProvider.MakePkgPath = makePkg; + _pathConfigurationProvider.MakePkg2Path = makePkg2; + + var tool = _viewModel.ResolveMsixvc2Tool(); + + Assert.IsNotNull(tool, "makepkg2.exe fallback must resolve when MakePkg.exe is legacy"); + Assert.AreEqual(makePkg2, tool.ExecutablePath); + Assert.IsTrue(tool.IsMakePkg2Fallback, "Must be flagged as a makepkg2 fallback"); + } + finally + { + File.Delete(makePkg); + File.Delete(makePkg2); + } + } + + [TestMethod] + public void ResolveMsixvc2Tool_ReturnsNull_WhenBothToolsFailProbe() + { + string makePkg = WriteTempScript("makepkg_legacy2", "@exit /b 1"); + string makePkg2 = WriteTempScript("makepkg2_legacy2", "@exit /b 1"); + try + { + _pathConfigurationProvider.MakePkgPath = makePkg; + _pathConfigurationProvider.MakePkg2Path = makePkg2; + + Assert.IsNull(_viewModel.ResolveMsixvc2Tool(), "MSIXVC2 must be unavailable when both probes fail"); + Assert.IsFalse(_viewModel.SupportsUploadSourceFlag()); + } + finally + { + File.Delete(makePkg); + File.Delete(makePkg2); + } + } + + [TestMethod] + public void ResolveMsixvc2Tool_ReturnsNull_WhenBothToolsMissing() + { + _pathConfigurationProvider.MakePkgPath = @"C:\nonexistent\MakePkg.exe"; + _pathConfigurationProvider.MakePkg2Path = @"C:\nonexistent\makepkg2.exe"; + + Assert.IsNull(_viewModel.ResolveMsixvc2Tool()); + } + + [TestMethod] + public void ResolveMsixvc2Tool_FallsBackToMakePkg2_WhenMakePkgTimesOut() + { + string makePkg = WriteTempScript("makepkg_hang", "@ping -n 30 127.0.0.1 > nul"); + string makePkg2 = WriteTempScript("makepkg2_after_hang", "@exit /b 0"); + try + { + _pathConfigurationProvider.MakePkgPath = makePkg; + _pathConfigurationProvider.MakePkg2Path = makePkg2; + + var tool = _viewModel.ResolveMsixvc2Tool(); + + Assert.IsNotNull(tool, "A hung MakePkg.exe probe must time out and fall through to makepkg2.exe"); + Assert.AreEqual(makePkg2, tool.ExecutablePath); + Assert.IsTrue(tool.IsMakePkg2Fallback); + } + finally + { + File.Delete(makePkg); + File.Delete(makePkg2); + } + } + + [TestMethod] + public void ResolveMsixvc2Tool_FallsBackToMakePkg2_WhenMakePkgMissing() + { + string makePkg2 = WriteTempScript("makepkg2_only", "@exit /b 0"); + try + { + _pathConfigurationProvider.MakePkgPath = string.Empty; + _pathConfigurationProvider.MakePkg2Path = makePkg2; + + var tool = _viewModel.ResolveMsixvc2Tool(); + + Assert.IsNotNull(tool); + Assert.AreEqual(makePkg2, tool.ExecutablePath); + Assert.IsTrue(tool.IsMakePkg2Fallback); + } + finally + { + File.Delete(makePkg2); + } + } + + [TestMethod] + public void ResolveMsixvc2Tool_ReturnsNull_WhenMakePkgPathIsDirectory() + { + string dirPath = Path.Combine(Path.GetTempPath(), $"makepkg_dir_{Guid.NewGuid():N}"); + Directory.CreateDirectory(dirPath); + try + { + _pathConfigurationProvider.MakePkgPath = dirPath; + _pathConfigurationProvider.MakePkg2Path = dirPath; + + Assert.IsNull(_viewModel.ResolveMsixvc2Tool(), + "A directory must never be treated as a packaging tool"); + } + finally + { + Directory.Delete(dirPath); + } + } + + [TestMethod] + public void BuildUploadArguments_IncludesUploadSource_WhenOnlyNewMakePkgAvailable() + { + string makePkg = WriteTempScript("makepkg_args", "@exit /b 0"); + try + { + _pathConfigurationProvider.MakePkgPath = makePkg; + _pathConfigurationProvider.MakePkg2Path = string.Empty; + _viewModel.ContentPath = @"C:\game\content"; + _viewModel.BranchOrFlightDisplayName = "Branch: Main"; + _viewModel.MarketGroupName = "default"; + + string args = _viewModel.BuildUploadArguments(); + + Assert.IsTrue(args.Contains("/uploadsource XGPM")); + } + finally + { + File.Delete(makePkg); + } + } + + #endregion } diff --git a/src/PackageUploader.UI.Test/ViewModel/PackageCreationViewModelTest.cs b/src/PackageUploader.UI.Test/ViewModel/PackageCreationViewModelTest.cs index f28f1a87..418b703b 100644 --- a/src/PackageUploader.UI.Test/ViewModel/PackageCreationViewModelTest.cs +++ b/src/PackageUploader.UI.Test/ViewModel/PackageCreationViewModelTest.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; +using PackageUploader.ClientApi.Tools; using PackageUploader.UI.Model; using PackageUploader.UI.Providers; using PackageUploader.UI.Utility; @@ -64,7 +65,8 @@ public void TestInitialize() _progressProvider, _mockLogger.Object, _errorModelProvider, - _validatorResultsProvider + _validatorResultsProvider, + new Msixvc2ToolResolver() ); // mock files @@ -829,6 +831,96 @@ public void PopulateSubValArgs_NoAutoUpdateWithEmptySubValPath_ReturnsTrue() Assert.AreEqual(string.Empty, _viewModel.SubValDllError); } + #region MSIXVC2 capability probe + + private PackageCreationViewModel CreateViewModel(IMsixvc2ToolResolver resolver) => + new( + _packageModelProvider, + _pathConfigProvider, + _mockWindowService.Object, + _progressProvider, + _mockLogger.Object, + _errorModelProvider, + _validatorResultsProvider, + resolver); + + [TestMethod] + public async Task Msixvc2Probe_SetsIsMsixvc2Available_WhenAToolIsResolved() + { + var resolver = new Mock(); + resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .Returns(new Msixvc2Tool(@"C:\gdk\MakePkg.exe", IsMakePkg2Fallback: false)); + + var viewModel = CreateViewModel(resolver.Object); + await viewModel.Msixvc2ProbeTask; + + Assert.IsTrue(viewModel.IsMsixvc2Available); + } + + [TestMethod] + public async Task Msixvc2Probe_ClearsIsMsixvc2Available_WhenNoToolIsResolved() + { + var resolver = new Mock(); + resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .Returns((Msixvc2Tool)null); + + var viewModel = CreateViewModel(resolver.Object); + await viewModel.Msixvc2ProbeTask; + + Assert.IsFalse(viewModel.IsMsixvc2Available); + } + + [TestMethod] + public async Task Msixvc2Probe_DoesNotBlockTheConstructor() + { + // The probe launches a child process and can block for up to the probe timeout (twice, + // if MakePkg.exe fails and we fall back to makepkg2.exe). It must never run inline on + // the UI thread while the packaging page is being constructed. + var probeStarted = new ManualResetEventSlim(false); + var releaseProbe = new ManualResetEventSlim(false); + + var resolver = new Mock(); + resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .Returns(() => + { + probeStarted.Set(); + releaseProbe.Wait(TimeSpan.FromSeconds(30)); + return new Msixvc2Tool(@"C:\gdk\MakePkg.exe", IsMakePkg2Fallback: false); + }); + + var stopwatch = Stopwatch.StartNew(); + var viewModel = CreateViewModel(resolver.Object); + stopwatch.Stop(); + + Assert.IsTrue(probeStarted.Wait(TimeSpan.FromSeconds(10)), "The probe should have been started in the background."); + Assert.IsTrue( + stopwatch.Elapsed < TimeSpan.FromSeconds(5), + $"The constructor blocked for {stopwatch.Elapsed.TotalSeconds:F1}s waiting on the capability probe."); + + // The property keeps its safe default until the probe reports back. + Assert.IsFalse(viewModel.IsMsixvc2Available); + + releaseProbe.Set(); + await viewModel.Msixvc2ProbeTask; + + Assert.IsTrue(viewModel.IsMsixvc2Available); + } + + [TestMethod] + public async Task Msixvc2Probe_ClearsIsMsixvc2Available_WhenTheResolverThrows() + { + var resolver = new Mock(); + resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .Throws(new InvalidOperationException("boom")); + + var viewModel = CreateViewModel(resolver.Object); + await viewModel.Msixvc2ProbeTask; + + Assert.IsFalse(viewModel.IsMsixvc2Available); + } + + #endregion + [TestCleanup] public void Cleanup() { diff --git a/src/PackageUploader.UI.Test/ViewModel/PackageUploadViewModelTest.cs b/src/PackageUploader.UI.Test/ViewModel/PackageUploadViewModelTest.cs index 215b2f20..25d61d8b 100644 --- a/src/PackageUploader.UI.Test/ViewModel/PackageUploadViewModelTest.cs +++ b/src/PackageUploader.UI.Test/ViewModel/PackageUploadViewModelTest.cs @@ -3,13 +3,16 @@ using PackageUploader.ClientApi; using PackageUploader.ClientApi.Client.Ingestion.Models; using PackageUploader.ClientApi.Models; +using PackageUploader.ClientApi.Tools; using PackageUploader.UI.Model; using PackageUploader.UI.Providers; using PackageUploader.UI.Utility; using PackageUploader.UI.View; using PackageUploader.UI.ViewModel; using System; +using System.Diagnostics; using System.IO; +using System.Reflection; using System.Threading.Tasks; using System.Windows.Input; using PackageUploader.ClientApi.Client.Ingestion; @@ -64,7 +67,8 @@ public void Setup() _mockWindowService.Object, _uploadingProgressPercentageProvider, _errorModelProvider, - new PathConfigurationProvider() + new PathConfigurationProvider(), + new Msixvc2ToolResolver() ); } @@ -89,7 +93,8 @@ public void Test_BranchOrFlightDisplayName() _mockWindowService.Object, _uploadingProgressPercentageProvider, _errorModelProvider, - new PathConfigurationProvider() + new PathConfigurationProvider(), + new Msixvc2ToolResolver() ); viewModel2.BranchOrFlightDisplayName = "Test"; Assert.AreEqual("Test", viewModel2.BranchOrFlightDisplayName); @@ -127,7 +132,8 @@ public void Test_BranchAndFlightNames() _mockWindowService.Object, _uploadingProgressPercentageProvider, _errorModelProvider, - new PathConfigurationProvider() + new PathConfigurationProvider(), + new Msixvc2ToolResolver() ); viewModel2.BranchAndFlightNames = names; // tests the former value is successfully retrieved @@ -150,7 +156,8 @@ public void Test_MarketGroupNames() _mockWindowService.Object, _uploadingProgressPercentageProvider, _errorModelProvider, - new PathConfigurationProvider() + new PathConfigurationProvider(), + new Msixvc2ToolResolver() ); viewModel2.MarketGroupNames = names; // tests the former value is successfully retrieved @@ -395,5 +402,200 @@ public void Test_ProcessSelectedPackage() { // very important to test this for user input shenanigans } + + #region MSIXVC2 capability probe on package selection + + private PackageUploadViewModel CreateViewModel(IMsixvc2ToolResolver resolver) => + new( + _packageModelProvider, + _mockPackageUploaderService.Object, + _mockWindowService.Object, + _uploadingProgressPercentageProvider, + _errorModelProvider, + new PathConfigurationProvider(), + resolver); + + /// + /// Writes a file that XvcFile.IsLikelyMsixvc2Package recognises: a .msixvc at least + /// FirstReadSize (4096) bytes long starting with the ZIP local file header. + /// + private static string WriteFakeMsixvc2Package() + { + string path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".msixvc"); + var bytes = new byte[8192]; + bytes[0] = 0x50; bytes[1] = 0x4B; bytes[2] = 0x03; bytes[3] = 0x04; + File.WriteAllBytes(path, bytes); + return path; + } + + [TestMethod] + public async Task Msixvc2Probe_DoesNotBlockPackageSelection() + { + // Selecting a package must not freeze the UI while the capability probe runs. The probe + // launches a child process and can block for up to the probe timeout, twice if + // MakePkg.exe fails and we fall back to makepkg2.exe. + var probeStarted = new ManualResetEventSlim(false); + var releaseProbe = new ManualResetEventSlim(false); + + var resolver = new Mock(); + resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .Returns(() => + { + probeStarted.Set(); + releaseProbe.Wait(TimeSpan.FromSeconds(30)); + return new Msixvc2Tool(@"C:\gdk\MakePkg.exe", IsMakePkg2Fallback: false); + }); + + var viewModel = CreateViewModel(resolver.Object); + string packagePath = WriteFakeMsixvc2Package(); + + try + { + var stopwatch = Stopwatch.StartNew(); + viewModel.PackageFilePath = packagePath; + stopwatch.Stop(); + + Assert.IsTrue(viewModel.IsMsixvc2Package, "The fake package should be detected as MSIXVC2."); + Assert.IsTrue(probeStarted.Wait(TimeSpan.FromSeconds(10)), "The probe should have been started in the background."); + Assert.IsTrue( + stopwatch.Elapsed < TimeSpan.FromSeconds(5), + $"Package selection blocked for {stopwatch.Elapsed.TotalSeconds:F1}s waiting on the capability probe."); + + releaseProbe.Set(); + await viewModel.Msixvc2ProbeTask; + + Assert.AreEqual(string.Empty, viewModel.Msixvc2UnavailableMessage); + } + finally + { + releaseProbe.Set(); + File.Delete(packagePath); + } + } + + [TestMethod] + public async Task Msixvc2Probe_SetsUnavailableMessageAndBlocksUpload_WhenNoToolIsResolved() + { + var resolver = new Mock(); + resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .Returns((Msixvc2Tool)null); + + var viewModel = CreateViewModel(resolver.Object); + string packagePath = WriteFakeMsixvc2Package(); + + try + { + bool canExecuteRaised = false; + viewModel.UploadPackageCommand.CanExecuteChanged += (s, e) => canExecuteRaised = true; + + viewModel.PackageFilePath = packagePath; + await viewModel.Msixvc2ProbeTask; + + Assert.AreEqual( + PackageUploader.UI.Resources.Strings.MainPage.MakePkg2NotFoundErrorMsg, + viewModel.Msixvc2UnavailableMessage); + + // IsUploadReady() gates on the message, so the probe result must block the upload. + // + // Note: RelayCommand routes CanExecuteChanged through WPF's + // CommandManager.RequerySuggested, which only fires on a running dispatcher, so the + // event itself can't be observed in a unit test host. CanExecute is the real + // contract, so assert on that; canExecuteRaised is left in only to document that + // the event is deliberately not asserted here. + _ = canExecuteRaised; + Assert.IsFalse(viewModel.UploadPackageCommand.CanExecute(null)); + } + finally + { + File.Delete(packagePath); + } + } + + [TestMethod] + public void Msixvc2Probe_UploadClickedWhileProbeInFlight_ShowsErrorAndDoesNotLaunchTool() + { + // A user can click Upload after selecting a package but before the probe completes, + // while Msixvc2UnavailableMessage is still empty. StartMsixvc2Upload() re-resolves + // synchronously, so that click must produce a clean error rather than launching a + // missing tool or crashing. + var releaseProbe = new ManualResetEventSlim(false); + var probeStarted = new ManualResetEventSlim(false); + + var resolver = new Mock(); + resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny())) + .Returns(() => + { + // Only the background probe blocks; the synchronous re-resolve on the upload + // path returns "no tool" immediately. + if (!probeStarted.IsSet) + { + probeStarted.Set(); + releaseProbe.Wait(TimeSpan.FromSeconds(30)); + } + + return null; + }); + + var viewModel = CreateViewModel(resolver.Object); + string packagePath = WriteFakeMsixvc2Package(); + + try + { + viewModel.PackageFilePath = packagePath; + + Assert.IsTrue(probeStarted.Wait(TimeSpan.FromSeconds(10))); + Assert.IsTrue(viewModel.IsMsixvc2Package); + + // Probe still in flight: the message hasn't been published yet. + Assert.AreEqual(string.Empty, viewModel.Msixvc2UnavailableMessage); + + // Put the view model into an otherwise upload-ready state so the command is + // genuinely clickable, which is what makes this race reachable in the real app. + typeof(PackageUploadViewModel) + .GetField("_gameProduct", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(viewModel, new GameProduct()); + viewModel.MarketGroupName = "default"; + + Assert.IsTrue( + viewModel.UploadPackageCommand.CanExecute(null), + "Upload should be clickable while the probe is still in flight - that's the race being tested."); + + // Take the click's synchronous branch directly. UploadPackageProcessAsync is + // 'async void', so an exception from its WPF navigation (Application.Current is null + // in a unit test host) would be rethrown on the thread pool and kill the test + // process rather than surface here. StartMsixvc2Upload is the whole MSIXVC2 branch + // of that method, so invoking it directly tests the same path safely. + var startMsixvc2Upload = typeof(PackageUploadViewModel) + .GetMethod("StartMsixvc2Upload", BindingFlags.NonPublic | BindingFlags.Instance)!; + + try + { + startMsixvc2Upload.Invoke(viewModel, null); + } + catch (TargetInvocationException ex) when (ex.InnerException is NullReferenceException) + { + // SetErrorAndGoToErrorPage navigates via System.Windows.Application.Current, + // which doesn't exist in a unit test host. The error state it sets beforehand is + // still observable, and that's what matters here. + } + + // The tool was re-resolved synchronously, came back missing, and the upload was + // abandoned: an error was raised, no MSIXVC2 tool path was handed to the uploader, + // and we never navigated to the uploading screen. + Assert.AreEqual("MSIXVC2 packaging tool not found", _errorModelProvider.Error.MainMessage); + Assert.AreEqual( + PackageUploader.UI.Resources.Strings.MainPage.MakePkg2NotFoundErrorMsg, + _errorModelProvider.Error.DetailMessage); + _mockWindowService.Verify(x => x.NavigateTo(typeof(Msixvc2UploadingView)), Times.Never); + Assert.IsTrue(string.IsNullOrEmpty(_packageModelProvider.Package.Msixvc2ToolPath)); + } + finally + { + releaseProbe.Set(); + File.Delete(packagePath); + } + } + + #endregion } } \ No newline at end of file diff --git a/src/PackageUploader.UI.Test/ViewModel/TestablePackageUploadViewModel.cs b/src/PackageUploader.UI.Test/ViewModel/TestablePackageUploadViewModel.cs index 3bb82cc5..32eb38d0 100644 --- a/src/PackageUploader.UI.Test/ViewModel/TestablePackageUploadViewModel.cs +++ b/src/PackageUploader.UI.Test/ViewModel/TestablePackageUploadViewModel.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using PackageUploader.ClientApi; +using PackageUploader.ClientApi.Tools; using PackageUploader.UI.Model; using PackageUploader.UI.Providers; using PackageUploader.UI.Utility; @@ -43,7 +44,7 @@ public TestablePackageUploadViewModel( IWindowService windowService, UploadingProgressPercentageProvider uploadingProgressPercentageProvider, ErrorModelProvider errorModelProvider) - : base(packageModelProvider, uploaderService, windowService, uploadingProgressPercentageProvider, errorModelProvider, new PathConfigurationProvider()) + : base(packageModelProvider, uploaderService, windowService, uploadingProgressPercentageProvider, errorModelProvider, new PathConfigurationProvider(), new Msixvc2ToolResolver()) { } diff --git a/src/PackageUploader.UI/App.xaml.cs b/src/PackageUploader.UI/App.xaml.cs index 7c97f398..52dedbfc 100644 --- a/src/PackageUploader.UI/App.xaml.cs +++ b/src/PackageUploader.UI/App.xaml.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Win32; using PackageUploader.ClientApi; +using PackageUploader.ClientApi.Tools; using PackageUploader.FileLogger; using PackageUploader.UI.Providers; using PackageUploader.UI.Utility; @@ -46,6 +47,7 @@ public App() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddMsixvc2ToolResolver(); // Register providers services.AddSingleton(); diff --git a/src/PackageUploader.UI/Model/PackageModel.cs b/src/PackageUploader.UI/Model/PackageModel.cs index 067bfcbf..47d7ef3e 100644 --- a/src/PackageUploader.UI/Model/PackageModel.cs +++ b/src/PackageUploader.UI/Model/PackageModel.cs @@ -26,6 +26,7 @@ public class PackageModel public string PackageIdentityName { get; set; } = string.Empty; public string FolderSize { get; set; } = string.Empty; public string UploadArguments { get; set; } = string.Empty; - public string MakePkg2Path { get; set; } = string.Empty; + /// Resolved MSIXVC2 packaging tool (MakePkg.exe, or makepkg2.exe as fallback). + public string Msixvc2ToolPath { get; set; } = string.Empty; public Type? UploadOriginPage { get; set; } = null; } diff --git a/src/PackageUploader.UI/Resources/Strings/MainPage.Designer.cs b/src/PackageUploader.UI/Resources/Strings/MainPage.Designer.cs index 4a0ab522..7b8656e6 100644 --- a/src/PackageUploader.UI/Resources/Strings/MainPage.Designer.cs +++ b/src/PackageUploader.UI/Resources/Strings/MainPage.Designer.cs @@ -178,7 +178,7 @@ public static string MakePackageNotFoundErrorMsg { } /// - /// Looks up a localized string similar to MSIXVC2 upload requires makepkg2 tools, available as a preview in the April 2026 GDK. Install the April 2026 GDK to get started.. + /// Looks up a localized string similar to MSIXVC2 requires a packaging tool that supports it. Install the latest GDK to get MakePkg.exe with MSIXVC2 support.. /// public static string MakePkg2NotFoundErrorMsg { get { diff --git a/src/PackageUploader.UI/Resources/Strings/MainPage.resx b/src/PackageUploader.UI/Resources/Strings/MainPage.resx index fd6e9d53..555d992a 100644 --- a/src/PackageUploader.UI/Resources/Strings/MainPage.resx +++ b/src/PackageUploader.UI/Resources/Strings/MainPage.resx @@ -214,6 +214,6 @@ Learn more: https://aka.ms/MSIXVC2 - MSIXVC2 upload requires makepkg2 tools, available as a preview in the April 2026 GDK. Install the April 2026 GDK to get started. + MSIXVC2 requires a packaging tool that supports it. Install the latest GDK to get MakePkg.exe with MSIXVC2 support. \ No newline at end of file diff --git a/src/PackageUploader.UI/View/MainPageView.xaml b/src/PackageUploader.UI/View/MainPageView.xaml index 66aec8b6..d06efee7 100644 --- a/src/PackageUploader.UI/View/MainPageView.xaml +++ b/src/PackageUploader.UI/View/MainPageView.xaml @@ -273,14 +273,14 @@