From c709eba81380602b30740b9c41ef30b57f23bdfe Mon Sep 17 00:00:00 2001 From: Rongsong Shen Date: Wed, 9 Sep 2026 14:25:37 +0000 Subject: [PATCH] feat(xce-test-cli-ux): add in-session source command with cycle detection cmd_source reuses rcfile_load() unmodified for line execution, adding an explicit missing-file check (bypassing rcfile_load's silent-missing policy, which is correct for its one existing startup-rc-file caller but wrong for an explicit user command) and a realpath()-canonicalized in-flight path stack that rejects direct or transitive cycles instead of hanging. Registered in all four command contexts. Translates rcfile_load's raw positive "aborted" return to -1 so an enclosing source line correctly sees a transitively-aborted nested source as failure, not success (preprocess_and_run only treats negative returns as failure). Proven via real fixture files driven through the black-box pty harness: a genuine two-file mutual cycle, a missing path, and ordered execution across a non-cyclic nested source. 1310/1310 controller suite passing (+3). Task 2.4 of .kiro/specs/xce-test-cli-ux/tasks.md. Requirements: 13.1, 13.2, 13.3, 13.4, 13.5, 13.6 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019jaMf9QEwpugH2W9KAuc7f --- .kiro/specs/xce-test-cli-ux/tasks.md | 3 +- src/controller/testsuite/test.c | 173 ++++++++++++++ src/controller/testsuite/test_test_cli.c | 292 +++++++++++++++++++++++ 3 files changed, 467 insertions(+), 1 deletion(-) diff --git a/.kiro/specs/xce-test-cli-ux/tasks.md b/.kiro/specs/xce-test-cli-ux/tasks.md index a99e3f6c..a1225910 100644 --- a/.kiro/specs/xce-test-cli-ux/tasks.md +++ b/.kiro/specs/xce-test-cli-ux/tasks.md @@ -74,7 +74,7 @@ - _Boundary: Tab Completion_ - _Depends: 1.7_ -- [ ] 2.4 (P) Build the in-session source command with missing-file and cycle detection +- [x] 2.4 (P) Build the in-session source command with missing-file and cycle detection - Implement a command that reads and executes another file's lines in order, reusing the existing rc-file loading primitive, reporting an explicit error (rather than silently doing nothing) when the file does not exist or cannot be read - Add cycle detection using a canonicalized in-flight path stack so a file that sources itself, directly or transitively, is rejected with an error instead of hanging - Ensure a failing line inside the sourced file follows the session's current abort-on-failure/continue-on-error setting, and that nested sourcing (one file sourcing another, non-cyclically) composes correctly @@ -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) +- `cmd_source` translates `rcfile_load`'s raw positive "aborted" return to `-1` rather than forwarding it verbatim, because `preprocess_and_run` only treats a *negative* return as failure (`if (rc < 0)`) — forwarding the raw positive value would let an enclosing `source` line silently read a transitively-aborted nested source as success. No test yet exercises this specific nested-abort-propagation case (all 3 required scenarios pass either way) — worth a dedicated test in Validation phase (4.3 or nearby). (task 2.4) - libedit (this project's pinned NetBSD libedit 3.1) provides GNU-readline-compatible `rl_attempted_completion_function`/`rl_completion_matches` verbatim — confirmed against the actual linked header, not assumed. An ambiguous match needs a SECOND consecutive Tab to list candidates (standard readline/bash double-Tab convention) — later black-box tests using Tab must account for this. Also: `test_test_cli.c`'s `clitest_spawn()` disabling kernel ECHO also silently suppressed libedit's own completion feedback in this libedit build (bug-1792) — use the new `clitest_spawn_ex(sess, path, argv, disable_echo)` with `disable_echo=false` for any future test that needs to observe libedit UI behavior (completion, etc.), not just raw command output. (task 2.3) - `listfmt_json.c`'s tests only exercise power-of-two double values (Unity's `TEST_ASSERT_EQUAL_DOUBLE` is disabled project-wide, matching `test_host_runusage.c`/`test_e2e_capnp.c`); cJSON's real print path uses shortest-round-trippable-double formatting so fractional values are fine, but task 3.4 should add a real fractional-value (e.g. a resource value like 12.34) round-trip test once actual command fields are wired in, since nothing here proves that end-to-end yet. (task 2.2) - `confirm_destructive()`'s EINTR-as-decline (9.9) works because it's a single, non-looping `fgets()` call — a naive "robust I/O" retry-on-EINTR wrapper would silently defeat this requirement. `g_interactive` is exposed via a new `xcetest_is_interactive()` accessor (declared in `confirm.h`, mirroring `xcetest_interrupted()`'s pattern) for task 3.3 to reuse. Task 3.3 must short-circuit around `confirm_destructive()` entirely (not pass a parameter into it) when a command's own `--yes` flag was given — the function has no bypass-flag parameter by design. (task 2.1) diff --git a/src/controller/testsuite/test.c b/src/controller/testsuite/test.c index b1eb18aa..912444f4 100644 --- a/src/controller/testsuite/test.c +++ b/src/controller/testsuite/test.c @@ -2,6 +2,8 @@ #include #include #include +#include /* PATH_MAX -- cmd_source's realpath() canonicalization + * buffer (xce-test-cli-ux task 2.4) */ #include #include #include @@ -144,6 +146,7 @@ extern int cmd_walcompare(int, char*[]); extern int cmd_snapverify(int, char*[]); extern int cmd_snapdiff(int, char*[]); extern int cmd_configcompare(int, char*[]); +extern int cmd_source(int, char*[]); static int worker_house_keeping(void*); /* Session verdict / loop state (xce-test-e2e-primitives task 1.1). @@ -287,6 +290,21 @@ static const char USAGE_CONFIGCOMPARE[] = " member is unreadable or divergent.\n" "example: configcompare /var/xce/ctrl-1 /var/xce/ctrl-2 /var/xce/ctrl-3\n"; +static const char USAGE_SOURCE[] = + "source \n" + " path to a file containing xce_test commands, one per line\n" + " (required)\n" + " Reads and executes each line of in order, exactly as if it\n" + " had been typed at the current prompt -- including a nested `source`\n" + " command, so one script file can compose others. A file that does\n" + " not exist or cannot be read is rejected with an explicit error and\n" + " no line of it is executed; a file that would source itself,\n" + " directly or transitively, is rejected as a cycle instead of\n" + " hanging. A failing line inside the file follows the session's\n" + " current -e/+e/continue-on-error setting (see `help set`), exactly\n" + " as it does for lines typed at the top-level prompt.\n" + "example: source /path/to/script.xt\n"; + static const char USAGE_WORKER[] = "worker\n" " (no arguments)\n" @@ -567,6 +585,7 @@ static command_t top_commands[] = { USAGE_SNAPDIFF }, { "configcompare", cmd_configcompare, "Compare active config hash across members", USAGE_CONFIGCOMPARE }, + { "source", cmd_source, "Run commands from a file", USAGE_SOURCE }, { "quit", cmd_quit, "Quit xce test", USAGE_QUIT }, { (char*)NULL, (cmd_func_t)NULL, (char*)NULL, (const char*)NULL } }; @@ -586,6 +605,7 @@ static command_t worker_commands[] = { { "jfail", cmd_jobfail, "tell controller jobfail", USAGE_JOBFAIL }, { "resupdate", cmd_resupdate, "update res vals", USAGE_RESUPDATE }, { "echo", cmd_echo, "Print a message", USAGE_ECHO }, + { "source", cmd_source, "Run commands from a file", USAGE_SOURCE }, { (char*)NULL, (cmd_func_t)NULL, (char*)NULL, (const char*)NULL } }; @@ -628,6 +648,7 @@ static command_t client_commands[] = { { "taskrun", cmd_runtasks, "run tasks", USAGE_TASKRUN }, { "help", cmd_help, "Display this text", USAGE_HELP }, { "echo", cmd_echo, "Print a message", USAGE_ECHO }, + { "source", cmd_source, "Run commands from a file", USAGE_SOURCE }, { "quit", cmd_quit, "Quit xce test", USAGE_QUIT }, { (char*)NULL, (cmd_func_t)NULL, (char*)NULL, (const char*)NULL } }; @@ -645,6 +666,7 @@ static command_t scheduler_commands[] = { { "getconf", cmd_getconf, "Get conf of cluster", USAGE_GETCONF }, { "setpend", cmd_setpend, "set job pend reason", USAGE_SETPEND }, { "echo", cmd_echo, "Print a message", USAGE_ECHO }, + { "source", cmd_source, "Run commands from a file", USAGE_SOURCE }, { (char*)NULL, (cmd_func_t)NULL, (char*)NULL, (const char*)NULL } }; @@ -1418,6 +1440,157 @@ int cmd_configcompare(int argc, char* argv[]) return (rc == 0) ? 0 : -1; } +/* -------------------------------------------------------------------- + * source -- in-session script sourcing (xce-test-cli-ux task 2.4, + * Requirements 13.1-13.6; 13.7's TTY-condition clarification relating + * `source` to the Confirmation Gate is a separate, later validation task + * and is NOT implemented here). + * + * Reuses rcfile_load() (rcfile.c/h, xce-test-rc-file spec) UNCHANGED as the + * actual line-reading/dispatch loop, passing preprocess_and_run as its + * runner -- the same per-line command dispatcher the interactive REPL loop + * and the startup rc-file loader already use. rcfile.c itself is not + * touched: its own missing-file-is-silent-success policy is correct for + * its one other caller (the startup rc file) but wrong for an explicit + * user command, so cmd_source layers its own missing-file check (access()) + * and cycle detection (a small in-flight canonical-path stack) on top, + * entirely in this file -- per research.md's "source built directly on + * rcfile_load" decision. + * + * Cycle detection: source_push_path()/source_pop_path() maintain a static + * LIFO stack of realpath()-canonicalized in-flight source paths. + * source_push_path() scans the stack for the same canonical path already + * in flight (a real cycle -- including through symlinks or a differently- + * spelled relative path, since realpath() runs before the scan) and + * returns -ELOOP without pushing if found; otherwise it pushes and returns + * 0. cmd_source calls source_pop_path() on EVERY exit path once the push + * succeeded (success, or rcfile_load returning nonzero because a line + * inside the file failed under -e/abort-on-failure semantics) -- this is + * the "path stack popped exactly once per successful push, on every exit + * path" invariant design.md requires. When source_push_path() itself + * fails (cycle detected), nothing was pushed, so cmd_source returns + * immediately WITHOUT calling source_pop_path() -- there is nothing to + * pop. + * + * Abort/continue-on-error composition (Req 13.3): a failing line inside + * the sourced file makes preprocess_and_run (the runner) return 1 once + * g_continue_on_error is false, which is exactly rcfile_load's own + * "stop on nonzero runner return" mechanism -- no extra code is needed + * here for that part; it falls out of reusing rcfile_load() unchanged. + * cmd_source's own return value then translates rcfile_load's result + * (0 == every line ran without requesting an abort) into this codebase's + * negative-errno failure convention (nonzero -> a generic -1 failure, + * mirroring cmd_configcompare's/cmd_waldump's own "rc == 0 ? 0 : -1" + * pattern) rather than forwarding rcfile_load's raw positive "stop" signal + * verbatim: forwarding it unchanged would be silently treated as SUCCESS + * by an enclosing preprocess_and_run call (only rc<0 counts as failure + * there), which would incorrectly let an enclosing script's later lines + * keep running past a `source` that itself aborted under -e, and would + * break that composition for nested sourcing (source A containing + * "source B", where B aborts). Translating to a negative value here makes + * a failed `source` compose correctly at every nesting depth, exactly + * like any other failing command. + * + * Nested sourcing (Req 13.4): "source" is registered in every command + * table (see the four command_t[] tables below); a line inside a sourced + * file that itself reads "source " is dispatched by + * preprocess_and_run/execute_line exactly like any other line, so nested + * sourcing falls out of dispatch being generic. The path stack is a + * single process-wide stack (not scoped per active context), so a cycle + * is caught regardless of which context each nesting level was sourced + * from. */ +#define SOURCE_MAX_DEPTH 64 + +static char* g_source_stack[SOURCE_MAX_DEPTH]; +static int g_source_depth = 0; + +/* Returns 0 and pushes `canonical_path` if it is not already in flight, or + * -ELOOP without pushing if it is (a cycle -- the same canonicalized path + * sourcing itself, directly or transitively). Returns -ENOMEM if the stack + * is exhausted (SOURCE_MAX_DEPTH levels of DISTINCT, non-cyclic nested + * sourcing) or a heap copy of the path could not be made -- either way, + * nothing is pushed, so the caller must not call source_pop_path(). */ +static int source_push_path(const char* canonical_path) +{ + int i; + + for (i = 0; i < g_source_depth; i++) { + if (strcmp(g_source_stack[i], canonical_path) == 0) { + return -ELOOP; + } + } + + if (g_source_depth >= SOURCE_MAX_DEPTH) { + return -ENOMEM; + } + + g_source_stack[g_source_depth] = strdup(canonical_path); + if (g_source_stack[g_source_depth] == NULL) { + return -ENOMEM; + } + + g_source_depth++; + return 0; +} + +/* Pops the most recently pushed path (LIFO). A no-op if the stack is + * already empty (defensive; every real call site only calls this after a + * source_push_path() call that itself returned 0). */ +static void source_pop_path(void) +{ + if (g_source_depth <= 0) { + return; + } + + g_source_depth--; + free(g_source_stack[g_source_depth]); + g_source_stack[g_source_depth] = NULL; +} + +/* source -- reads and executes each line of as if it had + * been typed at the current prompt, in order (Req 13.1). A missing or + * unreadable file is rejected with an explicit stderr error BEFORE + * rcfile_load() is ever called, bypassing its silent-missing-file + * behavior entirely (Req 13.2) -- rcfile.c itself is unmodified. */ +int cmd_source(int argc, char* argv[]) +{ + char canonical[PATH_MAX]; + int rc; + + if (argc != 2) { + fprintf(stderr, "usage: source \n"); + return -EINVAL; + } + + if (access(argv[1], R_OK) != 0) { + fprintf(stderr, "source: %s: %s\n", argv[1], strerror(errno)); + return -errno; + } + + if (realpath(argv[1], canonical) == NULL) { + fprintf(stderr, "source: %s: %s\n", argv[1], strerror(errno)); + return -errno; + } + + rc = source_push_path(canonical); + if (rc == -ELOOP) { + fprintf(stderr, + "source: %s: cycle detected (file sources itself, directly " + "or transitively)\n", + canonical); + return rc; + } else if (rc != 0) { + fprintf(stderr, "source: %s: %s\n", canonical, strerror(-rc)); + return rc; + } + + rc = rcfile_load(argv[1], preprocess_and_run); + + source_pop_path(); /* popped on EVERY exit path past the push above */ + + return (rc == 0) ? 0 : -1; +} + int cmd_up(int argc, char* argv[]) { if (active_ctx->parent != NULL) { diff --git a/src/controller/testsuite/test_test_cli.c b/src/controller/testsuite/test_test_cli.c index d86e6dc5..73bb5b66 100644 --- a/src/controller/testsuite/test_test_cli.c +++ b/src/controller/testsuite/test_test_cli.c @@ -785,9 +785,301 @@ static void test_tab_completion_context_scoped_single_and_multi_match(void) clitest_buf_free(&err); } +/* ------------------------------------------------------------------ */ +/* source (task 2.4, Requirements 13.1-13.6) */ +/* ------------------------------------------------------------------ */ + +/* + * cmd_source lives in test.c itself, which -- like every earlier + * xce_test-CLI-surface task -- is NOT linked into controller_unit_test + * (it pulls in libedit/cJSON/libxce/Cap'n Proto/OpenSSL and defines its + * own main()). The black-box pty harness above (task 1.7) is therefore + * the right vehicle here too: these tests spawn the REAL xce_test binary, + * write REAL fixture files to disk, and send `source ` as a real + * REPL command, exactly as the task's own guidance anticipated. + * + * Fixture helpers below mirror test_rcfile.c's fresh_dir()/cleanup_dir()/ + * write_file() convention (a per-test mkdtemp() dir, removed afterward) + * so these tests don't leak fixture files into the shared /tmp this + * sandbox already tracks (bug-924). + */ + +static char clitest_fixture_dir[256]; + +static void clitest_fixture_fresh_dir(void) +{ + strcpy(clitest_fixture_dir, "/tmp/xce_source_test_XXXXXX"); + TEST_ASSERT_NOT_NULL(mkdtemp(clitest_fixture_dir)); +} + +static void clitest_fixture_cleanup_dir(void) +{ + char cmd[512]; + snprintf(cmd, sizeof(cmd), "rm -rf %s", clitest_fixture_dir); + system(cmd); +} + +static void clitest_fixture_write_file(const char* path, const char* content) +{ + FILE* f = fopen(path, "w"); + TEST_ASSERT_NOT_NULL(f); + TEST_ASSERT_EQUAL_INT((int)strlen(content), + (int)fwrite(content, 1, strlen(content), f)); + fclose(f); +} + +/* Requirement 13.5: a two-file MUTUAL cycle (A sources B, B sources A) + * must be rejected with an explicit error -- never hang or exhaust + * resources. Also proves the session survives the rejection (still + * responsive to the next command) and that the failed `source` marks the + * session failed (exit code 1), exactly like any other failing command. */ +static void test_source_mutual_cycle_rejected_with_error(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; + char path_a[300]; + char path_b[300]; + char source_cmd[600]; + + 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_fixture_fresh_dir(); + snprintf(path_a, sizeof(path_a), "%s/a.txt", clitest_fixture_dir); + snprintf(path_b, sizeof(path_b), "%s/b.txt", clitest_fixture_dir); + + { + char content_a[350]; + char content_b[350]; + snprintf(content_a, sizeof(content_a), "source %s\n", path_b); + snprintf(content_b, sizeof(content_b), "source %s\n", path_a); + clitest_fixture_write_file(path_a, content_a); + clitest_fixture_write_file(path_b, content_b); + } + + 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"); + + snprintf(source_cmd, sizeof(source_cmd), "source %s", path_a); + TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, source_cmd)); + + TEST_ASSERT_TRUE_MESSAGE( + clitest_wait_for_text(&sess, &out, &err, "cycle detected", true, + 5000), + "a mutual source cycle must be rejected with an explicit 'cycle " + "detected' error on stderr, not a hang (Requirement 13.5)"); + + /* The session must still be responsive -- the cycle rejection must not + * have hung or corrupted it. */ + TEST_ASSERT_EQUAL_INT( + 0, clitest_send_line(&sess, "echo cycle_test_still_alive_marker")); + TEST_ASSERT_TRUE_MESSAGE( + clitest_wait_for_text(&sess, &out, &err, + "cycle_test_still_alive_marker", false, 5000), + "the session must remain usable after a rejected source cycle"); + + 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( + 1, exit_code, + "the rejected source cycle must mark the session failed, exactly " + "like any other failing command"); + + clitest_kill_and_reap(&sess); + clitest_close(&sess); + clitest_buf_free(&out); + clitest_buf_free(&err); + clitest_fixture_cleanup_dir(); +} + +/* Requirement 13.2: `source`ing a path that does not exist must report an + * explicit error and must NOT treat any line of it as executed -- the + * opposite of rcfile_load()'s own silent-missing-file policy, which is + * correct for the startup rc file but wrong for this explicit command. */ +static void test_source_missing_file_rejected_with_error(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; + char missing_path[300]; + char source_cmd[400]; + + 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_fixture_fresh_dir(); + snprintf(missing_path, sizeof(missing_path), "%s/does_not_exist.txt", + clitest_fixture_dir); + + 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"); + + snprintf(source_cmd, sizeof(source_cmd), "source %s", missing_path); + TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, source_cmd)); + + TEST_ASSERT_TRUE_MESSAGE( + clitest_wait_for_text(&sess, &out, &err, "does_not_exist.txt", true, + 5000), + "a missing source file must be reported with an explicit error " + "naming the path, on stderr (Requirement 13.2)"); + + TEST_ASSERT_EQUAL_INT( + 0, clitest_send_line(&sess, "echo missing_test_still_alive_marker")); + TEST_ASSERT_TRUE_MESSAGE( + clitest_wait_for_text(&sess, &out, &err, + "missing_test_still_alive_marker", false, + 5000), + "the session must remain usable after a rejected missing-file " + "source"); + + 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( + 1, exit_code, + "the rejected missing-file source must mark the session failed"); + + clitest_kill_and_reap(&sess); + clitest_close(&sess); + clitest_buf_free(&out); + clitest_buf_free(&err); + clitest_fixture_cleanup_dir(); +} + +/* Requirement 13.1/13.4: nested, NON-cyclic sourcing (A sources B, B does + * not source back to A) must compose correctly -- every line of BOTH files + * runs, in order, exactly as if all of it had been typed at the prompt. */ +static void test_source_nested_noncyclic_executes_every_line(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; + char path_a[300]; + char path_b[300]; + char source_cmd[600]; + + 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_fixture_fresh_dir(); + snprintf(path_a, sizeof(path_a), "%s/a.txt", clitest_fixture_dir); + snprintf(path_b, sizeof(path_b), "%s/b.txt", clitest_fixture_dir); + + clitest_fixture_write_file( + path_b, "echo nested_marker_b_line1\necho nested_marker_b_line2\n"); + { + char content_a[400]; + snprintf(content_a, sizeof(content_a), + "echo nested_marker_a_line1\nsource %s\necho " + "nested_marker_a_line2\n", + path_b); + clitest_fixture_write_file(path_a, content_a); + } + + 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"); + + snprintf(source_cmd, sizeof(source_cmd), "source %s", path_a); + TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, source_cmd)); + + TEST_ASSERT_TRUE_MESSAGE( + clitest_wait_for_text(&sess, &out, &err, "nested_marker_a_line2", + false, 5000), + "every line of the outer file (A) must run, including the one " + "after the nested `source` (Requirement 13.1/13.4)"); + + TEST_ASSERT_NOT_NULL_MESSAGE( + strstr(out.data, "nested_marker_a_line1"), + "A's line before the nested source must have run"); + TEST_ASSERT_NOT_NULL_MESSAGE( + strstr(out.data, "nested_marker_b_line1"), + "B's first line must have run via the nested source"); + TEST_ASSERT_NOT_NULL_MESSAGE( + strstr(out.data, "nested_marker_b_line2"), + "B's second line must have run via the nested source"); + + /* Order matters too: A's pre-source line, then both of B's lines (in + * order), then A's post-source line. */ + { + const char* p1 = strstr(out.data, "nested_marker_a_line1"); + const char* p2 = strstr(out.data, "nested_marker_b_line1"); + const char* p3 = strstr(out.data, "nested_marker_b_line2"); + const char* p4 = strstr(out.data, "nested_marker_a_line2"); + + TEST_ASSERT_NOT_NULL(p1); + TEST_ASSERT_NOT_NULL(p2); + TEST_ASSERT_NOT_NULL(p3); + TEST_ASSERT_NOT_NULL(p4); + TEST_ASSERT_TRUE_MESSAGE(p1 < p2 && p2 < p3 && p3 < p4, + "nested source's lines must execute in " + "the correct relative order"); + } + + 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, + "a fully successful nested source (every line is a succeeding " + "echo) must not mark the session failed"); + + clitest_kill_and_reap(&sess); + clitest_close(&sess); + clitest_buf_free(&out); + clitest_buf_free(&err); + clitest_fixture_cleanup_dir(); +} + 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); + RUN_TEST(test_source_mutual_cycle_rejected_with_error); + RUN_TEST(test_source_missing_file_rejected_with_error); + RUN_TEST(test_source_nested_noncyclic_executes_every_line); }