test framework phase 1: compile-time registry + El-side runner
Replace the hardcoded test harness main() with a generated static registry and index-based accessors, and move all reporting into runtime/eltest.el. The old harness inlined direct calls into main() and counted assertions in two globals. That shape cannot report which test failed, how long any test took, or whether a test ran at all -- a misspelled registration reported success for a test that never executed. - assertions record into per-test state instead of global counters - registry table emitted at compile time; discovery strictly precedes execution, which is what later enables --list, filtering and sharding - per-test wall timing on CLOCK_MONOTONIC, taken in C around the call - runner in El: structured NDJSON events as source of truth, human output rendered from the same fields
This commit is contained in:
@@ -1705,9 +1705,13 @@ fn cg_stmt(stmt: Map<String, Any>, indent: String, declared: [String]) -> [Strin
|
|||||||
} else {
|
} else {
|
||||||
let c_msg = "EL_STR_PTR(" + cg_expr(msg_node) + ")"
|
let c_msg = "EL_STR_PTR(" + cg_expr(msg_node) + ")"
|
||||||
}
|
}
|
||||||
|
// Assertions record into PER-TEST state, not global counters. The test
|
||||||
|
// is the unit of result; a global pass/fail tally cannot say which test
|
||||||
|
// failed or whether a test ran at all. Reporting is the runner's job —
|
||||||
|
// nothing is printed here.
|
||||||
emit_line(indent + "if (!(" + c_cond + ")) {")
|
emit_line(indent + "if (!(" + c_cond + ")) {")
|
||||||
emit_line(indent + " __el_test_fail(__el_cur_test, " + c_msg + "); __el_fail++;")
|
emit_line(indent + " __el_test_fail(" + c_msg + ");")
|
||||||
emit_line(indent + "} else { __el_pass++; }")
|
emit_line(indent + "} else { __el_cur_asserts++; }")
|
||||||
return declared
|
return declared
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4110,11 +4114,22 @@ fn codegen_streaming(tokens: [Any], sigs: [Map<String, Any>], source: String) ->
|
|||||||
// Emit test harness preamble (counters, fail printer) when in test mode.
|
// Emit test harness preamble (counters, fail printer) when in test mode.
|
||||||
if test_is_mode {
|
if test_is_mode {
|
||||||
emit_line("#include <stdio.h>")
|
emit_line("#include <stdio.h>")
|
||||||
|
emit_line("#include <string.h>")
|
||||||
|
emit_line("#include <time.h>")
|
||||||
emit_blank()
|
emit_blank()
|
||||||
emit_line("static int __el_pass = 0, __el_fail = 0;")
|
// Per-test result state. Reset by __el_reg_invoke before each test, so
|
||||||
|
// every test gets its own record rather than contributing to a global
|
||||||
|
// tally. The first failure message is retained; later ones only bump
|
||||||
|
// the count, which keeps the common case allocation-free.
|
||||||
|
emit_line("static int __el_cur_fails = 0;")
|
||||||
|
emit_line("static int __el_cur_asserts = 0;")
|
||||||
|
emit_line("static char __el_cur_msg[512] = \"\";")
|
||||||
emit_line("static const char *__el_cur_test = \"(none)\";")
|
emit_line("static const char *__el_cur_test = \"(none)\";")
|
||||||
emit_line("static void __el_test_fail(const char *test, const char *msg) {")
|
emit_line("static void __el_test_fail(const char *msg) {")
|
||||||
emit_line(" fprintf(stderr, \"FAIL %-40s %s\\n\", test, msg);")
|
emit_line(" if (__el_cur_fails == 0 && msg) {")
|
||||||
|
emit_line(" snprintf(__el_cur_msg, sizeof __el_cur_msg, \"%s\", msg);")
|
||||||
|
emit_line(" }")
|
||||||
|
emit_line(" __el_cur_fails++; __el_cur_asserts++;")
|
||||||
emit_line("}")
|
emit_line("}")
|
||||||
emit_blank()
|
emit_blank()
|
||||||
}
|
}
|
||||||
@@ -4312,17 +4327,72 @@ fn codegen_streaming(tokens: [Any], sigs: [Map<String, Any>], source: String) ->
|
|||||||
el_release(sigs)
|
el_release(sigs)
|
||||||
|
|
||||||
let test_arena_mark: Any = el_arena_push()
|
let test_arena_mark: Any = el_arena_push()
|
||||||
|
let tn: Int = native_list_len(test_c_names)
|
||||||
|
|
||||||
|
// ── Generated test registry ──────────────────────────────────────────
|
||||||
|
// Discovery happens HERE, at compile time. The runner never searches
|
||||||
|
// for tests; it walks this table. That ordering — discovery strictly
|
||||||
|
// before execution — is what makes --list, filtering, sharding and
|
||||||
|
// per-test reporting possible later, and it is why the old harness
|
||||||
|
// (which inlined direct calls into main) could not have any of them.
|
||||||
|
emit_line("typedef void (*__el_test_fp)(void);")
|
||||||
|
emit_line("typedef struct { const char *name; __el_test_fp fn; } __el_test_entry;")
|
||||||
|
emit_line("static const __el_test_entry __el_registry[] = {")
|
||||||
|
let ri: Int = 0
|
||||||
|
while ri < tn {
|
||||||
|
let r_name: String = native_list_get(test_names, ri)
|
||||||
|
let r_cfn: String = native_list_get(test_c_names, ri)
|
||||||
|
emit_line(" { \"" + c_escape(r_name) + "\", " + r_cfn + " },")
|
||||||
|
let ri = ri + 1
|
||||||
|
}
|
||||||
|
// Trailing sentinel keeps the array non-empty when a file declares no
|
||||||
|
// tests (a zero-length array is not valid C).
|
||||||
|
emit_line(" { 0, 0 }")
|
||||||
|
emit_line("};")
|
||||||
|
emit_line("static const int __el_registry_n = " + int_to_str(tn) + ";")
|
||||||
|
emit_blank()
|
||||||
|
emit_line("static long long __el_last_ns = 0;")
|
||||||
|
emit_line("static int __el_opt_json_v = 0;")
|
||||||
|
emit_blank()
|
||||||
|
|
||||||
|
// ── Index-based accessors ────────────────────────────────────────────
|
||||||
|
// El has no function pointers, so the runner works purely in indices.
|
||||||
|
// This is the whole seam between generated C and the El-side runner.
|
||||||
|
emit_line("el_val_t __el_reg_count(void) { return (el_val_t)(int64_t)__el_registry_n; }")
|
||||||
|
emit_line("el_val_t __el_reg_name(el_val_t i) {")
|
||||||
|
emit_line(" int64_t k = (int64_t)i;")
|
||||||
|
emit_line(" if (k < 0 || k >= __el_registry_n) return EL_STR(\"\");")
|
||||||
|
emit_line(" return EL_STR(__el_registry[k].name);")
|
||||||
|
emit_line("}")
|
||||||
|
// Timing is taken immediately around the call, in C, on the MONOTONIC
|
||||||
|
// clock — never the wall clock, which can step backwards under NTP.
|
||||||
|
emit_line("el_val_t __el_reg_invoke(el_val_t i) {")
|
||||||
|
emit_line(" int64_t k = (int64_t)i;")
|
||||||
|
emit_line(" if (k < 0 || k >= __el_registry_n) return 0;")
|
||||||
|
emit_line(" __el_cur_fails = 0; __el_cur_asserts = 0; __el_cur_msg[0] = '\\0';")
|
||||||
|
emit_line(" __el_cur_test = __el_registry[k].name;")
|
||||||
|
emit_line(" struct timespec _t0, _t1;")
|
||||||
|
emit_line(" clock_gettime(CLOCK_MONOTONIC, &_t0);")
|
||||||
|
emit_line(" __el_registry[k].fn();")
|
||||||
|
emit_line(" clock_gettime(CLOCK_MONOTONIC, &_t1);")
|
||||||
|
emit_line(" __el_last_ns = (long long)(_t1.tv_sec - _t0.tv_sec) * 1000000000LL")
|
||||||
|
emit_line(" + (long long)(_t1.tv_nsec - _t0.tv_nsec);")
|
||||||
|
emit_line(" return (el_val_t)(int64_t)__el_cur_fails;")
|
||||||
|
emit_line("}")
|
||||||
|
emit_line("el_val_t __el_reg_last_ns(void) { return (el_val_t)(int64_t)__el_last_ns; }")
|
||||||
|
emit_line("el_val_t __el_reg_msg(void) { return EL_STR(__el_cur_msg); }")
|
||||||
|
emit_line("el_val_t __el_reg_asserts(void) { return (el_val_t)(int64_t)__el_cur_asserts; }")
|
||||||
|
emit_line("el_val_t __el_opt_json(void) { return (el_val_t)(int64_t)__el_opt_json_v; }")
|
||||||
|
emit_blank()
|
||||||
|
|
||||||
|
// main() delegates to the El-side runner. Everything above this line is
|
||||||
|
// generated glue; all reporting logic lives in runtime/eltest.el.
|
||||||
emit_line("int main(int _argc, char **_argv) {")
|
emit_line("int main(int _argc, char **_argv) {")
|
||||||
emit_line(" el_runtime_init_args(_argc, _argv);")
|
emit_line(" el_runtime_init_args(_argc, _argv);")
|
||||||
let ti: Int = 0
|
emit_line(" for (int _i = 1; _i < _argc; _i++) {")
|
||||||
let tn: Int = native_list_len(test_c_names)
|
emit_line(" if (strcmp(_argv[_i], \"--json\") == 0) __el_opt_json_v = 1;")
|
||||||
while ti < tn {
|
emit_line(" }")
|
||||||
let tc_name: String = native_list_get(test_c_names, ti)
|
emit_line(" return (int)(int64_t)el_test_main();")
|
||||||
emit_line(" " + tc_name + "();")
|
|
||||||
let ti = ti + 1
|
|
||||||
}
|
|
||||||
emit_line(" printf(\"%d passed, %d failed\\n\", __el_pass, __el_fail);")
|
|
||||||
emit_line(" return __el_fail;")
|
|
||||||
emit_line("}")
|
emit_line("}")
|
||||||
el_arena_pop(test_arena_mark)
|
el_arena_pop(test_arena_mark)
|
||||||
el_release(test_names)
|
el_release(test_names)
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
// runtime/eltest.el — El test framework runner (Phase 1).
|
||||||
|
//
|
||||||
|
// This is the RUNNER. It is written in El and consumes a registry that the
|
||||||
|
// compiler generates into the same translation unit when invoked as
|
||||||
|
// `elc --test`. Nothing here discovers tests; discovery already happened at
|
||||||
|
// compile time, which is what makes `--list` and filtering possible later.
|
||||||
|
//
|
||||||
|
// ── Architecture ─────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The compiler lowers each `test "name" { ... }` block into a static C
|
||||||
|
// function and emits a static table of (name, fn) pairs plus a small set of
|
||||||
|
// index-based accessors. El has no function pointers, so the runner never
|
||||||
|
// sees one — it works entirely in indices:
|
||||||
|
//
|
||||||
|
// __el_reg_count() -> Int number of registered tests
|
||||||
|
// __el_reg_name(i) -> String test name at index i
|
||||||
|
// __el_reg_invoke(i) -> Int run test i, return its failure count
|
||||||
|
// __el_reg_last_ns() -> Int wall-clock ns of the last invoke
|
||||||
|
// __el_reg_msg() -> String first failure message of the last invoke
|
||||||
|
// __el_reg_asserts() -> Int assertions executed in the last invoke
|
||||||
|
// __el_opt_json() -> Int 1 if --json was passed
|
||||||
|
//
|
||||||
|
// Timing is taken in the generated C, immediately around the call, so no El
|
||||||
|
// call overhead lands inside the measurement.
|
||||||
|
//
|
||||||
|
// ── Output ───────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Structured events are the source of truth. The human renderer is written
|
||||||
|
// FROM the same fields the NDJSON renderer emits — never the reverse. Parsing
|
||||||
|
// human output back into structure is the one clear architectural mistake in
|
||||||
|
// Go's test tooling and we do not repeat it.
|
||||||
|
//
|
||||||
|
// Every result carries a duration. Always. A framework that cannot report how
|
||||||
|
// long its tests took cannot surface a performance regression, and a
|
||||||
|
// regression nobody can see is one nobody fixes.
|
||||||
|
|
||||||
|
// ── Small helpers (no imports — this file must stay self-contained) ──────────
|
||||||
|
|
||||||
|
// _elt_json_escape — minimal JSON string escaping for the NDJSON renderer.
|
||||||
|
fn _elt_json_escape(s: String) -> String {
|
||||||
|
let out: String = ""
|
||||||
|
let n: Int = str_len(s)
|
||||||
|
let i: Int = 0
|
||||||
|
while i < n {
|
||||||
|
let ch: String = str_slice(s, i, i + 1)
|
||||||
|
if str_eq(ch, "\"") {
|
||||||
|
let out = out + "\\\""
|
||||||
|
} else {
|
||||||
|
if str_eq(ch, "\\") {
|
||||||
|
let out = out + "\\\\"
|
||||||
|
} else {
|
||||||
|
if str_eq(ch, "\n") {
|
||||||
|
let out = out + "\\n"
|
||||||
|
} else {
|
||||||
|
if str_eq(ch, "\t") {
|
||||||
|
let out = out + "\\t"
|
||||||
|
} else {
|
||||||
|
if str_eq(ch, "\r") {
|
||||||
|
let out = out + "\\r"
|
||||||
|
} else {
|
||||||
|
let out = out + ch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let i = i + 1
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// _elt_pad3 — left-pad an integer to three digits (for the ms.fraction form).
|
||||||
|
fn _elt_pad3(v: Int) -> String {
|
||||||
|
if v < 10 { return "00" + int_to_str(v) }
|
||||||
|
if v < 100 { return "0" + int_to_str(v) }
|
||||||
|
return int_to_str(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// _elt_ms — render a nanosecond duration as "M.mmm" milliseconds.
|
||||||
|
//
|
||||||
|
// Deliberately avoids the modulo operator: the remainder is derived by
|
||||||
|
// subtraction so this stays portable across El backends.
|
||||||
|
fn _elt_ms(ns: Int) -> String {
|
||||||
|
let total_us: Int = ns / 1000
|
||||||
|
let ms_whole: Int = total_us / 1000
|
||||||
|
let us_rem: Int = total_us - (ms_whole * 1000)
|
||||||
|
return int_to_str(ms_whole) + "." + _elt_pad3(us_rem)
|
||||||
|
}
|
||||||
|
|
||||||
|
// _elt_secs — render a nanosecond duration as fractional seconds, for the
|
||||||
|
// NDJSON `elapsed` field. JUnit XML and test2json both use seconds-as-decimal.
|
||||||
|
fn _elt_secs(ns: Int) -> String {
|
||||||
|
let total_ms: Int = ns / 1000000
|
||||||
|
let s_whole: Int = total_ms / 1000
|
||||||
|
let ms_rem: Int = total_ms - (s_whole * 1000)
|
||||||
|
return int_to_str(s_whole) + "." + _elt_pad3(ms_rem)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event emission ───────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// One function per event shape. Both renderers read the same fields; the
|
||||||
|
// human renderer is a projection of the event, not a separate code path.
|
||||||
|
|
||||||
|
fn _elt_emit_run(json_mode: Bool, name: String) {
|
||||||
|
if json_mode {
|
||||||
|
println("{\"action\":\"run\",\"test\":\"" + _elt_json_escape(name) + "\"}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn _elt_emit_result(json_mode: Bool, name: String, fails: Int, ns: Int, asserts: Int, msg: String) {
|
||||||
|
if json_mode {
|
||||||
|
let action: String = "pass"
|
||||||
|
if fails > 0 { let action = "fail" }
|
||||||
|
let line: String = "{\"action\":\"" + action + "\""
|
||||||
|
let line = line + ",\"test\":\"" + _elt_json_escape(name) + "\""
|
||||||
|
let line = line + ",\"elapsed\":" + _elt_secs(ns)
|
||||||
|
let line = line + ",\"assertions\":" + int_to_str(asserts)
|
||||||
|
if fails > 0 {
|
||||||
|
let line = line + ",\"failures\":" + int_to_str(fails)
|
||||||
|
let line = line + ",\"message\":\"" + _elt_json_escape(msg) + "\""
|
||||||
|
}
|
||||||
|
let line = line + "}"
|
||||||
|
println(line)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Human renderer — duration is never optional.
|
||||||
|
if fails > 0 {
|
||||||
|
println("FAIL " + name + " (" + _elt_ms(ns) + "ms)")
|
||||||
|
println(" " + msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
println("ok " + name + " (" + _elt_ms(ns) + "ms)")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn _elt_emit_summary(json_mode: Bool, total: Int, failed: Int, ns: Int, asserts: Int) {
|
||||||
|
let passed: Int = total - failed
|
||||||
|
if json_mode {
|
||||||
|
let line: String = "{\"action\":\"summary\""
|
||||||
|
let line = line + ",\"tests\":" + int_to_str(total)
|
||||||
|
let line = line + ",\"passed\":" + int_to_str(passed)
|
||||||
|
let line = line + ",\"failed\":" + int_to_str(failed)
|
||||||
|
let line = line + ",\"assertions\":" + int_to_str(asserts)
|
||||||
|
let line = line + ",\"elapsed\":" + _elt_secs(ns)
|
||||||
|
let line = line + "}"
|
||||||
|
println(line)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
println("")
|
||||||
|
println(int_to_str(total) + " tests, " + int_to_str(passed) + " passed, "
|
||||||
|
+ int_to_str(failed) + " failed, " + int_to_str(asserts) + " assertions in "
|
||||||
|
+ _elt_ms(ns) + "ms")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The runner ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// el_test_main — drive the compile-time registry.
|
||||||
|
//
|
||||||
|
// Called from the generated main(). Returns the number of FAILING TESTS, which
|
||||||
|
// becomes the process exit code. Note that this counts tests, not assertions:
|
||||||
|
// a test is the unit of result. The old harness counted assertions globally and
|
||||||
|
// therefore could not say which test failed, how long any of them took, or
|
||||||
|
// whether a test had run at all.
|
||||||
|
fn el_test_main() -> Int {
|
||||||
|
let json_mode: Bool = false
|
||||||
|
if __el_opt_json() == 1 { let json_mode = true }
|
||||||
|
|
||||||
|
let n: Int = __el_reg_count()
|
||||||
|
let i: Int = 0
|
||||||
|
let failed: Int = 0
|
||||||
|
let total_ns: Int = 0
|
||||||
|
let total_asserts: Int = 0
|
||||||
|
|
||||||
|
while i < n {
|
||||||
|
let name: String = __el_reg_name(i)
|
||||||
|
_elt_emit_run(json_mode, name)
|
||||||
|
|
||||||
|
let fails: Int = __el_reg_invoke(i)
|
||||||
|
let ns: Int = __el_reg_last_ns()
|
||||||
|
let asserts: Int = __el_reg_asserts()
|
||||||
|
let msg: String = __el_reg_msg()
|
||||||
|
|
||||||
|
let total_ns = total_ns + ns
|
||||||
|
let total_asserts = total_asserts + asserts
|
||||||
|
if fails > 0 { let failed = failed + 1 }
|
||||||
|
|
||||||
|
_elt_emit_result(json_mode, name, fails, ns, asserts, msg)
|
||||||
|
let i = i + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_elt_emit_summary(json_mode, n, failed, total_ns, total_asserts)
|
||||||
|
return failed
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user