Thursday, January 3, 2013

Continuous Integration, how do I declare commonly derived MSBuild metadata?

So, following some recent posts on MSBuild-NUnit integration, I went back and reviewed some of the implementation and wondered if there were easier ways to reference commonly derived metadata.

The motivation for this post stems from issues I encountered with long paths and paths that contain spaces; eg "C:\Users\johnny g\Documents\Visual Studio 2012\Projects\UnitTests\Example.Continuous\..\Example.UnitTests\bin\debug". Both present particular logistic problems when commands require multiple paths as inputs.

So, what follows are a number of refinements I picked up in my exploration to address some of these issues.

Custom ItemGroup versus ProjectReference


In previous examples, I prescribed custom ItemGroups, like TestAssembly below

<ItemGroup>
  <TestAssembly Include="Example.UnitTests.dll">
    <WorkingDirectory>
      $(MSBuildProjectDirectory)\..\Example.UnitTests\$(OutputPath)
    </WorkingDirectory>
  </TestAssembly>
</ItemGroup>

I prefered this since it would not collide nor compromise any other build metadata. However, it does produce some visual artifacts in Visual Studio, and requires a manual edit of the project file to modify.

Figure 1, custom TestAssembly item appears as missing dll.

A better solution, which would resolve these issues and facilitate adding test projects, is to leverage the well-known ItemGroup ProjectReference.

Adding ProjectReference Items is as simple as using "Add Reference" feature from Visual Studio. However, to add custom metadata for these Items will require a little finesse, but that is what follows below!

Extending ItemGroup metadata


MSBuild permits the extension of existing metadata through the use of ItemDefinitionGroups. This will work for custom Items, such as TestAssembly, or well-known Items, like ProjectReference.

When we add a project reference via Visual Studio, we end up with a ProjectReference Item

<ItemGroup>
  <ProjectReference Include="..\Example.UnitTests\Example.UnitTests.csproj">
    <Project>{eaac5f22-bfb8-4df7-a711-126907831a0f}</Project>
    <Name>Example.UnitTests</Name>
  </ProjectReference>
</ItemGroup>

To add custom metadata, we simply define an ItemDefinitionGroup.

For example, let's define both a WorkingDirectory, and an OutputFile for our test libraries,

<ItemDefinitionGroup>
  <ProjectReference>
    <WorkingDirectory>%(RootDir)%(Directory)$(OutputPath)</WorkingDirectory>
    <OutputFile>%(Filename).dll</OutputFile>
  </ProjectReference>
</ItemDefinitionGroup>

Essentially, for each ProjectReference Item, MSBuild will add custom metadata WorkingDirectory, composed of well-known per-Item metadata RootDir, Directory, and project defined OutputPath. Similarly, OutputFile is a simple concatenation of per-Item metadata Filename and literal ".dll".

For a full list of well-known Item metadata, take a peek at this MSDN reference page.

From relative to absolute paths


When a project exists in a sibling directory we will often end up with relative paths which, when squeezing the most out of a command line, chews up valuable character space. We can mitigate this by converting unevaluated relative paths to absolute paths.

MSBuild exposes a task for path conversion (ConvertToAbsolutePathTask), but we may also inline static method calls; eg $([System.IO.Path]::GetFullPath(C:\Temp\..\Some.Other.Path)). For our purposes, we'll use the latter since we have only a handful of paths to define and the syntax is easy enough to manage.

Really, we only require a WorkingDirectory, a resources folder, and an artifact folder. We have already defined an absolute path WorkingDirectory in our ItemDefinitionGroup, so that leaves resources and artifacts.

Since our tools and output folders are not likely to vary from Item to Item, let's define some project properties to capture these paths.

<PropertyGroup>
  <TestResultsFolder>
    $([System.IO.Path]::GetFullPath($(MSBuildProjectDirectory)\..\TestResults\))
  </TestResultsFolder>
  <ResourcesFolder>
    $([System.IO.Path]::GetFullPath($(MSBuildProjectDirectory)\..\Resources\))
  </ResourcesFolder>
</PropertyGroup>

For example, these will evaluate from "C:\Projects\Example.Continuous\..\Resources\" to "C:\Projects\Resources\". With these root definitions defined, we can then define other properties and metadata like

  <PropertyGroup>
    <NUnitExe>$(ResourcesFolder)NUnit-2.6.0.12051\nunit-console.exe</NUnitExe>
    <NUnitExe-x86>$(ResourcesFolder)NUnit-2.6.0.12051\nunit-console-x86.exe</NUnitExe-x86>
    <NUnitTestResultsFolder>$(TestResultsFolder)NUnit\</NUnitTestResultsFolder>
  </PropertyGroup>

  <ItemDefinitionGroup>
    <ProjectReference>
      <NUnitTestResultsFile>$(NUnitTestResultsFolder)%(OutputFile).xml</NUnitTestResultsFile>
    </ProjectReference>
  </ItemDefinitionGroup>

Escaping paths with spaces


As cited earlier, paths with spaces in them may present particular problems depending on the form of our Exec task. For instance, consider

paths
  • $(NUnitExe), C:\Users\johnny g\Documents\Visual Studio 2012\Projects\UnitTests\Resources\nunit-console.exe
  • %(ProjectReference.OutputFile), Example.UnitTests.dll
  • %(ProjectReference.NUnitTestResultsFile), C:\Users\johnny g\Documents\Visual Studio 2012\Projects\UnitTests\TestResults\NUnit\Example.UnitTests.dll.xml
  • %(ProjectReference.WorkingDirectory), "C:\Users\johnny g\Documents\Visual Studio 2012\Projects\UnitTests\Example.UnitTests\bin\debug"
 and Exec task

<Exec
  Command="$(NUnitExe) %(ProjectReference.OutputFile) /nologo
    /result=%(ProjectReference.NUnitTestResultsFile)"
  WorkingDirectory="%(ProjectReference.WorkingDirectory)" />

this task will fail for two similar reasons. First, WorkingDirectory is escaped with quotation marks; this poses problems for Exec task's WorkingDirectory parameter, which expects a simple unenclosed path. Second, nunit-console.exe will fail because its result specification is not escaped with quotation marks; it will misinterpret any path spaces as new parameter specifications and blow up.

Since paths may be used in any number of ways during build (for instance arbitrary path concatenation, or various embedded parameters, or contexts that require or prohibit quotations marks), I have settled on simply defining raw paths in my properties and metadata, and explicitly escaping them where utilised.

To resolve our Exec task above, let's consider revising path
  •  %(ProjectReference.WorkingDirectory), C:\Users\johnny g\Documents\Visual Studio 2012\Projects\UnitTests\Example.UnitTests\bin\debug
 and our task

<Exec
   Command="$(NUnitExe) %(ProjectReference.OutputFile) /nologo
     /result=&quot;%(ProjectReference.NUnitTestResultsFile)&quot;"
   WorkingDirectory="%(ProjectReference.WorkingDirectory)" />

 The only real take away is that we must Html encode our quotation marks, so "" becomes &quot;&quot; .

Conclusion


Well, that about wraps up some small MSBuild tidbits. These little morsels should enable us to write reliable build tasks in a clear and concise manner.

Resources

StackOverflow, how to evaluate absolute paths
MSDN MSBuild Well-known Item Metadata
MSDN ConvertToAbsolutePath task