Thursday, June 26, 2008

How to use MSBuild and Wix to create msi package and then test scripts using NUnit?

Problem

Last few clients been requesting us to create MSBuild and Wix scripts to be incorporated into their daily builds as part of Continous Integration (CI) practice. I been struggling to find good documentation on how Wix work with MSBuild and how to create reusable Wix scripts.

1) How to integrate Wix scripts to MSBuild?
2) How to create generic Wix scripts that can be used in the future?
3) What is bootstrapper? How to create bootstrapper?
4) How to write unit test to test MSBuild and Wix 3.0 scripts?
5) What is t4 templates? How to write t4 templates to help with Wix?


References used to research and learn

http://blogs.technet.com/alexshev/pages/from-msi-to-wix.aspx
http://www.olegsych.com/2007/12/text-template-transformation-toolkit/
http://www.sedodream.com/
http://wixedit.sourceforge.net/
http://community.sharpdevelop.net/blogs/mattward/archive/2006/09/17/WixIntegration.aspx
http://msdn.microsoft.com/en-us/library/ms164294.aspx
http://msdn.microsoft.com/en-us/library/aa302186.aspx
http://geekswithblogs.net/vagmi.mudumbai/archive/2004/11/23/15669.aspx
http://blogs.msdn.com/robmen/archive/2003/10/18/56497.aspx

Assumption


1) The reader is somewhat familiar with MSBuild
1) The reader is somewhat familiar with Wix


About the project


1) MSBuild script generic.wixproj will be responsible executing Wix scripts which will create MSI packages.
2) MSBuild properties such as InstallVersion, UpgradeCode and any custom properties can be passed to Wix script as preprocessor. The custom properties passed to Wix scripts will be used like $(var.MyValueSentFromMSBuild).
3) Wix scripts are broken into fragments thanks to nice and short blog of best practice tips from http://geekswithblogs.net/vagmi.mudumbai/archive/2004/11/23/15669.aspx

Setup.wxs uses pre-built WixUI_InstallDir. Directory and Features in Setup.wxs only act as place holders and will be provided by external Wix fragment file called Files.wxs.


Download GenericWixDemo.zip


Requirements


.Net 3.5
VS2008
NUnit
Wix 3.0 toolset


Step by step instruction

1) First unzip the demo to GenericWixDemo directory
2) download latest Wix 3.0 toolset from Wix 3.0 toolset and unzip to GenericWixDemo



generic.wixproj


