From 51003667511478a50e8792137e1676e1006b8b42 Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Mon, 31 Aug 2026 16:20:47 +0300 Subject: [PATCH 01/28] Fix cached configs_hash being cleared on every launch, not only on identifier change --- source/gameanalytics/GAState.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/source/gameanalytics/GAState.cpp b/source/gameanalytics/GAState.cpp index ae6f694..92de1b4 100644 --- a/source/gameanalytics/GAState.cpp +++ b/source/gameanalytics/GAState.cpp @@ -624,8 +624,9 @@ namespace gameanalytics std::string lastUsedIdentifier = state_dict.contains("last_used_identifier") ? state_dict["last_used_identifier"].get() : ""; - if (!lastUsedIdentifier.empty()) + if (!lastUsedIdentifier.empty() && lastUsedIdentifier != _identifier) { + logging::GALogger::w("New identifier spotted compared to last one used, clearing cached configs hash!"); if (d.contains("configs_hash")) { d.erase("configs_hash"); From c5c85acf8c41bbf2363b007e450af94659422a88 Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Mon, 31 Aug 2026 16:20:52 +0300 Subject: [PATCH 02/28] Guard remote configs listeners and json rebuild with state mutex --- source/gameanalytics/GAState.cpp | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/source/gameanalytics/GAState.cpp b/source/gameanalytics/GAState.cpp index 92de1b4..038b262 100644 --- a/source/gameanalytics/GAState.cpp +++ b/source/gameanalytics/GAState.cpp @@ -887,6 +887,7 @@ namespace gameanalytics void GAState::addRemoteConfigsListener(const std::shared_ptr& listener) { + std::lock_guard lg(getInstance()._mtx); if(std::find(getInstance()._remoteConfigsListeners.begin(), getInstance()._remoteConfigsListeners.end(), listener) == getInstance()._remoteConfigsListeners.end()) { getInstance()._remoteConfigsListeners.push_back(listener); @@ -895,10 +896,11 @@ namespace gameanalytics void GAState::removeRemoteConfigsListener(const std::shared_ptr& listener) { + std::lock_guard lg(getInstance()._mtx); if(std::find(getInstance()._remoteConfigsListeners.begin(), getInstance()._remoteConfigsListeners.end(), listener) != getInstance()._remoteConfigsListeners.end()) { getInstance()._remoteConfigsListeners.erase( - std::remove(getInstance()._remoteConfigsListeners.begin(), getInstance()._remoteConfigsListeners.end(), listener), + std::remove(getInstance()._remoteConfigsListeners.begin(), getInstance()._remoteConfigsListeners.end(), listener), getInstance()._remoteConfigsListeners.end() ); } @@ -979,12 +981,20 @@ namespace gameanalytics } } - buildRemoteConfigsJsons(_tempRemoteConfigsJson); + std::string configStr; + std::vector> listeners; + { + std::lock_guard lg(_mtx); - _remoteConfigsIsReady = true; - - std::string const configStr = _gameRemoteConfigsJson.dump(); - for (auto& listener : _remoteConfigsListeners) + buildRemoteConfigsJsons(_tempRemoteConfigsJson); + _remoteConfigsIsReady = true; + + configStr = _gameRemoteConfigsJson.dump(); + listeners = _remoteConfigsListeners; + } + + // notify outside the lock so a listener can safely call back into the SDK + for (auto& listener : listeners) { listener->onRemoteConfigsUpdated(configStr); } From 74893f04c4b4ef107dd1f06faec6923760d5bb4e Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Mon, 31 Aug 2026 16:20:52 +0300 Subject: [PATCH 03/28] Add regression tests for configs_hash persistence across launches --- source/gameanalytics/GAState.h | 1 + test/GAStateTests.cpp | 182 ++++++++++++++------------------- 2 files changed, 76 insertions(+), 107 deletions(-) diff --git a/source/gameanalytics/GAState.h b/source/gameanalytics/GAState.h index 0be43dd..ef2056f 100644 --- a/source/gameanalytics/GAState.h +++ b/source/gameanalytics/GAState.h @@ -79,6 +79,7 @@ namespace gameanalytics friend class logging::GALogger; friend class store::GAStore; friend class http::GAHTTPApi; + friend struct GAStateTestAccessor; public: diff --git a/test/GAStateTests.cpp b/test/GAStateTests.cpp index 7e763a3..0661d3a 100644 --- a/test/GAStateTests.cpp +++ b/test/GAStateTests.cpp @@ -7,110 +7,78 @@ #include #include -//#include "rapidjson/document.h" -// -//#include "helpers/GATestHelpers.h" -// -//TEST(GAStateTest, testValidateAndCleanCustomFields) -//{ -// rapidjson::Document map; -// rapidjson::Value v; -// -// { -// v = rapidjson::Value(); -// map.SetObject(); -// rapidjson::Document::AllocatorType& a = map.GetAllocator(); -// while(map.MemberCount() < 100) -// { -// map.AddMember(rapidjson::Value(GATestHelpers::getRandomString(4).c_str(), a), rapidjson::Value(GATestHelpers::getRandomString(4).c_str(), a), a); -// } -// } -// ASSERT_EQ(100, map.MemberCount()); -// gameanalytics::state::GAState::validateAndCleanCustomFields(map, v); -// ASSERT_TRUE(v.MemberCount() == 50); -// -// { -// v = rapidjson::Value(); -// map.SetObject(); -// rapidjson::Document::AllocatorType& a = map.GetAllocator(); -// while(map.MemberCount() < 50) -// { -// map.AddMember(rapidjson::Value(GATestHelpers::getRandomString(4).c_str(), a), rapidjson::Value(GATestHelpers::getRandomString(4).c_str(), a), a); -// } -// } -// ASSERT_EQ(50, map.MemberCount()); -// gameanalytics::state::GAState::validateAndCleanCustomFields(map, v); -// ASSERT_EQ(50, v.MemberCount()); -// -// { -// v = rapidjson::Value(); -// map.SetObject(); -// rapidjson::Document::AllocatorType& a = map.GetAllocator(); -// map.AddMember(rapidjson::Value(GATestHelpers::getRandomString(4).c_str(), a), rapidjson::Value("", a), a); -// } -// gameanalytics::state::GAState::validateAndCleanCustomFields(map, v); -// ASSERT_TRUE(v.MemberCount() == 0); -// -// { -// v = rapidjson::Value(); -// map.SetObject(); -// rapidjson::Document::AllocatorType& a = map.GetAllocator(); -// map.AddMember(rapidjson::Value(GATestHelpers::getRandomString(4).c_str(), a), rapidjson::Value(GATestHelpers::getRandomString(257).c_str(), a), a); -// } -// gameanalytics::state::GAState::validateAndCleanCustomFields(map, v); -// ASSERT_TRUE(v.MemberCount() == 0); -// -// { -// v = rapidjson::Value(); -// map.SetObject(); -// rapidjson::Document::AllocatorType& a = map.GetAllocator(); -// map.AddMember("", rapidjson::Value(GATestHelpers::getRandomString(4).c_str(), a), a); -// } -// gameanalytics::state::GAState::validateAndCleanCustomFields(map, v); -// ASSERT_TRUE(v.MemberCount() == 0); -// -// { -// v = rapidjson::Value(); -// map.SetObject(); -// rapidjson::Document::AllocatorType& a = map.GetAllocator(); -// map.AddMember(rapidjson::Value("___", a), rapidjson::Value(GATestHelpers::getRandomString(4).c_str(), a), a); -// } -// gameanalytics::state::GAState::validateAndCleanCustomFields(map, v); -// ASSERT_TRUE(v.MemberCount() == 1); -// -// { -// v = rapidjson::Value(); -// map.SetObject(); -// rapidjson::Document::AllocatorType& a = map.GetAllocator(); -// map.AddMember(rapidjson::Value("_&_", a), rapidjson::Value(GATestHelpers::getRandomString(4).c_str(), a), a); -// } -// gameanalytics::state::GAState::validateAndCleanCustomFields(map, v); -// ASSERT_TRUE(v.MemberCount() == 0); -// -// { -// v = rapidjson::Value(); -// map.SetObject(); -// rapidjson::Document::AllocatorType& a = map.GetAllocator(); -// map.AddMember(rapidjson::Value(GATestHelpers::getRandomString(65).c_str(), a), rapidjson::Value(GATestHelpers::getRandomString(4).c_str(), a), a); -// } -// gameanalytics::state::GAState::validateAndCleanCustomFields(map, v); -// ASSERT_TRUE(v.MemberCount() == 0); -// -// { -// v = rapidjson::Value(); -// map.SetObject(); -// rapidjson::Document::AllocatorType& a = map.GetAllocator(); -// map.AddMember(rapidjson::Value(GATestHelpers::getRandomString(4).c_str(), a), rapidjson::Value(100), a); -// } -// gameanalytics::state::GAState::validateAndCleanCustomFields(map, v); -// ASSERT_TRUE(v.MemberCount() == 1); -// -// { -// v = rapidjson::Value(); -// map.SetObject(); -// rapidjson::Document::AllocatorType& a = map.GetAllocator(); -// map.AddMember(rapidjson::Value(GATestHelpers::getRandomString(4).c_str(), a), rapidjson::Value(true), a); -// } -// gameanalytics::state::GAState::validateAndCleanCustomFields(map, v); -// ASSERT_TRUE(v.MemberCount() == 0); -//} +#include + +namespace gameanalytics +{ + namespace state + { + // friend of GAState (see GAState.h) exposing the private bits needed + // to exercise ensurePersistedStates() in isolation + struct GAStateTestAccessor + { + static void resetConfigState(std::string const& customUserId) + { + GAState& s = GAState::getInstance(); + + s._sdkConfig = json(); + s._sdkConfigCached = json(); + s._configsHash.clear(); + s._defaultUserId.clear(); + + s._customUserId = customUserId; + s.cacheIdentifier(); + } + + static void ensurePersistedStates() + { + GAState::getInstance().ensurePersistedStates(); + } + + static std::string configsHash() + { + return GAState::getInstance()._configsHash; + } + }; + } +} + +using namespace gameanalytics; + +namespace +{ + constexpr const char* kGameKey = "bd624ee6f8e6efb32a054f8d7ba11618"; + + void seedCachedConfig(std::string const& lastUsedIdentifier, std::string const& configsHash) + { + ASSERT_TRUE(store::GAStore::ensureDatabase(false, kGameKey)); + + store::GAStore::setState("last_used_identifier", lastUsedIdentifier); + store::GAStore::setState("sdk_config_cached", std::string("{\"configs_hash\":\"") + configsHash + "\"}"); + } +} + +// Regression test: the cached configs_hash must survive a relaunch with the +// same user identifier, so the init request can tell the backend which config +// version it already has. It must only be cleared when the identifier changed +// since the config was cached (matches the iOS/C# SDK behavior). + +TEST(GAStateTest, testConfigsHashKeptWhenIdentifierUnchanged) +{ + seedCachedConfig("user-a", "hash-abc123"); + + state::GAStateTestAccessor::resetConfigState("user-a"); + state::GAStateTestAccessor::ensurePersistedStates(); + + ASSERT_EQ("hash-abc123", state::GAStateTestAccessor::configsHash()); +} + +TEST(GAStateTest, testConfigsHashClearedWhenIdentifierChanged) +{ + seedCachedConfig("user-a", "hash-abc123"); + + state::GAStateTestAccessor::resetConfigState("user-b"); + state::GAStateTestAccessor::ensurePersistedStates(); + + ASSERT_TRUE(state::GAStateTestAccessor::configsHash().empty()); +} From eb24d1d6eea85b1a1b348f003705af8d91df9959 Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Mon, 31 Aug 2026 16:26:45 +0300 Subject: [PATCH 04/28] Fix custom fields payload discarded when an illegal key holds a non-string value --- source/gameanalytics/GAState.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/gameanalytics/GAState.cpp b/source/gameanalytics/GAState.cpp index 038b262..aa8a32d 100644 --- a/source/gameanalytics/GAState.cpp +++ b/source/gameanalytics/GAState.cpp @@ -1083,8 +1083,8 @@ namespace gameanalytics else { constexpr const char* fmt = "validateAndCleanCustomFields: entry with key=%s, value=%s has been omitted because its key contains illegal character, is empty or exceeds the max number of characters (%d)"; - - const std::string value = fields[key].get(); + + const std::string value = fields[key].dump(); LogAndAddErrorEvent(EGAErrorSeverity::Warning, fmt, key.c_str(), value.c_str(), MAX_CUSTOM_FIELDS_KEY_LENGTH); } } From 0c1a806b8a73975c0872362a3ab52cad3eba7f52 Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Mon, 31 Aug 2026 16:26:45 +0300 Subject: [PATCH 05/28] Add custom fields validation tests --- test/GAStateTests.cpp | 66 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/test/GAStateTests.cpp b/test/GAStateTests.cpp index 0661d3a..8672d07 100644 --- a/test/GAStateTests.cpp +++ b/test/GAStateTests.cpp @@ -39,6 +39,13 @@ namespace gameanalytics { return GAState::getInstance()._configsHash; } + + static json validateAndCleanCustomFields(const json& fields) + { + json out; + GAState::getInstance().validateAndCleanCustomFields(fields, out); + return out; + } }; } } @@ -82,3 +89,62 @@ TEST(GAStateTest, testConfigsHashClearedWhenIdentifierChanged) ASSERT_TRUE(state::GAStateTestAccessor::configsHash().empty()); } + +// validateAndCleanCustomFields: keys must match ^[a-zA-Z0-9_]{1,64}$, values must be +// a number, a boolean or a non-empty string of at most 256 chars, capped at 50 fields + +static json cleanFields(const json& fields) +{ + return state::GAStateTestAccessor::validateAndCleanCustomFields(fields); +} + +TEST(GAStateTest, testCustomFieldsCappedAtMaxCount) +{ + json fields; + for (int i = 0; i < MAX_CUSTOM_FIELDS_COUNT * 2; ++i) + { + fields["key_" + std::to_string(i)] = "value"; + } + ASSERT_EQ(MAX_CUSTOM_FIELDS_COUNT, static_cast(cleanFields(fields).size())); + + fields.clear(); + for (int i = 0; i < MAX_CUSTOM_FIELDS_COUNT; ++i) + { + fields["key_" + std::to_string(i)] = "value"; + } + ASSERT_EQ(MAX_CUSTOM_FIELDS_COUNT, static_cast(cleanFields(fields).size())); +} + +TEST(GAStateTest, testCustomFieldsKeyValidation) +{ + ASSERT_EQ(1u, cleanFields({{"___", "value"}}).size()); + ASSERT_EQ(1u, cleanFields({{std::string(MAX_CUSTOM_FIELDS_KEY_LENGTH, 'k'), "value"}}).size()); + + ASSERT_TRUE(cleanFields({{"", "value"}}).empty()); + ASSERT_TRUE(cleanFields({{"_&_", "value"}}).empty()); + ASSERT_TRUE(cleanFields({{std::string(MAX_CUSTOM_FIELDS_KEY_LENGTH + 1, 'k'), "value"}}).empty()); +} + +TEST(GAStateTest, testCustomFieldsValueValidation) +{ + ASSERT_EQ(1u, cleanFields({{"key", 100}}).size()); + ASSERT_EQ(1u, cleanFields({{"key", 3.14}}).size()); + ASSERT_EQ(1u, cleanFields({{"key", true}}).size()); + ASSERT_EQ(1u, cleanFields({{"key", std::string(MAX_CUSTOM_FIELDS_VALUE_STRING_LENGTH, 'v')}}).size()); + + ASSERT_TRUE(cleanFields({{"key", ""}}).empty()); + ASSERT_TRUE(cleanFields({{"key", std::string(MAX_CUSTOM_FIELDS_VALUE_STRING_LENGTH + 1, 'v')}}).empty()); + ASSERT_TRUE(cleanFields({{"key", nullptr}}).empty()); + ASSERT_TRUE(cleanFields({{"key", json::object()}}).empty()); + ASSERT_TRUE(cleanFields({{"key", json::array()}}).empty()); +} + +// regression: a non-string value under an illegal key used to throw while logging +// the rejection, discarding every other field in the payload +TEST(GAStateTest, testCustomFieldsIllegalKeyWithNumberValueKeepsOtherFields) +{ + json out = cleanFields({{"bad&key", 100}, {"good_key", "value"}}); + + ASSERT_EQ(1u, out.size()); + ASSERT_TRUE(out.contains("good_key")); +} From 8a0046b9dbeda8f435536a257be6aee4f806048a Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Mon, 31 Aug 2026 16:49:29 +0300 Subject: [PATCH 06/28] Use atomic gcov counter updates in coverage builds --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 52a5c52..cdaf2a0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -299,7 +299,7 @@ if (ENABLE_COVERAGE AND NOT GA_SHARED_LIB) target_compile_options( GameAnalytics PRIVATE - -g -O0 -fprofile-arcs -ftest-coverage + -g -O0 -fprofile-arcs -ftest-coverage -fprofile-update=atomic ) target_link_libraries( From d4b1ae2315f0d7df98ba35766143ecdecf1d63f2 Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Mon, 31 Aug 2026 16:52:59 +0300 Subject: [PATCH 07/28] Disable event submission in unit tests --- test/main.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/main.cpp b/test/main.cpp index 4be4f35..b127276 100644 --- a/test/main.cpp +++ b/test/main.cpp @@ -1,8 +1,13 @@ #include +#include + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); - + + // tests must never send events to the GA servers + gameanalytics::state::GAState::setEnabledEventSubmission(false); + if (sizeof(void*) == 8) { std::cout << "64-bit architecture" << std::endl; } else if (sizeof(void*) == 4) { From 9de361aae021cde6e82341562ceff9b17a401ae3 Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Mon, 31 Aug 2026 17:06:24 +0300 Subject: [PATCH 08/28] Add event store and send queue tests using mock http client --- test/GAEventsTests.cpp | 276 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 test/GAEventsTests.cpp diff --git a/test/GAEventsTests.cpp b/test/GAEventsTests.cpp new file mode 100644 index 0000000..0edf1c8 --- /dev/null +++ b/test/GAEventsTests.cpp @@ -0,0 +1,276 @@ +// +// GA-SDK-CPP +// Tests for the event store and send queue, using a mock HTTP client +// + +#include +#include + +#include +#include +#include +#include +#include "GameAnalytics/GAHttpClient.h" + +using namespace gameanalytics; + +namespace +{ + constexpr const char* kGameKey = "bd624ee6f8e6efb32a054f8d7ba11618"; + constexpr const char* kGameSecret = "7f5c3f682cbd217841efba92e92ffb1b3b6612bc"; + + class MockHttpClient : public GAHttpClient + { + public: + void initialize() override {} + void cleanup() override {} + + Response sendRequest( + std::string const& url, + std::string const& auth, + std::vector const& payloadData, + bool useGzip, + void* userData) override + { + lastUrl = url; + lastAuth = auth; + lastPayload = payloadData; + lastUseGzip = useGzip; + requestCount++; + + return configuredResponse; + } + + int requestCount = 0; + std::string lastUrl; + std::string lastAuth; + std::vector lastPayload; + bool lastUseGzip = false; + + Response configuredResponse = {}; + }; + + class GAEventsTest : public ::testing::Test + { + protected: + void SetUp() override + { + state::GAState::setKeys(kGameKey, kGameSecret); + ASSERT_TRUE(store::GAStore::ensureDatabase(false, kGameKey)); + + auto mockPtr = std::make_unique(); + mock = mockPtr.get(); + setResponse(200, R"({"status":"ok"})"); + http::GAHTTPApi::setCustomHttpImpl(std::move(mockPtr)); + + state::GAState::setEnabledEventSubmission(true); + state::GAState::internalInitialize(); + events::GAEvents::stopEventQueue(); + + clearEvents(); + mock->requestCount = 0; + } + + void TearDown() override + { + clearEvents(); + state::GAState::setEnabledEventSubmission(false); + http::GAHTTPApi::setCustomHttpImpl(nullptr); + } + + void setResponse(long code, std::string const& body) + { + mock->configuredResponse.code = code; + mock->configuredResponse.packet.assign(body.begin(), body.end()); + } + + static void clearEvents() + { + store::GAStore::executeQuerySync("DELETE FROM ga_events;"); + } + + // parsed event payloads currently in the store, optionally filtered by category + static std::vector storedEvents(std::string const& category = "", std::string const& status = "") + { + std::string sql = "SELECT event FROM ga_events"; + if (!category.empty()) + { + sql += " WHERE category='" + category + "'"; + } + if (!status.empty()) + { + sql += category.empty() ? " WHERE" : " AND"; + sql += " status='" + status + "'"; + } + sql += ";"; + + json rows; + store::GAStore::executeQuerySync(sql, rows); + + std::vector events; + if (rows.is_array()) + { + for (auto& row : rows) + { + events.push_back(json::parse(row["event"].get())); + } + } + return events; + } + + MockHttpClient* mock = nullptr; + }; +} + +// ---- storing events ---- + +TEST_F(GAEventsTest, testDesignEventIsStoredWithAnnotations) +{ + events::GAEvents::addDesignEvent("level:complete", 42.5, true, json(), false); + + auto stored = storedEvents("design"); + ASSERT_EQ(1u, stored.size()); + + const json& ev = stored[0]; + ASSERT_EQ("design", ev["category"].get()); + ASSERT_EQ("level:complete", ev["event_id"].get()); + ASSERT_DOUBLE_EQ(42.5, ev["value"].get()); + + // shared annotations merged in by addEventToStore + ASSERT_EQ(2, ev["v"].get()); + ASSERT_FALSE(ev["user_id"].get().empty()); + ASSERT_FALSE(ev["session_id"].get().empty()); + ASSERT_TRUE(ev.contains("client_ts")); +} + +TEST_F(GAEventsTest, testDesignEventWithoutValueOmitsValue) +{ + events::GAEvents::addDesignEvent("level:skip", 0.0, false, json(), false); + + auto stored = storedEvents("design"); + ASSERT_EQ(1u, stored.size()); + ASSERT_FALSE(stored[0].contains("value")); +} + +TEST_F(GAEventsTest, testInvalidDesignEventIsNotStored) +{ + // event id may have at most 5 segments + events::GAEvents::addDesignEvent("a:b:c:d:e:f", 0.0, false, json(), false); + + ASSERT_TRUE(storedEvents("design").empty()); +} + +TEST_F(GAEventsTest, testErrorEventStoresSeverityAndMessage) +{ + events::GAEvents::addErrorEvent(EGAErrorSeverity::Warning, "something happened", "update", 42, json(), false); + + auto stored = storedEvents("error"); + ASSERT_EQ(1u, stored.size()); + + const json& ev = stored[0]; + ASSERT_EQ("warning", ev["severity"].get()); + ASSERT_EQ("something happened", ev["message"].get()); + ASSERT_EQ("update", ev["function_name"].get()); + ASSERT_EQ(42, ev["line_number"].get()); +} + +TEST_F(GAEventsTest, testBusinessEventIncrementsTransactionNum) +{ + const int64_t before = state::GAState::getTransactionNum(); + + events::GAEvents::addBusinessEvent("USD", 499, "weapon", "sword", "shop", json(), false); + events::GAEvents::addBusinessEvent("USD", 199, "weapon", "shield", "shop", json(), false); + + auto stored = storedEvents("business"); + ASSERT_EQ(2u, stored.size()); + + ASSERT_EQ("weapon:sword", stored[0]["event_id"].get()); + ASSERT_EQ(499, stored[0]["amount"].get()); + ASSERT_EQ("USD", stored[0]["currency"].get()); + ASSERT_EQ(before + 1, stored[0]["transaction_num"].get()); + ASSERT_EQ(before + 2, stored[1]["transaction_num"].get()); +} + +TEST_F(GAEventsTest, testProgressionCompleteCarriesAttemptNum) +{ + const json noFields; + + events::GAEvents::addProgressionEvent(EGAProgressionStatus::Fail, "world1", "level2", "", 0, false, noFields, false); + events::GAEvents::addProgressionEvent(EGAProgressionStatus::Fail, "world1", "level2", "", 0, false, noFields, false); + events::GAEvents::addProgressionEvent(EGAProgressionStatus::Complete, "world1", "level2", "", 100, true, noFields, false); + + auto stored = storedEvents("progression"); + ASSERT_EQ(3u, stored.size()); + + const json& complete = stored[2]; + ASSERT_EQ("Complete:world1:level2", complete["event_id"].get()); + ASSERT_EQ(3, complete["attempt_num"].get()); + ASSERT_EQ(100, complete["score"].get()); + + // completing clears the attempt counter + ASSERT_EQ(0, state::GAState::getProgressionTries("world1:level2")); +} + +// ---- sending events ---- + +TEST_F(GAEventsTest, testProcessEventsSendsBatchAndClearsQueue) +{ + events::GAEvents::addDesignEvent("send:one", 0.0, false, json(), false); + events::GAEvents::addDesignEvent("send:two", 0.0, false, json(), false); + + setResponse(200, R"({"status":"ok"})"); + events::GAEvents::processEvents("design", false); + + ASSERT_EQ(1, mock->requestCount); + ASSERT_NE(std::string::npos, mock->lastUrl.find(kGameKey)); + ASSERT_NE(std::string::npos, mock->lastUrl.find("/events")); + ASSERT_EQ(0u, mock->lastAuth.find("Authorization: ")); + ASSERT_FALSE(mock->lastPayload.empty()); + + // sent events are removed from the store + ASSERT_TRUE(storedEvents("design").empty()); +} + +TEST_F(GAEventsTest, testProcessEventsKeepsEventsWhenNoResponse) +{ + events::GAEvents::addDesignEvent("retry:later", 0.0, false, json(), false); + + setResponse(-1, ""); + events::GAEvents::processEvents("design", false); + + ASSERT_EQ(1, mock->requestCount); + + // events go back to 'new' so the next flush retries them + ASSERT_EQ(1u, storedEvents("design", "new").size()); +} + +TEST_F(GAEventsTest, testProcessEventsDropsEventsOnServerError) +{ + events::GAEvents::addDesignEvent("dropped:event", 0.0, false, json(), false); + + setResponse(500, "Internal Server Error"); + events::GAEvents::processEvents("design", false); + + ASSERT_EQ(1, mock->requestCount); + + // any answer other than no-response counts as processed + ASSERT_TRUE(storedEvents("design").empty()); +} + +TEST_F(GAEventsTest, testProcessEventsWithNoEventsSendsNothing) +{ + events::GAEvents::processEvents("design", false); + + ASSERT_EQ(0, mock->requestCount); +} + +TEST_F(GAEventsTest, testEventsAreNotStoredWhenSubmissionDisabled) +{ + state::GAState::setEnabledEventSubmission(false); + + events::GAEvents::addDesignEvent("blocked:event", 0.0, false, json(), false); + events::GAEvents::addErrorEvent(EGAErrorSeverity::Error, "blocked", "", -1, json(), false); + + state::GAState::setEnabledEventSubmission(true); + ASSERT_TRUE(storedEvents().empty()); +} From 44de4705bd827fb357f0ffa738c3c3c87efccfcb Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Mon, 31 Aug 2026 17:16:20 +0300 Subject: [PATCH 09/28] Bump version to 5.4.1 and update changelog --- CHANGELOG.md | 9 +++++++++ source/gameanalytics/GACommon.h | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ad4678..cad5034 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 5.4.1 + +### Fixed + +- **Remote configs caching** — fixed cached configs being re-downloaded on every launch instead of reused across sessions. +- **Remote configs listeners** — fixed a possible crash when adding or removing a listener during session start. +- **Custom fields** — fixed one invalid field dropping all other custom fields on the event. +- **Resource leaks** — fixed curl handle and header list leaks, and a per-event socket leak on Linux. + ## 5.4.0 ### Added diff --git a/source/gameanalytics/GACommon.h b/source/gameanalytics/GACommon.h index a9ab3c6..2cfa110 100644 --- a/source/gameanalytics/GACommon.h +++ b/source/gameanalytics/GACommon.h @@ -85,7 +85,7 @@ namespace gameanalytics class GAState; } - constexpr const char* GA_VERSION_STR = "cpp 5.4.0"; + constexpr const char* GA_VERSION_STR = "cpp 5.4.1"; constexpr int MAX_CUSTOM_FIELDS_COUNT = 50; constexpr int MAX_CUSTOM_FIELDS_KEY_LENGTH = 64; From b4da5681513f632479cc2288d518afb69a668798 Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Mon, 31 Aug 2026 17:46:25 +0300 Subject: [PATCH 10/28] Add public API integration tests --- test/GAStateTests.cpp | 41 +- test/GameAnalyticsApiTests.cpp | 635 +++++++++++++++++++++++++++++ test/helpers/GAStateTestAccessor.h | 93 +++++ 3 files changed, 729 insertions(+), 40 deletions(-) create mode 100644 test/GameAnalyticsApiTests.cpp create mode 100644 test/helpers/GAStateTestAccessor.h diff --git a/test/GAStateTests.cpp b/test/GAStateTests.cpp index 8672d07..60a1394 100644 --- a/test/GAStateTests.cpp +++ b/test/GAStateTests.cpp @@ -9,46 +9,7 @@ #include #include -namespace gameanalytics -{ - namespace state - { - // friend of GAState (see GAState.h) exposing the private bits needed - // to exercise ensurePersistedStates() in isolation - struct GAStateTestAccessor - { - static void resetConfigState(std::string const& customUserId) - { - GAState& s = GAState::getInstance(); - - s._sdkConfig = json(); - s._sdkConfigCached = json(); - s._configsHash.clear(); - s._defaultUserId.clear(); - - s._customUserId = customUserId; - s.cacheIdentifier(); - } - - static void ensurePersistedStates() - { - GAState::getInstance().ensurePersistedStates(); - } - - static std::string configsHash() - { - return GAState::getInstance()._configsHash; - } - - static json validateAndCleanCustomFields(const json& fields) - { - json out; - GAState::getInstance().validateAndCleanCustomFields(fields, out); - return out; - } - }; - } -} +#include "helpers/GAStateTestAccessor.h" using namespace gameanalytics; diff --git a/test/GameAnalyticsApiTests.cpp b/test/GameAnalyticsApiTests.cpp new file mode 100644 index 0000000..58a7313 --- /dev/null +++ b/test/GameAnalyticsApiTests.cpp @@ -0,0 +1,635 @@ +// +// GA-SDK-CPP +// Integration tests for the public GameAnalytics facade, driving the real GA +// thread and asserting on the observable outcome (state, event store, mock http) +// + +#include +#include + +#include "GameAnalytics/GameAnalytics.h" +#include "GameAnalytics/GAHttpClient.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "helpers/GAStateTestAccessor.h" + +#include +#include +#include +#include +#include +#include + +using namespace gameanalytics; + +namespace +{ + constexpr const char* kGameKey = "bd624ee6f8e6efb32a054f8d7ba11618"; + constexpr const char* kGameSecret = "7f5c3f682cbd217841efba92e92ffb1b3b6612bc"; + + // shared handle so the client installed through the public + // configureHttpClient() template stays inspectable from the test + struct MockHttpState + { + std::mutex mutex; + int requestCount = 0; + std::string lastUrl; + GAHttpClient::Response response; + }; + + class MockHttpClient : public GAHttpClient + { + public: + explicit MockHttpClient(std::shared_ptr state) : _state(std::move(state)) {} + + void initialize() override {} + void cleanup() override {} + + Response sendRequest( + std::string const& url, + std::string const&, + std::vector const&, + bool, + void*) override + { + std::lock_guard lock(_state->mutex); + _state->requestCount++; + _state->lastUrl = url; + return _state->response; + } + + private: + std::shared_ptr _state; + }; + + class RecordingConfigsListener : public IRemoteConfigsListener + { + public: + void onRemoteConfigsUpdated(std::string const& remoteConfigs) override + { + std::lock_guard lock(_mutex); + _updates.push_back(remoteConfigs); + } + + std::vector updates() + { + std::lock_guard lock(_mutex); + return _updates; + } + + private: + std::mutex _mutex; + std::vector _updates; + }; + + class GameAnalyticsApiTest : public ::testing::Test + { + protected: + void SetUp() override + { + events::GAEvents::stopEventQueue(); + state::GAStateTestAccessor::forceUninitialized(); + state::GAState::setEnabledEventSubmission(true); + + ASSERT_TRUE(store::GAStore::ensureDatabase(false, kGameKey)); + clearStoredEvents(); + + http = std::make_shared(); + setHttpResponse(200, json{{"server_ts", utilities::getTimestamp()}}.dump()); + GameAnalytics::configureHttpClient(http); + } + + void TearDown() override + { + drainGAThread(); + events::GAEvents::stopEventQueue(); + state::GAState::setEnabledEventSubmission(false); + http::GAHTTPApi::setCustomHttpImpl(nullptr); + state::GAStateTestAccessor::forceUninitialized(); + clearStoredEvents(); + } + + // barrier: the GA thread runs queued blocks in FIFO order, so once this + // marker task has run every previously queued task has run too + [[nodiscard]] static bool drainGAThread() + { + std::promise done; + std::future drained = done.get_future(); + threading::GAThreading::performTaskOnGAThread([&done]() { done.set_value(); }); + return drained.wait_for(std::chrono::seconds(10)) == std::future_status::ready; + } + + void setHttpResponse(long code, std::string const& body) + { + std::lock_guard lock(http->mutex); + http->response.code = code; + http->response.packet.assign(body.begin(), body.end()); + } + + int requestCount() + { + std::lock_guard lock(http->mutex); + return http->requestCount; + } + + std::string lastRequestUrl() + { + std::lock_guard lock(http->mutex); + return http->lastUrl; + } + + void configureDefaults() + { + GameAnalytics::configureBuild("1.2.3"); + GameAnalytics::configureAvailableCustomDimensions01({"ninja", "samurai"}); + GameAnalytics::configureAvailableCustomDimensions02({"guild_a", "guild_b"}); + GameAnalytics::configureAvailableCustomDimensions03({"tier1", "tier2"}); + GameAnalytics::configureAvailableResourceCurrencies({"gems", "gold"}); + GameAnalytics::configureAvailableResourceItemTypes({"boost", "weapon"}); + } + + void initializeSdk() + { + configureDefaults(); + GameAnalytics::initialize(kGameKey, kGameSecret); + ASSERT_TRUE(drainGAThread()); + ASSERT_TRUE(state::GAState::isInitialized()); + ASSERT_TRUE(state::GAState::sessionIsStarted()); + events::GAEvents::stopEventQueue(); + } + + static void clearStoredEvents() + { + store::GAStore::executeQuerySync("DELETE FROM ga_events;"); + } + + static std::vector storedEvents(std::string const& category = "") + { + std::string sql = "SELECT event FROM ga_events"; + if (!category.empty()) + { + sql += " WHERE category='" + category + "'"; + } + sql += ";"; + + json rows; + store::GAStore::executeQuerySync(sql, rows); + + std::vector events; + if (rows.is_array()) + { + for (auto& row : rows) + { + events.push_back(json::parse(row["event"].get())); + } + } + return events; + } + + std::shared_ptr http; + }; +} + +// ---- configuration before initialize ---- + +TEST_F(GameAnalyticsApiTest, PreInitConfigurationIsApplied) +{ + configureDefaults(); + GameAnalytics::configureWritablePath(device::GADevice::getWritablePath()); + GameAnalytics::configureBuildPlatform("windows"); + GameAnalytics::configureDeviceModel("TestDeviceModel"); + GameAnalytics::configureDeviceManufacturer("TestManufacturer"); + GameAnalytics::configureGameEngineVersion("unity 2021.3"); + GameAnalytics::configureSdkGameEngineVersion("unity 6.1.0"); + GameAnalytics::configureUserId("custom_user"); + GameAnalytics::configureExternalUserId("ext-42"); + ASSERT_TRUE(drainGAThread()); + + EXPECT_EQ("1.2.3", state::GAStateTestAccessor::build()); + EXPECT_TRUE(state::GAState::hasAvailableCustomDimensions01("ninja")); + EXPECT_FALSE(state::GAState::hasAvailableCustomDimensions01("pirate")); + EXPECT_TRUE(state::GAState::hasAvailableCustomDimensions02("guild_b")); + EXPECT_TRUE(state::GAState::hasAvailableCustomDimensions03("tier2")); + EXPECT_TRUE(state::GAState::hasAvailableResourceCurrency("gems")); + EXPECT_FALSE(state::GAState::hasAvailableResourceCurrency("diamonds")); + EXPECT_TRUE(state::GAState::hasAvailableResourceItemType("boost")); + + EXPECT_TRUE(device::GADevice::getWritablePathStatus()); + EXPECT_EQ("windows", device::GADevice::getBuildPlatform()); + EXPECT_EQ("TestDeviceModel", device::GADevice::getDeviceModel()); + EXPECT_EQ("TestManufacturer", device::GADevice::getDeviceManufacturer()); + EXPECT_EQ("unity 2021.3", device::GADevice::getGameEngineVersion()); + EXPECT_EQ("unity 6.1.0", device::GADevice::getRelevantSdkVersion()); + + EXPECT_EQ("custom_user", GameAnalytics::getUserId()); + EXPECT_EQ("ext-42", GameAnalytics::getExternalUserId()); +} + +TEST_F(GameAnalyticsApiTest, PreInitConfigurationRejectsInvalidValues) +{ + GameAnalytics::configureBuild("1.0.0"); + ASSERT_TRUE(drainGAThread()); + ASSERT_EQ("1.0.0", state::GAStateTestAccessor::build()); + + const std::string userIdBefore = GameAnalytics::getUserId(); + const std::string engineBefore = device::GADevice::getGameEngineVersion(); + const std::string sdkVersionBefore = device::GADevice::getRelevantSdkVersion(); + const std::string platformBefore = device::GADevice::getBuildPlatform(); + + GameAnalytics::configureBuild(std::string(33, 'b')); + GameAnalytics::configureUserId(""); + GameAnalytics::configureGameEngineVersion("notanengine 1.0"); + GameAnalytics::configureSdkGameEngineVersion("bogus"); + GameAnalytics::configureBuildPlatform(std::string(33, 'p')); + ASSERT_TRUE(drainGAThread()); + + EXPECT_EQ("1.0.0", state::GAStateTestAccessor::build()); + EXPECT_EQ(userIdBefore, GameAnalytics::getUserId()); + EXPECT_EQ(engineBefore, device::GADevice::getGameEngineVersion()); + EXPECT_EQ(sdkVersionBefore, device::GADevice::getRelevantSdkVersion()); + EXPECT_EQ(platformBefore, device::GADevice::getBuildPlatform()); +} + +// ---- initialize ---- + +TEST_F(GameAnalyticsApiTest, InitializeWithInvalidKeysIsRejected) +{ + GameAnalytics::initialize("invalid", "keys"); + ASSERT_TRUE(drainGAThread()); + + EXPECT_FALSE(state::GAState::isInitialized()); + EXPECT_EQ(0, requestCount()); +} + +TEST_F(GameAnalyticsApiTest, InitializeStartsSessionAndRequestsInit) +{ + initializeSdk(); + + EXPECT_GE(requestCount(), 1); + EXPECT_NE(std::string::npos, lastRequestUrl().find(kGameKey)); + + const std::string sessionId = state::GAState::getSessionId(); + EXPECT_EQ(36u, sessionId.size()); + EXPECT_EQ(utilities::toLowerCase(sessionId), sessionId); + + EXPECT_FALSE(GameAnalytics::getUserId().empty()); + EXPECT_FALSE(GameAnalytics::isThreadEnding()); + + EXPECT_GE(GameAnalytics::getElapsedSessionTime(), 0); + EXPECT_GE(GameAnalytics::getElapsedTimeFromAllSessions(), 0); + + // the session start event is dispatched immediately: init request first, + // then the events request that carries it + EXPECT_GE(requestCount(), 2); + EXPECT_NE(std::string::npos, lastRequestUrl().find("/events")); +} + +TEST_F(GameAnalyticsApiTest, InitializeTwiceKeepsFirstSession) +{ + initializeSdk(); + const std::string firstSessionId = state::GAState::getSessionId(); + + GameAnalytics::initialize(kGameKey, kGameSecret); + ASSERT_TRUE(drainGAThread()); + + EXPECT_TRUE(state::GAState::isInitialized()); + EXPECT_EQ(firstSessionId, state::GAState::getSessionId()); +} + +TEST_F(GameAnalyticsApiTest, ConfigureAfterInitializeIsIgnored) +{ + initializeSdk(); + const std::string userIdBefore = GameAnalytics::getUserId(); + + GameAnalytics::configureBuild("9.9.9"); + GameAnalytics::configureAvailableCustomDimensions01({"pirate"}); + GameAnalytics::configureUserId("late_user"); + ASSERT_TRUE(drainGAThread()); + + EXPECT_EQ("1.2.3", state::GAStateTestAccessor::build()); + EXPECT_FALSE(state::GAState::hasAvailableCustomDimensions01("pirate")); + EXPECT_EQ(userIdBefore, GameAnalytics::getUserId()); +} + +// ---- adding events ---- + +TEST_F(GameAnalyticsApiTest, AddDesignEventIsStoredWithValueAndFields) +{ + initializeSdk(); + + GameAnalytics::addDesignEvent("level:complete", 42.5, R"({"difficulty":"hard"})"); + ASSERT_TRUE(drainGAThread()); + + auto stored = storedEvents("design"); + ASSERT_EQ(1u, stored.size()); + EXPECT_EQ("level:complete", stored[0]["event_id"].get()); + EXPECT_DOUBLE_EQ(42.5, stored[0]["value"].get()); + EXPECT_EQ("hard", stored[0]["custom_fields"]["difficulty"].get()); +} + +TEST_F(GameAnalyticsApiTest, AddDesignEventWithMalformedFieldsIsStoredWithoutFields) +{ + initializeSdk(); + + GameAnalytics::addDesignEvent("level:skip", "{not valid json"); + ASSERT_TRUE(drainGAThread()); + + auto stored = storedEvents("design"); + ASSERT_EQ(1u, stored.size()); + EXPECT_FALSE(stored[0].contains("custom_fields")); +} + +TEST_F(GameAnalyticsApiTest, AddBusinessEventIsStored) +{ + initializeSdk(); + + GameAnalytics::addBusinessEvent("USD", 499, "weapon", "sword", "shop"); + ASSERT_TRUE(drainGAThread()); + + auto stored = storedEvents("business"); + ASSERT_EQ(1u, stored.size()); + EXPECT_EQ("weapon:sword", stored[0]["event_id"].get()); + EXPECT_EQ("USD", stored[0]["currency"].get()); + EXPECT_EQ(499, stored[0]["amount"].get()); + EXPECT_EQ("shop", stored[0]["cart_type"].get()); +} + +TEST_F(GameAnalyticsApiTest, AddResourceEventValidatesConfiguredCurrenciesAndItemTypes) +{ + initializeSdk(); + + GameAnalytics::addResourceEvent(EGAResourceFlowType::Source, "gems", 100.0f, "boost", "starter"); + GameAnalytics::addResourceEvent(EGAResourceFlowType::Sink, "gems", 25.0f, "boost", "starter"); + GameAnalytics::addResourceEvent(EGAResourceFlowType::Source, "diamonds", 10.0f, "boost", "starter"); + GameAnalytics::addResourceEvent(EGAResourceFlowType::Source, "gems", 10.0f, "hat", "starter"); + ASSERT_TRUE(drainGAThread()); + + auto stored = storedEvents("resource"); + ASSERT_EQ(2u, stored.size()); + EXPECT_EQ("Source:gems:boost:starter", stored[0]["event_id"].get()); + EXPECT_DOUBLE_EQ(100.0, stored[0]["amount"].get()); + EXPECT_EQ("Sink:gems:boost:starter", stored[1]["event_id"].get()); + EXPECT_DOUBLE_EQ(-25.0, stored[1]["amount"].get()); +} + +TEST_F(GameAnalyticsApiTest, AddProgressionEventTracksAttempts) +{ + initializeSdk(); + + GameAnalytics::addProgressionEvent(EGAProgressionStatus::Fail, "world1", "level2"); + GameAnalytics::addProgressionEvent(EGAProgressionStatus::Fail, "world1", "level2"); + GameAnalytics::addProgressionEvent(EGAProgressionStatus::Complete, 100, "world1", "level2"); + ASSERT_TRUE(drainGAThread()); + + auto stored = storedEvents("progression"); + ASSERT_EQ(3u, stored.size()); + + const json& complete = stored[2]; + EXPECT_EQ("Complete:world1:level2", complete["event_id"].get()); + EXPECT_EQ(100, complete["score"].get()); + EXPECT_EQ(3, complete["attempt_num"].get()); + EXPECT_EQ(0, state::GAState::getProgressionTries("world1:level2")); +} + +TEST_F(GameAnalyticsApiTest, AddErrorEventStoresSeverityAndTrimsMessage) +{ + initializeSdk(); + + const std::string longMessage(9000, 'x'); + GameAnalytics::addErrorEvent(EGAErrorSeverity::Critical, longMessage); + ASSERT_TRUE(drainGAThread()); + + auto stored = storedEvents("error"); + ASSERT_EQ(1u, stored.size()); + EXPECT_EQ("critical", stored[0]["severity"].get()); + EXPECT_EQ(8182u, stored[0]["message"].get().size()); +} + +TEST_F(GameAnalyticsApiTest, EventsBeforeInitializeAreNotStored) +{ + GameAnalytics::addDesignEvent("too:early"); + GameAnalytics::addBusinessEvent("USD", 100, "weapon", "sword", "shop"); + GameAnalytics::addErrorEvent(EGAErrorSeverity::Info, "too early"); + ASSERT_TRUE(drainGAThread()); + + EXPECT_TRUE(storedEvents().empty()); +} + +TEST_F(GameAnalyticsApiTest, OversizedCustomFieldsRejectTheEvent) +{ + initializeSdk(); + + const std::string oversizedFields = R"({"k":")" + std::string(5000, 'v') + R"("})"; + GameAnalytics::addDesignEvent("a:b", oversizedFields); + GameAnalytics::addProgressionEvent(EGAProgressionStatus::Start, "world1", "", "", oversizedFields); + GameAnalytics::addErrorEvent(EGAErrorSeverity::Info, "message", oversizedFields); + ASSERT_TRUE(drainGAThread()); + + EXPECT_TRUE(storedEvents("design").empty()); + EXPECT_TRUE(storedEvents("progression").empty()); + EXPECT_TRUE(storedEvents("error").empty()); +} + +// ---- state changes while running ---- + +TEST_F(GameAnalyticsApiTest, SetCustomDimensionsAreValidatedAgainstAvailable) +{ + initializeSdk(); + + GameAnalytics::setCustomDimension01("ninja"); + GameAnalytics::setCustomDimension02("guild_a"); + GameAnalytics::setCustomDimension03("tier1"); + ASSERT_TRUE(drainGAThread()); + + EXPECT_EQ("ninja", state::GAState::getCurrentCustomDimension01()); + EXPECT_EQ("guild_a", state::GAState::getCurrentCustomDimension02()); + EXPECT_EQ("tier1", state::GAState::getCurrentCustomDimension03()); + + GameAnalytics::setCustomDimension01("pirate"); + ASSERT_TRUE(drainGAThread()); + EXPECT_EQ("ninja", state::GAState::getCurrentCustomDimension01()); + + GameAnalytics::setCustomDimension01(""); + ASSERT_TRUE(drainGAThread()); + EXPECT_EQ("", state::GAState::getCurrentCustomDimension01()); +} + +TEST_F(GameAnalyticsApiTest, GlobalCustomEventFieldsAreMergedIntoEvents) +{ + initializeSdk(); + + GameAnalytics::setGlobalCustomEventFields(R"({"team":"red","run":7})"); + ASSERT_TRUE(drainGAThread()); + + GameAnalytics::addDesignEvent("uses:globals"); + GameAnalytics::addDesignEvent("overrides:globals", R"({"team":"blue"})"); + ASSERT_TRUE(drainGAThread()); + + auto stored = storedEvents("design"); + ASSERT_EQ(2u, stored.size()); + EXPECT_EQ("red", stored[0]["custom_fields"]["team"].get()); + EXPECT_EQ(7, stored[0]["custom_fields"]["run"].get()); + EXPECT_EQ("blue", stored[1]["custom_fields"]["team"].get()); +} + +TEST_F(GameAnalyticsApiTest, EventSubmissionToggleThroughFacade) +{ + initializeSdk(); + + GameAnalytics::setEnabledEventSubmission(false); + ASSERT_TRUE(drainGAThread()); + EXPECT_FALSE(state::GAState::isEventSubmissionEnabled()); + + GameAnalytics::addDesignEvent("blocked:event"); + ASSERT_TRUE(drainGAThread()); + EXPECT_TRUE(storedEvents("design").empty()); + + GameAnalytics::setEnabledEventSubmission(true); + ASSERT_TRUE(drainGAThread()); + EXPECT_TRUE(state::GAState::isEventSubmissionEnabled()); + + GameAnalytics::addDesignEvent("allowed:event"); + ASSERT_TRUE(drainGAThread()); + EXPECT_EQ(1u, storedEvents("design").size()); +} + +TEST_F(GameAnalyticsApiTest, LoggingAndErrorReportingToggles) +{ + GameAnalytics::setEnabledInfoLog(true); + GameAnalytics::setEnabledVerboseLog(true); + GameAnalytics::setEnabledErrorReporting(false); + ASSERT_TRUE(drainGAThread()); + EXPECT_FALSE(state::GAState::useErrorReporting()); + + GameAnalytics::setEnabledInfoLog(false); + GameAnalytics::setEnabledVerboseLog(false); + GameAnalytics::setEnabledErrorReporting(true); + ASSERT_TRUE(drainGAThread()); + EXPECT_TRUE(state::GAState::useErrorReporting()); +} + +// ---- session lifecycle ---- + +TEST_F(GameAnalyticsApiTest, AutomaticSessionHandlingOnSuspendAndResume) +{ + initializeSdk(); + const std::string firstSessionId = state::GAState::getSessionId(); + + // fail the http dispatch so the session end event stays queued in the store + setHttpResponse(-1, ""); + + GameAnalytics::onSuspend(); + ASSERT_TRUE(drainGAThread()); + EXPECT_FALSE(state::GAState::sessionIsStarted()); + EXPECT_EQ(1u, storedEvents("session_end").size()); + EXPECT_GE(GameAnalytics::getElapsedTimeForPreviousSession(), 0); + + GameAnalytics::onResume(); + ASSERT_TRUE(drainGAThread()); + EXPECT_TRUE(state::GAState::sessionIsStarted()); + EXPECT_NE(firstSessionId, state::GAState::getSessionId()); + + // resuming an already running session must not start another one + const std::string currentSessionId = state::GAState::getSessionId(); + GameAnalytics::onResume(); + ASSERT_TRUE(drainGAThread()); + EXPECT_EQ(currentSessionId, state::GAState::getSessionId()); +} + +TEST_F(GameAnalyticsApiTest, ManualSessionHandlingControlsSessionExplicitly) +{ + initializeSdk(); + GameAnalytics::setEnabledManualSessionHandling(true); + ASSERT_TRUE(drainGAThread()); + ASSERT_TRUE(state::GAState::useManualSessionHandling()); + + const std::string firstSessionId = state::GAState::getSessionId(); + + // fail the http dispatch so the session end event stays queued in the store + setHttpResponse(-1, ""); + + GameAnalytics::endSession(); + ASSERT_TRUE(drainGAThread()); + EXPECT_FALSE(state::GAState::sessionIsStarted()); + EXPECT_EQ(1u, storedEvents("session_end").size()); + + GameAnalytics::startSession(); + ASSERT_TRUE(drainGAThread()); + EXPECT_TRUE(state::GAState::sessionIsStarted()); + EXPECT_NE(firstSessionId, state::GAState::getSessionId()); +} + +// ---- remote configs ---- + +TEST_F(GameAnalyticsApiTest, RemoteConfigsFromInitReachListenerAndGetters) +{ + auto listener = std::make_shared(); + GameAnalytics::addRemoteConfigsListener(listener); + + const json initResponse = { + {"server_ts", utilities::getTimestamp()}, + {"configs", json::array({ + {{"key", "speed"}, {"value", "fast"}, {"id", "cfg1"}, {"vsn", 1}} + })}, + {"configs_hash", "hash-1"}, + {"ab_id", "ab1"}, + {"ab_variant_id", "var1"} + }; + setHttpResponse(201, initResponse.dump()); + initializeSdk(); + + EXPECT_TRUE(GameAnalytics::isRemoteConfigsReady()); + EXPECT_EQ("fast", GameAnalytics::getRemoteConfigsValueAsString("speed")); + EXPECT_EQ("slow", GameAnalytics::getRemoteConfigsValueAsString("missing", "slow")); + EXPECT_NE(std::string::npos, GameAnalytics::getRemoteConfigsContentAsString().find("speed")); + EXPECT_EQ("ab1", GameAnalytics::getABTestingId()); + EXPECT_EQ("var1", GameAnalytics::getABTestingVariantId()); + EXPECT_EQ("hash-1", state::GAStateTestAccessor::configsHash()); + + auto updates = listener->updates(); + ASSERT_EQ(1u, updates.size()); + EXPECT_NE(std::string::npos, updates[0].find("speed")); + + // a removed listener is not notified by the next config refresh + GameAnalytics::removeRemoteConfigsListener(listener); + GameAnalytics::onSuspend(); + GameAnalytics::onResume(); + ASSERT_TRUE(drainGAThread()); + EXPECT_EQ(1u, listener->updates().size()); +} + +TEST_F(GameAnalyticsApiTest, RemoteConfigsFallBackToDefaultsWhenAbsent) +{ + initializeSdk(); + + EXPECT_TRUE(GameAnalytics::isRemoteConfigsReady()); + EXPECT_EQ("fallback", GameAnalytics::getRemoteConfigsValueAsString("absent", "fallback")); + EXPECT_EQ("", GameAnalytics::getRemoteConfigsValueAsJson("absent")); +} + +// ---- health tracking facade ---- + +TEST_F(GameAnalyticsApiTest, HealthTrackingtogglesAreForwardedToTracker) +{ + initializeSdk(); + + GAHealth* tracker = device::GADevice::getHealthTracker(); + ASSERT_NE(nullptr, tracker); + + GameAnalytics::enableSDKInitEvent(true); + GameAnalytics::enableMemoryHistogram(true); + GameAnalytics::enableFPSHistogram([]() { return 60.0f; }, true); + GameAnalytics::enableHardwareTracking(true); + + EXPECT_TRUE(tracker->enableAppBootTimeTracking); + EXPECT_TRUE(tracker->enableMemoryTracking); + EXPECT_TRUE(tracker->enableFPSTracking); + EXPECT_TRUE(tracker->enableHardwareTracking); +} diff --git a/test/helpers/GAStateTestAccessor.h b/test/helpers/GAStateTestAccessor.h new file mode 100644 index 0000000..4951946 --- /dev/null +++ b/test/helpers/GAStateTestAccessor.h @@ -0,0 +1,93 @@ +#pragma once + +#include + +namespace gameanalytics +{ + namespace state + { + // friend of GAState (see GAState.h) exposing the private bits needed + // to exercise state transitions deterministically from tests + struct GAStateTestAccessor + { + static void resetConfigState(std::string const& customUserId) + { + GAState& s = GAState::getInstance(); + + s._sdkConfig = json(); + s._sdkConfigCached = json(); + s._configsHash.clear(); + s._defaultUserId.clear(); + + s._customUserId = customUserId; + s.cacheIdentifier(); + } + + static void ensurePersistedStates() + { + GAState::getInstance().ensurePersistedStates(); + } + + static std::string configsHash() + { + return GAState::getInstance()._configsHash; + } + + static std::string build() + { + return GAState::getInstance()._build; + } + + static json validateAndCleanCustomFields(const json& fields) + { + json out; + GAState::getInstance().validateAndCleanCustomFields(fields, out); + return out; + } + + // returns the SDK to its pre-initialize() state so every test can + // drive the public API from a known starting point + static void forceUninitialized() + { + GAState& s = GAState::getInstance(); + std::lock_guard lg(s._mtx); + + s._initialized = false; + s._initAuthorized = false; + s._enabled = false; + + s._sessionStart = 0; + s._sessionId.clear(); + + s._build.clear(); + s._customUserId.clear(); + s._externalUserId.clear(); + s._identifier.clear(); + + s._configsHash.clear(); + s._abId.clear(); + s._abVariantId.clear(); + s._sdkConfig = json(); + s._sdkConfigCached = json(); + + s._gameRemoteConfigsJson = json::array(); + s._trackingRemoteConfigsJson = json::array(); + s._remoteConfigsIsReady = false; + s._remoteConfigsListeners.clear(); + + s._currentCustomDimension01.clear(); + s._currentCustomDimension02.clear(); + s._currentCustomDimension03.clear(); + s._currentGlobalCustomEventFields = json(); + + s._availableCustomDimensions01.clear(); + s._availableCustomDimensions02.clear(); + s._availableCustomDimensions03.clear(); + s._availableResourceCurrencies.clear(); + s._availableResourceItemTypes.clear(); + + s._useManualSessionHandling = false; + } + }; + } +} From cf7575a666db56493e2852a55694eb5cce54eb8a Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Mon, 31 Aug 2026 18:00:55 +0300 Subject: [PATCH 11/28] Fix local coverage report generation with lcov 2.x --- CMakeLists.txt | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index cdaf2a0..de9b455 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -345,15 +345,33 @@ if (ENABLE_COVERAGE AND NOT GA_SHARED_LIB) COMMAND GameAnalyticsUnitTests # Capturing lcov counters and generating report - COMMAND ${LCOV_PATH} --directory . --capture --output-file ${covname}.info --rc lcov_branch_coverage=1 --rc derive_function_end_line=0 + # System headers are excluded at capture time; format/inconsistent are + # clang gcov artifacts downgraded to warnings; the baseline capture keeps + # instrumented files the tests never executed in the report + COMMAND ${LCOV_PATH} --directory . --capture --initial --output-file ${covname}.base.info + --branch-coverage --rc derive_function_end_line=0 + --exclude '/Applications/Xcode.app/*' + --exclude '/usr/*' + --ignore-errors format,inconsistent,unused + COMMAND ${LCOV_PATH} --directory . --capture --output-file ${covname}.run.info + --branch-coverage --rc derive_function_end_line=0 + --exclude '/Applications/Xcode.app/*' + --exclude '/usr/*' + --ignore-errors format,inconsistent,unused + COMMAND ${LCOV_PATH} --add-tracefile ${covname}.base.info + --add-tracefile ${covname}.run.info + --output-file ${covname}.info + --branch-coverage + --ignore-errors inconsistent,format COMMAND ${LCOV_PATH} --remove ${covname}.info '${CMAKE_SOURCE_DIR}/source/dependencies/*' - '/usr/*' + '${CMAKE_SOURCE_DIR}/test/*' --output-file ${covname}.info.cleaned - --rc lcov_branch_coverage=1 + --branch-coverage --rc derive_function_end_line=0 - COMMAND ${GENHTML_PATH} -o ${covname} ${covname}.info.cleaned --rc lcov_branch_coverage=1 --rc derive_function_end_line=0 - COMMAND ${CMAKE_COMMAND} -E remove ${covname}.info ${covname}.info.cleaned + --ignore-errors unused,inconsistent,format + COMMAND ${GENHTML_PATH} -o ${covname} ${covname}.info.cleaned --branch-coverage --rc derive_function_end_line=0 --ignore-errors inconsistent,category + COMMAND ${CMAKE_COMMAND} -E remove ${covname}.base.info ${covname}.run.info ${covname}.info ${covname}.info.cleaned COMMENT "Resetting code coverage counters to zero.\nProcessing code coverage counters and generating report." ) From e36ba97d540e2b8325fe457b64b42496b18929df Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Mon, 31 Aug 2026 18:23:57 +0300 Subject: [PATCH 12/28] Update coverage report action to v7.2.0 --- .github/workflows/coverage.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index c1f122b..307c72c 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -6,6 +6,8 @@ jobs: build: name: Report Test Coverage runs-on: ubuntu-latest + permissions: + pull-requests: write steps: - uses: actions/checkout@v6 @@ -52,7 +54,7 @@ jobs: run: cmake --build . --target cov_data - name: Report code coverage - uses: zgosalvez/github-actions-report-lcov@v4 + uses: zgosalvez/github-actions-report-lcov@v7.2.0 with: coverage-files: build/cov.info.cleaned minimum-coverage: 30 From 510a0734cb633f5a2c4aee86251e5b705f6c4f75 Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Mon, 31 Aug 2026 18:33:26 +0300 Subject: [PATCH 13/28] Cache vcpkg binary archives in CI --- .github/workflows/cmake.yml | 8 ++++++++ .github/workflows/coverage.yml | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/.github/workflows/cmake.yml b/.github/workflows/cmake.yml index 21e6345..7eaf1fd 100644 --- a/.github/workflows/cmake.yml +++ b/.github/workflows/cmake.yml @@ -105,6 +105,14 @@ jobs: echo "check lld..." ldd --version + - name: Cache vcpkg binaries + if: matrix.dependency_mode != 'no_deps' + uses: actions/cache@v4 + with: + path: ${{ runner.os == 'Windows' && '~/AppData/Local/vcpkg/archives' || '~/.cache/vcpkg/archives' }} + key: vcpkg-${{ runner.os }}-${{ hashFiles('vcpkg.json', 'vcpkg-configuration.json') }} + restore-keys: vcpkg-${{ runner.os }}- + - name: Install vcpkg (Linux/macOS) if: runner.os != 'Windows' && matrix.dependency_mode != 'no_deps' shell: bash diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 307c72c..3f86d5f 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -14,6 +14,13 @@ jobs: with: submodules: true + - name: Cache vcpkg binaries + uses: actions/cache@v4 + with: + path: ~/.cache/vcpkg/archives + key: vcpkg-${{ runner.os }}-${{ hashFiles('vcpkg.json', 'vcpkg-configuration.json') }} + restore-keys: vcpkg-${{ runner.os }}- + - name: Install vcpkg (Linux/macOS) if: runner.os != 'Windows' shell: bash From ead2c1fe45e93e6100eb523996ff84ba8128d8d2 Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Mon, 31 Aug 2026 18:42:34 +0300 Subject: [PATCH 14/28] Preserve branch data in CI coverage and fix changed-files matching --- .github/workflows/coverage.yml | 5 +++++ CMakeLists.txt | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 3f86d5f..8c06f1a 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -60,6 +60,11 @@ jobs: shell: bash run: cmake --build . --target cov_data + - name: Make coverage paths repo-relative + working-directory: ${{github.workspace}}/build + shell: bash + run: sed -i "s|^SF:${{ github.workspace }}/|SF:|" cov.info.cleaned + - name: Report code coverage uses: zgosalvez/github-actions-report-lcov@v7.2.0 with: diff --git a/CMakeLists.txt b/CMakeLists.txt index de9b455..ceeaf56 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -330,7 +330,8 @@ if (ENABLE_COVERAGE AND NOT GA_SHARED_LIB) '/usr/*' '/Applications/Xcode.app/*' --output-file ${covname}.info.cleaned - --ignore-errors unused + --branch-coverage + --ignore-errors unused,inconsistent COMMAND echo "Finished processing code coverage counters and generating report." ) From 7db3e4bf027c998cc7e3ca83cec25831183c56a6 Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Mon, 31 Aug 2026 18:50:58 +0300 Subject: [PATCH 15/28] Fix session end test isolation from stale ga_session rows --- test/GameAnalyticsApiTests.cpp | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/test/GameAnalyticsApiTests.cpp b/test/GameAnalyticsApiTests.cpp index 58a7313..49e4459 100644 --- a/test/GameAnalyticsApiTests.cpp +++ b/test/GameAnalyticsApiTests.cpp @@ -165,9 +165,26 @@ namespace events::GAEvents::stopEventQueue(); } + // ga_session rows survive until a session_end is successfully sent, and + // fixMissingSessionEndEvents synthesizes session_end events for stale rows, + // so both tables must be cleared for tests to stay independent static void clearStoredEvents() { store::GAStore::executeQuerySync("DELETE FROM ga_events;"); + store::GAStore::executeQuerySync("DELETE FROM ga_session;"); + } + + static size_t storedSessionEndCount(std::string const& sessionId) + { + size_t count = 0; + for (const json& ev : storedEvents("session_end")) + { + if (ev.value("session_id", "") == sessionId) + { + ++count; + } + } + return count; } static std::vector storedEvents(std::string const& category = "") @@ -528,7 +545,7 @@ TEST_F(GameAnalyticsApiTest, AutomaticSessionHandlingOnSuspendAndResume) GameAnalytics::onSuspend(); ASSERT_TRUE(drainGAThread()); EXPECT_FALSE(state::GAState::sessionIsStarted()); - EXPECT_EQ(1u, storedEvents("session_end").size()); + EXPECT_EQ(1u, storedSessionEndCount(firstSessionId)); EXPECT_GE(GameAnalytics::getElapsedTimeForPreviousSession(), 0); GameAnalytics::onResume(); @@ -558,7 +575,7 @@ TEST_F(GameAnalyticsApiTest, ManualSessionHandlingControlsSessionExplicitly) GameAnalytics::endSession(); ASSERT_TRUE(drainGAThread()); EXPECT_FALSE(state::GAState::sessionIsStarted()); - EXPECT_EQ(1u, storedEvents("session_end").size()); + EXPECT_EQ(1u, storedSessionEndCount(firstSessionId)); GameAnalytics::startSession(); ASSERT_TRUE(drainGAThread()); From 0d067a884f344c3fec4b469da8e214c4b2ea9d03 Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Mon, 31 Aug 2026 19:00:18 +0300 Subject: [PATCH 16/28] Pin lcov 2.3.2 in CI and check GA thread drain in teardown --- .github/workflows/coverage.yml | 2 ++ test/GameAnalyticsApiTests.cpp | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 8c06f1a..72f10f8 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -44,6 +44,8 @@ jobs: - name: Setup LCOV uses: hrishikesh-kadam/setup-lcov@v1 + with: + ref: v2.3.2 - name: Configure CMake shell: bash diff --git a/test/GameAnalyticsApiTests.cpp b/test/GameAnalyticsApiTests.cpp index 49e4459..df83f90 100644 --- a/test/GameAnalyticsApiTests.cpp +++ b/test/GameAnalyticsApiTests.cpp @@ -108,7 +108,7 @@ namespace void TearDown() override { - drainGAThread(); + EXPECT_TRUE(drainGAThread()); events::GAEvents::stopEventQueue(); state::GAState::setEnabledEventSubmission(false); http::GAHTTPApi::setCustomHttpImpl(nullptr); From 6fd4d3bbea653f4274e129e70ec657573332e44f Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Mon, 31 Aug 2026 19:18:00 +0300 Subject: [PATCH 17/28] Update CMakeLists.txt --- CMakeLists.txt | 103 ++++++++++++------------------------------------- 1 file changed, 24 insertions(+), 79 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index ceeaf56..6705148 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -279,23 +279,10 @@ endif() # Coverage requires tests, which are only available for static library builds if (ENABLE_COVERAGE AND NOT GA_SHARED_LIB) - find_program(GCOV_PATH gcov) - if (NOT GCOV_PATH) - message(WARNING "program gcov not found") - endif() - find_program(LCOV_PATH lcov) - if (NOT LCOV_PATH) - message(WARNING "program lcov not found") - endif() - find_program(GENHTML_PATH genhtml) - if (NOT GENHTML_PATH) - message(WARNING "program genhtml not found") - endif() - - if (LCOV_PATH AND GCOV_PATH) + if (LCOV_PATH) target_compile_options( GameAnalytics PRIVATE @@ -306,81 +293,39 @@ if (ENABLE_COVERAGE AND NOT GA_SHARED_LIB) GameAnalytics PRIVATE -fprofile-arcs -ftest-coverage ) - set(covname cov) + set(LCOV_COMMON_ARGS + --branch-coverage + --rc geninfo_unexecuted_blocks=1 + --rc derive_function_end_line=0 + --ignore-errors format,inconsistent,unused,mismatch,empty + ) add_custom_target(cov_data - # Cleanup lcov - COMMENT "Resetting code coverage counters to zero." - ${LCOV_PATH} --directory . --zerocounters - - # Run tests + COMMAND ${LCOV_PATH} --directory . --zerocounters COMMAND GameAnalyticsUnitTests - - # Capturing lcov counters and generating report - - COMMAND echo "Processing code coverage counters and generating report." - - COMMAND ${LCOV_PATH} --directory . --capture --output-file ${covname}.info --branch-coverage --rc geninfo_unexecuted_blocks=1 --rc no_exception_branch=1 - - COMMAND echo "Removing unwanted files from coverage report." - - COMMAND ${LCOV_PATH} --remove ${covname}.info - '${CMAKE_SOURCE_DIR}/source/dependencies/*' - '${CMAKE_SOURCE_DIR}/test/*' - '/usr/*' - '/Applications/Xcode.app/*' - --output-file ${covname}.info.cleaned - --branch-coverage - --ignore-errors unused,inconsistent - - COMMAND echo "Finished processing code coverage counters and generating report." + COMMAND ${LCOV_PATH} --directory . --capture --output-file cov.info.cleaned + --exclude '${CMAKE_SOURCE_DIR}/source/dependencies/*' + --exclude '${CMAKE_SOURCE_DIR}/test/*' + --exclude '/usr/*' + --exclude '/Applications/Xcode.app/*' + ${LCOV_COMMON_ARGS} + COMMENT "Running tests and capturing coverage into cov.info.cleaned" ) + add_dependencies(cov_data ${UT_PROJECT_NAME}) if (GENHTML_PATH) add_custom_target(cov - - # Cleanup lcov - ${LCOV_PATH} --directory . --zerocounters - - # Run tests - COMMAND GameAnalyticsUnitTests - - # Capturing lcov counters and generating report - # System headers are excluded at capture time; format/inconsistent are - # clang gcov artifacts downgraded to warnings; the baseline capture keeps - # instrumented files the tests never executed in the report - COMMAND ${LCOV_PATH} --directory . --capture --initial --output-file ${covname}.base.info - --branch-coverage --rc derive_function_end_line=0 - --exclude '/Applications/Xcode.app/*' - --exclude '/usr/*' - --ignore-errors format,inconsistent,unused - COMMAND ${LCOV_PATH} --directory . --capture --output-file ${covname}.run.info - --branch-coverage --rc derive_function_end_line=0 - --exclude '/Applications/Xcode.app/*' - --exclude '/usr/*' - --ignore-errors format,inconsistent,unused - COMMAND ${LCOV_PATH} --add-tracefile ${covname}.base.info - --add-tracefile ${covname}.run.info - --output-file ${covname}.info - --branch-coverage - --ignore-errors inconsistent,format - COMMAND ${LCOV_PATH} --remove ${covname}.info - '${CMAKE_SOURCE_DIR}/source/dependencies/*' - '${CMAKE_SOURCE_DIR}/test/*' - --output-file ${covname}.info.cleaned - --branch-coverage - --rc derive_function_end_line=0 - --ignore-errors unused,inconsistent,format - COMMAND ${GENHTML_PATH} -o ${covname} ${covname}.info.cleaned --branch-coverage --rc derive_function_end_line=0 --ignore-errors inconsistent,category - COMMAND ${CMAKE_COMMAND} -E remove ${covname}.base.info ${covname}.run.info ${covname}.info ${covname}.info.cleaned - - COMMENT "Resetting code coverage counters to zero.\nProcessing code coverage counters and generating report." + COMMAND ${GENHTML_PATH} -o cov cov.info.cleaned + --branch-coverage + --rc derive_function_end_line=0 + --ignore-errors inconsistent,category + COMMENT "Generating HTML coverage report in cov/" ) + add_dependencies(cov cov_data) else() - message(WARNING "unable to generate coverage report: missing genhtml") + message(WARNING "unable to add 'cov' target: missing genhtml") endif() - else() - message(WARNING "unable to add coverage targets: missing coverage tools") + message(WARNING "unable to add coverage targets: missing lcov") endif() endif() From 800dfd81d4eb6de6149c4292fecf021af2e8ce5e Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Mon, 31 Aug 2026 19:36:10 +0300 Subject: [PATCH 18/28] Update coverage.yml --- .github/workflows/coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 72f10f8..7105c6b 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -45,7 +45,7 @@ jobs: - name: Setup LCOV uses: hrishikesh-kadam/setup-lcov@v1 with: - ref: v2.3.2 + ref: v2.0 - name: Configure CMake shell: bash From c24d712c26f9da98ed5765a8b13687c166c1c295 Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Mon, 31 Aug 2026 19:37:54 +0300 Subject: [PATCH 19/28] Update coverage.yml --- .github/workflows/coverage.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 7105c6b..8c06f1a 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -44,8 +44,6 @@ jobs: - name: Setup LCOV uses: hrishikesh-kadam/setup-lcov@v1 - with: - ref: v2.0 - name: Configure CMake shell: bash From a639d4795c39b12c40aecac07503f8cb8f02434a Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Mon, 31 Aug 2026 19:52:35 +0300 Subject: [PATCH 20/28] Update CMakeLists.txt --- CMakeLists.txt | 86 ++++++++++++++++++++++---------------------------- 1 file changed, 37 insertions(+), 49 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6705148..b4692c9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -279,53 +279,41 @@ endif() # Coverage requires tests, which are only available for static library builds if (ENABLE_COVERAGE AND NOT GA_SHARED_LIB) - find_program(LCOV_PATH lcov) - find_program(GENHTML_PATH genhtml) - - if (LCOV_PATH) - target_compile_options( - GameAnalytics - PRIVATE - -g -O0 -fprofile-arcs -ftest-coverage -fprofile-update=atomic - ) - - target_link_libraries( - GameAnalytics PRIVATE -fprofile-arcs -ftest-coverage - ) - - set(LCOV_COMMON_ARGS - --branch-coverage - --rc geninfo_unexecuted_blocks=1 - --rc derive_function_end_line=0 - --ignore-errors format,inconsistent,unused,mismatch,empty - ) - - add_custom_target(cov_data - COMMAND ${LCOV_PATH} --directory . --zerocounters - COMMAND GameAnalyticsUnitTests - COMMAND ${LCOV_PATH} --directory . --capture --output-file cov.info.cleaned - --exclude '${CMAKE_SOURCE_DIR}/source/dependencies/*' - --exclude '${CMAKE_SOURCE_DIR}/test/*' - --exclude '/usr/*' - --exclude '/Applications/Xcode.app/*' - ${LCOV_COMMON_ARGS} - COMMENT "Running tests and capturing coverage into cov.info.cleaned" - ) - add_dependencies(cov_data ${UT_PROJECT_NAME}) - - if (GENHTML_PATH) - add_custom_target(cov - COMMAND ${GENHTML_PATH} -o cov cov.info.cleaned - --branch-coverage - --rc derive_function_end_line=0 - --ignore-errors inconsistent,category - COMMENT "Generating HTML coverage report in cov/" - ) - add_dependencies(cov cov_data) - else() - message(WARNING "unable to add 'cov' target: missing genhtml") - endif() - else() - message(WARNING "unable to add coverage targets: missing lcov") - endif() + find_program(LCOV_PATH lcov REQUIRED) + find_program(GENHTML_PATH genhtml REQUIRED) + + target_compile_options(GameAnalytics PRIVATE -g -O0 --coverage -fprofile-update=atomic) + target_link_options(GameAnalytics PUBLIC --coverage) + + # geninfo_unexecuted_blocks and derive_function_end_line smooth over gcc/llvm + # gcov differences; the ignored error classes are non-fatal data quirks of the + # same two toolchains (never add no_exception_branch: it corrupts both) + set(LCOV_OPTIONS + --branch-coverage + --rc geninfo_unexecuted_blocks=1 + --rc derive_function_end_line=0 + --ignore-errors format,inconsistent,unused,mismatch,empty + ) + + add_custom_target(cov_data + COMMAND ${LCOV_PATH} --zerocounters --directory . + COMMAND GameAnalyticsUnitTests + COMMAND ${LCOV_PATH} --capture --directory . --output-file cov.info.cleaned + --exclude '${CMAKE_SOURCE_DIR}/source/dependencies/*' + --exclude '${CMAKE_SOURCE_DIR}/test/*' + --exclude '/usr/*' + --exclude '/Applications/Xcode.app/*' + ${LCOV_OPTIONS} + COMMENT "Running tests and capturing coverage into cov.info.cleaned" + ) + add_dependencies(cov_data ${UT_PROJECT_NAME}) + + add_custom_target(cov + COMMAND ${GENHTML_PATH} cov.info.cleaned --output-directory cov + --branch-coverage + --rc derive_function_end_line=0 + --ignore-errors inconsistent,category + COMMENT "Generating HTML coverage report in cov/" + ) + add_dependencies(cov cov_data) endif() From 954f28da9057d8cf9179438ef3aa9dd24f83aaf7 Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Mon, 31 Aug 2026 20:01:46 +0300 Subject: [PATCH 21/28] fix --- .github/workflows/coverage.yml | 43 +++++----------------------------- CMakeLists.txt | 21 ++++++++++++----- 2 files changed, 21 insertions(+), 43 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 8c06f1a..09ec8eb 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -3,7 +3,7 @@ name: Test Coverage on: [pull_request, workflow_dispatch] jobs: - build: + coverage: name: Report Test Coverage runs-on: ubuntu-latest permissions: @@ -21,49 +21,20 @@ jobs: key: vcpkg-${{ runner.os }}-${{ hashFiles('vcpkg.json', 'vcpkg-configuration.json') }} restore-keys: vcpkg-${{ runner.os }}- - - name: Install vcpkg (Linux/macOS) - if: runner.os != 'Windows' - shell: bash + - name: Install vcpkg run: | git clone https://github.com/microsoft/vcpkg.git "$HOME/vcpkg" "$HOME/vcpkg/bootstrap-vcpkg.sh" -disableMetrics echo "VCPKG_ROOT=$HOME/vcpkg" >> "$GITHUB_ENV" echo "$HOME/vcpkg" >> "$GITHUB_PATH" - - name: Install vcpkg (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - git clone https://github.com/microsoft/vcpkg.git "$env:USERPROFILE\vcpkg" - & "$env:USERPROFILE\vcpkg\bootstrap-vcpkg.bat" -disableMetrics - echo "VCPKG_ROOT=$env:USERPROFILE\vcpkg" >> $env:GITHUB_ENV - echo "$env:USERPROFILE\vcpkg" >> $env:GITHUB_PATH - - - name: Create Build Environment - run: cmake -E make_directory ${{github.workspace}}/build - - name: Setup LCOV uses: hrishikesh-kadam/setup-lcov@v1 + with: + ref: v2.3.2 # apt's lcov 2.0 emits corrupt per-file rates for this codebase - - name: Configure CMake - shell: bash - working-directory: ${{github.workspace}}/build - run: cmake -DENABLE_COVERAGE=ON .. - - - name: Build - working-directory: ${{github.workspace}}/build - shell: bash - run: cmake --build . - - - name: Prepare coverage data - working-directory: ${{github.workspace}}/build - shell: bash - run: cmake --build . --target cov_data - - - name: Make coverage paths repo-relative - working-directory: ${{github.workspace}}/build - shell: bash - run: sed -i "s|^SF:${{ github.workspace }}/|SF:|" cov.info.cleaned + - name: Build, test and capture coverage + run: python3 setup.py --platform linux_x64 --compiler gcc --build --test --coverage - name: Report code coverage uses: zgosalvez/github-actions-report-lcov@v7.2.0 @@ -72,6 +43,4 @@ jobs: minimum-coverage: 30 artifact-name: code-coverage-report github-token: ${{ secrets.GITHUB_TOKEN }} - working-directory: ${{github.workspace}} update-comment: true - diff --git a/CMakeLists.txt b/CMakeLists.txt index b4692c9..63ed90e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -298,22 +298,31 @@ if (ENABLE_COVERAGE AND NOT GA_SHARED_LIB) add_custom_target(cov_data COMMAND ${LCOV_PATH} --zerocounters --directory . COMMAND GameAnalyticsUnitTests - COMMAND ${LCOV_PATH} --capture --directory . --output-file cov.info.cleaned - --exclude '${CMAKE_SOURCE_DIR}/source/dependencies/*' - --exclude '${CMAKE_SOURCE_DIR}/test/*' - --exclude '/usr/*' - --exclude '/Applications/Xcode.app/*' + COMMAND ${LCOV_PATH} --capture --directory . --output-file cov.info + --exclude "${CMAKE_SOURCE_DIR}/source/dependencies/*" + --exclude "${CMAKE_SOURCE_DIR}/test/*" + --exclude "/usr/*" + --exclude "/Applications/Xcode.app/*" + ${LCOV_OPTIONS} + # repo-relative source paths; substituting during --capture instead would + # break geninfo's source-file lookup + COMMAND ${LCOV_PATH} --add-tracefile cov.info --output-file cov.info.cleaned + --substitute "s|^${CMAKE_SOURCE_DIR}/||" ${LCOV_OPTIONS} COMMENT "Running tests and capturing coverage into cov.info.cleaned" + VERBATIM ) add_dependencies(cov_data ${UT_PROJECT_NAME}) add_custom_target(cov - COMMAND ${GENHTML_PATH} cov.info.cleaned --output-directory cov + COMMAND ${GENHTML_PATH} ${CMAKE_BINARY_DIR}/cov.info.cleaned + --output-directory ${CMAKE_BINARY_DIR}/cov --branch-coverage --rc derive_function_end_line=0 --ignore-errors inconsistent,category + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} COMMENT "Generating HTML coverage report in cov/" + VERBATIM ) add_dependencies(cov cov_data) endif() From e9d7c1ff2b2d8f15c0361d6a34b75d817301830b Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Tue, 1 Sep 2026 09:38:26 +0300 Subject: [PATCH 22/28] remove code coverage target and CI job --- .github/workflows/coverage.yml | 46 ----------------------------- .gitignore | 1 + CMakeLists.txt | 53 ---------------------------------- README.md | 3 +- setup.py | 14 +-------- 5 files changed, 3 insertions(+), 114 deletions(-) delete mode 100644 .github/workflows/coverage.yml diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml deleted file mode 100644 index 09ec8eb..0000000 --- a/.github/workflows/coverage.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Test Coverage - -on: [pull_request, workflow_dispatch] - -jobs: - coverage: - name: Report Test Coverage - runs-on: ubuntu-latest - permissions: - pull-requests: write - - steps: - - uses: actions/checkout@v6 - with: - submodules: true - - - name: Cache vcpkg binaries - uses: actions/cache@v4 - with: - path: ~/.cache/vcpkg/archives - key: vcpkg-${{ runner.os }}-${{ hashFiles('vcpkg.json', 'vcpkg-configuration.json') }} - restore-keys: vcpkg-${{ runner.os }}- - - - name: Install vcpkg - run: | - git clone https://github.com/microsoft/vcpkg.git "$HOME/vcpkg" - "$HOME/vcpkg/bootstrap-vcpkg.sh" -disableMetrics - echo "VCPKG_ROOT=$HOME/vcpkg" >> "$GITHUB_ENV" - echo "$HOME/vcpkg" >> "$GITHUB_PATH" - - - name: Setup LCOV - uses: hrishikesh-kadam/setup-lcov@v1 - with: - ref: v2.3.2 # apt's lcov 2.0 emits corrupt per-file rates for this codebase - - - name: Build, test and capture coverage - run: python3 setup.py --platform linux_x64 --compiler gcc --build --test --coverage - - - name: Report code coverage - uses: zgosalvez/github-actions-report-lcov@v7.2.0 - with: - coverage-files: build/cov.info.cleaned - minimum-coverage: 30 - artifact-name: code-coverage-report - github-token: ${{ secrets.GITHUB_TOKEN }} - update-comment: true diff --git a/.gitignore b/.gitignore index 776038b..de8a4ab 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ [Bb]uild_old/ /gtest_build .DS_Store +*.pyc diff --git a/CMakeLists.txt b/CMakeLists.txt index 63ed90e..8031663 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,7 +15,6 @@ include("create_source_groups_macro") include("eval_condition_macro") # --------------------------- Options --------------------------- # -option(ENABLE_COVERAGE "Enable code coverage reporting" OFF) option(GA_SHARED_LIB "Build GA as a shared library" OFF) option(GA_UWP_BUILD "Build GA for UWP (if targeting windows)" OFF) option(GA_BUILD_SAMPLE "Builds the GA Sample app" ON) @@ -274,55 +273,3 @@ if(NOT GA_SHARED_LIB) else() message(STATUS "Skipping unit tests (not available for shared library builds)") endif() - -# --------------------------- Code Coverage Setup --------------------------- # - -# Coverage requires tests, which are only available for static library builds -if (ENABLE_COVERAGE AND NOT GA_SHARED_LIB) - find_program(LCOV_PATH lcov REQUIRED) - find_program(GENHTML_PATH genhtml REQUIRED) - - target_compile_options(GameAnalytics PRIVATE -g -O0 --coverage -fprofile-update=atomic) - target_link_options(GameAnalytics PUBLIC --coverage) - - # geninfo_unexecuted_blocks and derive_function_end_line smooth over gcc/llvm - # gcov differences; the ignored error classes are non-fatal data quirks of the - # same two toolchains (never add no_exception_branch: it corrupts both) - set(LCOV_OPTIONS - --branch-coverage - --rc geninfo_unexecuted_blocks=1 - --rc derive_function_end_line=0 - --ignore-errors format,inconsistent,unused,mismatch,empty - ) - - add_custom_target(cov_data - COMMAND ${LCOV_PATH} --zerocounters --directory . - COMMAND GameAnalyticsUnitTests - COMMAND ${LCOV_PATH} --capture --directory . --output-file cov.info - --exclude "${CMAKE_SOURCE_DIR}/source/dependencies/*" - --exclude "${CMAKE_SOURCE_DIR}/test/*" - --exclude "/usr/*" - --exclude "/Applications/Xcode.app/*" - ${LCOV_OPTIONS} - # repo-relative source paths; substituting during --capture instead would - # break geninfo's source-file lookup - COMMAND ${LCOV_PATH} --add-tracefile cov.info --output-file cov.info.cleaned - --substitute "s|^${CMAKE_SOURCE_DIR}/||" - ${LCOV_OPTIONS} - COMMENT "Running tests and capturing coverage into cov.info.cleaned" - VERBATIM - ) - add_dependencies(cov_data ${UT_PROJECT_NAME}) - - add_custom_target(cov - COMMAND ${GENHTML_PATH} ${CMAKE_BINARY_DIR}/cov.info.cleaned - --output-directory ${CMAKE_BINARY_DIR}/cov - --branch-coverage - --rc derive_function_end_line=0 - --ignore-errors inconsistent,category - WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - COMMENT "Generating HTML coverage report in cov/" - VERBATIM - ) - add_dependencies(cov cov_data) -endif() diff --git a/README.md b/README.md index 502b902..02415d4 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ How to build Run `setup.py` with the required arguments for your platform: ```sh -python setup.py --platform {linux_x64,linux_x86,osx,win32,win64,uwp} [--cfg {Release,Debug}] [--compiler {gcc,clang}] [--shared] [--build] [--test] [--coverage] +python setup.py --platform {linux_x64,linux_x86,osx,win32,win64,uwp} [--cfg {Release,Debug}] [--compiler {gcc,clang}] [--shared] [--build] [--test] ``` | Argument | Values | Description | @@ -42,7 +42,6 @@ python setup.py --platform {linux_x64,linux_x86,osx,win32,win64,uwp} [--cfg {Rel | `--shared` | — | Build a shared library (`.dll`/`.so`/`.dylib`) instead of a static library | | `--build` | — | Execute the build step | | `--test` | — | Execute the test step (not available with `--shared`) | -| `--coverage` | — | Generate code coverage report (not available with `--shared`) | #### Examples diff --git a/setup.py b/setup.py index dc72b46..1a9c0cf 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,6 @@ def main(): parser.add_argument('--shared', action='store_true', help='Build shared library instead of static') parser.add_argument('--build', action='store_true', help='Execute the build step') parser.add_argument('--test', action='store_true', help='Execute the test step') - parser.add_argument('--coverage', action='store_true', help='Generate code coverage report') parser.add_argument('--no_vcpkg', action='store_true', help='Do not download vcpkg packages') parser.add_argument('--no_curl', action='store_true', help='Compile the SDK without CURL (you will need to provide a custom HTTP client implementation)') @@ -44,10 +43,6 @@ def main(): if args.compiler and not args.platform.startswith('linux'): parser.error('--compiler can only be used with Linux platforms') - # Validate coverage is not used with shared library - if args.coverage and args.shared: - parser.error('--coverage cannot be used with --shared (coverage requires tests which need static library)') - # Get compiler configuration for this platform (single compiler, like cmake.yml) compiler_config = get_compiler_for_platform(args.platform, args.compiler) c_compiler = compiler_config.get('c', '') @@ -121,9 +116,7 @@ def main(): cmake_command += f' -DCMAKE_BUILD_TYPE={args.cfg}' if args.platform: cmake_command += f' -DPLATFORM:STRING={args.platform}' - if args.coverage: - cmake_command += ' -DENABLE_COVERAGE=ON' - + run_command(cmake_command) # Build @@ -138,11 +131,6 @@ def main(): else: exit(0) - # Code Coverage - if args.coverage: - # Prepare coverage data - run_command(f'cmake --build {build_output_dir} --target cov', cwd=build_output_dir) - # Package Build Artifacts package_dir = os.path.join(build_output_dir, 'package') os.makedirs(package_dir, exist_ok=True) From 3922a49017e0742a2a22806377d3dd352415aca3 Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Tue, 1 Sep 2026 10:13:12 +0300 Subject: [PATCH 23/28] add gcovr-based code coverage (local via setup.py, gated CI job) --- .github/workflows/coverage.yml | 67 ++++++++++++++++++++++++++++++++++ CMakeLists.txt | 11 ++++++ README.md | 3 +- setup.py | 28 ++++++++++++++ 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/coverage.yml diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..8b3831d --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,67 @@ +name: Test Coverage + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + coverage: + name: Report Test Coverage + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v6 + with: + submodules: true + + - name: Cache vcpkg binaries + uses: actions/cache@v4 + with: + path: ~/.cache/vcpkg/archives + key: vcpkg-${{ runner.os }}-${{ hashFiles('vcpkg.json', 'vcpkg-configuration.json') }} + restore-keys: vcpkg-${{ runner.os }}- + + - name: Install vcpkg + run: | + git clone https://github.com/microsoft/vcpkg.git "$HOME/vcpkg" + "$HOME/vcpkg/bootstrap-vcpkg.sh" -disableMetrics + echo "VCPKG_ROOT=$HOME/vcpkg" >> "$GITHUB_ENV" + echo "$HOME/vcpkg" >> "$GITHUB_PATH" + + - name: Build and test with coverage instrumentation + run: | + python3 setup.py --platform linux_x64 --compiler gcc --build --test --coverage --no_cov_report + mkdir -p coverage + + - name: Generate coverage report + uses: threeal/gcovr-action@v1.2.0 + with: + filter: | + source/gameanalytics/ + include/GameAnalytics/ + excludes: source/gameanalytics/Platform/ + fail-under-line: 65 + fail-under-branch: 25 + print-summary: true + txt-out: coverage/coverage.txt + html-out: coverage/index.html + html-details: true + + - name: Publish coverage summary + if: ${{ !cancelled() }} + run: | + { + echo '```' + cat coverage/coverage.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload coverage report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: code-coverage-report + path: coverage/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 8031663..8055457 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,6 +15,7 @@ include("create_source_groups_macro") include("eval_condition_macro") # --------------------------- Options --------------------------- # +option(ENABLE_COVERAGE "Build with code coverage instrumentation" OFF) option(GA_SHARED_LIB "Build GA as a shared library" OFF) option(GA_UWP_BUILD "Build GA for UWP (if targeting windows)" OFF) option(GA_BUILD_SAMPLE "Builds the GA Sample app" ON) @@ -273,3 +274,13 @@ if(NOT GA_SHARED_LIB) else() message(STATUS "Skipping unit tests (not available for shared library builds)") endif() + +# --------------------------- Code Coverage Instrumentation --------------------------- # + +if(ENABLE_COVERAGE) + if(GA_SHARED_LIB) + message(FATAL_ERROR "ENABLE_COVERAGE requires a static library build (coverage is measured through the unit tests)") + endif() + target_compile_options(GameAnalytics PRIVATE -g -O0 --coverage -fprofile-update=atomic) + target_link_options(GameAnalytics PUBLIC --coverage) +endif() diff --git a/README.md b/README.md index 02415d4..e3a53fa 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ How to build Run `setup.py` with the required arguments for your platform: ```sh -python setup.py --platform {linux_x64,linux_x86,osx,win32,win64,uwp} [--cfg {Release,Debug}] [--compiler {gcc,clang}] [--shared] [--build] [--test] +python setup.py --platform {linux_x64,linux_x86,osx,win32,win64,uwp} [--cfg {Release,Debug}] [--compiler {gcc,clang}] [--shared] [--build] [--test] [--coverage] ``` | Argument | Values | Description | @@ -42,6 +42,7 @@ python setup.py --platform {linux_x64,linux_x86,osx,win32,win64,uwp} [--cfg {Rel | `--shared` | — | Build a shared library (`.dll`/`.so`/`.dylib`) instead of a static library | | `--build` | — | Execute the build step | | `--test` | — | Execute the test step (not available with `--shared`) | +| `--coverage` | — | Build with coverage instrumentation and generate an HTML report in `build/coverage/` (requires `--build --test`; not available with `--shared`; needs [gcovr](https://gcovr.com) installed) | #### Examples diff --git a/setup.py b/setup.py index 1a9c0cf..a251f1f 100644 --- a/setup.py +++ b/setup.py @@ -34,6 +34,8 @@ def main(): parser.add_argument('--shared', action='store_true', help='Build shared library instead of static') parser.add_argument('--build', action='store_true', help='Execute the build step') parser.add_argument('--test', action='store_true', help='Execute the test step') + parser.add_argument('--coverage', action='store_true', help='Build with coverage instrumentation and generate a coverage report') + parser.add_argument('--no_cov_report', action='store_true', help='Skip the local coverage report generation (used on CI where gcovr runs separately)') parser.add_argument('--no_vcpkg', action='store_true', help='Do not download vcpkg packages') parser.add_argument('--no_curl', action='store_true', help='Compile the SDK without CURL (you will need to provide a custom HTTP client implementation)') @@ -43,6 +45,15 @@ def main(): if args.compiler and not args.platform.startswith('linux'): parser.error('--compiler can only be used with Linux platforms') + if args.coverage and args.shared: + parser.error('--coverage cannot be used with --shared (coverage requires tests which need static library)') + + if args.coverage and not (args.build and args.test): + parser.error('--coverage requires --build and --test') + + if args.no_cov_report and not args.coverage: + parser.error('--no_cov_report requires --coverage') + # Get compiler configuration for this platform (single compiler, like cmake.yml) compiler_config = get_compiler_for_platform(args.platform, args.compiler) c_compiler = compiler_config.get('c', '') @@ -116,6 +127,8 @@ def main(): cmake_command += f' -DCMAKE_BUILD_TYPE={args.cfg}' if args.platform: cmake_command += f' -DPLATFORM:STRING={args.platform}' + if args.coverage: + cmake_command += ' -DENABLE_COVERAGE=ON' run_command(cmake_command) @@ -131,6 +144,21 @@ def main(): else: exit(0) + # Code Coverage Report + if args.coverage and not args.no_cov_report: + coverage_dir = os.path.join(build_output_dir, 'coverage') + os.makedirs(coverage_dir, exist_ok=True) + gcovr_command = 'gcovr --print-summary' + gcovr_command += ' --filter source/gameanalytics/ --filter include/GameAnalytics/' + gcovr_command += ' --exclude source/gameanalytics/Platform/' + gcovr_command += f' --html-details {os.path.join(coverage_dir, "index.html")}' + if args.platform == 'osx': + gcovr_command += ' --gcov-executable "xcrun llvm-cov gcov"' + elif cxx_compiler == 'clang++': + gcovr_command += ' --gcov-executable "llvm-cov gcov"' + run_command(gcovr_command) + print(f"\nCoverage report: {os.path.join(coverage_dir, 'index.html')}\n") + # Package Build Artifacts package_dir = os.path.join(build_output_dir, 'package') os.makedirs(package_dir, exist_ok=True) From 438ce03de3769bc24cdb247d451f308f1e22a10a Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Tue, 1 Sep 2026 10:25:53 +0300 Subject: [PATCH 24/28] coverage: gcovr.cfg, Coveralls/Codecov upload, Pages publish, badges, auto-open --- .github/workflows/coverage.yml | 44 ++++++++++++++++++++++++++++++---- README.md | 3 +++ gcovr.cfg | 4 ++++ setup.py | 10 ++++---- 4 files changed, 52 insertions(+), 9 deletions(-) create mode 100644 gcovr.cfg diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 8b3831d..f28cf06 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -7,10 +7,17 @@ on: branches: [main] workflow_dispatch: +concurrency: + group: coverage-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: coverage: name: Report Test Coverage runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write steps: - uses: actions/checkout@v6 @@ -39,16 +46,16 @@ jobs: - name: Generate coverage report uses: threeal/gcovr-action@v1.2.0 with: - filter: | - source/gameanalytics/ - include/GameAnalytics/ - excludes: source/gameanalytics/Platform/ fail-under-line: 65 fail-under-branch: 25 + fail-under-function: 78 print-summary: true txt-out: coverage/coverage.txt html-out: coverage/index.html html-details: true + cobertura-out: coverage/cobertura.xml + coveralls-out: coverage/coveralls.json + coveralls-send: true - name: Publish coverage summary if: ${{ !cancelled() }} @@ -59,9 +66,38 @@ jobs: echo '```' } >> "$GITHUB_STEP_SUMMARY" + - name: Upload to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5 + with: + files: coverage/cobertura.xml + use_oidc: true + - name: Upload coverage report if: ${{ !cancelled() }} uses: actions/upload-artifact@v7 with: name: code-coverage-report path: coverage/ + + - name: Upload Pages artifact + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-pages-artifact@v4 + with: + path: coverage/ + + publish-pages: + name: Publish Report to GitHub Pages + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: coverage + runs-on: ubuntu-24.04 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index e3a53fa..2061d2c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ GA-SDK-CPP ========== +[![Coverage Status](https://coveralls.io/repos/github/GameAnalytics/gameanalytics-sdk-cpp/badge.svg?branch=main)](https://coveralls.io/github/GameAnalytics/gameanalytics-sdk-cpp?branch=main) +[![codecov](https://codecov.io/gh/GameAnalytics/gameanalytics-sdk-cpp/branch/main/graph/badge.svg)](https://codecov.io/gh/GameAnalytics/gameanalytics-sdk-cpp) + GameAnalytics C++ SDK Documentation can be found [here](https://gameanalytics.com/docs/cpp-sdk). diff --git a/gcovr.cfg b/gcovr.cfg new file mode 100644 index 0000000..90152d2 --- /dev/null +++ b/gcovr.cfg @@ -0,0 +1,4 @@ +filter = source/gameanalytics/ +filter = include/GameAnalytics/ +exclude = source/gameanalytics/Platform/ +exclude-throw-branches = yes diff --git a/setup.py b/setup.py index a251f1f..6f451b4 100644 --- a/setup.py +++ b/setup.py @@ -4,6 +4,7 @@ import shutil import glob import platform as Platform +import webbrowser def run_command(command, shell=True, cwd=None): if os.name == 'nt': # Check if the OS is Windows @@ -148,16 +149,15 @@ def main(): if args.coverage and not args.no_cov_report: coverage_dir = os.path.join(build_output_dir, 'coverage') os.makedirs(coverage_dir, exist_ok=True) - gcovr_command = 'gcovr --print-summary' - gcovr_command += ' --filter source/gameanalytics/ --filter include/GameAnalytics/' - gcovr_command += ' --exclude source/gameanalytics/Platform/' - gcovr_command += f' --html-details {os.path.join(coverage_dir, "index.html")}' + report_path = os.path.join(coverage_dir, 'index.html') + gcovr_command = f'gcovr --print-summary --html-details {report_path}' if args.platform == 'osx': gcovr_command += ' --gcov-executable "xcrun llvm-cov gcov"' elif cxx_compiler == 'clang++': gcovr_command += ' --gcov-executable "llvm-cov gcov"' run_command(gcovr_command) - print(f"\nCoverage report: {os.path.join(coverage_dir, 'index.html')}\n") + print(f"\nCoverage report: {report_path}\n") + webbrowser.open(f'file://{report_path}') # Package Build Artifacts package_dir = os.path.join(build_output_dir, 'package') From 3e148e782c977d93b21e0d4ee14911c860629bde Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Tue, 1 Sep 2026 10:39:55 +0300 Subject: [PATCH 25/28] drop Coveralls, keep Codecov only --- .github/workflows/coverage.yml | 2 -- README.md | 1 - 2 files changed, 3 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index f28cf06..725ca51 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -54,8 +54,6 @@ jobs: html-out: coverage/index.html html-details: true cobertura-out: coverage/cobertura.xml - coveralls-out: coverage/coveralls.json - coveralls-send: true - name: Publish coverage summary if: ${{ !cancelled() }} diff --git a/README.md b/README.md index 2061d2c..425e346 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ GA-SDK-CPP ========== -[![Coverage Status](https://coveralls.io/repos/github/GameAnalytics/gameanalytics-sdk-cpp/badge.svg?branch=main)](https://coveralls.io/github/GameAnalytics/gameanalytics-sdk-cpp?branch=main) [![codecov](https://codecov.io/gh/GameAnalytics/gameanalytics-sdk-cpp/branch/main/graph/badge.svg)](https://codecov.io/gh/GameAnalytics/gameanalytics-sdk-cpp) GameAnalytics C++ SDK From fb4f40ed943ecbf354adc13c42d36b0eb4faba09 Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Tue, 1 Sep 2026 10:55:33 +0300 Subject: [PATCH 26/28] recalibrate coverage gates to real gcc CI baseline --- .github/workflows/coverage.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 725ca51..600357a 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -46,9 +46,9 @@ jobs: - name: Generate coverage report uses: threeal/gcovr-action@v1.2.0 with: - fail-under-line: 65 - fail-under-branch: 25 - fail-under-function: 78 + fail-under-line: 60 + fail-under-branch: 50 + fail-under-function: 80 print-summary: true txt-out: coverage/coverage.txt html-out: coverage/index.html From 9867a8c999bab36eee37e03b8cc25ee41de6e2c9 Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Tue, 1 Sep 2026 11:00:14 +0300 Subject: [PATCH 27/28] only run report steps when the build itself succeeded --- .github/workflows/coverage.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 600357a..d6798c2 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -39,6 +39,7 @@ jobs: echo "$HOME/vcpkg" >> "$GITHUB_PATH" - name: Build and test with coverage instrumentation + id: build run: | python3 setup.py --platform linux_x64 --compiler gcc --build --test --coverage --no_cov_report mkdir -p coverage @@ -56,7 +57,7 @@ jobs: cobertura-out: coverage/cobertura.xml - name: Publish coverage summary - if: ${{ !cancelled() }} + if: ${{ !cancelled() && steps.build.outcome == 'success' }} run: | { echo '```' @@ -65,14 +66,14 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Upload to Codecov - if: ${{ !cancelled() }} + if: ${{ !cancelled() && steps.build.outcome == 'success' }} uses: codecov/codecov-action@v5 with: files: coverage/cobertura.xml use_oidc: true - name: Upload coverage report - if: ${{ !cancelled() }} + if: ${{ !cancelled() && steps.build.outcome == 'success' }} uses: actions/upload-artifact@v7 with: name: code-coverage-report From 866c440ebdef2183d74a02e0f9ae7b95c2b666a7 Mon Sep 17 00:00:00 2001 From: Andrei Dabija Date: Tue, 1 Sep 2026 11:07:11 +0300 Subject: [PATCH 28/28] update cache CI action --- .github/workflows/cmake.yml | 2 +- .github/workflows/coverage.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cmake.yml b/.github/workflows/cmake.yml index 7eaf1fd..9022c0b 100644 --- a/.github/workflows/cmake.yml +++ b/.github/workflows/cmake.yml @@ -107,7 +107,7 @@ jobs: - name: Cache vcpkg binaries if: matrix.dependency_mode != 'no_deps' - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ runner.os == 'Windows' && '~/AppData/Local/vcpkg/archives' || '~/.cache/vcpkg/archives' }} key: vcpkg-${{ runner.os }}-${{ hashFiles('vcpkg.json', 'vcpkg-configuration.json') }} diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index d6798c2..43f6699 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -25,7 +25,7 @@ jobs: submodules: true - name: Cache vcpkg binaries - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/vcpkg/archives key: vcpkg-${{ runner.os }}-${{ hashFiles('vcpkg.json', 'vcpkg-configuration.json') }}