Monday, September 26, 2011

Setting up CCNet in combination with VS2010

In this post I'll describe how one can set up CCNet to work with VS2010. Not everyone has the full blown version of TFS at their disposal.

Scenario setup

I always use the following setup at work :
  • Project_CI : for Continuous Integration (compile, unit-test)
  • Project_MakePackage : this makes the install package (compile, unit-test,integration-test, make package)
  • Project_QA : this does unit-test, integration_test, coverage and code analysis.
This is a pragmatic approach: a 'fix' can be deployed even when for example coverage is still below X percent, as long as all tests are passed. It's convention that QA must be fixed ASAP!

The CI must be as fast as possible, so it runs only the unit-tests. The CI project has an interval trigger checking the repo every 5 minutes. This project does NOT label TFS.

The makePackage project has a schedule trigger : every day at 20:00, and a labeler so we can easily branch via a label.

The QA project also has a schedule trigger : every day at 21:00. This project does NOT label TFS.

Step 1 : Setting up the source control part


In Tfs itself I have the following layout :

ProjectName
\__Main
| \Lib
| \Src
\__Releases
\__1_0_0_3450
| \Lib
| \Src
\__1_0_1_5678
\Lib
\Src
This allows for easy branching.

Step 2 : Setting up CCNet.config

I use the pre-processor to reduce a lot of the configuration. This allows me to define a CCNet project in just 30 lines!
You can read the full configuration for the tfs source control at the wiki Tfs Source Control
My advise : set the deleteworkspace and cleancopy to true, this prevents a lot of problems.
A full example of ccnet.config with comparable layout is at the bottom of this post.
The preprocessor declaration :
<cb:define name="vsts_ci">
<server>http://tfs-server:8080/tfs/default/</server>
<username>cruise</username>
<password>**********</password>
<domain>tfs-server</domain>
<autoGetSource>true</autoGetSource>
<cleanCopy>true</cleanCopy>
<force>true</force>
<deleteWorkspace>true</deleteWorkspace>
</cb:define>

The source control block inside a project :
<sourcecontrol type="vsts">
<workspace>$(ProjectName)</workspace>
<project>$/$(ProjectName)/Main</project>
<cb:vsts_ci/>
</sourcecontrol>

Step 3 : Setting up the build script


The main action lays of course in the build script, for which I use Nant. The reason I (still) use Nant is that I know it rather well, and it works. For compiling I just call the MSBuild task from NantContrib pointing to the VS2010 solution, but all other logic is in Nant.
An example of the Nant build script is also at the bottom of this post.

Step 4 : Testing with Ms-test


Like I said in the beginning, I have 2 kind of tests, UnitTests and Integration Tests. In MS-test I create a test-list with the name UnitTests directly under the root item. All tests in this list, and in test-lists beneath it will be ran when I specify UnitTests. The 'Integration Tests' (slow running ones, going to the database, ...) are in a test-list named IntegrationTests also directly under the root item. Here's an example of calling MS-Test via nant :
<exec program="${mstest_exe}">
<arg value="/testmetadata:${mstest_metadatafile}" />
<arg value="/resultsfile:MStest_Results.xml" />
<arg value="/testlist:UnitTests" />
<arg value="/testlist:IntegrationTests" if="${CCNetBuildCondition=='ForceBuild'}" />
</exec>


Step 5 : Using Ms-Test with coverage


In Ms-test you can specify that you also want coverage to run, see for setting it up.
I just made a company rule that for code coverage to be ran via ccnet, the testsettings file must be named : CodeCoverage.testsettings,
with a specific base name(cover_me) and no timestamps appended. Just to make things easier for me.
If you want MS-Test to run coverage, just pass the testsettings as an extra argument :
<exec program="${mstest_exe}" failonerror="false" resultproperty="testresult.temp" >
<arg value="/testmetadata:${mstest_metadatafile}" />
<arg value="/resultsfile:MStest_Results.xml" />
<arg value="/testsettings:CodeCoverage.testsettings" />
<arg value="/testlist:UnitTests" />
<arg value="/testlist:IntegrationTests" if="${CCNetBuildCondition=='ForceBuild'}" />
</exec>


