spec/vm.sx — bytecode VM written in SX (the spec):
- Stack-based interpreter for bytecode from compiler.sx
- 24 opcodes: constants, variables (local/upvalue/global), control flow,
function calls (with TCO), closures with upvalue capture, collections,
string concat, define
- Upvalue cells for shared mutable closure variables
- Call dispatch: vm-closure (fast path), native-fn, CEK fallback
- Platform interface: 7 primitives (vm-stack-*, call-primitive, cek-call,
get-primitive, env-parent)
spec/tests/test-vm.sx — 72 tests exercising compile→bytecode→VM pipeline:
constants, arithmetic, comparison, control flow (if/when/cond/case/and/or),
let bindings, lambda, closures, upvalue mutation, TCO (10K iterations),
collections, strings, define, letrec, quasiquote, threading, integration
(fibonacci, recursive map/filter/reduce, compose)
spec/compiler.sx — fix :else keyword detection in case/cond compilation
(was comparing Keyword object to evaluated string, now checks type)
Platform primitives added (JS + OCaml):
make-vm-stack, vm-stack-get, vm-stack-set!, vm-stack-length, vm-stack-copy!,
primitive?, get-primitive, call-primitive, set-nth! (JS)
Test runners updated to load bytecode.sx + compiler.sx + vm.sx for --full.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
352 lines
13 KiB
JavaScript
352 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 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 compiler + VM spec when running full tests
|
|
if (fullBuild) {
|
|
const specDir = path.join(projectDir, "spec");
|
|
for (const specFile of ["bytecode.sx", "compiler.sx", "vm.sx"]) {
|
|
const specPath = path.join(specDir, specFile);
|
|
if (fs.existsSync(specPath)) {
|
|
const src = fs.readFileSync(specPath, "utf8");
|
|
const exprs = Sx.parse(src);
|
|
for (const expr of exprs) {
|
|
try { Sx.eval(expr, env); } catch (e) {
|
|
console.error(`Error loading ${specFile}: ${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
|
|
for (const arg of args) {
|
|
const name = arg.endsWith(".sx") ? arg : `${arg}.sx`;
|
|
const specPath = path.join(specTests, name);
|
|
const webPath = path.join(webTests, name);
|
|
if (fs.existsSync(specPath)) testFiles.push(specPath);
|
|
else if (fs.existsSync(webPath)) testFiles.push(webPath);
|
|
else console.error(`Test file not found: ${name}`);
|
|
}
|
|
} else {
|
|
// Tests requiring optional modules (only run with --full)
|
|
const requiresFull = new Set(["test-continuations.sx", "test-continuations-advanced.sx", "test-types.sx", "test-freeze.sx", "test-vm.sx"]);
|
|
// All spec tests
|
|
for (const f of fs.readdirSync(specTests).sort()) {
|
|
if (f.startsWith("test-") && f.endsWith(".sx") && f !== "test-framework.sx") {
|
|
if (!fullBuild && requiresFull.has(f)) {
|
|
console.log(`Skipping ${f} (requires --full)`);
|
|
continue;
|
|
}
|
|
testFiles.push(path.join(specTests, 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);
|