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:
@@ -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");
|
||||
|
||||
@@ -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)")])]
|
||||
[];
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user