Skip to content

Test: Register Boost.Test suites discovered from the test binaries - #655

Merged
heifner merged 5 commits into
masterfrom
feature/contract-suite-discovery
Oct 2, 2026
Merged

heifner merged 5 commits into
masterfrom
feature/contract-suite-discovery

Conversation

@heifner

@heifner heifner commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Add boost_test_discover_tests() (cmake/boost-test-discovery.cmake), modeled on CMake's gtest_discover_tests(DISCOVERY_MODE PRE_TEST): when ctest starts it lists a Boost.Test executable's top-level units with --list_content, caches the result until the executable changes, and registers one CTest entry per unit.
  • Use it for every per-suite registration in the repo: unit_test (per runtime, plus the -nofsgs variants), wasm_spec_test, test_chain_plugin and contracts_unit_test. Remove the unused unittest_tests_add macro.
  • Add boost_test_discovery_test, which checks the listing parser and, through a small project read back by ctest, the generated scripts.

Why

Each executable had its own scheme, and each could miss tests. #618's hand-maintained contract list broke configure on every platform when #634 added suites; unit_test and wasm_spec_test grepped their sources, so unit_test never registered test cases declared outside a suite; and test_chain_plugin's case list was edited by hand. Reading the units from the built executable, as CMake does for GoogleTest, leaves nothing to keep in step.