<Project ToolsVersion="3.5" DefaultTargets="BuildWix" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
 
   <!--
   ==================================================================================================
      These are REQUIRED by Setup.wxs WIX script
      [IN]
         "OutputName"
         "OutputType"
         "WixToolPath"
         "MsiOutputPath"
         "LicenseFile"
         "WixSetupFile"
         "WixUIDialogBmp"
         "WixUIBannerBmp"
         "ApplicationName"         
         "InstallVersion"
         "CompanyName"         
         "CompanyUID"
         "WixDirectoryFregment"
         "UpgradeCode"
         "ProductId"
         "Compressed"
         "Description"
   ==================================================================================================
   -->
   <PropertyGroup>
      <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
      <Cultures>en-US</Cultures>
      <DefineSolutionProperties>False</DefineSolutionProperties>
   </PropertyGroup>
 
   <PropertyGroup>
      <!-- 
      Required by Wix 
      -->
      <WixToolPath Condition="!HasTrailingSlash('$(WixToolPath)') ">$(WixToolPath)\</WixToolPath>
      <WixTasksPath>$(WixToolPath)WixTasks.dll</WixTasksPath>
      <WixTargetsPath>$(WixToolPath)Wix.targets</WixTargetsPath>
      <OutputPath Condition="'$(MsiOutputPath)' != ''">$(MsiOutputPath)</OutputPath>
      <OutputPath Condition="!HasTrailingSlash('$(OutputPath)') ">$(OutputPath)\</OutputPath>
 
      <!--   Required by Wix Preprocessing variables; 
            basically passing external masbuild property to Wix script.
            i.e. to use InstallVersion in Wix do $(var.INSTALLVERSION) -->
      <WixVariables>WixUILicenseRtf=$(LicenseFile);WixUIDialogBmp=$(WixUIDialogBmp);WixUIBannerBmp=$(WixUIBannerBmp)</WixVariables>
      <DefineConstants>
         INSTALLVERSION=$(InstallVersion)
         ;APPLICATIONNAME=$(ApplicationName)
         ;COMPANYNAME=$(CompanyName)
         ;COMPANYUID=$(CompanyUID)
         ;UPGRADECODE=$(UpgradeCode)
         ;PRODUCTID=$(ProductId)
         ;COMPRESSED=$(Compressed)
         ;DESCRIPTION=$(Description)
      </DefineConstants>
   </PropertyGroup>
 
   <PropertyGroup Condition="'$(Configuration)' == 'Debug'">
      <DebugSymbols Condition="'$(DebugSymbols)' == ''" >True</DebugSymbols>
      <DebugType Condition="'$(DebugType)' == ''" >Full</DebugType>
      <Optimize Condition="'$(Optimize)' == ''" >False</Optimize>
   </PropertyGroup>
 
   <PropertyGroup Condition="'$(DebugSymbols)' == 'Release' ">
      <DebugSymbols Condition="'$(DebugSymbols)' == ''" >False</DebugSymbols>
      <DebugType Condition=" '$(DebugType)' == ''" >None</DebugType>
      <Optimize Condition=" '$(Optimize)' == ''" >True</Optimize>
   </PropertyGroup>
 
   <Import Project="$(WixTargetsPath)" />
 
   <ItemGroup>
      <WixExtension Include="$(WixToolPath)WixNetFxExtension.dll" />
      <WixExtension Include="$(WixToolPath)WixUIExtension.dll" />
      <Compile Include="$(WixDirectoryFregment)" />
      <Compile Include="$(WixSetupFile)" />
   </ItemGroup>
 
 
   <!-- Create .Net 3.5 boostrapper to be used by Wix to install .net 3.5 if the user computer does not exist -->
   <ItemGroup>
      <BootstrapperFile Include="Microsoft.Net.Framework.3.5">
         <ProductName>Microsoft .NET Framework 3.5</ProductName>
      </BootstrapperFile>
      <BootstrapperFile Include="Microsoft.Windows.Installer.3.1">
         <ProductName>Windows Installer 3.1</ProductName>
      </BootstrapperFile>
   </ItemGroup>
 
   <Target Name="BuildWix" DependsOnTargets="ValidateGenericWix;Build">
      <GenerateBootstrapper
         ApplicationFile="$(OutputName).msi"
         ApplicationName="$(ApplicationName)"
         BootstrapperItems="@(BootstrapperFile)"
         OutputPath="$(OutputPath)"
         ComponentsLocation="HomeSite"
         Culture="en"
         CopyComponents="false"
         SupportUrl="http://www.microsoft.com/downloads/details.aspx?familyid=333325FD-AE52-4E35-B531-508D977D32A6"
       />
   </Target>
 
   <Target Name="ValidateGenericWix">
      <Error Condition="'$(OutputName)'==''" Text="REQUIRED OutputName. used for naming msi. i.e. [OutputName].msi"/>
      <Error Condition="'$(OutputType)'==''" Text="REQUIRED OutputType. Package(.msi), Module(.msm), Library(.wixlib)"/>
      <Error Condition="'$(WixToolPath)'==''" Text="REQUIRED WixToolPath. Provide path to where Wix is installed."/>
      <Error Condition="'$(MsiOutputPath)'==''" Text="REQUIRED MsiOutputPath. Where msi will be created to."/>
      <Error Condition="'$(LicenseFile)'==''" Text="REQUIRED LicenseFile. EULA for the company."/>
      <Error Condition="'$(WixSetupFile)'==''" Text="REQUIRED WixSetupFile. Main entry point of Wix script we can swap it with different Setup to compltely change the behavior of Wix script."/>
      <Error Condition="'$(WixUIDialogBmp)'==''" Text="REQUIRED WixUIDialogBmp. Branding company setup."/>
      <Error Condition="'$(WixUIBannerBmp)'==''" Text="REQUIRED WixUIBannerBmp. Branding company setup."/>
      <Error Condition="'$(ApplicationName)'==''" Text="REQUIRED ApplicationName"/>
      <Error Condition="'$(InstallVersion)'==''" Text="REQUIRED InstallVersion"/>
      <Error Condition="'$(CompanyName)'==''" Text="REQUIRED CompanyName."/>
      <Error Condition="'$(WixDirectoryFregment)'==''"  Text="REQUIRED WixDirectoryFregment. Every application provide its own directory, registry, components."/>
      <Error Condition="'$(UpgradeCode)'==''"  Text="REQUIRED UpgradeCode."/>
      <Error Condition="'$(ProductId)'==''"  Text="REQUIRED ProductId."/>
      <Error Condition="'$(CompanyUID)'==''"  Text="REQUIRED CompanyUID. It is used as unique required by Wix"/>
      <Error Condition="'$(Compressed)'==''"  Text="REQUIRED Compressed. Set it to no if admin install needed for patching."/>
      <Error Condition="'$(Description)'==''"  Text="REQUIRED Description."/>
   </Target>
 
