// Live PTY smoke test: drives the real nocal binary through interactive // views and verifies the terminal state (alternate screen, cursor) is // restored on exit. Usage: tui-pty-smoke /path/to/nocal #include #include #include #include #include #include #include #include #include #include #include namespace { int failures = 0; void check(const bool condition, const std::string_view message) { if (!condition) { ++failures; std::cerr << "FAIL: " << message << '\n'; } } class PtySession { public: PtySession(const char* binary, const char* home) { struct winsize size {}; size.ws_col = 100; size.ws_row = 30; int master = -1; const pid_t child = ::forkpty(&master, nullptr, nullptr, &size); if (child < 0) { std::cerr << "forkpty failed: " << std::strerror(errno) << '\n'; std::exit(2); } if (child == 0) { ::setenv("HOME", home, 1); ::setenv("XDG_DATA_HOME", home, 1); ::execl(binary, "nocal", "--demo", static_cast(nullptr)); std::cerr << "exec failed: " << std::strerror(errno) << '\n'; _exit(127); } master_ = master; child_ = child; } ~PtySession() { if (master_ >= 0) ::close(master_); if (child_ > 0 && !reaped_) { ::kill(child_, SIGKILL); ::waitpid(child_, nullptr, 0); } } [[nodiscard]] bool wait_for(const std::string_view needle, const std::chrono::milliseconds timeout) { const auto deadline = std::chrono::steady_clock::now() + timeout; while (std::chrono::steady_clock::now() < deadline) { if (capture_.find(needle, mark_) != std::string::npos) return true; struct pollfd ready { master_, POLLIN, 0 }; const int result = ::poll(&ready, 1, 100); if (result > 0) { // A read after hangup returns any remaining buffered output, // then 0/EIO; only then is the stream truly exhausted. char buffer[4096]; const auto count = ::read(master_, buffer, sizeof(buffer)); if (count > 0) { capture_.append(buffer, static_cast(count)); continue; } return capture_.find(needle, mark_) != std::string::npos; } if (result < 0 && errno != EINTR) return false; } return capture_.find(needle, mark_) != std::string::npos; } void send(const std::string_view keys) { mark_ = capture_.size(); const auto written = ::write(master_, keys.data(), keys.size()); check(written == static_cast(keys.size()), "pty write completed"); } [[nodiscard]] bool exited_cleanly(const std::chrono::milliseconds timeout) { const auto deadline = std::chrono::steady_clock::now() + timeout; while (std::chrono::steady_clock::now() < deadline) { int status = 0; const auto done = ::waitpid(child_, &status, WNOHANG); if (done == child_) { reaped_ = true; exit_status_ = status; // Drain whatever the child wrote while exiting. static_cast(wait_for("\x1b[?1049l", std::chrono::milliseconds{500})); return WIFEXITED(status) && WEXITSTATUS(status) == 0; } ::usleep(50'000); } return false; } [[nodiscard]] const std::string& capture() const { return capture_; } private: int master_{-1}; pid_t child_{-1}; bool reaped_{false}; int exit_status_{0}; std::string capture_; std::size_t mark_{0}; }; void test_interactive_views(const char* binary, const char* home) { PtySession session{binary, home}; check(session.wait_for("\x1b[?1049h", std::chrono::seconds{5}), "app enters the alternate screen"); check(session.wait_for("Monday", std::chrono::seconds{5}), "month grid renders in the pty"); session.send("?"); check(session.wait_for("KEYBOARD SHORTCUTS", std::chrono::seconds{5}), "? opens the keyboard shortcut help"); session.send("\x1b"); check(session.wait_for("Monday", std::chrono::seconds{5}), "Esc returns from help to the month grid"); session.send("g"); check(session.wait_for("AGENDA", std::chrono::seconds{5}), "g opens the agenda"); session.send("\x1b"); check(session.wait_for("Monday", std::chrono::seconds{5}), "Esc closes the agenda"); session.send("/"); check(session.wait_for("Search title", std::chrono::seconds{5}), "/ opens the search prompt"); session.send("\x1b"); check(session.wait_for("Monday", std::chrono::seconds{5}), "Esc cancels search"); session.send("q"); check(session.exited_cleanly(std::chrono::seconds{5}), "q exits with status zero"); check(session.capture().find("\x1b[?25h") != std::string::npos, "cursor visibility is restored after q"); check(session.capture().find("\x1b[?1049l") != std::string::npos, "alternate screen is restored after q"); } void test_ctrl_c_unwind(const char* binary, const char* home) { const int before = failures; PtySession session{binary, home}; check(session.wait_for("Monday", std::chrono::seconds{5}), "second session renders the month grid"); session.send("\x03"); check(session.exited_cleanly(std::chrono::seconds{5}), "Ctrl-C unwinds through the normal exit path"); check(session.capture().find("\x1b[?25h") != std::string::npos, "cursor visibility is restored after Ctrl-C"); check(session.capture().find("\x1b[?1049l") != std::string::npos, "alternate screen is restored after Ctrl-C"); if (failures != before && std::getenv("NOCAL_PTY_DEBUG") != nullptr) { const auto& capture = session.capture(); std::cerr << "--- ctrl-c capture tail ---\n" << capture.substr(capture.size() > 2000 ? capture.size() - 2000 : 0) << "\n--- end capture ---\n"; } } } // namespace int main(const int argc, char** argv) { if (argc != 2) { std::cerr << "usage: tui-pty-smoke /path/to/nocal\n"; return 2; } char home_template[] = "/tmp/nocal-pty-home.XXXXXX"; const char* home = ::mkdtemp(home_template); if (home == nullptr) { std::cerr << "mkdtemp failed: " << std::strerror(errno) << '\n'; return 2; } test_interactive_views(argv[1], home); test_ctrl_c_unwind(argv[1], home); if (failures != 0) { std::cerr << failures << " PTY smoke check(s) failed\n"; return EXIT_FAILURE; } std::cout << "PTY smoke passed\n"; return EXIT_SUCCESS; }