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>
369 lines
13 KiB
JavaScript
369 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Run SX spec tests in Node.js using the bootstrapped evaluator.
|
|
*
|
|
* Usage:
|
|
* node hosts/javascript/run_tests.js # all spec tests
|
|
* node hosts/javascript/run_tests.js test-primitives # specific test
|
|
*/
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
// Provide globals that sx-browser.js expects
|
|
global.window = global;
|
|
global.addEventListener = () => {};
|
|
global.self = global;
|
|
global.document = {
|
|
createElement: () => ({ style: {}, setAttribute: () => {}, appendChild: () => {}, children: [] }),
|
|
createDocumentFragment: () => ({ appendChild: () => {}, children: [], childNodes: [] }),
|
|
head: { appendChild: () => {} },
|
|
body: { appendChild: () => {} },
|
|
querySelector: () => null,
|
|
querySelectorAll: () => [],
|
|
createTextNode: (s) => ({ textContent: s }),
|
|
addEventListener: () => {},
|
|
};
|
|
global.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} };
|
|
global.CustomEvent = class CustomEvent { constructor(n, o) { this.type = n; this.detail = (o||{}).detail||{}; } };
|
|
global.MutationObserver = class { observe() {} disconnect() {} };
|
|
global.requestIdleCallback = (fn) => setTimeout(fn, 0);
|
|
global.matchMedia = () => ({ matches: false });
|
|
global.navigator = { serviceWorker: { register: () => Promise.resolve() } };
|
|
global.location = { href: "", pathname: "/", hostname: "localhost" };
|
|
global.history = { pushState: () => {}, replaceState: () => {} };
|
|
global.fetch = () => Promise.resolve({ ok: true, text: () => Promise.resolve("") });
|
|
global.setTimeout = setTimeout;
|
|
global.clearTimeout = clearTimeout;
|
|
global.console = console;
|
|
|
|
// Load the bootstrapped evaluator
|
|
// Use --full flag to load a full-spec build (if available)
|
|
const fullBuild = process.argv.includes("--full");
|
|
const jsPath = fullBuild
|
|
? path.join(__dirname, "..", "..", "shared", "static", "scripts", "sx-full-test.js")
|
|
: path.join(__dirname, "..", "..", "shared", "static", "scripts", "sx-browser.js");
|
|
if (fullBuild && !fs.existsSync(jsPath)) {
|
|
console.error("Full test build not found. Run: python3 hosts/javascript/cli.py --extensions continuations --spec-modules types --output shared/static/scripts/sx-full-test.js");
|
|
process.exit(1);
|
|
}
|
|
const Sx = require(jsPath);
|
|
if (!Sx || !Sx.parse) {
|
|
console.error("Failed to load Sx evaluator");
|
|
process.exit(1);
|
|
}
|
|
|
|
// Reset render mode — boot process may have set it to true
|
|
if (Sx.setRenderActive) Sx.setRenderActive(false);
|
|
|
|
// Test infrastructure
|
|
let passCount = 0;
|
|
let failCount = 0;
|
|
const suiteStack = [];
|
|
|
|
// Build env with all primitives + spec functions
|
|
const env = Sx.getEnv ? Object.assign({}, Sx.getEnv()) : {};
|
|
|
|
// Additional test helpers needed by spec tests
|
|
env["sx-parse"] = function(s) { return Sx.parse(s); };
|
|
env["sx-parse-one"] = function(s) { const r = Sx.parse(s); return r && r.length > 0 ? r[0] : null; };
|
|
env["test-env"] = function() { return Sx.getEnv ? Object.assign({}, Sx.getEnv()) : {}; };
|
|
env["cek-eval"] = function(s) {
|
|
const parsed = Sx.parse(s);
|
|
if (!parsed || parsed.length === 0) return null;
|
|
return Sx.eval(parsed[0], Sx.getEnv ? Object.assign({}, Sx.getEnv()) : {});
|
|
};
|
|
env["eval-expr-cek"] = function(expr, e) { return Sx.eval(expr, e || env); };
|
|
env["env-get"] = function(e, k) { return e && e[k] !== undefined ? e[k] : null; };
|
|
env["env-has?"] = function(e, k) { return e && k in e; };
|
|
env["env-bind!"] = function(e, k, v) { if (e) e[k] = v; return v; };
|
|
env["env-set!"] = function(e, k, v) { if (e) e[k] = v; return v; };
|
|
env["env-extend"] = function(e) { return Object.create(e); };
|
|
env["env-merge"] = function(a, b) { return Object.assign({}, a, b); };
|
|
|
|
// Missing primitives referenced by tests
|
|
// primitive? is now in platform.py PRIMITIVES
|
|
env["upcase"] = function(s) { return s.toUpperCase(); };
|
|
env["downcase"] = function(s) { return s.toLowerCase(); };
|
|
env["make-keyword"] = function(name) { return new Sx.Keyword(name); };
|
|
env["string-length"] = function(s) { return s.length; };
|
|
env["dict-get"] = function(d, k) { return d && d[k] !== undefined ? d[k] : null; };
|
|
env["apply"] = function(f) {
|
|
var args = Array.prototype.slice.call(arguments, 1);
|
|
var lastArg = args.pop();
|
|
if (Array.isArray(lastArg)) args = args.concat(lastArg);
|
|
return f.apply(null, args);
|
|
};
|
|
|
|
// Deep equality
|
|
function deepEqual(a, b) {
|
|
if (a === b) return true;
|
|
if (a == null || b == null) return a == b;
|
|
if (typeof a !== typeof b) return false;
|
|
if (Array.isArray(a) && Array.isArray(b)) {
|
|
if (a.length !== b.length) return false;
|
|
return a.every((v, i) => deepEqual(v, b[i]));
|
|
}
|
|
if (typeof a === "object") {
|
|
const ka = Object.keys(a).filter(k => k !== "_nil");
|
|
const kb = Object.keys(b).filter(k => k !== "_nil");
|
|
if (ka.length !== kb.length) return false;
|
|
return ka.every(k => deepEqual(a[k], b[k]));
|
|
}
|
|
return false;
|
|
}
|
|
env["equal?"] = deepEqual;
|
|
env["identical?"] = function(a, b) { return a === b; };
|
|
|
|
// Continuation support
|
|
env["make-continuation"] = function(fn) {
|
|
// Continuation must be callable as a function AND have _continuation flag
|
|
var c = function(v) { return fn(v !== undefined ? v : null); };
|
|
c._continuation = true;
|
|
c.fn = fn;
|
|
c.call = function(v) { return fn(v !== undefined ? v : null); };
|
|
return c;
|
|
};
|
|
env["continuation?"] = function(x) { return x != null && x._continuation === true; };
|
|
env["continuation-fn"] = function(c) { return c.fn; };
|
|
|
|
// Render helpers
|
|
// render-html: the tests call this with an SX source string, parse it, and render to HTML
|
|
// IMPORTANT: renderToHtml sets a global _renderMode flag but never resets it.
|
|
// We must reset it after each call so subsequent eval calls don't go through the render path.
|
|
env["render-html"] = function(src, e) {
|
|
var result;
|
|
if (typeof src === "string") {
|
|
var parsed = Sx.parse(src);
|
|
if (!parsed || parsed.length === 0) return "";
|
|
var expr = parsed.length === 1 ? parsed[0] : [{ name: "do" }].concat(parsed);
|
|
if (Sx.renderToHtml) {
|
|
result = Sx.renderToHtml(expr, e || (Sx.getEnv ? Object.assign({}, Sx.getEnv()) : {}));
|
|
} else {
|
|
result = Sx.serialize(expr);
|
|
}
|
|
} else {
|
|
if (Sx.renderToHtml) {
|
|
result = Sx.renderToHtml(src, e || env);
|
|
} else {
|
|
result = Sx.serialize(src);
|
|
}
|
|
}
|
|
// Reset render mode so subsequent eval calls don't go through DOM/HTML render path
|
|
if (Sx.setRenderActive) Sx.setRenderActive(false);
|
|
return result;
|
|
};
|
|
// Also register render-to-html directly
|
|
env["render-to-html"] = env["render-html"];
|
|
|
|
// Type system helpers — available when types module is included
|
|
|
|
// test-prim-types: dict of primitive return types for type inference
|
|
env["test-prim-types"] = function() {
|
|
return {
|
|
"+": "number", "-": "number", "*": "number", "/": "number",
|
|
"mod": "number", "inc": "number", "dec": "number",
|
|
"abs": "number", "min": "number", "max": "number",
|
|
"floor": "number", "ceil": "number", "round": "number",
|
|
"str": "string", "upper": "string", "lower": "string",
|
|
"trim": "string", "join": "string", "replace": "string",
|
|
"format": "string", "substr": "string",
|
|
"=": "boolean", "<": "boolean", ">": "boolean",
|
|
"<=": "boolean", ">=": "boolean", "!=": "boolean",
|
|
"not": "boolean", "nil?": "boolean", "empty?": "boolean",
|
|
"number?": "boolean", "string?": "boolean", "boolean?": "boolean",
|
|
"list?": "boolean", "dict?": "boolean", "symbol?": "boolean",
|
|
"keyword?": "boolean", "contains?": "boolean", "has-key?": "boolean",
|
|
"starts-with?": "boolean", "ends-with?": "boolean",
|
|
"len": "number", "first": "any", "rest": "list",
|
|
"last": "any", "nth": "any", "cons": "list",
|
|
"append": "list", "concat": "list", "reverse": "list",
|
|
"sort": "list", "slice": "list", "range": "list",
|
|
"flatten": "list", "keys": "list", "vals": "list",
|
|
"map-dict": "dict", "assoc": "dict", "dissoc": "dict",
|
|
"merge": "dict", "dict": "dict",
|
|
"get": "any", "type-of": "string",
|
|
};
|
|
};
|
|
|
|
// test-prim-param-types: dict of primitive param type specs
|
|
env["test-prim-param-types"] = function() {
|
|
return {
|
|
"+": {"positional": [["a", "number"]], "rest-type": "number"},
|
|
"-": {"positional": [["a", "number"]], "rest-type": "number"},
|
|
"*": {"positional": [["a", "number"]], "rest-type": "number"},
|
|
"/": {"positional": [["a", "number"]], "rest-type": "number"},
|
|
"inc": {"positional": [["n", "number"]], "rest-type": null},
|
|
"dec": {"positional": [["n", "number"]], "rest-type": null},
|
|
"upper": {"positional": [["s", "string"]], "rest-type": null},
|
|
"lower": {"positional": [["s", "string"]], "rest-type": null},
|
|
"keys": {"positional": [["d", "dict"]], "rest-type": null},
|
|
"vals": {"positional": [["d", "dict"]], "rest-type": null},
|
|
};
|
|
};
|
|
|
|
// Component type accessors
|
|
env["component-param-types"] = function(c) {
|
|
return c && c._paramTypes ? c._paramTypes : null;
|
|
};
|
|
env["component-set-param-types!"] = function(c, t) {
|
|
if (c) c._paramTypes = t;
|
|
return null;
|
|
};
|
|
env["component-params"] = function(c) {
|
|
return c && c.params ? c.params : null;
|
|
};
|
|
env["component-body"] = function(c) {
|
|
return c && c.body ? c.body : null;
|
|
};
|
|
env["component-has-children"] = function(c) {
|
|
return c && c.has_children ? c.has_children : false;
|
|
};
|
|
|
|
// Aser test helper: parse SX source, evaluate via aser, return wire format string
|
|
env["render-sx"] = function(source) {
|
|
const exprs = Sx.parse(source);
|
|
const parts = [];
|
|
for (const expr of exprs) {
|
|
const result = Sx.renderToSx(expr, env);
|
|
if (result !== null && result !== undefined && result !== Sx.NIL) {
|
|
parts.push(typeof result === "string" ? result : Sx.serialize(result));
|
|
}
|
|
}
|
|
return parts.join("");
|
|
};
|
|
|
|
// Platform test functions
|
|
env["try-call"] = function(thunk) {
|
|
try {
|
|
Sx.eval([thunk], env);
|
|
return { ok: true };
|
|
} catch (e) {
|
|
return { ok: false, error: e.message || String(e) };
|
|
}
|
|
};
|
|
|
|
env["report-pass"] = function(name) {
|
|
passCount++;
|
|
const ctx = suiteStack.join(" > ");
|
|
console.log(` PASS: ${ctx} > ${name}`);
|
|
return null;
|
|
};
|
|
|
|
env["report-fail"] = function(name, error) {
|
|
failCount++;
|
|
const ctx = suiteStack.join(" > ");
|
|
console.log(` FAIL: ${ctx} > ${name}: ${error}`);
|
|
return null;
|
|
};
|
|
|
|
env["push-suite"] = function(name) {
|
|
suiteStack.push(name);
|
|
console.log(`${" ".repeat(suiteStack.length - 1)}Suite: ${name}`);
|
|
return null;
|
|
};
|
|
|
|
env["pop-suite"] = function() {
|
|
suiteStack.pop();
|
|
return null;
|
|
};
|
|
|
|
// Load test framework
|
|
const projectDir = path.join(__dirname, "..", "..");
|
|
const specTests = path.join(projectDir, "spec", "tests");
|
|
const libTests = path.join(projectDir, "lib", "tests");
|
|
const webTests = path.join(projectDir, "web", "tests");
|
|
|
|
const frameworkSrc = fs.readFileSync(path.join(specTests, "test-framework.sx"), "utf8");
|
|
const frameworkExprs = Sx.parse(frameworkSrc);
|
|
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");
|
|
for (const libFile of ["bytecode.sx", "compiler.sx", "vm.sx", "tree-tools.sx"]) {
|
|
const libPath = path.join(libDir, libFile);
|
|
if (fs.existsSync(libPath)) {
|
|
const src = fs.readFileSync(libPath, "utf8");
|
|
const exprs = Sx.parse(src);
|
|
for (const expr of exprs) {
|
|
try { Sx.eval(expr, env); } catch (e) {
|
|
console.error(`Error loading ${libFile}: ${e.message}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Determine which tests to run
|
|
const args = process.argv.slice(2).filter(a => !a.startsWith("--"));
|
|
let testFiles = [];
|
|
|
|
if (args.length > 0) {
|
|
// Specific test files — search spec, lib, and web test dirs
|
|
for (const arg of args) {
|
|
const name = arg.endsWith(".sx") ? arg : `${arg}.sx`;
|
|
const specPath = path.join(specTests, name);
|
|
const libPath = path.join(libTests, name);
|
|
const webPath = path.join(webTests, name);
|
|
if (fs.existsSync(specPath)) testFiles.push(specPath);
|
|
else if (fs.existsSync(libPath)) testFiles.push(libPath);
|
|
else if (fs.existsSync(webPath)) testFiles.push(webPath);
|
|
else console.error(`Test file not found: ${name}`);
|
|
}
|
|
} else {
|
|
// All spec tests (core language — always run)
|
|
for (const f of fs.readdirSync(specTests).sort()) {
|
|
if (f.startsWith("test-") && f.endsWith(".sx") && f !== "test-framework.sx") {
|
|
testFiles.push(path.join(specTests, f));
|
|
}
|
|
}
|
|
// Library tests (only with --full — require compiler, vm, signals, etc.)
|
|
if (fullBuild) {
|
|
for (const f of fs.readdirSync(libTests).sort()) {
|
|
if (f.startsWith("test-") && f.endsWith(".sx")) {
|
|
testFiles.push(path.join(libTests, f));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run tests
|
|
for (const testFile of testFiles) {
|
|
const name = path.basename(testFile);
|
|
console.log("=" .repeat(60));
|
|
console.log(`Running ${name}`);
|
|
console.log("=" .repeat(60));
|
|
|
|
try {
|
|
const src = fs.readFileSync(testFile, "utf8");
|
|
const exprs = Sx.parse(src);
|
|
for (const expr of exprs) {
|
|
Sx.eval(expr, env);
|
|
}
|
|
} catch (e) {
|
|
console.error(`ERROR in ${name}: ${e.message}`);
|
|
failCount++;
|
|
}
|
|
}
|
|
|
|
// Summary
|
|
console.log("=" .repeat(60));
|
|
console.log(`Results: ${passCount} passed, ${failCount} failed`);
|
|
console.log("=" .repeat(60));
|
|
|
|
process.exit(failCount > 0 ? 1 : 0);
|