diff --git a/.kiro/specs/xce-test-cli-ux/tasks.md b/.kiro/specs/xce-test-cli-ux/tasks.md index b7586235..a14d6f91 100644 --- a/.kiro/specs/xce-test-cli-ux/tasks.md +++ b/.kiro/specs/xce-test-cli-ux/tasks.md @@ -46,7 +46,7 @@ - _Requirements: 4.1, 4.2, 4.3_ - _Boundary: Process Entry_ -- [ ] 1.7 Build the black-box CLI test harness +- [x] 1.7 Build the black-box CLI test harness - Add a subprocess/pty-driven test harness capable of launching the built client, sending input lines (including partial input followed by a tab keypress and raw signals), and capturing its stdout/stderr/exit code separately - Observable completion: a smoke test using the new harness launches the client, sends a trivial command, and confirms the expected output arrives on the correct stream with the expected exit code - _Boundary: Testing Infrastructure_ @@ -178,6 +178,7 @@ ## Implementation Notes - `src/tools/xce-build.sh controller asan xce.build --incremental` writes its build dir at `src/xce.build/` (relative to the script's own location), not a repo-root `xce.build/` — meson logs are at `src/xce.build/meson-logs/testlog.txt`. (task 1.1) - New Unity test files need two wiring points, not just meson.build's source list: also register an `extern void test_(void);` + `TEST(test_);` in `src/controller/testsuite/controller_test.c`, mirroring the existing `test_rcfile` registration, or the suite silently never runs. (task 1.1) +- The `test_test_cli.c` black-box harness (`clitest_*` functions) is file-static and single-file by design (matching design.md's File Structure Plan and this directory's own no-split norm) — tasks 2.3, 3.5, 4.1-4.4 should add their black-box test functions directly into this same file rather than a new one. `clitest_wait_for_text()` is generic (arbitrary needle/stream) and safe to call repeatedly mid-session; `clitest_send_signal()` targets the child's real pid via `kill()`. (task 1.7) - `argp` has no built-in `-h` alias (its default short help flag is `-?`) — Requirement 4.1 literally names `-h`, so it needed an explicit `OPTION_HIDDEN` option added, not just verification of existing defaults. Task 1.6's new `test_xcetest_process_entry.c` already covers "`-h`/`--help`/`--version` exit without entering the REPL" end-to-end against the real binary; task 1.7's `test_test_cli.c` should either skip re-adding this exact scenario or consolidate the two rather than duplicating coverage. (task 1.6) - The per-connection lock is a POSIX semaphore (`sem_t`), not a `pthread_mutex_t` — a mutex was tried first but rejected on review (POSIX classifies a non-owning-thread `pthread_mutex_unlock()` as UB for every mutex type, not just error-checking ones, and this design needs the worker thread to release a lock the caller thread acquired). `sem_t` has no ownership concept and is fully POSIX-defined for this pattern. See bug-1791. (task 1.5) - `interruptcall.c`'s forced-abandonment test proves `-EINTR` via return-value disambiguation against a slow `fn`, not a direct wall-clock elapsed-time assertion — sound today (poll interval is 50ms, matching design.md) but a future regression widening the poll interval wouldn't be caught unless the test also asserts an elapsed-time bound. Non-blocking; noted for task 4.1's end-to-end Ctrl-C validation to consider. (task 1.4) diff --git a/src/controller/meson.build b/src/controller/meson.build index 61eb1c4d..310bbd2f 100644 --- a/src/controller/meson.build +++ b/src/controller/meson.build @@ -175,6 +175,7 @@ controller_unit_test_src = [ 'testsuite/test_interruptcall.c', 'testsuite/interruptcall.c', 'testsuite/test_xcetest_process_entry.c', + 'testsuite/test_test_cli.c', 'testsuite/test_persist_snapshot_entities.c', 'testsuite/test_apply_seam.c', 'testsuite/test_apply_committed_entry.c', diff --git a/src/controller/testsuite/controller_test.c b/src/controller/testsuite/controller_test.c index bb024c31..abeb3ffa 100644 --- a/src/controller/testsuite/controller_test.c +++ b/src/controller/testsuite/controller_test.c @@ -75,6 +75,7 @@ extern void test_flagspec(void); extern void test_xcetest_interrupt(void); extern void test_interruptcall(void); extern void test_xcetest_process_entry(void); +extern void test_test_cli(void); extern void test_drive_async_config(void); extern void test_slowlane(void); extern void test_commit_admission_pool(void); @@ -259,6 +260,7 @@ int main(int argc, char* argv[]) TEST(test_xcetest_interrupt); TEST(test_interruptcall); TEST(test_xcetest_process_entry); + TEST(test_test_cli); TEST(test_drive_async_config); TEST(test_slowlane); TEST(test_commit_admission_pool); diff --git a/src/controller/testsuite/test_test_cli.c b/src/controller/testsuite/test_test_cli.c new file mode 100644 index 00000000..c3e3552d --- /dev/null +++ b/src/controller/testsuite/test_test_cli.c @@ -0,0 +1,581 @@ +/* + * test_test_cli.c -- xce-test-cli-ux task 1.7: the reusable black-box/ + * subprocess (pty-driven) test harness for xce_test's own CLI/REPL surface. + * + * Design.md's Testing Strategy ("E2E / Black-box Tests") and several + * component Implementation Notes name THIS file as the vehicle for: + * - `-h`/`--help`/`--version` process-entry checks that must NOT enter + * the REPL (Requirement 4) -- task 1.6 already covers this narrow case + * in test_xcetest_process_entry.c; this file is for everything that + * genuinely needs an interactive session. + * - Tab completion at the prompt (Requirement 7) -- "libedit completion + * isn't practically unit-testable in isolation" (design.md). + * - `help ` content spot-checks across contexts (Requirement 8). + * - A destructive command run non-interactively via piped stdin + * (Requirement 9.4). + * - A REAL SIGINT delivered to the process while a confirmation prompt + * is displayed, proving the corrected sigaction()/SA_RESTART fix + * end-to-end (Requirement 9.9). + * - `source` two-file mutual-cycle and missing-file cases, which need + * real files on disk (Requirement 13.2, 13.5). + * Those scenarios belong to tasks 2.3, 3.5, 4.1, 4.2, 4.3, 4.4 respectively + * (per tasks.md's own `_Depends:_` tags) -- task 1.7 (this file, as first + * built) provides ONLY the shared harness plus one smoke test proving the + * harness itself works end-to-end. Expect this file to GROW as those later + * tasks land their own test functions on top of the harness below, per + * design.md's File Structure Plan naming exactly one new file for this + * whole black-box suite. + * + * --------------------------------------------------------------------- + * Design choice: C + openpty(), not a Python pty.fork() driver script + * --------------------------------------------------------------------- + * Two proven patterns already exist in this codebase for driving a real + * interactive session: + * (a) test_rcfile_integration.sh (this directory) -- a bash integration + * test with an embedded Python `pty.fork()` driver, invoked as a + * standalone script (not through meson's `test()`/Unity). + * (b) src/scheduler/testsuite/test_repl_prompt_context.c -- a Unity C + * test that opens a pty pair with openpty() () and dup2()s + * the slave onto a forked child's stdin/stdout/stderr, proven to + * make readline()/libedit behave exactly as it does for a real + * terminal (that file's header comment documents the empirical + * verification that both ends must be a real tty for the prompt to + * even be written). + * This file follows (b), not (a): design.md's own File Structure Plan and + * its "adds ... their test_*.c counterparts plus test_test_cli.c to the + * appropriate test target(s)" line (Cross-Cutting Concerns) both name + * `test_test_cli.c` as a plain `.c` file meant to sit in meson.build's + * `controller_unit_test_src` list alongside every other `test_*.c` -- + * i.e. a Unity test, not a separately-invoked shell script. Building this + * as C also gives future tasks (2.3, 3.5, 4.1-4.4) tight, synchronous, + * assertion-friendly control (TEST_ASSERT_* directly against captured + * buffers) rather than shelling out to a second interpreter and scraping + * its stdout. The one piece (a) has that (b)/task 1.6 do not is stdout/ + * stderr stream separation -- solved below by putting ONLY stdin+stdout on + * the pty and routing the child's stderr through a separate, ordinary + * pipe (see clitest_spawn()), which is exactly what Requirement 3's + * stdout/stderr-discipline testing needs and what the task's own + * observable-completion line calls out explicitly. + * + * Not a duplicate of task 1.6's test_xcetest_process_entry.c: that file's + * run_and_capture() attaches stdin to /dev/null (deliberately non- + * interactive, so a REPL fallback hangs instead of accepting input) and + * only reads one combined stdout pipe -- sufficient for its narrow + * process-level-flag scope, but unusable for anything that needs to type + * INTO a live session or exercise tab completion (which libedit only + * offers when stdin is a genuine pty, never a plain pipe). This file reuses + * that file's resolve_xce_test_path() (made non-static for exactly this + * reuse -- see that file's own updated header comment) rather than + * duplicating the "find xce_test next to this test binary" logic. + * + * --------------------------------------------------------------------- + * Public harness API (for tasks 2.3, 3.5, 4.1-4.4 to build on) + * --------------------------------------------------------------------- + * clitest_buf_t / clitest_buf_init / clitest_buf_append / clitest_buf_free + * Growable, NUL-terminated accumulation buffers for captured output. + * + * clitest_session_t + * One live xce_test subprocess: pid, the pty master fd (drives the + * child's stdin+stdout), and a plain pipe read fd carrying ONLY the + * child's stderr. + * + * clitest_spawn(sess, path, argv) + * Capability 1: launches `path` with argv (NULL-terminated, argv[0] + * conventionally the program name) with a genuine pty on stdin+stdout + * and a separate pipe on stderr. + * + * clitest_send_line(sess, line) / clitest_send_partial(sess, partial) / + * clitest_send_tab(sess) / clitest_send_raw(sess, buf, n) + * Capabilities 2-3: send a full line (line + '\n', "as if typed then + * Enter"), a partial line with NO trailing newline, a single raw Tab + * keystroke, or an arbitrary raw byte sequence (e.g. a future test + * wanting Ctrl-D/arrow-key escape sequences can use send_raw directly + * without any harness change). send_partial() + send_tab() compose + * for the "partial input, then Tab" tab-completion scenario. + * + * clitest_send_signal(sess, signo) + * Capability 4: delivers a real OS signal (e.g. SIGINT) directly to + * the child process via kill(), independent of pty line-discipline + * signal generation -- reliable regardless of controlling-terminal + * setup (this harness deliberately does not set one up; see + * clitest_spawn()'s comment). + * + * clitest_pump_once(sess, out_stdout, out_stderr, timeout_ms) + * One poll() cycle appending any currently-available bytes from + * EACH stream into its OWN buffer -- this is capability 5's + * mechanism: stdout and stderr are captured, and stay distinguishable, + * as separate streams throughout. + * + * clitest_wait_for_text(sess, out_stdout, out_stderr, needle, in_stderr, + * overall_timeout_ms) + * Repeatedly pumps until `needle` appears in the chosen stream's + * accumulated buffer or the timeout elapses; returns true/false. Lets + * a caller wait for e.g. the "xce>" prompt or a specific completion + * candidate before sending the next input, instead of a fixed sleep. + * + * clitest_wait_for_exit(sess, out_stdout, out_stderr, timeout_ms, + * exit_code) + * Capability 6 + 7: pumps and polls for the child's exit up to + * timeout_ms (continuing to accumulate output meanwhile so nothing is + * lost), then does a couple of short final drain pumps to catch + * anything flushed right at exit. Returns 0 and sets *exit_code + * (WEXITSTATUS, or the negated signal number if the child died via + * signal) on success, -1 on timeout (child is left running -- the + * caller MUST follow up with clitest_kill_and_reap()). This -1 path, + * plus every caller being expected to always end with + * clitest_kill_and_reap(), IS capability 7's safety timeout: a hung + * child can never wedge the suite past this file's own timeouts. + * + * clitest_kill_and_reap(sess) + * SIGKILLs the child (if not already exited) and reaps it. Idempotent + * -- safe to call unconditionally at the end of every test, including + * after a successful clitest_wait_for_exit(). + * + * clitest_close(sess) + * Closes the harness's own fds. Call after kill_and_reap. + * + * All of the above are file-local (`static`) -- design.md's File + * Structure Plan names exactly one new file, `test_test_cli.c`, for this + * entire black-box suite, so later tasks are expected to add their own + * test functions INTO this file (calling these same statics directly) + * rather than reaching into it from a separate translation unit. See + * CONCERNS in this task's status report if a future task instead needs + * cross-file reuse. + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +/* Reused from test_xcetest_process_entry.c (task 1.6) -- see this file's + * own header comment above for why this is an `extern` reuse rather than + * a duplicated copy. */ +extern int resolve_xce_test_path(char* out, size_t outsz); + +/* ------------------------------------------------------------------ */ +/* Growable output-capture buffer */ +/* ------------------------------------------------------------------ */ + +typedef struct { + char* data; + size_t len; + size_t cap; +} clitest_buf_t; + +static void clitest_buf_init(clitest_buf_t* b) +{ + b->cap = 4096; + b->len = 0; + b->data = (char*)malloc(b->cap); + TEST_ASSERT_NOT_NULL(b->data); + b->data[0] = '\0'; +} + +static void clitest_buf_append(clitest_buf_t* b, const char* p, size_t n) +{ + if (n == 0) { + return; + } + while (b->len + n + 1 > b->cap) { + b->cap *= 2; + b->data = (char*)realloc(b->data, b->cap); + TEST_ASSERT_NOT_NULL(b->data); + } + memcpy(b->data + b->len, p, n); + b->len += n; + b->data[b->len] = '\0'; +} + +static void clitest_buf_free(clitest_buf_t* b) +{ + free(b->data); + b->data = NULL; + b->len = b->cap = 0; +} + +/* ------------------------------------------------------------------ */ +/* Session handle + spawn */ +/* ------------------------------------------------------------------ */ + +typedef struct { + pid_t pid; + int master_fd; /* pty master -- drives child's stdin+stdout */ + int err_fd; /* read end of a PLAIN pipe carrying ONLY the + * child's stderr -- kept off the pty entirely so + * stdout/stderr stay genuinely separate streams. */ + bool reaped; +} clitest_session_t; + +/* clitest_spawn - capability 1: forks and execs `path` (argv[0] + * conventionally the program name, NULL-terminated) with: + * - a genuine pty pair on the child's stdin+stdout (openpty(), ), + * so isatty()/g_interactive/libedit's readline() all behave exactly as + * they do for a real interactive terminal session -- the same proven + * technique as src/scheduler/testsuite/test_repl_prompt_context.c's + * run_repl_via_pty(), except that file puts stderr on the pty too + * (fine for its purpose) whereas THIS harness deliberately does not: + * - the child's stderr is redirected to a SEPARATE, ordinary pipe, kept + * entirely off the pty -- this is capability 5 (stdout/stderr as + * genuinely distinguishable streams), and exactly what Requirement 3's + * stdout/stderr-discipline testing needs. + * Kernel echo is disabled on the pty slave BEFORE forking (matching + * test_repl_prompt_context.c's verified-safe technique): this only + * suppresses the line discipline's own re-echo of bytes we write into the + * master, not readline()/libedit's own prompt/redraw output, which stays + * intact. + * No controlling-terminal setup (no setsid()/TIOCSCTTY) is performed -- + * unnecessary here because clitest_send_signal() below delivers signals + * directly via kill(pid, signo) rather than relying on the pty line + * discipline to turn a literal Ctrl-C byte into SIGINT. + * Returns 0 on success (sess->pid/master_fd/err_fd populated) or -1 on + * failure (openpty/pipe/fork failure -- errno is left set by whichever + * call failed). */ +static int clitest_spawn(clitest_session_t* sess, const char* path, + char* const argv[]) +{ + int master_fd; + int slave_fd; + int errpipe[2]; + struct termios tio; + pid_t pid; + + memset(sess, 0, sizeof(*sess)); + sess->master_fd = -1; + sess->err_fd = -1; + + if (openpty(&master_fd, &slave_fd, NULL, NULL, NULL) != 0) { + return -1; + } + if (pipe(errpipe) != 0) { + close(master_fd); + close(slave_fd); + return -1; + } + + if (tcgetattr(slave_fd, &tio) == 0) { + tio.c_lflag &= (tcflag_t)~ECHO; + (void)tcsetattr(slave_fd, TCSANOW, &tio); + } + + pid = fork(); + if (pid < 0) { + close(master_fd); + close(slave_fd); + close(errpipe[0]); + close(errpipe[1]); + return -1; + } + + if (pid == 0) { + /* Child: stdin+stdout -> pty slave, stderr -> pipe write end. */ + close(master_fd); + close(errpipe[0]); + dup2(slave_fd, STDIN_FILENO); + dup2(slave_fd, STDOUT_FILENO); + dup2(errpipe[1], STDERR_FILENO); + if (slave_fd > STDERR_FILENO) { + close(slave_fd); + } + if (errpipe[1] > STDERR_FILENO) { + close(errpipe[1]); + } + + execv(path, argv); + _exit(127); /* only reached if execv() itself failed */ + } + + /* Parent: keep the master end + the pipe's read end only. */ + close(slave_fd); + close(errpipe[1]); + + sess->pid = pid; + sess->master_fd = master_fd; + sess->err_fd = errpipe[0]; + sess->reaped = false; + return 0; +} + +/* ------------------------------------------------------------------ */ +/* Input injection */ +/* ------------------------------------------------------------------ */ + +/* clitest_send_raw - writes `n` bytes verbatim into the pty master, i.e. + * "as if typed at the keyboard". Loops past short writes/EINTR so callers + * never need to. This is the one primitive every other send_* helper below + * is built from, and is itself exposed so a future test needing something + * this file doesn't yet name (Ctrl-D, an arrow-key escape sequence, ...) + * can use it directly without a harness change. */ +static int clitest_send_raw(clitest_session_t* sess, const char* buf, + size_t n) +{ + size_t off = 0; + + while (off < n) { + ssize_t w = write(sess->master_fd, buf + off, n - off); + + if (w < 0) { + if (errno == EINTR) { + continue; + } + return -1; + } + off += (size_t)w; + } + return 0; +} + +static int clitest_send_line(clitest_session_t* sess, const char* line) +{ + if (clitest_send_raw(sess, line, strlen(line)) != 0) { + return -1; + } + return clitest_send_raw(sess, "\n", 1); +} + +static int clitest_send_partial(clitest_session_t* sess, const char* partial) +{ + return clitest_send_raw(sess, partial, strlen(partial)); +} + +static int clitest_send_tab(clitest_session_t* sess) +{ + const char tab = '\t'; + return clitest_send_raw(sess, &tab, 1); +} + +/* clitest_send_signal - capability 4: delivers `signo` (e.g. SIGINT) + * directly to the child process via kill(), independent of pty line- + * discipline signal generation (this harness sets up no controlling + * terminal, so a literal ^C byte would NOT itself generate SIGINT) -- a + * real OS signal, exactly as a future SIGINT-during-a-confirmation-prompt + * test (task 4.2) needs. */ +static int clitest_send_signal(clitest_session_t* sess, int signo) +{ + if (sess->pid <= 0) { + return -1; + } + return kill(sess->pid, signo); +} + +/* ------------------------------------------------------------------ */ +/* Output pumping / waiting */ +/* ------------------------------------------------------------------ */ + +#define CLITEST_POLL_SLICE_MS 100 + +/* clitest_pump_once - one poll() cycle over BOTH the pty master (stdout) + * and the stderr pipe, appending whatever is currently available from + * EACH into its OWN buffer. This is capability 5's actual mechanism: since + * the two streams arrive on two independent fds that are never merged, + * out_stdout and out_stderr stay genuinely distinguishable throughout the + * whole session, not just at the end. + * A read() of size 0 or -1 on either fd (EOF, or on Linux, EIO on the pty + * master once the child's last reference to the slave closes) is treated + * as "nothing [more] available right now", not an error -- callers loop + * this via clitest_wait_for_text()/clitest_wait_for_exit() until their own + * condition or timeout is met. */ +static void clitest_pump_once(clitest_session_t* sess, + clitest_buf_t* out_stdout, + clitest_buf_t* out_stderr, int timeout_ms) +{ + struct pollfd pfds[2]; + int n; + char tmp[4096]; + ssize_t r; + + pfds[0].fd = sess->master_fd; + pfds[0].events = POLLIN; + pfds[0].revents = 0; + pfds[1].fd = sess->err_fd; + pfds[1].events = POLLIN; + pfds[1].revents = 0; + + n = poll(pfds, 2, timeout_ms); + if (n <= 0) { + return; + } + + if (pfds[0].revents & (POLLIN | POLLHUP | POLLERR)) { + r = read(sess->master_fd, tmp, sizeof(tmp)); + if (r > 0) { + clitest_buf_append(out_stdout, tmp, (size_t)r); + } + } + if (pfds[1].revents & (POLLIN | POLLHUP | POLLERR)) { + r = read(sess->err_fd, tmp, sizeof(tmp)); + if (r > 0) { + clitest_buf_append(out_stderr, tmp, (size_t)r); + } + } +} + +static bool clitest_wait_for_text(clitest_session_t* sess, + clitest_buf_t* out_stdout, + clitest_buf_t* out_stderr, + const char* needle, bool in_stderr, + int overall_timeout_ms) +{ + int elapsed = 0; + + while (elapsed < overall_timeout_ms) { + clitest_pump_once(sess, out_stdout, out_stderr, + CLITEST_POLL_SLICE_MS); + elapsed += CLITEST_POLL_SLICE_MS; + if (strstr(in_stderr ? out_stderr->data : out_stdout->data, + needle) != NULL) { + return true; + } + } + return false; +} + +/* clitest_wait_for_exit - capabilities 6+7: polls for the child's exit up + * to timeout_ms, pumping output on every iteration so nothing emitted + * while we wait is lost, then does two short final drain pumps (catching + * output flushed right at exit that the very last regular pump might + * still be racing against). On success returns 0 and sets *exit_code to + * WEXITSTATUS(status), or to -WTERMSIG(status) (negative) if the child + * died via a signal rather than exiting normally -- so a caller can tell + * the two cases apart without inspecting raw wait-status bits itself. + * On timeout returns -1 and leaves the child RUNNING; the caller MUST + * follow up with clitest_kill_and_reap() (capability 7's actual safety + * net -- this function alone only detects the hang, it does not resolve + * it, so every caller is required to always end a session with + * clitest_kill_and_reap() regardless of which path was taken). */ +static int clitest_wait_for_exit(clitest_session_t* sess, + clitest_buf_t* out_stdout, + clitest_buf_t* out_stderr, int timeout_ms, + int* exit_code) +{ + int elapsed = 0; + pid_t w; + int status; + + while (elapsed < timeout_ms) { + clitest_pump_once(sess, out_stdout, out_stderr, + CLITEST_POLL_SLICE_MS); + elapsed += CLITEST_POLL_SLICE_MS; + + w = waitpid(sess->pid, &status, WNOHANG); + if (w == sess->pid) { + sess->reaped = true; + clitest_pump_once(sess, out_stdout, out_stderr, 50); + clitest_pump_once(sess, out_stdout, out_stderr, 50); + if (WIFEXITED(status)) { + *exit_code = WEXITSTATUS(status); + } else { + *exit_code = -WTERMSIG(status); + } + return 0; + } + } + return -1; +} + +/* ------------------------------------------------------------------ */ +/* Teardown */ +/* ------------------------------------------------------------------ */ + +static void clitest_kill_and_reap(clitest_session_t* sess) +{ + int status; + + if (sess->pid <= 0 || sess->reaped) { + return; + } + kill(sess->pid, SIGKILL); + waitpid(sess->pid, &status, 0); + sess->reaped = true; +} + +static void clitest_close(clitest_session_t* sess) +{ + if (sess->master_fd >= 0) { + close(sess->master_fd); + sess->master_fd = -1; + } + if (sess->err_fd >= 0) { + close(sess->err_fd); + sess->err_fd = -1; + } +} + +/* ------------------------------------------------------------------ */ +/* Smoke test (task 1.7's Observable Completion) */ +/* ------------------------------------------------------------------ */ + +/* Launches the real xce_test binary behind the harness, sends a trivial + * `echo` command, and confirms the expected output arrives on stdout + * (not stderr) with exit code 0 after `quit` -- proving the harness + * itself works end-to-end. This is deliberately NOT an exhaustive test of + * xce_test's behavior; that is what tasks 2.3/3.5/4.1-4.4 build later. */ +static void test_smoke_launch_send_echo_capture_stdout_and_exit_code(void) +{ + char xce_test_path[PATH_MAX]; + char* const argv[] = { "xce_test", "--norc", NULL }; + clitest_session_t sess; + clitest_buf_t out; + clitest_buf_t err; + int exit_code = -999; + + TEST_ASSERT_EQUAL_INT_MESSAGE( + 0, resolve_xce_test_path(xce_test_path, sizeof(xce_test_path)), + "must be able to resolve the sibling xce_test binary's path"); + + clitest_buf_init(&out); + clitest_buf_init(&err); + + TEST_ASSERT_EQUAL_INT_MESSAGE( + 0, clitest_spawn(&sess, xce_test_path, argv), + "clitest_spawn must launch xce_test behind a real pty"); + + TEST_ASSERT_TRUE_MESSAGE( + clitest_wait_for_text(&sess, &out, &err, "xce>", false, 5000), + "expected the initial 'xce>' interactive prompt on stdout"); + + TEST_ASSERT_EQUAL_INT( + 0, clitest_send_line(&sess, "echo cli_harness_smoke_marker")); + TEST_ASSERT_TRUE_MESSAGE( + clitest_wait_for_text(&sess, &out, &err, "cli_harness_smoke_marker", + false, 5000), + "echo's output must appear on stdout"); + + TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "quit")); + + TEST_ASSERT_EQUAL_INT_MESSAGE( + 0, clitest_wait_for_exit(&sess, &out, &err, 5000, &exit_code), + "xce_test did not exit within the harness's safety timeout after " + "'quit'"); + TEST_ASSERT_EQUAL_INT_MESSAGE( + 0, exit_code, "xce_test should exit 0 after a clean echo+quit " + "session"); + + TEST_ASSERT_NOT_NULL_MESSAGE( + strstr(out.data, "cli_harness_smoke_marker"), + "the echoed marker must be present in captured stdout"); + TEST_ASSERT_NULL_MESSAGE( + strstr(err.data, "cli_harness_smoke_marker"), + "echo's output must NOT leak onto stderr -- stdout/stderr must be " + "captured as genuinely separate streams"); + + clitest_kill_and_reap(&sess); /* idempotent safety net */ + clitest_close(&sess); + clitest_buf_free(&out); + clitest_buf_free(&err); +} + +void test_test_cli(void) +{ + UnitySetTestFile(__FILE__); + RUN_TEST(test_smoke_launch_send_echo_capture_stdout_and_exit_code); +} diff --git a/src/controller/testsuite/test_xcetest_process_entry.c b/src/controller/testsuite/test_xcetest_process_entry.c index 7ba8540c..66aff495 100644 --- a/src/controller/testsuite/test_xcetest_process_entry.c +++ b/src/controller/testsuite/test_xcetest_process_entry.c @@ -16,6 +16,12 @@ * 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 @@ -38,7 +44,7 @@ * 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. */ -static int resolve_xce_test_path(char* out, size_t outsz) +int resolve_xce_test_path(char* out, size_t outsz) { char self[PATH_MAX]; ssize_t n;