myxce/src/controller/testsuite/test_xcetest_process_entry.c
Rongsong Shen 4d1f247674
test(xce-test-cli-ux): build reusable pty-driven black-box CLI harness
clitest_spawn/send_line/send_partial/send_tab/send_signal/wait_for_text/
wait_for_exit/kill_and_reap launch the real compiled xce_test behind a
genuine pty (so isatty/g_interactive/libedit behave as a real terminal),
with stderr routed to a separate pipe for real stream-separation testing.
All waits are poll()-bounded with an explicit timeout so a future
regression can't hang the whole test suite. This is the shared
infrastructure tasks 2.3, 3.5, and all of Validation phase (4.1-4.4)
will build their black-box scenarios on top of. One smoke test proves
launch/send/capture/exit-code/stream-separation end to end.
1290/1290 controller suite passing (+1).

Task 1.7 of .kiro/specs/xce-test-cli-ux/tasks.md.
Foundation phase (1.1-1.7) now complete.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019jaMf9QEwpugH2W9KAuc7f
2026-09-09 11:06:31 +00:00

249 lines
8.5 KiB
C

/*
* test_xcetest_process_entry.c -- xce-test-cli-ux task 1.6, Requirements
* 4.1, 4.2, 4.3 (Process-Level Help and Version Discovery).
*
* These are black-box, subprocess tests: they exec the REAL, built
* xce_test binary (a sibling of this test binary in the same build
* directory -- see resolve_xce_test_path()) with a process-level flag and
* inspect its exit code and captured stdout. This is deliberately NOT a
* plain unit test that calls argp_parse()/parse_opt() in-process: what's
* under test is main()'s own process-level argv handling, including
* whether it reaches the interactive/scripted REPL loop at all, which can
* only be observed by actually running the process to completion.
*
* This is a small, self-contained subprocess harness scoped to this one
* task's needs (process-level -h/--help/--version) -- NOT the reusable
* pty-driven black-box CLI harness that task 1.7 builds separately (that
* one will live in test_test_cli.c and is needed for interactive-only
* surfaces like tab completion that a plain pipe can't exercise).
*
* resolve_xce_test_path() below is deliberately non-static (task 1.7):
* test_test_cli.c's pty-driven harness needs the exact same "find the real
* built xce_test binary next to this test binary" logic and reuses this
* copy via an `extern` declaration rather than duplicating it -- see this
* project's own consolidation-over-copy-paste convention.
*/
#define _GNU_SOURCE
#include <fcntl.h>
#include <libgen.h>
#include <limits.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
#include <unity/unity.h>
#define CAPTURE_BUF_SIZE 16384
#define CHILD_TIMEOUT_TENTHS 50 /* 5 seconds, polled every 100ms */
/* xce_test is always built as a sibling of this test binary in the same
* meson build directory (src/controller/meson.build builds both `xce_test`
* and `controller_unit_test` into the same target dir) -- resolving via
* /proc/self/exe's directory avoids depending on the caller's cwd or an
* env var that could go stale. */
int resolve_xce_test_path(char* out, size_t outsz)
{
char self[PATH_MAX];
ssize_t n;
char* dir;
n = readlink("/proc/self/exe", self, sizeof(self) - 1);
if (n <= 0) {
return -1;
}
self[n] = '\0';
dir = dirname(self); /* dirname() may modify its argument in place;
* `self` is a disposable local copy, so that's
* fine here. */
if (snprintf(out, outsz, "%s/xce_test", dir) >= (int)outsz) {
return -1;
}
return 0;
}
/* Runs xce_test_path with argv (NULL-terminated; argv[0] conventionally
* the program name) with stdin attached to /dev/null -- so that if a
* regression makes it fall through into the interactive REPL instead of
* exiting, readline() sees immediate EOF rather than hanging -- and
* captures its stdout into out_buf. Kills and fails the test if the child
* does not exit within CHILD_TIMEOUT_TENTHS/10 seconds, so a genuine hang
* can't wedge the whole suite. */
static void run_and_capture(const char* xce_test_path, char* const argv[],
char* out_buf, size_t out_buf_size,
int* exit_code)
{
int outpipe[2];
pid_t pid;
int status = 0;
int elapsed = 0;
pid_t waited;
TEST_ASSERT_EQUAL_INT(0, pipe(outpipe));
pid = fork();
TEST_ASSERT_TRUE(pid >= 0);
if (pid == 0) {
int devnull = open("/dev/null", O_RDONLY);
if (devnull >= 0) {
dup2(devnull, STDIN_FILENO);
close(devnull);
}
dup2(outpipe[1], STDOUT_FILENO);
close(outpipe[0]);
close(outpipe[1]);
execv(xce_test_path, argv);
_exit(127); /* only reached if execv() itself failed */
}
close(outpipe[1]);
for (;;) {
waited = waitpid(pid, &status, WNOHANG);
if (waited == pid) {
break;
}
if (elapsed >= CHILD_TIMEOUT_TENTHS) {
kill(pid, SIGKILL);
waitpid(pid, &status, 0);
close(outpipe[0]);
TEST_FAIL_MESSAGE(
"xce_test did not exit within timeout -- it likely fell "
"through into the REPL instead of exiting after a "
"process-level help/version flag");
}
usleep(100000);
elapsed++;
}
{
ssize_t total = 0;
ssize_t r;
while (total < (ssize_t)out_buf_size - 1) {
r = read(outpipe[0], out_buf + total,
out_buf_size - 1 - (size_t)total);
if (r <= 0) {
break;
}
total += r;
}
out_buf[total] = '\0';
}
close(outpipe[0]);
TEST_ASSERT_TRUE(WIFEXITED(status));
*exit_code = WEXITSTATUS(status);
}
/* Word-wrapped help text can break a multi-word phrase across lines at any
* space -- argp wraps the doc string to the terminal width. Folding
* newlines to spaces before substring-matching makes assertions robust to
* exactly where those wraps land. */
static void flatten_newlines(char* s)
{
for (; *s; s++) {
if (*s == '\n') {
*s = ' ';
}
}
}
static void test_help_flag_prints_usage_and_exits_without_repl(void)
{
char xce_test_path[PATH_MAX];
char out[CAPTURE_BUF_SIZE];
int exit_code;
char* const argv[] = { "xce_test", "--help", NULL };
TEST_ASSERT_EQUAL_INT(
0, resolve_xce_test_path(xce_test_path, sizeof(xce_test_path)));
run_and_capture(xce_test_path, argv, out, sizeof(out), &exit_code);
TEST_ASSERT_EQUAL_INT(0, exit_code);
TEST_ASSERT_NOT_NULL(strstr(out, "Usage:"));
/* Never reached the interactive session's top-level prompt. */
TEST_ASSERT_NULL(strstr(out, "xce>"));
}
/* Requirement 4.3: the process-level help output must describe the
* in-session `help` command as the way to list/learn available commands
* -- not just document its own --help/-?/-h flags. */
static void test_help_flag_describes_insession_help_command(void)
{
char xce_test_path[PATH_MAX];
char out[CAPTURE_BUF_SIZE];
int exit_code;
char* const argv[] = { "xce_test", "--help", NULL };
TEST_ASSERT_EQUAL_INT(
0, resolve_xce_test_path(xce_test_path, sizeof(xce_test_path)));
run_and_capture(xce_test_path, argv, out, sizeof(out), &exit_code);
TEST_ASSERT_EQUAL_INT(0, exit_code);
flatten_newlines(out);
TEST_ASSERT_NOT_NULL_MESSAGE(
strstr(out, "type `help' at the prompt to list"),
"xce_test --help must describe the in-session `help' command as "
"the way to list/learn available commands (Requirement 4.3)");
}
/* Requirement 4.1 explicitly calls out `-h` alongside `--help`. GNU argp's
* OWN built-in short help alias is `-?`, not `-h` -- so unlike --help/-?,
* this is not automatic and must be verified (and, if missing, added). */
static void test_dash_h_flag_prints_usage_and_exits_without_repl(void)
{
char xce_test_path[PATH_MAX];
char out[CAPTURE_BUF_SIZE];
int exit_code;
char* const argv[] = { "xce_test", "-h", NULL };
TEST_ASSERT_EQUAL_INT(
0, resolve_xce_test_path(xce_test_path, sizeof(xce_test_path)));
run_and_capture(xce_test_path, argv, out, sizeof(out), &exit_code);
TEST_ASSERT_EQUAL_INT(0, exit_code);
TEST_ASSERT_NOT_NULL(strstr(out, "Usage:"));
TEST_ASSERT_NULL(strstr(out, "xce>"));
}
static void test_version_flag_prints_version_and_exits_without_repl(void)
{
char xce_test_path[PATH_MAX];
char out[CAPTURE_BUF_SIZE];
int exit_code;
char* const argv[] = { "xce_test", "--version", NULL };
TEST_ASSERT_EQUAL_INT(
0, resolve_xce_test_path(xce_test_path, sizeof(xce_test_path)));
run_and_capture(xce_test_path, argv, out, sizeof(out), &exit_code);
TEST_ASSERT_EQUAL_INT(0, exit_code);
TEST_ASSERT_NOT_NULL(strstr(out, "xce"));
/* xce_compose_version_ex()'s output includes a "built <timestamp>"
* segment -- distinguishes real version info from a coincidental
* "xce" substring elsewhere. */
TEST_ASSERT_NOT_NULL(strstr(out, "built"));
TEST_ASSERT_NULL(strstr(out, "Usage:"));
TEST_ASSERT_NULL(strstr(out, "xce>"));
}
void test_xcetest_process_entry(void)
{
UnitySetTestFile(__FILE__);
RUN_TEST(test_help_flag_prints_usage_and_exits_without_repl);
RUN_TEST(test_help_flag_describes_insession_help_command);
RUN_TEST(test_dash_h_flag_prints_usage_and_exits_without_repl);
RUN_TEST(test_version_flag_prints_version_and_exits_without_repl);
}