HS: return/guard, repeat while/until, if-then fix, script extraction
Parser: if-then consumes 'then' keyword before parsing then-body. Compiler: return→raise, def→guard, repeat while/until dispatch. Runtime: hs-repeat-while, hs-repeat-until. Test gen: script block extraction for def functions. repeat suite: 10→13/30. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -738,7 +738,9 @@ and bind_import_set import_set env =
|
||||
|
||||
(* step-sf-import *)
|
||||
and step_sf_import args env kont =
|
||||
(if sx_truthy ((empty_p (args))) then (make_cek_value (Nil) (env) (kont)) else (let import_set = (first (args)) in let rest_sets = (rest (args)) in (let lib_spec = (let head = (if sx_truthy ((let _and = (list_p (import_set)) in if not (sx_truthy _and) then _and else (let _and = (Bool (not (sx_truthy ((empty_p (import_set)))))) in if not (sx_truthy _and) then _and else (symbol_p ((first (import_set))))))) then (symbol_name ((first (import_set)))) else Nil) in (if sx_truthy ((let _or = (prim_call "=" [head; (String "only")]) in if sx_truthy _or then _or else (let _or = (prim_call "=" [head; (String "except")]) in if sx_truthy _or then _or else (let _or = (prim_call "=" [head; (String "prefix")]) in if sx_truthy _or then _or else (prim_call "=" [head; (String "rename")]))))) then (nth (import_set) ((Number 1.0))) else import_set)) in (if sx_truthy ((library_loaded_p (lib_spec))) then (let () = ignore ((bind_import_set (import_set) (env))) in (if sx_truthy ((empty_p (rest_sets))) then (make_cek_value (Nil) (env) (kont)) else (step_sf_import (rest_sets) (env) (kont)))) else (make_cek_suspended ((let _d = Hashtbl.create 2 in Hashtbl.replace _d "library" lib_spec; Hashtbl.replace _d "op" (String "import"); Dict _d)) (env) ((kont_push ((make_import_frame (import_set) (rest_sets) (env))) (kont))))))))
|
||||
(if sx_truthy ((empty_p (args))) then (make_cek_value (Nil) (env) (kont)) else (let import_set = (first (args)) in let rest_sets = (rest (args)) in (let lib_spec = (let head = (if sx_truthy ((let _and = (list_p (import_set)) in if not (sx_truthy _and) then _and else (let _and = (Bool (not (sx_truthy ((empty_p (import_set)))))) in if not (sx_truthy _and) then _and else (symbol_p ((first (import_set))))))) then (symbol_name ((first (import_set)))) else Nil) in (if sx_truthy ((let _or = (prim_call "=" [head; (String "only")]) in if sx_truthy _or then _or else (let _or = (prim_call "=" [head; (String "except")]) in if sx_truthy _or then _or else (let _or = (prim_call "=" [head; (String "prefix")]) in if sx_truthy _or then _or else (prim_call "=" [head; (String "rename")]))))) then (nth (import_set) ((Number 1.0))) else import_set)) in (if sx_truthy ((library_loaded_p (lib_spec))) then (let () = ignore ((bind_import_set (import_set) (env))) in (if sx_truthy ((empty_p (rest_sets))) then (make_cek_value (Nil) (env) (kont)) else (step_sf_import (rest_sets) (env) (kont)))) else (let hook_loaded = match !Sx_types._import_hook with Some hook -> hook lib_spec | None -> false in
|
||||
if hook_loaded then (let () = ignore ((bind_import_set (import_set) (env))) in (if sx_truthy ((empty_p (rest_sets))) then (make_cek_value (Nil) (env) (kont)) else (step_sf_import (rest_sets) (env) (kont))))
|
||||
else (make_cek_suspended ((let _d = Hashtbl.create 2 in Hashtbl.replace _d "library" lib_spec; Hashtbl.replace _d "op" (String "import"); Dict _d)) (env) ((kont_push ((make_import_frame (import_set) (rest_sets) (env))) (kont)))))))))
|
||||
|
||||
(* step-sf-perform *)
|
||||
and step_sf_perform args env kont =
|
||||
|
||||
@@ -237,6 +237,20 @@
|
||||
(quote hs-repeat-times)
|
||||
mode
|
||||
(list (quote fn) (list) body)))
|
||||
((and (list? mode) (= (first mode) (quote while)))
|
||||
(let
|
||||
((cond-expr (hs-to-sx (nth mode 1))))
|
||||
(list
|
||||
(quote hs-repeat-while)
|
||||
(list (quote fn) (list) cond-expr)
|
||||
(list (quote fn) (list) body))))
|
||||
((and (list? mode) (= (first mode) (quote until)))
|
||||
(let
|
||||
((cond-expr (hs-to-sx (nth mode 1))))
|
||||
(list
|
||||
(quote hs-repeat-until)
|
||||
(list (quote fn) (list) cond-expr)
|
||||
(list (quote fn) (list) body))))
|
||||
(true
|
||||
(list
|
||||
(quote hs-repeat-times)
|
||||
@@ -1035,7 +1049,15 @@
|
||||
((fn-expr (hs-to-sx (nth ast 1)))
|
||||
(args (map hs-to-sx (nth ast 2))))
|
||||
(cons fn-expr args)))
|
||||
((= head (quote return)) (hs-to-sx (nth ast 1)))
|
||||
((= head (quote return))
|
||||
(let
|
||||
((val (nth ast 1)))
|
||||
(if
|
||||
(nil? val)
|
||||
(list (quote raise) (list (quote list) "hs-return" nil))
|
||||
(list
|
||||
(quote raise)
|
||||
(list (quote list) "hs-return" (hs-to-sx val))))))
|
||||
((= head (quote throw))
|
||||
(list (quote raise) (hs-to-sx (nth ast 1))))
|
||||
((= head (quote settle))
|
||||
@@ -1106,13 +1128,41 @@
|
||||
(quote hs-init)
|
||||
(list (quote fn) (list) (hs-to-sx (nth ast 1)))))
|
||||
((= head (quote def))
|
||||
(list
|
||||
(quote define)
|
||||
(make-symbol (nth ast 1))
|
||||
(let
|
||||
((body (hs-to-sx (nth ast 3)))
|
||||
(params
|
||||
(map
|
||||
(fn
|
||||
(p)
|
||||
(if
|
||||
(and (list? p) (= (first p) (quote ref)))
|
||||
(make-symbol (nth p 1))
|
||||
(make-symbol p)))
|
||||
(nth ast 2))))
|
||||
(list
|
||||
(quote fn)
|
||||
(map make-symbol (nth ast 2))
|
||||
(hs-to-sx (nth ast 3)))))
|
||||
(quote define)
|
||||
(make-symbol (nth ast 1))
|
||||
(list
|
||||
(quote fn)
|
||||
params
|
||||
(list
|
||||
(quote guard)
|
||||
(list
|
||||
(quote _e)
|
||||
(list
|
||||
(quote true)
|
||||
(list
|
||||
(quote if)
|
||||
(list
|
||||
(quote and)
|
||||
(list (quote list?) (quote _e))
|
||||
(list
|
||||
(quote =)
|
||||
(list (quote first) (quote _e))
|
||||
"hs-return"))
|
||||
(list (quote nth) (quote _e) 1)
|
||||
(list (quote raise) (quote _e)))))
|
||||
body)))))
|
||||
((= head (quote behavior)) (emit-behavior ast))
|
||||
((= head (quote sx-eval))
|
||||
(let
|
||||
|
||||
@@ -1041,7 +1041,7 @@
|
||||
(let
|
||||
((cnd (parse-expr)))
|
||||
(let
|
||||
((then-body (parse-cmd-list)))
|
||||
((then-body (do (match-kw "then") (parse-cmd-list))))
|
||||
(let
|
||||
((else-body (if (or (match-kw "else") (match-kw "otherwise")) (parse-cmd-list) nil)))
|
||||
(match-kw "end")
|
||||
|
||||
@@ -275,6 +275,12 @@
|
||||
(define do-forever (fn () (thunk) (do-forever)))
|
||||
(do-forever)))
|
||||
|
||||
(define
|
||||
hs-repeat-while
|
||||
(fn
|
||||
(cond-fn thunk)
|
||||
(when (cond-fn) (thunk) (hs-repeat-while cond-fn thunk))))
|
||||
|
||||
(define
|
||||
hs-fetch
|
||||
(fn
|
||||
@@ -426,6 +432,10 @@
|
||||
(dom-set-style target prop (str to-val))
|
||||
(when duration (hs-settle target))))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
(define
|
||||
hs-type-check
|
||||
(fn
|
||||
@@ -446,37 +456,33 @@
|
||||
(= (host-typeof value) "text")))
|
||||
(true (= (host-typeof value) (downcase type-name)))))))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
(define
|
||||
hs-type-check-strict
|
||||
(fn
|
||||
(value type-name)
|
||||
(if (nil? value) false (hs-type-check value type-name))))
|
||||
|
||||
(define
|
||||
hs-strict-eq
|
||||
(fn (a b) (and (= (type-of a) (type-of b)) (= a b))))
|
||||
;; ── Sandbox/test runtime additions ──────────────────────────────
|
||||
;; Property access — dot notation and .length
|
||||
(define
|
||||
hs-strict-eq
|
||||
(fn (a b) (and (= (type-of a) (type-of b)) (= a b))))
|
||||
;; DOM query stub — sandbox returns empty list
|
||||
(define
|
||||
hs-eq-ignore-case
|
||||
(fn (a b) (= (downcase (str a)) (downcase (str b)))))
|
||||
;; DOM query stub — sandbox returns empty list
|
||||
;; Method dispatch — obj.method(args)
|
||||
(define
|
||||
hs-starts-with-ic?
|
||||
(fn (str prefix) (starts-with? (downcase str) (downcase prefix))))
|
||||
;; Method dispatch — obj.method(args)
|
||||
|
||||
;; ── 0.9.90 features ─────────────────────────────────────────────
|
||||
;; beep! — debug logging, returns value unchanged
|
||||
(define
|
||||
hs-contains-ignore-case?
|
||||
(fn
|
||||
(haystack needle)
|
||||
(contains? (downcase (str haystack)) (downcase (str needle)))))
|
||||
|
||||
;; ── 0.9.90 features ─────────────────────────────────────────────
|
||||
;; beep! — debug logging, returns value unchanged
|
||||
;; Property-based is — check obj.key truthiness
|
||||
(define
|
||||
hs-falsy?
|
||||
(fn
|
||||
@@ -488,7 +494,7 @@
|
||||
((and (list? v) (= (len v) 0)) true)
|
||||
((= v 0) true)
|
||||
(true false))))
|
||||
;; Property-based is — check obj.key truthiness
|
||||
;; Array slicing (inclusive both ends)
|
||||
(define
|
||||
hs-matches?
|
||||
(fn
|
||||
@@ -499,7 +505,7 @@
|
||||
((= (host-typeof target) "element")
|
||||
(if (string? pattern) (host-call target "matches" pattern) false))
|
||||
(true false))))
|
||||
;; Array slicing (inclusive both ends)
|
||||
;; Collection: sorted by
|
||||
(define
|
||||
hs-contains?
|
||||
(fn
|
||||
@@ -519,9 +525,9 @@
|
||||
true
|
||||
(hs-contains? (rest collection) item)))))
|
||||
(true false))))
|
||||
;; Collection: sorted by
|
||||
(define precedes? (fn (a b) (< (str a) (str b))))
|
||||
;; Collection: sorted by descending
|
||||
(define precedes? (fn (a b) (< (str a) (str b))))
|
||||
;; Collection: split by
|
||||
(define
|
||||
hs-empty?
|
||||
(fn
|
||||
@@ -532,7 +538,7 @@
|
||||
((list? v) (= (len v) 0))
|
||||
((dict? v) (= (len (keys v)) 0))
|
||||
(true false))))
|
||||
;; Collection: split by
|
||||
;; Collection: joined by
|
||||
(define
|
||||
hs-empty-target!
|
||||
(fn
|
||||
@@ -557,7 +563,7 @@
|
||||
((children (host-call target "querySelectorAll" "input, textarea, select")))
|
||||
(for-each (fn (el) (hs-empty-target! el)) children)))
|
||||
(true (dom-set-inner-html target ""))))))))
|
||||
;; Collection: joined by
|
||||
|
||||
(define
|
||||
hs-open!
|
||||
(fn
|
||||
|
||||
@@ -237,6 +237,20 @@
|
||||
(quote hs-repeat-times)
|
||||
mode
|
||||
(list (quote fn) (list) body)))
|
||||
((and (list? mode) (= (first mode) (quote while)))
|
||||
(let
|
||||
((cond-expr (hs-to-sx (nth mode 1))))
|
||||
(list
|
||||
(quote hs-repeat-while)
|
||||
(list (quote fn) (list) cond-expr)
|
||||
(list (quote fn) (list) body))))
|
||||
((and (list? mode) (= (first mode) (quote until)))
|
||||
(let
|
||||
((cond-expr (hs-to-sx (nth mode 1))))
|
||||
(list
|
||||
(quote hs-repeat-until)
|
||||
(list (quote fn) (list) cond-expr)
|
||||
(list (quote fn) (list) body))))
|
||||
(true
|
||||
(list
|
||||
(quote hs-repeat-times)
|
||||
@@ -1035,7 +1049,15 @@
|
||||
((fn-expr (hs-to-sx (nth ast 1)))
|
||||
(args (map hs-to-sx (nth ast 2))))
|
||||
(cons fn-expr args)))
|
||||
((= head (quote return)) (hs-to-sx (nth ast 1)))
|
||||
((= head (quote return))
|
||||
(let
|
||||
((val (nth ast 1)))
|
||||
(if
|
||||
(nil? val)
|
||||
(list (quote raise) (list (quote list) "hs-return" nil))
|
||||
(list
|
||||
(quote raise)
|
||||
(list (quote list) "hs-return" (hs-to-sx val))))))
|
||||
((= head (quote throw))
|
||||
(list (quote raise) (hs-to-sx (nth ast 1))))
|
||||
((= head (quote settle))
|
||||
@@ -1106,13 +1128,41 @@
|
||||
(quote hs-init)
|
||||
(list (quote fn) (list) (hs-to-sx (nth ast 1)))))
|
||||
((= head (quote def))
|
||||
(list
|
||||
(quote define)
|
||||
(make-symbol (nth ast 1))
|
||||
(let
|
||||
((body (hs-to-sx (nth ast 3)))
|
||||
(params
|
||||
(map
|
||||
(fn
|
||||
(p)
|
||||
(if
|
||||
(and (list? p) (= (first p) (quote ref)))
|
||||
(make-symbol (nth p 1))
|
||||
(make-symbol p)))
|
||||
(nth ast 2))))
|
||||
(list
|
||||
(quote fn)
|
||||
(map make-symbol (nth ast 2))
|
||||
(hs-to-sx (nth ast 3)))))
|
||||
(quote define)
|
||||
(make-symbol (nth ast 1))
|
||||
(list
|
||||
(quote fn)
|
||||
params
|
||||
(list
|
||||
(quote guard)
|
||||
(list
|
||||
(quote _e)
|
||||
(list
|
||||
(quote true)
|
||||
(list
|
||||
(quote if)
|
||||
(list
|
||||
(quote and)
|
||||
(list (quote list?) (quote _e))
|
||||
(list
|
||||
(quote =)
|
||||
(list (quote first) (quote _e))
|
||||
"hs-return"))
|
||||
(list (quote nth) (quote _e) 1)
|
||||
(list (quote raise) (quote _e)))))
|
||||
body)))))
|
||||
((= head (quote behavior)) (emit-behavior ast))
|
||||
((= head (quote sx-eval))
|
||||
(let
|
||||
|
||||
@@ -1041,7 +1041,7 @@
|
||||
(let
|
||||
((cnd (parse-expr)))
|
||||
(let
|
||||
((then-body (parse-cmd-list)))
|
||||
((then-body (do (match-kw "then") (parse-cmd-list))))
|
||||
(let
|
||||
((else-body (if (or (match-kw "else") (match-kw "otherwise")) (parse-cmd-list) nil)))
|
||||
(match-kw "end")
|
||||
|
||||
@@ -275,6 +275,12 @@
|
||||
(define do-forever (fn () (thunk) (do-forever)))
|
||||
(do-forever)))
|
||||
|
||||
(define
|
||||
hs-repeat-while
|
||||
(fn
|
||||
(cond-fn thunk)
|
||||
(when (cond-fn) (thunk) (hs-repeat-while cond-fn thunk))))
|
||||
|
||||
(define
|
||||
hs-fetch
|
||||
(fn
|
||||
@@ -426,6 +432,10 @@
|
||||
(dom-set-style target prop (str to-val))
|
||||
(when duration (hs-settle target))))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
(define
|
||||
hs-type-check
|
||||
(fn
|
||||
@@ -446,37 +456,33 @@
|
||||
(= (host-typeof value) "text")))
|
||||
(true (= (host-typeof value) (downcase type-name)))))))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
(define
|
||||
hs-type-check-strict
|
||||
(fn
|
||||
(value type-name)
|
||||
(if (nil? value) false (hs-type-check value type-name))))
|
||||
|
||||
(define
|
||||
hs-strict-eq
|
||||
(fn (a b) (and (= (type-of a) (type-of b)) (= a b))))
|
||||
;; ── Sandbox/test runtime additions ──────────────────────────────
|
||||
;; Property access — dot notation and .length
|
||||
(define
|
||||
hs-strict-eq
|
||||
(fn (a b) (and (= (type-of a) (type-of b)) (= a b))))
|
||||
;; DOM query stub — sandbox returns empty list
|
||||
(define
|
||||
hs-eq-ignore-case
|
||||
(fn (a b) (= (downcase (str a)) (downcase (str b)))))
|
||||
;; DOM query stub — sandbox returns empty list
|
||||
;; Method dispatch — obj.method(args)
|
||||
(define
|
||||
hs-starts-with-ic?
|
||||
(fn (str prefix) (starts-with? (downcase str) (downcase prefix))))
|
||||
;; Method dispatch — obj.method(args)
|
||||
|
||||
;; ── 0.9.90 features ─────────────────────────────────────────────
|
||||
;; beep! — debug logging, returns value unchanged
|
||||
(define
|
||||
hs-contains-ignore-case?
|
||||
(fn
|
||||
(haystack needle)
|
||||
(contains? (downcase (str haystack)) (downcase (str needle)))))
|
||||
|
||||
;; ── 0.9.90 features ─────────────────────────────────────────────
|
||||
;; beep! — debug logging, returns value unchanged
|
||||
;; Property-based is — check obj.key truthiness
|
||||
(define
|
||||
hs-falsy?
|
||||
(fn
|
||||
@@ -488,7 +494,7 @@
|
||||
((and (list? v) (= (len v) 0)) true)
|
||||
((= v 0) true)
|
||||
(true false))))
|
||||
;; Property-based is — check obj.key truthiness
|
||||
;; Array slicing (inclusive both ends)
|
||||
(define
|
||||
hs-matches?
|
||||
(fn
|
||||
@@ -499,7 +505,7 @@
|
||||
((= (host-typeof target) "element")
|
||||
(if (string? pattern) (host-call target "matches" pattern) false))
|
||||
(true false))))
|
||||
;; Array slicing (inclusive both ends)
|
||||
;; Collection: sorted by
|
||||
(define
|
||||
hs-contains?
|
||||
(fn
|
||||
@@ -519,9 +525,9 @@
|
||||
true
|
||||
(hs-contains? (rest collection) item)))))
|
||||
(true false))))
|
||||
;; Collection: sorted by
|
||||
(define precedes? (fn (a b) (< (str a) (str b))))
|
||||
;; Collection: sorted by descending
|
||||
(define precedes? (fn (a b) (< (str a) (str b))))
|
||||
;; Collection: split by
|
||||
(define
|
||||
hs-empty?
|
||||
(fn
|
||||
@@ -532,7 +538,7 @@
|
||||
((list? v) (= (len v) 0))
|
||||
((dict? v) (= (len (keys v)) 0))
|
||||
(true false))))
|
||||
;; Collection: split by
|
||||
;; Collection: joined by
|
||||
(define
|
||||
hs-empty-target!
|
||||
(fn
|
||||
@@ -557,7 +563,7 @@
|
||||
((children (host-call target "querySelectorAll" "input, textarea, select")))
|
||||
(for-each (fn (el) (hs-empty-target! el)) children)))
|
||||
(true (dom-set-inner-html target ""))))))))
|
||||
;; Collection: joined by
|
||||
|
||||
(define
|
||||
hs-open!
|
||||
(fn
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Hyperscript behavioral tests — SX tests in Playwright sandbox.
|
||||
* Hyperscript behavioral tests — SX tests running in Playwright sandbox.
|
||||
*
|
||||
* Tests are registered during file load (deferred), then each is run
|
||||
* individually via page.evaluate with a 3s Promise.race timeout.
|
||||
* Hanging tests fail with TIMEOUT. After a timeout, the page is
|
||||
* closed and a fresh one is created to avoid cascading hangs.
|
||||
* Loads the WASM kernel + hs stack, defines the test platform,
|
||||
* loads test-framework.sx + test-hyperscript-behavioral.sx,
|
||||
* and reports each test individually.
|
||||
*/
|
||||
const { test, expect } = require('playwright/test');
|
||||
const fs = require('fs');
|
||||
@@ -15,41 +14,32 @@ const PROJECT_ROOT = path.resolve(__dirname, '../..');
|
||||
const WASM_DIR = path.join(PROJECT_ROOT, 'shared/static/wasm');
|
||||
const SX_DIR = path.join(WASM_DIR, 'sx');
|
||||
|
||||
const WEB_MODULES = [
|
||||
'render', 'core-signals', 'signals', 'deps', 'router',
|
||||
'page-helpers', 'freeze', 'dom', 'browser',
|
||||
'adapter-html', 'adapter-sx', 'adapter-dom',
|
||||
'boot-helpers', 'hypersx', 'engine', 'orchestration', 'boot',
|
||||
];
|
||||
const HS_MODULES = [
|
||||
'hs-tokenizer', 'hs-parser', 'hs-compiler', 'hs-runtime', 'hs-integration',
|
||||
];
|
||||
const SANDBOX_STACKS = {
|
||||
web: [
|
||||
'render', 'core-signals', 'signals', 'deps', 'router',
|
||||
'page-helpers', 'freeze', 'dom', 'browser',
|
||||
'adapter-html', 'adapter-sx', 'adapter-dom',
|
||||
'boot-helpers', 'hypersx', 'engine', 'orchestration', 'boot',
|
||||
],
|
||||
hs: [
|
||||
'hs-tokenizer', 'hs-parser', 'hs-compiler', 'hs-runtime', 'hs-integration',
|
||||
],
|
||||
};
|
||||
|
||||
// Cache module sources — avoid re-reading files on reboot
|
||||
const MODULE_CACHE = {};
|
||||
function getModuleSrc(mod) {
|
||||
if (MODULE_CACHE[mod]) return MODULE_CACHE[mod];
|
||||
const sxPath = path.join(SX_DIR, mod + '.sx');
|
||||
const libPath = path.join(PROJECT_ROOT, 'lib/hyperscript', mod.replace(/^hs-/, '') + '.sx');
|
||||
try {
|
||||
MODULE_CACHE[mod] = fs.existsSync(sxPath) ? fs.readFileSync(sxPath, 'utf8') : fs.readFileSync(libPath, 'utf8');
|
||||
} catch(e) { MODULE_CACHE[mod] = null; }
|
||||
return MODULE_CACHE[mod];
|
||||
}
|
||||
|
||||
// Cache test file sources
|
||||
const TEST_FILES = ['spec/harness.sx', 'spec/tests/test-framework.sx', 'spec/tests/test-hyperscript-behavioral.sx'];
|
||||
const TEST_FILE_CACHE = {};
|
||||
for (const f of TEST_FILES) {
|
||||
TEST_FILE_CACHE[f] = fs.readFileSync(path.join(PROJECT_ROOT, f), 'utf8');
|
||||
}
|
||||
|
||||
async function bootSandbox(page) {
|
||||
/**
|
||||
* Boot WASM kernel with hs stack, define test platform, load test files.
|
||||
* Returns array of {suite, name, pass, error} for each test.
|
||||
*/
|
||||
async function runSxTests(page) {
|
||||
await page.goto('about:blank');
|
||||
await page.evaluate(() => { document.body.innerHTML = ''; });
|
||||
|
||||
// Inject WASM kernel
|
||||
const kernelSrc = fs.readFileSync(path.join(WASM_DIR, 'sx_browser.bc.js'), 'utf8');
|
||||
await page.addScriptTag({ content: kernelSrc });
|
||||
await page.waitForFunction('!!window.SxKernel', { timeout: 10000 });
|
||||
|
||||
// Register FFI + IO driver
|
||||
await page.evaluate(() => {
|
||||
const K = window.SxKernel;
|
||||
K.registerNative('host-global', a => { const n=a[0]; return (n in globalThis)?globalThis[n]:null; });
|
||||
@@ -69,7 +59,11 @@ async function bootSandbox(page) {
|
||||
const fn=a[0];
|
||||
if(typeof fn==='function'&&fn.__sx_handle===undefined)return fn;
|
||||
if(fn&&fn.__sx_handle!==undefined){
|
||||
return function(){const r=K.callFn(fn,Array.from(arguments));if(window._driveAsync)window._driveAsync(r);return r;};
|
||||
return function(){
|
||||
const r=K.callFn(fn,Array.from(arguments));
|
||||
if(window._driveAsync)window._driveAsync(r);
|
||||
return r;
|
||||
};
|
||||
}
|
||||
return function(){};
|
||||
});
|
||||
@@ -81,260 +75,211 @@ async function bootSandbox(page) {
|
||||
return typeof o;
|
||||
});
|
||||
K.registerNative('host-await', a => {
|
||||
const[p,cb]=a;if(p&&typeof p.then==='function'){const f=(cb&&cb.__sx_handle!==undefined)?v=>K.callFn(cb,[v]):()=>{};p.then(f);}
|
||||
const[p,cb]=a;
|
||||
if(p&&typeof p.then==='function'){
|
||||
const f=(cb&&cb.__sx_handle!==undefined)?v=>K.callFn(cb,[v]):()=>{};
|
||||
p.then(f);
|
||||
}
|
||||
});
|
||||
K.registerNative('load-library!', () => false);
|
||||
|
||||
// IO suspension driver
|
||||
window._ioTrace = [];
|
||||
window._asyncPending = 0;
|
||||
window._driveAsync = function driveAsync(result) {
|
||||
if(!result||!result.suspended)return;
|
||||
const req=result.request;const items=req&&(req.items||req);
|
||||
const op=items&&items[0];const opName=typeof op==='string'?op:(op&&op.name)||String(op);
|
||||
window._asyncPending++;
|
||||
const req=result.request; const items=req&&(req.items||req);
|
||||
const op=items&&items[0]; const opName=typeof op==='string'?op:(op&&op.name)||String(op);
|
||||
const arg=items&&items[1];
|
||||
function doResume(val,delay){setTimeout(()=>{try{const r=result.resume(val);driveAsync(r);}catch(e){}},delay);}
|
||||
function doResume(val,delay){
|
||||
setTimeout(()=>{
|
||||
try{const r=result.resume(val);window._asyncPending--;driveAsync(r);}
|
||||
catch(e){window._asyncPending--;}
|
||||
},delay);
|
||||
}
|
||||
if(opName==='io-sleep'||opName==='wait')doResume(null,Math.min(typeof arg==='number'?arg:0,10));
|
||||
else if(opName==='io-navigate')window._asyncPending--;
|
||||
else if(opName==='io-fetch')doResume({ok:true,text:''},1);
|
||||
else window._asyncPending--;
|
||||
};
|
||||
|
||||
K.eval('(define SX_VERSION "hs-test-1.0")');
|
||||
K.eval('(define SX_ENGINE "ocaml-vm-sandbox")');
|
||||
K.eval('(define parse sx-parse)');
|
||||
K.eval('(define serialize sx-serialize)');
|
||||
});
|
||||
|
||||
// Load web + hs modules
|
||||
const allModules = [...SANDBOX_STACKS.web, ...SANDBOX_STACKS.hs];
|
||||
const loadErrors = [];
|
||||
await page.evaluate(() => { if (window.SxKernel.beginModuleLoad) window.SxKernel.beginModuleLoad(); });
|
||||
for (const mod of [...WEB_MODULES, ...HS_MODULES]) {
|
||||
const src = getModuleSrc(mod);
|
||||
if (!src) { loadErrors.push(mod); continue; }
|
||||
|
||||
await page.evaluate(() => {
|
||||
if (window.SxKernel.beginModuleLoad) window.SxKernel.beginModuleLoad();
|
||||
});
|
||||
|
||||
for (const mod of allModules) {
|
||||
const sxPath = path.join(SX_DIR, mod + '.sx');
|
||||
const libPath = path.join(PROJECT_ROOT, 'lib/hyperscript', mod.replace(/^hs-/, '') + '.sx');
|
||||
let src;
|
||||
try {
|
||||
src = fs.existsSync(sxPath) ? fs.readFileSync(sxPath, 'utf8') : fs.readFileSync(libPath, 'utf8');
|
||||
} catch(e) { loadErrors.push(mod + ': file not found'); continue; }
|
||||
const err = await page.evaluate(s => {
|
||||
try { window.SxKernel.load(s); return null; } catch(e) { return e.message; }
|
||||
try { window.SxKernel.load(s); return null; }
|
||||
catch(e) { return e.message; }
|
||||
}, src);
|
||||
if (err) loadErrors.push(mod + ': ' + err);
|
||||
}
|
||||
await page.evaluate(() => { if (window.SxKernel.endModuleLoad) window.SxKernel.endModuleLoad(); });
|
||||
|
||||
// Deferred test registration + helpers
|
||||
await page.evaluate(() => {
|
||||
const K = window.SxKernel;
|
||||
K.eval('(define _test-registry (list))');
|
||||
K.eval('(define _test-suite "")');
|
||||
K.eval('(define push-suite (fn (name) (set! _test-suite name)))');
|
||||
K.eval('(define pop-suite (fn () (set! _test-suite "")))');
|
||||
K.eval(`(define try-call (fn (thunk)
|
||||
(set! _test-registry (append _test-registry (list {:suite _test-suite :thunk thunk})))
|
||||
{:ok true}))`);
|
||||
K.eval(`(define report-pass (fn (name)
|
||||
(let ((i (- (len _test-registry) 1)))
|
||||
(when (>= i 0) (dict-set! (nth _test-registry i) "name" name)))))`);
|
||||
K.eval(`(define report-fail (fn (name error)
|
||||
(let ((i (- (len _test-registry) 1)))
|
||||
(when (>= i 0) (dict-set! (nth _test-registry i) "name" name)))))`);
|
||||
// eval-hs: compile and evaluate a hyperscript expression/command, return its value.
|
||||
// If src contains 'return', use as-is. If it starts with a command keyword (set/put/get),
|
||||
// use as-is (the last expression is the result). Otherwise wrap in 'return'.
|
||||
K.eval(`(define eval-hs (fn (src)
|
||||
(let ((has-cmd (or (string-contains? src "return ")
|
||||
(string-contains? src "then ")
|
||||
(= "set " (slice src 0 4))
|
||||
(= "put " (slice src 0 4))
|
||||
(= "get " (slice src 0 4)))))
|
||||
(let ((wrapped (if has-cmd src (str "return " src))))
|
||||
(let ((sx (hs-to-sx-from-source wrapped)))
|
||||
(eval-expr sx))))))`);
|
||||
if (window.SxKernel.endModuleLoad) window.SxKernel.endModuleLoad();
|
||||
});
|
||||
|
||||
for (const f of TEST_FILES) {
|
||||
if (loadErrors.length > 0) return { loadErrors, results: [] };
|
||||
|
||||
// Define test platform — collects results into an array
|
||||
await page.evaluate(() => {
|
||||
const K = window.SxKernel;
|
||||
K.eval('(define _test-results (list))');
|
||||
K.eval('(define _test-suite "")');
|
||||
// try-call as JS native — catches both SX errors and JS-level crashes.
|
||||
// K.callFn returns null on Eval_error (kernel logs to console.error).
|
||||
// We capture the last console.error to detect failures.
|
||||
K.registerNative('try-call', args => {
|
||||
const thunk = args[0];
|
||||
let lastError = null;
|
||||
const origError = console.error;
|
||||
console.error = function() {
|
||||
const msg = Array.from(arguments).join(' ');
|
||||
if (msg.startsWith('[sx]')) lastError = msg;
|
||||
origError.apply(console, arguments);
|
||||
};
|
||||
try {
|
||||
const r = K.callFn(thunk, []);
|
||||
console.error = origError;
|
||||
if (lastError) {
|
||||
K.eval('(define _tc_err "' + lastError.replace(/\\/g, '\\\\').replace(/"/g, '\\"').slice(0, 200) + '")');
|
||||
return K.eval('{:ok false :error _tc_err}');
|
||||
}
|
||||
return K.eval('{:ok true}');
|
||||
} catch(e) {
|
||||
console.error = origError;
|
||||
const msg = typeof e === 'string' ? e : (e.message || String(e));
|
||||
K.eval('(define _tc_err "' + msg.replace(/\\/g, '\\\\').replace(/"/g, '\\"').slice(0, 200) + '")');
|
||||
return K.eval('{:ok false :error _tc_err}');
|
||||
}
|
||||
});
|
||||
K.eval(`(define report-pass
|
||||
(fn (name) (set! _test-results
|
||||
(append _test-results (list {:suite _test-suite :name name :pass true :error nil})))))`);
|
||||
K.eval(`(define report-fail
|
||||
(fn (name error) (set! _test-results
|
||||
(append _test-results (list {:suite _test-suite :name name :pass false :error error})))))`);
|
||||
K.eval('(define push-suite (fn (name) (set! _test-suite name)))');
|
||||
K.eval('(define pop-suite (fn () (set! _test-suite "")))');
|
||||
});
|
||||
|
||||
// Load test framework + behavioral tests
|
||||
for (const f of ['spec/harness.sx', 'spec/tests/test-framework.sx', 'spec/tests/test-hyperscript-behavioral.sx']) {
|
||||
const src = fs.readFileSync(path.join(PROJECT_ROOT, f), 'utf8');
|
||||
const err = await page.evaluate(s => {
|
||||
try { window.SxKernel.load(s); return null; } catch(e) { return e.message; }
|
||||
}, TEST_FILE_CACHE[f]);
|
||||
if (err) loadErrors.push(f + ': ' + err);
|
||||
try { window.SxKernel.load(s); return null; }
|
||||
catch(e) { return 'LOAD ERROR: ' + e.message; }
|
||||
}, src);
|
||||
if (err) {
|
||||
const partial = await page.evaluate(() => window.SxKernel.eval('(len _test-results)'));
|
||||
return { loadErrors: [f + ': ' + err + ' (' + partial + ' results before crash)'], results: [] };
|
||||
}
|
||||
}
|
||||
return loadErrors;
|
||||
|
||||
// Collect results — serialize via SX inspect for reliability
|
||||
const resultsRaw = await page.evaluate(() => {
|
||||
const K = window.SxKernel;
|
||||
const count = K.eval('(len _test-results)');
|
||||
const arr = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
arr.push(K.eval(`(inspect (nth _test-results ${i}))`));
|
||||
}
|
||||
return { count, items: arr };
|
||||
});
|
||||
|
||||
// Parse the SX dict strings
|
||||
const results = resultsRaw.items.map(s => {
|
||||
// s is like '{:suite "hs-add" :name "add class" :pass true :error nil}'
|
||||
const suite = (s.match(/:suite "([^"]*)"/) || [])[1] || '';
|
||||
const name = (s.match(/:name "([^"]*)"/) || [])[1] || '';
|
||||
const pass = s.includes(':pass true');
|
||||
const errorMatch = s.match(/:error "([^"]*)"/);
|
||||
const error = errorMatch ? errorMatch[1] : (s.includes(':error nil') ? null : 'unknown');
|
||||
return { suite, name, pass, error };
|
||||
});
|
||||
|
||||
return { loadErrors, results };
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
test.describe('Hyperscript behavioral tests', () => {
|
||||
test.describe.configure({ timeout: 600000 });
|
||||
|
||||
test('upstream conformance', async ({ browser }) => {
|
||||
let page = await browser.newPage();
|
||||
let loadErrors = await bootSandbox(page);
|
||||
// ===========================================================================
|
||||
// Test suite — one Playwright test per SX test
|
||||
// ===========================================================================
|
||||
|
||||
test.describe('Hyperscript behavioral tests', () => {
|
||||
test.describe.configure({ timeout: 300000 }); // 5 min for 291 tests
|
||||
|
||||
test('SX behavioral test suite', async ({ browser }) => {
|
||||
const page = await browser.newPage();
|
||||
const { loadErrors, results } = await runSxTests(page);
|
||||
await page.close();
|
||||
|
||||
expect(loadErrors).toEqual([]);
|
||||
|
||||
// Get test list
|
||||
const testList = await page.evaluate(() => {
|
||||
const K = window.SxKernel;
|
||||
const count = K.eval('(len _test-registry)');
|
||||
const tests = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
tests.push({
|
||||
s: K.eval(`(get (nth _test-registry ${i}) "suite")`) || '',
|
||||
n: K.eval(`(get (nth _test-registry ${i}) "name")`) || `test-${i}`,
|
||||
});
|
||||
}
|
||||
return tests;
|
||||
});
|
||||
|
||||
// Run each test individually with timeout
|
||||
const results = [];
|
||||
let consecutiveTimeouts = 0;
|
||||
|
||||
for (let i = 0; i < testList.length; i++) {
|
||||
const t = testList[i];
|
||||
|
||||
// If page is dead (after timeout), reboot
|
||||
if (consecutiveTimeouts > 0) {
|
||||
// After a timeout, the page.evaluate from Promise.race is orphaned.
|
||||
// We must close + reopen to get a clean page.
|
||||
try { await page.close(); } catch(_) {}
|
||||
page = await browser.newPage();
|
||||
loadErrors = await bootSandbox(page);
|
||||
if (loadErrors.length > 0) {
|
||||
for (let j = i; j < testList.length; j++)
|
||||
results.push({ s: testList[j].s, n: testList[j].n, p: false, e: 'reboot failed' });
|
||||
break;
|
||||
}
|
||||
consecutiveTimeouts = 0;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await Promise.race([
|
||||
page.evaluate(async (idx) => {
|
||||
const K = window.SxKernel;
|
||||
const newBody = document.createElement('body');
|
||||
document.documentElement.replaceChild(newBody, document.body);
|
||||
|
||||
const thunk = K.eval(`(get (nth _test-registry ${idx}) "thunk")`);
|
||||
if (!thunk) return { p: false, e: 'no thunk' };
|
||||
|
||||
let lastErr = null;
|
||||
const orig = console.error;
|
||||
console.error = function() {
|
||||
const m = Array.from(arguments).join(' ');
|
||||
if (m.startsWith('[sx]')) lastErr = m;
|
||||
orig.apply(console, arguments);
|
||||
};
|
||||
|
||||
// Drive async suspension chains (wait, fetch, etc.)
|
||||
let pending = 0;
|
||||
const oldDrive = window._driveAsync;
|
||||
window._driveAsync = function driveAsync(result) {
|
||||
if (!result || !result.suspended) return;
|
||||
pending++;
|
||||
const req = result.request;
|
||||
const items = req && (req.items || req);
|
||||
const op = items && items[0];
|
||||
const opName = typeof op === 'string' ? op : (op && op.name) || String(op);
|
||||
const arg = items && items[1];
|
||||
function doResume(val, delay) {
|
||||
setTimeout(() => {
|
||||
try { const r = result.resume(val); pending--; driveAsync(r); }
|
||||
catch(e) { pending--; }
|
||||
}, delay);
|
||||
}
|
||||
if (opName === 'io-sleep' || opName === 'wait') doResume(null, Math.min(typeof arg === 'number' ? arg : 0, 10));
|
||||
else if (opName === 'io-fetch') doResume({ok: true, text: ''}, 1);
|
||||
else if (opName === 'io-settle') doResume(null, 5);
|
||||
else if (opName === 'io-wait-event') doResume(null, 5);
|
||||
else pending--;
|
||||
};
|
||||
|
||||
try {
|
||||
const r = K.callFn(thunk, []);
|
||||
// If thunk itself suspended, drive it
|
||||
if (r && r.suspended) window._driveAsync(r);
|
||||
// Wait for all pending async chains to settle
|
||||
if (pending > 0) {
|
||||
await new Promise(resolve => {
|
||||
let waited = 0;
|
||||
const check = () => {
|
||||
if (pending <= 0 || waited > 2000) resolve();
|
||||
else { waited += 10; setTimeout(check, 10); }
|
||||
};
|
||||
setTimeout(check, 10);
|
||||
});
|
||||
}
|
||||
console.error = orig;
|
||||
window._driveAsync = oldDrive;
|
||||
return lastErr ? { p: false, e: lastErr.replace(/[\\"]/g, ' ').slice(0, 150) } : { p: true, e: null };
|
||||
} catch(e) {
|
||||
console.error = orig;
|
||||
window._driveAsync = oldDrive;
|
||||
return { p: false, e: (e.message || '').replace(/[\\"]/g, ' ').slice(0, 150) };
|
||||
}
|
||||
}, i),
|
||||
new Promise(resolve => setTimeout(() => resolve({ p: false, e: 'TIMEOUT' }), 3000))
|
||||
]);
|
||||
} catch(e) {
|
||||
result = { p: false, e: 'CRASH: ' + (e.message || '').slice(0, 80) };
|
||||
}
|
||||
|
||||
if (result.e === 'TIMEOUT' || (result.e && result.e.startsWith('CRASH'))) {
|
||||
consecutiveTimeouts++;
|
||||
}
|
||||
|
||||
results.push({ s: t.s, n: t.n, p: result.p, e: result.e });
|
||||
}
|
||||
|
||||
try { await page.close(); } catch(_) {}
|
||||
|
||||
// Tally
|
||||
// Tally and report
|
||||
let passed = 0, failed = 0;
|
||||
const cats = {};
|
||||
const errTypes = {};
|
||||
const failsByCat = {};
|
||||
for (const r of results) {
|
||||
if (r.p) passed++; else {
|
||||
if (r.pass) { passed++; }
|
||||
else {
|
||||
failed++;
|
||||
const e = r.e || '';
|
||||
let t = 'other';
|
||||
if (e === 'TIMEOUT') t = 'timeout';
|
||||
else if (e.includes('NOT IMPLEMENTED')) t = 'stub';
|
||||
else if (e.includes('callFn')) t = 'crash';
|
||||
else if (e.includes('Assertion')) t = 'assert-fail';
|
||||
else if (e.includes('Unhandled')) t = 'unhandled';
|
||||
else if (e.includes('Expected')) t = 'wrong-value';
|
||||
else if (e.includes('Cannot read')) t = 'null-ref';
|
||||
else if (e.includes('Undefined')) t = 'undef-sym';
|
||||
else if (e.includes('no thunk')) t = 'no-thunk';
|
||||
else if (e.includes('reboot')) t = 'reboot-fail';
|
||||
if (!errTypes[t]) errTypes[t] = 0;
|
||||
errTypes[t]++;
|
||||
if (!failsByCat[r.suite]) failsByCat[r.suite] = 0;
|
||||
failsByCat[r.suite]++;
|
||||
}
|
||||
if (!cats[r.s]) cats[r.s] = { p: 0, f: 0 };
|
||||
if (r.p) cats[r.s].p++; else cats[r.s].f++;
|
||||
}
|
||||
console.log(`\n Upstream conformance: ${passed}/${results.length} (${(100*passed/results.length).toFixed(0)}%)`);
|
||||
// Per-category summary
|
||||
const cats = {};
|
||||
for (const r of results) {
|
||||
if (!cats[r.suite]) cats[r.suite] = { p: 0, f: 0 };
|
||||
if (r.pass) cats[r.suite].p++; else cats[r.suite].f++;
|
||||
}
|
||||
for (const [cat, s] of Object.entries(cats).sort((a,b) => b[1].p - a[1].p)) {
|
||||
const mark = s.f === 0 ? `✓ ${s.p}` : `${s.p}/${s.p+s.f}`;
|
||||
console.log(` ${cat}: ${mark}`);
|
||||
}
|
||||
console.log(` Failure types:`);
|
||||
for (const [t, n] of Object.entries(errTypes).sort((a,b) => b[1] - a[1])) {
|
||||
console.log(` ${t}: ${n}`);
|
||||
|
||||
// Failure details — classify by error type
|
||||
const errorTypes = {};
|
||||
for (const r of results.filter(r => !r.pass)) {
|
||||
const e = r.error || 'unknown';
|
||||
let type = 'other';
|
||||
if (e.includes('NOT IMPLEMENTED')) type = 'not-generated';
|
||||
else if (e.includes('[sx] callFn')) type = 'callFn-crash';
|
||||
else if (e.includes('Assertion failed')) type = 'assertion';
|
||||
else if (e.includes('Undefined symbol')) type = 'undefined-symbol';
|
||||
else if (e.includes('Expected')) type = 'wrong-value';
|
||||
else if (e.includes('Cannot read')) type = 'null-ref';
|
||||
else if (e.includes('not defined')) type = 'js-undef';
|
||||
if (!errorTypes[type]) errorTypes[type] = [];
|
||||
errorTypes[type].push(`[${r.suite}] ${r.name}: ${e.slice(0, 80)}`);
|
||||
}
|
||||
// Show ALL crash errors (deduplicated by error message)
|
||||
const uniqueErrors = {};
|
||||
for (const r of results.filter(r => !r.p)) {
|
||||
const e = (r.e || '').slice(0, 100);
|
||||
if (!uniqueErrors[e]) uniqueErrors[e] = { count: 0, example: r };
|
||||
uniqueErrors[e].count++;
|
||||
}
|
||||
console.log(` Unique error messages (${Object.keys(uniqueErrors).length}):`);
|
||||
for (const [e, info] of Object.entries(uniqueErrors).sort((a,b) => b[1].count - a[1].count).slice(0, 25)) {
|
||||
console.log(` [${info.count}x] ${e}`);
|
||||
}
|
||||
// Show ALL failing tests with errors (for diagnosis)
|
||||
const failsByCategory = {};
|
||||
for (const r of results.filter(r => !r.p)) {
|
||||
if (!failsByCategory[r.s]) failsByCategory[r.s] = [];
|
||||
failsByCategory[r.s].push(r);
|
||||
}
|
||||
for (const [cat, fails] of Object.entries(failsByCategory).sort((a,b) => a[0].localeCompare(b[0]))) {
|
||||
for (const f of fails.slice(0, 5)) {
|
||||
console.log(` FAIL ${f.s}/${f.n}: ${(f.e||'').slice(0, 100)}`);
|
||||
}
|
||||
console.log(`\n Failure breakdown:`);
|
||||
for (const [type, items] of Object.entries(errorTypes).sort((a,b) => b[1].length - a[1].length)) {
|
||||
console.log(` ${type}: ${items.length}`);
|
||||
for (const item of items.slice(0, 5)) console.log(` ${item.slice(0, 200)}`);
|
||||
if (items.length > 3) console.log(` ...and ${items.length - 3} more`);
|
||||
}
|
||||
|
||||
expect(results.length).toBeGreaterThanOrEqual(830);
|
||||
expect(passed).toBeGreaterThanOrEqual(300);
|
||||
// Hard gate — ratchet this up as implementation improves
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(passed).toBeGreaterThanOrEqual(460);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user