From ad64843d85f40d730fa04df1aecdac1d3b3f259f Mon Sep 17 00:00:00 2001 From: nircoe Date: Sat, 26 Sep 2026 18:40:21 +0300 Subject: [PATCH 1/6] [Feature]: Stop output duplications --- include/testcoe/terminal_utils.hpp | 1 + src/testcoe/terminal_utils.cpp | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/include/testcoe/terminal_utils.hpp b/include/testcoe/terminal_utils.hpp index 29a77a4..6242b0a 100644 --- a/include/testcoe/terminal_utils.hpp +++ b/include/testcoe/terminal_utils.hpp @@ -12,5 +12,6 @@ namespace testcoe { void clear(); bool isAnsiEnabled(); + bool isInteractive(); } // namespace terminal } // namespace testcoe \ No newline at end of file diff --git a/src/testcoe/terminal_utils.cpp b/src/testcoe/terminal_utils.cpp index 11e9912..83a1030 100644 --- a/src/testcoe/terminal_utils.cpp +++ b/src/testcoe/terminal_utils.cpp @@ -1,5 +1,12 @@ #include +#ifdef _WIN32 + #include +#else + #include +#endif +#include + namespace testcoe { namespace terminal @@ -66,5 +73,16 @@ namespace testcoe // \033[J clears the screen from cursor to end std::cout << "\033[H\033[J"; } + + // Distinct from isAnsiEnabled(): TTY-ness, not ANSI capability. A pipe is never + // interactive regardless of color support. + bool isInteractive() + { +#ifdef _WIN32 + return _isatty(_fileno(stdout)) != 0; +#else + return isatty(STDOUT_FILENO) != 0; +#endif + } } // namespace terminal } // namespace testcoe \ No newline at end of file From 121c46be25d224933893fb5934117ee0d1ab299b Mon Sep 17 00:00:00 2001 From: nircoe Date: Sat, 26 Sep 2026 19:38:26 +0300 Subject: [PATCH 2/6] Gate grid redraws on interactivity --- include/testcoe/grid_listener.hpp | 1 + src/testcoe/grid_listener.cpp | 23 +++++++++++++++-------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/include/testcoe/grid_listener.hpp b/include/testcoe/grid_listener.hpp index a3942c8..1d777a5 100644 --- a/include/testcoe/grid_listener.hpp +++ b/include/testcoe/grid_listener.hpp @@ -40,6 +40,7 @@ namespace testcoe class GridTestListener : public testing::TestEventListener { testing::TestEventListener* m_originalListener; + bool m_interactive; // true only on a real TTY, gates per-event grid redraws std::map> m_suiteTestStatus; std::map> m_suiteTestNames; diff --git a/src/testcoe/grid_listener.cpp b/src/testcoe/grid_listener.cpp index 336282a..c5cac65 100644 --- a/src/testcoe/grid_listener.cpp +++ b/src/testcoe/grid_listener.cpp @@ -79,7 +79,7 @@ namespace testcoe } GridTestListener::GridTestListener(testing::TestEventListener *listener) : - m_originalListener(listener), + m_originalListener(listener), m_interactive(terminal::isInteractive()), m_totalTests(0), m_completedTests(0), m_passedTests(0), m_failedTests(0), m_originalCoutBuf(std::cout.rdbuf()), m_originalCerrBuf(std::cerr.rdbuf()) { @@ -122,7 +122,8 @@ namespace testcoe std::cout << color::bold << "Running " << m_totalTests << " tests..." << std::endl << color::reset; - printGrid(); + if (m_interactive) + printGrid(); std::cout.rdbuf(m_nullStream.rdbuf()); std::cerr.rdbuf(m_nullStream.rdbuf()); @@ -160,9 +161,12 @@ namespace testcoe if(0 <= testIndex && testIndex < static_cast(m_suiteTestStatus[m_currentTestSuite].size())) m_suiteTestStatus[m_currentTestSuite][testIndex] = TestStatus::Running; - std::cout.rdbuf(m_originalCoutBuf); - printGrid(); - std::cout.rdbuf(m_nullStream.rdbuf()); + if (m_interactive) + { + std::cout.rdbuf(m_originalCoutBuf); + printGrid(); + std::cout.rdbuf(m_nullStream.rdbuf()); + } } void GridTestListener::OnTestPartResult(const testing::TestPartResult &testPartResult) @@ -189,9 +193,12 @@ namespace testcoe ++m_completedTests; - std::cout.rdbuf(m_originalCoutBuf); - printGrid(); - std::cout.rdbuf(m_nullStream.rdbuf()); + if (m_interactive) + { + std::cout.rdbuf(m_originalCoutBuf); + printGrid(); + std::cout.rdbuf(m_nullStream.rdbuf()); + } } void GridTestListener::OnTestSuiteEnd(const testing::TestSuite &) { } From 3783a3fe7c233d16ca009307ce96af96dfe9fac9 Mon Sep 17 00:00:00 2001 From: nircoe Date: Sat, 26 Sep 2026 20:02:11 +0300 Subject: [PATCH 3/6] Add non-interactive output regression test --- tests/examples_integration_tests.cpp | 35 ++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/examples_integration_tests.cpp b/tests/examples_integration_tests.cpp index 5de1159..a05bbe6 100644 --- a/tests/examples_integration_tests.cpp +++ b/tests/examples_integration_tests.cpp @@ -215,6 +215,41 @@ TEST_F(ExampleTests, BasicExampleRuns) << "Missing test summary in output"; } +// runCommand() pipes output through popen/_popen, which is never a TTY, so this covers +// the non-interactive path (see terminal::isInteractive()). +TEST_F(ExampleTests, BasicExampleNonInteractiveOutputHasNoDuplicateGridFrames) +{ + std::string executable = getExecutablePath("basic"); + ASSERT_TRUE(executableExists(executable)) << "Basic example executable not found: " << executable; + + std::string output = runCommand(executable); + + // Sits between printGrid()'s two dynamic counts, so it's never split by ANSI color codes. + const std::string gridFrameMarker = " tests... Completed: "; + + std::size_t frameCount = 0; + std::size_t pos = 0; + while ((pos = output.find(gridFrameMarker, pos)) != std::string::npos) + { + ++frameCount; + pos += gridFrameMarker.length(); + } + + EXPECT_EQ(frameCount, 1u) + << "Non-interactive output should contain exactly one grid frame (the final summary), " + << "not one per test start/end event. Output:\n" << output; + + // Loose bound - a duplicate-frame regression would blow well past this. + std::size_t lineCount = 0; + for (char c : output) + if (c == '\n') + ++lineCount; + + EXPECT_LT(lineCount, 60u) + << "Non-interactive output is unexpectedly long (" << lineCount << " lines) - possible " + << "duplicate-frame regression. Output:\n" << output; +} + // Test that filter example runs with different options TEST_F(ExampleTests, FilterExampleRuns) { From b53709912efd9c7dc5d03f2a6c5c1fc9b75c14e4 Mon Sep 17 00:00:00 2001 From: nircoe Date: Sat, 26 Sep 2026 21:03:14 +0300 Subject: [PATCH 4/6] Update docs --- README.md | 2 +- docs/ARCHITECTURE.md | 7 ++++--- examples/basic/README.md | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4e0f1b0..77c2f1f 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ That's it! Run your tests and see the enhanced output. ## Features -- **Grid Visualization** - See all tests progress in real-time +- **Grid Visualization** - Real-time progress on interactive terminals; a single, duplication-free summary when output is piped or redirected (e.g. CI logs) - **Crash Handling** - Get stack traces when tests crash - **Color Support** - Automatic terminal detection - **Test Filtering** - Run specific tests or suites diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d113338..49a8850 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -41,7 +41,7 @@ testcoe enhances Google Test by intercepting test events and providing visual fe - **Purpose**: Implements Google Test event listener for visual grid display - **Key Features**: - Tracks test execution state - - Updates terminal display in real-time + - Updates terminal display in real-time on interactive terminals; when output is piped or redirected (e.g. CI logs), redraws only once, in the final summary, to avoid duplicate frames - Collects and displays failure information - Shows execution time statistics @@ -59,6 +59,7 @@ testcoe enhances Google Test by intercepting test events and providing visual fe - **Key Functions**: - `isAnsiEnabled()` - Detects ANSI color support - `clear()` - Clears terminal screen + - `isInteractive()` - Detects whether stdout is an interactive terminal (TTY) vs piped/redirected ## Data Flow @@ -73,8 +74,8 @@ testcoe enhances Google Test by intercepting test events and providing visual fe - Google Test begins execution - GridListener receives events: - `OnTestProgramStart` - Initialize grid display - - `OnTestStart` - Mark test as running - - `OnTestEnd` - Mark test as passed/failed + - `OnTestStart` - Mark test as running (redraws the grid only when output is interactive) + - `OnTestEnd` - Mark test as passed/failed (redraws the grid only when output is interactive) - `OnTestProgramEnd` - Show final summary 3. **Crash Handling**: diff --git a/examples/basic/README.md b/examples/basic/README.md index 0831318..1eee7e8 100644 --- a/examples/basic/README.md +++ b/examples/basic/README.md @@ -33,7 +33,7 @@ cmake --build . --target run_basic_example 1. testcoe is initialized with `testcoe::init(&argc, argv)`, replacing the usual Google Test initialization. 2. Tests are run with `testcoe::run()`, replacing the usual `RUN_ALL_TESTS()`. -3. The grid visualization shows all test suites and their status in real-time. +3. The grid visualization shows all test suites and their status in real-time when run in an interactive terminal; when output is piped or redirected (e.g. `./basic_example | cat`, or a CI log), only the final grid and summary are printed once. 4. An intentional failure is included to demonstrate how failures are displayed. ## Expected Output From 813418467ee9a77583acbcb844aed59fdde0119b Mon Sep 17 00:00:00 2001 From: nircoe Date: Sun, 27 Sep 2026 11:12:36 +0300 Subject: [PATCH 5/6] Skip crash tests --- examples/crash/crash_tests.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/crash/crash_tests.cpp b/examples/crash/crash_tests.cpp index 18ace92..eba6ea3 100644 --- a/examples/crash/crash_tests.cpp +++ b/examples/crash/crash_tests.cpp @@ -19,7 +19,7 @@ TEST(CrashTests, SegmentationFault) std::cout << "This test will cause a segmentation fault by dereferencing a null pointer." << std::endl; // Skip by default - uncomment the next line and comment the GTEST_SKIP to run - // GTEST_SKIP() << "Skipping intentional crash test"; + GTEST_SKIP() << "Skipping intentional crash test"; // This will cause a segmentation fault int *nullPtr = nullptr; @@ -39,7 +39,7 @@ TEST(CrashTests, DivideByZero) std::cout << "This test will cause a floating point exception by dividing by zero." << std::endl; // Skip by default - uncomment the next line and comment the GTEST_SKIP to run - // GTEST_SKIP() << "Skipping intentional crash test"; + GTEST_SKIP() << "Skipping intentional crash test"; // This will cause a floating point exception volatile int zero = 0; @@ -59,7 +59,7 @@ TEST(CrashTests, Abort) std::cout << "This test will cause a program abort." << std::endl; // Skip by default - uncomment the next line and comment the GTEST_SKIP to run - // GTEST_SKIP() << "Skipping intentional crash test"; + GTEST_SKIP() << "Skipping intentional crash test"; // This will abort the program std::abort(); From 151b88d00ff1f7607e299205681414fa8278f989 Mon Sep 17 00:00:00 2001 From: nircoe Date: Sun, 27 Sep 2026 12:31:16 +0300 Subject: [PATCH 6/6] Filter crash example to avoid crashes in CI --- examples/basic/main.cpp | 2 +- examples/crash/crash_tests.cpp | 9 ++++----- examples/crash/main.cpp | 4 ++-- examples/filter/main.cpp | 2 +- tests/examples_integration_tests.cpp | 2 -- 5 files changed, 8 insertions(+), 11 deletions(-) diff --git a/examples/basic/main.cpp b/examples/basic/main.cpp index e4b4a44..ac6ea84 100644 --- a/examples/basic/main.cpp +++ b/examples/basic/main.cpp @@ -17,4 +17,4 @@ int main(int argc, char **argv) // Run all tests return testcoe::run(); -} \ No newline at end of file +} diff --git a/examples/crash/crash_tests.cpp b/examples/crash/crash_tests.cpp index eba6ea3..7f8fa76 100644 --- a/examples/crash/crash_tests.cpp +++ b/examples/crash/crash_tests.cpp @@ -2,7 +2,6 @@ #include #include #include -#include #include class CrashTests : public ::testing::Test @@ -19,7 +18,7 @@ TEST(CrashTests, SegmentationFault) std::cout << "This test will cause a segmentation fault by dereferencing a null pointer." << std::endl; // Skip by default - uncomment the next line and comment the GTEST_SKIP to run - GTEST_SKIP() << "Skipping intentional crash test"; + // GTEST_SKIP() << "Skipping intentional crash test"; // This will cause a segmentation fault int *nullPtr = nullptr; @@ -39,7 +38,7 @@ TEST(CrashTests, DivideByZero) std::cout << "This test will cause a floating point exception by dividing by zero." << std::endl; // Skip by default - uncomment the next line and comment the GTEST_SKIP to run - GTEST_SKIP() << "Skipping intentional crash test"; + // GTEST_SKIP() << "Skipping intentional crash test"; // This will cause a floating point exception volatile int zero = 0; @@ -59,7 +58,7 @@ TEST(CrashTests, Abort) std::cout << "This test will cause a program abort." << std::endl; // Skip by default - uncomment the next line and comment the GTEST_SKIP to run - GTEST_SKIP() << "Skipping intentional crash test"; + // GTEST_SKIP() << "Skipping intentional crash test"; // This will abort the program std::abort(); @@ -182,4 +181,4 @@ TEST(BasicTests, Addition) TEST(BasicTests, Subtraction) { EXPECT_EQ(5 - 3, 2); -} \ No newline at end of file +} diff --git a/examples/crash/main.cpp b/examples/crash/main.cpp index 6096bb6..0624f4d 100644 --- a/examples/crash/main.cpp +++ b/examples/crash/main.cpp @@ -53,8 +53,8 @@ int main(int argc, char **argv) } } - return testcoe::run(); + return testcoe::run("-CrashTests.*"); // TODO: add specific test run, and why do they say "by default all crash tests are skipped"?? // you don't really do anything with the --gtest_filter flag, and we should call it "--run-test" -} \ No newline at end of file +} diff --git a/examples/filter/main.cpp b/examples/filter/main.cpp index fc41fd1..ada8572 100644 --- a/examples/filter/main.cpp +++ b/examples/filter/main.cpp @@ -87,4 +87,4 @@ int main(int argc, char **argv) // Default: run all tests return testcoe::run(); -} \ No newline at end of file +} diff --git a/tests/examples_integration_tests.cpp b/tests/examples_integration_tests.cpp index a05bbe6..784fc13 100644 --- a/tests/examples_integration_tests.cpp +++ b/tests/examples_integration_tests.cpp @@ -1,8 +1,6 @@ #include #include #include -#include -#include #include #include