There is a catch : Ms-Test from VS2010 does not produce XML anymore, see this post for a solution. You really need the dll from VS2008 for it to work, the VS2010 has another interface sadly enough. So best to digg up you DVD of VS2008. I've updated that program a bit so that is also removes the Lines from the coverage result file, making it a lot smaller to merge. Below is my source code (its VB.Net)

Showing the results


I've added 2 new xsl files (MsTestReport2010.xsl and MsTest2010Cover.xsl) to CCNet, you can use these in the dashboard in the build plugins.
<buildPlugins>
...
<xslReportBuildPlugin description="Ms Test" actionName="MSTest" xslFileName="xsl\MsTestReport2010.xsl" />
<xslReportBuildPlugin description="MS Test Coverage" actionName="MSTest2008Cover" xslFileName="xsl\MsTestCover2010.xsl"/>
...
</buildPlugins>





Attachments


CCNet.config

<cruisecontrol xmlns:cb="urn:ccnet.config.builder">
<!-- preprocessor settings -->
<cb:define WorkingDir="D:\WorkingFolders\" />
<cb:define WorkingMainDir="D:\ArtifactFolders\" />
<cb:define ArtifactsDir="\Artifacts" />

<cb:define name="vsts_ci">
<server>http://tfs-server:8080/tfs/default/</server>
<username>cruise</username>
<password>**********</password>
<domain>tfs-server</domain>
<autoGetSource>true</autoGetSource>
<cleanCopy>true</cleanCopy>
<force>true</force>
<deleteWorkspace>true</deleteWorkspace>
</cb:define>

<cb:define name="vsts_package">
<server>http://tfs-server:8080/tfs/default/</server>
<username>cruise</username>
<password>**********</password>
<domain>tfs-server</domain>
<autoGetSource>true</autoGetSource>
<cleanCopy>true</cleanCopy>
<force>true</force>
<applyLabel>true</applyLabel>
<deleteWorkspace>true</deleteWorkspace>
</cb:define>

<cb:define name="common_publishers">
<merge>
<files>
<file>Coverage.xml</file>
<file>MStest_Results.xml</file>
<file>simian.xml</file>
</files>
</merge>
<xmllogger />
<statistics />
<modificationHistory onlyLogWhenChangesFound="true" />
<artifactcleanup cleanUpMethod="KeepLastXSubDirs" cleanUpValue="2" />
<artifactcleanup cleanUpMethod="KeepLastXBuilds" cleanUpValue="25000" />
<email from="CruiseControl@TheBuilder.com"
mailhost="TheMailer.Company.com"
includeDetails="TRUE">
<groups/>
<users/>
<converters>
<ldapConverter domainName="Company" />
</converters>
<modifierNotificationTypes>
<NotificationType>Failed</NotificationType>
<NotificationType>Fixed</NotificationType>
</modifierNotificationTypes>
</email>
</cb:define>

<cb:define name="nant_common">
<executable>c:\Tools\nant\bin\nant.exe</executable>
<nologo>true</nologo>
<buildTimeoutSeconds>1800</buildTimeoutSeconds>
<buildArgs>-D:useExtraMsbuildLogger=true -D:isCI=true -listener:CCNetListener,CCNetListener -D:configuration=Debug</buildArgs>
</cb:define>

<cb:define name="nant_package">
<executable>c:\Tools\nant\bin\nant.exe</executable>
<nologo>true</nologo>
<buildTimeoutSeconds>1800</buildTimeoutSeconds>
<buildArgs> -D:useExtraMsbuildLogger=true -D:CreateInstallZips=true -listener:CCNetListener,CCNetListener -D:configuration=Release</buildArgs>
</cb:define>

<cb:define name="nant_qa">
<executable>c:\Tools\nant\bin\nant.exe</executable>
<nologo>true</nologo>
<buildTimeoutSeconds>3600</buildTimeoutSeconds>
<buildArgs>-D:useExtraMsbuildLogger=true -listener:CCNetListener,CCNetListener -D:configuration=DebugCA</buildArgs>
</cb:define>

<cb:define name="nant_target_CI">
<targetList>
<target>clean</target>
<target>compile</target>
<target>test</target>
</targetList>
</cb:define>

<cb:define name="nant_target_qa">
<targetList>
<target>clean</target>
<target>simian</target>
<target>compile</target>
<target>cover</target>
</targetList>
</cb:define>

