htmx demos working: activation, fetch, swap, OOB filtering, test runner page

- htmx-boot-subtree! wired into process-elements for auto-activation
- Fixed cond compilation bug in hx-verb-info (Clojure-style flat cond)
- Platform io-fetch upgraded: method/body/headers support, full response dict
- Replaced perform IO ops with browser primitives (set-timeout, browser-confirm, etc)
- SX→HTML rendering in hx-do-swap with OOB section filtering
- hx-collect-params: collects input name/value for all methods
- Handler naming: ex-{slug} convention, removed perform IO dependencies
- Test runner page at (test.(applications.(htmx))) with iframe-based runner
- Header "test" link on every page linking to test URL
- Page file restructure: 285 files moved to URL-matching paths (a/b/c/index.sx)
- page-functions.sx: ~100 component name references updated
- _test added to skip_dirs, test- file prefix convention for test files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 11:56:15 +00:00
parent 4f02f82f4e
commit 4aa49e42e8
16 changed files with 3201 additions and 1562 deletions

View File

@@ -1,257 +1,223 @@
// sx-test-runner.js — Run SX test specs in the browser using sx-browser.js.
// Supports both legacy (monolithic test.sx) and modular (per-spec) modes.
/**
* SX Test Runner — drives tests in an iframe, reports results.
* Updates [data-test] elements in-place with pass/fail icons.
* Expects: #run-btn, #test-status, #test-summary, #test-iframe, [data-test]
*/
(function() {
var NIL = Sx.NIL;
function isNil(x) { return x === NIL || x === null || x === undefined; }
function deepEqual(a, b) {
if (a === b) return true;
if (isNil(a) && isNil(b)) return true;
if (typeof a !== typeof b) return false;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
for (var i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false;
return true;
"use strict";
var TESTS = [
{ name: "click-to-load", actions: [
{ type: "click", selector: "button[hx-get]" },
{ type: "wait", ms: 2000 },
{ type: "assert-text", selector: "#click-result", contains: "Content loaded!" }
]},
{ name: "click-no-oob-leak", actions: [
{ type: "click", selector: "button[hx-get]" },
{ type: "wait", ms: 2000 },
{ type: "assert-text", selector: "#click-result", notContains: "defcomp" }
]},
{ name: "search-debounce", actions: [
{ type: "fill", selector: "input[hx-get]", value: "hx-get" },
{ type: "wait", ms: 1500 },
{ type: "assert-text", selector: "#search-results", contains: "GET request" }
]},
{ name: "search-no-results", actions: [
{ type: "fill", selector: "input[hx-get]", value: "xyznonexistent" },
{ type: "wait", ms: 1500 },
{ type: "assert-text", selector: "#search-results", contains: "No results" }
]},
{ name: "tab-overview", actions: [
{ type: "click", selector: "button[hx-get*='tab=overview']" },
{ type: "wait", ms: 2000 },
{ type: "assert-text", selector: "#htmx-tab-content", contains: "htmx gives you access" }
]},
{ name: "tab-features", actions: [
{ type: "click", selector: "button[hx-get*='tab=features']" },
{ type: "wait", ms: 2000 },
{ type: "assert-text", selector: "#htmx-tab-content", contains: "Any element" }
]},
{ name: "append-item", actions: [
{ type: "click", selector: "button[hx-post*='api.append']" },
{ type: "wait", ms: 2000 },
{ type: "assert-count", selector: "#item-list > *", gte: 1 }
]},
{ name: "form-submit", actions: [
{ type: "fill", selector: "form[hx-post] input[name='name']", value: "Alice" },
{ type: "fill", selector: "form[hx-post] input[name='email']", value: "alice@test.com" },
{ type: "click", selector: "form[hx-post] button[type='submit']" },
{ type: "wait", ms: 2000 },
{ type: "assert-text", selector: "#form-result", contains: "Alice" }
]}
];
if (window.__sxTests) TESTS = window.__sxTests;
var runBtn, statusEl, summaryEl, iframe;
function init() {
runBtn = document.getElementById("run-btn");
statusEl = document.getElementById("test-status");
summaryEl = document.getElementById("test-summary");
iframe = document.getElementById("test-iframe");
if (runBtn) {
console.log("[sx-test] Runner initialized, " + TESTS.length + " tests");
runBtn.addEventListener("click", function() {
console.log("[sx-test] Run clicked");
runAll();
});
} else {
console.warn("[sx-test] #run-btn not found");
}
if (a && typeof a === "object" && b && typeof b === "object") {
var ka = Object.keys(a), kb = Object.keys(b);
if (ka.length !== kb.length) return false;
for (var j = 0; j < ka.length; j++) if (!deepEqual(a[ka[j]], b[ka[j]])) return false;
return true;
}
return false;
}
// --- Platform functions shared across all specs ---
function makeEnv() {
var stack = [], passed = 0, failed = 0, num = 0, lines = [];
var env = {
"try-call": function(thunk) {
try {
Sx.eval([thunk], env);
return { ok: true };
} catch(e) {
return { ok: false, error: e.message || String(e) };
function sleep(ms) { return new Promise(function(r) { setTimeout(r, ms); }); }
function waitForReady(doc, timeout) {
return new Promise(function(resolve, reject) {
var start = Date.now();
(function check() {
try { if (doc.documentElement.getAttribute("data-sx-ready") === "true") return resolve(); } catch(e) {}
if (Date.now() - start > timeout) return reject(new Error("Timeout"));
setTimeout(check, 200);
})();
});
}
function reloadIframe() {
return new Promise(function(resolve) {
iframe.addEventListener("load", function onLoad() {
iframe.removeEventListener("load", onLoad);
resolve();
});
iframe.contentWindow.location.reload();
});
}
function updateTestItem(name, state, error) {
var item = document.querySelector('[data-test="' + name + '"]');
if (!item) return;
var icon = item.querySelector('[data-role="test-icon"]');
if (!icon) return;
if (state === "running") {
icon.textContent = "⟳";
icon.style.color = "#7c3aed";
item.style.borderColor = "#c4b5fd";
} else if (state === "pass") {
icon.textContent = "✓";
icon.style.color = "#16a34a";
item.style.borderColor = "#bbf7d0";
item.style.backgroundColor = "#f0fdf4";
} else if (state === "fail") {
icon.textContent = "✗";
icon.style.color = "#dc2626";
item.style.borderColor = "#fecaca";
item.style.backgroundColor = "#fef2f2";
if (error) {
var errEl = item.querySelector('[data-role="test-error"]');
if (!errEl) {
errEl = document.createElement("div");
errEl.setAttribute("data-role", "test-error");
errEl.style.cssText = "padding:8px 16px;font-size:12px;color:#dc2626;border-top:1px solid #fecaca;background:#fef2f2";
item.querySelector("summary").after(errEl);
}
},
"report-pass": function(name) {
num++; passed++;
lines.push("ok " + num + " - " + stack.concat([name]).join(" > "));
},
"report-fail": function(name, error) {
num++; failed++;
lines.push("not ok " + num + " - " + stack.concat([name]).join(" > "));
lines.push(" # " + error);
},
"push-suite": function(name) { stack.push(name); },
"pop-suite": function() { stack.pop(); },
// Primitives that sx-browser.js may not expose in env
"equal?": function(a, b) { return deepEqual(a, b); },
"eq?": function(a, b) { return a === b; },
"boolean?": function(x) { return typeof x === "boolean"; },
"string-length": function(s) { return String(s).length; },
"substring": function(s, start, end) { return String(s).slice(start, end); },
"string-contains?": function(s, n) { return String(s).indexOf(n) !== -1; },
"upcase": function(s) { return String(s).toUpperCase(); },
"downcase": function(s) { return String(s).toLowerCase(); },
"reverse": function(c) { return c ? c.slice().reverse() : []; },
"flatten": function(c) {
var r = [];
for (var i = 0; i < (c||[]).length; i++) {
if (Array.isArray(c[i])) for (var j = 0; j < c[i].length; j++) r.push(c[i][j]);
else r.push(c[i]);
}
return r;
},
"has-key?": function(d, k) { return d && typeof d === "object" && k in d; },
"append": function(c, x) { return Array.isArray(x) ? (c||[]).concat(x) : (c||[]).concat([x]); },
"for-each-indexed": function(f, coll) {
for (var i = 0; i < (coll||[]).length; i++) Sx.eval([f, i, coll[i]], env);
},
"for-each": function(f, coll) {
for (var i = 0; i < (coll||[]).length; i++) Sx.eval([f, coll[i]], env);
},
"dict-set!": function(d, k, v) { if (d) d[k] = v; },
"dict-has?": function(d, k) { return d && typeof d === "object" && k in d; },
"dict-get": function(d, k) { return d ? d[k] : undefined; },
"starts-with?": function(s, prefix) { return String(s).indexOf(prefix) === 0; },
"ends-with?": function(s, suffix) { var str = String(s); return str.indexOf(suffix) === str.length - suffix.length; },
"slice": function(s, start, end) { return end !== undefined ? s.slice(start, end) : s.slice(start); },
"inc": function(n) { return n + 1; },
"append!": function(arr, item) { if (Array.isArray(arr)) arr.push(item); },
"dict": function() { return {}; },
// --- Parser platform functions ---
"sx-parse": function(source) { return Sx.parseAll(source); },
"sx-serialize": function(val) {
if (val === NIL || val === null || val === undefined) return "nil";
if (typeof val === "boolean") return val ? "true" : "false";
if (typeof val === "number") return String(val);
if (typeof val === "string") return '"' + val.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
if (val && (val._sym || val._sx_symbol)) return val.name;
if (val && (val._kw || val._sx_keyword)) return ":" + val.name;
if (Array.isArray(val)) return "(" + val.map(function(x) { return env["sx-serialize"](x); }).join(" ") + ")";
if (val && typeof val === "object") {
var parts = [];
Object.keys(val).forEach(function(k) { parts.push(":" + k); parts.push(env["sx-serialize"](val[k])); });
return "{" + parts.join(" ") + "}";
}
return String(val);
},
"make-symbol": function(name) { return Sx.sym ? Sx.sym(name) : { _sx_symbol: true, name: name, toString: function() { return name; } }; },
"make-keyword": function(name) { return Sx.kw ? Sx.kw(name) : { _sx_keyword: true, name: name, toString: function() { return name; } }; },
"symbol-name": function(s) { return s && s.name ? s.name : String(s); },
"keyword-name": function(k) { return k && k.name ? k.name : String(k); },
// --- Render platform function ---
"render-html": function(sxSource) {
if (!Sx.renderToHtml) throw new Error("render-to-html not available");
var exprs = Sx.parseAll(sxSource);
var result = "";
for (var i = 0; i < exprs.length; i++) result += Sx.renderToHtml(exprs[i], env);
return result;
},
};
return { env: env, getResults: function() { return { passed: passed, failed: failed, num: num, lines: lines }; } };
}
function evalSource(src, env) {
var exprs = Sx.parseAll(src);
for (var i = 0; i < exprs.length; i++) Sx.eval(exprs[i], env);
}
function loadRouterFromBootstrap(env) {
if (Sx.splitPathSegments) {
env["split-path-segments"] = Sx.splitPathSegments;
env["parse-route-pattern"] = Sx.parseRoutePattern;
env["match-route-segments"] = Sx.matchRouteSegments;
env["match-route"] = Sx.matchRoute;
env["find-matching-route"] = Sx.findMatchingRoute;
env["make-route-segment"] = Sx.makeRouteSegment;
errEl.textContent = error;
}
}
}
function loadDepsFromBootstrap(env) {
if (Sx.scanRefs) {
env["scan-refs"] = Sx.scanRefs;
env["scan-components-from-source"] = Sx.scanComponentsFromSource;
env["transitive-deps"] = Sx.transitiveDeps;
env["compute-all-deps"] = Sx.computeAllDeps;
env["components-needed"] = Sx.componentsNeeded;
env["page-component-bundle"] = Sx.pageComponentBundle;
env["page-css-classes"] = Sx.pageCssClasses;
env["scan-io-refs"] = Sx.scanIoRefs;
env["transitive-io-refs"] = Sx.transitiveIoRefs;
env["compute-all-io-refs"] = Sx.computeAllIoRefs;
env["component-pure?"] = Sx.componentPure_p;
env["test-env"] = function() { return env; };
function resetAllItems() {
var items = document.querySelectorAll("[data-test]");
for (var i = 0; i < items.length; i++) {
var icon = items[i].querySelector('[data-role="test-icon"]');
if (icon) { icon.textContent = "○"; icon.style.color = ""; }
items[i].style.borderColor = "";
items[i].style.backgroundColor = "";
var err = items[i].querySelector('[data-role="test-error"]');
if (err) err.remove();
}
}
function loadEngineFromBootstrap(env) {
if (Sx.parseTime) {
env["parse-time"] = Sx.parseTime;
env["parse-trigger-spec"] = Sx.parseTriggerSpec;
env["default-trigger"] = Sx.defaultTrigger;
env["parse-swap-spec"] = Sx.parseSwapSpec;
env["parse-retry-spec"] = Sx.parseRetrySpec;
env["next-retry-ms"] = function(cur, cap) { return Math.min(cur * 2, cap); };
env["filter-params"] = Sx.filterParams;
async function runOneTest(test) {
var doc = iframe.contentDocument;
for (var j = 0; j < test.actions.length; j++) {
var a = test.actions[j];
if (a.type === "click") {
var el = doc.querySelector(a.selector);
if (!el) throw new Error("Not found: " + a.selector);
el.click();
} else if (a.type === "fill") {
var el = doc.querySelector(a.selector);
if (!el) throw new Error("Not found: " + a.selector);
el.focus();
el.value = "";
el.value = a.value;
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
} else if (a.type === "wait") {
await sleep(a.ms);
} else if (a.type === "assert-text") {
var el = doc.querySelector(a.selector);
if (!el) throw new Error("Not found: " + a.selector);
var txt = el.textContent || "";
if (a.contains && txt.indexOf(a.contains) === -1)
throw new Error("Expected '" + a.contains + "' in '" + txt.substring(0, 60) + "'");
if (a.notContains && txt.indexOf(a.notContains) !== -1)
throw new Error("Unexpected '" + a.notContains + "'");
} else if (a.type === "assert-count") {
var els = doc.querySelectorAll(a.selector);
if (a.gte !== undefined && els.length < a.gte)
throw new Error("Expected >=" + a.gte + ", got " + els.length);
}
}
}
// --- Legacy runner (monolithic test.sx) ---
window.sxRunTests = function(srcId, outId, btnId) {
var src = document.getElementById(srcId).textContent;
var out = document.getElementById(outId);
var btn = document.getElementById(btnId);
var ctx = makeEnv();
async function runAll() {
resetAllItems();
summaryEl.innerHTML = "";
runBtn.disabled = true;
runBtn.style.opacity = "0.5";
var passed = 0, failed = 0;
try {
var t0 = performance.now();
evalSource(src, ctx.env);
var elapsed = Math.round(performance.now() - t0);
var r = ctx.getResults();
r.lines.push("");
r.lines.push("1.." + r.num);
r.lines.push("# tests " + (r.passed + r.failed));
r.lines.push("# pass " + r.passed);
if (r.failed > 0) r.lines.push("# fail " + r.failed);
r.lines.push("# time " + elapsed + "ms");
} catch(e) {
var r = ctx.getResults();
r.lines.push("");
r.lines.push("FATAL: " + (e.message || String(e)));
}
for (var i = 0; i < TESTS.length; i++) {
var test = TESTS[i];
statusEl.textContent = test.name + " (" + (i+1) + "/" + TESTS.length + ")";
updateTestItem(test.name, "running");
out.textContent = r.lines.join("\n");
out.style.display = "block";
btn.textContent = r.passed + "/" + (r.passed + r.failed) + " passed" + (r.failed === 0 ? "" : " (" + r.failed + " failed)");
btn.className = r.failed > 0
? "px-4 py-2 rounded-md bg-red-600 text-white font-medium text-sm cursor-default"
: "px-4 py-2 rounded-md bg-green-600 text-white font-medium text-sm cursor-default";
};
if (i > 0) await reloadIframe();
// --- Modular runner (per-spec or all) ---
var SPECS = {
"eval": { needs: [] },
"parser": { needs: ["sx-parse"] },
"router": { needs: [] },
"render": { needs: ["render-html"] },
"deps": { needs: [] },
"engine": { needs: [] },
};
window.sxRunModularTests = function(specName, outId, btnId) {
var out = document.getElementById(outId);
var btn = document.getElementById(btnId);
var ctx = makeEnv();
var specs = specName === "all" ? Object.keys(SPECS) : [specName];
try {
var t0 = performance.now();
// Load framework
var fwEl = document.getElementById("test-framework-source");
if (fwEl) {
evalSource(fwEl.textContent, ctx.env);
try {
await waitForReady(iframe.contentDocument, 15000);
await sleep(500);
} catch(e) {
updateTestItem(test.name, "fail", "Page load timeout");
failed++;
continue;
}
for (var si = 0; si < specs.length; si++) {
var sn = specs[si];
if (!SPECS[sn]) continue;
// Load module functions from bootstrap
if (sn === "router") loadRouterFromBootstrap(ctx.env);
if (sn === "deps") loadDepsFromBootstrap(ctx.env);
if (sn === "engine") loadEngineFromBootstrap(ctx.env);
// Find spec source — either per-spec textarea or embedded in overview
var specEl = document.getElementById("test-spec-" + sn);
if (specEl) {
evalSource(specEl.textContent, ctx.env);
}
try {
await runOneTest(test);
updateTestItem(test.name, "pass");
passed++;
} catch(err) {
updateTestItem(test.name, "fail", err.message);
failed++;
}
var elapsed = Math.round(performance.now() - t0);
var r = ctx.getResults();
r.lines.push("");
r.lines.push("1.." + r.num);
r.lines.push("# tests " + (r.passed + r.failed));
r.lines.push("# pass " + r.passed);
if (r.failed > 0) r.lines.push("# fail " + r.failed);
r.lines.push("# time " + elapsed + "ms");
} catch(e) {
var r = ctx.getResults();
r.lines.push("");
r.lines.push("FATAL: " + (e.message || String(e)));
}
out.textContent = r.lines.join("\n");
out.style.display = "block";
btn.textContent = r.passed + "/" + (r.passed + r.failed) + " passed" + (r.failed === 0 ? "" : " (" + r.failed + " failed)");
btn.className = r.failed > 0
? "px-4 py-2 rounded-md bg-red-600 text-white font-medium text-sm cursor-default"
: "px-4 py-2 rounded-md bg-green-600 text-white font-medium text-sm cursor-default";
};
runBtn.disabled = false;
runBtn.style.opacity = "1";
statusEl.textContent = "Done";
var total = passed + failed;
summaryEl.innerHTML = '<div style="padding:12px;border-radius:8px;font-weight:600;' +
(failed === 0 ? 'background:#f0fdf4;color:#166534' : 'background:#fef2f2;color:#991b1b') +
'">' + passed + '/' + total + ' tests passed</div>';
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();