diff --git a/CMakeLists.txt b/CMakeLists.txt index 7ea45acf9..6f4c7f667 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,7 +2,7 @@ set(AGENT_VERSION_MAJOR 2) set(AGENT_VERSION_MINOR 7) set(AGENT_VERSION_PATCH 0) -set(AGENT_VERSION_BUILD 11) +set(AGENT_VERSION_BUILD 12) set(AGENT_VERSION_RC "") # This minimum version is to support Visual Studio 2019 and C++ feature checking and FetchContent @@ -71,6 +71,7 @@ include(cmake/remove_link_directories.cmake) include(cmake/osx_no_app_or_frameworks.cmake) include(cmake/ClangFormat.cmake) include(cmake/ClangTidy.cmake) +include(cmake/Coverage.cmake) # Add our projects add_subdirectory(agent_lib) diff --git a/cmake/Coverage.cmake b/cmake/Coverage.cmake new file mode 100644 index 000000000..484f2f310 --- /dev/null +++ b/cmake/Coverage.cmake @@ -0,0 +1,27 @@ +# Code coverage instrumentation. +# +# Enable with -DAGENT_ENABLE_COVERAGE=ON (or, via Conan, -o "&:coverage=True"). +# Must be included before the agent/test targets are defined so the flags +# propagate to every subsequently-declared target. +# +# Clang/AppleClang -> LLVM source-based coverage (llvm-profdata + llvm-cov) +# GCC -> gcov instrumentation (gcov/gcovr/lcov) + +option(AGENT_ENABLE_COVERAGE "Instrument the agent and tests for code coverage" OFF) + +if(AGENT_ENABLE_COVERAGE) + if(CMAKE_CXX_COMPILER_ID MATCHES "Clang") + message(STATUS "Coverage: enabling LLVM source-based coverage instrumentation") + add_compile_options( + $<$:-fprofile-instr-generate> + $<$:-fcoverage-mapping>) + add_link_options(-fprofile-instr-generate) + elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + message(STATUS "Coverage: enabling gcov instrumentation") + add_compile_options($<$:--coverage>) + add_link_options(--coverage) + else() + message(WARNING + "AGENT_ENABLE_COVERAGE requested but compiler '${CMAKE_CXX_COMPILER_ID}' is not supported") + endif() +endif() diff --git a/conanfile.py b/conanfile.py index 44567d713..f9ff32150 100644 --- a/conanfile.py +++ b/conanfile.py @@ -14,8 +14,9 @@ class MTConnectAgentConan(ConanFile): license = "Apache License 2.0" settings = "os", "compiler", "arch", "build_type" options = { "without_ipv6": [True, False], - "with_ruby": [True, False], + "with_ruby": [True, False], "development" : [True, False], + "coverage" : [True, False], "shared": [True, False], "winver": [None, "ANY"], "with_docs" : [True, False], @@ -34,6 +35,7 @@ class MTConnectAgentConan(ConanFile): "without_ipv6": False, "with_ruby": True, "development": False, + "coverage": False, "shared": False, "winver": "0x0A00", "with_docs": False, @@ -177,6 +179,7 @@ def generate(self): tc.cache_variables['AGENT_WITH_DOCS'] = self.options.with_docs.__bool__() tc.cache_variables['AGENT_WITHOUT_IPV6'] = self.options.without_ipv6.__bool__() tc.cache_variables['DEVELOPMENT'] = self.options.development.__bool__() + tc.cache_variables['AGENT_ENABLE_COVERAGE'] = self.options.coverage.__bool__() if self.options.agent_prefix: tc.cache_variables['AGENT_PREFIX'] = self.options.agent_prefix if is_msvc(self): diff --git a/src/mtconnect/sink/rest_sink/rest_service.cpp b/src/mtconnect/sink/rest_sink/rest_service.cpp index 24a113a41..b6101cc74 100644 --- a/src/mtconnect/sink/rest_sink/rest_service.cpp +++ b/src/mtconnect/sink/rest_sink/rest_service.cpp @@ -74,8 +74,19 @@ namespace mtconnect { auto base = GetOption(options, config::ExternalBaseUrl); if (!base) - base = "http://localhost:" + to_string(m_server->getPort()); - m_externalBaseAddress = *base; + { + auto ip = GetOption(options, config::ServerIp); + if (!ip || *ip == "0.0.0.0") + ip = GetBestHostAddress(m_context, true); + string protocol; + if (m_server->isTlsEnabled()) + protocol = "https://"; + else + protocol = "http://"; + base = protocol + *ip + ":" + to_string(m_server->getPort()) + '/'; + } + m_baseUrl = *base; + m_server->setBaseUrl(m_baseUrl); auto xmlPrinter = dynamic_cast(m_sinkContract->getPrinter("xml")); auto jsonPrinter = dynamic_cast(m_sinkContract->getPrinter("json")); diff --git a/src/mtconnect/sink/rest_sink/rest_service.hpp b/src/mtconnect/sink/rest_sink/rest_service.hpp index fd9455431..48b534a95 100644 --- a/src/mtconnect/sink/rest_sink/rest_service.hpp +++ b/src/mtconnect/sink/rest_sink/rest_service.hpp @@ -380,7 +380,7 @@ namespace mtconnect { std::string externalUrl(const std::string &url) { - std::string fullUrl = m_externalBaseAddress; + std::string fullUrl = m_baseUrl; if (!fullUrl.empty() && !url.empty()) { if (fullUrl.back() == '/' && url.front() == '/') @@ -405,7 +405,7 @@ namespace mtconnect { std::shared_ptr m_loopback; uint64_t m_instanceId; std::unique_ptr m_server; - std::string m_externalBaseAddress; + std::string m_baseUrl; // Buffers FileCache m_fileCache; diff --git a/src/mtconnect/sink/rest_sink/server.cpp b/src/mtconnect/sink/rest_sink/server.cpp index 6821f8bc0..0d2d1fa7f 100644 --- a/src/mtconnect/sink/rest_sink/server.cpp +++ b/src/mtconnect/sink/rest_sink/server.cpp @@ -371,15 +371,7 @@ namespace mtconnect::sink::rest_sink { AutoJsonArray ary(writer, "servers"); { AutoJsonObject obj(writer); - - stringstream str; - if (m_tlsEnabled) - str << "https://"; - else - str << "http://"; - - str << GetBestHostAddress(m_context, true) << ':' << m_port << '/'; - obj.AddPairs("url", str.str()); + obj.AddPairs("url", m_baseUrl); } } { diff --git a/src/mtconnect/sink/rest_sink/server.hpp b/src/mtconnect/sink/rest_sink/server.hpp index d6aece965..ac238e182 100644 --- a/src/mtconnect/sink/rest_sink/server.hpp +++ b/src/mtconnect/sink/rest_sink/server.hpp @@ -124,6 +124,14 @@ namespace mtconnect::sink::rest_sink { /// @return the port being bound auto getPort() const { return m_port; } + ///@brief Is TLS enabled? + ///@return `true` if TLS is enabled + bool isTlsEnabled() const { return m_tlsEnabled; } + + /// @brief Set the external base URL + /// @param[in] url the base URL to set + void setBaseUrl(const std::string &url) { m_baseUrl = url; } + /// @name PUT and POST handling ///@{ @@ -318,6 +326,7 @@ namespace mtconnect::sink::rest_sink { boost::asio::ip::address m_address; unsigned short m_port {5000}; + std::string m_baseUrl; bool m_run {false}; bool m_listening {false}; diff --git a/src/mtconnect/source/adapter/agent_adapter/agent_adapter.cpp b/src/mtconnect/source/adapter/agent_adapter/agent_adapter.cpp index 4082300e8..11c379b5d 100644 --- a/src/mtconnect/source/adapter/agent_adapter/agent_adapter.cpp +++ b/src/mtconnect/source/adapter/agent_adapter/agent_adapter.cpp @@ -232,8 +232,12 @@ namespace mtconnect::source::adapter::agent_adapter { m_strand, [weak = std::weak_ptr(getptr())](boost::system::error_code ec) { if (auto self = weak.lock(); self && !ec) { + // Rebuild the sample request from the latest processed sequence rather + // than replaying the cached request. In streaming mode the cached + // request's `from` is baked in when the stream opens and never advances, + // so replaying it re-delivers every observation seen during the session. if (self->canRecover() && self->m_streamRequest) - self->m_session->makeRequest(*self->m_streamRequest); + self->recover(); else self->run(); } @@ -352,7 +356,6 @@ namespace mtconnect::source::adapter::agent_adapter { clear(); - m_reconnecting = false; if (m_probeAgent) { probe(); @@ -369,7 +372,6 @@ namespace mtconnect::source::adapter::agent_adapter { if (m_stopped) return; - m_reconnecting = false; sample(); } diff --git a/src/mtconnect/source/adapter/agent_adapter/agent_adapter.hpp b/src/mtconnect/source/adapter/agent_adapter/agent_adapter.hpp index 308dc4b91..9a64bc194 100644 --- a/src/mtconnect/source/adapter/agent_adapter/agent_adapter.hpp +++ b/src/mtconnect/source/adapter/agent_adapter/agent_adapter.hpp @@ -89,6 +89,10 @@ namespace mtconnect::source::adapter::agent_adapter { /// @return reference to the transform feedback auto &getFeedback() { return m_feedback; } + /// @brief get the current outstanding stream request (for testing) + /// @return reference to the optional stream request + auto &getStreamRequest() { return m_streamRequest; } + ~AgentAdapter() override; /// @name Source interface @@ -128,7 +132,6 @@ namespace mtconnect::source::adapter::agent_adapter { url::Url m_url; int m_count = 1000; std::chrono::milliseconds m_heartbeat; - bool m_reconnecting = false; bool m_failed = false; bool m_stopped = false; bool m_usePolling = false; diff --git a/test_package/agent_adapter_test.cpp b/test_package/agent_adapter_test.cpp index 21394cbaf..1509ba6ca 100644 --- a/test_package/agent_adapter_test.cpp +++ b/test_package/agent_adapter_test.cpp @@ -417,6 +417,232 @@ TEST_F(AgentAdapterTest, should_reconnect) timeout.cancel(); } +TEST_F(AgentAdapterTest, should_reset_request_from_sequence_on_recovery) +{ + createAgent(); + + auto port = m_agentTestHelper->m_restService->getServer()->getPort(); + auto adapter = createAdapter(port, {}, "", 1000); + + addAdapter(); + + unique_ptr handler = make_unique(); + + int rc = 0; + ResponseDocument rd; + bool response = false; + handler->m_processData = [&](const string &d, const string &s) { + response = true; + + ResponseDocument::parse(d, rd, m_context); + rc++; + + if (rd.m_next != 0) + adapter->getFeedback().m_next = rd.m_next; + adapter->getFeedback().m_instanceId = rd.m_instanceId; + }; + handler->m_connecting = [&](const string id) {}; + handler->m_connected = [&](const string id) {}; + handler->m_disconnected = [&](const string id) {}; + + adapter->setHandler(handler); + adapter->start(); + + sink::rest_sink::SessionPtr session; + m_agentTestHelper->m_restService->getServer()->m_lastSession = + [&](sink::rest_sink::SessionPtr ptr) { session = ptr; }; + + boost::asio::steady_timer timeout(m_agentTestHelper->m_ioContext, 5s); + timeout.async_wait([](boost::system::error_code ec) { + if (!ec) + { + throw runtime_error("test timed out"); + } + }); + + // Wait for the current and the initial sample request. After this the + // streaming sample request exists with `from` set to the sequence that + // followed the current response. + while (rc < 2) + { + m_agentTestHelper->m_ioContext.run_one(); + } + ASSERT_EQ(2, rc); + ASSERT_TRUE(session); + + ASSERT_TRUE(adapter->getStreamRequest()); + auto fromInitial = boost::lexical_cast(adapter->getStreamRequest()->m_query.at("from")); + + // Push a new observation upstream so the next sequence advances past the + // value baked into the original streaming request. + rd.m_entities.clear(); + m_agentTestHelper->m_adapter->processData("2021-02-01T12:00:00Z|execution|READY"); + while (rc < 3) + { + m_agentTestHelper->m_ioContext.run_one(); + } + ASSERT_EQ(3, rc); + + auto advanced = adapter->getFeedback().m_next; + ASSERT_GT(advanced, fromInitial); + + // Force a disconnect to trigger recovery. + session->close(); + response = false; + while (!response) + { + m_agentTestHelper->m_ioContext.run_one(); + } + + // On recovery the request must be rebuilt ("reset") so that it resumes from + // the advanced sequence instead of replaying the original `from`, which would + // re-deliver every observation already streamed during the prior session. + ASSERT_TRUE(adapter->getStreamRequest()); + auto fromRecovered = + boost::lexical_cast(adapter->getStreamRequest()->m_query.at("from")); + + ASSERT_GT(fromRecovered, fromInitial); + ASSERT_EQ(advanced, fromRecovered); + + m_agentTestHelper->m_restService->getServer()->m_lastSession = nullptr; + timeout.cancel(); +} + +TEST_F(AgentAdapterTest, should_resync_with_current_when_instance_id_changes_on_recovery) +{ + createAgent(); + + auto port = m_agentTestHelper->m_restService->getServer()->getPort(); + auto adapter = createAdapter(port, {}, "", 500); + + addAdapter(); + + unique_ptr handler = make_unique(); + + int rc = 0; + bool disconnected = false; + bool recovering = false; + bool response = false; + ResponseDocument rd; + + // Record the (operation, from) of the request each response answers so we can + // verify the adapter resets to a `current` after an instance-id change rather + // than resuming a `sample` from the now-stale sequence. + vector> requests; + + handler->m_processData = [&](const string &d, const string &s) { + rd.m_next = 0; + rd.m_instanceId = 0; + ResponseDocument::parse(d, rd, m_context); + rc++; + + if (auto &req = adapter->getStreamRequest()) + { + string from; + auto f = req->m_query.find("from"); + if (f != req->m_query.end()) + from = f->second; + requests.emplace_back(req->m_operation, from); + } + + if (rd.m_next != 0) + response = true; + + auto seq = &adapter->getFeedback(); + if (rd.m_next != 0) + seq->m_next = rd.m_next; + if (recovering) + { + // Simulate the source agent restarting with a new instance id and + // sequences reset backward: the transform would detect this and throw. + recovering = false; + throw std::system_error(make_error_code(mtconnect::source::ErrorCode::INSTANCE_ID_CHANGED)); + } + seq->m_instanceId = rd.m_instanceId; + disconnected = false; + }; + handler->m_connecting = [&](const string id) {}; + handler->m_connected = [&](const string id) {}; + handler->m_disconnected = [&](const string id) { disconnected = true; }; + + adapter->setHandler(handler); + adapter->start(); + + sink::rest_sink::SessionPtr session; + m_agentTestHelper->m_restService->getServer()->m_lastSession = + [&](sink::rest_sink::SessionPtr ptr) { session = ptr; }; + + boost::asio::steady_timer timeout(m_agentTestHelper->m_ioContext, 5s); + timeout.async_wait([](boost::system::error_code ec) { + if (!ec) + { + throw runtime_error("test timed out"); + } + }); + + // Wait for the current and the initial sample. + while (rc < 2) + { + m_agentTestHelper->m_ioContext.run_one(); + } + ASSERT_EQ(2, rc); + ASSERT_TRUE(session); + + // Advance the sequence so the streaming `from` is well past the start. + rd.m_entities.clear(); + m_agentTestHelper->m_adapter->processData("2021-02-01T12:00:00Z|execution|READY"); + while (rc < 3) + { + m_agentTestHelper->m_ioContext.run_one(); + } + ASSERT_EQ(3, rc); + + auto staleNext = adapter->getFeedback().m_next; + ASSERT_GT(staleNext, 0u); + + size_t requestsBefore = requests.size(); + + // Drop the connection and trigger an instance-id change on the next response. + session->close(); + recovering = true; + session.reset(); + while (recovering) + { + m_agentTestHelper->m_ioContext.run_one(); + } + ASSERT_FALSE(recovering); + ASSERT_TRUE(disconnected); + + // After the instance change the adapter must resync with a `current`. + response = false; + rd.m_entities.clear(); + while (!response) + { + m_agentTestHelper->m_ioContext.run_one(); + } + ASSERT_TRUE(response); + + // A `current` (no stale `from`) must be issued after the instance change, + // proving the request was reset rather than resumed from the old sequence. + bool sawCurrent = false; + for (size_t i = requestsBefore; i < requests.size(); i++) + { + if (requests[i].first == "current") + { + ASSERT_TRUE(requests[i].second.empty()) << "current request must not carry a stale 'from'"; + sawCurrent = true; + break; + } + } + ASSERT_TRUE(sawCurrent) << "Adapter must issue a current() to resync after an instance-id change"; + + // The resync must actually deliver the new observations, not filter them out. + ASSERT_FALSE(rd.m_entities.empty()); + + m_agentTestHelper->m_restService->getServer()->m_lastSession = nullptr; + timeout.cancel(); +} + TEST_F(AgentAdapterTest, should_connect_with_http_10_agent) { createAgent(); diff --git a/test_package/agent_test.cpp b/test_package/agent_test.cpp index 060341172..b05a5f312 100644 --- a/test_package/agent_test.cpp +++ b/test_package/agent_test.cpp @@ -2759,8 +2759,7 @@ TEST_F(AgentTest, should_not_add_the_default_schema_location_for_json_documents_ TEST_F(AgentTest, should_add_local_locations_when_files_are_given) { using namespace nlohmann; - auto configFiles = std::format(R"( -Files {{ + auto configFiles = std::format(R"(Files {{ schemas {{ Path = {}/schemas Location = /myschemas/ @@ -2771,8 +2770,10 @@ Files {{ auto config = configuration::Parser::parse(configFiles); - m_agentTestHelper->createAgent("/samples/test_config.xml", 8, 4, "2.7", 4, true, true, - {{configuration::JsonVersion, 2}}, config); + m_agentTestHelper->createAgent( + "/samples/test_config.xml", 8, 4, "2.7", 4, true, true, + {{configuration::JsonVersion, 2}, {configuration::ExternalBaseUrl, "http://localhost:0"s}}, + config); { PARSE_JSON_RESPONSE("/probe"); diff --git a/test_package/config_test.cpp b/test_package/config_test.cpp index 70e2f8d55..50599b122 100644 --- a/test_package/config_test.cpp +++ b/test_package/config_test.cpp @@ -2531,7 +2531,8 @@ DevicesStyle { TEST_F(ConfigTest, custom_json_schema_location_for_devices) { - string config {std::format(R"(DevicesJsonSchema {{ + string config {std::format(R"(ExternalBaseUrl = http://localhost:5000 +DevicesJsonSchema {{ Location = /myschemas/MTConnectDevices_2.7_draft-04.schema.json Path = {}/schemas/MTConnectDevices_2.7_draft-04.schema.json }})", @@ -2549,7 +2550,8 @@ TEST_F(ConfigTest, custom_json_schema_location_for_devices) TEST_F(ConfigTest, custom_json_schema_location_for_streams) { - string config {std::format(R"(StreamsJsonSchema {{ + string config {std::format(R"(ExternalBaseUrl = http://localhost:5000 +StreamsJsonSchema {{ Location = /myschemas/MTConnectStreams_2.7_draft-04.schema.json Path = {}/schemas/MTConnectStreams_2.7_draft-04.schema.json }})", @@ -2567,7 +2569,8 @@ TEST_F(ConfigTest, custom_json_schema_location_for_streams) TEST_F(ConfigTest, custom_json_schema_location_for_assets) { - string config {std::format(R"(AssetsJsonSchema {{ + string config {std::format(R"(ExternalBaseUrl = http://localhost:5000 +AssetsJsonSchema {{ Location = /myschemas/MTConnectAssets_2.7_draft-04.schema.json Path = {}/schemas/MTConnectAssets_2.7_draft-04.schema.json }})", @@ -2585,7 +2588,8 @@ TEST_F(ConfigTest, custom_json_schema_location_for_assets) TEST_F(ConfigTest, custom_json_schema_location_for_error) { - string config {std::format(R"(ErrorJsonSchema {{ + string config {std::format(R"(ExternalBaseUrl = http://localhost:5000 +ErrorJsonSchema {{ Location = /myschemas/MTConnectError_2.7_draft-04.schema.json Path = {}/schemas/MTConnectError_2.7_draft-04.schema.json }})", diff --git a/tools/coverage.sh b/tools/coverage.sh new file mode 100755 index 000000000..6cc3d9e90 --- /dev/null +++ b/tools/coverage.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# +# Generate an LLVM source-based code-coverage report for the cppagent project. +# +# Prerequisites: build the agent + tests with coverage instrumentation, e.g. +# +# conan build . --build=missing -pr conan/profiles/macos \ +# -o "&:development=True" -o "&:coverage=True" -o "&:shared=True" +# +# # or, with plain CMake: +# cmake -S . -B -DDEVELOPMENT=ON -DSHARED_AGENT_LIB=ON -DAGENT_ENABLE_COVERAGE=ON +# cmake --build -j +# +# Usage: +# tools/coverage.sh [BUILD_DIR] +# +# BUILD_DIR Directory containing the instrumented build (with CMakeCache.txt). +# May also be set via COVERAGE_BUILD_DIR. Defaults to ./build. +# +# Output: +# /coverage_report.txt per-file summary +# /coverage_html/ browsable line-by-line HTML +# +# Env knobs: +# REUSE_PROFILES=1 skip running ctest, reuse existing cov/*.profraw +# CTEST_ARGS="..." extra args passed to ctest (default: -j) + +set -euo pipefail + +BUILD_DIR="${1:-${COVERAGE_BUILD_DIR:-build}}" + +if [ ! -f "$BUILD_DIR/CMakeCache.txt" ]; then + echo "error: '$BUILD_DIR' is not a CMake build directory (no CMakeCache.txt)." >&2 + echo " Pass the instrumented build dir as the first argument or set COVERAGE_BUILD_DIR." >&2 + exit 1 +fi + +if ! grep -q "AGENT_ENABLE_COVERAGE:.*=ON" "$BUILD_DIR/CMakeCache.txt" 2>/dev/null; then + echo "warning: AGENT_ENABLE_COVERAGE is not ON in '$BUILD_DIR'." >&2 + echo " The report will be empty unless the build was instrumented." >&2 +fi + +# Resolve the LLVM tools (xcrun on macOS, otherwise expect them on PATH). +if command -v xcrun >/dev/null 2>&1; then + LLVM_PROFDATA=(xcrun llvm-profdata) + LLVM_COV=(xcrun llvm-cov) +else + LLVM_PROFDATA=(llvm-profdata) + LLVM_COV=(llvm-cov) +fi + +BUILD_DIR="$(cd "$BUILD_DIR" && pwd)" +cd "$BUILD_DIR" + +NCPU="$( (sysctl -n hw.ncpu 2>/dev/null || nproc 2>/dev/null || echo 4) )" +CTEST_ARGS="${CTEST_ARGS:--j${NCPU}}" + +# 1. Run the test suite, each process emitting its own raw profile. +if [ "${REUSE_PROFILES:-0}" != "1" ]; then + rm -rf cov coverage.profdata coverage_html coverage_report.txt + mkdir -p cov + echo "=== Running ctest (instrumented) ===" + # Don't abort the whole report if a test fails; we still want coverage. + LLVM_PROFILE_FILE="$BUILD_DIR/cov/%p.profraw" ctest --output-on-failure $CTEST_ARGS || true +fi + +shopt -s nullglob +PROFRAW=(cov/*.profraw) +if [ "${#PROFRAW[@]}" -eq 0 ]; then + echo "error: no .profraw files were produced in $BUILD_DIR/cov." >&2 + echo " Was the build instrumented and did any tests run?" >&2 + exit 1 +fi +echo "=== Merging ${#PROFRAW[@]} raw profiles ===" +"${LLVM_PROFDATA[@]}" merge -sparse "${PROFRAW[@]}" -o coverage.profdata + +# 2. Locate the instrumented object(s). +# Shared build -> a single agent dylib/so. Static build -> all test binaries. +OBJECTS=() +while IFS= read -r lib; do OBJECTS+=("$lib"); done < <( + find . \( -name 'libagent_lib.*' -o -name 'libmtconnect_agent*.dylib' \ + -o -name 'libagent_lib*.so' \) \ + ! -name '*.a' -type f 2>/dev/null | head -1) + +if [ "${#OBJECTS[@]}" -eq 0 ]; then + echo "=== No shared agent lib found; using test binaries (static build) ===" + COV_ARGS=() + first="" + while IFS= read -r bin; do + if [ -z "$first" ]; then first="$bin"; else COV_ARGS+=(-object "$bin"); fi + done < <(find . -maxdepth 3 -name '*_test' -type f -perm -u+x 2>/dev/null) + set -- "$first" "${COV_ARGS[@]}" +else + echo "=== Coverage object: ${OBJECTS[0]} ===" + set -- "${OBJECTS[0]}" +fi + +IGNORE='(test_package|/build/|\.conan2|_deps|/usr/|gtest|googletest|/Xcode)' + +# 3. Per-file summary (also saved to a file). +echo "=== Coverage summary ===" +"${LLVM_COV[@]}" report "$@" -instr-profile=coverage.profdata \ + -ignore-filename-regex="$IGNORE" | tee "$BUILD_DIR/coverage_report.txt" + +# 4. Browsable HTML. +"${LLVM_COV[@]}" show "$@" -instr-profile=coverage.profdata \ + -format=html -output-dir="$BUILD_DIR/coverage_html" \ + -show-line-counts-or-regions -ignore-filename-regex="$IGNORE" + +echo +echo "Text report: $BUILD_DIR/coverage_report.txt" +echo "HTML report: $BUILD_DIR/coverage_html/index.html"