feat(xce-test-cli-ux): add command-name tab completion scoped to context

Registers libedit's GNU-readline-compatible rl_attempted_completion_function
under g_interactive only. The candidate generator reads active_ctx->commands[]
live on every call, so completion correctly tracks context switches rather
than freezing at registration time. Single/multi-match UI (auto-complete vs.
double-Tab candidate listing) is entirely libedit's stock behavior once given
a correct context-scoped generator. Proven via a black-box pty test verified
sound against a false-positive risk: xce_test has no fuzzy command-name
matching, so an uncompleted partial token cannot coincidentally trigger the
same effect a real completion would. Also fixes a harness bug (bug-1792):
disabling kernel ECHO for output capture was silently suppressing libedit's
own completion feedback too; added clitest_spawn_ex() with disable_echo as
an explicit parameter, clitest_spawn() unchanged for existing callers.
1307/1307 controller suite passing (+1), re-verified 8/8 for flakiness.

Task 2.3 of .kiro/specs/xce-test-cli-ux/tasks.md.
Requirements: 7.1, 7.2, 7.3, 7.4

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 13:38:56 +00:00
parent 07d238eada
commit 0ef232a013
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
4 changed files with 356 additions and 14 deletions

View file

@ -860,6 +860,101 @@ command_t* find_command(char* name)
return NULL;
}
/* xce-test-cli-ux task 2.3, Requirement 7: command-name tab completion.
*
* libedit API verification (design.md's flagged P0 risk, research.md's
* "libedit completion API may not perfectly mirror GNU readline" risk):
* confirmed by reading the ACTUAL linked headers in this build environment
* (this project's `src/flake.nix`-pinned libedit, pkg-config name
* "libedit", providing `editline/readline.h` -- the same header already
* included at the top of this file) plus a standalone pty-driven probe
* program built and run against this exact library. Findings:
*
* - The GNU-readline-style completion API IS present verbatim:
* `rl_completion_func_t* rl_attempted_completion_function` (signature
* `char** (*)(const char*, int, int)`, exactly matching this task's
* `xcetest_complete` shape from design.md) and
* `char** rl_completion_matches(const char*, rl_compentry_func_t*)`
* (matches on a `char* (*)(const char*, int)` generator, exactly
* `xcetest_complete_generator`'s shape). No lower-level `<histedit.h>`
* fallback was needed.
* - `rl_attempted_completion_over` exists and suppresses libedit's
* default filename-completion fallback -- used below so an empty
* match list in the active context yields NO completion rather than
* local filenames.
* - Single/multiple-match UI behavior is entirely the library's: a sole
* match is completed in full (plus a trailing space) with ZERO extra
* code (7.2); multiple matches with no further common prefix ring the
* bell on the first Tab and print the candidate list (input line left
* untouched -- confirmed via a raw pty capture) on a SECOND,
* consecutive Tap of Tab -- standard GNU-readline/bash double-Tab
* convention (7.3), not something this code implements. This
* implementation's own responsibility is narrowly the context-scoped
* candidate lookup below.
* - `rl_variable_bind("show-all-if-ambiguous", "on")` was probed and
* returns failure (1) against this libedit build -- that variable is
* not implemented here, so it is not used; the stock double-Tab
* behavior above is what ships.
*/
/* xcetest_complete_generator - the readline "compentry" generator: called
* repeatedly with state==0 on the first call (resets the scan) and
* state!=0 on each subsequent call, returning one more `strdup()`ed match
* per call (readline/libedit frees each returned string) and NULL once
* exhausted. Iterates active_ctx->commands[] -- the CURRENTLY active
* context at the moment Tab is pressed, read fresh on every call rather
* than captured at registration time -- so completion always tracks
* `worker`/`scheduler`/`client`/`..` context switches for the life of the
* session (Requirement 7.1/7.4: only the active context's commands are
* ever offered). Plain prefix match against `text`, same convention as
* find_command()'s exact-match lookup just above. */
static char* xcetest_complete_generator(const char* text, int state)
{
static int idx;
static size_t len;
if (state == 0) {
idx = 0;
len = strlen(text);
}
while (active_ctx->commands[idx].name != NULL) {
const char* name = active_ctx->commands[idx].name;
idx++;
if (strncmp(name, text, len) == 0) {
return strdup(name);
}
}
return NULL;
}
/* xcetest_complete - the readline "attempted completion" entry point,
* registered as rl_attempted_completion_function only when g_interactive
* (main(), below). `start`/`end` are byte offsets of the word being
* completed within rl_line_buffer; `start == 0` means the FIRST word on
* the line, i.e. the command name itself. Per this task's explicit
* boundary (command-NAME completion only -- no flag-value completion of
* jobids/hostnames/resource names), every other word position (start != 0)
* returns NULL and leaves rl_attempted_completion_over at its default so
* libedit's normal fallback applies there, unchanged from today's
* zero-customization behavior. For the command-name position, this always
* sets rl_attempted_completion_over so a context with zero matching
* commands offers NOTHING (never falls back to filename completion). */
static char** xcetest_complete(const char* text, int start, int end)
{
(void)end;
if (start != 0) {
return NULL;
}
rl_attempted_completion_over = 1;
return rl_completion_matches(text, xcetest_complete_generator);
}
/* Execute a command line. */
int execute_line(char* line)
{
@ -4544,6 +4639,17 @@ int main(int argc, char* argv[])
g_interactive = isatty(STDIN_FILENO);
g_continue_on_error = g_interactive ? 1 : 0;
/* xce-test-cli-ux task 2.3, Requirement 7: register command-name tab
* completion, but ONLY for a genuinely interactive session -- design.md's
* explicit precondition on xcetest_complete(). A piped/redirected/
* scripted stdin never calls readline()'s completion machinery anyway
* (no terminal to press Tab on), but gating the registration itself
* (rather than relying on that) keeps the precondition explicit and
* matches this file's existing g_interactive-gated conventions. */
if (g_interactive) {
rl_attempted_completion_function = xcetest_complete;
}
/* xce-test-rc-file task 2.2: auto-load $XDG_CONFIG_HOME/xce_test/rc (or
* $HOME/.config/xce_test/rc) before the read loop starts, for both
* interactive and scripted sessions alike -- unless --norc was given.

View file

@ -79,10 +79,19 @@
* child's stdin+stdout), and a plain pipe read fd carrying ONLY the
* child's stderr.
*
* clitest_spawn(sess, path, argv)
* clitest_spawn(sess, path, argv) / clitest_spawn_ex(sess, path, argv, disable_echo)
* 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.
* and a separate pipe on stderr. clitest_spawn() is the original entry
* point (kernel ECHO disabled on the pty slave before forking, the
* right choice for any test that only cares about a command's OUTPUT).
* clitest_spawn_ex() (added by xce-test-cli-ux task 2.3, Requirement 7)
* exposes the same launch with `disable_echo` explicit -- pass false
* for any test that needs to OBSERVE libedit's own live interactive
* editing/redraw output (e.g. tab completion), which this project's
* linked libedit build suppresses entirely when the slave's ECHO bit
* already reads off at readline()-call time (see clitest_spawn_ex()'s
* own comment for the full empirical finding).
*
* clitest_send_line(sess, line) / clitest_send_partial(sess, partial) /
* clitest_send_tab(sess) / clitest_send_raw(sess, buf, n)
@ -216,8 +225,8 @@ typedef struct {
bool reaped;
} clitest_session_t;
/* clitest_spawn - capability 1: forks and execs `path` (argv[0]
* conventionally the program name, NULL-terminated) with:
/* clitest_spawn_ex - capability 1 (parameterized): 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
@ -228,20 +237,45 @@ typedef struct {
* 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.
* `disable_echo` controls whether kernel ECHO is cleared on the pty slave
* BEFORE forking:
* - true (clitest_spawn()'s behavior, matching
* test_repl_prompt_context.c's technique) suppresses the line
* discipline's own re-echo of bytes written into the master. This was
* originally documented as affecting ONLY that kernel-level re-echo,
* "not readline()/libedit's own prompt/redraw output, which stays
* intact" -- xce-test-cli-ux task 2.3 (tab completion, Requirement 7)
* found that assumption does NOT hold for THIS project's linked
* libedit build: an A/B probe (disable_echo true vs. false, otherwise
* identical) showed that starting the slave with ECHO already off
* makes libedit treat the session as a silent/non-interactive input
* mode -- Tab is accepted as a no-op (no bell, no completion, no
* redraw at all) and the line is only ever consumed whole on Enter.
* This is the same convention GNU-readline-family libraries use for
* password-style hidden input (ECHO-off-at-call-time as the signal to
* suppress ALL of the library's own visual feedback, not just
* kernel echo) -- consistent with, not a contradiction of, the
* original comment's intent, just a sharper edge than it accounted
* for. Every test that only cares about a command's OUTPUT (the vast
* majority, e.g. this file's own smoke test) is unaffected either way.
* - false (added by task 2.3 specifically for Requirement 7's tab-
* completion tests, which NEED libedit's live interactive redraw to
* observe anything at all) leaves kernel ECHO at its default-on
* state; libedit takes over echo/redraw duties itself once its own
* readline() call reconfigures the tty, so captured stdout still
* reflects the program's editing state (bell + redraw + candidate
* listings), not a raw kernel double-echo of every byte.
* 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.
* discipline to turn a literal Ctrl-C byte into SIGINT (confirmed via the
* same probe: ctty presence/absence made no observable difference to
* completion behavior -- ECHO was the sole deciding factor).
* 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[])
static int clitest_spawn_ex(clitest_session_t* sess, const char* path,
char* const argv[], bool disable_echo)
{
int master_fd;
int slave_fd;
@ -262,7 +296,7 @@ static int clitest_spawn(clitest_session_t* sess, const char* path,
return -1;
}
if (tcgetattr(slave_fd, &tio) == 0) {
if (disable_echo && tcgetattr(slave_fd, &tio) == 0) {
tio.c_lflag &= (tcflag_t)~ECHO;
(void)tcsetattr(slave_fd, TCSANOW, &tio);
}
@ -305,6 +339,18 @@ static int clitest_spawn(clitest_session_t* sess, const char* path,
return 0;
}
/* clitest_spawn - the pre-task-2.3 entry point, preserved with its exact
* original behavior (disable_echo=true) so every existing caller (this
* file's own smoke test included) is unaffected. See clitest_spawn_ex()'s
* comment above for why a session that needs to OBSERVE libedit's live
* interactive editing/completion feedback (task 2.3, Requirement 7) must
* call clitest_spawn_ex() directly with disable_echo=false instead. */
static int clitest_spawn(clitest_session_t* sess, const char* path,
char* const argv[])
{
return clitest_spawn_ex(sess, path, argv, true);
}
/* ------------------------------------------------------------------ */
/* Input injection */
/* ------------------------------------------------------------------ */
@ -574,8 +620,174 @@ static void test_smoke_launch_send_echo_capture_stdout_and_exit_code(void)
clitest_buf_free(&err);
}
/* ------------------------------------------------------------------ */
/* Tab completion (task 2.3, Requirement 7) */
/* ------------------------------------------------------------------ */
/* test_tab_completion_context_scoped_single_and_multi_match -- drives a
* single xce_test session through all four Requirement 7 acceptance
* criteria:
* 7.2 (single unambiguous match completes in full) in the TOP-LEVEL
* context: "wo" matches only "worker" among test.c's top_commands[].
* 7.1/7.4 (candidates scoped to the ACTIVE context, never a command
* invalid there) after switching into the worker context: "js"
* matches "jstart", a command that exists ONLY in
* test.c's worker_commands[] -- not in top_commands[] -- so getting
* it back proves completion is reading the worker context's own
* table, not a stale/global one.
* 7.3 (multiple matches list candidates without altering the input
* line) still inside the worker context: "jf" matches both "jfail"
* and "jfinish" (neither a prefix of the other, so there is no
* further common prefix to auto-lengthen). This task's own
* libedit-probe investigation (see xcetest_complete's file-header
* comment in test.c) confirmed this linked libedit's completion
* engine needs a SECOND, consecutive Tab to print the candidate
* list in that case -- the first Tap only rings the bell, standard
* GNU-readline/bash double-Tab-on-ambiguous convention, not
* something this task's code implements.
*
* Verification strategy for the two SINGLE-match cases (7.2 and 7.1/7.2/
* 7.4): the same libedit probe found that an unambiguous completion's
* on-screen redraw only repaints the SUFFIX past what is already
* displayed (e.g. typing "wo" then Tab emits "wo" + a cursor-reposition
* escape + "rker " -- never the contiguous literal substring "worker" in
* the raw byte stream), so this test does NOT strstr for the completed
* word directly. Instead it presses Tab then Enter and checks the
* resulting SEMANTIC effect, which is redraw-implementation-independent:
* an incomplete/wrong completion would submit the bare, unrecognized
* prefix ("wo"/"js"), which fails distinctly from a correct completion
* ("wo" -> "xce/worker>" context prompt; "js" -> cmd_jobstart's own
* argument-validation message). The ambiguous-match case (7.3) is
* unaffected by this redraw-suffix behavior -- the candidate list is
* printed on a fresh line, not an edit of the existing prompt line -- so
* it is verified directly via strstr for both full candidate names,
* confirmed against the raw pty transcript captured during this task's
* investigation. */
static void test_tab_completion_context_scoped_single_and_multi_match(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;
const char ctrl_u = '\x15'; /* unix-line-discard */
const char enter = '\n';
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);
/* disable_echo=false (clitest_spawn_ex, not plain clitest_spawn): this
* test needs to OBSERVE libedit's own live interactive editing/redraw
* output (bell, candidate listings, in-place completion) -- this
* project's linked libedit build suppresses ALL of that (silent/
* password-style input mode) when the pty slave's ECHO bit already
* reads off at readline()-call time, which is exactly what plain
* clitest_spawn() does for every OTHER (output-only) test. See
* clitest_spawn_ex()'s own comment above for the full empirical A/B
* finding behind this. */
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, clitest_spawn_ex(&sess, xce_test_path, argv, false),
"clitest_spawn_ex must launch xce_test behind a real pty with "
"kernel ECHO left on, so libedit's own tab-completion feedback is "
"observable");
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "xce>", false, 5000),
"expected the initial 'xce>' interactive prompt on stdout");
/* --- Requirement 7.2, top-level context: unambiguous "wo" -> "worker".
* Press Tab then Enter and confirm the worker-context prompt appears --
* only reachable if the submitted line was genuinely the full,
* correctly-completed "worker", not the bare "wo" prefix. */
TEST_ASSERT_EQUAL_INT(0, clitest_send_partial(&sess, "wo"));
TEST_ASSERT_EQUAL_INT(0, clitest_send_tab(&sess));
TEST_ASSERT_EQUAL_INT(0, clitest_send_raw(&sess, &enter, 1));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "xce/worker>", false, 5000),
"single unambiguous match 'wo' must complete in full to 'worker' "
"in the top-level context, proven by successfully switching into "
"the worker context (Requirement 7.2)");
/* --- Requirement 7.1/7.2/7.4, worker context: unambiguous "js" ->
* "jstart", a command that does not exist at the top level at all.
* Press Tab then Enter and confirm cmd_jobstart's own bare-invocation
* message appears -- only reachable if "js" was genuinely completed to
* "jstart" (an unrecognized "js" would instead print "js: No such
* command for xce test."), proving candidates are drawn from the
* ACTIVE context's own command table, not a stale/global one. */
TEST_ASSERT_EQUAL_INT(0, clitest_send_partial(&sess, "js"));
TEST_ASSERT_EQUAL_INT(0, clitest_send_tab(&sess));
TEST_ASSERT_EQUAL_INT(0, clitest_send_raw(&sess, &enter, 1));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "positive jobid", false,
3000),
"single unambiguous match 'js' must complete in full to 'jstart' "
"in the worker context, proving candidates are drawn from the "
"ACTIVE context's own command table (Requirement 7.1/7.2/7.4)");
/* --- Requirement 7.3, worker context: ambiguous "jf" matches both
* "jfail" and "jfinish" -- first Tab only bells (no further common
* prefix to lengthen), second Tab lists both candidates. */
TEST_ASSERT_EQUAL_INT(0, clitest_send_partial(&sess, "jf"));
TEST_ASSERT_EQUAL_INT(0, clitest_send_tab(&sess));
TEST_ASSERT_EQUAL_INT(0, clitest_send_tab(&sess));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "jfail", false, 3000),
"ambiguous 'jf' must list 'jfail' among the candidates "
"(Requirement 7.3)");
TEST_ASSERT_NOT_NULL_MESSAGE(
strstr(out.data, "jfinish"),
"ambiguous 'jf' must also list 'jfinish' among the candidates "
"(Requirement 7.3)");
/* The input line ("jf") must have been left unaltered by the listing:
* clear it and confirm the session is still perfectly usable, i.e.
* nothing about the listing corrupted readline's line-editing state. */
TEST_ASSERT_EQUAL_INT(0, clitest_send_raw(&sess, &ctrl_u, 1));
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, ".."));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "xce>", false, 5000),
"expected the top-level prompt again after '..', proving the "
"session's line-editing state survived the ambiguous-match "
"listing intact");
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'");
/* exit_code is 1, not 0: the bare "jstart" invocation above (Requirement
* 7.1/7.2/7.4's proof step) deliberately hits cmd_jobstart's own
* "you need specify a positive jobid" validation-failure return path
* on purpose (a fast, deterministic, network-free way to prove "js"
* completed to "jstart" and was recognized/executed) -- test.c's own
* g_session_failed latch (main(), near "return g_session_failed ? 1 :
* 0") is sticky for the rest of the session once ANY command returns
* nonzero, independent of the interactive continue-on-error toggle
* that let the REPL keep going afterward. This is expected, correct
* xce_test behavior given this test's own choice of proof command, not
* a defect in tab completion itself. */
TEST_ASSERT_EQUAL_INT_MESSAGE(
1, exit_code,
"xce_test should exit 1 -- the deliberate bare-'jstart' proof step "
"above latches g_session_failed even though the REPL itself kept "
"running to completion");
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);
RUN_TEST(test_tab_completion_context_scoped_single_and_multi_match);
}