From ca6e44d496b0c589fdaabad69a00643f539c47cd Mon Sep 17 00:00:00 2001 From: Bob Arnson Date: Fri, 14 Feb 2025 22:29:39 -0500 Subject: Convert ext\ to MSTest and traversal projects. - Move ext\ unit tests to MSTest. - MSBuildify ext projects with MSTest execution. - Fork test support projects for MSTest: - WixInternal.TestSupport - WixInternal.Core.TestPackage --- .../SetBuildNumber/Directory.Packages.props.pp | 6 + src/internal/SetBuildNumber/global.json.pp | 3 +- src/internal/WixInternal.MSTestSupport/Builder.cs | 204 +++++++++++ .../DisposableFileSystem.cs | 94 ++++++ .../WixInternal.MSTestSupport/DotnetRunner.cs | 57 ++++ .../ExternalExecutable.cs | 374 +++++++++++++++++++++ .../ExternalExecutableResult.cs | 19 ++ .../WixInternal.MSTestSupport/FakeBuildEngine.cs | 33 ++ .../WixInternal.MSTestSupport/MsbuildRunner.cs | 112 ++++++ .../MsbuildRunnerResult.cs | 19 ++ .../WixInternal.MSTestSupport/MsbuildUtilities.cs | 99 ++++++ src/internal/WixInternal.MSTestSupport/Pushd.cs | 46 +++ src/internal/WixInternal.MSTestSupport/Query.cs | 207 ++++++++++++ .../WixInternal.MSTestSupport/RobocopyRunner.cs | 16 + src/internal/WixInternal.MSTestSupport/TestData.cs | 78 +++++ .../TestDataFolderFileSystem.cs | 42 +++ .../WixInternal.MSTestSupport/VswhereRunner.cs | 41 +++ .../WixInternal.MSTestSupport/WixAssert.cs | 164 +++++++++ .../WixInternal.MSTestSupport.csproj | 27 ++ src/internal/WixInternal.TestSupport/WixAssert.cs | 143 ++++++++ .../XunitExtensions/WixAssert.cs | 26 ++ src/internal/internal.sln | 36 ++ src/internal/internal_t.proj | 1 + 23 files changed, 1846 insertions(+), 1 deletion(-) create mode 100644 src/internal/WixInternal.MSTestSupport/Builder.cs create mode 100644 src/internal/WixInternal.MSTestSupport/DisposableFileSystem.cs create mode 100644 src/internal/WixInternal.MSTestSupport/DotnetRunner.cs create mode 100644 src/internal/WixInternal.MSTestSupport/ExternalExecutable.cs create mode 100644 src/internal/WixInternal.MSTestSupport/ExternalExecutableResult.cs create mode 100644 src/internal/WixInternal.MSTestSupport/FakeBuildEngine.cs create mode 100644 src/internal/WixInternal.MSTestSupport/MsbuildRunner.cs create mode 100644 src/internal/WixInternal.MSTestSupport/MsbuildRunnerResult.cs create mode 100644 src/internal/WixInternal.MSTestSupport/MsbuildUtilities.cs create mode 100644 src/internal/WixInternal.MSTestSupport/Pushd.cs create mode 100644 src/internal/WixInternal.MSTestSupport/Query.cs create mode 100644 src/internal/WixInternal.MSTestSupport/RobocopyRunner.cs create mode 100644 src/internal/WixInternal.MSTestSupport/TestData.cs create mode 100644 src/internal/WixInternal.MSTestSupport/TestDataFolderFileSystem.cs create mode 100644 src/internal/WixInternal.MSTestSupport/VswhereRunner.cs create mode 100644 src/internal/WixInternal.MSTestSupport/WixAssert.cs create mode 100644 src/internal/WixInternal.MSTestSupport/WixInternal.MSTestSupport.csproj create mode 100644 src/internal/WixInternal.TestSupport/WixAssert.cs (limited to 'src/internal') diff --git a/src/internal/SetBuildNumber/Directory.Packages.props.pp b/src/internal/SetBuildNumber/Directory.Packages.props.pp index 8cf271d7..18a9b5eb 100644 --- a/src/internal/SetBuildNumber/Directory.Packages.props.pp +++ b/src/internal/SetBuildNumber/Directory.Packages.props.pp @@ -17,6 +17,7 @@ + @@ -37,6 +38,7 @@ + @@ -97,6 +99,10 @@ + + + + diff --git a/src/internal/SetBuildNumber/global.json.pp b/src/internal/SetBuildNumber/global.json.pp index bad3c0bd..fe48e6cc 100644 --- a/src/internal/SetBuildNumber/global.json.pp +++ b/src/internal/SetBuildNumber/global.json.pp @@ -1,6 +1,7 @@ { "msbuild-sdks": { - "Microsoft.Build.Traversal": "3.2.0", + "MSTest.Sdk": "3.8.0", + "Microsoft.Build.Traversal": "4.1.82", "Microsoft.Build.NoTargets": "3.5.6", "WixToolset.Sdk": "{packageversion}" }, diff --git a/src/internal/WixInternal.MSTestSupport/Builder.cs b/src/internal/WixInternal.MSTestSupport/Builder.cs new file mode 100644 index 00000000..62f2891b --- /dev/null +++ b/src/internal/WixInternal.MSTestSupport/Builder.cs @@ -0,0 +1,204 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. + +namespace WixInternal.MSTestSupport +{ + using System; + using System.Collections.Generic; + using System.IO; + + public class Builder + { + public Builder(string sourceFolder, Type extensionType = null, string[] bindPaths = null, string outputFile = null) + { + this.SourceFolder = sourceFolder; + if (extensionType != null) + { + this.ExtensionTypes = new Type[] { extensionType }; + } + else + { + this.ExtensionTypes = new Type[] { }; + } + this.BindPaths = bindPaths; + this.OutputFile = outputFile ?? "test.msi"; + } + + public Builder(string sourceFolder, Type[] extensionTypes, string[] bindPaths = null, string outputFile = null) + { + this.SourceFolder = sourceFolder; + this.ExtensionTypes = extensionTypes; + this.BindPaths = bindPaths; + this.OutputFile = outputFile ?? "test.msi"; + } + + public string[] BindPaths { get; set; } + + public Type[] ExtensionTypes { get; set; } + + public string OutputFile { get; set; } + + public string SourceFolder { get; } + + public string[] BuildAndQuery(Action buildFunc, params string[] tables) + { + return this.BuildAndQuery(buildFunc, validate: false, tables); + } + + public string[] BuildAndQuery(Action buildFunc, bool validate, params string[] tables) + { + var sourceFiles = Directory.GetFiles(this.SourceFolder, "*.wxs"); + var wxlFiles = Directory.GetFiles(this.SourceFolder, "*.wxl"); + + using (var fs = new DisposableFileSystem()) + { + var intermediateFolder = fs.GetFolder(); + var outputPath = Path.Combine(intermediateFolder, "bin", this.OutputFile); + + var args = new List + { + "build", + "-o", outputPath, + "-intermediateFolder", intermediateFolder, + }; + + foreach (var ext in this.ExtensionTypes) + { + args.Add("-ext"); + args.Add(Path.GetFullPath(ext.Assembly.Location)); + } + + args.AddRange(sourceFiles); + + foreach (var wxlFile in wxlFiles) + { + args.Add("-loc"); + args.Add(wxlFile); + } + + foreach (var bindPath in this.BindPaths) + { + args.Add("-bindpath"); + args.Add(bindPath); + } + + buildFunc(args.ToArray()); + + if (validate) + { + args = new List + { + "msi", + "validate", + "-intermediateFolder", intermediateFolder, + outputPath, + }; + + buildFunc(args.ToArray()); + } + + return Query.QueryDatabase(outputPath, tables); + } + } + + public void BuildAndDecompileAndBuild(Action buildFunc, Action decompileFunc, string decompilePath, bool validate = false) + { + var sourceFiles = Directory.GetFiles(this.SourceFolder, "*.wxs"); + var wxlFiles = Directory.GetFiles(this.SourceFolder, "*.wxl"); + + using (var fs = new DisposableFileSystem()) + { + var intermediateFolder = fs.GetFolder(); + var outputFolder = Path.Combine(intermediateFolder, "bin"); + var decompileExtractFolder = Path.Combine(intermediateFolder, "decompiled", "extract"); + var decompileIntermediateFolder = Path.Combine(intermediateFolder, "decompiled", "obj"); + var decompileBuildFolder = Path.Combine(intermediateFolder, "decompiled", "bin"); + var outputPath = Path.Combine(outputFolder, this.OutputFile); + var decompileBuildPath = Path.Combine(decompileBuildFolder, this.OutputFile); + + // First build. + var firstBuildArgs = new List + { + "build", + "-o", outputPath, + "-intermediateFolder", intermediateFolder, + }; + + foreach (var ext in this.ExtensionTypes) + { + firstBuildArgs.Add("-ext"); + firstBuildArgs.Add(Path.GetFullPath(ext.Assembly.Location)); + } + + firstBuildArgs.AddRange(sourceFiles); + + foreach (var wxlFile in wxlFiles) + { + firstBuildArgs.Add("-loc"); + firstBuildArgs.Add(wxlFile); + } + + foreach (var bindPath in this.BindPaths) + { + firstBuildArgs.Add("-bindpath"); + firstBuildArgs.Add(bindPath); + } + + buildFunc(firstBuildArgs.ToArray()); + + if (validate) + { + firstBuildArgs = new List + { + "msi", + "validate", + "-intermediateFolder", intermediateFolder, + outputPath, + }; + + buildFunc(firstBuildArgs.ToArray()); + } + + // Decompile built output. + var decompileArgs = new List + { + "msi", "decompile", + outputPath, + "-intermediateFolder", decompileIntermediateFolder, + "-x", decompileExtractFolder, + "-o", decompilePath + }; + + foreach (var ext in this.ExtensionTypes) + { + decompileArgs.Add("-ext"); + decompileArgs.Add(Path.GetFullPath(ext.Assembly.Location)); + } + + decompileFunc(decompileArgs.ToArray()); + + // Build decompiled output. + var secondBuildArgs = new List + { + "build", + decompilePath, + "-o", decompileBuildPath, + "-intermediateFolder", decompileIntermediateFolder + }; + + foreach (var ext in this.ExtensionTypes) + { + secondBuildArgs.Add("-ext"); + secondBuildArgs.Add(Path.GetFullPath(ext.Assembly.Location)); + } + + secondBuildArgs.Add("-bindpath"); + secondBuildArgs.Add(outputFolder); + + secondBuildArgs.Add("-bindpath"); + secondBuildArgs.Add(decompileExtractFolder); + + buildFunc(secondBuildArgs.ToArray()); + } + } + } +} diff --git a/src/internal/WixInternal.MSTestSupport/DisposableFileSystem.cs b/src/internal/WixInternal.MSTestSupport/DisposableFileSystem.cs new file mode 100644 index 00000000..5e4bb23f --- /dev/null +++ b/src/internal/WixInternal.MSTestSupport/DisposableFileSystem.cs @@ -0,0 +1,94 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. + +namespace WixInternal.MSTestSupport +{ + using System; + using System.Collections.Generic; + using System.IO; + + public class DisposableFileSystem : IDisposable + { + protected bool Disposed { get; private set; } + + private List CleanupPaths { get; } = new List(); + + public bool Keep { get; } + + public DisposableFileSystem(bool keep = false) + { + this.Keep = keep; + } + + protected string GetFile(bool create = false) + { + var path = Path.GetTempFileName(); + + if (!create) + { + File.Delete(path); + } + + this.CleanupPaths.Add(path); + + return path; + } + + public string GetFolder(bool create = false) + { + // Always return a path with a space in it. + var path = Path.Combine(Path.GetTempPath(), ".WIXTEST " + Path.GetRandomFileName()); + + if (create) + { + Directory.CreateDirectory(path); + } + + this.CleanupPaths.Add(path); + + return path; + } + + + #region // IDisposable + + public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (this.Disposed) + { + return; + } + + if (disposing && !this.Keep) + { + foreach (var path in this.CleanupPaths) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + else if (Directory.Exists(path)) + { + Directory.Delete(path, true); + } + } + catch + { + // Best effort delete, so ignore any failures. + } + } + } + + this.Disposed = true; + } + + #endregion + } +} diff --git a/src/internal/WixInternal.MSTestSupport/DotnetRunner.cs b/src/internal/WixInternal.MSTestSupport/DotnetRunner.cs new file mode 100644 index 00000000..8fa3a739 --- /dev/null +++ b/src/internal/WixInternal.MSTestSupport/DotnetRunner.cs @@ -0,0 +1,57 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. + +namespace WixInternal.MSTestSupport +{ + using System; + using System.Collections.Generic; + using System.IO; + + public class DotnetRunner : ExternalExecutable + { + private static readonly object InitLock = new object(); + private static bool Initialized; + private static DotnetRunner Instance; + + public static ExternalExecutableResult Execute(string command, string[] arguments = null) => + InitAndExecute(command, arguments); + + private static ExternalExecutableResult InitAndExecute(string command, string[] arguments) + { + lock (InitLock) + { + if (!Initialized) + { + Initialized = true; + var dotnetPath = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH"); + if (String.IsNullOrEmpty(dotnetPath) || !File.Exists(dotnetPath)) + { + dotnetPath = "dotnet"; + } + + Instance = new DotnetRunner(dotnetPath); + } + } + + return Instance.ExecuteCore(command, arguments); + } + + private DotnetRunner(string exePath) : base(exePath) { } + + private ExternalExecutableResult ExecuteCore(string command, string[] arguments) + { + var total = new List + { + command, + }; + + if (arguments != null) + { + total.AddRange(arguments); + } + + var args = CombineArguments(total); + var mergeErrorIntoOutput = true; + return this.Run(args, mergeErrorIntoOutput); + } + } +} diff --git a/src/internal/WixInternal.MSTestSupport/ExternalExecutable.cs b/src/internal/WixInternal.MSTestSupport/ExternalExecutable.cs new file mode 100644 index 00000000..927240ec --- /dev/null +++ b/src/internal/WixInternal.MSTestSupport/ExternalExecutable.cs @@ -0,0 +1,374 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. + +namespace WixInternal.MSTestSupport +{ + using System; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.ComponentModel; + using System.Diagnostics; + using System.IO; + using System.Runtime.InteropServices; + using System.Text; + using System.Threading.Tasks; + using Microsoft.Win32.SafeHandles; + + public abstract class ExternalExecutable + { + private readonly string exePath; + + protected ExternalExecutable(string exePath) + { + this.exePath = exePath; + } + + protected ExternalExecutableResult Run(string args, bool mergeErrorIntoOutput = false, string workingDirectory = null) + { + // https://github.com/dotnet/runtime/issues/58492 + // Process.Start doesn't currently support starting a process with a long path, + // but the way to support long paths doesn't support searching for the executable if it was a relative path. + // Avoid the managed way of doing this even if the target isn't a long path to help verify that the native way works. + if (!Path.IsPathRooted(this.exePath)) + { + return this.RunManaged(args, mergeErrorIntoOutput, workingDirectory); + } + + // https://web.archive.org/web/20150331190801/https://support.microsoft.com/en-us/kb/190351 + var commandLine = $"\"{this.exePath}\" {args}"; + var currentDirectory = workingDirectory ?? Path.GetDirectoryName(this.exePath); + if (String.IsNullOrEmpty(currentDirectory)) + { + currentDirectory = null; + } + var processInfo = new PROCESS_INFORMATION(); + var startInfo = new STARTUPINFOW + { + cb = Marshal.SizeOf(typeof(STARTUPINFOW)), + dwFlags = StartupInfoFlags.STARTF_FORCEOFFFEEDBACK | StartupInfoFlags.STARTF_USESTDHANDLES, + hStdInput = GetStdHandle(StdHandleType.STD_INPUT_HANDLE), + }; + SafeFileHandle hStdOutputParent = null; + SafeFileHandle hStdErrorParent = null; + + try + { + CreatePipeForProcess(out hStdOutputParent, out startInfo.hStdOutput); + + if (!mergeErrorIntoOutput) + { + CreatePipeForProcess(out hStdErrorParent, out startInfo.hStdError); + } + else + { + if (!DuplicateHandle(GetCurrentProcess(), startInfo.hStdOutput, GetCurrentProcess(), out startInfo.hStdError, 0, true, DuplicateHandleOptions.DUPLICATE_SAME_ACCESS)) + { + throw new Win32Exception(); + } + } + + if (!CreateProcessW(this.exePath, commandLine, IntPtr.Zero, IntPtr.Zero, true, CreateProcessFlags.CREATE_NO_WINDOW, IntPtr.Zero, + currentDirectory, ref startInfo, ref processInfo)) + { + throw new Win32Exception(); + } + + startInfo.Dispose(); + + return GetResultFromNative(mergeErrorIntoOutput, hStdOutputParent, hStdErrorParent, processInfo.hProcess, this.exePath, args); + } + finally + { + hStdErrorParent?.Dispose(); + hStdOutputParent?.Dispose(); + + startInfo.Dispose(); + processInfo.Dispose(); + } + } + + private static ExternalExecutableResult GetResultFromNative(bool mergeErrorIntoOutput, SafeFileHandle hStdOutputParent, SafeFileHandle hStdErrorParent, IntPtr hProcess, string fileName, string args) + { + using (var outputStream = new StreamReader(new FileStream(hStdOutputParent, FileAccess.Read))) + using (var errorStream = mergeErrorIntoOutput ? null : new StreamReader(new FileStream(hStdErrorParent, FileAccess.Read))) + { + var outputTask = Task.Run(() => ReadProcessStreamLines(outputStream)); + var errorTask = Task.Run(() => ReadProcessStreamLines(errorStream)); + + while (!outputTask.Wait(100) || !errorTask.Wait(100)) { Task.Yield(); } + var standardOutput = outputTask.Result; + var standardError = errorTask.Result; + + if (WaitForSingleObject(hProcess, -1) != 0) + { + throw new Win32Exception(); + } + + if (!GetExitCodeProcess(hProcess, out var exitCode)) + { + throw new Win32Exception(); + } + + return new ExternalExecutableResult + { + ExitCode = exitCode, + StandardError = standardError, + StandardOutput = standardOutput, + FileName = fileName, + Arguments = args, + }; + } + } + + private static string[] ReadProcessStreamLines(StreamReader streamReader) + { + if (streamReader == null) + { + return null; + } + + var lines = new List(); + while (true) + { + var line = streamReader.ReadLine(); + if (line == null) + { + break; + } + + lines.Add(line); + } + + return lines.ToArray(); + } + + protected ExternalExecutableResult RunManaged(string args, bool mergeErrorIntoOutput = false, string workingDirectory = null) + { + var startInfo = new ProcessStartInfo(this.exePath, args) + { + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + WorkingDirectory = workingDirectory ?? Path.GetDirectoryName(this.exePath), + }; + + using (var process = Process.Start(startInfo)) + { + // This implementation of merging the streams does not guarantee that lines are retrieved in the same order that they were written. + // If the process is simultaneously writing to both streams, this is impossible to do anyway. + var standardOutput = new ConcurrentQueue(); + var standardError = mergeErrorIntoOutput ? standardOutput : new ConcurrentQueue(); + + process.ErrorDataReceived += (s, e) => { if (e.Data != null) { standardError.Enqueue(e.Data); } }; + process.OutputDataReceived += (s, e) => { if (e.Data != null) { standardOutput.Enqueue(e.Data); } }; + + process.BeginErrorReadLine(); + process.BeginOutputReadLine(); + + process.WaitForExit(); + + return new ExternalExecutableResult + { + ExitCode = process.ExitCode, + StandardError = mergeErrorIntoOutput ? null : standardError.ToArray(), + StandardOutput = standardOutput.ToArray(), + FileName = this.exePath, + Arguments = args, + }; + } + } + + // This is internal because it assumes backslashes aren't used as escape characters and there aren't any double quotes. + internal static string CombineArguments(IEnumerable arguments) + { + if (arguments == null) + { + return null; + } + + var sb = new StringBuilder(); + + foreach (var arg in arguments) + { + if (sb.Length > 0) + { + sb.Append(' '); + } + + if (arg.IndexOf(' ') > -1 && !arg.EndsWith("\"")) + { + sb.Append("\""); + sb.Append(arg); + sb.Append("\""); + } + else + { + sb.Append(arg); + } + } + + return sb.ToString(); + } + + private static void CreatePipeForProcess(out SafeFileHandle hReadPipe, out IntPtr hWritePipe) + { + var securityAttributes = new SECURITY_ATTRIBUTES + { + nLength = Marshal.SizeOf(typeof(SECURITY_ATTRIBUTES)), + bInheritHandle = true, + }; + + if (!CreatePipe(out var hReadTemp, out hWritePipe, ref securityAttributes, 0)) + { + throw new Win32Exception(); + } + + // Only the handle passed to the process should be inheritable, so have to duplicate the other handle to get an uninheritable one. + if (!DuplicateHandle(GetCurrentProcess(), hReadTemp, GetCurrentProcess(), out var hReadPipePtr, 0, false, DuplicateHandleOptions.DUPLICATE_CLOSE_SOURCE | DuplicateHandleOptions.DUPLICATE_SAME_ACCESS)) + { + throw new Win32Exception(); + } + + hReadPipe = new SafeFileHandle(hReadPipePtr, true); + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + private extern static IntPtr GetStdHandle(StdHandleType nStdHandle); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private extern static bool CreatePipe(out IntPtr hReadPipe, out IntPtr hWritePipe, ref SECURITY_ATTRIBUTES lpPipeAttributes, int nSize); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private extern static bool CreateProcessW( + string lpApplicationName, + string lpCommandLine, + IntPtr lpProcessAttributes, + IntPtr lpThreadAttributes, + [MarshalAs(UnmanagedType.Bool)] bool bInheritHandles, + CreateProcessFlags dwCreationFlags, + IntPtr lpEnvironment, + string lpCurrentDirectory, + ref STARTUPINFOW lpStartupInfo, + ref PROCESS_INFORMATION lpProcessInformation); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] + private extern static IntPtr GetCurrentProcess(); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private extern static bool GetExitCodeProcess(IntPtr hHandle, out int lpExitCode); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + private extern static int WaitForSingleObject(IntPtr hHandle, int dwMilliseconds); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private extern static bool CloseHandle(IntPtr hObject); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private extern static bool DuplicateHandle(IntPtr hSourceProcessHandle, IntPtr hSourceHandle, IntPtr hTargetProcessHandle, out IntPtr lpTargetHandle, int dwDesiredAccess, [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle, DuplicateHandleOptions dwOptions); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct SECURITY_ATTRIBUTES + { + public int nLength; + public IntPtr lpSecurityDescriptor; + [MarshalAs(UnmanagedType.Bool)] + public bool bInheritHandle; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct STARTUPINFOW + { + public int cb; + public string lpReserved; + public string lpDesktop; + public string lpTitle; + public int dwX; + public int dwY; + public int dwXSize; + public int dwYSize; + public int dwXCountChars; + public int dwYCountChars; + public int dwFillAttribute; + public StartupInfoFlags dwFlags; + public short wShowWindow; + public short cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + + public void Dispose() + { + // This makes assumptions based on how it's used above. + if (this.hStdError != IntPtr.Zero) + { + CloseHandle(this.hStdError); + this.hStdError = IntPtr.Zero; + } + + if (this.hStdOutput != IntPtr.Zero) + { + CloseHandle(this.hStdOutput); + this.hStdOutput = IntPtr.Zero; + } + } + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct PROCESS_INFORMATION + { + public IntPtr hProcess; + public IntPtr hThread; + public int dwProcessId; + public int dwThreadId; + + public void Dispose() + { + if (this.hProcess != IntPtr.Zero) + { + CloseHandle(this.hProcess); + this.hProcess = IntPtr.Zero; + } + + if (this.hThread != IntPtr.Zero) + { + CloseHandle(this.hThread); + this.hThread = IntPtr.Zero; + } + } + } + + private enum StdHandleType + { + STD_INPUT_HANDLE = -10, + STD_OUTPUT_HANDLE = -11, + STD_ERROR_HANDLE = -12, + } + + [Flags] + private enum CreateProcessFlags + { + None = 0x0, + CREATE_NO_WINDOW = 0x08000000, + } + + [Flags] + private enum StartupInfoFlags + { + None = 0x0, + STARTF_FORCEOFFFEEDBACK = 0x80, + STARTF_USESTDHANDLES = 0x100, + } + + private enum DuplicateHandleOptions + { + DUPLICATE_CLOSE_SOURCE = 1, + DUPLICATE_SAME_ACCESS = 2, + } + } +} diff --git a/src/internal/WixInternal.MSTestSupport/ExternalExecutableResult.cs b/src/internal/WixInternal.MSTestSupport/ExternalExecutableResult.cs new file mode 100644 index 00000000..57bf0d11 --- /dev/null +++ b/src/internal/WixInternal.MSTestSupport/ExternalExecutableResult.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. + +namespace WixInternal.MSTestSupport +{ + using System.Diagnostics; + + public class ExternalExecutableResult + { + public int ExitCode { get; set; } + + public string[] StandardError { get; set; } + + public string[] StandardOutput { get; set; } + + public string FileName { get; set; } + + public string Arguments { get; set; } + } +} diff --git a/src/internal/WixInternal.MSTestSupport/FakeBuildEngine.cs b/src/internal/WixInternal.MSTestSupport/FakeBuildEngine.cs new file mode 100644 index 00000000..951d1bc8 --- /dev/null +++ b/src/internal/WixInternal.MSTestSupport/FakeBuildEngine.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. + +namespace WixInternal.MSTestSupport +{ + using System.Collections; + using System.Text; + using Microsoft.Build.Framework; + + public class FakeBuildEngine : IBuildEngine + { + private readonly StringBuilder output = new StringBuilder(); + + public int ColumnNumberOfTaskNode => 0; + + public bool ContinueOnError => false; + + public int LineNumberOfTaskNode => 0; + + public string ProjectFileOfTaskNode => "fake_wix.targets"; + + public string Output => this.output.ToString(); + + public bool BuildProjectFile(string projectFileName, string[] targetNames, IDictionary globalProperties, IDictionary targetOutputs) => throw new System.NotImplementedException(); + + public void LogCustomEvent(CustomBuildEventArgs e) => this.output.AppendLine(e.Message); + + public void LogErrorEvent(BuildErrorEventArgs e) => this.output.AppendLine(e.Message); + + public void LogMessageEvent(BuildMessageEventArgs e) => this.output.AppendLine(e.Message); + + public void LogWarningEvent(BuildWarningEventArgs e) => this.output.AppendLine(e.Message); + } +} diff --git a/src/internal/WixInternal.MSTestSupport/MsbuildRunner.cs b/src/internal/WixInternal.MSTestSupport/MsbuildRunner.cs new file mode 100644 index 00000000..69fc7292 --- /dev/null +++ b/src/internal/WixInternal.MSTestSupport/MsbuildRunner.cs @@ -0,0 +1,112 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. + +namespace WixInternal.MSTestSupport +{ + using System; + using System.Collections.Generic; + using System.IO; + + public class MsbuildRunner : ExternalExecutable + { + private static readonly string VswhereFindArguments = "-property installationPath -version [17.0,18.0)"; + private static readonly string MsbuildCurrentRelativePath = @"MSBuild\Current\Bin\MSBuild.exe"; + private static readonly string MsbuildCurrentRelativePath64 = @"MSBuild\Current\Bin\amd64\MSBuild.exe"; + + private static readonly object InitLock = new object(); + + private static bool Initialized; + private static MsbuildRunner MsbuildCurrentRunner; + private static MsbuildRunner MsbuildCurrentRunner64; + + public static MsbuildRunnerResult Execute(string projectPath, string[] arguments = null, bool x64 = false) => + InitAndExecute(String.Empty, projectPath, arguments, x64); + + public static MsbuildRunnerResult ExecuteWithMsbuildCurrent(string projectPath, string[] arguments = null, bool x64 = false) => + InitAndExecute("Current", projectPath, arguments, x64); + + private static MsbuildRunnerResult InitAndExecute(string msbuildVersion, string projectPath, string[] arguments, bool x64) + { + lock (InitLock) + { + if (!Initialized) + { + Initialized = true; + var vswhereResult = VswhereRunner.Execute(VswhereFindArguments, true); + if (vswhereResult.ExitCode != 0) + { + throw new InvalidOperationException($"Failed to execute vswhere.exe, exit code: {vswhereResult.ExitCode}. Output:\r\n{String.Join("\r\n", vswhereResult.StandardOutput)}"); + } + + string msbuildCurrentPath = null; + string msbuildCurrentPath64 = null; + + foreach (var installPath in vswhereResult.StandardOutput) + { + if (msbuildCurrentPath == null) + { + var path = Path.Combine(installPath, MsbuildCurrentRelativePath); + if (File.Exists(path)) + { + msbuildCurrentPath = path; + } + } + + if (msbuildCurrentPath64 == null) + { + var path = Path.Combine(installPath, MsbuildCurrentRelativePath64); + if (File.Exists(path)) + { + msbuildCurrentPath64 = path; + } + } + } + + if (msbuildCurrentPath != null) + { + MsbuildCurrentRunner = new MsbuildRunner(msbuildCurrentPath); + } + + if (msbuildCurrentPath64 != null) + { + MsbuildCurrentRunner64 = new MsbuildRunner(msbuildCurrentPath64); + } + } + } + + MsbuildRunner runner = x64 ? MsbuildCurrentRunner64 : MsbuildCurrentRunner; + + if (runner == null) + { + throw new InvalidOperationException($"Failed to find an installed{(x64 ? " 64-bit" : String.Empty)} MSBuild{msbuildVersion}"); + } + + return runner.ExecuteCore(projectPath, arguments); + } + + private MsbuildRunner(string exePath) : base(exePath) { } + + private MsbuildRunnerResult ExecuteCore(string projectPath, string[] arguments) + { + var total = new List + { + projectPath, + }; + + if (arguments != null) + { + total.AddRange(arguments); + } + + var args = CombineArguments(total); + var mergeErrorIntoOutput = true; + var workingFolder = Path.GetDirectoryName(projectPath); + var result = this.Run(args, mergeErrorIntoOutput, workingFolder); + + return new MsbuildRunnerResult + { + ExitCode = result.ExitCode, + Output = result.StandardOutput, + }; + } + } +} diff --git a/src/internal/WixInternal.MSTestSupport/MsbuildRunnerResult.cs b/src/internal/WixInternal.MSTestSupport/MsbuildRunnerResult.cs new file mode 100644 index 00000000..02e25ebb --- /dev/null +++ b/src/internal/WixInternal.MSTestSupport/MsbuildRunnerResult.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. + +namespace WixInternal.MSTestSupport +{ + using System; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public class MsbuildRunnerResult + { + public int ExitCode { get; set; } + + public string[] Output { get; set; } + + public void AssertSuccess() + { + Assert.IsTrue(0 == this.ExitCode, $"MSBuild failed unexpectedly. Output:{Environment.NewLine}{String.Join(Environment.NewLine, this.Output)}"); + } + } +} diff --git a/src/internal/WixInternal.MSTestSupport/MsbuildUtilities.cs b/src/internal/WixInternal.MSTestSupport/MsbuildUtilities.cs new file mode 100644 index 00000000..4776e6f1 --- /dev/null +++ b/src/internal/WixInternal.MSTestSupport/MsbuildUtilities.cs @@ -0,0 +1,99 @@ +// Copyright(c) .NET Foundation and contributors.All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. + +namespace WixInternal.MSTestSupport +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + + public enum BuildSystem + { + DotNetCoreSdk, + MSBuild, + MSBuild64, + } + + public static class MsbuildUtilities + { + public static MsbuildRunnerResult BuildProject(BuildSystem buildSystem, string projectPath, string[] arguments = null, string configuration = "Release", string verbosityLevel = "normal", bool suppressValidation = true) + { + var allArgs = new List + { + $"-verbosity:{verbosityLevel}", + $"-p:Configuration={configuration}", + $"-p:SuppressValidation={suppressValidation}", + // Node reuse means that child msbuild processes can stay around after the build completes. + // Under that scenario, the root msbuild does not reliably close its streams which causes us to hang. + "-nr:false", + MsbuildUtilities.GetQuotedSwitch(buildSystem, "bl", Path.ChangeExtension(projectPath, ".binlog")) + }; + + if (arguments != null) + { + allArgs.AddRange(arguments); + } + + switch (buildSystem) + { + case BuildSystem.DotNetCoreSdk: + { + allArgs.Add(projectPath); + var result = DotnetRunner.Execute("msbuild", allArgs.ToArray()); + return new MsbuildRunnerResult + { + ExitCode = result.ExitCode, + Output = result.StandardOutput, + }; + } + case BuildSystem.MSBuild: + case BuildSystem.MSBuild64: + { + return MsbuildRunner.Execute(projectPath, allArgs.ToArray(), buildSystem == BuildSystem.MSBuild64); + } + default: + { + throw new NotImplementedException(); + } + } + } + + public static string GetQuotedSwitch(BuildSystem _, string switchName, string switchValue) + { + // If the value ends with a backslash, escape it. + if (switchValue?.EndsWith("\\") == true) + { + switchValue += @"\"; + } + + return $"-{switchName}:\"{switchValue}\""; + } + + public static string GetQuotedPropertySwitch(BuildSystem buildSystem, string propertyName, string propertyValue) + { + // If the value ends with a backslash, escape it. + if (propertyValue?.EndsWith("\\") == true) + { + propertyValue += @"\"; + } + + var quotedValue = "\"" + propertyValue + "\""; + + // If the value contains a semicolon then escape-quote it (wrap with the characters: \") to wrap the value + // instead of just quoting the value, otherwise dotnet.exe will not pass the value to MSBuild correctly. + if (buildSystem == BuildSystem.DotNetCoreSdk && propertyValue?.IndexOf(';') > -1) + { + quotedValue = "\\\"" + propertyValue + "\\\""; + } + + return $"-p:{propertyName}={quotedValue}"; + } + + public static IEnumerable GetToolCommandLines(MsbuildRunnerResult result, string toolName, string operation, BuildSystem buildSystem) + { + var expectedToolExe = buildSystem == BuildSystem.DotNetCoreSdk ? $"{toolName}.dll\"" : $"{toolName}.exe"; + var expectedToolCommand = $"{expectedToolExe} {operation}"; + return result.Output.Where(line => line.Contains(expectedToolCommand)); + } + } +} diff --git a/src/internal/WixInternal.MSTestSupport/Pushd.cs b/src/internal/WixInternal.MSTestSupport/Pushd.cs new file mode 100644 index 00000000..7086ffd0 --- /dev/null +++ b/src/internal/WixInternal.MSTestSupport/Pushd.cs @@ -0,0 +1,46 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. + +namespace WixInternal.MSTestSupport +{ + using System; + using System.IO; + + public class Pushd : IDisposable + { + protected bool Disposed { get; private set; } + + public Pushd(string path) + { + this.PreviousDirectory = Directory.GetCurrentDirectory(); + + Directory.SetCurrentDirectory(path); + } + + public string PreviousDirectory { get; } + + #region // IDisposable + + public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (this.Disposed) + { + return; + } + + if (disposing) + { + Directory.SetCurrentDirectory(this.PreviousDirectory); + } + + this.Disposed = true; + } + + #endregion + } +} diff --git a/src/internal/WixInternal.MSTestSupport/Query.cs b/src/internal/WixInternal.MSTestSupport/Query.cs new file mode 100644 index 00000000..5a8868b3 --- /dev/null +++ b/src/internal/WixInternal.MSTestSupport/Query.cs @@ -0,0 +1,207 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. + +namespace WixInternal.MSTestSupport +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Text; + using WixToolset.Dtf.Compression.Cab; + using WixToolset.Dtf.WindowsInstaller; + + public class Query + { + public static string[] QueryDatabase(string path, string[] tables) + { + var results = new List(); + var resultsByTable = QueryDatabaseByTable(path, tables); + var sortedTables = tables.ToList(); + sortedTables.Sort(); + foreach (var tableName in sortedTables) + { + var rows = resultsByTable[tableName]; + rows?.ForEach(r => results.Add($"{tableName}:{r}")); + } + return results.ToArray(); + } + + public static string[] QueryDatabase(Database db, string[] tables) + { + var results = new List(); + var resultsByTable = QueryDatabaseByTable(db, tables); + var sortedTables = tables.ToList(); + sortedTables.Sort(); + foreach (var tableName in sortedTables) + { + var rows = resultsByTable[tableName]; + rows?.ForEach(r => results.Add($"{tableName}:{r}")); + } + return results.ToArray(); + } + + /// + /// Returns rows from requested tables formatted to facilitate testing. + /// If the table did not exist in the database, its list will be null. + /// + /// + /// + /// + public static Dictionary> QueryDatabaseByTable(string path, string[] tables) + { + var results = new Dictionary>(); + + if (tables?.Length > 0) + { + using (var db = new Database(path)) + { + results = QueryDatabaseByTable(db, tables); + } + } + + return results; + } + + /// + /// Returns rows from requested tables formatted to facilitate testing. + /// If the table did not exist in the database, its list will be null. + /// + /// + /// + /// + public static Dictionary> QueryDatabaseByTable(Database db, string[] tables) + { + var results = new Dictionary>(); + + if (tables?.Length > 0) + { + var sb = new StringBuilder(); + + foreach (var table in tables) + { + if (table == "_SummaryInformation") + { + var entries = new List(); + results.Add(table, entries); + + entries.Add($"Title\t{db.SummaryInfo.Title}"); + entries.Add($"Subject\t{db.SummaryInfo.Subject}"); + entries.Add($"Author\t{db.SummaryInfo.Author}"); + entries.Add($"Keywords\t{db.SummaryInfo.Keywords}"); + entries.Add($"Comments\t{db.SummaryInfo.Comments}"); + entries.Add($"Template\t{db.SummaryInfo.Template}"); + entries.Add($"CodePage\t{db.SummaryInfo.CodePage}"); + entries.Add($"PageCount\t{db.SummaryInfo.PageCount}"); + entries.Add($"WordCount\t{db.SummaryInfo.WordCount}"); + entries.Add($"CharacterCount\t{db.SummaryInfo.CharacterCount}"); + entries.Add($"Security\t{db.SummaryInfo.Security}"); + + continue; + } + + if (!db.IsTablePersistent(table)) + { + results.Add(table, null); + continue; + } + + var rows = new List(); + results.Add(table, rows); + + using (var view = db.OpenView("SELECT * FROM `{0}`", table)) + { + view.Execute(); + + Record record; + while ((record = view.Fetch()) != null) + { + sb.Clear(); + + using (record) + { + for (var i = 0; i < record.FieldCount; ++i) + { + if (i > 0) + { + sb.Append("\t"); + } + + sb.Append(record[i + 1]?.ToString()); + } + } + + rows.Add(sb.ToString()); + } + } + + rows.Sort(); + } + } + + return results; + } + + public static CabFileInfo[] GetCabinetFiles(string path) + { + var cab = new CabInfo(path); + + var result = cab.GetFiles(); + + return result.Select(c => c).ToArray(); + } + + public static void ExtractStream(string path, string streamName, string outputPath) + { + Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); + + using (var db = new Database(path)) + using (var view = db.OpenView("SELECT `Data` FROM `_Streams` WHERE `Name` = '{0}'", streamName)) + { + view.Execute(); + + using (var record = view.Fetch()) + { + record.GetStream(1, outputPath); + } + } + } + + public static void ExtractSubStorage(string path, string subStorageName, string outputPath) + { + Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); + + using (var db = new Database(path)) + using (var view = db.OpenView("SELECT `Name`, `Data` FROM `_Storages` WHERE `Name` = '{0}'", subStorageName)) + { + view.Execute(); + + using (var record = view.Fetch()) + { + var name = record.GetString(1); + record.GetStream(2, outputPath); + } + } + } + + public static string[] GetSubStorageNames(string path) + { + var result = new List(); + + using (var db = new Database(path)) + using (var view = db.OpenView("SELECT `Name` FROM `_Storages`")) + { + view.Execute(); + + Record record; + while ((record = view.Fetch()) != null) + { + var name = record.GetString(1); + result.Add(name); + } + } + + result.Sort(); + return result.ToArray(); + } + } +} diff --git a/src/internal/WixInternal.MSTestSupport/RobocopyRunner.cs b/src/internal/WixInternal.MSTestSupport/RobocopyRunner.cs new file mode 100644 index 00000000..7ad8f6fe --- /dev/null +++ b/src/internal/WixInternal.MSTestSupport/RobocopyRunner.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. + +namespace WixInternal.MSTestSupport +{ + public class RobocopyRunner : ExternalExecutable + { + private static readonly RobocopyRunner Instance = new RobocopyRunner(); + + private RobocopyRunner() : base("robocopy") { } + + public static ExternalExecutableResult Execute(string args) + { + return Instance.Run(args); + } + } +} diff --git a/src/internal/WixInternal.MSTestSupport/TestData.cs b/src/internal/WixInternal.MSTestSupport/TestData.cs new file mode 100644 index 00000000..5f167a87 --- /dev/null +++ b/src/internal/WixInternal.MSTestSupport/TestData.cs @@ -0,0 +1,78 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. + +namespace WixInternal.MSTestSupport +{ + using System; + using System.IO; + using System.Reflection; + using System.Runtime.CompilerServices; + + public class TestData + { + public static void CreateFile(string path, long size, bool fill = false) + { + // Ensure the directory exists. + path = Path.GetFullPath(path); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + + using (var file = File.OpenWrite(path)) + { + if (fill) + { + var random = new Random(); + var bytes = new byte[4096]; + var generated = 0L; + + // Put fill bytes in the file so it doesn't compress trivially. + while (generated < size) + { + var generate = (int)Math.Min(size - generated, bytes.Length); + + random.NextBytes(bytes); + + file.Write(bytes, 0, generate); + + generated += generate; + } + } + else + { + file.SetLength(size); + } + } + } + + public static string Get(params string[] paths) + { + var localPath = AppDomain.CurrentDomain.BaseDirectory; + return Path.Combine(localPath, Path.Combine(paths)); + } + + public static string GetUnitTestLogsFolder([CallerFilePath] string path = "", [CallerMemberName] string method = "") + { + var startingPath = AppDomain.CurrentDomain.BaseDirectory; + var buildPath = startingPath; + + while (!String.IsNullOrEmpty(buildPath)) + { + var folderName = Path.GetFileName(buildPath); + if (String.Equals("build", folderName, StringComparison.OrdinalIgnoreCase)) + { + break; + } + + buildPath = Path.GetDirectoryName(buildPath); + } + + if (String.IsNullOrEmpty(buildPath)) + { + throw new InvalidOperationException($"Could not find the 'build' folder in the test path: {startingPath}. Cannot get test logs folder without being able to find the build folder."); + } + + var testLogsFolder = Path.Combine(buildPath, "logs", "UnitTests", $"{Path.GetFileNameWithoutExtension(path)}_{method}"); + Directory.CreateDirectory(testLogsFolder); + + return testLogsFolder; + } + } +} diff --git a/src/internal/WixInternal.MSTestSupport/TestDataFolderFileSystem.cs b/src/internal/WixInternal.MSTestSupport/TestDataFolderFileSystem.cs new file mode 100644 index 00000000..1fea9665 --- /dev/null +++ b/src/internal/WixInternal.MSTestSupport/TestDataFolderFileSystem.cs @@ -0,0 +1,42 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. + +namespace WixInternal.MSTestSupport +{ + using System; + + /// + /// This class builds on top of DisposableFileSystem + /// to make it easy to write a test that needs a whole folder of test data copied to a temp location + /// that will automatically be cleaned up at the end of the test. + /// + public class TestDataFolderFileSystem : IDisposable + { + private DisposableFileSystem fileSystem; + + public string BaseFolder { get; private set; } + + public void Dispose() + { + this.fileSystem?.Dispose(); + } + + public void Initialize(string sourceDirectoryPath) + { + if (this.fileSystem != null) + { + throw new InvalidOperationException(); + } + this.fileSystem = new DisposableFileSystem(); + + this.BaseFolder = this.fileSystem.GetFolder(); + + RobocopyFolder(sourceDirectoryPath, this.BaseFolder); + } + + public static ExternalExecutableResult RobocopyFolder(string sourceFolderPath, string destinationFolderPath) + { + var args = $"\"{sourceFolderPath}\" \"{destinationFolderPath}\" /E /R:1 /W:1"; + return RobocopyRunner.Execute(args); + } + } +} diff --git a/src/internal/WixInternal.MSTestSupport/VswhereRunner.cs b/src/internal/WixInternal.MSTestSupport/VswhereRunner.cs new file mode 100644 index 00000000..f6ff2116 --- /dev/null +++ b/src/internal/WixInternal.MSTestSupport/VswhereRunner.cs @@ -0,0 +1,41 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. + +namespace WixInternal.MSTestSupport +{ + using System; + using System.IO; + + public class VswhereRunner : ExternalExecutable + { + private static readonly string VswhereRelativePath = @"Microsoft Visual Studio\Installer\vswhere.exe"; + + private static readonly object InitLock = new object(); + private static bool Initialized; + private static VswhereRunner Instance; + + public static ExternalExecutableResult Execute(string args, bool mergeErrorIntoOutput = false) => + InitAndExecute(args, mergeErrorIntoOutput); + + private static ExternalExecutableResult InitAndExecute(string args, bool mergeErrorIntoOutput) + { + lock (InitLock) + { + if (!Initialized) + { + Initialized = true; + var vswherePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), VswhereRelativePath); + if (!File.Exists(vswherePath)) + { + throw new InvalidOperationException($"Failed to find vswhere at: {vswherePath}"); + } + + Instance = new VswhereRunner(vswherePath); + } + } + + return Instance.Run(args, mergeErrorIntoOutput); + } + + private VswhereRunner(string exePath) : base(exePath) { } + } +} diff --git a/src/internal/WixInternal.MSTestSupport/WixAssert.cs b/src/internal/WixInternal.MSTestSupport/WixAssert.cs new file mode 100644 index 00000000..927ebee6 --- /dev/null +++ b/src/internal/WixInternal.MSTestSupport/WixAssert.cs @@ -0,0 +1,164 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. + +namespace WixInternal.MSTestSupport +{ + using Microsoft.VisualStudio.TestTools.UnitTesting; + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text; + using System.Xml.Linq; + + public class WixAssert + { + public static void CompareLineByLine(string[] expectedLines, string[] actualLines) + { + var lineNumber = 0; + + for (; lineNumber < expectedLines.Length && lineNumber < actualLines.Length; ++lineNumber) + { + StringEqual($"{lineNumber}: {expectedLines[lineNumber]}", $"{lineNumber}: {actualLines[lineNumber]}"); + } + + var additionalExpectedLines = expectedLines.Length > lineNumber ? String.Join(Environment.NewLine, expectedLines.Skip(lineNumber).Select((s, i) => $"{lineNumber + i}: {s}")) : $"Missing {actualLines.Length - lineNumber} lines"; + var additionalActualLines = actualLines.Length > lineNumber ? String.Join(Environment.NewLine, actualLines.Skip(lineNumber).Select((s, i) => $"{lineNumber + i}: {s}")) : $"Missing {expectedLines.Length - lineNumber} lines"; + + Assert.AreEqual(additionalExpectedLines, additionalActualLines, StringObjectEqualityComparer.InvariantCulture); + } + + public static void CompareXml(XContainer xExpected, XContainer xActual) + { + var expecteds = ComparableElements(xExpected); + var actuals = ComparableElements(xActual); + + CompareLineByLine(expecteds.OrderBy(s => s).ToArray(), actuals.OrderBy(s => s).ToArray()); + } + + public static void CompareXml(string expectedPath, string actualPath) + { + var expectedDoc = XDocument.Load(expectedPath, LoadOptions.PreserveWhitespace | LoadOptions.SetBaseUri | LoadOptions.SetLineInfo); + var actualDoc = XDocument.Load(actualPath, LoadOptions.PreserveWhitespace | LoadOptions.SetBaseUri | LoadOptions.SetLineInfo); + + CompareXml(expectedDoc, actualDoc); + } + + private static IEnumerable ComparableElements(XContainer container) + { + return container.Descendants().Select(x => $"{x.Name.LocalName}:{String.Join(",", x.Attributes().OrderBy(a => a.Name.LocalName).Select(a => $"{a.Name.LocalName}={ComparableAttribute(a)}"))}"); + } + + private static string ComparableAttribute(XAttribute attribute) + { + switch (attribute.Name.LocalName) + { + case "SourceFile": + return ""; + default: + return attribute.Value; + } + } + + public static void StringCollectionEmpty(IList collection) + { + if (collection.Count > 0) + { + Assert.Fail($"The collection was expected to be empty, but instead was [{Environment.NewLine}\"{String.Join($"\", {Environment.NewLine}\"", collection)}\"{Environment.NewLine}]"); + } + } + + public static void StringEqual(string expected, string actual, bool ignoreCase = false) + { + WixStringEqualException.ThrowIfNotEqual(expected, actual, ignoreCase); + } + + public static void NotStringEqual(string expected, string actual, bool ignoreCase = false) + { + var comparer = ignoreCase ? StringObjectEqualityComparer.InvariantCultureIgnoreCase : StringObjectEqualityComparer.InvariantCulture; + Assert.AreNotEqual(expected, actual, comparer); + } + + public static void Single(IEnumerable collection) + { + Assert.AreEqual(1, collection.Count()); + } + + public static void Single(IEnumerable collection, Func predicate) + { + var results = collection.Where(predicate); + Assert.AreEqual(1, results.Count()); + } + + public static void Empty(IEnumerable collection) + { + Assert.AreEqual(0, collection.Count()); + } + + public static void Empty(IEnumerable collection, Func predicate) + { + var results = collection.Where(predicate); + Assert.AreEqual(0, results.Count()); + } + } + + internal class StringObjectEqualityComparer : IEqualityComparer + { + public static readonly StringObjectEqualityComparer InvariantCultureIgnoreCase = new StringObjectEqualityComparer(true); + public static readonly StringObjectEqualityComparer InvariantCulture = new StringObjectEqualityComparer(false); + + private readonly StringComparer stringComparer; + + public StringObjectEqualityComparer(bool ignoreCase) + { + this.stringComparer = ignoreCase ? StringComparer.InvariantCultureIgnoreCase : StringComparer.InvariantCulture; + } + + public new bool Equals(object x, object y) + { + return this.stringComparer.Equals((string)x, (string)y); + } + + public int GetHashCode(object obj) + { + return this.stringComparer.GetHashCode((string)obj); + } + } + + public class WixStringEqualException : AssertFailedException + { + public WixStringEqualException(string userMessage) : base(userMessage) { } + + public static void ThrowIfNotEqual(string expected, string actual, bool ignoreCase) + { + var comparer = ignoreCase ? StringObjectEqualityComparer.InvariantCultureIgnoreCase : StringObjectEqualityComparer.InvariantCulture; + if (comparer.Equals(expected, actual)) + { + return; + } + + var sbMessage = new StringBuilder(); + + try + { + Assert.AreEqual(expected, actual, ignoreCase); + } + catch (AssertFailedException xe) + { + // If either string is not completely in the message, then make sure it gets in there. + if (!xe.Message.Contains(expected) || !xe.Message.Contains(actual)) + { + sbMessage.AppendLine(xe.Message); + sbMessage.AppendLine(); + sbMessage.AppendFormat("Expected: {0}", expected); + sbMessage.AppendLine(); + sbMessage.AppendFormat("Actual: {0}", actual); + } + else + { + throw; + } + } + + throw new WixStringEqualException(sbMessage.ToString()); + } + } +} diff --git a/src/internal/WixInternal.MSTestSupport/WixInternal.MSTestSupport.csproj b/src/internal/WixInternal.MSTestSupport/WixInternal.MSTestSupport.csproj new file mode 100644 index 00000000..b48aacd4 --- /dev/null +++ b/src/internal/WixInternal.MSTestSupport/WixInternal.MSTestSupport.csproj @@ -0,0 +1,27 @@ + + + + + + + net6.0;net472 + true + embedded + true + true + $(NoWarn);CS1591 + false + true + + + + + + + + + + + + + diff --git a/src/internal/WixInternal.TestSupport/WixAssert.cs b/src/internal/WixInternal.TestSupport/WixAssert.cs new file mode 100644 index 00000000..40355131 --- /dev/null +++ b/src/internal/WixInternal.TestSupport/WixAssert.cs @@ -0,0 +1,143 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. + +namespace WixInternal.MSTestSupport +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text; + using System.Xml.Linq; + using Xunit; + using Xunit.Sdk; + + public class WixAssert + { + public static void CompareLineByLine(string[] expectedLines, string[] actualLines) + { + var lineNumber = 0; + + for (; lineNumber < expectedLines.Length && lineNumber < actualLines.Length; ++lineNumber) + { + StringEqual($"{lineNumber}: {expectedLines[lineNumber]}", $"{lineNumber}: {actualLines[lineNumber]}"); + } + + var additionalExpectedLines = expectedLines.Length > lineNumber ? String.Join(Environment.NewLine, expectedLines.Skip(lineNumber).Select((s, i) => $"{lineNumber + i}: {s}")) : $"Missing {actualLines.Length - lineNumber} lines"; + var additionalActualLines = actualLines.Length > lineNumber ? String.Join(Environment.NewLine, actualLines.Skip(lineNumber).Select((s, i) => $"{lineNumber + i}: {s}")) : $"Missing {expectedLines.Length - lineNumber} lines"; + + Assert.Equal(additionalExpectedLines, additionalActualLines, StringObjectEqualityComparer.InvariantCulture); + } + + public static void CompareXml(XContainer xExpected, XContainer xActual) + { + var expecteds = ComparableElements(xExpected); + var actuals = ComparableElements(xActual); + + CompareLineByLine(expecteds.OrderBy(s => s).ToArray(), actuals.OrderBy(s => s).ToArray()); + } + + public static void CompareXml(string expectedPath, string actualPath) + { + var expectedDoc = XDocument.Load(expectedPath, LoadOptions.PreserveWhitespace | LoadOptions.SetBaseUri | LoadOptions.SetLineInfo); + var actualDoc = XDocument.Load(actualPath, LoadOptions.PreserveWhitespace | LoadOptions.SetBaseUri | LoadOptions.SetLineInfo); + + CompareXml(expectedDoc, actualDoc); + } + + private static IEnumerable ComparableElements(XContainer container) + { + return container.Descendants().Select(x => $"{x.Name.LocalName}:{String.Join(",", x.Attributes().OrderBy(a => a.Name.LocalName).Select(a => $"{a.Name.LocalName}={ComparableAttribute(a)}"))}"); + } + + private static string ComparableAttribute(XAttribute attribute) + { + switch (attribute.Name.LocalName) + { + case "SourceFile": + return ""; + default: + return attribute.Value; + } + } + + public static void StringCollectionEmpty(IList collection) + { + if (collection.Count > 0) + { + Assert.Fail($"The collection was expected to be empty, but instead was [{Environment.NewLine}\"{String.Join($"\", {Environment.NewLine}\"", collection)}\"{Environment.NewLine}]"); + } + } + + public static void StringEqual(string expected, string actual, bool ignoreCase = false) + { + WixStringEqualException.ThrowIfNotEqual(expected, actual, ignoreCase); + } + + public static void NotStringEqual(string expected, string actual, bool ignoreCase = false) + { + var comparer = ignoreCase ? StringObjectEqualityComparer.InvariantCultureIgnoreCase : StringObjectEqualityComparer.InvariantCulture; + Assert.NotEqual(expected, actual, comparer); + } + } + + internal class StringObjectEqualityComparer : IEqualityComparer + { + public static readonly StringObjectEqualityComparer InvariantCultureIgnoreCase = new StringObjectEqualityComparer(true); + public static readonly StringObjectEqualityComparer InvariantCulture = new StringObjectEqualityComparer(false); + + private readonly StringComparer stringComparer; + + public StringObjectEqualityComparer(bool ignoreCase) + { + this.stringComparer = ignoreCase ? StringComparer.InvariantCultureIgnoreCase : StringComparer.InvariantCulture; + } + + public new bool Equals(object x, object y) + { + return this.stringComparer.Equals((string)x, (string)y); + } + + public int GetHashCode(object obj) + { + return this.stringComparer.GetHashCode((string)obj); + } + } + + public class WixStringEqualException : XunitException + { + public WixStringEqualException(string userMessage) : base(userMessage) { } + + public static void ThrowIfNotEqual(string expected, string actual, bool ignoreCase) + { + var comparer = ignoreCase ? StringObjectEqualityComparer.InvariantCultureIgnoreCase : StringObjectEqualityComparer.InvariantCulture; + if (comparer.Equals(expected, actual)) + { + return; + } + + var sbMessage = new StringBuilder(); + + try + { + Assert.Equal(expected, actual, ignoreCase); + } + catch (XunitException xe) + { + // If either string is not completely in the message, then make sure it gets in there. + if (!xe.Message.Contains(expected) || !xe.Message.Contains(actual)) + { + sbMessage.AppendLine(xe.Message); + sbMessage.AppendLine(); + sbMessage.AppendFormat("Expected: {0}", expected); + sbMessage.AppendLine(); + sbMessage.AppendFormat("Actual: {0}", actual); + } + else + { + throw; + } + } + + throw new WixStringEqualException(sbMessage.ToString()); + } + } +} diff --git a/src/internal/WixInternal.TestSupport/XunitExtensions/WixAssert.cs b/src/internal/WixInternal.TestSupport/XunitExtensions/WixAssert.cs index 5ac28de1..131d4e83 100644 --- a/src/internal/WixInternal.TestSupport/XunitExtensions/WixAssert.cs +++ b/src/internal/WixInternal.TestSupport/XunitExtensions/WixAssert.cs @@ -105,6 +105,32 @@ namespace WixInternal.TestSupport Assert.NotEqual(expected, actual, comparer); } + public static void Single(IEnumerable collection) + { + Assert.Single(collection); + // TODO: MSTEST: Assert.Equal(1, collection.Count()); + } + + public static void Single(IEnumerable collection, Func predicate) + { + var results = collection.Where(predicate); + Assert.Single(results); + // TODO: MSTEST: Assert.Equal(1, results.Count()); + } + + public static void Empty(IEnumerable collection) + { + Assert.Empty(collection); + // TODO: MSTEST: Assert.Equal(0, collection.Count()); + } + + public static void Empty(IEnumerable collection, Func predicate) + { + var results = collection.Where(predicate); + Assert.Empty(results); + // TODO: MSTEST: Assert.Equal(0, results.Count()); + } + // There appears to have been a bug in VC++, which might or might not have been partially // or completely corrected. It was unable to disambiguate a call to: // Xunit::Assert::Throws(System::Type^, System::Action^) diff --git a/src/internal/internal.sln b/src/internal/internal.sln index e8d8db17..5514e543 100644 --- a/src/internal/internal.sln +++ b/src/internal/internal.sln @@ -9,6 +9,10 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "WixInternal.TestSupport.Nat EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WixInternal.BaseBuildTasks.Sources", "WixInternal.BaseBuildTasks.Sources\WixInternal.BaseBuildTasks.Sources.csproj", "{6B654490-AB0D-4F94-B564-DAA80044D5A3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WixInternal.XunitTestSupport", "WixInternal.XunitTestSupport\WixInternal.XunitTestSupport.csproj", "{AF7C4730-583B-46F8-9BB6-16D1F0330932}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WixInternal.MSTestSupport", "WixInternal.MSTestSupport\WixInternal.MSTestSupport.csproj", "{E70898F2-8D08-4FCE-9CFF-EF1792FCA2E2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -65,6 +69,38 @@ Global {6B654490-AB0D-4F94-B564-DAA80044D5A3}.Release|x64.Build.0 = Release|Any CPU {6B654490-AB0D-4F94-B564-DAA80044D5A3}.Release|x86.ActiveCfg = Release|Any CPU {6B654490-AB0D-4F94-B564-DAA80044D5A3}.Release|x86.Build.0 = Release|Any CPU + {AF7C4730-583B-46F8-9BB6-16D1F0330932}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AF7C4730-583B-46F8-9BB6-16D1F0330932}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AF7C4730-583B-46F8-9BB6-16D1F0330932}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {AF7C4730-583B-46F8-9BB6-16D1F0330932}.Debug|ARM64.Build.0 = Debug|Any CPU + {AF7C4730-583B-46F8-9BB6-16D1F0330932}.Debug|x64.ActiveCfg = Debug|Any CPU + {AF7C4730-583B-46F8-9BB6-16D1F0330932}.Debug|x64.Build.0 = Debug|Any CPU + {AF7C4730-583B-46F8-9BB6-16D1F0330932}.Debug|x86.ActiveCfg = Debug|Any CPU + {AF7C4730-583B-46F8-9BB6-16D1F0330932}.Debug|x86.Build.0 = Debug|Any CPU + {AF7C4730-583B-46F8-9BB6-16D1F0330932}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AF7C4730-583B-46F8-9BB6-16D1F0330932}.Release|Any CPU.Build.0 = Release|Any CPU + {AF7C4730-583B-46F8-9BB6-16D1F0330932}.Release|ARM64.ActiveCfg = Release|Any CPU + {AF7C4730-583B-46F8-9BB6-16D1F0330932}.Release|ARM64.Build.0 = Release|Any CPU + {AF7C4730-583B-46F8-9BB6-16D1F0330932}.Release|x64.ActiveCfg = Release|Any CPU + {AF7C4730-583B-46F8-9BB6-16D1F0330932}.Release|x64.Build.0 = Release|Any CPU + {AF7C4730-583B-46F8-9BB6-16D1F0330932}.Release|x86.ActiveCfg = Release|Any CPU + {AF7C4730-583B-46F8-9BB6-16D1F0330932}.Release|x86.Build.0 = Release|Any CPU + {E70898F2-8D08-4FCE-9CFF-EF1792FCA2E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E70898F2-8D08-4FCE-9CFF-EF1792FCA2E2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E70898F2-8D08-4FCE-9CFF-EF1792FCA2E2}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {E70898F2-8D08-4FCE-9CFF-EF1792FCA2E2}.Debug|ARM64.Build.0 = Debug|Any CPU + {E70898F2-8D08-4FCE-9CFF-EF1792FCA2E2}.Debug|x64.ActiveCfg = Debug|Any CPU + {E70898F2-8D08-4FCE-9CFF-EF1792FCA2E2}.Debug|x64.Build.0 = Debug|Any CPU + {E70898F2-8D08-4FCE-9CFF-EF1792FCA2E2}.Debug|x86.ActiveCfg = Debug|Any CPU + {E70898F2-8D08-4FCE-9CFF-EF1792FCA2E2}.Debug|x86.Build.0 = Debug|Any CPU + {E70898F2-8D08-4FCE-9CFF-EF1792FCA2E2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E70898F2-8D08-4FCE-9CFF-EF1792FCA2E2}.Release|Any CPU.Build.0 = Release|Any CPU + {E70898F2-8D08-4FCE-9CFF-EF1792FCA2E2}.Release|ARM64.ActiveCfg = Release|Any CPU + {E70898F2-8D08-4FCE-9CFF-EF1792FCA2E2}.Release|ARM64.Build.0 = Release|Any CPU + {E70898F2-8D08-4FCE-9CFF-EF1792FCA2E2}.Release|x64.ActiveCfg = Release|Any CPU + {E70898F2-8D08-4FCE-9CFF-EF1792FCA2E2}.Release|x64.Build.0 = Release|Any CPU + {E70898F2-8D08-4FCE-9CFF-EF1792FCA2E2}.Release|x86.ActiveCfg = Release|Any CPU + {E70898F2-8D08-4FCE-9CFF-EF1792FCA2E2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/internal/internal_t.proj b/src/internal/internal_t.proj index 7dd52354..ab96b6c8 100644 --- a/src/internal/internal_t.proj +++ b/src/internal/internal_t.proj @@ -2,6 +2,7 @@ + -- cgit v1.2.3-55-g6feb