diff --git a/.kiro/specs/xce-test-rc-file/tasks.md b/.kiro/specs/xce-test-rc-file/tasks.md index 459a580f..9452bf27 100644 --- a/.kiro/specs/xce-test-rc-file/tasks.md +++ b/.kiro/specs/xce-test-rc-file/tasks.md @@ -33,7 +33,7 @@ - _Boundary: test.c::main_ - [ ] 3. Validation: end-to-end behavior against the built binary -- [ ] 3.1 Verify rc-file discovery, loading, and skip-flag behavior end-to-end +- [x] 3.1 Verify rc-file discovery, loading, and skip-flag behavior end-to-end - Scenario: a valid rc file's settings/context take effect in both a piped run and an interactive run - Scenario: the skip flag leaves default settings untouched even with a valid rc file present - Observable: an integration test run demonstrates both scenarios pass against the real built binary @@ -60,3 +60,4 @@ - Task 1.1: the 2 pre-existing `test_config_repl.c` failures (`test_conf_trim_keeps_only_n_versions`, `test_conf_get_saved_tag_no_config`) are the tracked `bug-1561` overlayfs-artifact regression, unrelated to this spec — expect them to keep appearing in every subsequent task's `controller_unit_test` run; do not treat them as a new regression. - Task 1.2: `fopen()` succeeds on a directory path on this platform's glibc — the EISDIR failure only surfaces on the first `getline()` call (`errno=EISDIR`, `ferror(f)` set). A naive `if (!f)` open-failure check misses this; a post-loop `ferror(f)` check is required to catch it (see `rcfile_load()`, bug-1716). - Task 1.2: an ASAN heap-use-after-free was observed ONCE in an ad-hoc standalone `controller_unit_test` run (`commit_resolve_pending`/`fsync_worker`/`tap_teardown` in `test_apply_pump.c`) — did not reproduce on a second run or in the canonical `xce-build.sh` run. Unrelated to this spec's files; logged as bug-1717 (unreproduced flake) for tracking. If it recurs on a later task's run, capture the full ASAN report before assuming it's this spec's fault. +- Task 3.1: `test_rcfile_integration.sh`'s per-test scratch `$HOME` dirs are cleaned via explicit `rm -rf` in each test function's normal flow, but there's no script-level `trap ... EXIT INT TERM` (unlike `clean_failover_preservation.sh`'s global trap) — an interrupted run could leak a small scratch dir. Verified low-impact (small footprint, no leak in normal runs) but worth adding a trap if this script is extended later. diff --git a/src/controller/testsuite/test_rcfile_integration.sh b/src/controller/testsuite/test_rcfile_integration.sh new file mode 100755 index 00000000..895e180e --- /dev/null +++ b/src/controller/testsuite/test_rcfile_integration.sh @@ -0,0 +1,286 @@ +#!/usr/bin/env bash +# +# test_rcfile_integration.sh — xce-test-rc-file integration test, shell-driven +# against the REAL built `xce_test` binary. Mirrors clean_failover_preservation.sh's +# harness conventions in this directory: mktemp -d scratch fixtures, a +# log()/pass()/failcase() reporting style, explicit per-scenario PASS/FAIL, and +# a real process exit code (0 == every scenario passed). +# +# Design: .kiro/specs/xce-test-rc-file/design.md, "Testing Strategy" -> +# "Integration Tests". Requirements: .kiro/specs/xce-test-rc-file/requirements.md +# Requirement 1 (rc-file discovery/loading) and Requirement 2 (--norc opt-out). +# +# Task 3.1 scope (implemented here): +# test_rcfile_takes_effect_piped() Req 1.1, 1.2, 1.4, 1.5, 2.3 +# test_rcfile_takes_effect_interactive() Req 1.1, 1.2, 1.4, 1.5, 2.3 +# test_norc_skips_rcfile() Req 2.1, 2.3 +# +# Task 3.2 (a LATER task, NOT implemented by this file) is expected to append +# below, reusing the same setup_rc_home/cleanup_rc_home/run_piped/ +# run_interactive_pty helpers and the same pass()/failcase() accounting: +# test_rcfile_failure_aborts_piped() Req 3.1, 3.3, 3.4 +# test_rcfile_failure_continues_interactive() Req 3.1, 3.2, 3.4 +# and both would be added to the scenario list in main() below. +# +# Exit 0 == every scenario PASSed; nonzero == at least one scenario FAILed. + +set -u + +# ── Config ─────────────────────────────────────────────────────────────────── +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$HERE/../../.." && pwd)" +XCE_TEST="${XCE_TEST_BIN:-$REPO_ROOT/src/xce.build/controller/xce_test}" + +# xce_test's `masters` setting defaults to "localhost:9990" (test.c's +# settings[] table) and prints as "" until something actually +# assigns it (KVAL() macro in cmd_set). Pick an override value that can never +# collide with either, so a positive match is unambiguous. +OVERRIDE_MASTERS="rchost-a:19991,rchost-b:19992" +NOT_SET="" + +RUN_TIMEOUT_S="${XCE_RCFILE_TEST_TIMEOUT_S:-20}" + +FAILURES=0 +RESULTS=() + +# The interactive-pty driver (see run_interactive_pty below) is written ONCE +# to its own temp file rather than fed via a heredoc on the python3 command +# line -- a heredoc there would compete with the SEPARATE pipe that must also +# deliver the xce_test command script to the same process's stdin (`python3 -` +# reads its whole program from stdin, consuming it, leaving nothing for the +# script itself to read via sys.stdin). Cleaned up on exit. +PTY_DRIVER="$(mktemp "${TMPDIR:-/tmp}/xce-rcfile-pty-driver.XXXXXX")" +trap 'rm -f "$PTY_DRIVER"' EXIT + +cat >"$PTY_DRIVER" <<'PYEOF' +import os, pty, select, signal, sys, time + +def read_available(fd, timeout): + r, _, _ = select.select([fd], [], [], timeout) + if fd in r: + try: + return os.read(fd, 65536) + except OSError: + return b"" + return b"" + +def main(): + xce_test_bin, homedir, xdg, extra, timeout_s = sys.argv[1:6] + timeout_s = float(timeout_s) + commands = sys.stdin.read().splitlines() + + env = dict(os.environ) + env["HOME"] = homedir + if xdg: + env["XDG_CONFIG_HOME"] = xdg + else: + env.pop("XDG_CONFIG_HOME", None) + + args = [xce_test_bin] + ([extra] if extra else []) + + pid, master_fd = pty.fork() + if pid == 0: + os.execvpe(args[0], args, env) + os._exit(127) + + output = b"" + deadline = time.time() + timeout_s + + # Let xce_test start up and print its first prompt before sending input. + time.sleep(0.4) + output += read_available(master_fd, 0.4) + for cmd in commands: + os.write(master_fd, (cmd + "\n").encode()) + time.sleep(0.3) + output += read_available(master_fd, 0.3) + + status = None + while time.time() < deadline: + chunk = read_available(master_fd, 0.3) + if chunk: + output += chunk + continue + pid_done, st = os.waitpid(pid, os.WNOHANG) + if pid_done == pid: + status = st + break + if status is None: + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + _, status = os.waitpid(pid, 0) + + while True: + chunk = read_available(master_fd, 0.2) + if not chunk: + break + output += chunk + + try: + os.close(master_fd) + except OSError: + pass + + exitcode = os.waitstatus_to_exitcode(status) + sys.stdout.buffer.write(output) + sys.exit(exitcode if exitcode >= 0 else 1) + +main() +PYEOF + +# ── Reporting helpers ──────────────────────────────────────────────────────── +log() { printf '[rcfile-integration] %s\n' "$*" >&2; } +pass() { RESULTS+=("PASS: $1"); log "PASS: $1"; } +failcase() { + RESULTS+=("FAIL: $1 -- $2") + log "FAIL: $1 -- $2" + FAILURES=$((FAILURES + 1)) +} + +# ── Fixture helpers ────────────────────────────────────────────────────────── + +# Creates a scratch $HOME with $HOME/config/xce_test/rc containing an +# OVERRIDE_MASTERS `set masters` line, and echoes "|". +setup_rc_home() { + local dir + dir="$(mktemp -d "${TMPDIR:-/tmp}/xce-rcfile-it.XXXXXX")" || return 1 + mkdir -p "$dir/config/xce_test" || return 1 + printf 'set masters %s\n' "$OVERRIDE_MASTERS" >"$dir/config/xce_test/rc" + printf '%s|%s\n' "$dir" "$dir/config" +} + +cleanup_rc_home() { + local dir="$1" + [ -n "$dir" ] && [ -d "$dir" ] && rm -rf "$dir" +} + +# Extracts the current-value column of the `masters` line from a `set` +# command's output (cmd_set's "%-12s:%s default: %s\n" format). Robust to the +# exact column padding and to a value containing an embedded space (the +# unset-sentinel "") by capturing everything between the first ':' +# and the literal " default:" tail. +masters_value_in_output() { + printf '%s\n' "$1" | grep '^masters' | head -1 \ + | sed -E 's/^masters[[:space:]]*:(.*) default:.*/\1/' +} + +# Runs xce_test PIPED (stdin is not a tty -> scripted/batch mode -- the +# session's existing abort-on-first-failure default). Args: +# $1 homedir $2 xdg_config_home $3 script (newline-joined commands) +# $4... extra xce_test flags (e.g. --norc) +# Prints combined stdout+stderr; returns xce_test's own exit code. +run_piped() { + local homedir="$1" xdg="$2" script="$3" + shift 3 + printf '%s\n' "$script" \ + | env HOME="$homedir" XDG_CONFIG_HOME="$xdg" \ + timeout "$RUN_TIMEOUT_S" "$XCE_TEST" "$@" 2>&1 +} + +# Runs xce_test behind a REAL pty (genuine interactive/TTY session), so +# libedit's readline() and isatty(STDIN_FILENO)-gated g_interactive/ +# g_continue_on_error behave exactly as they do for a human at a terminal -- +# a piped run cannot exercise this. Uses a small embedded Python driver +# (pty.fork()), the same technique task 2.2's own implementer used for manual +# interactive verification of this feature. +# Args: $1 homedir $2 xdg_config_home $3 script $4 extra flag ("" or e.g. "--norc") +# Prints the captured pty transcript; returns xce_test's own exit code. +run_interactive_pty() { + local homedir="$1" xdg="$2" script="$3" extra="${4:-}" + printf '%s\n' "$script" \ + | python3 "$PTY_DRIVER" "$XCE_TEST" "$homedir" "$xdg" "$extra" "$RUN_TIMEOUT_S" +} + +# ── Scenarios (task 3.1) ───────────────────────────────────────────────────── + +test_rcfile_takes_effect_piped() { + local name="rcfile_takes_effect_piped" + local fixture homedir xdg out rc mval + fixture="$(setup_rc_home)" || { failcase "$name" "setup_rc_home failed"; return; } + homedir="${fixture%%|*}"; xdg="${fixture##*|}" + + out="$(run_piped "$homedir" "$xdg" $'set\nquit')" + rc=$? + mval="$(masters_value_in_output "$out")" + + if [ "$rc" -ne 0 ]; then + failcase "$name" "xce_test exited $rc (expected 0); output:"$'\n'"$out" + elif [ "$mval" != "$OVERRIDE_MASTERS" ]; then + failcase "$name" "expected masters=='$OVERRIDE_MASTERS', got '$mval'; output:"$'\n'"$out" + else + pass "$name" + fi + cleanup_rc_home "$homedir" +} + +test_rcfile_takes_effect_interactive() { + local name="rcfile_takes_effect_interactive" + local fixture homedir xdg out rc mval + fixture="$(setup_rc_home)" || { failcase "$name" "setup_rc_home failed"; return; } + homedir="${fixture%%|*}"; xdg="${fixture##*|}" + + out="$(run_interactive_pty "$homedir" "$xdg" $'set\nquit')" + rc=$? + mval="$(masters_value_in_output "$out")" + + if [ "$rc" -ne 0 ]; then + failcase "$name" "xce_test exited $rc (expected 0); transcript:"$'\n'"$out" + elif [ "$mval" != "$OVERRIDE_MASTERS" ]; then + failcase "$name" "expected masters=='$OVERRIDE_MASTERS', got '$mval'; transcript:"$'\n'"$out" + elif ! grep -qF 'xce>' <<<"$out"; then + failcase "$name" "no 'xce>' interactive prompt seen in transcript -- session may not have been a real tty; transcript:"$'\n'"$out" + else + pass "$name" + fi + cleanup_rc_home "$homedir" +} + +test_norc_skips_rcfile() { + local name="norc_skips_rcfile" + local fixture homedir xdg out rc mval + fixture="$(setup_rc_home)" || { failcase "$name" "setup_rc_home failed"; return; } + homedir="${fixture%%|*}"; xdg="${fixture##*|}" + + out="$(run_piped "$homedir" "$xdg" $'set\nquit' --norc)" + rc=$? + mval="$(masters_value_in_output "$out")" + + if [ "$rc" -ne 0 ]; then + failcase "$name" "xce_test --norc exited $rc (expected 0); output:"$'\n'"$out" + elif [ "$mval" = "$OVERRIDE_MASTERS" ]; then + failcase "$name" "--norc did not skip rc-file load -- override value leaked into 'set' output; output:"$'\n'"$out" + elif [ "$mval" != "$NOT_SET" ]; then + failcase "$name" "expected masters=='$NOT_SET' (never assigned) with --norc, got '$mval'; output:"$'\n'"$out" + else + pass "$name" + fi + cleanup_rc_home "$homedir" +} + +# ── Main ────────────────────────────────────────────────────────────────────── + +main() { + [ -x "$XCE_TEST" ] \ + || { log "FAIL: xce_test not found/executable: $XCE_TEST (run /xce-build controller)"; exit 1; } + + test_rcfile_takes_effect_piped + test_rcfile_takes_effect_interactive + test_norc_skips_rcfile + + log "---- summary ----" + local r + for r in "${RESULTS[@]}"; do + log "$r" + done + + if [ "$FAILURES" -eq 0 ]; then + log "ALL SCENARIOS PASSED" + exit 0 + else + log "$FAILURES SCENARIO(S) FAILED" + exit 1 + fi +} + +main "$@"