Files
ww/lib/test/run.ww
Hojun-Cho 939c984f51 wcc,ww: prepend synth use test; user fn run coexists with runner (M4 E2, #80)
The -T harness synthesized `use test;` after name-binding, so the lib/test runner run keyed the bare scope and collided with a user-defined bare fn run — a spurious "duplicate fn run" reject (the E1 tolerance seam). Prepending the synth use before binding keys the runner as test.run in the test module namespace, distinct from the user bare run; the two coexist. Hare-faithful: the runner is its own test module (ref/hare/test/+test.ha:97). Inverts attest_userrun.ww from the #23-mandated reject to a coexist fixture; gate asserts exactly 1 TEXT run + 1 TEXT test.run on the -T asm (distinct symbols, not a dead-dup). Closes #80.
2026-06-17 23:54:21 +09:00

162 lines
6.3 KiB
Plaintext

package test;
// Plan-9-lean port of Hare's @test runner (ref/hare/test/+test.ha
// __test_main:97-115, run_test:284, do_test:250). MECHANISM DIVERGENCE
// (drew-17-attest-spec.md §a; rob ruling 2026-06-10, task #17): Hare
// isolates each test in ONE process via arch::setjmp + an rt::onabort
// hook + a SIGSEGV signal handler; ww has none of those primitives, so
// we fork per test and read the child's wait-status. A clean child
// exit(0) is a pass; abort/div0/SIGSEGV/nonzero-exit all surface as a
// failing child status, so the fork boundary catches EVERY fault class
// the setjmp+signal path catches, at a process boundary. The proven
// fork+wait4+WEXITSTATUS decode is procrun (selfhost/cmd/ww/main.ww:
// 154-177). CONSEQUENCE (sanctioned): module globals do NOT persist
// test-to-test — each test runs in a fresh fork snapshot. Hare's tests
// share one process, so its globals persist; ww's are hermetic.
//
// D1/D4/D5 reductions also retained from the -T synth era: no
// __test_array linker section (the synth hands us a value table), no
// sort, no file:line reflection.
//
// fnmatch NAME-FILTER (task #17 commit-3, drew spec §d): when the test
// binary is invoked with trailing argv (the driver passes `ww test
// <file> [pattern]` through as argv[1..]), each pattern is a shell glob
// matched against the test name via [[fnmatch.fnmatch]]; a test runs iff
// some pattern matches. No pattern (argv.len == 1) runs ALL tests — the
// no-filter path is byte-for-byte the pre-filter behavior. Zero matches
// prints "No tests run" and exits 0, mirroring Hare's __test_main
// (ref/hare/test/+test.ha:104-117). The synth (`run(__wwtests)`) and the
// value table are UNCHANGED — the pattern reaches us via os.args, so
// 990-997 byte-id and the no-pattern path hold.
//
// BUNDLE-FLOOR CONSEQUENCE (sanctioned, task #17): importing fnmatch
// raises lib/test's auto-bundle floor from os-only to
// os+fnmatch+ascii+strings (+their transitive bytes/encoding.utf8/types/
// rt) in EVERY -T build. fnmatch's module-private symbols co-bundle into
// the same flat "" scope as the test's modules (pre-#8, no package
// isolation); a same-named module-private fn in a tested module would
// collide. Accepted as the cost of name-filtering; #8 (real packages)
// retires it.
import fnmatch;
import os;
// run — execute each test in `tests` in a forked subprocess, report a
// per-test status line, and return the failure count (the synthesized
// `export fn main()` returns this, so the process exits nonzero iff any
// test failed). The table is `[](str, *fn() void)` — Hare models a test
// as `struct { name: str, func: *fn() void }` (+test.ha:23-26); the -T
// synth emits the value-table tuple form (rob ruling), so the runner
// reads `.0`/`.1` off each row rather than named fields.
//
// The private helpers carry a `tst` prefix: until real packages (#8) give
// lib/test symbol isolation, every -T build flat-bundles this module into
// the SAME bare-leaf scope as the test's own modules, so an un-prefixed
// `puts`/`runone` collides with a same-named module-private fn (lib/dirs
// and lib/temp both ship a private `puts(off, s)`). `run` is the bound
// runner name (the synth's callee), reached module-qualified as `test.run`:
// the -T synth prepends `use test;` before name-binding (#80), so this
// runner keys into the `test` module namespace — a symbol distinct from any
// bare user `fn run` in the @test unit. The two COEXIST: `run` and
// `test.run` are separate symbols (ref/hare/test/+test.ha:97 — the runner
// is its own `test` module), so a user `fn run` is no longer a duplicate and
// both stages accept it under @test/-T.
export fn run(tests: [](str, *fn() void)) i32 = {
let av: []str = os.args();
let nfail: i32 = 0;
let nrun: i32 = 0;
let i: i32 = 0;
for (i < tests.len) {
let row: (str, *fn() void) = tests[i];
let name: str = row.0;
let f: *fn() void = row.1;
if (tstenabled(name, av)) {
tstputs(name);
tstputs(" ... ");
let st: i32 = tstrunone(f);
if (st == 0) {
tstputs("ok\n");
} else {
tstputs("FAIL\n");
nfail += 1;
};
nrun += 1;
};
i += 1;
};
// Zero tests selected (an over-narrow pattern, or none matched): Hare
// prints "No tests run" and returns 0 regardless of whether a pattern
// was given (ref/hare/test/+test.ha:114-117).
if (nrun == 0) {
tstputs("No tests run\n");
return 0;
};
tstputuint(nrun - nfail);
tstputs(" passed, ");
tstputuint(nfail);
tstputs(" failed\n");
return nfail;
};
// tstenabled — does `name` pass the name-filter? `av` is os.args():
// av[0] is the program name, av[1..] the glob patterns the driver
// forwarded. No patterns (av.len <= 1) enables every test; otherwise a
// test is enabled iff SOME pattern fnmatch-matches (Hare's OR-over-args,
// ref/hare/test/+test.ha:106-113). DIVERGENCE: Hare iterates all of
// os::args including [0]; ww skips [0] — the test binary path is never a
// test-name pattern (the driver passes the pattern as the first
// post-program arg, never argv[0]).
fn tstenabled(name: str, av: []str) bool = {
if (av.len <= 1) { return true; };
let j: i32 = 1;
for (j < av.len) {
if (fnmatch.fnmatch(av[j], name, fnmatch.flag.NONE)) {
return true;
};
j += 1;
};
return false;
};
// runone — fork, run `f` in the child, decode the child's wait-status.
// Byte-for-byte the procrun (main.ww:154-177) status decode: low 7 bits
// are the killing signal (abort=SIGABRT, div0/SIGSEGV), 0 if the child
// exited; next byte is the exit code.
fn tstrunone(f: *fn() void) i32 = {
let pid: i32 = os.fork();
if (pid < 0) { return -1; };
if (pid == 0) {
(*f)();
os.exit(0i32);
};
let status: i32 = 0;
let r: i32 = os.wait4(pid, &status, 0i32, nil: *void);
if (r < 0) { return -1; };
if ((status & 127i32) != 0) { return 1; };
return (status >> 8i32) & 255i32;
};
fn tstputs(s: str) void = {
os.write(os.STDOUT_FILENO, s.ptr, s.len: u64);
};
// tstputuint — write a non-negative i32 in decimal. Lean substitute for
// fmt::printf (Hare's test uses fmt); keeping lib/test's bundle floor at
// os-only avoids pulling the io/strconv stack into every -T build.
fn tstputuint(n: i32) void = {
let buf: [16]u8;
let i: i32 = 16;
let v: i32 = n;
if (v == 0) {
i -= 1;
buf[i] = 48u8;
} else {
for (v > 0) {
i -= 1;
buf[i] = (48 + v % 10): u8;
v = v / 10;
};
};
os.write(os.STDOUT_FILENO, &buf[i], (16 - i): u64);
};