Introduce one key-hint grammar across every view: bold key, dim label, priority-ordered hints that drop from the end instead of ellipsizing. The month footer becomes a single split status bar with a friendly selected date, the '?' mega-line becomes a real keyboard shortcut panel, and the editor, agenda, search, picker, detail, and delete frames share the same chips. Notifications carry check/warning marks. Adds a live PTY smoke test covering view interaction and terminal state restoration on q and Ctrl-C exits.
204 lines
6.9 KiB
C++
204 lines
6.9 KiB
C++
// 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 <pty.h>
|
|
#include <sys/types.h>
|
|
#include <sys/wait.h>
|
|
|
|
#include <poll.h>
|
|
#include <unistd.h>
|
|
|
|
#include <chrono>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <iostream>
|
|
#include <string>
|
|
#include <string_view>
|
|
|
|
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<char*>(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<std::size_t>(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<ssize_t>(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<void>(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;
|
|
}
|