From c843b47d6233153fa961c6d0e61edf7cedf255bb Mon Sep 17 00:00:00 2001 From: Rob Mensching Date: Tue, 8 Nov 2022 14:58:05 -0800 Subject: Separate WixInternal content from official WixToolset namespace --- src/internal/WixInternal.TestSupport/Builder.cs | 158 +++++++++ .../DisposableFileSystem.cs | 93 +++++ .../WixInternal.TestSupport/DotnetRunner.cs | 57 ++++ .../WixInternal.TestSupport/ExternalExecutable.cs | 374 +++++++++++++++++++++ .../ExternalExecutableResult.cs | 19 ++ .../WixInternal.TestSupport/FakeBuildEngine.cs | 33 ++ .../WixInternal.TestSupport/MsbuildRunner.cs | 112 ++++++ .../WixInternal.TestSupport/MsbuildRunnerResult.cs | 19 ++ .../WixInternal.TestSupport/MsbuildUtilities.cs | 101 ++++++ src/internal/WixInternal.TestSupport/Pushd.cs | 46 +++ src/internal/WixInternal.TestSupport/Query.cs | 172 ++++++++++ .../WixInternal.TestSupport/RobocopyRunner.cs | 16 + src/internal/WixInternal.TestSupport/TestData.cs | 78 +++++ .../TestDataFolderFileSystem.cs | 42 +++ .../WixInternal.TestSupport/VswhereRunner.cs | 41 +++ .../WixInternal.TestSupport.csproj | 27 ++ .../XunitExtensions/SkipTestException.cs | 15 + .../XunitExtensions/SkippableFactAttribute.cs | 13 + .../XunitExtensions/SkippableFactDiscoverer.cs | 23 ++ .../XunitExtensions/SkippableFactMessageBus.cs | 40 +++ .../XunitExtensions/SkippableFactTestCase.cs | 40 +++ .../XunitExtensions/SkippableTheoryAttribute.cs | 12 + .../XunitExtensions/SkippableTheoryDiscoverer.cs | 41 +++ .../XunitExtensions/SkippableTheoryTestCase.cs | 41 +++ .../XunitExtensions/SpecificReturnCodeException.cs | 20 ++ .../XunitExtensions/SucceededException.cs | 19 ++ .../XunitExtensions/WixAssert.cs | 201 +++++++++++ 27 files changed, 1853 insertions(+) create mode 100644 src/internal/WixInternal.TestSupport/Builder.cs create mode 100644 src/internal/WixInternal.TestSupport/DisposableFileSystem.cs create mode 100644 src/internal/WixInternal.TestSupport/DotnetRunner.cs create mode 100644 src/internal/WixInternal.TestSupport/ExternalExecutable.cs create mode 100644 src/internal/WixInternal.TestSupport/ExternalExecutableResult.cs create mode 100644 src/internal/WixInternal.TestSupport/FakeBuildEngine.cs create mode 100644 src/internal/WixInternal.TestSupport/MsbuildRunner.cs create mode 100644 src/internal/WixInternal.TestSupport/MsbuildRunnerResult.cs create mode 100644 src/internal/WixInternal.TestSupport/MsbuildUtilities.cs create mode 100644 src/internal/WixInternal.TestSupport/Pushd.cs create mode 100644 src/internal/WixInternal.TestSupport/Query.cs create mode 100644 src/internal/WixInternal.TestSupport/RobocopyRunner.cs create mode 100644 src/internal/WixInternal.TestSupport/TestData.cs create mode 100644 src/internal/WixInternal.TestSupport/TestDataFolderFileSystem.cs create mode 100644 src/internal/WixInternal.TestSupport/VswhereRunner.cs create mode 100644 src/internal/WixInternal.TestSupport/WixInternal.TestSupport.csproj create mode 100644 src/internal/WixInternal.TestSupport/XunitExtensions/SkipTestException.cs create mode 100644 src/internal/WixInternal.TestSupport/XunitExtensions/SkippableFactAttribute.cs create mode 100644 src/internal/WixInternal.TestSupport/XunitExtensions/SkippableFactDiscoverer.cs create mode 100644 src/internal/WixInternal.TestSupport/XunitExtensions/SkippableFactMessageBus.cs create mode 100644 src/internal/WixInternal.TestSupport/XunitExtensions/SkippableFactTestCase.cs create mode 100644 src/internal/WixInternal.TestSupport/XunitExtensions/SkippableTheoryAttribute.cs create mode 100644 src/internal/WixInternal.TestSupport/XunitExtensions/SkippableTheoryDiscoverer.cs create mode 100644 src/internal/WixInternal.TestSupport/XunitExtensions/SkippableTheoryTestCase.cs create mode 100644 src/internal/WixInternal.TestSupport/XunitExtensions/SpecificReturnCodeException.cs create mode 100644 src/internal/WixInternal.TestSupport/XunitExtensions/SucceededException.cs create mode 100644 src/internal/WixInternal.TestSupport/XunitExtensions/WixAssert.cs (limited to 'src/internal/WixInternal.TestSupport') diff --git a/src/internal/WixInternal.TestSupport/Builder.cs b/src/internal/WixInternal.TestSupport/Builder.cs new file mode 100644 index 00000000..fa6e0064 --- /dev/null +++ b/src/internal/WixInternal.TestSupport/Builder.cs @@ -0,0 +1,158 @@ +// 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.TestSupport +{ + 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; + this.ExtensionType = extensionType; + this.BindPaths = bindPaths; + this.OutputFile = outputFile ?? "test.msi"; + } + + public string[] BindPaths { get; set; } + + public Type ExtensionType { get; set; } + + public string OutputFile { get; set; } + + public string SourceFolder { get; } + + public string[] BuildAndQuery(Action buildFunc, 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, + }; + + if (this.ExtensionType != null) + { + args.Add("-ext"); + args.Add(Path.GetFullPath(new Uri(this.ExtensionType.Assembly.CodeBase).LocalPath)); + } + + 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()); + + return Query.QueryDatabase(outputPath, tables); + } + } + + public void BuildAndDecompileAndBuild(Action buildFunc, Action decompileFunc, string decompilePath) + { + 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, + }; + + if (this.ExtensionType != null) + { + firstBuildArgs.Add("-ext"); + firstBuildArgs.Add(Path.GetFullPath(new Uri(this.ExtensionType.Assembly.CodeBase).LocalPath)); + } + + 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()); + + // Decompile built output. + var decompileArgs = new List + { + "msi", "decompile", + outputPath, + "-intermediateFolder", decompileIntermediateFolder, + "-x", decompileExtractFolder, + "-o", decompilePath + }; + + if (this.ExtensionType != null) + { + decompileArgs.Add("-ext"); + decompileArgs.Add(Path.GetFullPath(new Uri(this.ExtensionType.Assembly.CodeBase).LocalPath)); + } + + decompileFunc(decompileArgs.ToArray()); + + // Build decompiled output. + var secondBuildArgs = new List + { + "build", + decompilePath, + "-o", decompileBuildPath, + "-intermediateFolder", decompileIntermediateFolder + }; + + if (this.ExtensionType != null) + { + secondBuildArgs.Add("-ext"); + secondBuildArgs.Add(Path.GetFullPath(new Uri(this.ExtensionType.Assembly.CodeBase).LocalPath)); + } + + secondBuildArgs.Add("-bindpath"); + secondBuildArgs.Add(outputFolder); + + secondBuildArgs.Add("-bindpath"); + secondBuildArgs.Add(decompileExtractFolder); + + buildFunc(secondBuildArgs.ToArray()); + } + } + } +} diff --git a/src/internal/WixInternal.TestSupport/DisposableFileSystem.cs b/src/internal/WixInternal.TestSupport/DisposableFileSystem.cs new file mode 100644 index 00000000..b03bbaa4 --- /dev/null +++ b/src/internal/WixInternal.TestSupport/DisposableFileSystem.cs @@ -0,0 +1,93 @@ +// 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.TestSupport +{ + 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) + { + var path = Path.Combine(Path.GetTempPath(), 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.TestSupport/DotnetRunner.cs b/src/internal/WixInternal.TestSupport/DotnetRunner.cs new file mode 100644 index 00000000..befd8952 --- /dev/null +++ b/src/internal/WixInternal.TestSupport/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.TestSupport +{ + 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.TestSupport/ExternalExecutable.cs b/src/internal/WixInternal.TestSupport/ExternalExecutable.cs new file mode 100644 index 00000000..e0345dfb --- /dev/null +++ b/src/internal/WixInternal.TestSupport/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.TestSupport +{ + 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) + { + 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.TestSupport/ExternalExecutableResult.cs b/src/internal/WixInternal.TestSupport/ExternalExecutableResult.cs new file mode 100644 index 00000000..a142e07a --- /dev/null +++ b/src/internal/WixInternal.TestSupport/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.TestSupport +{ + 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.TestSupport/FakeBuildEngine.cs b/src/internal/WixInternal.TestSupport/FakeBuildEngine.cs new file mode 100644 index 00000000..c1f07708 --- /dev/null +++ b/src/internal/WixInternal.TestSupport/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.TestSupport +{ + 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.TestSupport/MsbuildRunner.cs b/src/internal/WixInternal.TestSupport/MsbuildRunner.cs new file mode 100644 index 00000000..2cbf5e91 --- /dev/null +++ b/src/internal/WixInternal.TestSupport/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.TestSupport +{ + 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.TestSupport/MsbuildRunnerResult.cs b/src/internal/WixInternal.TestSupport/MsbuildRunnerResult.cs new file mode 100644 index 00000000..b85acb16 --- /dev/null +++ b/src/internal/WixInternal.TestSupport/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.TestSupport +{ + using System; + using Xunit; + + public class MsbuildRunnerResult + { + public int ExitCode { get; set; } + + public string[] Output { get; set; } + + public void AssertSuccess() + { + Assert.True(0 == this.ExitCode, $"MSBuild failed unexpectedly. Output:{Environment.NewLine}{String.Join(Environment.NewLine, this.Output)}"); + } + } +} diff --git a/src/internal/WixInternal.TestSupport/MsbuildUtilities.cs b/src/internal/WixInternal.TestSupport/MsbuildUtilities.cs new file mode 100644 index 00000000..5560f993 --- /dev/null +++ b/src/internal/WixInternal.TestSupport/MsbuildUtilities.cs @@ -0,0 +1,101 @@ +// 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 WixToolsetTest.Sdk +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using WixInternal.TestSupport; + + 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", + $"-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 GetQuotedPropertySwitch(BuildSystem buildSystem, string propertyName, string valueToQuote) + { + switch (buildSystem) + { + case BuildSystem.DotNetCoreSdk: + { + // If the value ends with a backslash, double-escape it (it should end up with four backslashes). + if (valueToQuote?.EndsWith("\\") == true) + { + valueToQuote += @"\\\"; + } + + return $"-p:{propertyName}=\\\"{valueToQuote}\\\""; + } + case BuildSystem.MSBuild: + case BuildSystem.MSBuild64: + { + // If the value ends with a backslash, escape it. + if (valueToQuote?.EndsWith("\\") == true) + { + valueToQuote += @"\"; + } + + return $"-p:{propertyName}=\"{valueToQuote}\""; + } + default: + { + throw new NotImplementedException(); + } + } + } + + 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.TestSupport/Pushd.cs b/src/internal/WixInternal.TestSupport/Pushd.cs new file mode 100644 index 00000000..43737382 --- /dev/null +++ b/src/internal/WixInternal.TestSupport/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.TestSupport +{ + 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.TestSupport/Query.cs b/src/internal/WixInternal.TestSupport/Query.cs new file mode 100644 index 00000000..38f5df64 --- /dev/null +++ b/src/internal/WixInternal.TestSupport/Query.cs @@ -0,0 +1,172 @@ +// 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.TestSupport +{ + 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(); + } + + /// + /// 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) + { + var sb = new StringBuilder(); + using (var db = new Database(path)) + { + 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.TestSupport/RobocopyRunner.cs b/src/internal/WixInternal.TestSupport/RobocopyRunner.cs new file mode 100644 index 00000000..d93fbaa0 --- /dev/null +++ b/src/internal/WixInternal.TestSupport/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.TestSupport +{ + 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.TestSupport/TestData.cs b/src/internal/WixInternal.TestSupport/TestData.cs new file mode 100644 index 00000000..05691e8c --- /dev/null +++ b/src/internal/WixInternal.TestSupport/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.TestSupport +{ + 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 = Path.GetDirectoryName(new Uri(Assembly.GetCallingAssembly().CodeBase).LocalPath); + return Path.Combine(localPath, Path.Combine(paths)); + } + + public static string GetUnitTestLogsFolder([CallerFilePath] string path = "", [CallerMemberName] string method = "") + { + var startingPath = Path.GetDirectoryName(new Uri(Assembly.GetCallingAssembly().CodeBase).LocalPath); + 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.TestSupport/TestDataFolderFileSystem.cs b/src/internal/WixInternal.TestSupport/TestDataFolderFileSystem.cs new file mode 100644 index 00000000..38b05101 --- /dev/null +++ b/src/internal/WixInternal.TestSupport/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.TestSupport +{ + 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.TestSupport/VswhereRunner.cs b/src/internal/WixInternal.TestSupport/VswhereRunner.cs new file mode 100644 index 00000000..f7361423 --- /dev/null +++ b/src/internal/WixInternal.TestSupport/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.TestSupport +{ + 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.TestSupport/WixInternal.TestSupport.csproj b/src/internal/WixInternal.TestSupport/WixInternal.TestSupport.csproj new file mode 100644 index 00000000..4e1c3c26 --- /dev/null +++ b/src/internal/WixInternal.TestSupport/WixInternal.TestSupport.csproj @@ -0,0 +1,27 @@ + + + + + + + netstandard2.0;net472 + true + embedded + true + true + $(NoWarn);CS1591 + false + true + + + + + + + + + + + + + diff --git a/src/internal/WixInternal.TestSupport/XunitExtensions/SkipTestException.cs b/src/internal/WixInternal.TestSupport/XunitExtensions/SkipTestException.cs new file mode 100644 index 00000000..6ef30829 --- /dev/null +++ b/src/internal/WixInternal.TestSupport/XunitExtensions/SkipTestException.cs @@ -0,0 +1,15 @@ +// 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.TestSupport.XunitExtensions +{ + using System; + + public class SkipTestException : Exception + { + public SkipTestException(string reason) + : base(reason) + { + + } + } +} diff --git a/src/internal/WixInternal.TestSupport/XunitExtensions/SkippableFactAttribute.cs b/src/internal/WixInternal.TestSupport/XunitExtensions/SkippableFactAttribute.cs new file mode 100644 index 00000000..ba4678c8 --- /dev/null +++ b/src/internal/WixInternal.TestSupport/XunitExtensions/SkippableFactAttribute.cs @@ -0,0 +1,13 @@ +// 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.TestSupport.XunitExtensions +{ + using Xunit; + using Xunit.Sdk; + + // https://github.com/xunit/samples.xunit/blob/5dc1d35a63c3394a8678ac466b882576a70f56f6/DynamicSkipExample + [XunitTestCaseDiscoverer("WixInternal.TestSupport.XunitExtensions.SkippableFactDiscoverer", "WixInternal.TestSupport")] + public class SkippableFactAttribute : FactAttribute + { + } +} diff --git a/src/internal/WixInternal.TestSupport/XunitExtensions/SkippableFactDiscoverer.cs b/src/internal/WixInternal.TestSupport/XunitExtensions/SkippableFactDiscoverer.cs new file mode 100644 index 00000000..d5061b04 --- /dev/null +++ b/src/internal/WixInternal.TestSupport/XunitExtensions/SkippableFactDiscoverer.cs @@ -0,0 +1,23 @@ +// 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.TestSupport.XunitExtensions +{ + using System.Collections.Generic; + using Xunit.Abstractions; + using Xunit.Sdk; + + public class SkippableFactDiscoverer : IXunitTestCaseDiscoverer + { + private IMessageSink DiagnosticMessageSink { get; } + + public SkippableFactDiscoverer(IMessageSink diagnosticMessageSink) + { + this.DiagnosticMessageSink = diagnosticMessageSink; + } + + public IEnumerable Discover(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo factAttribute) + { + yield return new SkippableFactTestCase(this.DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), discoveryOptions.MethodDisplayOptionsOrDefault(), testMethod); + } + } +} diff --git a/src/internal/WixInternal.TestSupport/XunitExtensions/SkippableFactMessageBus.cs b/src/internal/WixInternal.TestSupport/XunitExtensions/SkippableFactMessageBus.cs new file mode 100644 index 00000000..dd143c93 --- /dev/null +++ b/src/internal/WixInternal.TestSupport/XunitExtensions/SkippableFactMessageBus.cs @@ -0,0 +1,40 @@ +// 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.TestSupport.XunitExtensions +{ + using System.Linq; + using Xunit.Abstractions; + using Xunit.Sdk; + + public class SkippableFactMessageBus : IMessageBus + { + private IMessageBus InnerBus { get; } + + public SkippableFactMessageBus(IMessageBus innerBus) + { + this.InnerBus = innerBus; + } + + public int DynamicallySkippedTestCount { get; private set; } + + public void Dispose() + { + } + + public bool QueueMessage(IMessageSinkMessage message) + { + if (message is ITestFailed testFailed) + { + var exceptionType = testFailed.ExceptionTypes.FirstOrDefault(); + if (exceptionType == typeof(SkipTestException).FullName) + { + ++this.DynamicallySkippedTestCount; + return this.InnerBus.QueueMessage(new TestSkipped(testFailed.Test, testFailed.Messages.FirstOrDefault())); + } + } + + // Nothing we care about, send it on its way + return this.InnerBus.QueueMessage(message); + } + } +} diff --git a/src/internal/WixInternal.TestSupport/XunitExtensions/SkippableFactTestCase.cs b/src/internal/WixInternal.TestSupport/XunitExtensions/SkippableFactTestCase.cs new file mode 100644 index 00000000..7a78b820 --- /dev/null +++ b/src/internal/WixInternal.TestSupport/XunitExtensions/SkippableFactTestCase.cs @@ -0,0 +1,40 @@ +// 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.TestSupport.XunitExtensions +{ + using System; + using System.ComponentModel; + using System.Threading; + using System.Threading.Tasks; + using Xunit.Abstractions; + using Xunit.Sdk; + + public class SkippableFactTestCase : XunitTestCase + { + [EditorBrowsable(EditorBrowsableState.Never)] + [Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes")] + public SkippableFactTestCase() { } + + public SkippableFactTestCase(IMessageSink diagnosticMessageSink, TestMethodDisplay defaultMethodDisplay, TestMethodDisplayOptions defaultMethodDisplayOptions, ITestMethod testMethod, object[] testMethodArguments = null) + : base(diagnosticMessageSink, defaultMethodDisplay, defaultMethodDisplayOptions, testMethod, testMethodArguments) + { + } + + public override async Task RunAsync(IMessageSink diagnosticMessageSink, + IMessageBus messageBus, + object[] constructorArguments, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) + { + var skipMessageBus = new SkippableFactMessageBus(messageBus); + var result = await base.RunAsync(diagnosticMessageSink, skipMessageBus, constructorArguments, aggregator, cancellationTokenSource); + if (skipMessageBus.DynamicallySkippedTestCount > 0) + { + result.Failed -= skipMessageBus.DynamicallySkippedTestCount; + result.Skipped += skipMessageBus.DynamicallySkippedTestCount; + } + + return result; + } + } +} diff --git a/src/internal/WixInternal.TestSupport/XunitExtensions/SkippableTheoryAttribute.cs b/src/internal/WixInternal.TestSupport/XunitExtensions/SkippableTheoryAttribute.cs new file mode 100644 index 00000000..1ed04e01 --- /dev/null +++ b/src/internal/WixInternal.TestSupport/XunitExtensions/SkippableTheoryAttribute.cs @@ -0,0 +1,12 @@ +// 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.TestSupport.XunitExtensions +{ + using Xunit; + using Xunit.Sdk; + + [XunitTestCaseDiscoverer("WixInternal.TestSupport.XunitExtensions.SkippableFactDiscoverer", "WixInternal.TestSupport")] + public class SkippableTheoryAttribute : TheoryAttribute + { + } +} diff --git a/src/internal/WixInternal.TestSupport/XunitExtensions/SkippableTheoryDiscoverer.cs b/src/internal/WixInternal.TestSupport/XunitExtensions/SkippableTheoryDiscoverer.cs new file mode 100644 index 00000000..bf850cba --- /dev/null +++ b/src/internal/WixInternal.TestSupport/XunitExtensions/SkippableTheoryDiscoverer.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.TestSupport.XunitExtensions +{ + using System.Collections.Generic; + using Xunit.Abstractions; + using Xunit.Sdk; + + public class SkippableTheoryDiscoverer : IXunitTestCaseDiscoverer + { + private IMessageSink DiagnosticMessageSink { get; } + private TheoryDiscoverer TheoryDiscoverer { get; } + + public SkippableTheoryDiscoverer(IMessageSink diagnosticMessageSink) + { + this.DiagnosticMessageSink = diagnosticMessageSink; + + this.TheoryDiscoverer = new TheoryDiscoverer(diagnosticMessageSink); + } + + public IEnumerable Discover(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo factAttribute) + { + var defaultMethodDisplay = discoveryOptions.MethodDisplayOrDefault(); + var defaultMethodDisplayOptions = discoveryOptions.MethodDisplayOptionsOrDefault(); + + // Unlike fact discovery, the underlying algorithm for theories is complex, so we let the theory discoverer + // do its work, and do a little on-the-fly conversion into our own test cases. + foreach (var testCase in this.TheoryDiscoverer.Discover(discoveryOptions, testMethod, factAttribute)) + { + if (testCase is XunitTheoryTestCase) + { + yield return new SkippableTheoryTestCase(this.DiagnosticMessageSink, defaultMethodDisplay, defaultMethodDisplayOptions, testCase.TestMethod); + } + else + { + yield return new SkippableFactTestCase(this.DiagnosticMessageSink, defaultMethodDisplay, defaultMethodDisplayOptions, testCase.TestMethod, testCase.TestMethodArguments); + } + } + } + } +} diff --git a/src/internal/WixInternal.TestSupport/XunitExtensions/SkippableTheoryTestCase.cs b/src/internal/WixInternal.TestSupport/XunitExtensions/SkippableTheoryTestCase.cs new file mode 100644 index 00000000..d9b7d068 --- /dev/null +++ b/src/internal/WixInternal.TestSupport/XunitExtensions/SkippableTheoryTestCase.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.TestSupport.XunitExtensions +{ + using System; + using System.ComponentModel; + using System.Threading; + using System.Threading.Tasks; + using Xunit.Abstractions; + using Xunit.Sdk; + + public class SkippableTheoryTestCase : XunitTheoryTestCase + { + [EditorBrowsable(EditorBrowsableState.Never)] + [Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes")] + public SkippableTheoryTestCase() { } + + public SkippableTheoryTestCase(IMessageSink diagnosticMessageSink, TestMethodDisplay defaultMethodDisplay, TestMethodDisplayOptions defaultMethodDisplayOptions, ITestMethod testMethod) + : base(diagnosticMessageSink, defaultMethodDisplay, defaultMethodDisplayOptions, testMethod) + { + } + + public override async Task RunAsync(IMessageSink diagnosticMessageSink, + IMessageBus messageBus, + object[] constructorArguments, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) + { + // Duplicated code from SkippableFactTestCase. I'm sure we could find a way to de-dup with some thought. + var skipMessageBus = new SkippableFactMessageBus(messageBus); + var result = await base.RunAsync(diagnosticMessageSink, skipMessageBus, constructorArguments, aggregator, cancellationTokenSource); + if (skipMessageBus.DynamicallySkippedTestCount > 0) + { + result.Failed -= skipMessageBus.DynamicallySkippedTestCount; + result.Skipped += skipMessageBus.DynamicallySkippedTestCount; + } + + return result; + } + } +} diff --git a/src/internal/WixInternal.TestSupport/XunitExtensions/SpecificReturnCodeException.cs b/src/internal/WixInternal.TestSupport/XunitExtensions/SpecificReturnCodeException.cs new file mode 100644 index 00000000..49be13d5 --- /dev/null +++ b/src/internal/WixInternal.TestSupport/XunitExtensions/SpecificReturnCodeException.cs @@ -0,0 +1,20 @@ +// 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.TestSupport +{ + using System; + using Xunit.Sdk; + + public class SpecificReturnCodeException : XunitException + { + public SpecificReturnCodeException(int hrExpected, int hr, string userMessage) + : base(String.Format("WixAssert.SpecificReturnCode() Failure\r\n" + + "Expected HRESULT: 0x{0:X8}\r\n" + + "Actual HRESULT: 0x{1:X8}\r\n" + + "Message: {2}", + hrExpected, hr, userMessage)) + { + this.HResult = hr; + } + } +} diff --git a/src/internal/WixInternal.TestSupport/XunitExtensions/SucceededException.cs b/src/internal/WixInternal.TestSupport/XunitExtensions/SucceededException.cs new file mode 100644 index 00000000..d50c07ed --- /dev/null +++ b/src/internal/WixInternal.TestSupport/XunitExtensions/SucceededException.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.TestSupport +{ + using System; + using Xunit.Sdk; + + public class SucceededException : XunitException + { + public SucceededException(int hr, string userMessage) + : base(String.Format("WixAssert.Succeeded() Failure\r\n" + + "HRESULT: 0x{0:X8}\r\n" + + "Message: {1}", + hr, userMessage)) + { + this.HResult = hr; + } + } +} diff --git a/src/internal/WixInternal.TestSupport/XunitExtensions/WixAssert.cs b/src/internal/WixInternal.TestSupport/XunitExtensions/WixAssert.cs new file mode 100644 index 00000000..66f831a1 --- /dev/null +++ b/src/internal/WixInternal.TestSupport/XunitExtensions/WixAssert.cs @@ -0,0 +1,201 @@ +// 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.TestSupport +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text; + using System.Xml.Linq; + using WixInternal.TestSupport.XunitExtensions; + using Xunit; + using Xunit.Sdk; + + public class WixAssert : Assert + { + public static void CompareLineByLine(string[] expectedLines, string[] actualLines) + { + var lineNumber = 0; + + for (; lineNumber < expectedLines.Length && lineNumber < actualLines.Length; ++lineNumber) + { + WixAssert.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 = xExpected.Descendants().Select(x => $"{x.Name.LocalName}:{String.Join(",", x.Attributes().OrderBy(a => a.Name.LocalName).Select(a => $"{a.Name.LocalName}={a.Value}"))}"); + var actuals = xActual.Descendants().Select(x => $"{x.Name.LocalName}:{String.Join(",", x.Attributes().OrderBy(a => a.Name.LocalName).Select(a => $"{a.Name.LocalName}={a.Value}"))}"); + + 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); + } + + /// + /// Dynamically skips the test. + /// Requires that the test was marked with a fact attribute derived from + /// or + /// + public static void Skip(string message) + { + throw new SkipTestException(message); + } + + public static void SpecificReturnCode(int hrExpected, int hr, string format, params object[] formatArgs) + { + if (hrExpected != hr) + { + throw new SpecificReturnCodeException(hrExpected, hr, String.Format(format, formatArgs)); + } + } + + public static void Succeeded(int hr, string format, params object[] formatArgs) + { + if (0 > hr) + { + throw new SucceededException(hr, String.Format(format, formatArgs)); + } + } + + public static void StringCollectionEmpty(IList collection) + { + if (collection.Count > 0) + { + Assert.True(false, $"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); + } + + // 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^) + // from a call to: + // Xunit::Assert::Throws(System::Type^, System::Func^) + // that implicitly ignores its return value. + // + // The ambiguity may have been reported by some versions of the compiler and not by others. + // Some versions of the compiler may not have emitted any code in this situation, making it + // appear that the test has passed when, in fact, it hasn't been run. + // + // This situation is not an issue for C#. + // + // The following method is used to isolate DUtilTests in order to overcome the above problem. + + /// + /// This shim allows C++/CLR code to call the Xunit method with the same signature + /// without getting an ambiguous overload error. If the specified test code + /// fails to generate an exception of the exact specified type, an assertion + /// exception is thrown. Otherwise, execution flow proceeds as normal. + /// + /// The type name of the expected exception. + /// An Action delegate to run the test code. + public static new void Throws(System.Action testCode) + where T : System.Exception + { + Xunit.Assert.Throws(testCode); + } + + // This shim has been tested, but is not currently used anywhere. It was provided + // at the same time as the preceding shim because it involved the same overload + // resolution conflict. + + /// + /// This shim allows C++/CLR code to call the Xunit method with the same signature + /// without getting an ambiguous overload error. If the specified test code + /// fails to generate an exception of the exact specified type, an assertion + /// exception is thrown. Otherwise, execution flow proceeds as normal. + /// + /// The type object associated with exceptions of the expected type. + /// An Action delegate to run the test code. + /// An exception of a type other than the type specified, is such an exception is thrown. + public static new System.Exception Throws(System.Type exceptionType, System.Action testCode) + { + return Xunit.Assert.Throws(exceptionType, testCode); + } + } + + 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()); + } + } +} -- cgit v1.2.3-55-g6feb