<cb:define name="nant_target_package">
<targetList>
<target>clean</target>
<target>compile</target>
<target>test</target>
<target>make_package</target>
<target>makehelp</target>
</targetList>
</cb:define>
<!-- end preprocessor settings -->


<!-- Projects -->
<cb:scope ProjectName="ProjectX">
<cb:define ProjectType="_CI" />
<project name="$(ProjectName)$(ProjectType)" queue="Q1" queuePriority="901">
<workingDirectory>$(WorkingDir)$(ProjectName)$(ProjectType)</workingDirectory>
<artifactDirectory>$(WorkingMainDir)$(ProjectName)$(ProjectType)$(ArtifactsDir)</artifactDirectory>

<labeller type="defaultlabeller" />

<sourcecontrol type="vsts">
<workspace>$(ProjectName)</workspace>
<project>$/$(ProjectName)/Main</project>
<cb:vsts_ci/>
</sourcecontrol>

<tasks>
<nant>
<cb:nant_common/>
<cb:nant_target_CI />
</nant>
</tasks>

<publishers>
<cb:common_publishers />
</publishers>

</project>
</cb:scope>

<cb:scope ProjectName="ProjectX">
<cb:define ProjectType="_Package" />
<project name="$(ProjectName)$(ProjectType)" queue="Q1" queuePriority="801">
<workingDirectory>$(WorkingDir)$(ProjectName)$(ProjectType)</workingDirectory>
<artifactDirectory>$(WorkingMainDir)$(ProjectName)$(ProjectType)$(ArtifactsDir)</artifactDirectory>

<labeller type="defaultlabeller">
<prefix>1.0.1.</prefix>
<incrementOnFailure>false</incrementOnFailure>
</labeller>

<sourcecontrol type="vsts">
<workspace>$(ProjectName)</workspace>
<project>$/$(ProjectName)/Main</project>
<cb:vsts_package/>
</sourcecontrol>

<tasks>
<nant>
<cb:nant_package/>
<cb:nant_target_package />
</nant>
</tasks>

<publishers>
<cb:common_publishers />
</publishers>

</project>
</cb:scope>

<cb:scope ProjectName="ProjectX">
<cb:define ProjectType="_QA" />
<project name="$(ProjectName)$(ProjectType)" queue="Q1" queuePriority="801">
<workingDirectory>$(WorkingDir)$(ProjectName)$(ProjectType)</workingDirectory>
<artifactDirectory>$(WorkingMainDir)$(ProjectName)$(ProjectType)$(ArtifactsDir)</artifactDirectory>

<labeller type="defaultlabeller" />

<sourcecontrol type="vsts">
<workspace>$(ProjectName)</workspace>
<project>$/$(ProjectName)/Main</project>
<cb:vsts_package/>
</sourcecontrol>

<tasks>
<nant>
<cb:nant_common/>
<cb:nant_target_qa />
</nant>
</tasks>

<publishers>
<cb:common_publishers />
</publishers>

</project>
</cb:scope>

</cruisecontrol>


Nant Build Script

<project default="help">
<property name="solution" unless="${property::exists('solution')}" value="ProjectX.sln" />
<property name="configuration" unless="${property::exists('configuration')}" value="Debug" />
<property name="CCNetListenerFile" unless="${property::exists('CCNetListenerFile')}" value="listen.xml" />
<property name="msbuildverbose" unless="${property::exists('msbuildverbose')}" value="normal" />
<property name="CCNetLabel" unless="${property::exists('CCNetLabel')}" value="0.0.0.0" />

<property name="mstest_metadatafile" value="ProjectX.vsmdi" />

<property overwrite="false" name="Simian_exe" value="c:\Tools\simian\bin\simian-2.3.32.exe" />
<property overwrite="false" name="msbuildlogger" value="C:\Program Files\CruiseControl.NET\server\MSBuildListener.dll" />
<property overwrite="false" name="versionInfofile" value="VersionInfo.cs" />
<property overwrite="false" name="mstest_exe" value="C:\Program Files\Microsoft Visual Studio 10.0\Common7\IDE\mstest.exe" />

