Add SX test harness: mock IO platform for testing components

spec/harness.sx — spec-level test harness with:
- Mock platform (30+ default IO mocks: fetch, query, DOM, storage, etc.)
- Session management (make-harness, harness-reset!, harness-set!/get)
- IO interception (make-interceptor, install-interceptors)
- IO log queries (io-calls, io-call-count, io-call-nth, io-call-args)
- IO assertions (assert-io-called, assert-no-io, assert-io-count, etc.)

15 harness tests passing on both OCaml (1116/1116) and JS (15/15).
Loaded automatically by both test runners.

MCP tool: sx_harness_eval — evaluate SX with mock IO, returns result + IO trace.

The harness is extensible: new platforms just add entries to the platform dict.
Components can ship with deftest forms that verify IO behavior against mocks.
Tests are independent objects that can be published separately (by CID).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 00:00:19 +00:00
parent 4b733e71b0
commit b1690a92c4
5 changed files with 144 additions and 8 deletions

View File

@@ -279,6 +279,18 @@ for (const expr of frameworkExprs) {
Sx.eval(expr, env);
}
// Load test harness (mock IO platform)
const harnessPath = path.join(projectDir, "spec", "harness.sx");
if (fs.existsSync(harnessPath)) {
const harnessSrc = fs.readFileSync(harnessPath, "utf8");
const harnessExprs = Sx.parse(harnessSrc);
for (const expr of harnessExprs) {
try { Sx.eval(expr, env); } catch (e) {
console.error(`Error loading harness.sx: ${e.message}`);
}
}
}
// Load compiler + VM from lib/ when running full tests
if (fullBuild) {
const libDir = path.join(projectDir, "lib");

View File

@@ -105,6 +105,41 @@ let setup_env () =
| [Keyword k] -> String k | _ -> String "");
bind "make-symbol" (fun args -> match args with
| [String s] -> Symbol s | _ -> Nil);
(* Environment operations needed by harness *)
bind "env-bind!" (fun args -> match args with
| [Env env_val; String name; v] -> ignore (env_bind env_val name v); v
| _ -> Nil);
bind "env-get" (fun args -> match args with
| [Env env_val; String name] -> env_get env_val name
| _ -> Nil);
bind "env-has?" (fun args -> match args with
| [Env env_val; String name] -> Bool (env_has env_val name)
| _ -> Bool false);
bind "make-env" (fun _args -> Env (make_env ()));
bind "keys" (fun args -> match args with
| [Dict d] -> List (Hashtbl.fold (fun k _ acc -> String k :: acc) d [])
| _ -> List []);
bind "get" (fun args -> match args with
| [Dict d; String k] -> (match Hashtbl.find_opt d k with Some v -> v | None -> Nil)
| [Dict d; Keyword k] -> (match Hashtbl.find_opt d k with Some v -> v | None -> Nil)
| [List items; Number n] -> (let i = int_of_float n in if i >= 0 && i < List.length items then List.nth items i else Nil)
| _ -> Nil);
bind "dict-set!" (fun args -> match args with
| [Dict d; String k; v] -> Hashtbl.replace d k v; v
| [Dict d; Keyword k; v] -> Hashtbl.replace d k v; v
| _ -> Nil);
bind "merge" (fun args -> match args with
| [Dict a; Dict b] ->
let d = Hashtbl.create (Hashtbl.length a + Hashtbl.length b) in
Hashtbl.iter (fun k v -> Hashtbl.replace d k v) a;
Hashtbl.iter (fun k v -> Hashtbl.replace d k v) b;
Dict d
| _ -> Nil);
bind "apply" (fun args -> match args with
| [f; List items] | [f; ListRef { contents = items }] ->
Sx_ref.cek_call f (List items)
| _ -> Nil);
bind "current-env" (fun _args -> Env e);
bind "type-of" (fun args -> match args with
| [v] -> String (type_of v) | _ -> String "nil");
bind "list?" (fun args -> match args with
@@ -163,7 +198,10 @@ let setup_env () =
(* Load tree-tools *)
(try load_sx_file e (Filename.concat lib_dir "tree-tools.sx")
with exn -> Printf.eprintf "[mcp] Error: tree-tools.sx load failed: %s\n%!" (Printexc.to_string exn); exit 1);
Printf.eprintf "[mcp] SX tree-tools loaded\n%!";
(* Load harness *)
(try load_sx_file e (Filename.concat spec_dir "harness.sx")
with exn -> Printf.eprintf "[mcp] Warning: harness.sx load failed: %s\n%!" (Printexc.to_string exn));
Printf.eprintf "[mcp] SX tree-tools + harness loaded\n%!";
env := e
(* ------------------------------------------------------------------ *)
@@ -667,6 +705,46 @@ let rec handle_tool name args =
in
text_result result
| "sx_harness_eval" ->
let expr_str = args |> member "expr" |> to_string in
let mock_str = args |> member "mock" |> to_string_option in
let file = args |> member "file" |> to_string_option in
let e = !env in
(* Optionally load a file's definitions *)
(match file with
| Some f ->
(try load_sx_file e f
with exn -> Printf.eprintf "[mcp] Warning: %s: %s\n%!" f (Printexc.to_string exn))
| None -> ());
(* Create harness with optional mock overrides *)
let mock_arg = match mock_str with
| Some s ->
let parsed = Sx_parser.parse_all s in
if parsed <> [] then List [Keyword "platform"; List.hd parsed] else List []
| None -> List []
in
let session = Sx_ref.cek_call (env_get e "make-harness") mock_arg in
(* Install interceptors *)
ignore (call_sx "install-interceptors" [session; Env e]);
(* Evaluate the expression *)
let exprs = Sx_parser.parse_all expr_str in
let result = List.fold_left (fun _acc expr ->
try Sx_ref.eval_expr expr (Env e)
with exn -> String (Printf.sprintf "Error: %s" (Printexc.to_string exn))
) Nil exprs in
(* Get the IO log *)
let log = call_sx "harness-log" [session] in
let log_str = match log with
| List items | ListRef { contents = items } when items <> [] ->
"\n\nIO Log:\n" ^ String.concat "\n" (List.map (fun entry ->
let op = value_to_string (call_sx "get" [entry; String "op"]) in
let args_val = call_sx "get" [entry; String "args"] in
Printf.sprintf " %s(%s)" op (Sx_types.inspect args_val)
) items)
| _ -> "\n\n(no IO calls)"
in
text_result (Printf.sprintf "Result: %s%s" (Sx_types.inspect result) log_str)
| "sx_write_file" ->
let file = args |> member "file" |> to_string in
let source = args |> member "source" |> to_string in
@@ -964,6 +1042,11 @@ let tool_definitions = `List [
[file_prop; path_prop] ["file"];
tool "sx_doc_gen" "Generate component documentation from all defcomp/defisland/defmacro signatures in a directory."
[dir_prop] ["dir"];
tool "sx_harness_eval" "Evaluate SX in a test harness with mock IO. Returns result + IO trace. Use mock param to override default mock responses."
[("expr", `Assoc [("type", `String "string"); ("description", `String "SX expression to evaluate")]);
("mock", `Assoc [("type", `String "string"); ("description", `String "Optional mock platform overrides as SX dict, e.g. {:fetch (fn (url) {:status 200})}")]);
("file", `Assoc [("type", `String "string"); ("description", `String "Optional .sx file to load for definitions")])]
["expr"];
tool "sx_playwright" "Run Playwright browser tests for the SX docs site. Optionally specify a single spec file."
[("spec", `Assoc [("type", `String "string"); ("description", `String "Optional spec file name (e.g. demo-interactions.spec.js)")])]
[];

View File

@@ -10,12 +10,7 @@
dune exec bin/run_tests.exe -- test-primitives # specific test
dune exec bin/run_tests.exe -- --foundation # foundation only *)
module Sx_types = Sx.Sx_types
module Sx_parser = Sx.Sx_parser
module Sx_primitives = Sx.Sx_primitives
module Sx_runtime = Sx.Sx_runtime
module Sx_ref = Sx.Sx_ref
module Sx_render = Sx.Sx_render
(* Modules accessed directly — library is unwrapped *)
open Sx_types
open Sx_parser
@@ -273,7 +268,7 @@ let make_test_env () =
| _ -> raise (Eval_error "append!: expected list and value"));
(* --- HTML Renderer (from sx_render.ml library module) --- *)
Sx.Sx_render.setup_render_env env;
Sx_render.setup_render_env env;
(* Stubs needed by adapter-html.sx when loaded at test time *)
bind "set-render-active!" (fun _args -> Nil);
@@ -734,6 +729,14 @@ let run_spec_tests env test_files =
Printf.printf "\nLoading test framework...\n%!";
load_and_eval framework_path;
(* Load test harness (mock IO platform) *)
let harness_path = Filename.concat (Filename.concat project_dir "spec") "harness.sx" in
if Sys.file_exists harness_path then begin
Printf.printf "Loading test harness...\n%!";
(try load_and_eval harness_path
with e -> Printf.eprintf "Warning: harness.sx: %s\n%!" (Printexc.to_string e))
end;
(* Load modules needed by tests *)
let spec_dir = Filename.concat project_dir "spec" in
let lib_dir = Filename.concat project_dir "lib" in