Changeset View
Changeset View
Standalone View
Standalone View
src/unit/engine/XUnit2TestEngine.php
- This file was added.
<?php | |||||
/** | |||||
* Uses xUnit 2 (http://xunit.codeplex.com/) to test C# code. | |||||
* | |||||
* Assumes that when modifying a file with a path like `SomeAssembly/MyFile.cs`, | |||||
* that the test assembly that verifies the functionality of `SomeAssembly` is | |||||
* located at `SomeAssembly.Tests`. | |||||
* | |||||
* @concrete-extensible | |||||
*/ | |||||
class XUnit2TestEngine extends ArcanistUnitTestEngine { | |||||
protected $runtimeEngine; | |||||
protected $buildEngine; | |||||
protected $testEngine; | |||||
protected $projectRoot; | |||||
protected $xunitHintPath; | |||||
protected $discoveryRules; | |||||
/** | |||||
* This test engine supports running all tests. | |||||
*/ | |||||
protected function supportsRunAllTests() { | |||||
return true; | |||||
} | |||||
/** | |||||
* Determines what executables and test paths to use. Between platforms this | |||||
* also changes whether the test engine is run under .NET or Mono. It also | |||||
* ensures that all of the required binaries are available for the tests to | |||||
* run successfully. | |||||
* | |||||
* @return void | |||||
*/ | |||||
protected function loadEnvironment() { | |||||
$this->projectRoot = $this->getWorkingCopy()->getProjectRoot(); | |||||
// Determine build engine. | |||||
if (Filesystem::binaryExists('msbuild')) { | |||||
$this->buildEngine = 'msbuild'; | |||||
} else if (Filesystem::binaryExists('xbuild')) { | |||||
$this->buildEngine = 'xbuild'; | |||||
} else { | |||||
throw new Exception('Unable to find msbuild or xbuild in PATH!'); | |||||
} | |||||
// Determine runtime engine (.NET or Mono). | |||||
if (phutil_is_windows()) { | |||||
$this->runtimeEngine = ''; | |||||
} else if (Filesystem::binaryExists('mono')) { | |||||
$this->runtimeEngine = Filesystem::resolveBinary('mono'); | |||||
} else { | |||||
throw new Exception('Unable to find Mono and you are not on Windows!'); | |||||
} | |||||
// Read the discovery rules. | |||||
$this->discoveryRules = | |||||
$this->getConfigurationManager()->getConfigFromAnySource( | |||||
'unit.csharp.discovery'); | |||||
if ($this->discoveryRules === null) { | |||||
throw new Exception( | |||||
'You must configure discovery rules to map C# files '. | |||||
'back to test projects (`unit.csharp.discovery` in .arcconfig).'); | |||||
} | |||||
// Determine xUnit test runner path. | |||||
if ($this->xunitHintPath === null) { | |||||
$this->xunitHintPath = | |||||
$this->getConfigurationManager()->getConfigFromAnySource( | |||||
'unit.csharp.xunit.binary'); | |||||
} | |||||
$xunit = $this->projectRoot.DIRECTORY_SEPARATOR.$this->xunitHintPath; | |||||
if (file_exists($xunit) && $this->xunitHintPath !== null) { | |||||
$this->testEngine = Filesystem::resolvePath($xunit); | |||||
} else if (Filesystem::binaryExists('xunit.console.exe')) { | |||||
$this->testEngine = 'xunit.console.exe'; | |||||
} else { | |||||
throw new Exception( | |||||
"Unable to locate xUnit console runner. Configure ". | |||||
"it with the `unit.csharp.xunit.binary' option in .arcconfig"); | |||||
} | |||||
} | |||||
/** | |||||
* Main entry point for the test engine. Determines what assemblies to build | |||||
* and test based on the files that have changed. | |||||
* | |||||
* @return array Array of test results. | |||||
*/ | |||||
public function run() { | |||||
$this->loadEnvironment(); | |||||
if ($this->getRunAllTests()) { | |||||
$paths = id(new FileFinder($this->projectRoot))->find(); | |||||
} else { | |||||
$paths = $this->getPaths(); | |||||
} | |||||
return $this->runAllTests($this->mapPathsToResults($paths)); | |||||
} | |||||
/** | |||||
* Applies the discovery rules to the set of paths specified. | |||||
* | |||||
* @param array Array of paths. | |||||
* @return array Array of paths to test projects and assemblies. | |||||
*/ | |||||
public function mapPathsToResults(array $paths) { | |||||
$results = array(); | |||||
foreach ($this->discoveryRules as $regex => $targets) { | |||||
$regex = str_replace('/', '\\/', $regex); | |||||
foreach ($paths as $path) { | |||||
if (preg_match('/'.$regex.'/', $path) === 1) { | |||||
foreach ($targets as $target) { | |||||
// Index 0 is the test project (.csproj file) | |||||
// Index 1 is the output assembly (.dll file) | |||||
$project = preg_replace('/'.$regex.'/', $target[0], $path); | |||||
$project = $this->projectRoot.DIRECTORY_SEPARATOR.$project; | |||||
$assembly = preg_replace('/'.$regex.'/', $target[1], $path); | |||||
$assembly = $this->projectRoot.DIRECTORY_SEPARATOR.$assembly; | |||||
if (file_exists($project)) { | |||||
$project = Filesystem::resolvePath($project); | |||||
$assembly = Filesystem::resolvePath($assembly); | |||||
// Check to ensure uniqueness. | |||||
$exists = false; | |||||
foreach ($results as $existing) { | |||||
if ($existing['assembly'] === $assembly) { | |||||
$exists = true; | |||||
break; | |||||
} | |||||
} | |||||
if (!$exists) { | |||||
$results[] = array( | |||||
'project' => $project, | |||||
'assembly' => $assembly, | |||||
); | |||||
} | |||||
} | |||||
} | |||||
} | |||||
} | |||||
} | |||||
return $results; | |||||
} | |||||
/** | |||||
* Builds and runs the specified test assemblies. | |||||
* | |||||
* @param array Array of paths to test project files. | |||||
* @return array Array of test results. | |||||
*/ | |||||
public function runAllTests(array $test_projects) { | |||||
if (empty($test_projects)) { | |||||
return array(); | |||||
} | |||||
$results = array(); | |||||
$results[] = $this->generateProjects(); | |||||
if ($this->resultsContainFailures($results)) { | |||||
return array_mergev($results); | |||||
} | |||||
$results[] = $this->buildProjects($test_projects); | |||||
if ($this->resultsContainFailures($results)) { | |||||
return array_mergev($results); | |||||
} | |||||
$results[] = $this->testAssemblies($test_projects); | |||||
return array_mergev($results); | |||||
} | |||||
/** | |||||
* Determine whether or not a current set of results contains any failures. | |||||
* This is needed since we build the assemblies as part of the unit tests, but | |||||
* we can't run any of the unit tests if the build fails. | |||||
* | |||||
* @param array Array of results to check. | |||||
* @return bool If there are any failures in the results. | |||||
*/ | |||||
private function resultsContainFailures(array $results) { | |||||
$results = array_mergev($results); | |||||
foreach ($results as $result) { | |||||
if ($result->getResult() != ArcanistUnitTestResult::RESULT_PASS) { | |||||
return true; | |||||
} | |||||
} | |||||
return false; | |||||
} | |||||
/** | |||||
* If the `Build` directory exists, we assume that this is a multi-platform | |||||
* project that requires generation of C# project files. Because we want to | |||||
* test that the generation and subsequent build is whole, we need to | |||||
* regenerate any projects in case the developer has added files through an | |||||
* IDE and then forgotten to add them to the respective `.definitions` file. | |||||
* By regenerating the projects we ensure that any missing definition entries | |||||
* will cause the build to fail. | |||||
* | |||||
* @return array Array of test results. | |||||
*/ | |||||
private function generateProjects() { | |||||
// No "Build" directory; so skip generation of projects. | |||||
if (!is_dir(Filesystem::resolvePath($this->projectRoot.'/Build'))) { | |||||
return array(); | |||||
} | |||||
// No "Protobuild.exe" file; so skip generation of projects. | |||||
if (!is_file(Filesystem::resolvePath( | |||||
$this->projectRoot.'/Protobuild.exe'))) { | |||||
return array(); | |||||
} | |||||
// Work out what platform the user is building for already. | |||||
$platform = phutil_is_windows() ? 'Windows' : 'Linux'; | |||||
$files = Filesystem::listDirectory($this->projectRoot); | |||||
foreach ($files as $file) { | |||||
if (strtolower(substr($file, -4)) == '.sln') { | |||||
$parts = explode('.', $file); | |||||
$platform = $parts[count($parts) - 2]; | |||||
break; | |||||
} | |||||
} | |||||
$regenerate_start = microtime(true); | |||||
$regenerate_future = new ExecFuture( | |||||
'%C Protobuild.exe --resync %s', | |||||
$this->runtimeEngine, | |||||
$platform); | |||||
$regenerate_future->setCWD(Filesystem::resolvePath( | |||||
$this->projectRoot)); | |||||
$results = array(); | |||||
$result = new ArcanistUnitTestResult(); | |||||
$result->setName("(regenerate projects for $platform)"); | |||||
try { | |||||
$regenerate_future->resolvex(); | |||||
$result->setResult(ArcanistUnitTestResult::RESULT_PASS); | |||||
} catch(CommandException $exc) { | |||||
if ($exc->getError() > 1) { | |||||
throw $exc; | |||||
} | |||||
$result->setResult(ArcanistUnitTestResult::RESULT_FAIL); | |||||
$result->setUserdata($exc->getStdout()); | |||||
} | |||||
$result->setDuration(microtime(true) - $regenerate_start); | |||||
$results[] = $result; | |||||
return $results; | |||||
} | |||||
/** | |||||
* Build the projects relevant for the specified test assemblies and return | |||||
* the results of the builds as test results. This build also passes the | |||||
* "SkipTestsOnBuild" parameter when building the projects, so that MSBuild | |||||
* conditionals can be used to prevent any tests running as part of the | |||||
* build itself (since the unit tester is about to run each of the tests | |||||
* individually). | |||||
* | |||||
* @param array Array of test assemblies. | |||||
* @return array Array of test results. | |||||
*/ | |||||
private function buildProjects(array $test_assemblies) { | |||||
$build_start = microtime(true); | |||||
// Build an MSBuild project that imports all of the projects we want. | |||||
// This allows MSBuild to build all of our targets in parallel, instead | |||||
// of executing lots of MSBuild commands in sequence. | |||||
$imports = ""; | |||||
foreach ($test_assemblies as $test_assembly) { | |||||
$path = Filesystem::resolvePath($test_assembly['project']); | |||||
if (file_exists($path)) { | |||||
$imports .= "<ProjectsToBuild Include=\"".$path."\" />\n"; | |||||
} else { | |||||
$result = new ArcanistUnitTestResult(); | |||||
$result->setName('Missing: '.$path); | |||||
$result->setResult(ArcanistUnitTestResult::RESULT_UNSOUND); | |||||
$result->setUserData('C# project not found'); | |||||
$results[] = $result; | |||||
if ($this->renderer) { | |||||
echo $this->renderer->renderUnitResult($result); | |||||
} | |||||
} | |||||
} | |||||
$xml = <<<EOF | |||||
<?xml version="1.0" encoding="utf-8"?> | |||||
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> | |||||
<ItemGroup> | |||||
$imports | |||||
</ItemGroup> | |||||
<Target Name="Build"> | |||||
<MSBuild Projects="@(ProjectsToBuild)" BuildInParallel="true" /> | |||||
</Target> | |||||
</Project> | |||||
EOF; | |||||
$temp = new TempFile(); | |||||
file_put_contents((string)$temp, $xml); | |||||
$result = new ArcanistUnitTestResult(); | |||||
$result->setName('(build code)'); | |||||
$args = array(); | |||||
$args[] = (string)$temp; | |||||
$args[] = '/p:SkipTestsOnBuild=True'; | |||||
if ($this->buildEngine === 'msbuild') { | |||||
$args[] = '/m'; | |||||
} | |||||
$build_future = new ExecFuture( | |||||
'%C %Ls', | |||||
$this->buildEngine, | |||||
$args); | |||||
try { | |||||
$build_future->resolvex(); | |||||
$result->setResult(ArcanistUnitTestResult::RESULT_PASS); | |||||
} catch (CommandException $exc) { | |||||
if ($exc->getError() > 1) { | |||||
throw $exc; | |||||
} | |||||
$result->setResult(ArcanistUnitTestResult::RESULT_FAIL); | |||||
$result->setUserdata($exc->getStdout()); | |||||
} | |||||
$result->setDuration(microtime(true) - $build_start); | |||||
if ($this->renderer) { | |||||
echo $this->renderer->renderUnitResult($result); | |||||
} | |||||
return array($result); | |||||
} | |||||
/** | |||||
* Run the xUnit test runner on each of the assemblies and parse the | |||||
* resulting XML. | |||||
* | |||||
* @param array Array of test assemblies. | |||||
* @return array Array of test results. | |||||
*/ | |||||
private function testAssemblies(array $test_assemblies) { | |||||
$results = array(); | |||||
// Make sure we only pass in test assemblies that exist. | |||||
$existing_test_assemblies = array(); | |||||
foreach (ipull($test_assemblies, 'assembly') as $assembly_path) { | |||||
if (file_exists($assembly_path)) { | |||||
$existing_test_assemblies[] = $assembly_path; | |||||
} else { | |||||
$result = new ArcanistUnitTestResult(); | |||||
$result->setName('Missing: '.$assembly_path); | |||||
$result->setResult(ArcanistUnitTestResult::RESULT_UNSOUND); | |||||
$result->setUserData('Assembly not found'); | |||||
$results[] = $result; | |||||
if ($this->renderer) { | |||||
echo $this->renderer->renderUnitResult($result); | |||||
} | |||||
} | |||||
} | |||||
$future = new ExecFuture( | |||||
'%C %Ls -teamcity', | |||||
trim($this->runtimeEngine.' '.$this->testEngine), | |||||
$existing_test_assemblies); | |||||
$futures = new FutureIterator(array($future)); | |||||
$stdout_buffer = ''; | |||||
foreach ($futures->setUpdateInterval(0.1) as $key => $future_iter) { | |||||
list($stdout, $stderr) = $future->read(); | |||||
$stdout_buffer .= $stdout; | |||||
echo $stderr; | |||||
$future->discardBuffers(); | |||||
$lines = phutil_split_lines($stdout_buffer); | |||||
if (strlen($stdout_buffer) > 0 && | |||||
$stdout_buffer[strlen($stdout_buffer) - 1] !== "\n") { | |||||
$stdout_buffer = $lines[count($lines) - 1]; | |||||
array_pop($lines); | |||||
} else { | |||||
$stdout_buffer = ""; | |||||
} | |||||
if (count($lines) > 0) { | |||||
foreach ($lines as $line) { | |||||
if (substr($line, 0, 10) === '##teamcity') { | |||||
if (!preg_match('/\[([a-zA-Z]+)\ /', $line, $matches)) { | |||||
continue; | |||||
} | |||||
if (count($matches) < 2) { | |||||
continue; | |||||
} | |||||
$result_code = null; | |||||
switch ($matches[1]) { | |||||
case "testFinished": | |||||
$result_code = ArcanistUnitTestResult::RESULT_PASS; | |||||
break; | |||||
case "testIgnored": | |||||
$result_code = ArcanistUnitTestResult::RESULT_SKIP; | |||||
break; | |||||
case "testFailed": | |||||
$result_code = ArcanistUnitTestResult::RESULT_FAIL; | |||||
break; | |||||
case "testStarted": | |||||
case "testSuiteStarted": | |||||
case "testSuiteFinished": | |||||
break; | |||||
default: | |||||
throw new Exception('Unknown test event \''.$matches[1].'\' occurred!'); | |||||
} | |||||
if ($result_code === null) { | |||||
// Some kind of event that we don't care about. | |||||
continue; | |||||
} | |||||
preg_match_all("/([a-zA-Z]+)=\'([^'\\|]*(?:\\|.[^'\\|]*)*)\'/s", $line, $matches); | |||||
$attributes = array(); | |||||
if (isset($matches[1])) { | |||||
for ($i = 0; $i < count($matches[1]); $i++) { | |||||
$attributes[$matches[1][$i]] = $matches[2][$i]; | |||||
} | |||||
} | |||||
$userdata = ''; | |||||
if (idx($attributes, 'message')) { | |||||
$userdata .= idx($attributes, 'message'); | |||||
} | |||||
if (idx($attributes, 'details')) { | |||||
$userdata .= | |||||
str_replace("|n", "\n", | |||||
str_replace("|r", "\r", | |||||
str_replace("|'", "'", | |||||
str_replace("||", "|", | |||||
idx($attributes, 'details'))))); | |||||
} | |||||
$result = new ArcanistUnitTestResult(); | |||||
$result->setName(idx($attributes, 'name')); | |||||
$result->setResult($result_code); | |||||
$result->setDuration(idx($attributes, 'duration') / 1000); | |||||
$result->setUserData($userdata); | |||||
$results[] = $result; | |||||
if ($this->renderer) { | |||||
echo $this->renderer->renderUnitResult($result); | |||||
} | |||||
} | |||||
} | |||||
} | |||||
if ($future_iter !== null) { | |||||
break; | |||||
} | |||||
} | |||||
return $results; | |||||
} | |||||
public function shouldEchoTestResults() { | |||||
return !$this->renderer; | |||||
} | |||||
} |