Notes

  • Every existing entry keeps its name, command, working directory, labels, environment and COST. The differences: chainbase_type_heap_alloc and chainbase_type_segment_alloc now run on each runtime; the contract entries carry only the contract label (nothing read Register contract suites as independent CTest tests #618's per-contract labels, and they had drifted); the wasm-spec COST hints now apply, since their loop iterated an undefined EOSIO_WASM_RUNTIMES; and the coverage target's selector, which matched nothing, now selects the unit_test entries.
  • Discovery runs when ctest reads the test list, not during the build, so a test binary that cannot start fails a <target>_DISCOVERY_FAILED entry instead of the build. Before a binary is built, a failing <target>_NOT_BUILT entry stands in.
  • The listing goes to a dedicated report sink, so nothing the executable prints is mistaken for a unit, and concurrent ctest runs discover one at a time.
  • A default-disabled top-level unit is registered as a disabled CTest entry.

…ilt binary

The hand-maintained contract suite registry failed configure whenever a source declared a suite it did not list, so two pull requests that each pass CI, one adding a suite and one changing the registry, break configure on master once both merge, as #618 and #634 did. Its hand-written contract labels had also drifted: eight entries lacked contracts their suites deploy.

After contracts_unit_test links, contracts/tools/discover_contract_suites.py reads the executable's --list_content=DOT listing and writes one CTest entry per top-level unit, so a new suite, or a test case declared outside any suite, is registered without a CMake edit. Each entry is labelled with the contracts referenced by the files that declare the unit, its test cases and their dependencies, and by the local headers those files include: accessor calls resolved through the accessor headers, and contract header includes. The sysio.bios and sysio.roa contracts that libtester deploys during default setup label every entry.

If the executable cannot list itself, a single failing contract.DISCOVERY_FAILED entry carrying every label reports the error, so the build and the other tests still run. Before the executable is built, a failing contract.NOT_BUILT entry stands in. Editing the script relinks the executable, which reruns discovery.
@heifner
heifner requested a review from a team September 29, 2026 20:31
Replace the Python discovery of contract suites with boost_test_discover_tests(), a plain-CMake module modeled on CMake's gtest_discover_tests(DISCOVERY_MODE PRE_TEST). When ctest starts, it lists a Boost.Test executable's top-level units with --list_content, caches the result until the executable changes, and registers one CTest entry per unit, so adding or removing a suite needs no CMake edit.

Use it for every per-suite registration: unit_test and wasm_spec_test, which grepped their sources at configure time and never saw test cases declared outside a suite, test_chain_plugin, whose case list was maintained by hand, and contracts_unit_test. Every existing entry keeps its name, command, working directory, labels, environment and COST, except that the contract entries now carry only the contract label; nothing read the per-contract labels, and they had drifted. The file-scope chainbase_type_heap_alloc and chainbase_type_segment_alloc cases now run, the wasm-spec COST hints now apply (their loop iterated an undefined EOSIO_WASM_RUNTIMES), and the coverage target's selector, which matched nothing, now selects the unit_test entries. The unused unittest_tests_add macro is removed.

A binary that cannot list itself fails a <target>_DISCOVERY_FAILED entry instead of the build, and a missing one a <target>_NOT_BUILT entry. boost_test_discovery_test covers the listing parser and, through a small project read back by ctest, the generated scripts.
…ite-discovery

# Conflicts:
#	contracts/tests/CMakeLists.txt
@heifner heifner changed the title Test: Register contract suites and their labels from the built test binary Test: Register Boost.Test suites discovered from the test binaries Sep 30, 2026
Comment thread cmake/boost-test-discovery-impl.cmake Outdated

# One ctest process discovers at a time, and one that waited finds the script already current. The script is stale
# when the executable or the include script is newer (a tie counts) or this implementation changed.
file(LOCK "${arg_TESTS_FILE}.lock" GUARD FUNCTION TIMEOUT ${_BOOST_TEST_LIST_TIMEOUT_SECONDS}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

file(LOCK ... RESULT_VARIABLE lock_result) makes timeout and lock errors nonfatal, but lock_result is never checked. Discovery then removes and rewrites the shared .listing and .tmp files without owning the lock, so concurrent CTest processes can race or fail the rename. Please check for success before touching shared files, or let file(LOCK) fail fatally.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a1a0843. When the lock isn't acquired, discovery now leaves the shared files alone: it keeps the current list and registers a failing <target>_DISCOVERY_FAILED entry with the lock error, so the failure shows up in the test results rather than aborting ctest. The lock also waits 180 s now, longer than one discovery can take (the listing times out at 60 s), so a waiter outlasts a live holder. The regression test holds the lock from a second cmake -P process and checks that the attempt reports the error and leaves the existing tests file untouched.

endif()
set(build "${work}/build")
run("configure" "${CMAKE_COMMAND}" -S "${project}" -B "${build}" -G "${GENERATOR}" ${compiler})
listed_tests(tests "${build}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the top-level generator is multi-config, this nested project is multi-config too. listed_tests() omits -C, leaving CTEST_CONFIGURATION_TYPE empty and making the generated dispatcher include fake_discovery_1_include-.cmake. Please thread a configuration through the nested build and all CTest calls.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a1a0843. The test now gets -DCONFIG=$<CONFIG> and passes --config/-C to the nested build and every ctest call. The stand-in's output directory is pinned with a generator expression so file(TOUCH) finds the executable under multi-config generators too. I ran it with Ninja, Ninja Multi-Config (Debug and Release) and Unix Makefiles as the enclosing generator.

… its test

A failed file(LOCK) was ignored, so a ctest process that timed out waiting for another's discovery went on to rewrite the shared listing and tests files without owning the lock. It now keeps the current list and registers a failing <target>_DISCOVERY_FAILED entry naming the lock error, and it waits 180 s, longer than one discovery can take, so a waiter outlasts a live holder. The regression test holds the lock from a second process and checks both.

Under a multi-config generator the regression test's nested project was multi-config too, but it built and ran ctest without a configuration, so the generated dispatcher included <target>_discovery_1_include-.cmake. The test now receives $<CONFIG> and passes it to the nested build and every ctest call, and pins the stand-in executable's output directory so multi-config generators add no configuration subdirectory.
set(build_config "")
set(ctest_config "")
if(CONFIG)
set(build_config --config "${CONFIG}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CONFIG is only applied to cmake --build and CTest here; the nested configure still uses the generator defaults for CMAKE_CONFIGURATION_TYPES. When the outer multi-config build uses a valid custom configuration such as ASan, ctest -C ASan tries to include fake_discovery_1_include-ASan.cmake, which the nested configure never generated, and this self-test exits 8 before the build. I reproduced that with Ninja Multi-Config. Please pass the active configuration (or the parent configuration list) into the nested configure, for example with -DCMAKE_CONFIGURATION_TYPES=${CONFIG}.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0836c39. The nested configure now gets -DCMAKE_CONFIGURATION_TYPES=${CONFIG}, so a custom configuration's include script is generated. I reproduced your ASan case with Ninja Multi-Config before the change; it passes now, as do Ninja, Ninja Multi-Config (Debug, Release) and Unix Makefiles.

Under a multi-config generator with a custom configuration such as ASan, the regression test's nested project kept the generator's default configurations, so ctest -C ASan included a <target>_discovery_1_include-ASan.cmake that was never generated. The nested configure now gets CMAKE_CONFIGURATION_TYPES from the configuration under test.
# Lists arrive as single `;`-joined arguments; the ${ARGN} form splits them where PARSE_ARGV would not.
set(single_values EXECUTABLE TESTS_FILE INCLUDE_FILE FAILURE_NAME CMAKE_COMMAND WORKING_DIRECTORY NAME INCLUDE
EXCLUDE LOCK_TIMEOUT)
cmake_parse_arguments(arg "" "${single_values}" "NAME_STRIP;RUNTIMES;ARGS;PROPERTIES;COST_BY_NAME" ${ARGN})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cmake_parse_arguments(... ${ARGN}) reparses every function argument through CMake list syntax, whose elements cannot contain unmatched [ or ]. A valid checkout/build path such as /tmp/pr655]review therefore corrupts the later argument boundaries; on this head arg_TESTS_FILE becomes empty, file(REMOVE) warns about an empty filename, and discovery registers no tests. Please preserve scalar arguments with positional/PARSE_ARGV handling and explicitly expand only the serialized multi-value options. _boost_test_append_command also iterates its ARGN as a list at line 28, so path-valued emitted arguments need the same lossless handling.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, that's accurate, but I'd rather not handle it here. A checkout or build path with an unmatched [ or ] breaks CMake list handling generally, not just in this module: the file(GLOB UNIT_TESTS ...) → add_executable(... ${UNIT_TESTS}) pattern in unittests/ and contracts/tests/ merges every source path into one element and configure fails with "Cannot find source file" in such a directory, so discovery never runs. CMake's own GoogleTestAddTests.cmake parses with the same ${ARGN} form. Making this lossless would mean one argument per list element, PARSE_ARGV, and ARGV-indexed command building, all for paths the build can't use, so I'm leaving it as is.

@huangminghuang huangminghuang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed current head 0836c39. The lock handling and multi-config issues are resolved, and targeted Ninja, Unix Makefiles, and Ninja Multi-Config validation (including a custom ASan configuration) passes. The unmatched-bracket path limitation is a non-blocking compatibility edge consistent with existing project and upstream helper behavior.

@heifner
heifner merged commit 313420a into master Oct 2, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants