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
This commit is contained in:
Rongsong Shen 2026-09-09 11:06:31 +00:00
parent b70228c558
commit 4d1f247674
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
5 changed files with 593 additions and 2 deletions

View file

@ -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_<module>(void);` + `TEST(test_<module>);` 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)

View file

@ -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',

View file

@ -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);

View file

@ -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 <cmd>` 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() (<pty.h>) 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 <errno.h>
#include <limits.h>
#include <poll.h>
#include <pty.h>
#include <signal.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <termios.h>
#include <unistd.h>
#include <unity/unity.h>
/* 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(), <pty.h>),
* 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);
}

View file

@ -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 <fcntl.h>
@ -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;