</Project>


Unit Test


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.IO;
using System.Diagnostics;
using System.Management;
using Microsoft.Build.BuildEngine;
using Microsoft.Win32;
using System.Security.Cryptography;
using System.Xml.Serialization;
using System.Collections;
using NUnit.Core;
using NUnit.Framework;
 
namespace GenericWixTest
{
    [TestFixture]
    public class GenericWixScriptTest
    {
        private Engine _msbuild;
        private string _msbuildLog;
        private string _msbuildScript;
        private string _msiUnitTest;
        private bool _msiInstalled;
 
        [TestFixtureSetUp]
        public void Init()
        {
            _msiInstalled = false;
            _msbuild = new Engine();
            // Point to the path that contains the .NET Framework 3.5 CLR and tools
            _msbuild.BinPath = System.Runtime.InteropServices.RuntimeEnvironment.GetRuntimeDirectory();
 
            _msbuildLog = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "msbuild_wix_script.log");
            _msbuildScript = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "generic.wixproj");
            _msiUnitTest = AppDomain.CurrentDomain.BaseDirectory + @"\unittestmsioutput\UnitTest.msi";
        }
 
        [SetUp]
        public void InitPerTest()
        {
            _msiInstalled = false;
            Clean(_msbuildLog, _msiUnitTest);
        }
 
        [TearDown]
        public void DisposePerTest()
        {
        }
 
        [TestFixtureTearDown]
        public void Dispose()
        {
            if (_msbuild != null)
                _msbuild.UnregisterAllLoggers();
            if (_msiInstalled)
            {
                Process installerProcess = System.Diagnostics.Process.Start("msiexec", "/qn /x " + _msiUnitTest);
                installerProcess.WaitForExit();
            }
        }
 
        [Test]
        public void TestGenericWixUpgradeScript()
        {
            FileLogger logger = new FileLogger();
            logger.Parameters = "logfile=" + _msbuildLog;
            _msbuild.RegisterLogger(logger);
 
            // Variables required by Wix script
            string version = "2.0.0";
            string appName = "Unit Test Application";
            string companyName = "Unit Test Company";
            string companyUID = "UnitTestCompany";
            string wixDirectoryFregment = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"WxsAndSource\Files.wxs");
            string licenseFile = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"WxsAndSource\license.rtf");
            string wixSetupFile = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"WxsAndSource\Setup.wxs");
            string msiOutputPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"unittestmsioutput\");
            string outputType = "package";
            string wixUIDialogBmp = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"WxsAndSource\BackGround.bmp");
            string wixUIBannerBmp = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"WxsAndSource\Banner.bmp");
            string upgradecode = Guid.NewGuid().ToString();
            string productId = "{1479ADCA-EA02-4EEA-980F-F7E52D2A6509}";
            string description = "Unit Test Description";
            string compressed = "yes";
            // Wix build test
            string wixVersion = "3.0.2925.0";
            BuildPropertyGroup pg = new BuildPropertyGroup();
            DirectoryInfo dir = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory + string.Format(@"\..\..\..\wix-{0}-binaries\", wixVersion));
 
            string[] a = upgradecode.Split(';');
            // These are REQUIRED by Wix script
            pg.SetProperty("WixToolPath", dir.FullName);
            pg.SetProperty("OutputName", "UnitTest");
            pg.SetProperty("InstallVersion", version);
            pg.SetProperty("ApplicationName", appName);
            pg.SetProperty("CompanyName", companyName);
            pg.SetProperty("CompanyUID", companyUID);
            pg.SetProperty("WixDirectoryFregment", wixDirectoryFregment);
            pg.SetProperty("LicenseFile", licenseFile);
            pg.SetProperty("WixSetupFile", wixSetupFile);
            pg.SetProperty("MsiOutputPath", msiOutputPath);
            pg.SetProperty("OutputType", outputType);
            pg.SetProperty("WixUIDialogBmp", wixUIDialogBmp);
            pg.SetProperty("WixUIBannerBmp", wixUIBannerBmp);
            pg.SetProperty("UpgradeCode", upgradecode);
            pg.SetProperty("ProductId", productId);
            pg.SetProperty("Compressed", compressed);
            pg.SetProperty("Description", description);
            bool success = _msbuild.BuildProjectFile(_msbuildScript, new string[] { "BuildWix" }, pg);
            Assert.AreEqual(true, success, "msbuild failed.");
            Assert.AreEqual(true, File.Exists(_msiUnitTest));
 
 
            // msi install test
            string installedLicenFile = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), string.Format("{0}/license.rtf", appName));
            Process installerProcess;
            installerProcess = System.Diagnostics.Process.Start(_msiUnitTest, "/qn");
            installerProcess.WaitForExit();
            Assert.AreEqual(true, File.Exists(installedLicenFile));
            _msiInstalled = true;
 
 
            // Check installed version in registry
            RegistryKey regKey = Microsoft.Win32.Registry.LocalMachine;
            RegistryKey subKey1 = regKey.OpenSubKey(string.Format("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{0}", "{1479ADCA-EA02-4EEA-980F-F7E52D2A6509}"));
            Assert.AreEqual(true, subKey1 != null);
            Assert.AreEqual(version, subKey1.GetValue("DisplayVersion"));
            Assert.AreEqual(appName, subKey1.GetValue("DisplayName"));
 
            // msi uninstall test
            installerProcess = System.Diagnostics.Process.Start("msiexec", "/qn /x " + _msiUnitTest);
            installerProcess.WaitForExit();
            Assert.AreEqual(false, File.Exists(installedLicenFile));
 
            // Check to make sure regitry entries are clean
            subKey1 = regKey.OpenSubKey(string.Format("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{0}", "{1479ADCA-EA02-4EEA-980F-F7E52D2A6509}"));
            Assert.AreEqual(null, subKey1, "Uninstall did not clean the registry entries");
        }
 
        [Test]
        public void TestGenericWixPatchScript()
        {
            List<WxsKeyValue> list = new List<WxsKeyValue>();
            list.Add(new WxsKeyValue("1key", "1val"));
            list.Add(new WxsKeyValue("2key", "2val"));
            XmlSerializer ser = new XmlSerializer(typeof(List<WxsKeyValue>));
            string file = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "text.xml");
            using (Stream stream = File.Open(file, FileMode.Create))
            {
                ser.Serialize(stream, list);
            }
            using (Stream read = File.Open(file, FileMode.Open))
            {
                list = (List<WxsKeyValue>) ser.Deserialize(read);
            }
            WxsKeyValue found = list.Find(new WxsKeyMatch("1key").Match);
            Assert.AreEqual(list[0], found);
        }
 
        private string ComputeHash(string plainText, string hashAlgorithm, byte[] saltBytes)
        {
            // If salt is not specified, generate it on the fly.
            if (saltBytes == null)
            {
                // Define min and max salt sizes.
                int minSaltSize = 4;
                int maxSaltSize = 8;
 
                // Generate a random number for the size of the salt.
                Random random = new Random();
                int saltSize = random.Next(minSaltSize, maxSaltSize);
 
                // Allocate a byte array, which will hold the salt.
                saltBytes = new byte[saltSize];
 
                // Initialize a random number generator.
                RNGCryptoServiceProvider rng = new RNGCryptoServiceProvider();
 
                // Fill the salt with cryptographically strong byte values.
                rng.GetNonZeroBytes(saltBytes);
            }
 
            // Convert plain text into a byte array.
            byte[] plainTextBytes = Encoding.UTF8.GetBytes(plainText);
 
            // Allocate array, which will hold plain text and salt.
            byte[] plainTextWithSaltBytes =
                    new byte[plainTextBytes.Length + saltBytes.Length];
 
            // Copy plain text bytes into resulting array.
            for (int i = 0; i < plainTextBytes.Length; i++)
                plainTextWithSaltBytes[i] = plainTextBytes[i];
 
            // Append salt bytes to the resulting array.
            for (int i = 0; i < saltBytes.Length; i++)
                plainTextWithSaltBytes[plainTextBytes.Length + i] = saltBytes[i];
 
            // Because we support multiple hashing algorithms, we must define
            // hash object as a common (abstract) base class. We will specify the
            // actual hashing algorithm class later during object creation.
            HashAlgorithm hash;
 
            // Make sure hashing algorithm name is specified.
            if (hashAlgorithm == null)
                hashAlgorithm = "";
 
            // Initialize appropriate hashing algorithm class.
            switch (hashAlgorithm.ToUpper())
            {
                case "SHA1":
                    hash = new SHA1Managed();
                    break;
 
                case "SHA256":
                    hash = new SHA256Managed();
                    break;
 
                case "SHA384":
                    hash = new SHA384Managed();
                    break;
 
                case "SHA512":
                    hash = new SHA512Managed();
                    break;
 
                default:
                    hash = new MD5CryptoServiceProvider();
                    break;
            }
 
            // Compute hash value of our plain text with appended salt.
            byte[] hashBytes = hash.ComputeHash(plainTextWithSaltBytes);
 
            // Create array which will hold hash and original salt bytes.
            byte[] hashWithSaltBytes = new byte[hashBytes.Length +
                                                saltBytes.Length];
 
            // Copy hash bytes into resulting array.
            for (int i = 0; i < hashBytes.Length; i++)
                hashWithSaltBytes[i] = hashBytes[i];
 
            // Append salt bytes to the result.
            for (int i = 0; i < saltBytes.Length; i++)
                hashWithSaltBytes[hashBytes.Length + i] = saltBytes[i];
 
            // Convert result into a base64-encoded string.
            string hashValue = Convert.ToBase64String(hashWithSaltBytes);
 
            // Return the result.
            return hashValue;
        }
 
 
        private void RecurseDirectories(DirectoryInfo sourceDir, StringBuilder wixDirectory, StringBuilder wixFeature)
        {
            string src;
            string longName;
            string shortName;
            foreach (FileInfo file in sourceDir.GetFiles())
            {
                src = file.FullName;
                longName = file.Name;
                shortName = (file.Name.Length > 8 ? file.Name.Substring(0, 5) : file.Name) + (file.Extension.Length > 3 ? file.Extension.Substring(0, 3) : file.Extension);
            }
            foreach (DirectoryInfo dir in sourceDir.GetDirectories())
            {
                RecurseDirectories(dir, wixDirectory, wixFeature);
            }
        }
 
 
        private void Clean(params string[] files)
        {
            foreach (string file in files)
            {
                if (File.Exists(file))
                {
                    File.Delete(file);
                }
            }
        }
    }
    public class WxsKeyValue
    {
        public string WxsKey;
        public string CompId;
        public WxsKeyValue()
        {
        }
        public WxsKeyValue(string wxsKey, string wxsValue) 
        {
            WxsKey = wxsKey;
            CompId = wxsValue;
        }
    }
 
    public class WxsKeyMatch
    {
        private string _key;
 
        public WxsKeyMatch(string key)
        {
            _key = key;
        }
 
        public Predicate<WxsKeyValue> Match
        {
            get { return IsMatch; }
        }
 
        private bool IsMatch(WxsKeyValue wxsKeyValue)
        {
            if (_key.ToLower().Equals(wxsKeyValue.WxsKey.ToLower()))