<!-- custom scripts -->
<script language="C#" prefix="RuWi">
<references>
<include name="System.Xml.dll" />
<include name="System.dll" />
</references>
<imports>
<import namespace="System.Text" />
</imports>
<code>
<![CDATA[
[Function("UpdateVersionFile")]
public static bool UpdateVersionFile(string inputFile, string newVersion, bool debugMode)
{
bool ok = true;
try
{
System.IO.StreamReader versionFile = new System.IO.StreamReader(inputFile, System.Text.Encoding.ASCII);
string line = "";
System.Text.StringBuilder result = new StringBuilder();
string searchPatternVersion = @"(\d+\.\d+\.\d+\.\d+)";
string searchPatternAssemblyProduct = string.Format(@"AssemblyProduct\({0}(.*?)\{0}", "\"");
string replacePatternAssemblyProduct = string.Format(@"AssemblyProduct({0}(Debug)${1}1{2}{0}", "\"", "{", "}");

while (!versionFile.EndOfStream)
{
line = versionFile.ReadLine();

if (System.Text.RegularExpressions.Regex.IsMatch(line, searchPatternVersion) && (line.Contains("AssemblyFileVersion")))
{
line = System.Text.RegularExpressions.Regex.Replace(line, searchPatternVersion, newVersion);
}

if (debugMode && System.Text.RegularExpressions.Regex.IsMatch(line, searchPatternAssemblyProduct))
{
line = System.Text.RegularExpressions.Regex.Replace(line, searchPatternAssemblyProduct, replacePatternAssemblyProduct);
}

result.AppendLine(line);
}

versionFile.Close();

System.IO.StreamWriter updatedVersionfile = new System.IO.StreamWriter(inputFile);
updatedVersionfile.Write(result.ToString());
updatedVersionfile.Close();
}
catch (Exception ex)
{
ok = false;
Console.WriteLine(ex.ToString());
}
return ok;
}
]]>
</code>
</script>

<target name="help" >
<echo message="Removed for keeping the file shorter." />
</target>

<target name="clean" description="deletes all created files">
<delete >
<fileset>
<patternset >
<include name="**/bin/**" />
<include name="**/obj/**" />
<include name="Coverage*.xml" />
<include name="*.zip" />
<include name="MStest_Results.xml" />
<include name="simian.xml" />
</patternset>
</fileset>
</delete>
</target>

<target name="adjustversion" description="Adjusts the version in the version.info file">
<if test="${not file::exists(versionInfofile)}">
<fail message="file: ${versionInfofile} which must contains the version info was NOT found" />
</if>

<echo message="Setting version to ${CCNetLabel}" />

<property name="debugMode" value = "False" />
<property name="debugMode" value = "True" if="${configuration=='Debug'}" />
<if test="${not RuWi::UpdateVersionFile(versionInfofile,CCNetLabel,debugMode)}">
<fail message="updating file: ${versionInfofile} which must contains the version info failed" />
</if>
</target>

<target name="compile" description="compiles the solution in the wanted configuration" depends="adjustversion">
<msbuild project="${solution}" >
<arg value="/p:Configuration=${configuration}" />
<arg value="/p:CCNetListenerFile=${CCNetListenerFile}" />
<arg value="/v:${msbuildverbose}" />
<arg value="/l:${msbuildlogger}" />
</msbuild>
</target>

<target name="test" description="runs the tests" depends="deploy.services">
<if test="${string::get-length(mstest_metadatafile)>0}" >
<exec program="${mstest_exe}">
<arg value="/testmetadata:${mstest_metadatafile}" />
<arg value="/resultsfile:MStest_Results.xml" />
<arg value="/testlist:UnitTests" />
<arg value="/testlist:IntegrationTests" if="${CCNetBuildCondition=='ForceBuild'}" />
</exec>
</if>
</target>

<target name = "cover" description="runs the tests with coverage" >
<if test="${string::get-length(mstest_metadatafile)>0}" >
<!--
company rule : code coverage settings must be set via this file
with the following NamingScheme : baseName="cover_me" appendTimeStamp="false" useDefault="false"
-->
<if test="${file::exists('CodeCoverage.testsettings')}">

<exec program="${mstest_exe}" failonerror="false" resultproperty="testresult.temp" >
<arg value="/testmetadata:${mstest_metadatafile}" />
<arg value="/resultsfile:MStest_Results.xml" />
<arg value="/testsettings:CodeCoverage.testsettings" />
<arg value="/testlist:UnitTests" />
<arg value="/testlist:IntegrationTests" if="${CCNetBuildCondition=='ForceBuild'}" />
</exec>

