Refactoring CTest
30 Sep 2024Previously, I wrote about the four different ways of configuring a dashboard client script with CTest:
- CTest Command-Line
- Declarative CTest Script (undocumented)
- CTest Module
- CTest Script
I also mentioned that 4 is the most flexible approach because it can both initialize and update the source directory. And that it is the only approach that allows you to execute the same step more than once. This is the approach that newcomers should be directed to.
Since it is the most flexible approach, you would assume that it is the core
implementation and the other three are legacy wrappers? Unfortunately no.
The core implementation is the one with the options named in CamelCase.
When a CTest command is executed, all relevant variables are copied into a
central dictionary in the cmCTest
class, as if they had been parsed from a
.tcl
file. This implementation detail is leaked in the --extra-verbose
output of CTest:
SetCTestConfigurationFromCMakeVariable:UseLaunchers:CTEST_USE_LAUNCHERS
But that approach is not only at the core of the implementation, but also at the
core of the documentation. Look at the documentation of the
CTEST_USE_LAUNCHERS
variable:
Specify the CTest
UseLaunchers
setting in actest(1)
dashboard client script.
There is more documentation about this setting under
CTest Build Step,
but that is not even linked in the documentation about the CTEST_USE_LAUNCHERS
variable.
I am convinced that it is both possible and desirable to refactor CTest so that the script mode really is at the core of the implementation. I will first summarize the necessary refactoring steps and then highlight the benefits.
Let me start with the CTest Module, more specifically CTestTargets.cmake
:
This module currently configures the file
${CMAKE_ROOT}/Modules/DartConfiguration.tcl.in
to
${PROJECT_BINARY_DIR}/DartConfiguration.tcl
and then creates a bunch of custom
targets that invoke ctest -D
in the build directory.
First, I port the function cmCTest::ProcessSteps()
from C++ to a CTest Script.
For now, I ignore the GetRemainingTimeAllowed
checks, as the
CTEST_TIME_LIMIT
variable is not set by the CTest module. I store the script
template as Templates/CTestScript.cmake.in
, a file that was just recently
removed.
Next, I extend CTestTargets.cmake
, so that it configures the file
${CMAKE_ROOT}/Templates/CTestScript.cmake.in
to
${PROJECT_BINARY_DIR}/CTestScript.cmake
. I also change the custom targets so
that they execute ctest -S CTestScript.cmake
instead.
Result: CTest Module mode no longer depends on parsing DartConfiguration.tcl
;
mission accomplished.
Declarative CTest Script mode can safely be removed, I am quite sure. Remember
that this is the mode where you invoke ctest -S
with a script that calls
none of the ctest_*
commands, neither directly nor indirectly. This mode
is not documented and the only test that covered it has been “temporarily
disabled” for the last
fifteen years.
Removing it involves removing the code around CTEST_RUN_CURRENT_SCRIPT
,
cmCTestScriptHandler::SetRunCurrentScript
, cmCTestScriptHandler::RunCurrentScript
,
but also cmCTestScriptHandler::RunConfigurationDashboard
. It is quite possible
that a lot of dead code can be removed as it is no longer accessible after
removing those functions.
Next is reimplementing a legacy wrapper for the options -D
, -M
, and -T
.
This implementation should
- parse the
DartConfiguration.tcl
file, - map the parsed settings to their corresponding CTest module variables,
- produce a temporary
CTestScript.cmake
by configuring${CMAKE_ROOT}/Templates/CTestScript.cmake.in
, - invoke the generated script with
ctest -S
.
What all this enables is a cleanup refactoring that may reduce the CTest codebase by about 50%. How so?
At the moment there is a cmCTest*Handler
instance for each build step owned by
the cmCTest
class. But once CTest scripting mode is the core of the
implementation and the CTest command line mode is merely a legacy wrapper, the
only place where for example the cmCTestConfigureHandler
is used is the
cmCTestConfigureCommand
! The first cleanup will involve moving all the handler
instances from the cmCTest
class to their corresponding command
implementations.
The second cleanup step will involve merging all the handler implementations
directly into their command implementations. It will be no longer necessary
to map “CMake variables” into “CTest configurations”. Instead, the CMake
variables are used directly, which reduces the dependency to the cmCTest
instance.
Once a cmCTest*Command
is stateless and independent of cmCTest
, its
implementation can be turned into a free function, like all the CMake commands
already are. Once that is done, the classes cmCommand
and
cmLegacyCommandWrapper
can finally be removed.
This cleanup greatly improves maintainability and extensibility of CTest
commands. One feature that is currently missing is the support of a
CONFIGURATION
option in the ctest_test
command. At the moment, you can use
ctest_configure
to configure a project for a multi-config generator like
Xcode
followed by multiple ctest_build
commands that each build one
configuration. But you cannot use multiple ctest_test
commands, as that
command has no CONFIGURATION
option.
Adding support for this option before the cleanup will involve changes to multiple files. After the cleanup it will be possible with only changing one file.
It will also simplify adding new commands. One command that I would consider
useful is a ctest_package
command, which invokes CPack in a way that CTest
knows how to submit the generated packages as build artefacts to a dashboard.