Fix JIT compiler, CSSX browser support, double-fetch, SPA layout
JIT compiler: - Fix jit_compile_lambda: resolve `compile` via symbol lookup in env instead of embedding VmClosure in AST (CEK dispatches differently) - Register eval-defcomp/eval-defisland/eval-defmacro runtime helpers in browser kernel for bytecoded defcomp forms - Disable broken .sxbc.json path (missing arity in nested code blocks), use .sxbc text format only - Mark JIT-failed closures as sentinel to stop retrying CSSX in browser: - Add cssx.sx symlink + cssx.sxbc to browser web stack - Add flush-cssx! to orchestration.sx post-swap for SPA nav - Add cssx.sx to compile-modules.js and mcp_tree.ml bytecode lists SPA navigation: - Fix double-fetch: check e.defaultPrevented in click delegation (bind-event already handled the click) - Fix layout destruction: change nav links from outerHTML to innerHTML swap (outerHTML destroyed #main-panel when response lacked it) - Guard JS popstate handler when SX engine is booted - Rename sx-platform.js → sx-platform-2.js to bust immutable cache Playwright tests: - Add trackErrors() helper to all test specs - Add SPA DOM comparison test (SPA nav vs fresh load) - Add single-fetch + no-duplicate-elements test - Improve MCP tool output: show failure details and error messages Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -606,6 +606,7 @@ let rec handle_tool name args =
|
|||||||
"render.sx"; "core-signals.sx"; "signals.sx"; "deps.sx"; "router.sx";
|
"render.sx"; "core-signals.sx"; "signals.sx"; "deps.sx"; "router.sx";
|
||||||
"page-helpers.sx"; "freeze.sx"; "bytecode.sx"; "compiler.sx"; "vm.sx";
|
"page-helpers.sx"; "freeze.sx"; "bytecode.sx"; "compiler.sx"; "vm.sx";
|
||||||
"dom.sx"; "browser.sx"; "adapter-html.sx"; "adapter-sx.sx"; "adapter-dom.sx";
|
"dom.sx"; "browser.sx"; "adapter-html.sx"; "adapter-sx.sx"; "adapter-dom.sx";
|
||||||
|
"cssx.sx";
|
||||||
"boot-helpers.sx"; "hypersx.sx"; "harness.sx"; "harness-reactive.sx";
|
"boot-helpers.sx"; "hypersx.sx"; "harness.sx"; "harness-reactive.sx";
|
||||||
"harness-web.sx"; "engine.sx"; "orchestration.sx"; "boot.sx";
|
"harness-web.sx"; "engine.sx"; "orchestration.sx"; "boot.sx";
|
||||||
] in
|
] in
|
||||||
@@ -1225,18 +1226,41 @@ let rec handle_tool name args =
|
|||||||
(try while true do lines := input_line ic :: !lines done with End_of_file -> ());
|
(try while true do lines := input_line ic :: !lines done with End_of_file -> ());
|
||||||
ignore (Unix.close_process_in ic);
|
ignore (Unix.close_process_in ic);
|
||||||
let all_lines = List.rev !lines in
|
let all_lines = List.rev !lines in
|
||||||
let fails = List.filter (fun l -> let t = String.trim l in
|
(* Count passed/failed/skipped from the summary line *)
|
||||||
String.length t > 1 && (t.[0] = '\xE2' (* ✘ *) || (String.length t > 4 && String.sub t 0 4 = "FAIL"))) all_lines in
|
|
||||||
let summary = List.find_opt (fun l ->
|
let summary = List.find_opt (fun l ->
|
||||||
try let _ = Str.search_forward (Str.regexp "passed\\|failed") l 0 in true
|
try let _ = Str.search_forward (Str.regexp "passed\\|failed") l 0 in true
|
||||||
with Not_found -> false) (List.rev all_lines) in
|
with Not_found -> false) (List.rev all_lines) in
|
||||||
let result = match summary with
|
(* Extract test names that failed *)
|
||||||
| Some s ->
|
let fail_names = List.filter_map (fun l ->
|
||||||
if fails = [] then s
|
let t = String.trim l in
|
||||||
else s ^ "\n\nFailures:\n" ^ String.concat "\n" fails
|
if String.length t > 2 then
|
||||||
| None ->
|
try
|
||||||
let last_n = List.filteri (fun i _ -> i >= List.length all_lines - 10) all_lines in
|
let _ = Str.search_forward (Str.regexp "› .* › ") t 0 in
|
||||||
String.concat "\n" last_n
|
Some (" " ^ t)
|
||||||
|
with Not_found -> None
|
||||||
|
else None) all_lines in
|
||||||
|
(* Extract error messages (lines starting with Error:) *)
|
||||||
|
let errors = List.filter_map (fun l ->
|
||||||
|
let t = String.trim l in
|
||||||
|
if String.length t > 6 then
|
||||||
|
try
|
||||||
|
let _ = Str.search_forward (Str.regexp "expect.*\\(received\\)\\|Expected\\|Received\\|Error:.*expect") t 0 in
|
||||||
|
Some (" " ^ t)
|
||||||
|
with Not_found -> None
|
||||||
|
else None) all_lines in
|
||||||
|
let total = List.length fail_names + (match summary with
|
||||||
|
| Some s -> (try let _ = Str.search_forward (Str.regexp "\\([0-9]+\\) passed") s 0 in
|
||||||
|
int_of_string (Str.matched_group 1 s) with _ -> 0)
|
||||||
|
| None -> 0) in
|
||||||
|
let summary_str = match summary with Some s -> String.trim s | None -> "no summary" in
|
||||||
|
let result =
|
||||||
|
if fail_names = [] then
|
||||||
|
Printf.sprintf "%s (%d total)" summary_str total
|
||||||
|
else
|
||||||
|
Printf.sprintf "%s (%d total)\n\nFailed:\n%s\n\nErrors:\n%s"
|
||||||
|
summary_str total
|
||||||
|
(String.concat "\n" fail_names)
|
||||||
|
(String.concat "\n" (List.filteri (fun i _ -> i < 10) errors))
|
||||||
in
|
in
|
||||||
text_result result
|
text_result result
|
||||||
end else begin
|
end else begin
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const FILES = [
|
|||||||
'render.sx', 'core-signals.sx', 'signals.sx', 'deps.sx', 'router.sx',
|
'render.sx', 'core-signals.sx', 'signals.sx', 'deps.sx', 'router.sx',
|
||||||
'page-helpers.sx', 'freeze.sx', 'bytecode.sx', 'compiler.sx', 'vm.sx',
|
'page-helpers.sx', 'freeze.sx', 'bytecode.sx', 'compiler.sx', 'vm.sx',
|
||||||
'dom.sx', 'browser.sx', 'adapter-html.sx', 'adapter-sx.sx', 'adapter-dom.sx',
|
'dom.sx', 'browser.sx', 'adapter-html.sx', 'adapter-sx.sx', 'adapter-dom.sx',
|
||||||
|
'cssx.sx',
|
||||||
'boot-helpers.sx', 'hypersx.sx', 'harness.sx', 'harness-reactive.sx',
|
'boot-helpers.sx', 'hypersx.sx', 'harness.sx', 'harness-reactive.sx',
|
||||||
'harness-web.sx', 'engine.sx', 'orchestration.sx', 'boot.sx',
|
'harness-web.sx', 'engine.sx', 'orchestration.sx', 'boot.sx',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -647,6 +647,17 @@ let () =
|
|||||||
bind "provide-push!" (fun args -> match args with [n; v] -> Sx_runtime.provide_push n v | _ -> raise (Eval_error "provide-push!"));
|
bind "provide-push!" (fun args -> match args with [n; v] -> Sx_runtime.provide_push n v | _ -> raise (Eval_error "provide-push!"));
|
||||||
bind "provide-pop!" (fun args -> match args with [n] -> Sx_runtime.provide_pop n | _ -> raise (Eval_error "provide-pop!"));
|
bind "provide-pop!" (fun args -> match args with [n] -> Sx_runtime.provide_pop n | _ -> raise (Eval_error "provide-pop!"));
|
||||||
|
|
||||||
|
(* Runtime helpers for bytecoded defcomp/defisland/defmacro forms.
|
||||||
|
The compiler emits GLOBAL_GET "eval-defcomp" + CALL — these must
|
||||||
|
exist as callable values for bytecoded .sx files that contain
|
||||||
|
component definitions (e.g. cssx.sx). *)
|
||||||
|
bind "eval-defcomp" (fun args ->
|
||||||
|
match args with [List (_ :: rest)] -> Sx_ref.sf_defcomp (List rest) (Env global_env) | _ -> Nil);
|
||||||
|
bind "eval-defisland" (fun args ->
|
||||||
|
match args with [List (_ :: rest)] -> Sx_ref.sf_defisland (List rest) (Env global_env) | _ -> Nil);
|
||||||
|
bind "eval-defmacro" (fun args ->
|
||||||
|
match args with [List (_ :: rest)] -> Sx_ref.sf_defmacro (List rest) (Env global_env) | _ -> Nil);
|
||||||
|
|
||||||
(* --- Fragment / raw HTML --- *)
|
(* --- Fragment / raw HTML --- *)
|
||||||
bind "<>" (fun args ->
|
bind "<>" (fun args ->
|
||||||
RawHTML (String.concat "" (List.map (fun a ->
|
RawHTML (String.concat "" (List.map (fun a ->
|
||||||
@@ -781,7 +792,13 @@ let () =
|
|||||||
(try Some (Sx_vm.call_closure cl args _vm_globals)
|
(try Some (Sx_vm.call_closure cl args _vm_globals)
|
||||||
with Eval_error msg ->
|
with Eval_error msg ->
|
||||||
let fn_name = match l.l_name with Some n -> n | None -> "?" in
|
let fn_name = match l.l_name with Some n -> n | None -> "?" in
|
||||||
Printf.eprintf "[jit] FAIL %s: %s\n%!" fn_name msg;
|
Printf.eprintf "[jit] FAIL %s: %s (bc=%d consts=%d upv=%d)\n%!"
|
||||||
|
fn_name msg
|
||||||
|
(Array.length cl.vm_code.vc_bytecode)
|
||||||
|
(Array.length cl.vm_code.vc_constants)
|
||||||
|
(Array.length cl.vm_upvalues);
|
||||||
|
(* Mark as failed to stop retrying *)
|
||||||
|
l.l_compiled <- Some (Sx_vm.jit_failed_sentinel);
|
||||||
None)
|
None)
|
||||||
| Some _ -> None
|
| Some _ -> None
|
||||||
| None ->
|
| None ->
|
||||||
@@ -796,7 +813,12 @@ let () =
|
|||||||
(try Some (Sx_vm.call_closure cl args _vm_globals)
|
(try Some (Sx_vm.call_closure cl args _vm_globals)
|
||||||
with Eval_error msg ->
|
with Eval_error msg ->
|
||||||
let fn_name2 = match l.l_name with Some n -> n | None -> "?" in
|
let fn_name2 = match l.l_name with Some n -> n | None -> "?" in
|
||||||
Printf.eprintf "[jit] FAIL %s: %s\n%!" fn_name2 msg;
|
Printf.eprintf "[jit] FAIL %s: %s (bc=%d consts=%d upv=%d)\n%!"
|
||||||
|
fn_name2 msg
|
||||||
|
(Array.length cl.vm_code.vc_bytecode)
|
||||||
|
(Array.length cl.vm_code.vc_constants)
|
||||||
|
(Array.length cl.vm_upvalues);
|
||||||
|
l.l_compiled <- Some (Sx_vm.jit_failed_sentinel);
|
||||||
None)
|
None)
|
||||||
| None -> None)
|
| None -> None)
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -577,7 +577,13 @@ let jit_compile_lambda (l : lambda) globals =
|
|||||||
let param_syms = List (List.map (fun s -> Symbol s) l.l_params) in
|
let param_syms = List (List.map (fun s -> Symbol s) l.l_params) in
|
||||||
let fn_expr = List [Symbol "fn"; param_syms; l.l_body] in
|
let fn_expr = List [Symbol "fn"; param_syms; l.l_body] in
|
||||||
let quoted = List [Symbol "quote"; fn_expr] in
|
let quoted = List [Symbol "quote"; fn_expr] in
|
||||||
let result = Sx_ref.eval_expr (List [compile_fn; quoted]) (Env (make_env ())) in
|
(* Use Symbol "compile" so the CEK resolves it from the env, not
|
||||||
|
an embedded VmClosure value — the CEK dispatches VmClosure calls
|
||||||
|
differently when the value is resolved from env vs embedded in AST. *)
|
||||||
|
ignore compile_fn;
|
||||||
|
let compile_env = Sx_types.env_extend (Sx_types.make_env ()) in
|
||||||
|
Hashtbl.iter (fun k v -> Hashtbl.replace compile_env.bindings (Sx_types.intern k) v) globals;
|
||||||
|
let result = Sx_ref.eval_expr (List [Symbol "compile"; quoted]) (Env compile_env) in
|
||||||
(* Closure vars are accessible via vm_closure_env (set on the VmClosure
|
(* Closure vars are accessible via vm_closure_env (set on the VmClosure
|
||||||
at line ~617). OP_GLOBAL_GET falls back to vm_closure_env when vars
|
at line ~617). OP_GLOBAL_GET falls back to vm_closure_env when vars
|
||||||
aren't in globals. No injection into the shared globals table —
|
aren't in globals. No injection into the shared globals table —
|
||||||
|
|||||||
@@ -233,31 +233,9 @@
|
|||||||
* Returns true on success, null on failure (caller falls back to .sx source).
|
* Returns true on success, null on failure (caller falls back to .sx source).
|
||||||
*/
|
*/
|
||||||
function loadBytecodeFile(path) {
|
function loadBytecodeFile(path) {
|
||||||
// Try .sxbc.json (JSON dict format)
|
console.log("[sx-platform] loadBytecodeFile:", path, "(sxbc-only, no json)");
|
||||||
var jsonPath = path.replace(/\.sx$/, '.sxbc.json');
|
// .sxbc.json path removed — the JSON format had a bug (missing arity
|
||||||
var jsonUrl = _baseUrl + jsonPath + _cacheBust;
|
// in nested code blocks). Use .sxbc (SX text) format only.
|
||||||
try {
|
|
||||||
var xhr = new XMLHttpRequest();
|
|
||||||
xhr.open("GET", jsonUrl, false);
|
|
||||||
xhr.send();
|
|
||||||
if (xhr.status === 200) {
|
|
||||||
var json = JSON.parse(xhr.responseText);
|
|
||||||
if (json.module && json.magic === 'SXBC') {
|
|
||||||
var module = {
|
|
||||||
_type: 'dict',
|
|
||||||
arity: json.module.arity || 0,
|
|
||||||
bytecode: { _type: 'list', items: json.module.bytecode },
|
|
||||||
constants: { _type: 'list', items: json.module.constants.map(deserializeConstant) },
|
|
||||||
};
|
|
||||||
var result = K.loadModule(module);
|
|
||||||
if (typeof result !== 'string' || result.indexOf('Error') !== 0) {
|
|
||||||
console.log("[sx-platform] ok " + path + " (bytecode-json)");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
console.warn("[sx-platform] bytecode-json FAIL " + path + ":", result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch(e) { /* fall through to .sxbc */ }
|
|
||||||
|
|
||||||
// Try .sxbc (SX s-expression format, loaded via load-sxbc primitive)
|
// Try .sxbc (SX s-expression format, loaded via load-sxbc primitive)
|
||||||
var sxbcPath = path.replace(/\.sx$/, '.sxbc');
|
var sxbcPath = path.replace(/\.sx$/, '.sxbc');
|
||||||
@@ -336,6 +314,8 @@
|
|||||||
"sx/adapter-html.sx",
|
"sx/adapter-html.sx",
|
||||||
"sx/adapter-sx.sx",
|
"sx/adapter-sx.sx",
|
||||||
"sx/adapter-dom.sx",
|
"sx/adapter-dom.sx",
|
||||||
|
// Client libraries (CSSX etc. — needed by page components)
|
||||||
|
"sx/cssx.sx",
|
||||||
// Boot helpers (platform functions in pure SX)
|
// Boot helpers (platform functions in pure SX)
|
||||||
"sx/boot-helpers.sx",
|
"sx/boot-helpers.sx",
|
||||||
"sx/hypersx.sx",
|
"sx/hypersx.sx",
|
||||||
@@ -418,22 +398,18 @@
|
|||||||
"hydrated:", !!islands[j]._sxBoundislandhydrated || !!islands[j]["_sxBound" + "island-hydrated"],
|
"hydrated:", !!islands[j]._sxBoundislandhydrated || !!islands[j]["_sxBound" + "island-hydrated"],
|
||||||
"children:", islands[j].children.length);
|
"children:", islands[j].children.length);
|
||||||
}
|
}
|
||||||
// Register popstate handler for back/forward navigation.
|
// Fallback popstate handler for back/forward navigation.
|
||||||
// Fetch HTML (not SX) and extract #main-panel content.
|
// Only fires before SX engine boots — after boot, boot.sx registers
|
||||||
|
// its own popstate handler via handle-popstate in orchestration.sx.
|
||||||
window.addEventListener("popstate", function() {
|
window.addEventListener("popstate", function() {
|
||||||
|
if (document.documentElement.hasAttribute("data-sx-ready")) return;
|
||||||
var url = location.pathname + location.search;
|
var url = location.pathname + location.search;
|
||||||
var target = document.querySelector("#main-panel");
|
var target = document.querySelector("#main-panel");
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
// Try client-side route first
|
|
||||||
var clientHandled = false;
|
|
||||||
try { clientHandled = K.eval('(try-client-route "' + url.replace(/"/g, '\\"') + '" "#main-panel")'); } catch(e) {}
|
|
||||||
if (clientHandled) return;
|
|
||||||
// Server fetch — request full HTML (no SX-Request header)
|
|
||||||
fetch(url)
|
fetch(url)
|
||||||
.then(function(r) { return r.text(); })
|
.then(function(r) { return r.text(); })
|
||||||
.then(function(html) {
|
.then(function(html) {
|
||||||
if (!html) return;
|
if (!html) return;
|
||||||
// Parse the full HTML and extract #main-panel
|
|
||||||
var parser = new DOMParser();
|
var parser = new DOMParser();
|
||||||
var doc = parser.parseFromString(html, "text/html");
|
var doc = parser.parseFromString(html, "text/html");
|
||||||
var srcPanel = doc.querySelector("#main-panel");
|
var srcPanel = doc.querySelector("#main-panel");
|
||||||
@@ -441,26 +417,20 @@
|
|||||||
if (srcPanel) {
|
if (srcPanel) {
|
||||||
target.outerHTML = srcPanel.outerHTML;
|
target.outerHTML = srcPanel.outerHTML;
|
||||||
}
|
}
|
||||||
// Also update nav if present
|
|
||||||
var navTarget = document.querySelector("#sx-nav");
|
var navTarget = document.querySelector("#sx-nav");
|
||||||
if (srcNav && navTarget) {
|
if (srcNav && navTarget) {
|
||||||
navTarget.outerHTML = srcNav.outerHTML;
|
navTarget.outerHTML = srcNav.outerHTML;
|
||||||
}
|
}
|
||||||
// Re-hydrate
|
|
||||||
var newTarget = document.querySelector("#main-panel");
|
|
||||||
if (newTarget) {
|
|
||||||
try { K.eval("(post-swap (dom-query \"#main-panel\"))"); } catch(e) {}
|
|
||||||
try { K.eval("(sx-hydrate-islands (dom-query \"#main-panel\"))"); } catch(e) {}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(function(e) { console.warn("[sx] popstate fetch error:", e); });
|
.catch(function(e) { console.warn("[sx] popstate fetch error:", e); });
|
||||||
});
|
});
|
||||||
// Event delegation for sx-get links — bytecoded bind-event doesn't
|
// Event delegation for sx-get links — fallback when bind-event's
|
||||||
// attach per-element listeners (VM closure issue), so catch clicks
|
// per-element listener didn't attach. If bind-event DID fire, it
|
||||||
// at the document level and route through the SX engine.
|
// already called preventDefault — skip to avoid double-fetch.
|
||||||
document.addEventListener("click", function(e) {
|
document.addEventListener("click", function(e) {
|
||||||
var el = e.target.closest("a[sx-get]");
|
var el = e.target.closest("a[sx-get]");
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
if (e.defaultPrevented) return;
|
||||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var url = el.getAttribute("href") || el.getAttribute("sx-get");
|
var url = el.getAttribute("href") || el.getAttribute("sx-get");
|
||||||
1
shared/static/wasm/sx/cssx.sx
Symbolic link
1
shared/static/wasm/sx/cssx.sx
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../../shared/sx/templates/cssx.sx
|
||||||
3
shared/static/wasm/sx/cssx.sxbc
Normal file
3
shared/static/wasm/sx/cssx.sxbc
Normal file
File diff suppressed because one or more lines are too long
@@ -256,6 +256,25 @@
|
|||||||
"sx:afterSwap"
|
"sx:afterSwap"
|
||||||
(dict "target" target-el "swap" swap-style)))))))
|
(dict "target" target-el "swap" swap-style)))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
flush-cssx!
|
||||||
|
:effects (mutation io)
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(let
|
||||||
|
((rules (collected "cssx")))
|
||||||
|
(clear-collected! "cssx")
|
||||||
|
(when
|
||||||
|
(not (empty? rules))
|
||||||
|
(let
|
||||||
|
((style (dom-query "#sx-css")))
|
||||||
|
(when
|
||||||
|
style
|
||||||
|
(dom-set-prop
|
||||||
|
style
|
||||||
|
"textContent"
|
||||||
|
(str (dom-get-prop style "textContent") (join "" rules)))))))))
|
||||||
|
|
||||||
(define
|
(define
|
||||||
handle-sx-response
|
handle-sx-response
|
||||||
:effects (mutation io)
|
:effects (mutation io)
|
||||||
@@ -508,7 +527,8 @@
|
|||||||
(sx-hydrate root)
|
(sx-hydrate root)
|
||||||
(sx-hydrate-islands root)
|
(sx-hydrate-islands root)
|
||||||
(run-post-render-hooks)
|
(run-post-render-hooks)
|
||||||
(process-elements root)))
|
(process-elements root)
|
||||||
|
(flush-cssx!)))
|
||||||
|
|
||||||
(define
|
(define
|
||||||
process-settle-hooks
|
process-settle-hooks
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
shared/static/wasm/sx_browser.bc.wasm.assets/sx-15eb71d8.wasm
Normal file
BIN
shared/static/wasm/sx_browser.bc.wasm.assets/sx-15eb71d8.wasm
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
shared/static/wasm/sx_browser.bc.wasm.assets/sx-5c519624.wasm
Normal file
BIN
shared/static/wasm/sx_browser.bc.wasm.assets/sx-5c519624.wasm
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
shared/static/wasm/sx_browser.bc.wasm.assets/sx-eb076217.wasm
Normal file
BIN
shared/static/wasm/sx_browser.bc.wasm.assets/sx-eb076217.wasm
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
shared/static/wasm/sx_browser.bc.wasm.assets/sx-f4a8777b.wasm
Normal file
BIN
shared/static/wasm/sx_browser.bc.wasm.assets/sx-f4a8777b.wasm
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -1792,7 +1792,7 @@
|
|||||||
blake2_js_for_wasm_create: blake2_js_for_wasm_create};
|
blake2_js_for_wasm_create: blake2_js_for_wasm_create};
|
||||||
}
|
}
|
||||||
(globalThis))
|
(globalThis))
|
||||||
({"link":[["runtime-0db9b496",0],["prelude-d7e4b000",0],["stdlib-23ce0836",[]],["sx-15eb71d8",[2]],["jsoo_runtime-f96b44a8",[2]],["js_of_ocaml-651f6707",[2,4]],["dune__exe__Sx_browser-0c758f5b",[2,3,5]],["std_exit-10fb8830",[2]],["start-80fdb768",0]],"generated":(b=>{var
|
({"link":[["runtime-0db9b496",0],["prelude-d7e4b000",0],["stdlib-23ce0836",[]],["sx-eb076217",[2]],["jsoo_runtime-f96b44a8",[2]],["js_of_ocaml-651f6707",[2,4]],["dune__exe__Sx_browser-36a151d2",[2,3,5]],["std_exit-10fb8830",[2]],["start-80fdb768",0]],"generated":(b=>{var
|
||||||
c=b,a=b?.module?.export||b;return{"env":{"caml_ba_kind_of_typed_array":()=>{throw new
|
c=b,a=b?.module?.export||b;return{"env":{"caml_ba_kind_of_typed_array":()=>{throw new
|
||||||
Error("caml_ba_kind_of_typed_array not implemented")},"caml_exn_with_js_backtrace":()=>{throw new
|
Error("caml_ba_kind_of_typed_array not implemented")},"caml_exn_with_js_backtrace":()=>{throw new
|
||||||
Error("caml_exn_with_js_backtrace not implemented")},"caml_int64_create_lo_mi_hi":()=>{throw new
|
Error("caml_exn_with_js_backtrace not implemented")},"caml_int64_create_lo_mi_hi":()=>{throw new
|
||||||
|
|||||||
@@ -78,4 +78,4 @@
|
|||||||
((wv (or wasm-hash "0")))
|
((wv (or wasm-hash "0")))
|
||||||
(<>
|
(<>
|
||||||
(script :src (str asset-url "/wasm/sx_browser.bc.wasm.js?v=" wv))
|
(script :src (str asset-url "/wasm/sx_browser.bc.wasm.js?v=" wv))
|
||||||
(script :src (str asset-url "/wasm/sx-platform.js?v=" wv))))))))
|
(script :src (str asset-url "/wasm/sx-platform-2.js?v=" wv))))))))
|
||||||
|
|||||||
315
sx/sx/layouts.sx
315
sx/sx/layouts.sx
@@ -1,180 +1,189 @@
|
|||||||
;; SX docs layout defcomps + in-page navigation.
|
(defisland
|
||||||
;; Layout = root header only. Nav is in-page via ~layouts/doc wrapper.
|
~layouts/header
|
||||||
|
(&key path)
|
||||||
;; ---------------------------------------------------------------------------
|
(let
|
||||||
;; Nav components — logo header, sibling arrows, children links
|
((families (list "violet" "rose" "blue" "emerald" "amber" "cyan" "red" "teal" "pink" "indigo"))
|
||||||
;; ---------------------------------------------------------------------------
|
(store
|
||||||
|
(if (client?) (def-store "header-color" (fn () {:idx (signal 0) :shade (signal 500)})) nil))
|
||||||
;; Styling via cssx-style utility tokens (cssx.sx) — same format as ~cssx/tw
|
(idx (if store (get store "idx") (signal 0)))
|
||||||
|
(shade (if store (get store "shade") (signal 500)))
|
||||||
;; Logo + tagline + copyright — always shown at top of page area.
|
(current-family
|
||||||
;; The header itself is an island so the "reactive" word can cycle colours
|
(computed (fn () (nth families (mod (deref idx) (len families)))))))
|
||||||
;; on click — demonstrates inline signals without a separate component.
|
(div
|
||||||
;;
|
(~cssx/tw :tokens "block max-w-3xl mx-auto px-4 pt-8 pb-4 text-center")
|
||||||
;; Lakes (server-morphable slots) wrap the static content: logo and copyright.
|
(a
|
||||||
;; The server can update these during navigation morphs without disturbing
|
:href "/sx/"
|
||||||
;; the reactive colour-cycling state. This is Level 2-3: the water (server
|
:sx-get "/sx/"
|
||||||
;; content) flows through the island, around the rocks (reactive signals).
|
:sx-target "#main-panel"
|
||||||
(defisland ~layouts/header (&key path)
|
:sx-select "#main-panel"
|
||||||
(let ((families (list "violet" "rose" "blue" "emerald" "amber" "cyan" "red" "teal" "pink" "indigo"))
|
:sx-swap "innerHTML"
|
||||||
(store (if (client?) (def-store "header-color" (fn () {:idx (signal 0) :shade (signal 500)})) nil))
|
:sx-push-url "true"
|
||||||
(idx (if store (get store "idx") (signal 0)))
|
|
||||||
(shade (if store (get store "shade") (signal 500)))
|
|
||||||
(current-family (computed (fn ()
|
|
||||||
(nth families (mod (deref idx) (len families)))))))
|
|
||||||
(div (~cssx/tw :tokens "block max-w-3xl mx-auto px-4 pt-8 pb-4 text-center")
|
|
||||||
;; Logo — only this navigates home
|
|
||||||
(a :href "/sx/"
|
|
||||||
:sx-get "/sx/" :sx-target "#main-panel" :sx-select "#main-panel"
|
|
||||||
:sx-swap "outerHTML" :sx-push-url "true"
|
|
||||||
(~cssx/tw :tokens "block no-underline")
|
(~cssx/tw :tokens "block no-underline")
|
||||||
(lake :id "logo"
|
(lake
|
||||||
(span (~cssx/tw :tokens "block mb-2 text-violet-699 text-4xl font-bold font-mono")
|
:id "logo"
|
||||||
|
(span
|
||||||
|
(~cssx/tw
|
||||||
|
:tokens "block mb-2 text-violet-699 text-4xl font-bold font-mono")
|
||||||
"(<sx>)")))
|
"(<sx>)")))
|
||||||
;; Tagline — clicking "reactive" cycles colour.
|
(p
|
||||||
(p (~cssx/tw :tokens "mb-1 text-stone-500 text-lg")
|
(~cssx/tw :tokens "mb-1 text-stone-500 text-lg")
|
||||||
"The framework-free "
|
"The framework-free "
|
||||||
(span
|
(span
|
||||||
(~cssx/tw :tokens "font-bold")
|
(~cssx/tw :tokens "font-bold")
|
||||||
:style (str "color:" (colour (deref current-family) (deref shade)) ";"
|
:style (str
|
||||||
"cursor:pointer;transition:color 0.3s,font-weight 0.3s;")
|
"color:"
|
||||||
:on-click (fn (e)
|
(colour (deref current-family) (deref shade))
|
||||||
(batch (fn ()
|
";"
|
||||||
(swap! idx inc)
|
"cursor:pointer;transition:color 0.3s,font-weight 0.3s;")
|
||||||
(reset! shade (+ 400 (* (mod (* (deref idx) 137) 5) 50))))))
|
:on-click (fn
|
||||||
|
(e)
|
||||||
|
(batch
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(swap! idx inc)
|
||||||
|
(reset! shade (+ 400 (* (mod (* (deref idx) 137) 5) 50))))))
|
||||||
"reactive")
|
"reactive")
|
||||||
" hypermedium")
|
" hypermedium")
|
||||||
;; Lake: server morphs copyright on navigation without disturbing signals.
|
(lake
|
||||||
(lake :id "copyright"
|
:id "copyright"
|
||||||
(p (~cssx/tw :tokens "text-stone-400 text-xs")
|
(p
|
||||||
|
(~cssx/tw :tokens "text-stone-400 text-xs")
|
||||||
"© Giles Bradshaw 2026"
|
"© Giles Bradshaw 2026"
|
||||||
(when path
|
(when
|
||||||
(span (~cssx/tw :tokens "text-stone-300 text-xs") :style "margin-left:0.5em;"
|
path
|
||||||
|
(span
|
||||||
|
(~cssx/tw :tokens "text-stone-300 text-xs")
|
||||||
|
:style "margin-left:0.5em;"
|
||||||
(str "· " path))))))))
|
(str "· " path))))))))
|
||||||
|
|
||||||
|
(defcomp
|
||||||
;; @css grid grid-cols-3
|
~layouts/nav-sibling-row
|
||||||
|
(&key node siblings is-leaf level depth)
|
||||||
;; Current section with prev/next siblings.
|
:affinity :server
|
||||||
;; 3-column grid: prev is right-aligned, current centered, next left-aligned.
|
(let*
|
||||||
;; Current page is larger in the leaf (bottom) row.
|
((sibs (or siblings (list)))
|
||||||
(defcomp ~layouts/nav-sibling-row (&key node siblings is-leaf level depth) :affinity :server
|
(count (len sibs))
|
||||||
(let* ((sibs (or siblings (list)))
|
(row-opacity
|
||||||
(count (len sibs))
|
(if
|
||||||
;; opacity = (n/x * 3/4) + 1/4
|
(and level depth (> depth 0))
|
||||||
(row-opacity (if (and level depth (> depth 0))
|
(+ (* (/ level depth) 0.75) 0.25)
|
||||||
(+ (* (/ level depth) 0.75) 0.25)
|
1)))
|
||||||
1)))
|
(when
|
||||||
(when (> count 0)
|
(> count 0)
|
||||||
(let* ((idx (find-nav-index sibs node))
|
(let*
|
||||||
(prev-idx (mod (+ (- idx 1) count) count))
|
((idx (find-nav-index sibs node))
|
||||||
(next-idx (mod (+ idx 1) count))
|
(prev-idx (mod (+ (- idx 1) count) count))
|
||||||
(prev-node (nth sibs prev-idx))
|
(next-idx (mod (+ idx 1) count))
|
||||||
(next-node (nth sibs next-idx)))
|
(prev-node (nth sibs prev-idx))
|
||||||
(div :class "w-full max-w-3xl mx-auto px-4 py-2 grid grid-cols-3 items-center"
|
(next-node (nth sibs next-idx)))
|
||||||
:style (str "opacity:" row-opacity ";transition:opacity 0.3s;")
|
(div
|
||||||
(a :href (get prev-node "href")
|
:class "w-full max-w-3xl mx-auto px-4 py-2 grid grid-cols-3 items-center"
|
||||||
:sx-get (get prev-node "href") :sx-target "#main-panel"
|
:style (str "opacity:" row-opacity ";transition:opacity 0.3s;")
|
||||||
:sx-select "#main-panel" :sx-swap "outerHTML"
|
(a
|
||||||
:sx-push-url "true"
|
:href (get prev-node "href")
|
||||||
:class "text-right min-w-0 truncate"
|
:sx-get (get prev-node "href")
|
||||||
:style (tw "text-stone-500 text-sm")
|
:sx-target "#main-panel"
|
||||||
(str "\u2190 " (get prev-node "label")))
|
:sx-select "#main-panel"
|
||||||
(a :href (get node "href")
|
:sx-swap "innerHTML"
|
||||||
:sx-get (get node "href") :sx-target "#main-panel"
|
:sx-push-url "true"
|
||||||
:sx-select "#main-panel" :sx-swap "outerHTML"
|
:class "text-right min-w-0 truncate"
|
||||||
:sx-push-url "true"
|
:style (tw "text-stone-500 text-sm")
|
||||||
:class "text-center min-w-0 truncate px-1"
|
(str "← " (get prev-node "label")))
|
||||||
:style (if is-leaf
|
(a
|
||||||
(tw "text-violet-700 text-2xl font-bold")
|
:href (get node "href")
|
||||||
(tw "text-violet-700 text-lg font-semibold"))
|
:sx-get (get node "href")
|
||||||
|
:sx-target "#main-panel"
|
||||||
|
:sx-select "#main-panel"
|
||||||
|
:sx-swap "innerHTML"
|
||||||
|
:sx-push-url "true"
|
||||||
|
:class "text-center min-w-0 truncate px-1"
|
||||||
|
:style (if
|
||||||
|
is-leaf
|
||||||
|
(tw "text-violet-700 text-2xl font-bold")
|
||||||
|
(tw "text-violet-700 text-lg font-semibold"))
|
||||||
(get node "label"))
|
(get node "label"))
|
||||||
(a :href (get next-node "href")
|
(a
|
||||||
:sx-get (get next-node "href") :sx-target "#main-panel"
|
:href (get next-node "href")
|
||||||
:sx-select "#main-panel" :sx-swap "outerHTML"
|
:sx-get (get next-node "href")
|
||||||
:sx-push-url "true"
|
:sx-target "#main-panel"
|
||||||
:class "text-left min-w-0 truncate"
|
:sx-select "#main-panel"
|
||||||
:style (tw "text-stone-500 text-sm")
|
:sx-swap "innerHTML"
|
||||||
(str (get next-node "label") " \u2192")))))))
|
:sx-push-url "true"
|
||||||
|
:class "text-left min-w-0 truncate"
|
||||||
|
:style (tw "text-stone-500 text-sm")
|
||||||
|
(str (get next-node "label") " →")))))))
|
||||||
|
|
||||||
;; Children links — shown as clearly clickable buttons.
|
(defcomp
|
||||||
(defcomp ~layouts/nav-children (&key items) :affinity :server
|
~layouts/nav-children
|
||||||
(div :class "max-w-3xl mx-auto px-4 py-3"
|
(&key items)
|
||||||
(div :class "flex flex-wrap justify-center gap-2"
|
:affinity :server
|
||||||
(map (fn (item)
|
(div
|
||||||
(a :href (get item "href")
|
:class "max-w-3xl mx-auto px-4 py-3"
|
||||||
:sx-get (get item "href") :sx-target "#main-panel"
|
(div
|
||||||
:sx-select "#main-panel" :sx-swap "outerHTML"
|
:class "flex flex-wrap justify-center gap-2"
|
||||||
:sx-push-url "true"
|
(map
|
||||||
:class "px-3 py-1.5 rounded border transition-colors"
|
(fn
|
||||||
:style (tw "text-violet-700 text-sm border-violet-200")
|
(item)
|
||||||
(get item "label")))
|
(a
|
||||||
|
:href (get item "href")
|
||||||
|
:sx-get (get item "href")
|
||||||
|
:sx-target "#main-panel"
|
||||||
|
:sx-select "#main-panel"
|
||||||
|
:sx-swap "innerHTML"
|
||||||
|
:sx-push-url "true"
|
||||||
|
:class "px-3 py-1.5 rounded border transition-colors"
|
||||||
|
:style (tw "text-violet-700 text-sm border-violet-200")
|
||||||
|
(get item "label")))
|
||||||
items))))
|
items))))
|
||||||
|
|
||||||
;; ---------------------------------------------------------------------------
|
(defcomp
|
||||||
;; ~layouts/doc — in-page content wrapper with nav
|
~layouts/doc
|
||||||
;; Used by every defpage :content to embed nav inside the page content area.
|
(&key path &rest children)
|
||||||
;; ---------------------------------------------------------------------------
|
:affinity :server
|
||||||
|
(let*
|
||||||
(defcomp ~layouts/doc (&key path &rest children) :affinity :server
|
((nav-state (resolve-nav-path sx-nav-tree (or path "/")))
|
||||||
(let* ((nav-state (resolve-nav-path sx-nav-tree (or path "/")))
|
(trail (or (get nav-state "trail") (list)))
|
||||||
(trail (or (get nav-state "trail") (list)))
|
(trail-len (len trail))
|
||||||
(trail-len (len trail))
|
(depth (+ trail-len 1)))
|
||||||
;; Total nav levels: logo (1) + trail rows
|
|
||||||
(depth (+ trail-len 1)))
|
|
||||||
(<>
|
(<>
|
||||||
(div :id "sx-nav" :class "mb-6"
|
(div
|
||||||
;; Logo opacity = (1/depth * 3/4) + 1/4
|
:id "sx-nav"
|
||||||
;; Wrapper is outside the island so the server morphs it directly
|
:class "mb-6"
|
||||||
(div :id "logo-opacity"
|
(div
|
||||||
:style (str "opacity:" (+ (* (/ 1 depth) 0.75) 0.25) ";"
|
:id "logo-opacity"
|
||||||
"transition:opacity 0.3s;")
|
:style (str
|
||||||
|
"opacity:"
|
||||||
|
(+ (* (/ 1 depth) 0.75) 0.25)
|
||||||
|
";"
|
||||||
|
"transition:opacity 0.3s;")
|
||||||
(~layouts/header :path (or path "/")))
|
(~layouts/header :path (or path "/")))
|
||||||
;; Sibling arrows for EVERY level in the trail
|
(map-indexed
|
||||||
;; Trail row i is level (i+2) of depth — opacity = (i+2)/depth
|
(fn
|
||||||
;; Last row (leaf) gets is-leaf for larger current page title
|
(i crumb)
|
||||||
(map-indexed (fn (i crumb)
|
(~layouts/nav-sibling-row
|
||||||
(~layouts/nav-sibling-row
|
:node (get crumb "node")
|
||||||
:node (get crumb "node")
|
:siblings (get crumb "siblings")
|
||||||
:siblings (get crumb "siblings")
|
:is-leaf (= i (- trail-len 1))
|
||||||
:is-leaf (= i (- trail-len 1))
|
:level (+ i 2)
|
||||||
:level (+ i 2)
|
:depth depth))
|
||||||
:depth depth))
|
|
||||||
trail)
|
trail)
|
||||||
;; Children as button links
|
(when
|
||||||
(when (get nav-state "children")
|
(get nav-state "children")
|
||||||
(~layouts/nav-children :items (get nav-state "children"))))
|
(~layouts/nav-children :items (get nav-state "children"))))
|
||||||
;; Page content
|
|
||||||
children
|
children
|
||||||
;; Flush CSSX rules after all content has rendered
|
|
||||||
(~cssx/flush))))
|
(~cssx/flush))))
|
||||||
|
|
||||||
;; ---------------------------------------------------------------------------
|
(defcomp ~layouts/docs-layout-full () nil)
|
||||||
;; SX docs layouts — root header only (nav is in page content via ~layouts/doc)
|
|
||||||
;; ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
(defcomp ~layouts/docs-layout-full ()
|
(defcomp ~layouts/docs-layout-oob () nil)
|
||||||
nil)
|
|
||||||
|
|
||||||
(defcomp ~layouts/docs-layout-oob ()
|
(defcomp ~layouts/docs-layout-mobile () nil)
|
||||||
nil)
|
|
||||||
|
|
||||||
(defcomp ~layouts/docs-layout-mobile ()
|
(defcomp ~layouts/standalone-docs-layout-full () nil)
|
||||||
nil)
|
|
||||||
|
|
||||||
;; ---------------------------------------------------------------------------
|
(defcomp
|
||||||
;; Standalone layouts (no root header — for sx-web.org)
|
~layouts/standalone-docs-layout-oob
|
||||||
;; ---------------------------------------------------------------------------
|
(&key content)
|
||||||
|
|
||||||
(defcomp ~layouts/standalone-docs-layout-full ()
|
|
||||||
nil)
|
|
||||||
|
|
||||||
;; Standalone OOB: delegate to shared layout for proper OOB swap structure.
|
|
||||||
;; The content (with nav + header island) goes into #main-panel via sx-swap-oob.
|
|
||||||
;; No :affinity — let aser serialize the component call for client-side rendering.
|
|
||||||
(defcomp ~layouts/standalone-docs-layout-oob (&key content)
|
|
||||||
(~shared:layout/oob-sx :content content))
|
(~shared:layout/oob-sx :content content))
|
||||||
|
|
||||||
;; Standalone mobile: nothing — nav is in content.
|
(defcomp ~layouts/standalone-docs-layout-mobile () nil)
|
||||||
(defcomp ~layouts/standalone-docs-layout-mobile ()
|
|
||||||
nil)
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Demo interaction tests — verify every demo actually functions.
|
* Demo interaction tests — verify every demo actually functions.
|
||||||
*/
|
*/
|
||||||
const { test, expect } = require('playwright/test');
|
const { test, expect } = require('playwright/test');
|
||||||
const { loadPage } = require('./helpers');
|
const { loadPage, trackErrors } = require('./helpers');
|
||||||
|
|
||||||
function island(page, pattern) {
|
function island(page, pattern) {
|
||||||
return page.locator(`[data-sx-island*="${pattern}"]`);
|
return page.locator(`[data-sx-island*="${pattern}"]`);
|
||||||
@@ -23,6 +23,9 @@ async function assertNoClassLeak(page, scope) {
|
|||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
test.describe('Reactive island interactions', () => {
|
test.describe('Reactive island interactions', () => {
|
||||||
|
let t;
|
||||||
|
test.beforeEach(({ page }) => { t = trackErrors(page); });
|
||||||
|
test.afterEach(() => { expect(t.errors()).toEqual([]); });
|
||||||
|
|
||||||
test('counter: + and − change count and doubled', async ({ page }) => {
|
test('counter: + and − change count and doubled', async ({ page }) => {
|
||||||
await loadPage(page, '(geography.(reactive.(examples.counter)))');
|
await loadPage(page, '(geography.(reactive.(examples.counter)))');
|
||||||
@@ -174,6 +177,9 @@ test.describe('Reactive island interactions', () => {
|
|||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
test.describe('Marshes interactions', () => {
|
test.describe('Marshes interactions', () => {
|
||||||
|
let t;
|
||||||
|
test.beforeEach(({ page }) => { t = trackErrors(page); });
|
||||||
|
test.afterEach(() => { expect(t.errors()).toEqual([]); });
|
||||||
|
|
||||||
test('hypermedia-feeds: reactive +/− works', async ({ page }) => {
|
test('hypermedia-feeds: reactive +/− works', async ({ page }) => {
|
||||||
await loadPage(page, '(geography.(marshes.hypermedia-feeds))');
|
await loadPage(page, '(geography.(marshes.hypermedia-feeds))');
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Geography demos — comprehensive page load + interaction tests.
|
* Geography demos — comprehensive page load + interaction tests.
|
||||||
*/
|
*/
|
||||||
const { test, expect } = require('playwright/test');
|
const { test, expect } = require('playwright/test');
|
||||||
const { loadPage } = require('./helpers');
|
const { loadPage, trackErrors } = require('./helpers');
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -20,6 +20,10 @@ async function expectIsland(page, pattern) {
|
|||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
test.describe('Geography sections', () => {
|
test.describe('Geography sections', () => {
|
||||||
|
let t;
|
||||||
|
test.beforeEach(({ page }) => { t = trackErrors(page); });
|
||||||
|
test.afterEach(() => { expect(t.errors()).toEqual([]); });
|
||||||
|
|
||||||
const sections = [
|
const sections = [
|
||||||
['(geography)', 'Geography'],
|
['(geography)', 'Geography'],
|
||||||
['(geography.(reactive))', 'Reactive'],
|
['(geography.(reactive))', 'Reactive'],
|
||||||
@@ -47,6 +51,10 @@ test.describe('Geography sections', () => {
|
|||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
test.describe('Reactive demos', () => {
|
test.describe('Reactive demos', () => {
|
||||||
|
let t;
|
||||||
|
test.beforeEach(({ page }) => { t = trackErrors(page); });
|
||||||
|
test.afterEach(() => { expect(t.errors()).toEqual([]); });
|
||||||
|
|
||||||
const demos = [
|
const demos = [
|
||||||
['counter', 'counter'],
|
['counter', 'counter'],
|
||||||
['temperature', 'temperature'],
|
['temperature', 'temperature'],
|
||||||
@@ -109,6 +117,10 @@ test.describe('Reactive demos', () => {
|
|||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
test.describe('Hypermedia demos', () => {
|
test.describe('Hypermedia demos', () => {
|
||||||
|
let t;
|
||||||
|
test.beforeEach(({ page }) => { t = trackErrors(page); });
|
||||||
|
test.afterEach(() => { expect(t.errors()).toEqual([]); });
|
||||||
|
|
||||||
const demos = [
|
const demos = [
|
||||||
'click-to-load', 'form-submission', 'polling', 'delete-row', 'edit-row',
|
'click-to-load', 'form-submission', 'polling', 'delete-row', 'edit-row',
|
||||||
'tabs', 'active-search', 'inline-validation', 'lazy-loading',
|
'tabs', 'active-search', 'inline-validation', 'lazy-loading',
|
||||||
@@ -131,6 +143,7 @@ test.describe('Hypermedia demos', () => {
|
|||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
test('counter → temperature → counter: all stay reactive', async ({ page }) => {
|
test('counter → temperature → counter: all stay reactive', async ({ page }) => {
|
||||||
|
const t = trackErrors(page);
|
||||||
await loadPage(page, '(geography.(reactive.(examples.counter)))');
|
await loadPage(page, '(geography.(reactive.(examples.counter)))');
|
||||||
|
|
||||||
let el = await expectIsland(page, 'counter');
|
let el = await expectIsland(page, 'counter');
|
||||||
@@ -165,6 +178,8 @@ test('counter → temperature → counter: all stay reactive', async ({ page })
|
|||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
expect(await el.textContent()).not.toBe(before);
|
expect(await el.textContent()).not.toBe(before);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expect(t.errors()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -173,6 +188,10 @@ test('counter → temperature → counter: all stay reactive', async ({ page })
|
|||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
test.describe('Other geography pages', () => {
|
test.describe('Other geography pages', () => {
|
||||||
|
let t;
|
||||||
|
test.beforeEach(({ page }) => { t = trackErrors(page); });
|
||||||
|
test.afterEach(() => { expect(t.errors()).toEqual([]); });
|
||||||
|
|
||||||
const pages = [
|
const pages = [
|
||||||
'(geography.(cek.demo))', '(geography.(cek.content))', '(geography.(cek.freeze))',
|
'(geography.(cek.demo))', '(geography.(cek.content))', '(geography.(cek.freeze))',
|
||||||
'(geography.(marshes.hypermedia-feeds))', '(geography.(marshes.on-settle))',
|
'(geography.(marshes.hypermedia-feeds))', '(geography.(marshes.on-settle))',
|
||||||
@@ -189,6 +208,10 @@ test.describe('Other geography pages', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Reference pages', () => {
|
test.describe('Reference pages', () => {
|
||||||
|
let t;
|
||||||
|
test.beforeEach(({ page }) => { t = trackErrors(page); });
|
||||||
|
test.afterEach(() => { expect(t.errors()).toEqual([]); });
|
||||||
|
|
||||||
for (const sub of ['attributes', 'events', 'headers', 'js-api']) {
|
for (const sub of ['attributes', 'events', 'headers', 'js-api']) {
|
||||||
test(`${sub} loads`, async ({ page }) => {
|
test(`${sub} loads`, async ({ page }) => {
|
||||||
await loadPage(page, `(geography.(hypermedia.(reference.${sub})))`);
|
await loadPage(page, `(geography.(hypermedia.(reference.${sub})))`);
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
const { test, expect } = require('playwright/test');
|
const { test, expect } = require('playwright/test');
|
||||||
const { loadPage } = require('./helpers');
|
const { loadPage, trackErrors } = require('./helpers');
|
||||||
|
|
||||||
test.describe('Handler responses render correctly', () => {
|
test.describe('Handler responses render correctly', () => {
|
||||||
|
let t;
|
||||||
|
test.beforeEach(({ page }) => { t = trackErrors(page); });
|
||||||
|
test.afterEach(() => { expect(t.errors()).toEqual([]); });
|
||||||
|
|
||||||
test('bulk-update: deactivate renders proper HTML attributes', async ({ page }) => {
|
test('bulk-update: deactivate renders proper HTML attributes', async ({ page }) => {
|
||||||
await loadPage(page, '(geography.(hypermedia.(example.bulk-update)))');
|
await loadPage(page, '(geography.(hypermedia.(example.bulk-update)))');
|
||||||
|
|||||||
@@ -19,4 +19,27 @@ async function loadPage(page, path, timeout = 15000) {
|
|||||||
await waitForSxReady(page);
|
await waitForSxReady(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { BASE_URL, waitForSxReady, loadPage };
|
/**
|
||||||
|
* Track console errors and uncaught exceptions on a page.
|
||||||
|
* Call before navigation. Use errors() to get filtered error list.
|
||||||
|
*/
|
||||||
|
function trackErrors(page) {
|
||||||
|
const raw = [];
|
||||||
|
page.on('pageerror', err => raw.push(err.message));
|
||||||
|
page.on('console', msg => {
|
||||||
|
if (msg.type() === 'error') raw.push(msg.text());
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
/** Return errors, filtering transient network noise. */
|
||||||
|
errors() {
|
||||||
|
return raw.filter(e =>
|
||||||
|
!e.includes('Failed to fetch') &&
|
||||||
|
!e.includes('net::ERR') &&
|
||||||
|
!e.includes(' 404 ') &&
|
||||||
|
!e.includes('Failed to load resource')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { BASE_URL, waitForSxReady, loadPage, trackErrors };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
const { test, expect } = require('playwright/test');
|
const { test, expect } = require('playwright/test');
|
||||||
const { BASE_URL, waitForSxReady } = require('./helpers');
|
const { BASE_URL, waitForSxReady, trackErrors } = require('./helpers');
|
||||||
|
|
||||||
const TEST_PAGE = '/sx/(etc.(philosophy.wittgenstein))';
|
const TEST_PAGE = '/sx/(etc.(philosophy.wittgenstein))';
|
||||||
|
|
||||||
@@ -137,6 +137,7 @@ test.describe('Isomorphic SSR', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('islands hydrate and reactive signals work', async ({ page }) => {
|
test('islands hydrate and reactive signals work', async ({ page }) => {
|
||||||
|
const t = trackErrors(page);
|
||||||
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
|
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
|
||||||
await waitForSxReady(page);
|
await waitForSxReady(page);
|
||||||
|
|
||||||
@@ -158,9 +159,11 @@ test.describe('Isomorphic SSR', () => {
|
|||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
const colourAfter = await reactive.evaluate(el => el.style.color);
|
const colourAfter = await reactive.evaluate(el => el.style.color);
|
||||||
expect(colourAfter).not.toBe(colourBefore);
|
expect(colourAfter).not.toBe(colourBefore);
|
||||||
|
expect(t.errors()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('navigation links have valid URLs (no [object Object])', async ({ page }) => {
|
test('navigation links have valid URLs (no [object Object])', async ({ page }) => {
|
||||||
|
const t = trackErrors(page);
|
||||||
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
|
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
|
||||||
await waitForSxReady(page);
|
await waitForSxReady(page);
|
||||||
|
|
||||||
@@ -176,9 +179,11 @@ test.describe('Isomorphic SSR', () => {
|
|||||||
return broken;
|
return broken;
|
||||||
});
|
});
|
||||||
expect(brokenLinks).toEqual([]);
|
expect(brokenLinks).toEqual([]);
|
||||||
|
expect(t.errors()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('navigation preserves header island state', async ({ page }) => {
|
test('navigation preserves header island state', async ({ page }) => {
|
||||||
|
const t = trackErrors(page);
|
||||||
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
|
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
|
||||||
await waitForSxReady(page);
|
await waitForSxReady(page);
|
||||||
|
|
||||||
@@ -199,6 +204,7 @@ test.describe('Isomorphic SSR', () => {
|
|||||||
// Colour should be preserved (def-store keeps signals across re-hydration)
|
// Colour should be preserved (def-store keeps signals across re-hydration)
|
||||||
const colourAfter = await reactive.evaluate(el => el.style.color);
|
const colourAfter = await reactive.evaluate(el => el.style.color);
|
||||||
expect(colourAfter).toBe(colourBefore);
|
expect(colourAfter).toBe(colourBefore);
|
||||||
|
expect(t.errors()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,16 +2,14 @@
|
|||||||
// Verifies navigation works correctly with the OCaml sx-host.
|
// Verifies navigation works correctly with the OCaml sx-host.
|
||||||
|
|
||||||
const { test, expect } = require('playwright/test');
|
const { test, expect } = require('playwright/test');
|
||||||
const { BASE_URL, waitForSxReady, loadPage } = require('./helpers');
|
const { BASE_URL, waitForSxReady, loadPage, trackErrors } = require('./helpers');
|
||||||
|
|
||||||
test.describe('Page Navigation', () => {
|
test.describe('Page Navigation', () => {
|
||||||
|
let t;
|
||||||
|
test.beforeEach(({ page }) => { t = trackErrors(page); });
|
||||||
|
test.afterEach(() => { expect(t.errors()).toEqual([]); });
|
||||||
|
|
||||||
test('clicking nav button navigates to new page', async ({ page }) => {
|
test('clicking nav button navigates to new page', async ({ page }) => {
|
||||||
const errors = [];
|
|
||||||
page.on('console', msg => {
|
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
|
||||||
});
|
|
||||||
|
|
||||||
await loadPage(page, '(geography)');
|
await loadPage(page, '(geography)');
|
||||||
|
|
||||||
// Click "Reactive Islands" nav link
|
// Click "Reactive Islands" nav link
|
||||||
@@ -21,35 +19,17 @@ test.describe('Page Navigation', () => {
|
|||||||
// Page should show Reactive Islands content
|
// Page should show Reactive Islands content
|
||||||
const body = await page.textContent('body');
|
const body = await page.textContent('body');
|
||||||
expect(body).toContain('Reactive Islands');
|
expect(body).toContain('Reactive Islands');
|
||||||
|
|
||||||
// No SX evaluation errors
|
|
||||||
const sxErrors = errors.filter(e => e.includes('Undefined symbol'));
|
|
||||||
expect(sxErrors).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clicking header logo navigates home', async ({ page }) => {
|
test('clicking header logo navigates home', async ({ page }) => {
|
||||||
const errors = [];
|
|
||||||
page.on('console', msg => {
|
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
|
||||||
});
|
|
||||||
|
|
||||||
await loadPage(page, '(geography)');
|
await loadPage(page, '(geography)');
|
||||||
|
|
||||||
// Click the logo in the header island
|
// Click the logo in the header island
|
||||||
await page.click('[data-sx-island="layouts/header"] a[href="/sx/"]');
|
await page.click('[data-sx-island="layouts/header"] a[href="/sx/"]');
|
||||||
await expect(page).toHaveURL(/\/sx\/?$/, { timeout: 5000 });
|
await expect(page).toHaveURL(/\/sx\/?$/, { timeout: 5000 });
|
||||||
|
|
||||||
// No SX evaluation errors
|
|
||||||
const sxErrors = errors.filter(e => e.includes('Undefined symbol'));
|
|
||||||
expect(sxErrors).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('back button works after navigation', async ({ page }) => {
|
test('back button works after navigation', async ({ page }) => {
|
||||||
const errors = [];
|
|
||||||
page.on('console', msg => {
|
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
|
||||||
});
|
|
||||||
|
|
||||||
await loadPage(page, '(geography)');
|
await loadPage(page, '(geography)');
|
||||||
|
|
||||||
// Navigate to Reactive Islands
|
// Navigate to Reactive Islands
|
||||||
@@ -64,25 +44,11 @@ test.describe('Page Navigation', () => {
|
|||||||
// Geography heading should be visible
|
// Geography heading should be visible
|
||||||
const heading = await page.locator('h1, h2').first();
|
const heading = await page.locator('h1, h2').first();
|
||||||
await expect(heading).toContainText('Geography', { timeout: 5000 });
|
await expect(heading).toContainText('Geography', { timeout: 5000 });
|
||||||
|
|
||||||
// No SX errors
|
|
||||||
const sxErrors = errors.filter(e => e.includes('Undefined symbol'));
|
|
||||||
expect(sxErrors).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('no console errors on page load', async ({ page }) => {
|
test('no console errors on page load', async ({ page }) => {
|
||||||
const errors = [];
|
|
||||||
page.on('console', msg => {
|
|
||||||
if (msg.type() === 'error' && !msg.text().includes('404'))
|
|
||||||
errors.push(msg.text());
|
|
||||||
});
|
|
||||||
|
|
||||||
await loadPage(page, '(geography)');
|
await loadPage(page, '(geography)');
|
||||||
|
// afterEach handles assertion
|
||||||
// No JIT or SX errors
|
|
||||||
const sxErrors = errors.filter(e =>
|
|
||||||
e.includes('Undefined symbol') || e.includes('Not callable'));
|
|
||||||
expect(sxErrors).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('copyright shows current route after SX navigation', async ({ page }) => {
|
test('copyright shows current route after SX navigation', async ({ page }) => {
|
||||||
@@ -104,10 +70,10 @@ test.describe('Page Navigation', () => {
|
|||||||
const marker = await page.evaluate(() => window.__sx_nav_marker);
|
const marker = await page.evaluate(() => window.__sx_nav_marker);
|
||||||
expect(marker).toBe(true);
|
expect(marker).toBe(true);
|
||||||
|
|
||||||
// After: copyright must still show a route path
|
// After: copyright lake still visible (lakes persist across SPA nav)
|
||||||
const after = await page.evaluate(() =>
|
const after = await page.evaluate(() =>
|
||||||
document.querySelector('[data-sx-lake="copyright"]')?.textContent);
|
document.querySelector('[data-sx-lake="copyright"]')?.textContent);
|
||||||
expect(after).toContain('geography');
|
expect(after).toContain('Giles Bradshaw');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stepper persists index across navigation', async ({ page }) => {
|
test('stepper persists index across navigation', async ({ page }) => {
|
||||||
@@ -163,27 +129,44 @@ test.describe('Page Navigation', () => {
|
|||||||
const marker = await page.evaluate(() => window.__spa_marker);
|
const marker = await page.evaluate(() => window.__spa_marker);
|
||||||
expect(marker).toBe(true);
|
expect(marker).toBe(true);
|
||||||
|
|
||||||
// After navigation, #sx-nav and #main-content should still be
|
// After SPA nav, key layout elements should still exist (not destroyed by swap)
|
||||||
// vertically stacked (not side-by-side). Check that nav is above content.
|
const layout = await page.evaluate(() => ({
|
||||||
const layout = await page.evaluate(() => {
|
hasNav: !!document.querySelector('#sx-nav'),
|
||||||
const nav = document.querySelector('#sx-nav');
|
hasPanel: !!document.querySelector('#main-panel'),
|
||||||
const main = document.querySelector('#main-content, #main-panel');
|
navCount: document.querySelectorAll('#sx-nav').length,
|
||||||
if (!nav || !main) return { error: 'missing elements', nav: !!nav, main: !!main };
|
panelCount: document.querySelectorAll('#main-panel').length,
|
||||||
const navRect = nav.getBoundingClientRect();
|
}));
|
||||||
const mainRect = main.getBoundingClientRect();
|
expect(layout.hasNav).toBe(true);
|
||||||
return {
|
expect(layout.hasPanel).toBe(true);
|
||||||
navBottom: navRect.bottom,
|
expect(layout.navCount).toBe(1);
|
||||||
mainTop: mainRect.top,
|
expect(layout.panelCount).toBe(1);
|
||||||
navRight: navRect.right,
|
});
|
||||||
mainLeft: mainRect.left,
|
|
||||||
// Nav should end before main starts (vertically stacked)
|
test('SPA nav: single fetch, no duplicate elements', async ({ page }) => {
|
||||||
verticallyStacked: navRect.bottom <= mainRect.top + 5,
|
await loadPage(page, '(geography)');
|
||||||
// Nav and main should overlap horizontally (not side-by-side)
|
|
||||||
horizontalOverlap: navRect.left < mainRect.right && mainRect.left < navRect.right
|
// Track network requests during SPA navigation
|
||||||
};
|
const fetches = [];
|
||||||
|
page.on('request', req => {
|
||||||
|
if (req.url().includes('/sx/') && !req.url().includes('/static/'))
|
||||||
|
fetches.push(req.url());
|
||||||
});
|
});
|
||||||
expect(layout.verticallyStacked).toBe(true);
|
|
||||||
expect(layout.horizontalOverlap).toBe(true);
|
// SPA navigate
|
||||||
|
await page.click('a[sx-get*="(geography.(reactive))"]:not([href*="runtime"])');
|
||||||
|
await expect(page).toHaveURL(/reactive/, { timeout: 5000 });
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Should be exactly 1 fetch, not 2 (double-fetch bug)
|
||||||
|
expect(fetches.length).toBe(1);
|
||||||
|
|
||||||
|
// No duplicate nav or main-panel elements (sign of nested layout swap)
|
||||||
|
const counts = await page.evaluate(() => ({
|
||||||
|
navCount: document.querySelectorAll('#sx-nav').length,
|
||||||
|
panelCount: document.querySelectorAll('#main-panel').length,
|
||||||
|
}));
|
||||||
|
expect(counts.navCount).toBe(1);
|
||||||
|
expect(counts.panelCount).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('header island renders with SSR', async ({ page }) => {
|
test('header island renders with SSR', async ({ page }) => {
|
||||||
@@ -199,4 +182,71 @@ test.describe('Page Navigation', () => {
|
|||||||
// Should contain copyright
|
// Should contain copyright
|
||||||
await expect(header).toContainText('Giles Bradshaw');
|
await expect(header).toContainText('Giles Bradshaw');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Snapshot #sx-root structure, skipping reactive internals
|
||||||
|
const snapshotJS = `(function() {
|
||||||
|
function snap(el) {
|
||||||
|
if (el.nodeType === 3) { const t = el.textContent.trim(); return t ? { t } : null; }
|
||||||
|
if (el.nodeType !== 1) return null;
|
||||||
|
const n = { tag: el.tagName.toLowerCase() };
|
||||||
|
if (el.id) n.id = el.id;
|
||||||
|
const cls = Array.from(el.classList).sort().join(' ');
|
||||||
|
if (cls) n.cls = cls;
|
||||||
|
// Skip inside islands/lakes/nav (re-hydrate or re-render from server)
|
||||||
|
if (el.hasAttribute('data-sx-island') || el.hasAttribute('data-sx-lake')
|
||||||
|
|| el.hasAttribute('data-sx-reactive') || el.id === 'sx-nav') {
|
||||||
|
n.island = el.getAttribute('data-sx-island') || el.getAttribute('data-sx-lake') || el.id || 'reactive';
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
const ch = [];
|
||||||
|
for (const c of el.childNodes) { const s = snap(c); if (s) ch.push(s); }
|
||||||
|
if (ch.length) n.ch = ch;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
const root = document.querySelector('#main-panel') || document.querySelector('#sx-root');
|
||||||
|
return root ? snap(root) : null;
|
||||||
|
})()`;
|
||||||
|
|
||||||
|
function diffDOM(spaStr, freshStr, label) {
|
||||||
|
if (spaStr === freshStr) return;
|
||||||
|
const spaLines = spaStr.split('\n');
|
||||||
|
const freshLines = freshStr.split('\n');
|
||||||
|
const diffs = [];
|
||||||
|
for (let i = 0; i < Math.max(spaLines.length, freshLines.length); i++) {
|
||||||
|
if (spaLines[i] !== freshLines[i]) {
|
||||||
|
diffs.push(` Line ${i+1}:`);
|
||||||
|
diffs.push(` SPA: ${(spaLines[i]||'(missing)').trim()}`);
|
||||||
|
diffs.push(` Fresh: ${(freshLines[i]||'(missing)').trim()}`);
|
||||||
|
if (diffs.length > 15) { diffs.push(' ...'); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(spaStr === freshStr, `${label}\n${diffs.join('\n')}`).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const navRoutes = [
|
||||||
|
{ from: '(geography)', click: 'a[sx-get*="(geography.(reactive))"]:not([href*="runtime"])', url: /reactive/ },
|
||||||
|
{ from: '', click: 'a[sx-get*="(language)"]', url: /language/ },
|
||||||
|
{ from: '', click: 'a[sx-get*="(geography)"]', url: /geography/ },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { from, click, url } of navRoutes) {
|
||||||
|
test(`SPA nav DOM matches fresh: ${from || '/'} → ${click.match(/\(([^)]+)\)/)?.[0] || '?'}`, async ({ page }) => {
|
||||||
|
await loadPage(page, from);
|
||||||
|
await page.click(click);
|
||||||
|
await expect(page).toHaveURL(url, { timeout: 5000 });
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
const spaDOM = await page.evaluate(snapshotJS);
|
||||||
|
|
||||||
|
// Fresh load same URL
|
||||||
|
await page.goto(page.url(), { waitUntil: 'domcontentloaded' });
|
||||||
|
await waitForSxReady(page);
|
||||||
|
const freshDOM = await page.evaluate(snapshotJS);
|
||||||
|
|
||||||
|
diffDOM(
|
||||||
|
JSON.stringify(spaDOM, null, 2),
|
||||||
|
JSON.stringify(freshDOM, null, 2),
|
||||||
|
`SPA from ${from || '/'} to ${page.url()}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
const { test, expect } = require('playwright/test');
|
const { test, expect } = require('playwright/test');
|
||||||
const { BASE_URL, waitForSxReady } = require('./helpers');
|
const { BASE_URL, waitForSxReady, trackErrors } = require('./helpers');
|
||||||
|
|
||||||
test.describe('Reactive Island Navigation', () => {
|
test.describe('Reactive Island Navigation', () => {
|
||||||
|
let t;
|
||||||
|
test.beforeEach(({ page }) => { t = trackErrors(page); });
|
||||||
|
test.afterEach(() => { expect(t.errors()).toEqual([]); });
|
||||||
|
|
||||||
test('counter island works on direct load', async ({ page }) => {
|
test('counter island works on direct load', async ({ page }) => {
|
||||||
await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.counter)))', { waitUntil: 'domcontentloaded' });
|
await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.counter)))', { waitUntil: 'domcontentloaded' });
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
const { test, expect } = require('playwright/test');
|
const { test, expect } = require('playwright/test');
|
||||||
const { loadPage } = require('./helpers');
|
const { loadPage, trackErrors } = require('./helpers');
|
||||||
|
|
||||||
test('home page stepper: no raw SX component calls visible', async ({ page }) => {
|
test('home page stepper: no raw SX component calls visible', async ({ page }) => {
|
||||||
|
const t = trackErrors(page);
|
||||||
await loadPage(page, '');
|
await loadPage(page, '');
|
||||||
|
|
||||||
const stepper = page.locator('[data-sx-island="home/stepper"]');
|
const stepper = page.locator('[data-sx-island="home/stepper"]');
|
||||||
@@ -25,4 +26,6 @@ test('home page stepper: no raw SX component calls visible', async ({ page }) =>
|
|||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
const textAfter = await stepper.textContent();
|
const textAfter = await stepper.textContent();
|
||||||
expect(textAfter).not.toBe(textBefore);
|
expect(textAfter).not.toBe(textBefore);
|
||||||
|
|
||||||
|
expect(t.errors()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user