- 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>
224 lines
8.0 KiB
JavaScript
224 lines
8.0 KiB
JavaScript
/**
|
|
* 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() {
|
|
"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");
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
errEl.textContent = error;
|
|
}
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runAll() {
|
|
resetAllItems();
|
|
summaryEl.innerHTML = "";
|
|
runBtn.disabled = true;
|
|
runBtn.style.opacity = "0.5";
|
|
var passed = 0, failed = 0;
|
|
|
|
for (var i = 0; i < TESTS.length; i++) {
|
|
var test = TESTS[i];
|
|
statusEl.textContent = test.name + " (" + (i+1) + "/" + TESTS.length + ")";
|
|
updateTestItem(test.name, "running");
|
|
|
|
if (i > 0) await reloadIframe();
|
|
|
|
try {
|
|
await waitForReady(iframe.contentDocument, 15000);
|
|
await sleep(500);
|
|
} catch(e) {
|
|
updateTestItem(test.name, "fail", "Page load timeout");
|
|
failed++;
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
await runOneTest(test);
|
|
updateTestItem(test.name, "pass");
|
|
passed++;
|
|
} catch(err) {
|
|
updateTestItem(test.name, "fail", err.message);
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
})();
|