The hash, asked of the second target and compared to the first

sand-headless imports no raylib so that it can run here, and the point of the
case is not that a module exists — it is that the number matches native byte
for byte, which is only possible because rand-f32 is Flan's rather than libc's.
It does, at -O2 and at -O0; -O0 is the cheap way to say the agreement is not a
coincidence of how LLVM folded the float arithmetic. values and machine run
there too, which is where a 32-bit pointer would have shown.

Four separate things can be missing — clang's wasm target, the sysroot, the
builtins, a WASI runtime — so the skip is a probe rather than a lookup: build
the smallest program and run it, and print what went wrong. A which(1) would go
red on the machine where Node is too old, with a reason nobody could read.

No wasmtime and no wasmer here, so the runner is node:wasi, with wasmtime and
wasmer preferred if either appears. --no-warnings because node:wasi writes to
stderr on every run and this harness compares combined output.
This commit is contained in:
Joseph Ferano 2026-09-11 19:43:35 +07:00
parent 48a7186c58
commit b14793517b
3 changed files with 121 additions and 0 deletions

View File

@ -15,6 +15,9 @@
(glob_files programs/*.flan)
; The reload primitive's host: a C main that dlopens what Build.shared made.
(file reload_host.c)
; The WASI host the wasm32 case runs its module under, when no wasmtime or
; wasmer is installed.
(file wasm-run.mjs)
; test_dev runs the compiler itself: flan dev launches and owns a program.
(file %{workspace_root}/bin/main.exe)
; The Emacs client, which test_emacs drives against a real daemon.

View File

@ -325,6 +325,101 @@ let () =
print_endline "FAIL --no-bounds-checks: a check survived"
end;
(* ── wasm32 (NEXT.md, deferred item 6) ──────────────────────────────
The second target, and the reason sand-headless imports no raylib. What
is asserted is not that a wasm module exists it is that it prints the
*same hash* as the native build, byte for byte. That is only possible
because rand-f32 is written in Flan rather than bound to libc, so the
case is the regression test for that decision as much as for the port.
Four independent things can be absent clang's wasm target, the
wasi-libc sysroot, a builtins archive, and a runtime that speaks WASI
so the skip is a *probe*: build the smallest program and run it. A
[which] would go red on the machine where Node is too old, with a
reason nobody could read. *)
let wasm_runner =
if Sys.command "command -v wasmtime > /dev/null 2>&1" = 0 then
Some "wasmtime"
else if Sys.command "command -v wasmer > /dev/null 2>&1" = 0 then
Some "wasmer run"
else if Sys.command "command -v node > /dev/null 2>&1" = 0 then
(* --no-warnings because node:wasi prints an ExperimentalWarning to
stderr on every run, and this harness compares combined output. *)
Some "node --no-warnings wasm-run.mjs"
else None
in
let wasm_build ?(opt = "-O2") path out =
let l = Load.program ~file:path (Parse.program (Reader.read_file path)) in
let p = Check.program l.Load.decls in
ignore
(Build.executable
~opts:{ Build.default with opt; target = Some "wasm32-wasi" }
~csrcs:l.Load.csrcs ~lflags:l.Load.lflags p ~out)
in
let wasm_run runner wasm =
let out = Filename.concat scratch "flan-acceptance-wasm.out" in
let code =
Sys.command
(Printf.sprintf "%s %s > %s 2>&1" runner (Filename.quote wasm)
(Filename.quote out))
in
let text = In_channel.with_open_bin out In_channel.input_all in
(try Sys.remove out with Sys_error _ -> ());
(code, text)
in
(match wasm_runner with
| None ->
print_endline
"acceptance: skipping the wasm32 case (no wasmtime, wasmer or node)"
| Some runner ->
let probe = Filename.concat scratch "flan-wasm-probe.wasm" in
let outcome =
match wasm_build "programs/unit-main.flan" probe with
| () ->
let code, text = wasm_run runner probe in
if code = 0 && text = "ok\n" then Ok ()
else
Error
(Printf.sprintf "%s could not run it: %S (exit %d)" runner text
code)
| exception Failure m -> Error m
in
(try Sys.remove probe with Sys_error _ -> ());
(match outcome with
| Error why ->
Printf.printf "acceptance: skipping the wasm32 case (%s)\n" why
| Ok () ->
let wasm_case name ?opt path expected =
let wasm =
Filename.concat scratch
("flan-w-" ^ Filename.remove_extension (Filename.basename path)
^ ".wasm")
in
wasm_build ?opt path wasm;
let code, text = wasm_run runner wasm in
if text <> expected || code <> 0 then begin
incr failures;
Printf.printf
"FAIL %s\n got: %S (exit %d)\n wanted: %S (exit 0)\n"
name text code expected
end;
(try Sys.remove wasm with Sys_error _ -> ())
in
(* The hash, which must equal the native one above. At both levels:
agreement at -O2 alone could be a coincidence of how LLVM folded
the float arithmetic, and -O0 is the cheap way to say it is not. *)
wasm_case "sand, headless, wasm32" "programs/sand-headless.flan"
sand_out;
wasm_case "sand, headless, wasm32, -O0" ~opt:"-O0"
"programs/sand-headless.flan" sand_out;
(* And the two fixed-output programs, which between them cover the
milestone-2 surface: globals, 2-D arrays, places through a
pointer, casts and match. A 32-bit pointer is the thing most
likely to go wrong and these are where it would show. *)
wasm_case "value semantics, wasm32" "programs/values.flan" values_out;
wasm_case "machine surface, wasm32" "programs/machine.flan"
machine_out));
if !failures = 0 then print_endline "acceptance: all tests passed"
else begin
Printf.printf "\n%d failure(s)\n" !failures;

23
test/wasm-run.mjs Normal file
View File

@ -0,0 +1,23 @@
// A WASI host, so the acceptance table can run a wasm32 build without a
// wasmtime or wasmer being installed. Node has had node:wasi since 16; it is
// still flagged experimental, hence --no-warnings at the call site, and before
// Node 20 it needs --experimental-wasi-unstable-preview1 as well. The
// acceptance test probes rather than assumes: if this file cannot run the
// module, the wasm case is skipped with what went wrong.
//
// preopens is empty on purpose. sand-headless reads nothing and writes one
// line to stdout, which is the whole point of it being the cross-target case.
import { WASI } from 'node:wasi';
import { readFile } from 'node:fs/promises';
const [, , file, ...rest] = process.argv;
const wasi = new WASI({
version: 'preview1',
args: [file, ...rest],
env: {},
preopens: {},
returnOnExit: true,
});
const mod = await WebAssembly.compile(await readFile(file));
const inst = await WebAssembly.instantiate(mod, wasi.getImportObject());
process.exitCode = wasi.start(inst);