<property name="TestsOK" value="false" unless="${int::parse(testresult.temp)==0}"/>

<property name="DataCoverageFilePath" value="${RuWi::FindFile('cover_me','data.coverage')}" />
<property name="TurnCoverageFileIntoXml_exe" value="C:\Tools\TurnCoverageFileIntoXml\TurnCoverageFileIntoXml.exe" />

<fail message="No data.coverage found in cover_me folder" unless="${string::get-length(DataCoverageFilePath)>0}" />

<echo message="DataCoverageFilePath : ${DataCoverageFilePath}" />

<exec program="${TurnCoverageFileIntoXml_exe}" >
<arg value="${DataCoverageFilePath}" />
<arg value="cover_me\Out" />
<arg value="NCoverExplorer.xml" />
</exec>

<fail message="Failures reported in unit tests." unless="${TestsOK}" />
</if>
</if>

</target>

<target name="simian" description="find duplicate code" >
<exec program="${Simian_exe}" failonerror="false">
<arg value="-includes=**/*.cs" />
<arg value="-excludes=**/*Designer.*" />
<arg value="-excludes=**/*Generated.*" />
<arg value="-excludes=**/*Reference.*" />
<arg value="-excludes=**/obj/*" />
<arg value="-threshold=10" />
<arg value="-formatter=xml:simian.xml" />
</exec>
</target>

<target name="deploy.services" description="deploys all service (web/wcf)" /> <!-- company specific, just copies files to the iis folder -->
<target name="make_package" description="makes install packages" /> <!-- company specific, creates install packages and zips them -->
<target name="makehelp" description="makes install packages" /> <!-- company specific, makes user help with custom tool -->

</project>


Source code for Ms-Test binary2Xml

Imports Microsoft.VisualStudio.CodeCoverage

Module Module1

Sub Main()
Dim Arguments As String()
Dim obc = Console.BackgroundColor
Dim returnValue As Integer = 0

Try
Arguments = Environment.GetCommandLineArgs

If Arguments.Length <> 4 Then

Console.BackgroundColor = ConsoleColor.Blue
Console.WriteLine("Usage : {0} DataCoverageFilePath CoveredFilesPath ResultXmlFilePath", Arguments(0))
Console.BackgroundColor = ConsoleColor.DarkGreen
Console.WriteLine(" {0} In\LTREMRUBEN\data.coverage Out d:\codecover.xml", Arguments(0))
Console.BackgroundColor = obc
returnValue = 1
Exit Try
End If

Dim DataCoverageFilePath As String = Arguments(1)
Dim CoveredFilesPath As String = Arguments(2)
Dim ResultXmlFilePath As String = Arguments(3)

CoverageInfoManager.ExePath = CoveredFilesPath
CoverageInfoManager.SymPath = CoveredFilesPath

Console.WriteLine("converting {0}", DataCoverageFilePath)
Dim coverage = CoverageInfoManager.CreateInfoFromFile(DataCoverageFilePath)

Dim CoverResult = coverage.BuildDataSet(Nothing)

Dim CoverResultStream As New IO.MemoryStream
CoverResult.WriteXml(CoverResultStream)

Console.WriteLine("Initial Size in bytes : {0}", CoverResultStream.Length)
CoverResultStream.Position = 0


Console.WriteLine("Cleaning up xml info ...")
Dim CoverResultXmlDoc As New Xml.XmlDocument()
CoverResultXmlDoc.Load(CoverResultStream)

Dim LineInfos = CoverResultXmlDoc.SelectNodes("//Lines")

For Each lineInfo As Xml.XmlNode In LineInfos
lineInfo.RemoveAll()
Next

Dim SourceFileNameInfos = CoverResultXmlDoc.SelectNodes("//SourceFileNames")
For Each SourceFileNameInfo As Xml.XmlNode In SourceFileNameInfos
SourceFileNameInfo.RemoveAll()
Next

CoverResultXmlDoc.PreserveWhitespace = False
CoverResultXmlDoc.Normalize()
CoverResultXmlDoc.Save(ResultXmlFilePath)

Console.WriteLine("Compressed Size in bytes : {0}", New IO.FileInfo(ResultXmlFilePath).Length)

Console.WriteLine("Done.")

Catch ex As Exception
Console.WriteLine(ex.ToString)
End Try
End Sub

End Module

No comments:

Post a Comment