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:
parent
b70228c558
commit
4d1f247674
5 changed files with 593 additions and 2 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
581
src/controller/testsuite/test_test_cli.c
Normal file
581
src/controller/testsuite/test_test_cli.c
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue