diff --git a/.kiro/specs/xce-test-cli-ux/tasks.md b/.kiro/specs/xce-test-cli-ux/tasks.md index 311b9cc3..90b45e00 100644 --- a/.kiro/specs/xce-test-cli-ux/tasks.md +++ b/.kiro/specs/xce-test-cli-ux/tasks.md @@ -122,7 +122,7 @@ - _Boundary: Confirmation Gate_ - _Depends: 2.1, 3.1_ -- [ ] 3.4 Add JSON output mode to the three listing commands +- [x] 3.4 Add JSON output mode to the three listing commands - Bring the worker-listing, job-listing, and resource-listing commands onto the shared flag-descriptor library from 1.1 for the first time (none currently accepts any flag), adding a consistently named output-format flag that uses the JSON list-output library from 2.2 to emit every field present in each command's human-readable table, unabbreviated - Leave each command's existing human-readable output unchanged when the new flag is not given - Observable completion: running each of the three listing commands with the new flag against a fixture with a long field value produces valid JSON containing every table field with no truncation, while the default (no flag) invocation still prints the existing table @@ -176,6 +176,7 @@ - _Depends: 1.6, 2.3, 3.5, 1.7_ ## Implementation Notes +- `workerlist`/`joblist`/`reslist` all take the identical long-only `--json` flag (via each command's own `flagspec_t` table); JSON rows emit RAW numeric values for fields the table displays in a humanized/formatted form (`flags` as an int not a hex string, timestamps as raw epoch seconds not `format_job_time`'s human string, `maxmem`/`maxswap`/`maxtmp` as raw bytes not `format_bytes_human`'s string) — deliberate, since Requirement 5.1 asks for "the same fields" (value present) not identical display formatting, and a raw value is strictly more script-consumable. The field-mapping logic was extracted to a new pure module, `listrow_json.c`/`.h` (no RPC/networking/global-state coupling), following the `joblist_display.c` extraction precedent, with full unit coverage in `test_listrow_json.c` (a new standalone `listrow_json_unit_test` meson target, not counted in the main suite's test total) including the fractional-value (`cpufactor` 12.34) real cJSON-print-then-reparse round-trip the task-2.2 note asked for. `joblist_query` gained a 5th `json_mode` parameter; its other two callers (`expect-jobs`/`expect-job`) now pass a literal `0` and are behaviorally unchanged (verified: both `print_table` and `json_mode` false skips both branches exactly as before). Live-cluster E2E visual inspection of real JSON output was not done — same "genuinely untestable without a live cluster" situation task 2.5 already documented for these commands' inlined fetch+print bodies; coverage instead comes from the extracted module's unit tests plus a black-box test proving the flag parses/executes without a flag-parse error. (task 3.4) - `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) - Confirmed (review traced it concretely): a declined destructive command inside a `source`d file does NOT trip `set -e`'s abort-on-failure, since decline maps to `return 0` per confirm.h's own documented contract, not `execute_line`'s `rc<0` failure check. This only matters when a human is actively present declining (never a blind/unattended failure mode), and any later destructive line re-prompts independently — but task 4.3 should add an explicit `set -e` + `source ` case to prove/document this interaction, since it's currently untested. (task 3.3) diff --git a/src/controller/meson.build b/src/controller/meson.build index 42b52a07..2ce15933 100644 --- a/src/controller/meson.build +++ b/src/controller/meson.build @@ -55,7 +55,7 @@ controller = executable('xce_controller', link_args: wrap_link_args, install: true) -xce_test_src = [ 'testsuite/test.c', 'testsuite/waldump.c', 'testsuite/walcompare.c', 'testsuite/snapverify.c', 'testsuite/snapdiff.c', 'testsuite/configcompare.c', 'testsuite/resval_typeresolve.c', 'testsuite/joblist_display.c', 'testsuite/join_worker_args.c', 'testsuite/rcfile.c', 'testsuite/flagspec.c', 'testsuite/interruptcall.c', 'testsuite/confirm.c', 'testsuite/listfmt_json.c'] +xce_test_src = [ 'testsuite/test.c', 'testsuite/waldump.c', 'testsuite/walcompare.c', 'testsuite/snapverify.c', 'testsuite/snapdiff.c', 'testsuite/configcompare.c', 'testsuite/resval_typeresolve.c', 'testsuite/joblist_display.c', 'testsuite/join_worker_args.c', 'testsuite/rcfile.c', 'testsuite/flagspec.c', 'testsuite/interruptcall.c', 'testsuite/confirm.c', 'testsuite/listfmt_json.c', 'testsuite/listrow_json.c'] # BSD-licensed readline replacement (scheduler-core spec, task 12.1 review # finding): GNU readline is GPL; xce_test's REPL only needs readline()/ @@ -451,3 +451,31 @@ test('join worker args test', join_worker_args_unit_test, timeout: 60, env: ['ASAN_OPTIONS=detect_leaks=0']) +# xce-test-cli-ux task 3.4: listrow_json.c is a small, dependency-light +# module (no networking/REPL/global state) extracted from cmd_reslist/ +# cmd_workerlist/joblist_query specifically so their new --json output +# mode's per-command field-mapping logic can be unit-tested in isolation +# -- test.c itself cannot be white-box included into a Unity test, same +# rationale as joblist_display_unit_test/resval_typeresolve_unit_test +# above. Standalone binary (not folded into controller_unit_test_src) +# mirroring those tests' own standalone-binary rationale. Links +# listfmt_json.c directly (not struct2json_dep alone) since listrow_json.c +# calls its add_string()/add_number()/new_row() helpers. +listrow_json_unit_test_src = [ + 'testsuite/test_listrow_json.c', + 'testsuite/listrow_json.c', + 'testsuite/listfmt_json.c', +] + +listrow_json_unit_test = executable('listrow_json_unit_test', + listrow_json_unit_test_src, + include_directories: [incdir], + dependencies: controller_unit_test_deps, + c_args: ['-DXCE_TESTING'], + install: false) + +test('listrow json test', listrow_json_unit_test, + suite: 'controller', + timeout: 60, + env: ['ASAN_OPTIONS=detect_leaks=0']) + diff --git a/src/controller/testsuite/listrow_json.c b/src/controller/testsuite/listrow_json.c new file mode 100644 index 00000000..653d443e --- /dev/null +++ b/src/controller/testsuite/listrow_json.c @@ -0,0 +1,62 @@ +/* listrow_json.c -- xce-test-cli-ux task 3.4 (Requirements 5.1, 5.2, 5.3) + * + * See listrow_json.h for the full rationale. Pure field-mapping only: each + * function below just calls listfmt_json_add_string()/add_number() (task + * 2.2, unmodified) once per table column. + */ +#include "listrow_json.h" + +#include "listfmt_json.h" + +void reslist_json_row(cJSON* row, const char* name, int flags, int interval, + const char* desc) +{ + if (!row) + return; + + listfmt_json_add_string(row, "name", name); + listfmt_json_add_number(row, "flags", flags); + listfmt_json_add_number(row, "interval", interval); + listfmt_json_add_string(row, "description", desc); +} + +void workerlist_json_row(cJSON* row, const char* name, const char* status_str, + const char* hosttype, const char* hostmodel, + double cpufactor, int maxcpus, double maxmem, + double maxswap, double maxtmp, int ndisks) +{ + if (!row) + return; + + listfmt_json_add_string(row, "hostname", name); + listfmt_json_add_string(row, "status", status_str); + listfmt_json_add_string(row, "hosttype", hosttype); + listfmt_json_add_string(row, "hostmodel", hostmodel); + listfmt_json_add_number(row, "cpufactor", cpufactor); + listfmt_json_add_number(row, "maxcpu", maxcpus); + listfmt_json_add_number(row, "maxmem", maxmem); + listfmt_json_add_number(row, "maxswap", maxswap); + listfmt_json_add_number(row, "maxtmp", maxtmp); + listfmt_json_add_number(row, "ndisks", ndisks); +} + +void joblist_json_row(cJSON* row, const char* jobid_str, const char* user, + const char* queue, const char* state_str, + time_t submit_time, time_t start_time, + time_t finish_time, uint32_t mintasks, + uint32_t maxtasks, const char* cmdline) +{ + if (!row) + return; + + listfmt_json_add_string(row, "jobid", jobid_str); + listfmt_json_add_string(row, "user", user); + listfmt_json_add_string(row, "queue", queue); + listfmt_json_add_string(row, "state", state_str); + listfmt_json_add_number(row, "submit_time", (double)submit_time); + listfmt_json_add_number(row, "start_time", (double)start_time); + listfmt_json_add_number(row, "finish_time", (double)finish_time); + listfmt_json_add_number(row, "mintasks", mintasks); + listfmt_json_add_number(row, "maxtasks", maxtasks); + listfmt_json_add_string(row, "command", cmdline); +} diff --git a/src/controller/testsuite/listrow_json.h b/src/controller/testsuite/listrow_json.h new file mode 100644 index 00000000..59d6b016 --- /dev/null +++ b/src/controller/testsuite/listrow_json.h @@ -0,0 +1,113 @@ +#pragma once +/* + * listrow_json.h -- pure per-command JSON-row field-mapping helpers for + * cmd_reslist/cmd_workerlist/joblist_query's new --json output mode + * (xce-test-cli-ux task 3.4, Requirements 5.1, 5.2, 5.3). + * + * Each function here takes one already-fetched record's fields -- + * already extracted as plain scalars by the caller, no xce/types.h struct + * dependency, no RPC, no networking, no global session state -- and + * populates one JSON row object (from listfmt_json_new_row(), task 2.2) + * via listfmt_json.h's add_string()/add_number() primitives, mapping + * EVERY column the corresponding command's human-readable table prints + * (Requirement 5.1) with no truncation regardless of value length + * (Requirement 5.3). + * + * Extracted out of test.c (rather than inlined in cmd_reslist/ + * cmd_workerlist/joblist_query alongside their RPC calls) specifically so + * this field-mapping logic is unit-testable in isolation with plain + * literal inputs -- test.c itself cannot be white-box #include'd into a + * Unity test (thousands of lines, its own main(), heavy libedit/network + * dependencies), and cmd_reslist/joblist_query's fetch+print bodies were + * already found "genuinely untestable without a live cluster" for the + * SAME reason during task 2.5's review. This mirrors joblist_display.c's + * extraction precedent (task 2.5) for worker_status_str()/job_state_str()/ + * format_job_time() -- pure, dependency-light display formatting pulled + * out specifically to get real automated coverage. + * + * The caller (test.c) remains responsible for: calling xcecli_reslist()/ + * xcecli_workerlist()/xcecli_joblist(), iterating the result, calling + * listfmt_json_begin()/listfmt_json_new_row() around each call here, and + * listfmt_json_finish() exactly once when done -- this module never + * touches the array lifecycle itself (listfmt_json.h already owns that, + * task 2.2, and is not modified by this task). + */ + +#include +#include + +#include + +/* Maps cmd_reslist's table columns -- Name, Flags, Interval, Description, + * see test.c's printf("%-10s%10s%10d %-64s\n", ...) row -- onto `row`. + * + * `flags` is emitted as a JSON NUMBER under the key "flags" (the raw + * xce_resource_t.flags value), not the table's "0x%x"-formatted hex + * string (Requirement 11.4's hex prefix is a display-only convention for + * the human-readable table; the underlying integer value is identical + * either way) -- a number already carries the same information the + * table's Flags column shows, so nothing is lost relative to the table + * (Requirement 5.1), and a script consuming the JSON output gets a + * directly-usable integer instead of having to re-parse a "0x..." string. + * + * No-op if `row` is NULL (mirrors listfmt_json_add_string/number's own + * NULL-row no-op contract). */ +void reslist_json_row(cJSON* row, const char* name, int flags, int interval, + const char* desc); + +/* Maps cmd_workerlist's table columns -- HostName, Status, HostType, + * HostModel, CPUfactor, MaxCPU, MaxMem, MaxSwap, MaxTmp, NDisks -- onto + * `row`. + * + * Unlike the table (which humanizes MaxMem/MaxSwap/MaxTmp via + * format_bytes_human() for display, e.g. "64.0 MB"), this emits their RAW + * numeric byte values (`maxmem`/`maxswap`/`maxtmp`, matching hostattrs_t's + * own `double` field types in xce/types.h) as JSON numbers under keys + * "maxmem"/"maxswap"/"maxtmp" -- the same underlying value the table's + * humanized string represents, but directly script-consumable without + * re-parsing a unit suffix. + * + * `status_str` is the caller's worker_status_str(workers[i]->status) + * result (joblist_display.h) -- passed in rather than recomputed here, so + * this module has no dependency on xce/types.h's XCE_WORKER_* bit + * definitions. + * + * `cpufactor` is `double` (not truncated to float/int) so a genuine + * fractional value -- not just a power-of-two -- round-trips exactly + * through the JSON text (see test_listrow_json.c's fractional round-trip + * test, closing the gap task 2.2's Implementation Notes flagged: its own + * tests only exercised power-of-two doubles). + * + * No-op if `row` is NULL. */ +void workerlist_json_row(cJSON* row, const char* name, const char* status_str, + const char* hosttype, const char* hostmodel, + double cpufactor, int maxcpus, double maxmem, + double maxswap, double maxtmp, int ndisks); + +/* Maps joblist_query's table columns -- JobId, User, Queue, State, + * SubmitTime, StartTime, FinishTime, MinTasks, MaxTasks, Command -- onto + * `row`. + * + * `jobid_str` is the caller's fmt_jobid(...) result and `state_str` the + * caller's job_state_str(jobs[i]->state) result (joblist_display.h) -- + * both passed in rather than recomputed here, keeping this module free of + * test.c's own fmt_jobid()/RPC dependencies. `state_str` is stored + * verbatim (including job_state_str()'s Requirement-11.7 raw-state + * fallback text for an ambiguous RUN+EXIT state, e.g. "RUN(0x38)"), so no + * information the table's State column carries is lost. + * + * Unlike the table (which formats SubmitTime/StartTime/FinishTime as + * human-readable text via format_job_time(), Requirement 11.5), this + * emits their RAW epoch-second values as JSON numbers under keys + * "submit_time"/"start_time"/"finish_time" -- 0 meaning "has not happened + * yet", the same convention format_job_time() itself uses for its "-" + * display -- the same underlying value the table's formatted string + * represents, directly script-consumable without re-parsing a date + * string. + * + * No-op if `row` is NULL. */ +void joblist_json_row(cJSON* row, const char* jobid_str, const char* user, + const char* queue, const char* state_str, + time_t submit_time, time_t start_time, + time_t finish_time, uint32_t mintasks, + uint32_t maxtasks, const char* cmdline); diff --git a/src/controller/testsuite/test.c b/src/controller/testsuite/test.c index 3b825150..e247e268 100644 --- a/src/controller/testsuite/test.c +++ b/src/controller/testsuite/test.c @@ -67,6 +67,12 @@ * (xce-test-cli-ux task 2.5 remediation) */ #include "join_worker_args.h" /* join_worker_parse_attrs (xce-test-cli-ux * task 2.6 remediation, bug-1557) */ +#include "listfmt_json.h" /* listfmt_json_begin/new_row/finish -- JSON list + * output for workerlist/joblist/reslist + * (xce-test-cli-ux task 2.2, wired in task 3.4) */ +#include "listrow_json.h" /* reslist_json_row/workerlist_json_row/ + * joblist_json_row -- per-command JSON field + * mapping (xce-test-cli-ux task 3.4) */ #include "rcfile.h" /* startup rc-file path resolution + loading (xce-test-rc-file task 2.2) */ #include "resval_typeresolve.h" /* resval_resolve_type (resource-value-verification task 2.1) */ #include "xce/cluster.h" /* xce_parse_peers, xce_cluster_config_t */ @@ -444,14 +450,16 @@ static const char USAGE_HELLO[] = "example: hello -c 3 -m \"ping\"\n"; static const char USAGE_RESLIST[] = - "reslist\n" - " (no arguments)\n" + "reslist [--json]\n" + " --json emit each resource as a JSON object (same fields as the\n" + " table) instead of the human-readable table\n" " Prints all cluster-wide resources (name, flags, interval,\n" " description).\n"; static const char USAGE_WORKERLIST[] = - "workerlist\n" - " (no arguments)\n" + "workerlist [--json]\n" + " --json emit each worker as a JSON object (same fields as the\n" + " table) instead of the human-readable table\n" " Prints all joined workers (host, status, host type/model, cpu\n" " factor, max cpu/mem/swap/tmp, ndisks).\n"; @@ -482,10 +490,11 @@ static const char USAGE_UPGRADECONFIG[] = "example: upgradeconfig /var/xce/cluster.bin\n"; static const char USAGE_JOBLIST[] = - "joblist\n" - " (no arguments)\n" + "joblist [--json]\n" + " --json emit each job as a JSON object (same fields as the table)\n" + " instead of the human-readable table\n" " Prints all jobs in the cluster (JobId, user, queue, state, times,\n" - " ntasks, command) and captures the count into $jobcount.\n"; + " mintasks/maxtasks, command) and captures the count into $jobcount.\n"; static const char USAGE_EXPECT_JOBS[] = "expect-jobs \n" @@ -2002,12 +2011,53 @@ static int reslist_thunk(void* ctxp) return call->rc; } +/* xce-test-cli-ux task 3.4, Requirement 5.1/5.2, 6.2: reslist's first-ever + * flag, --json (long-only -- no short form is required by the + * requirements and none is added here). Same long_opt spelling ("json") + * and concept ("json-output") as workerlist_flags/joblist_flags below -- + * Requirement 6.2 mandates identical naming for the same concept across + * every command that has it. */ +enum { RESLIST_OPT_JSON = 0 }; + +static const flagspec_t reslist_flags[] = { + { 0, "json", "json-output", no_argument, 0, "human-readable table" }, +}; +#define RESLIST_NFLAGS (sizeof(reslist_flags) / sizeof(reslist_flags[0])) + int cmd_reslist(int argc, char* argv[]) { int rc, i; + int opt; + int json_mode = 0; xce_resource_t** res; - reslist_call_t* call = (reslist_call_t*)calloc(1, sizeof(*call)); + reslist_call_t* call; + struct option long_opts[RESLIST_NFLAGS + 1]; + char optstring[8]; + optind = 1; + opterr = 0; + + if (flagspec_build_opts(reslist_flags, RESLIST_NFLAGS, long_opts, + optstring, sizeof(optstring)) + < 0) { + fprintf(stderr, + "xce_test: reslist: internal error building flag table\n"); + return -EINVAL; + } + + while ((opt = getopt_long(argc, argv, optstring, long_opts, NULL)) + != -1) { + switch (opt) { + case FLAGSPEC_LONG_ONLY_BASE + RESLIST_OPT_JSON: + json_mode = 1; + break; + case '?': + return flagspec_reject("reslist", reslist_flags, RESLIST_NFLAGS, + argv[optind - 1], "reslist [--json]"); + } + } + + call = (reslist_call_t*)calloc(1, sizeof(*call)); if (call == NULL) { return -ENOMEM; } @@ -2032,6 +2082,32 @@ int cmd_reslist(int argc, char* argv[]) return rc; } + if (json_mode) { + /* xce-test-cli-ux task 3.4, Requirement 5.1/5.3: emit the SAME + * fields the table below prints (Name, Flags, Interval, + * Description), unabbreviated, as one JSON object per resource. + * reslist_json_row() (listrow_json.h) owns the field mapping; + * listfmt_json.h (task 2.2) owns the array lifecycle/printing. */ + cJSON* array = listfmt_json_begin(); + + if (array == NULL) { + free_Resource_list(rc, res); + return -ENOMEM; + } + + for (i = 0; i < rc; i++) { + cJSON* row = listfmt_json_new_row(array); + + reslist_json_row(row, res[i]->name, res[i]->flags, + res[i]->interval, res[i]->desc); + } + + listfmt_json_finish(array); + free_Resource_list(rc, res); + + return 0; + } + /* Note the extra literal space before the Description specifier: unlike * cmd_workerlist's all-left-justified text columns (which self-separate * via their own trailing padding), Interval stays right-justified here, @@ -2082,12 +2158,52 @@ static int workerlist_thunk(void* ctxp) return call->rc; } +/* xce-test-cli-ux task 3.4, Requirement 5.1/5.2, 6.2: workerlist's + * first-ever flag, --json -- IDENTICAL long_opt/concept spelling to + * reslist_flags/joblist_flags (Requirement 6.2). */ +enum { WORKERLIST_OPT_JSON = 0 }; + +static const flagspec_t workerlist_flags[] = { + { 0, "json", "json-output", no_argument, 0, "human-readable table" }, +}; +#define WORKERLIST_NFLAGS \ + (sizeof(workerlist_flags) / sizeof(workerlist_flags[0])) + int cmd_workerlist(int argc, char* argv[]) { int rc, i; + int opt; + int json_mode = 0; xce_worker_t** workers; - workerlist_call_t* call = (workerlist_call_t*)calloc(1, sizeof(*call)); + workerlist_call_t* call; + struct option long_opts[WORKERLIST_NFLAGS + 1]; + char optstring[8]; + optind = 1; + opterr = 0; + + if (flagspec_build_opts(workerlist_flags, WORKERLIST_NFLAGS, long_opts, + optstring, sizeof(optstring)) + < 0) { + fprintf(stderr, + "xce_test: workerlist: internal error building flag table\n"); + return -EINVAL; + } + + while ((opt = getopt_long(argc, argv, optstring, long_opts, NULL)) + != -1) { + switch (opt) { + case FLAGSPEC_LONG_ONLY_BASE + WORKERLIST_OPT_JSON: + json_mode = 1; + break; + case '?': + return flagspec_reject("workerlist", workerlist_flags, + WORKERLIST_NFLAGS, argv[optind - 1], + "workerlist [--json]"); + } + } + + call = (workerlist_call_t*)calloc(1, sizeof(*call)); if (call == NULL) { return -ENOMEM; } @@ -2111,6 +2227,40 @@ int cmd_workerlist(int argc, char* argv[]) return rc; } + if (json_mode) { + /* xce-test-cli-ux task 3.4, Requirement 5.1/5.3: emit the SAME + * fields the table below prints, unabbreviated, as one JSON + * object per worker -- MaxMem/MaxSwap/MaxTmp as their RAW byte + * values (not the table's humanized "64.0 MB"-style strings), see + * workerlist_json_row()'s own doc comment (listrow_json.h). */ + cJSON* array = listfmt_json_begin(); + + if (array == NULL) { + free_Worker_list(rc, workers); + return -ENOMEM; + } + + for (i = 0; i < rc; i++) { + cJSON* row = listfmt_json_new_row(array); + + workerlist_json_row(row, workers[i]->name, + worker_status_str(workers[i]->status), + workers[i]->attrs->hosttype, + workers[i]->attrs->hostmodel, + workers[i]->attrs->cpufactor, + workers[i]->attrs->maxcpus, + workers[i]->attrs->maxmem, + workers[i]->attrs->maxswap, + workers[i]->attrs->maxtmp, + workers[i]->attrs->ndisks); + } + + listfmt_json_finish(array); + free_Worker_list(rc, workers); + + return 0; + } + printf("%-20s%-8s%-25s%-25s%12s%12s%12s%12s%12s%12s\n", "HostName", "Status", "HostType", "HostModel", "CPUfactor", "MaxCPU", "MaxMem", "MaxSwap", "MaxTmp", "NDisks"); @@ -2162,6 +2312,13 @@ int cmd_workerlist(int argc, char* argv[]) * up. When `print_table` is set, the human-readable table is printed (the * `cmd_joblist` path). The job array is always freed internally. * + * `json_mode` (xce-test-cli-ux task 3.4, Requirement 5.1/5.2): when set, + * emits the SAME fields `print_table` would have printed as one JSON + * object per job instead (joblist_json_row(), listrow_json.h) -- + * mutually exclusive with `print_table` in practice: `cmd_joblist` (the + * only caller that ever sets either) passes exactly one of the two as + * true, never both; `expect-jobs`/`expect-job` always pass 0 for both. + * * On error this returns the negative rc WITHOUT setting any auto-variable, so a * failed joblist never advertises a fake `$jobcount` (Req 3.6). */ typedef struct { @@ -2180,10 +2337,11 @@ static int joblist_thunk(void* ctxp) } static int joblist_query(const char* find_jobid, int* contains, int print_table, - int quiet) + int quiet, int json_mode) { int rc, i; xce_job_t** jobs; + cJSON* json_array = NULL; joblist_call_t* call = (joblist_call_t*)calloc(1, sizeof(*call)); if (contains != NULL) { @@ -2224,6 +2382,16 @@ static int joblist_query(const char* find_jobid, int* contains, int print_table, printf("%-26s%-10s%-10s%-8s%-24s%-24s%-24s%12s%12s%30s\n", "JobId", "User", "Queue", "State", "SubmitTime", "StartTime", "FinishTime", "MinTasks", "MaxTasks", "Command"); + } else if (json_mode) { + json_array = listfmt_json_begin(); + + if (json_array == NULL) { + for (i = 0; i < rc; i++) { + free_Job_ptr(&(jobs[i])); + } + free(jobs); + return -ENOMEM; + } } for (i = 0; i < rc; i++) { @@ -2252,9 +2420,27 @@ static int joblist_query(const char* find_jobid, int* contains, int print_table, sizeof(finish_buf)), jobs[i]->submit->mintasks, jobs[i]->submit->maxtasks, jobs[i]->submit->start_info->cmdline); + } else if (json_mode) { + /* xce-test-cli-ux task 3.4, Requirement 5.1/5.3: the SAME + * fields the table above prints, unabbreviated, as one JSON + * object per job -- joblist_json_row() (listrow_json.h) owns + * the field mapping. */ + cJSON* row = listfmt_json_new_row(json_array); + + joblist_json_row(row, buf, jobs[i]->submit->user, + jobs[i]->submit->queue, + job_state_str(jobs[i]->state), + jobs[i]->queue_time, jobs[i]->start_time, + jobs[i]->finish_time, jobs[i]->submit->mintasks, + jobs[i]->submit->maxtasks, + jobs[i]->submit->start_info->cmdline); } } + if (json_mode) { + listfmt_json_finish(json_array); + } + for (i = 0; i < rc; i++) { free_Job_ptr(&(jobs[i])); } @@ -2264,11 +2450,49 @@ static int joblist_query(const char* find_jobid, int* contains, int print_table, return rc; } +/* xce-test-cli-ux task 3.4, Requirement 5.1/5.2, 6.2: joblist's first-ever + * flag, --json -- IDENTICAL long_opt/concept spelling to reslist_flags/ + * workerlist_flags above (Requirement 6.2). */ +enum { JOBLIST_OPT_JSON = 0 }; + +static const flagspec_t joblist_flags[] = { + { 0, "json", "json-output", no_argument, 0, "human-readable table" }, +}; +#define JOBLIST_NFLAGS (sizeof(joblist_flags) / sizeof(joblist_flags[0])) + int cmd_joblist(int argc, char* argv[]) { - int count; + int count; + int opt; + int json_mode = 0; + struct option long_opts[JOBLIST_NFLAGS + 1]; + char optstring[8]; - count = joblist_query(NULL, NULL, 1, /*quiet=*/0); + optind = 1; + opterr = 0; + + if (flagspec_build_opts(joblist_flags, JOBLIST_NFLAGS, long_opts, + optstring, sizeof(optstring)) + < 0) { + fprintf(stderr, + "xce_test: joblist: internal error building flag table\n"); + return -EINVAL; + } + + while ((opt = getopt_long(argc, argv, optstring, long_opts, NULL)) + != -1) { + switch (opt) { + case FLAGSPEC_LONG_ONLY_BASE + JOBLIST_OPT_JSON: + json_mode = 1; + break; + case '?': + return flagspec_reject("joblist", joblist_flags, JOBLIST_NFLAGS, + argv[optind - 1], "joblist [--json]"); + } + } + + count = joblist_query(NULL, NULL, /*print_table=*/!json_mode, + /*quiet=*/0, json_mode); if (count < 0) { return count; @@ -2319,7 +2543,7 @@ int cmd_expect_jobs(int argc, char* argv[]) /* Print-free count via the shared helper. A negative rc means the query * itself failed (e.g. no reachable master) — that is an assertion failure, * not "0 jobs". */ - count = joblist_query(NULL, NULL, 0, /*quiet=*/0); + count = joblist_query(NULL, NULL, 0, /*quiet=*/0, /*json_mode=*/0); if (count < 0) { printf("expect-jobs: could not determine job count (joblist query " @@ -2367,7 +2591,7 @@ int cmd_expect_job(int argc, char* argv[]) /* Print-free membership query via the shared helper. A negative rc means the * query itself failed (e.g. no reachable master) — that is an assertion * failure, distinct from "job absent". */ - rc = joblist_query(argv[1], &contains, 0, /*quiet=*/0); + rc = joblist_query(argv[1], &contains, 0, /*quiet=*/0, /*json_mode=*/0); if (rc < 0) { printf("expect-job: could not determine membership of '%s' (joblist " diff --git a/src/controller/testsuite/test_listrow_json.c b/src/controller/testsuite/test_listrow_json.c new file mode 100644 index 00000000..306a17e7 --- /dev/null +++ b/src/controller/testsuite/test_listrow_json.c @@ -0,0 +1,378 @@ +/* test_listrow_json.c -- xce-test-cli-ux task 3.4 (Requirements 5.1, 5.2, + * 5.3) unit tests for listrow_json.c's pure per-command JSON-row + * field-mapping helpers. + * + * Standalone Unity binary with its own main() -- mirrors + * test_joblist_display.c's/test_resval_typeresolve.c's precedent (task + * 2.5/resource-value-verification task 1.1): listrow_json.c has zero + * dependency on test.c or any networking, so it is built+tested in + * isolation rather than folded into controller_test.c's shared suite. + * + * Every test builds a JSON row directly with the module under test, then + * either inspects the in-memory cJSON tree (lifecycle-only assertions) or + * routes it through listfmt_json_finish() (task 2.2, unmodified) to prove + * a genuine JSON-text round trip -- this is where the fractional-value + * (cpufactor) round-trip test lives, closing the gap task 2.2's own + * Implementation Notes flagged ("listfmt_json.c's tests only exercise + * power-of-two doubles ... task 3.4 should add a real fractional-value + * round-trip test once actual command fields are wired in"). + */ +#include +#include +#include +#include + +#include + +#include "listfmt_json.h" +#include "listrow_json.h" + +/* ---- stdout capture, for the listfmt_json_finish()-based round-trip + * tests -- mirrors test_listfmt_json.c's own dup/dup2/freopen technique + * exactly (that file's tests establish the pattern this one reuses). */ + +static char g_dir[256]; + +static void fresh_dir(void) +{ + strcpy(g_dir, "/tmp/xce_listrow_json_test_XXXXXX"); + TEST_ASSERT_NOT_NULL(mkdtemp(g_dir)); +} + +static void cleanup_dir(void) +{ + char cmd[512]; + snprintf(cmd, sizeof(cmd), "rm -rf %s", g_dir); + system(cmd); +} + +typedef struct { + int saved_stdout_fd; + char stdout_path[300]; +} capture_t; + +static void capture_begin(capture_t* cap) +{ + fflush(stdout); + cap->saved_stdout_fd = dup(fileno(stdout)); + TEST_ASSERT_TRUE(cap->saved_stdout_fd >= 0); + snprintf(cap->stdout_path, sizeof(cap->stdout_path), "%s/out", g_dir); + TEST_ASSERT_NOT_NULL(freopen(cap->stdout_path, "w", stdout)); +} + +static void capture_end(capture_t* cap, char** out_stdout) +{ + fflush(stdout); + TEST_ASSERT_TRUE(dup2(cap->saved_stdout_fd, fileno(stdout)) >= 0); + close(cap->saved_stdout_fd); + clearerr(stdout); + + FILE* f = fopen(cap->stdout_path, "r"); + TEST_ASSERT_NOT_NULL(f); + fseek(f, 0, SEEK_END); + long len = ftell(f); + fseek(f, 0, SEEK_SET); + *out_stdout = malloc((size_t)len + 1); + TEST_ASSERT_NOT_NULL(*out_stdout); + size_t n = fread(*out_stdout, 1, (size_t)len, f); + (*out_stdout)[n] = '\0'; + fclose(f); +} + +/* ---- reslist_json_row ---------------------------------------------- */ + +static void test_reslist_json_row_maps_every_table_field(void) +{ + cJSON* array = listfmt_json_begin(); + cJSON* row = listfmt_json_new_row(array); + + reslist_json_row(row, "mem", 0x2a, 60, "physical memory"); + + /* Every column cmd_reslist's table header prints (Name, Flags, + * Interval, Description) must be present -- Requirement 5.1. */ + TEST_ASSERT_EQUAL_STRING( + "mem", cJSON_GetStringValue(cJSON_GetObjectItem(row, "name"))); + TEST_ASSERT_EQUAL_STRING("physical memory", cJSON_GetStringValue( + cJSON_GetObjectItem( + row, "description"))); + TEST_ASSERT_EQUAL_FLOAT( + 60.0f, (float)cJSON_GetNumberValue(cJSON_GetObjectItem(row, "interval"))); + + cJSON_Delete(array); +} + +static void test_reslist_json_row_flags_preserves_raw_numeric_value(void) +{ + /* bug-1711 (Requirement 11.4) formats Flags as "0x%x" for the TABLE + * only -- the JSON row must not lose the underlying value. 0x2a == 42 + * decimal; assert the JSON "flags" field carries that same integer, + * not a hex string. */ + cJSON* array = listfmt_json_begin(); + cJSON* row = listfmt_json_new_row(array); + + reslist_json_row(row, "mem", 0x2a, 60, "physical memory"); + + cJSON* flags = cJSON_GetObjectItem(row, "flags"); + TEST_ASSERT_NOT_NULL(flags); + TEST_ASSERT_TRUE(cJSON_IsNumber(flags)); + TEST_ASSERT_EQUAL_FLOAT(42.0f, (float)cJSON_GetNumberValue(flags)); + + cJSON_Delete(array); +} + +static void test_reslist_json_row_long_description_not_truncated(void) +{ + /* Requirement 5.3: no truncation regardless of value length -- the + * table's Description column is a fixed "%-64s" field; a value longer + * than 64 characters exercises exactly the scenario the table format + * would visually misalign but JSON must not lose or clip. */ + enum { LONG_LEN = 300 }; + char long_desc[LONG_LEN + 1]; + for (int i = 0; i < LONG_LEN; i++) + long_desc[i] = (char)('a' + (i % 26)); + long_desc[LONG_LEN] = '\0'; + + cJSON* array = listfmt_json_begin(); + cJSON* row = listfmt_json_new_row(array); + + reslist_json_row(row, "longres", 0, 1, long_desc); + + const char* desc = + cJSON_GetStringValue(cJSON_GetObjectItem(row, "description")); + TEST_ASSERT_NOT_NULL(desc); + TEST_ASSERT_EQUAL_INT(LONG_LEN, (int)strlen(desc)); + TEST_ASSERT_EQUAL_STRING(long_desc, desc); + + cJSON_Delete(array); +} + +static void test_reslist_json_row_noop_on_null_row(void) +{ + /* Must not crash. */ + reslist_json_row(NULL, "mem", 1, 1, "desc"); +} + +/* ---- workerlist_json_row --------------------------------------------- */ + +static void test_workerlist_json_row_maps_every_table_field(void) +{ + cJSON* array = listfmt_json_begin(); + cJSON* row = listfmt_json_new_row(array); + + workerlist_json_row(row, "worker-07", "OK", "linux", "x86_64", 1.0, 8, + 67108864.0, 134217728.0, 268435456.0, 2); + + /* Every column cmd_workerlist's table header prints (HostName, Status, + * HostType, HostModel, CPUfactor, MaxCPU, MaxMem, MaxSwap, MaxTmp, + * NDisks) must be present -- Requirement 5.1. */ + TEST_ASSERT_EQUAL_STRING( + "worker-07", + cJSON_GetStringValue(cJSON_GetObjectItem(row, "hostname"))); + TEST_ASSERT_EQUAL_STRING( + "OK", cJSON_GetStringValue(cJSON_GetObjectItem(row, "status"))); + TEST_ASSERT_EQUAL_STRING( + "linux", cJSON_GetStringValue(cJSON_GetObjectItem(row, "hosttype"))); + TEST_ASSERT_EQUAL_STRING( + "x86_64", + cJSON_GetStringValue(cJSON_GetObjectItem(row, "hostmodel"))); + TEST_ASSERT_EQUAL_FLOAT( + 8.0f, (float)cJSON_GetNumberValue(cJSON_GetObjectItem(row, "maxcpu"))); + TEST_ASSERT_EQUAL_FLOAT( + 2.0f, (float)cJSON_GetNumberValue(cJSON_GetObjectItem(row, "ndisks"))); + + cJSON_Delete(array); +} + +static void test_workerlist_json_row_maxmem_is_raw_bytes_not_humanized(void) +{ + /* The table humanizes MaxMem/MaxSwap/MaxTmp via format_bytes_human() + * (e.g. "64.0 MB") for DISPLAY only; the JSON row must carry the raw + * byte value instead, so a script can consume it without re-parsing a + * unit suffix. Use a value that is NOT a round unit boundary to prove + * this is the raw byte count, not a rounded "MB" figure. */ + cJSON* array = listfmt_json_begin(); + cJSON* row = listfmt_json_new_row(array); + + workerlist_json_row(row, "worker-01", "OK", "linux", "x86_64", 1.0, 4, + 100000000.0, 0.0, 0.0, 1); + + cJSON* maxmem = cJSON_GetObjectItem(row, "maxmem"); + TEST_ASSERT_NOT_NULL(maxmem); + TEST_ASSERT_TRUE(cJSON_IsNumber(maxmem)); + TEST_ASSERT_EQUAL_FLOAT(100000000.0f, (float)cJSON_GetNumberValue(maxmem)); + + cJSON_Delete(array); +} + +/* This is the fractional-value round-trip test task 2.2's Implementation + * Note explicitly asked for: listfmt_json.c's own tests only exercised + * power-of-two doubles; cpufactor is a genuine command field that is + * commonly fractional (e.g. a machine rated at 1.5x or 0.75x a reference + * host), and this proves the value survives an ACTUAL JSON-text + * round-trip (via listfmt_json_finish()'s real cJSON_Print() path, then + * re-parsed with cJSON_Parse()) bit-for-bit, not just while sitting in the + * in-memory cJSON tree. */ +static void test_workerlist_json_row_cpufactor_fractional_value_roundtrips(void) +{ + cJSON* array = listfmt_json_begin(); + cJSON* row = listfmt_json_new_row(array); + + workerlist_json_row(row, "worker-09", "OK", "linux", "x86_64", 12.34, 4, + 4294967296.0, 0.0, 0.0, 1); + + fresh_dir(); + capture_t cap; + capture_begin(&cap); + listfmt_json_finish(array); /* prints then frees `array`/`row` */ + char* out; + capture_end(&cap, &out); + + cJSON* parsed = cJSON_Parse(out); + TEST_ASSERT_NOT_NULL(parsed); + TEST_ASSERT_EQUAL_INT(1, cJSON_GetArraySize(parsed)); + + cJSON* p0 = cJSON_GetArrayItem(parsed, 0); + cJSON* cpufactor = cJSON_GetObjectItem(p0, "cpufactor"); + TEST_ASSERT_NOT_NULL(cpufactor); + TEST_ASSERT_TRUE(cJSON_IsNumber(cpufactor)); + /* Unity is built without UNITY_INCLUDE_DOUBLE (this codebase's + * convention) -- both sides are cast to float for comparison, same as + * test_listfmt_json.c's own pattern; since 12.34 round-trips exactly + * at double precision (cJSON's shortest-round-trippable-double + * formatting), the float cast of both sides is still an exact + * equality check, not merely "close enough". */ + TEST_ASSERT_EQUAL_FLOAT(12.34f, (float)cJSON_GetNumberValue(cpufactor)); + + /* A non-power-of-two maxmem value in the SAME row must also survive, + * proving the fractional field didn't corrupt a neighboring one. */ + TEST_ASSERT_EQUAL_FLOAT( + 4294967296.0f, + (float)cJSON_GetNumberValue(cJSON_GetObjectItem(p0, "maxmem"))); + + cJSON_Delete(parsed); + free(out); + cleanup_dir(); +} + +static void test_workerlist_json_row_noop_on_null_row(void) +{ + workerlist_json_row(NULL, "h", "OK", "t", "m", 1.0, 1, 1.0, 1.0, 1.0, 1); +} + +/* ---- joblist_json_row -------------------------------------------------- */ + +static void test_joblist_json_row_maps_every_table_field(void) +{ + cJSON* array = listfmt_json_begin(); + cJSON* row = listfmt_json_new_row(array); + + joblist_json_row(row, "1.0", "alice", "normal", "RUN", + (time_t)1000, (time_t)1010, (time_t)0, 2, 4, + "/bin/sleep 100"); + + /* Every column joblist_query's table header prints (JobId, User, + * Queue, State, SubmitTime, StartTime, FinishTime, MinTasks, MaxTasks, + * Command) must be present -- Requirement 5.1, including the + * mintasks/maxtasks pair as two distinct fields (mirroring + * Requirement 11.6's table-side fix, not a single ambiguous + * "ntasks"). */ + TEST_ASSERT_EQUAL_STRING( + "1.0", cJSON_GetStringValue(cJSON_GetObjectItem(row, "jobid"))); + TEST_ASSERT_EQUAL_STRING( + "alice", cJSON_GetStringValue(cJSON_GetObjectItem(row, "user"))); + TEST_ASSERT_EQUAL_STRING( + "normal", cJSON_GetStringValue(cJSON_GetObjectItem(row, "queue"))); + TEST_ASSERT_EQUAL_STRING( + "RUN", cJSON_GetStringValue(cJSON_GetObjectItem(row, "state"))); + TEST_ASSERT_EQUAL_STRING( + "/bin/sleep 100", + cJSON_GetStringValue(cJSON_GetObjectItem(row, "command"))); + TEST_ASSERT_EQUAL_FLOAT( + 1000.0f, + (float)cJSON_GetNumberValue(cJSON_GetObjectItem(row, "submit_time"))); + TEST_ASSERT_EQUAL_FLOAT( + 1010.0f, + (float)cJSON_GetNumberValue(cJSON_GetObjectItem(row, "start_time"))); + TEST_ASSERT_EQUAL_FLOAT( + 0.0f, + (float)cJSON_GetNumberValue(cJSON_GetObjectItem(row, "finish_time"))); + TEST_ASSERT_EQUAL_FLOAT( + 2.0f, + (float)cJSON_GetNumberValue(cJSON_GetObjectItem(row, "mintasks"))); + TEST_ASSERT_EQUAL_FLOAT( + 4.0f, + (float)cJSON_GetNumberValue(cJSON_GetObjectItem(row, "maxtasks"))); + + cJSON_Delete(array); +} + +static void test_joblist_json_row_ambiguous_state_fallback_kept_verbatim(void) +{ + /* Requirement 11.7's raw-state fallback ("RUN(0x38)"-style text from + * job_state_str()) must not be re-interpreted or stripped -- the JSON + * row stores whatever string the caller passed in. */ + cJSON* array = listfmt_json_begin(); + cJSON* row = listfmt_json_new_row(array); + + joblist_json_row(row, "2.0", "bob", "normal", "RUN(0x38)", (time_t)5, + (time_t)6, (time_t)7, 1, 1, "cmd"); + + TEST_ASSERT_EQUAL_STRING( + "RUN(0x38)", + cJSON_GetStringValue(cJSON_GetObjectItem(row, "state"))); + + cJSON_Delete(array); +} + +static void test_joblist_json_row_unstarted_job_times_are_zero_not_dash(void) +{ + /* format_job_time() renders a 0 time_t as "-" for the human table + * (Requirement 11.5); the JSON row instead keeps the raw 0 as a + * NUMBER (not the string "-") so a script can compare/sort on it + * without special-casing a sentinel string. */ + cJSON* array = listfmt_json_begin(); + cJSON* row = listfmt_json_new_row(array); + + joblist_json_row(row, "3.0", "carol", "normal", "PEND", (time_t)0, + (time_t)0, (time_t)0, 1, 1, "cmd"); + + cJSON* start = cJSON_GetObjectItem(row, "start_time"); + TEST_ASSERT_NOT_NULL(start); + TEST_ASSERT_TRUE(cJSON_IsNumber(start)); + TEST_ASSERT_EQUAL_FLOAT(0.0f, (float)cJSON_GetNumberValue(start)); + + cJSON_Delete(array); +} + +static void test_joblist_json_row_noop_on_null_row(void) +{ + joblist_json_row(NULL, "1.0", "u", "q", "RUN", 1, 1, 1, 1, 1, "c"); +} + +void setUp(void) { return; } +void tearDown(void) { return; } + +int main(void) +{ + printf("\n>> xce-test-cli-ux task 3.4: listrow_json JSON-row " + "field-mapping helpers (reslist/workerlist/joblist)\n\n"); + + UNITY_BEGIN(); + + RUN_TEST(test_reslist_json_row_maps_every_table_field); + RUN_TEST(test_reslist_json_row_flags_preserves_raw_numeric_value); + RUN_TEST(test_reslist_json_row_long_description_not_truncated); + RUN_TEST(test_reslist_json_row_noop_on_null_row); + + RUN_TEST(test_workerlist_json_row_maps_every_table_field); + RUN_TEST(test_workerlist_json_row_maxmem_is_raw_bytes_not_humanized); + RUN_TEST(test_workerlist_json_row_cpufactor_fractional_value_roundtrips); + RUN_TEST(test_workerlist_json_row_noop_on_null_row); + + RUN_TEST(test_joblist_json_row_maps_every_table_field); + RUN_TEST(test_joblist_json_row_ambiguous_state_fallback_kept_verbatim); + RUN_TEST(test_joblist_json_row_unstarted_job_times_are_zero_not_dash); + RUN_TEST(test_joblist_json_row_noop_on_null_row); + + return UNITY_END(); +} diff --git a/src/controller/testsuite/test_test_cli.c b/src/controller/testsuite/test_test_cli.c index 01e6fd1f..2b31de4f 100644 --- a/src/controller/testsuite/test_test_cli.c +++ b/src/controller/testsuite/test_test_cli.c @@ -1438,6 +1438,179 @@ static void test_walcompare_unrecognized_flag_rejected_with_usage(void) clitest_buf_free(&err); } +/* + * test_listing_commands_json_flag_accepted_and_bogus_flag_rejected -- + * xce-test-cli-ux task 3.4, Requirement 5.1, 5.2, 6.2, 2.1: workerlist/ + * joblist/reslist are migrated onto the shared flagspec_t library for the + * FIRST time by this task (none accepted any flag before it) -- this + * proves the CLI surface of that migration end-to-end against the real + * built binary: a genuine typo is rejected via the same flagspec_reject() + * mechanism every other migrated command uses (Requirement 2.1), and the + * new --json flag itself is recognized (not confused with an unrecognized + * flag) by all three, using the IDENTICAL long_opt spelling across all + * three commands' flagspec_t tables (Requirement 6.2). + * + * No live controller is running in this test, so --json's underlying RPC + * still fails -- but per this task's own validation guidance, that + * failure must be a NORMAL command failure (the same perror()-based + * "xce_reslist"/"xce_workerlist"/"xce_joblist" label each command's + * pre-existing non-flag failure path already uses), never the + * "unrecognized flag" rejection text -- proving --json parsed + * successfully and execution proceeded past the getopt_long() loop into + * the RPC call. The JSON field-mapping/content correctness itself + * (Requirement 5.1 "same fields", 5.3 "no truncation") is covered at the + * unit level by test_listrow_json.c, which needs no live cluster and so + * can assert on the actual JSON text -- this black-box test is + * deliberately scoped to the CLI-parsing surface only. + */ +static void test_listing_commands_json_flag_accepted_and_bogus_flag_rejected( + 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, "client")); + TEST_ASSERT_TRUE_MESSAGE( + clitest_wait_for_text(&sess, &out, &err, "xce/client>", false, 5000), + "expected to enter the client context"); + + /* A genuine typo on each of the three commands must still be rejected + * exactly like every other migrated command (Requirement 2.1) -- this + * is the FIRST flag these three commands have ever accepted, so it is + * worth proving the migration itself (flagspec_build_opts/ + * flagspec_reject wiring), not just the new --json flag in isolation. */ + TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "reslist --bogus")); + TEST_ASSERT_TRUE_MESSAGE( + clitest_wait_for_text(&sess, &out, &err, + "reslist: unrecognized flag '--bogus'", true, + 5000), + "reslist must reject an unrecognized flag (Requirement 2.1)"); + TEST_ASSERT_TRUE_MESSAGE( + clitest_wait_for_text(&sess, &out, &err, "usage: reslist", true, + 5000), + "the rejection must include reslist's usage summary, mentioning " + "--json (Requirement 2.4, 5.4)"); + + TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "workerlist --bogus")); + TEST_ASSERT_TRUE_MESSAGE( + clitest_wait_for_text(&sess, &out, &err, + "workerlist: unrecognized flag '--bogus'", + true, 5000), + "workerlist must reject an unrecognized flag (Requirement 2.1)"); + + TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "joblist --bogus")); + TEST_ASSERT_TRUE_MESSAGE( + clitest_wait_for_text(&sess, &out, &err, + "joblist: unrecognized flag '--bogus'", true, + 5000), + "joblist must reject an unrecognized flag (Requirement 2.1)"); + + /* Now the new --json flag itself: reset the captured buffers first so + * each check below is judged against only ITS OWN command's output, + * not contaminated by the "unrecognized flag" text already captured + * above for the deliberate typos. */ + clitest_buf_free(&out); + clitest_buf_init(&out); + clitest_buf_free(&err); + clitest_buf_init(&err); + + TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "reslist --json")); + TEST_ASSERT_EQUAL_INT( + 0, clitest_send_line(&sess, "echo marker_reslist_json_done")); + TEST_ASSERT_TRUE_MESSAGE( + clitest_wait_for_text(&sess, &out, &err, "marker_reslist_json_done", + false, 5000), + "reslist --json must run to completion (not hang) even without a " + "live cluster"); + TEST_ASSERT_NULL_MESSAGE( + strstr(err.data, "unrecognized flag"), + "--json must be a recognized flag for reslist, not rejected " + "(Requirement 6.2: identical long_opt spelling across all three " + "listing commands)"); + TEST_ASSERT_NOT_NULL_MESSAGE( + strstr(err.data, "xce_reslist"), + "with --json accepted, execution must reach reslist's normal " + "(non-flag-parse) RPC failure path, proving parsing succeeded"); + + clitest_buf_free(&out); + clitest_buf_init(&out); + clitest_buf_free(&err); + clitest_buf_init(&err); + + TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "workerlist --json")); + TEST_ASSERT_EQUAL_INT( + 0, clitest_send_line(&sess, "echo marker_workerlist_json_done")); + TEST_ASSERT_TRUE_MESSAGE( + clitest_wait_for_text(&sess, &out, &err, + "marker_workerlist_json_done", false, 5000), + "workerlist --json must run to completion (not hang) even without " + "a live cluster"); + TEST_ASSERT_NULL_MESSAGE( + strstr(err.data, "unrecognized flag"), + "--json must be a recognized flag for workerlist, not rejected " + "(Requirement 6.2)"); + TEST_ASSERT_NOT_NULL_MESSAGE( + strstr(err.data, "xce_workerlist"), + "with --json accepted, execution must reach workerlist's normal " + "(non-flag-parse) RPC failure path, proving parsing succeeded"); + + clitest_buf_free(&out); + clitest_buf_init(&out); + clitest_buf_free(&err); + clitest_buf_init(&err); + + TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "joblist --json")); + TEST_ASSERT_EQUAL_INT( + 0, clitest_send_line(&sess, "echo marker_joblist_json_done")); + TEST_ASSERT_TRUE_MESSAGE( + clitest_wait_for_text(&sess, &out, &err, "marker_joblist_json_done", + false, 5000), + "joblist --json must run to completion (not hang) even without a " + "live cluster"); + TEST_ASSERT_NULL_MESSAGE( + strstr(err.data, "unrecognized flag"), + "--json must be a recognized flag for joblist, not rejected " + "(Requirement 6.2)"); + TEST_ASSERT_NOT_NULL_MESSAGE( + strstr(err.data, "xce_joblist"), + "with --json accepted, execution must reach joblist's normal " + "(non-flag-parse) RPC failure path, proving parsing succeeded"); + + 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 three rejected --bogus invocations above must mark the " + "session failed"); + + clitest_kill_and_reap(&sess); + clitest_close(&sess); + clitest_buf_free(&out); + clitest_buf_free(&err); +} + /* ------------------------------------------------------------------ */ /* Interruptible Call Adapter end-to-end (task 3.2, Requirement 1) */ /* ------------------------------------------------------------------ */ @@ -2319,6 +2492,7 @@ void test_test_cli(void) RUN_TEST(test_qctrl_flag_forms_short_and_long_both_accepted); RUN_TEST(test_migrated_commands_sample_reject_unrecognized_flag); RUN_TEST(test_walcompare_unrecognized_flag_rejected_with_usage); + RUN_TEST(test_listing_commands_json_flag_accepted_and_bogus_flag_rejected); RUN_TEST(test_interrupt_abandons_blocked_rpc_well_before_timeout); RUN_TEST(test_interrupt_at_empty_prompt_discards_line_without_exiting); RUN_TEST(test_interrupt_then_immediate_teardown_no_uaf);