test: ww-native behavior harness (runww.ww) + 2 pilot cases

First piece of the Go-model test rebuild: runww.ww is a ww program (the
test/run.go analog) that drives compiler cases through BOTH stages and
asserts behavior/diagnostics. Directives //ww:run / //ww:run-exit N /
//ww:error "<substr>" / //ww:compile; spawn+stderr-capture lift the
driver's procrun (main.ww:160) + dup2(2). The //ww:error check requires
rc!=0 AND the diagnostic substring (the #20 non-vacuity guard -- a crash
can't pass), and a malformed error directive fails loudly.

Bulk corpus migration + ww test wiring are follow-up folds; case spawns
need os.envp() (#28) and per-pid /tmp paths (#29) first.
This commit is contained in:
2026-06-21 15:28:59 +09:00
parent 0f0910ae13
commit b817e5d498
3 changed files with 326 additions and 0 deletions

311
test/runww.ww Normal file
View File

@@ -0,0 +1,311 @@
// runww — ww-native behavior-test harness, the analog of Go's
// test/run.go. Each case file under test/wcc/data/<name>/case.ww carries
// a leading `//ww:` directive describing how to test it; runww builds (and
// optionally runs) the case through BOTH compiler stages and checks the
// directive holds. This replaces the per-test C harnesses that embedded ww
// source as C string literals and duplicated fork/exec/grep boilerplate.
//
// Directives (first `//ww:` line of the case):
// //ww:run compile + execute, expect exit 0
// //ww:run-exit N compile + execute, expect exit N
// //ww:error "<sub>" compilation must FAIL with <sub> on stderr
// //ww:compile compile must succeed (don't run)
//
// Invoke (pilot): `BIN=out/bin out/bin/ww run test/runww.ww <case.ww>...`.
// Full `ww test` integration is a later fold.
//
// Spawn + stderr capture are lifted from the driver's procrun
// (selfhost/cmd/ww/main.ww:154: fork+execve+wait4+real-exit-code); the
// only addition is the child-side dup2(efd, 2) redirect, mirroring the C
// harnesses' `2>errf` so the parent can grep the diagnostic. #20
// non-vacuity: a `//ww:error` row passes ONLY if the build failed AND the
// diagnostic substring is present — a crash (no diagnostic) FAILS the row.
//
// Env: the harness execve's the child with a nil envp, so a case that
// `import`s a lib module (needing WW_SRCLIB/WW_LIB) is out of pilot scope
// until os exposes an envp() forwarder; the pilot cases are self-contained.
package main;
import os;
type dkind = enum i32 {
NONE = 0,
RUN = 1,
RUNEXIT = 2,
ERROR = 3,
COMPILE = 4,
};
type dirv = struct {
kind: dkind,
code: i32, // expected exit for RUNEXIT
subp: *u8, // diagnostic substring (view into the case buffer)
subn: i32,
};
fn pr(s: str) void = {
os.write(1i32, s.ptr, s.len: u64);
};
fn mkstr(p: *u8, n: i32) str = {
let s: str;
s.ptr = p;
s.len = n;
return s;
};
// cstr — NUL-terminated heap copy for an execve argv element.
fn cstr(s: str) *u8 = {
let b: []u8 = alloc([], (s.len: u64) + 1u64)!;
b.len = s.len + 1;
let i: i32 = 0;
for (i < s.len) { b[i] = s[i]; i += 1; };
b[s.len] = 0u8;
return b.ptr;
};
fn joinp(dir: str, name: str) str = {
let n: i32 = dir.len + name.len;
let b: []u8 = alloc([], n: u64)!;
b.len = n;
let i: i32 = 0;
for (i < dir.len) { b[i] = dir[i]; i += 1; };
let j: i32 = 0;
for (j < name.len) { b[dir.len + j] = name[j]; j += 1; };
return mkstr(b.ptr, n);
};
fn findsub(hp: *u8, hn: i32, np: *u8, nn: i32) i32 = {
if (nn == 0) { return 0; };
let i: i32 = 0;
for (i + nn <= hn) {
let j: i32 = 0;
let ok: bool = true;
for (j < nn) {
if (hp[i + j] != np[j]) { ok = false; break; };
j += 1;
};
if (ok) { return i; };
i += 1;
};
return -1;
};
fn contains(hp: *u8, hn: i32, needle: str) bool = {
return findsub(hp, hn, needle.ptr, needle.len) >= 0;
};
fn matchat(hp: *u8, hn: i32, off: i32, lit: str) bool = {
if (off + lit.len > hn) { return false; };
let i: i32 = 0;
for (i < lit.len) {
if (hp[off + i] != lit[i]) { return false; };
i += 1;
};
return true;
};
// readfile — slurp `path` into `buf`, returning bytes read or -1.
fn readfile(path: str, buf: []u8) i64 = {
let fd: i32 = os.open(path, os.flag.RDONLY, 0i32);
if (fd < 0) { return -1i64; };
let total: u64 = 0u64;
for (true) {
let r: i64 = os.read(fd, buf.ptr + total, (buf.len: u64) - total);
if (r <= 0i64) { break; };
total += r: u64;
if (total >= buf.len: u64) { break; };
};
os.close(fd);
return total: i64;
};
fn parsedir(cp: *u8, cn: i32, out: *dirv) void = {
out.kind = dkind.NONE;
out.code = 0;
out.subp = nil: *u8;
out.subn = 0;
let i: i32 = findsub(cp, cn, "//ww:".ptr, 5i32);
if (i < 0) { return; };
let d: i32 = i + 5; // first byte after "//ww:"
let le: i32 = d; // end of the directive line
for (le < cn) {
if (cp[le] == 10u8) { break; }; // '\n'
le += 1;
};
if (matchat(cp, cn, d, "run-exit")) {
out.kind = dkind.RUNEXIT;
let k: i32 = d + 8;
for (k < le) { if (cp[k] != 32u8) { break; }; k += 1; }; // skip ' '
let v: i32 = 0;
for (k < le) {
let c: u8 = cp[k];
if (c < 48u8) { break; };
if (c > 57u8) { break; };
v = v * 10 + (c - 48u8): i32;
k += 1;
};
out.code = v;
return;
};
if (matchat(cp, cn, d, "run")) { out.kind = dkind.RUN; return; };
if (matchat(cp, cn, d, "compile")) { out.kind = dkind.COMPILE; return; };
if (matchat(cp, cn, d, "error")) {
out.kind = dkind.ERROR;
// substring between the two double-quote bytes (34)
let q1: i32 = -1;
let k: i32 = d;
for (k < le) { if (cp[k] == 34u8) { q1 = k; break; }; k += 1; };
// #20 non-vacuity: an absent or empty substring would collapse the
// ERROR check to a bare rc!=0, which a crash satisfies — the exact
// hole this harness closes. Reject the directive loudly instead.
if (q1 < 0) { out.kind = dkind.NONE; return; };
let q2: i32 = -1;
k = q1 + 1;
for (k < le) { if (cp[k] == 34u8) { q2 = k; break; }; k += 1; };
if (q2 < 0) { out.kind = dkind.NONE; return; };
if (q2 == q1 + 1) { out.kind = dkind.NONE; return; };
out.subp = cp + (q1 + 1): u64;
out.subn = q2 - (q1 + 1);
return;
};
};
// runcap — fork+execve `drv` with `argv`, child stderr redirected into
// errpath. Returns the child's real exit code (1 on signal, -1 on
// fork/wait failure). Lifted from selfhost/cmd/ww/main.ww:154 procrun.
fn runcap(drv: str, argv: []*u8, errpath: str) i32 = {
let pid: i32 = os.fork();
if (pid < 0) { return -1; };
if (pid == 0) {
let efd: i32 = os.open(errpath,
os.flag.WRONLY | os.flag.CREATE | os.flag.TRUNC, 420i32); // 0o644
if (efd >= 0) { os.dup2(efd, 2i32); };
os.execve(drv, argv.ptr, nil: **u8);
os.exit(127);
};
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;
};
// runcase — build (and maybe run) one case through `drv`; return whether
// the directive held.
fn runcase(drv: str, casepath: str, d: *dirv, errpath: str, tmpout: str) bool = {
let argv: []*u8 = alloc([], 6u64)!;
if (d.kind == dkind.COMPILE) {
argv.len = 6;
argv[0] = cstr(drv);
argv[1] = cstr("build");
argv[2] = cstr("-o");
argv[3] = cstr(tmpout);
argv[4] = cstr(casepath);
argv[5] = nil: *u8;
} else {
// RUN / RUNEXIT / ERROR all go through `ww run`: on a reject the
// compile fails before execution, so the diagnostic still lands on
// stderr and nothing is left to clean up.
argv.len = 4;
argv[0] = cstr(drv);
argv[1] = cstr("run");
argv[2] = cstr(casepath);
argv[3] = nil: *u8;
};
let rc: i32 = runcap(drv, argv, errpath);
if (d.kind == dkind.ERROR) {
if (rc == 0) { return false; }; // built+ran ok → not a reject
let eb: []u8 = alloc([], 65536u64)!;
eb.len = 65536;
let en: i64 = readfile(errpath, eb);
if (en < 0i64) { return false; };
// #20: rc!=0 AND the diagnostic body present (a crash has no body).
return contains(eb.ptr, en: i32, mkstr(d.subp, d.subn));
};
if (d.kind == dkind.RUNEXIT) { return rc == d.code; };
if (d.kind == dkind.RUN) { return rc == 0; };
if (d.kind == dkind.COMPILE) { return rc == 0; };
return false;
};
export fn main() int = {
let a: []str = os.args();
if (a.len < 2) {
pr("runww: usage: runww <case.ww>...\n");
return 2;
};
let bin: str = "out/bin";
match (os.getenv("BIN")) {
case let v: str => { bin = v; };
case void => { };
};
let cdrv: str = joinp(bin, "/ww");
let wdrv: str = joinp(bin, "/ww_ww");
let wwok: bool = os.access(wdrv, 1i32) == 0; // X_OK gate, mirrors C harness
// Fixed scratch paths: the pilot drives cases sequentially in one
// process, so reuse is safe; per-pid uniqueness for parallel invocation
// is a later wiring fold (task #29).
let errpath: str = "/tmp/runww.err";
let tmpout: str = "/tmp/runww.bin";
let total: i32 = 0;
let fail: i32 = 0;
let ci: i32 = 1;
for (ci < a.len) {
let casepath: str = a[ci];
let cbuf: []u8 = alloc([], 65536u64)!;
cbuf.len = 65536;
let cn: i64 = readfile(casepath, cbuf);
if (cn < 0i64) {
pr("FAIL read "); pr(casepath); pr("\n");
fail += 1; total += 1; ci += 1;
continue;
};
let d: dirv;
parsedir(cbuf.ptr, cn: i32, &d);
if (d.kind == dkind.NONE) {
pr("FAIL no-directive "); pr(casepath); pr("\n");
fail += 1; total += 1; ci += 1;
continue;
};
total += 1;
if (runcase(cdrv, casepath, &d, errpath, tmpout)) {
pr("PASS cstage "); pr(casepath); pr("\n");
} else {
pr("FAIL cstage "); pr(casepath); pr("\n");
fail += 1;
};
if (wwok) {
total += 1;
if (runcase(wdrv, casepath, &d, errpath, tmpout)) {
pr("PASS wwstage "); pr(casepath); pr("\n");
} else {
pr("FAIL wwstage "); pr(casepath); pr("\n");
fail += 1;
};
};
ci += 1;
};
if (fail > 0) {
pr("runww: FAIL\n");
return 1;
};
pr("runww: all ok\n");
return 0;
};

View File

@@ -0,0 +1,8 @@
//ww:error "enum storage type must be integer"
// Lifted from test/wcc/850_enum_reject.c reject_row "nonint_stor": a
// non-integer enum storage type must be LOUD-rejected by both stages.
// The directive's substring is the shared diagnostic body (no file:line
// prefix, which differs cstage vs wwstage) — the #20 non-vacuity anchor.
package main;
type e = enum f64 { A };
fn main() i32 = { return e.A as i32; };

View File

@@ -0,0 +1,7 @@
//ww:run-exit 6
// Lifted from test/wcc/850_enum_reject.c ok_row "distinct": a valid enum
// with forward member arithmetic must BUILD and run. main returns
// e.A (0) + e.C (B+1 = 6) == 6, signalled via the process exit code.
package main;
type e = enum { A, B = 5, C = B + 1 };
fn main() i32 = { return e.A as i32 + e.C as i32; };