more plans
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 12m0s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 12m0s
This commit is contained in:
@@ -6904,7 +6904,9 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() {
|
|||||||
_renderDOM: renderDOM,
|
_renderDOM: renderDOM,
|
||||||
};
|
};
|
||||||
|
|
||||||
global.Sx = Sx;
|
// OLD hand-written Sx object — NOT exported. The ref-generated Sx (line 5336) is authoritative.
|
||||||
|
// Reassign local Sx to the ref version so SxEngine (below) uses spec-generated eval/render.
|
||||||
|
Sx = global.Sx;
|
||||||
|
|
||||||
// --- SxEngine — native fetch/swap/history engine ---
|
// --- SxEngine — native fetch/swap/history engine ---
|
||||||
|
|
||||||
|
|||||||
@@ -400,6 +400,10 @@ def components_for_page(page_sx: str, service: str | None = None) -> tuple[str,
|
|||||||
parts.append(f"(defisland ~{val.name} {params_sx} {body_sx})")
|
parts.append(f"(defisland ~{val.name} {params_sx} {body_sx})")
|
||||||
elif isinstance(val, Component):
|
elif isinstance(val, Component):
|
||||||
if f"~{val.name}" in needed or key in needed:
|
if f"~{val.name}" in needed or key in needed:
|
||||||
|
# Skip server-affinity components — they're expanded server-side
|
||||||
|
# and the client doesn't have the define values they depend on.
|
||||||
|
if val.render_target == "server":
|
||||||
|
continue
|
||||||
param_strs = ["&key"] + list(val.params)
|
param_strs = ["&key"] + list(val.params)
|
||||||
if val.has_children:
|
if val.has_children:
|
||||||
param_strs.extend(["&rest", "children"])
|
param_strs.extend(["&rest", "children"])
|
||||||
|
|||||||
62
shared/sx/templates/shell.sx
Normal file
62
shared/sx/templates/shell.sx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Page shell — the outermost HTML document structure
|
||||||
|
;;
|
||||||
|
;; Replaces the Python HTML string template. All page data is computed in
|
||||||
|
;; Python and passed as keyword arguments. The component just arranges
|
||||||
|
;; the precomputed values into HTML structure.
|
||||||
|
;;
|
||||||
|
;; raw! is used for:
|
||||||
|
;; - <!doctype html> (not an element)
|
||||||
|
;; - Script/style content (must not be HTML-escaped)
|
||||||
|
;; - Pre-rendered meta HTML from callers
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defcomp ~sx-page-shell (&key title meta-html csrf
|
||||||
|
sx-css sx-css-classes
|
||||||
|
component-hash component-defs
|
||||||
|
pages-sx page-sx
|
||||||
|
asset-url sx-js-hash body-js-hash)
|
||||||
|
(<>
|
||||||
|
(raw! "<!doctype html>")
|
||||||
|
(html :lang "en"
|
||||||
|
(head
|
||||||
|
(meta :charset "utf-8")
|
||||||
|
(meta :name "viewport" :content "width=device-width, initial-scale=1")
|
||||||
|
(meta :name "robots" :content "index,follow")
|
||||||
|
(meta :name "theme-color" :content "#ffffff")
|
||||||
|
(title title)
|
||||||
|
(when meta-html (raw! meta-html))
|
||||||
|
(style (raw! "@media (min-width: 768px) { .js-mobile-sentinel { display:none !important; } }"))
|
||||||
|
(meta :name "csrf-token" :content csrf)
|
||||||
|
(style :id "sx-css" (raw! (or sx-css "")))
|
||||||
|
(meta :name "sx-css-classes" :content (or sx-css-classes ""))
|
||||||
|
;; CDN scripts
|
||||||
|
(script :src "https://unpkg.com/prismjs/prism.js")
|
||||||
|
(script :src "https://unpkg.com/prismjs/components/prism-javascript.min.js")
|
||||||
|
(script :src "https://unpkg.com/prismjs/components/prism-python.min.js")
|
||||||
|
(script :src "https://unpkg.com/prismjs/components/prism-bash.min.js")
|
||||||
|
(script :src "https://cdn.jsdelivr.net/npm/sweetalert2@11")
|
||||||
|
;; Inline JS
|
||||||
|
(script (raw! "if(matchMedia('(hover:hover) and (pointer:fine)').matches){document.documentElement.classList.add('hover-capable')}"))
|
||||||
|
(script (raw! "document.addEventListener('click',function(e){var t=e.target.closest('[data-close-details]');if(!t)return;var d=t.closest('details');if(d)d.removeAttribute('open')})"))
|
||||||
|
;; Inline CSS
|
||||||
|
(style (raw! "details[data-toggle-group=\"mobile-panels\"]>summary{list-style:none}
|
||||||
|
details[data-toggle-group=\"mobile-panels\"]>summary::-webkit-details-marker{display:none}
|
||||||
|
@media(min-width:768px){.nav-group:focus-within .submenu,.nav-group:hover .submenu{display:block}}
|
||||||
|
img{max-width:100%;height:auto}
|
||||||
|
.clamp-2{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||||
|
.clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
|
||||||
|
.no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}
|
||||||
|
details.group{overflow:hidden}details.group>summary{list-style:none}details.group>summary::-webkit-details-marker{display:none}
|
||||||
|
.sx-indicator{display:none}.sx-request .sx-indicator{display:inline-flex}
|
||||||
|
.sx-error .sx-indicator{display:none}.sx-loading .sx-indicator{display:inline-flex}
|
||||||
|
.js-wrap.open .js-pop{display:block}.js-wrap.open .js-backdrop{display:block}")))
|
||||||
|
(body :class "bg-stone-50 text-stone-900"
|
||||||
|
(script :type "text/sx" :data-components true :data-hash component-hash
|
||||||
|
(raw! (or component-defs "")))
|
||||||
|
(script :type "text/sx-pages"
|
||||||
|
(raw! (or pages-sx "")))
|
||||||
|
(script :type "text/sx" :data-mount "body"
|
||||||
|
(raw! (or page-sx "")))
|
||||||
|
(script :src (str asset-url "/scripts/sx-browser.js?v=" sx-js-hash))
|
||||||
|
(script :src (str asset-url "/scripts/body.js?v=" body-js-hash))))))
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
;; Docs page content — fully self-contained, no Python intermediaries
|
;; Docs page content — fully self-contained, no Python intermediaries
|
||||||
|
|
||||||
(defcomp ~sx-home-content ()
|
(defcomp ~sx-home-content ()
|
||||||
(div :id "main-content"
|
(div :id "main-content" :class "max-w-3xl mx-auto px-4 py-6"
|
||||||
(~sx-hero (highlight "(div :class \"p-4 bg-white rounded shadow\"\n (h1 :class \"text-2xl font-bold\" \"Hello\")\n (button :sx-get \"/api/data\"\n :sx-target \"#result\"\n \"Load data\"))" "lisp"))
|
(highlight "(defcomp ~sx-header ()
|
||||||
(~sx-philosophy)
|
(a :href \"/\"
|
||||||
(~sx-how-it-works)
|
:sx-get \"/\" :sx-target \"#main-panel\"
|
||||||
(~sx-credits)))
|
:sx-select \"#main-panel\"
|
||||||
|
:sx-swap \"outerHTML\" :sx-push-url \"true\"
|
||||||
|
:class \"block max-w-3xl mx-auto px-4 pt-8 pb-4 text-center no-underline\"
|
||||||
|
(span :class \"text-4xl font-bold font-mono text-violet-700 block mb-2\"
|
||||||
|
\"(<sx>)\")
|
||||||
|
(p :class \"text-lg text-stone-500 mb-1\"
|
||||||
|
\"Framework free reactive hypermedia\")
|
||||||
|
(p :class \"text-xs text-stone-400\"
|
||||||
|
\"© Giles Bradshaw 2026\")))" "lisp")))
|
||||||
|
|
||||||
(defcomp ~docs-introduction-content ()
|
(defcomp ~docs-introduction-content ()
|
||||||
(~doc-page :title "Introduction"
|
(~doc-page :title "Introduction"
|
||||||
|
|||||||
@@ -11,12 +11,11 @@
|
|||||||
|
|
||||||
;; Logo + tagline + copyright — always shown at top of page area.
|
;; Logo + tagline + copyright — always shown at top of page area.
|
||||||
(defcomp ~sx-header ()
|
(defcomp ~sx-header ()
|
||||||
(div :class "max-w-3xl mx-auto px-4 pt-8 pb-4 text-center"
|
(a :href "/"
|
||||||
(a :href "/"
|
:sx-get "/" :sx-target "#main-panel" :sx-select "#main-panel"
|
||||||
:sx-get "/" :sx-target "#main-panel" :sx-select "#main-panel"
|
:sx-swap "outerHTML" :sx-push-url "true"
|
||||||
:sx-swap "outerHTML" :sx-push-url "true"
|
:class "block max-w-3xl mx-auto px-4 pt-8 pb-4 text-center no-underline"
|
||||||
:class "block mb-2"
|
(span :class "text-4xl font-bold font-mono text-violet-700 block mb-2" "(<sx>)")
|
||||||
(span :class "text-4xl font-bold font-mono text-violet-700" "(<sx>)"))
|
|
||||||
(p :class "text-lg text-stone-500 mb-1"
|
(p :class "text-lg text-stone-500 mb-1"
|
||||||
"Framework free reactive hypermedia")
|
"Framework free reactive hypermedia")
|
||||||
(p :class "text-xs text-stone-400"
|
(p :class "text-xs text-stone-400"
|
||||||
@@ -70,7 +69,7 @@
|
|||||||
;; Used by every defpage :content to embed nav inside the page content area.
|
;; Used by every defpage :content to embed nav inside the page content area.
|
||||||
;; ---------------------------------------------------------------------------
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
(defcomp ~sx-doc (&key path &rest children)
|
(defcomp ~sx-doc (&key path &rest children) :affinity :server
|
||||||
(let ((nav-state (resolve-nav-path sx-nav-tree (or path "/"))))
|
(let ((nav-state (resolve-nav-path sx-nav-tree (or path "/"))))
|
||||||
(<>
|
(<>
|
||||||
(div :id "sx-nav" :class "mb-6"
|
(div :id "sx-nav" :class "mb-6"
|
||||||
|
|||||||
@@ -188,7 +188,13 @@
|
|||||||
(dict :label "Live Streaming" :href "/plans/live-streaming"
|
(dict :label "Live Streaming" :href "/plans/live-streaming"
|
||||||
:summary "SSE and WebSocket transports for re-resolving suspense slots after initial page load — live data, real-time collaboration.")
|
:summary "SSE and WebSocket transports for re-resolving suspense slots after initial page load — live data, real-time collaboration.")
|
||||||
(dict :label "sx-web Platform" :href "/plans/sx-web-platform"
|
(dict :label "sx-web Platform" :href "/plans/sx-web-platform"
|
||||||
:summary "sx-web.org as online development platform — embedded Claude Code, IPFS storage, sx-activity publishing, sx-ci testing. Author, stage, test, deploy from the browser.")))
|
:summary "sx-web.org as online development platform — embedded Claude Code, IPFS storage, sx-activity publishing, sx-ci testing. Author, stage, test, deploy from the browser.")
|
||||||
|
(dict :label "sx-forge" :href "/plans/sx-forge"
|
||||||
|
:summary "Git forge in SX — repositories, issues, pull requests, CI, permissions, and federation. Configuration as macros, diffs as components.")
|
||||||
|
(dict :label "sx-swarm" :href "/plans/sx-swarm"
|
||||||
|
:summary "Container orchestration in SX — service definitions, environment macros, deploy pipelines. Replace YAML with a real language.")
|
||||||
|
(dict :label "sx-proxy" :href "/plans/sx-proxy"
|
||||||
|
:summary "Reverse proxy in SX — routes, TLS, middleware chains, load balancing. Macros generate config from the same service definitions as the orchestrator.")))
|
||||||
|
|
||||||
(define reactive-islands-nav-items (list
|
(define reactive-islands-nav-items (list
|
||||||
(dict :label "Overview" :href "/reactive-islands/"
|
(dict :label "Overview" :href "/reactive-islands/"
|
||||||
|
|||||||
157
sx/sx/plans/sx-forge.sx
Normal file
157
sx/sx/plans/sx-forge.sx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
;; sx-forge — SX-based Git Forge
|
||||||
|
;; Plan: a Gitea/Forgejo-style git hosting platform where everything —
|
||||||
|
;; repositories, issues, pull requests, CI, permissions — is SX.
|
||||||
|
|
||||||
|
(defcomp ~plan-sx-forge-content ()
|
||||||
|
(~doc-page :title "sx-forge: Git Forge in SX"
|
||||||
|
|
||||||
|
(~doc-section :title "Vision" :id "vision"
|
||||||
|
(p "A git forge where the entire interface, configuration, and automation layer "
|
||||||
|
"is written in SX. Repositories are browsed, issues are filed, pull requests "
|
||||||
|
"are reviewed, and CI pipelines are triggered — all through SX components "
|
||||||
|
"rendered via the same hypermedia pipeline as every other SX app.")
|
||||||
|
(p "Configuration is SX. Webhooks are SX. Access control policies are SX macros "
|
||||||
|
"that expand to permission checks. Repository templates are defcomps. "
|
||||||
|
"The forge doesn't use SX — it " (em "is") " SX."))
|
||||||
|
|
||||||
|
(~doc-section :title "Why" :id "why"
|
||||||
|
(p "Gitea/Forgejo are excellent but they're Go binaries with YAML/INI config, "
|
||||||
|
"Markdown rendering, and a template engine that's separate from the application logic. "
|
||||||
|
"Every layer speaks a different language.")
|
||||||
|
(p "sx-forge collapses these layers:")
|
||||||
|
(ul :class "space-y-2 text-stone-600 list-disc pl-5"
|
||||||
|
(li "Repository browsing = SX components rendering git tree objects")
|
||||||
|
(li "Issue tracking = SX forms with sx-post, stored as content-addressed SX documents")
|
||||||
|
(li "Pull requests = SX diff viewer + sx-activity for review comments")
|
||||||
|
(li "CI integration = sx-ci pipelines triggered by push hooks")
|
||||||
|
(li "Configuration = SX s-expressions, manipulated by macros")
|
||||||
|
(li "Access control = SX macros that expand to permission predicates")
|
||||||
|
(li "API = SX wire format (text/sx) alongside JSON for compatibility")))
|
||||||
|
|
||||||
|
(~doc-section :title "Architecture" :id "architecture"
|
||||||
|
(div :class "overflow-x-auto mt-4"
|
||||||
|
(table :class "w-full text-sm text-left"
|
||||||
|
(thead
|
||||||
|
(tr :class "border-b border-stone-200"
|
||||||
|
(th :class "py-2 px-3 font-semibold text-stone-700" "Layer")
|
||||||
|
(th :class "py-2 px-3 font-semibold text-stone-700" "Implementation")
|
||||||
|
(th :class "py-2 px-3 font-semibold text-stone-700" "Notes")))
|
||||||
|
(tbody :class "text-stone-600"
|
||||||
|
(tr :class "border-b border-stone-100"
|
||||||
|
(td :class "py-2 px-3 font-semibold" "Git backend")
|
||||||
|
(td :class "py-2 px-3" "libgit2 or shell-out to git")
|
||||||
|
(td :class "py-2 px-3" "Smart HTTP + SSH protocols. Bare repos on disk."))
|
||||||
|
(tr :class "border-b border-stone-100"
|
||||||
|
(td :class "py-2 px-3 font-semibold" "UI")
|
||||||
|
(td :class "py-2 px-3" "SX components (defcomp)")
|
||||||
|
(td :class "py-2 px-3" "Tree browser, diff viewer, blame, commit log — all defcomps."))
|
||||||
|
(tr :class "border-b border-stone-100"
|
||||||
|
(td :class "py-2 px-3 font-semibold" "Issues / PRs")
|
||||||
|
(td :class "py-2 px-3" "SX documents on IPFS")
|
||||||
|
(td :class "py-2 px-3" "Content-addressed. Federated via sx-activity."))
|
||||||
|
(tr :class "border-b border-stone-100"
|
||||||
|
(td :class "py-2 px-3 font-semibold" "CI")
|
||||||
|
(td :class "py-2 px-3" "sx-ci pipelines")
|
||||||
|
(td :class "py-2 px-3" "Push hook triggers pipeline. Results as SX components."))
|
||||||
|
(tr :class "border-b border-stone-100"
|
||||||
|
(td :class "py-2 px-3 font-semibold" "Auth")
|
||||||
|
(td :class "py-2 px-3" "OAuth2 + SX policy macros")
|
||||||
|
(td :class "py-2 px-3" "Permissions are macro-expanded predicates."))
|
||||||
|
(tr :class "border-b border-stone-100"
|
||||||
|
(td :class "py-2 px-3 font-semibold" "Config")
|
||||||
|
(td :class "py-2 px-3" "SX s-expressions")
|
||||||
|
(td :class "py-2 px-3" "forge.sx per-instance. repo.sx per-repo."))
|
||||||
|
(tr :class "border-b border-stone-100"
|
||||||
|
(td :class "py-2 px-3 font-semibold" "Federation")
|
||||||
|
(td :class "py-2 px-3" "sx-activity (ActivityPub)")
|
||||||
|
(td :class "py-2 px-3" "Cross-instance PRs, issues, stars, forks."))))))
|
||||||
|
|
||||||
|
(~doc-section :title "Configuration as SX" :id "config"
|
||||||
|
(p "Instance configuration is an SX file, not YAML or INI:")
|
||||||
|
(highlight "(define forge-config
|
||||||
|
{:name \"Rose Ash Forge\"
|
||||||
|
:domain \"forge.rose-ash.com\"
|
||||||
|
:ssh-port 2222
|
||||||
|
:storage {:backend :filesystem :root \"/data/repos\"}
|
||||||
|
:auth {:provider :oauth2
|
||||||
|
:issuer \"https://account.rose-ash.com\"}
|
||||||
|
:federation {:enabled true
|
||||||
|
:allowlist (list \"*.rose-ash.com\")}
|
||||||
|
:ci {:runner :sx-ci
|
||||||
|
:default-pipeline \"ci/default.sx\"}})" "lisp")
|
||||||
|
(p "Macros transform configuration:")
|
||||||
|
(highlight ";; Macro: generate mirror config from upstream
|
||||||
|
(defmacro mirror-repo (name upstream)
|
||||||
|
`(define-repo ,name
|
||||||
|
{:mirror true
|
||||||
|
:upstream ,upstream
|
||||||
|
:sync-interval 3600
|
||||||
|
:ci false}))" "lisp")
|
||||||
|
(p "Per-repository configuration lives in " (code "repo.sx") " at the repo root:")
|
||||||
|
(highlight "(define repo-config
|
||||||
|
{:default-branch \"main\"
|
||||||
|
:ci (pipeline
|
||||||
|
(stage :test (sx-ci/run \"test.sx\"))
|
||||||
|
(stage :deploy
|
||||||
|
(when-branch \"main\"
|
||||||
|
(sx-ci/deploy :target :production))))
|
||||||
|
:permissions
|
||||||
|
{:push (or (role? :maintainer) (role? :admin))
|
||||||
|
:merge-pr (and (ci-passed?) (approved-by? 1))
|
||||||
|
:admin (role? :admin)}})" "lisp"))
|
||||||
|
|
||||||
|
(~doc-section :title "SX Diff Viewer" :id "diff-viewer"
|
||||||
|
(p "Diffs rendered as SX components, not pre-formatted text:")
|
||||||
|
(highlight ";; The diff viewer is a defcomp, composable like any other
|
||||||
|
(defcomp ~diff-view (&key diff)
|
||||||
|
(map (fn (hunk)
|
||||||
|
(~diff-hunk
|
||||||
|
:file (get hunk \"file\")
|
||||||
|
:old-start (get hunk \"old-start\")
|
||||||
|
:new-start (get hunk \"new-start\")
|
||||||
|
:lines (get hunk \"lines\")))
|
||||||
|
(get diff \"hunks\")))" "lisp")
|
||||||
|
(p "Because diffs are SX data, macros can transform them:")
|
||||||
|
(ul :class "space-y-2 text-stone-600 list-disc pl-5"
|
||||||
|
(li "Syntax highlighting via the same " (code "highlight") " helper used everywhere")
|
||||||
|
(li "Inline review comments as SX forms (sx-post to comment endpoint)")
|
||||||
|
(li "Suggestion blocks — click to apply a proposed change")
|
||||||
|
(li "SX-aware diffs — show component-level changes, not just line changes")))
|
||||||
|
|
||||||
|
(~doc-section :title "Federated Forge" :id "federation"
|
||||||
|
(p "sx-activity enables cross-instance collaboration:")
|
||||||
|
(ul :class "space-y-2 text-stone-600 list-disc pl-5"
|
||||||
|
(li (strong "Cross-instance PRs") " — open a PR from your fork on another instance")
|
||||||
|
(li (strong "Federated issues") " — file an issue on a remote repo from your instance")
|
||||||
|
(li (strong "Stars and forks") " — ActivityPub Follow/Like activities")
|
||||||
|
(li (strong "Mirror sync") " — subscribe to upstream changes via sx-activity")
|
||||||
|
(li (strong "Review comments") " — threaded discussions federated as SX documents"))
|
||||||
|
(p "Every issue, comment, and review is a content-addressed SX document on IPFS. "
|
||||||
|
"Federation distributes references. The content is permanent and verifiable."))
|
||||||
|
|
||||||
|
(~doc-section :title "Git Operations as IO Primitives" :id "git-ops"
|
||||||
|
(p "Git operations exposed as SX IO primitives in boundary.sx:")
|
||||||
|
(highlight ";; boundary.sx additions
|
||||||
|
(io git-log (repo &key branch limit offset) list)
|
||||||
|
(io git-tree (repo ref path) list)
|
||||||
|
(io git-blob (repo ref path) string)
|
||||||
|
(io git-diff (repo base head) dict)
|
||||||
|
(io git-refs (repo) list)
|
||||||
|
(io git-commit (repo message files &key author) dict)
|
||||||
|
(io git-create-branch (repo name from) dict)
|
||||||
|
(io git-merge (repo source target &key strategy) dict)" "lisp")
|
||||||
|
(p "Pages use these directly — no controller layer, no ORM:"))
|
||||||
|
|
||||||
|
(~doc-section :title "Implementation Path" :id "implementation"
|
||||||
|
(ol :class "space-y-3 text-stone-600 list-decimal pl-5"
|
||||||
|
(li (strong "Phase 1: Read-only browser") " — git-tree, git-blob, git-log, git-diff as IO primitives. "
|
||||||
|
"SX components for tree view, blob view, commit log, diff view.")
|
||||||
|
(li (strong "Phase 2: Issues") " — SX forms for create/edit. Content-addressed storage. "
|
||||||
|
"Labels, milestones, assignees as SX data.")
|
||||||
|
(li (strong "Phase 3: Pull requests") " — fork model, diff + review UI, merge strategies. "
|
||||||
|
"CI status checks from sx-ci.")
|
||||||
|
(li (strong "Phase 4: CI integration") " — push hooks trigger sx-ci pipelines. "
|
||||||
|
"Results rendered as SX components on the PR page.")
|
||||||
|
(li (strong "Phase 5: Federation") " — sx-activity for cross-instance PRs, issues, stars.")
|
||||||
|
(li (strong "Phase 6: Admin") " — SX macro-based permission policies. "
|
||||||
|
"Instance and org management via SX config.")))))
|
||||||
186
sx/sx/plans/sx-proxy.sx
Normal file
186
sx/sx/plans/sx-proxy.sx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
;; sx-proxy — SX-based Reverse Proxy
|
||||||
|
;; Plan: a Caddy-style reverse proxy where routes, TLS, middleware,
|
||||||
|
;; and load balancing are SX s-expressions manipulated by macros.
|
||||||
|
|
||||||
|
(defcomp ~plan-sx-proxy-content ()
|
||||||
|
(~doc-page :title "sx-proxy: Reverse Proxy in SX"
|
||||||
|
|
||||||
|
(~doc-section :title "Vision" :id "vision"
|
||||||
|
(p "A reverse proxy where routing rules, TLS configuration, middleware chains, "
|
||||||
|
"and load balancing policies are SX. Not a config file that gets parsed — "
|
||||||
|
"actual SX that gets evaluated. Macros generate routes from service definitions. "
|
||||||
|
"The proxy config is derived from the same stack definition as the deployment.")
|
||||||
|
(p "Caddy's Caddyfile is close — it's almost a language. But it stops short. "
|
||||||
|
"No functions, no macros, no computed values, no integration with the rest of the stack. "
|
||||||
|
"sx-proxy goes all the way: the proxy configuration " (em "is") " the application."))
|
||||||
|
|
||||||
|
(~doc-section :title "Why" :id "why"
|
||||||
|
(p "Every proxy config language reinvents the same features badly:")
|
||||||
|
(ul :class "space-y-2 text-stone-600 list-disc pl-5"
|
||||||
|
(li "Nginx: custom DSL with if-is-evil, no real conditionals, include for composition")
|
||||||
|
(li "Caddy: Caddyfile is nice but no functions, no macros, no types")
|
||||||
|
(li "Traefik: labels on Docker containers — stringly-typed, no validation")
|
||||||
|
(li "HAProxy: line-oriented config, ACLs as string matching, no abstraction"))
|
||||||
|
(p "All of them: separate config from application. "
|
||||||
|
"SX unifies them. The proxy reads the same service definitions "
|
||||||
|
"that the orchestrator deploys."))
|
||||||
|
|
||||||
|
(~doc-section :title "Route Definitions" :id "routes"
|
||||||
|
(p "Routes as SX, with macros for common patterns:")
|
||||||
|
(highlight ";; Basic route definition
|
||||||
|
(route blog.rose-ash.com
|
||||||
|
:upstream \"http://blog:8000\"
|
||||||
|
:tls :auto)
|
||||||
|
|
||||||
|
;; Service route macro — generates from service definition
|
||||||
|
(defmacro service-route (service)
|
||||||
|
(let ((domain (service-domain service))
|
||||||
|
(port (service-port service)))
|
||||||
|
`(route ,domain
|
||||||
|
:upstream ,(str \"http://\" (get service \"name\") \":\" port)
|
||||||
|
:tls :auto
|
||||||
|
:headers {:X-Forwarded-Proto \"https\"
|
||||||
|
:X-Real-IP (client-ip)})))
|
||||||
|
|
||||||
|
;; Generate all routes from the stack definition
|
||||||
|
(define routes
|
||||||
|
(map service-route (get rose-ash-stack \"services\")))" "lisp")
|
||||||
|
(p "One macro generates all routes from the same service definitions "
|
||||||
|
"that sx-swarm uses for deployment. Change the service, the route updates."))
|
||||||
|
|
||||||
|
(~doc-section :title "Middleware as Composition" :id "middleware"
|
||||||
|
(p "Middleware chains are function composition:")
|
||||||
|
(highlight ";; Middleware are functions: request -> response -> response
|
||||||
|
(define rate-limit
|
||||||
|
(middleware :name :rate-limit
|
||||||
|
:window \"1m\" :max 100 :key (client-ip)))
|
||||||
|
|
||||||
|
(define cors
|
||||||
|
(middleware :name :cors
|
||||||
|
:origins (list \"*.rose-ash.com\")
|
||||||
|
:methods (list :GET :POST :PUT :DELETE)
|
||||||
|
:headers (list :Authorization :Content-Type)))
|
||||||
|
|
||||||
|
(define auth-required
|
||||||
|
(middleware :name :auth
|
||||||
|
:provider :oauth2
|
||||||
|
:redirect \"https://account.rose-ash.com/login\"))
|
||||||
|
|
||||||
|
;; Compose middleware chains
|
||||||
|
(define public-chain
|
||||||
|
(chain rate-limit cors))
|
||||||
|
|
||||||
|
(define private-chain
|
||||||
|
(chain rate-limit cors auth-required))
|
||||||
|
|
||||||
|
;; Apply to routes
|
||||||
|
(route account.rose-ash.com
|
||||||
|
:upstream \"http://account:8000\"
|
||||||
|
:middleware private-chain
|
||||||
|
:tls :auto)" "lisp")
|
||||||
|
(p "Middleware is data. Chains compose with " (code "chain") ". "
|
||||||
|
"Apply different chains to different routes. "
|
||||||
|
"No nginx location blocks, no Caddy handle nesting."))
|
||||||
|
|
||||||
|
(~doc-section :title "TLS Configuration" :id "tls"
|
||||||
|
(p "TLS as SX with macro-generated cert management:")
|
||||||
|
(highlight ";; Auto TLS via ACME (like Caddy)
|
||||||
|
(define tls-auto
|
||||||
|
{:provider :acme
|
||||||
|
:ca \"https://acme-v02.api.letsencrypt.org/directory\"
|
||||||
|
:email \"admin@rose-ash.com\"
|
||||||
|
:storage \"/data/certs\"})
|
||||||
|
|
||||||
|
;; Wildcard via DNS challenge
|
||||||
|
(define tls-wildcard
|
||||||
|
{:provider :acme
|
||||||
|
:ca \"https://acme-v02.api.letsencrypt.org/directory\"
|
||||||
|
:dns-challenge {:provider :cloudflare
|
||||||
|
:api-token (from-secret \"cf-token\")}
|
||||||
|
:domains (list \"*.rose-ash.com\")})
|
||||||
|
|
||||||
|
;; Per-route TLS macro
|
||||||
|
(defmacro with-tls (config &rest routes)
|
||||||
|
`(map (fn (r) (assoc r :tls ,config)) (list ,@routes)))" "lisp"))
|
||||||
|
|
||||||
|
(~doc-section :title "Load Balancing" :id "load-balancing"
|
||||||
|
(p "Load balancing policies as SX values:")
|
||||||
|
(highlight ";; Load balancing strategies
|
||||||
|
(define round-robin (lb :strategy :round-robin))
|
||||||
|
(define least-conn (lb :strategy :least-connections))
|
||||||
|
(define ip-hash (lb :strategy :ip-hash))
|
||||||
|
|
||||||
|
;; Health-checked upstream pool
|
||||||
|
(define blog-pool
|
||||||
|
(upstream-pool
|
||||||
|
:backends (list \"blog-1:8000\" \"blog-2:8000\" \"blog-3:8000\")
|
||||||
|
:strategy least-conn
|
||||||
|
:health-check {:path \"/health\"
|
||||||
|
:interval \"10s\"
|
||||||
|
:timeout \"3s\"
|
||||||
|
:unhealthy-threshold 3}))
|
||||||
|
|
||||||
|
(route blog.rose-ash.com
|
||||||
|
:upstream blog-pool
|
||||||
|
:tls :auto)" "lisp"))
|
||||||
|
|
||||||
|
(~doc-section :title "Dynamic Reconfiguration" :id "dynamic"
|
||||||
|
(p "The proxy evaluates SX — so config can be dynamic:")
|
||||||
|
(ul :class "space-y-2 text-stone-600 list-disc pl-5"
|
||||||
|
(li (strong "Hot reload") " — change the SX, proxy re-evaluates. No restart.")
|
||||||
|
(li (strong "API-driven") " — sx-post new routes. The proxy is an SX app with endpoints.")
|
||||||
|
(li (strong "Swarm-aware") " — subscribe to swarm-status changes, "
|
||||||
|
"auto-update upstreams when services scale or move.")
|
||||||
|
(li (strong "Feature flags") " — route traffic based on SX predicates "
|
||||||
|
"(header match, percentage split, user attribute).")
|
||||||
|
(li (strong "A/B testing") " — split traffic via SX expressions, "
|
||||||
|
"not nginx map blocks or Traefik weighted services."))
|
||||||
|
(highlight ";; Traffic splitting — 90% to v2, 10% to v1
|
||||||
|
(route blog.rose-ash.com
|
||||||
|
:upstream (traffic-split
|
||||||
|
{:weight 90 :backend \"blog-v2:8000\"}
|
||||||
|
{:weight 10 :backend \"blog-v1:8000\"})
|
||||||
|
:tls :auto)
|
||||||
|
|
||||||
|
;; Feature flag routing
|
||||||
|
(route blog.rose-ash.com
|
||||||
|
:upstream (if (header? \"X-Beta\" \"true\")
|
||||||
|
\"blog-beta:8000\"
|
||||||
|
\"blog-prod:8000\")
|
||||||
|
:tls :auto)" "lisp"))
|
||||||
|
|
||||||
|
(~doc-section :title "Integration with sx-swarm" :id "integration"
|
||||||
|
(p "sx-proxy and sx-swarm share the same service definitions:")
|
||||||
|
(highlight ";; One source of truth
|
||||||
|
(defservice blog
|
||||||
|
:image \"rose-ash/blog:latest\"
|
||||||
|
:port 8000
|
||||||
|
:domain \"blog.rose-ash.com\"
|
||||||
|
:replicas 2)
|
||||||
|
|
||||||
|
;; sx-swarm reads: image, port, replicas -> container orchestration
|
||||||
|
;; sx-proxy reads: port, domain -> route generation
|
||||||
|
;; sx-ci reads: image -> build pipeline
|
||||||
|
;; sx-forge reads: domain -> webhook URLs
|
||||||
|
|
||||||
|
;; Everything derived from the same definition.
|
||||||
|
;; Change it once, everything updates." "lisp")
|
||||||
|
(p "This is the point of using one language for everything. "
|
||||||
|
"The proxy, the orchestrator, the CI, and the forge all consume "
|
||||||
|
"the same SX service definitions. No YAML-to-Caddyfile-to-Dockerfile translation."))
|
||||||
|
|
||||||
|
(~doc-section :title "Implementation Path" :id "implementation"
|
||||||
|
(ol :class "space-y-3 text-stone-600 list-decimal pl-5"
|
||||||
|
(li (strong "Phase 1: Config compiler") " — SX route definitions compiled to Caddy JSON API. "
|
||||||
|
"Use Caddy as the runtime, SX as the config language.")
|
||||||
|
(li (strong "Phase 2: Middleware macros") " — defmacro for common middleware patterns. "
|
||||||
|
"Chain composition. Apply to route groups.")
|
||||||
|
(li (strong "Phase 3: Swarm integration") " — auto-generate routes from sx-swarm service defs. "
|
||||||
|
"Hot reload on swarm state changes.")
|
||||||
|
(li (strong "Phase 4: Dynamic routing") " — traffic splitting, feature flags, A/B testing. "
|
||||||
|
"SX predicates evaluated per-request.")
|
||||||
|
(li (strong "Phase 5: Native proxy") " — replace Caddy with SX-native HTTP proxy. "
|
||||||
|
"Event loop in the host language, routing logic in SX. "
|
||||||
|
"Like phase 5 of sx-swarm: the ambitious endgame."))
|
||||||
|
(p "Phase 1-3 are practical today — Caddy's JSON API is a clean compilation target. "
|
||||||
|
"The SX layer adds macros, composition, and integration that Caddyfile lacks."))))
|
||||||
171
sx/sx/plans/sx-swarm.sx
Normal file
171
sx/sx/plans/sx-swarm.sx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
;; sx-swarm — SX-based Container Orchestration
|
||||||
|
;; Plan: Docker Swarm management where stack definitions, service configs,
|
||||||
|
;; and deployment logic are SX s-expressions manipulated by macros.
|
||||||
|
|
||||||
|
(defcomp ~plan-sx-swarm-content ()
|
||||||
|
(~doc-page :title "sx-swarm: Container Orchestration in SX"
|
||||||
|
|
||||||
|
(~doc-section :title "Vision" :id "vision"
|
||||||
|
(p "Replace docker-compose.yml and Docker Swarm stack files with SX. "
|
||||||
|
"Service definitions are defcomps. Environment configs are macros that expand "
|
||||||
|
"differently per target (dev, staging, production). Deployments are SX pipelines "
|
||||||
|
"executed by sx-ci. The entire infrastructure is the same language as the application.")
|
||||||
|
(p "YAML is a data format pretending to be a configuration language. "
|
||||||
|
"It has no functions, no macros, no composition, no type checking. "
|
||||||
|
"SX has all of these. A service definition is a value. A deployment is a function. "
|
||||||
|
"An environment override is a macro."))
|
||||||
|
|
||||||
|
(~doc-section :title "Why Not YAML" :id "why-not-yaml"
|
||||||
|
(p "Docker Compose and Swarm stack files share a fundamental problem: "
|
||||||
|
"they're static data with ad-hoc templating bolted on. "
|
||||||
|
"Variable substitution (${VAR}), extension fields (x-), YAML anchors (&/*) — "
|
||||||
|
"all workarounds for not having a real language.")
|
||||||
|
(ul :class "space-y-2 text-stone-600 list-disc pl-5"
|
||||||
|
(li "No functions — can't abstract repeated patterns")
|
||||||
|
(li "No macros — can't generate config at definition time")
|
||||||
|
(li "No conditionals — can't branch on environment")
|
||||||
|
(li "No types — a typo in an environment variable is a runtime error")
|
||||||
|
(li "No composition — merging files is fragile and order-dependent")
|
||||||
|
(li "No verification — can't check constraints before deploy"))
|
||||||
|
(p "SX solves all of these because it's a programming language, not a data format."))
|
||||||
|
|
||||||
|
(~doc-section :title "Service Definitions" :id "services"
|
||||||
|
(p "Services defined as SX, with macros for common patterns:")
|
||||||
|
(highlight ";; Base service macro — shared defaults
|
||||||
|
(defmacro defservice (name &rest body)
|
||||||
|
`(define ,name
|
||||||
|
(merge-service
|
||||||
|
{:restart-policy {:condition :on-failure :max-attempts 3}
|
||||||
|
:logging {:driver :json-file :options {:max-size \"10m\"}}
|
||||||
|
:networks (list \"internal\")}
|
||||||
|
(dict ,@body))))
|
||||||
|
|
||||||
|
;; A concrete service
|
||||||
|
(defservice blog-service
|
||||||
|
:image \"rose-ash/blog:latest\"
|
||||||
|
:ports (list \"8001:8000\")
|
||||||
|
:environment (env-for :blog)
|
||||||
|
:volumes (list \"./blog:/app/blog:ro\")
|
||||||
|
:depends-on (list :postgres :redis)
|
||||||
|
:deploy {:replicas 2
|
||||||
|
:update-config {:parallelism 1 :delay \"10s\"}})" "lisp")
|
||||||
|
(p "The " (code "defservice") " macro injects shared defaults. "
|
||||||
|
"Every service gets restart policy, logging, and network config for free. "
|
||||||
|
"Override any field by specifying it in the body."))
|
||||||
|
|
||||||
|
(~doc-section :title "Environment Macros" :id "environments"
|
||||||
|
(p "Environment-specific config via macros, not file merging:")
|
||||||
|
(highlight ";; Environment macro — expands differently per target
|
||||||
|
(defmacro env-for (service)
|
||||||
|
(let ((base (service-env-base service))
|
||||||
|
(target (current-deploy-target)))
|
||||||
|
(case target
|
||||||
|
:dev (merge base
|
||||||
|
{:DEBUG \"true\"
|
||||||
|
:DATABASE_URL (str \"postgresql://\" service \"_dev/\" service)})
|
||||||
|
:staging (merge base
|
||||||
|
{:DATABASE_URL (from-secret (str service \"-db-staging\"))})
|
||||||
|
:production (merge base
|
||||||
|
{:DATABASE_URL (from-secret (str service \"-db-prod\"))
|
||||||
|
:SENTRY_DSN (from-secret \"sentry-dsn\")}))))
|
||||||
|
|
||||||
|
;; Volume mounts differ per environment
|
||||||
|
(defmacro dev-volumes (service)
|
||||||
|
`(list ,(str \"./\" service \":/app/\" service)
|
||||||
|
,(str \"./shared:/app/shared\")
|
||||||
|
\"./dev.sh:/app/dev.sh:ro\"))
|
||||||
|
|
||||||
|
;; Production: no bind mounts, just named volumes
|
||||||
|
(defmacro prod-volumes (service)
|
||||||
|
`(list ,(str service \"-data:/data\")))" "lisp")
|
||||||
|
(p "No more docker-compose.yml + docker-compose.dev.yml + docker-compose.prod.yml. "
|
||||||
|
"One definition, macros handle the rest."))
|
||||||
|
|
||||||
|
(~doc-section :title "Stack Composition" :id "composition"
|
||||||
|
(p "Stacks compose like functions:")
|
||||||
|
(highlight ";; Infrastructure services shared across all stacks
|
||||||
|
(define infra-services
|
||||||
|
(list
|
||||||
|
(defservice postgres
|
||||||
|
:image \"postgres:16\"
|
||||||
|
:volumes (list \"pg-data:/var/lib/postgresql/data\")
|
||||||
|
:environment {:POSTGRES_PASSWORD (from-secret \"pg-pass\")})
|
||||||
|
(defservice redis
|
||||||
|
:image \"redis:7-alpine\"
|
||||||
|
:volumes (list \"redis-data:/data\"))
|
||||||
|
(defservice pgbouncer
|
||||||
|
:image \"edoburu/pgbouncer\"
|
||||||
|
:depends-on (list :postgres))))
|
||||||
|
|
||||||
|
;; Full stack = infra + app services
|
||||||
|
(define rose-ash-stack
|
||||||
|
(stack :name \"rose-ash\"
|
||||||
|
:services (concat infra-services app-services)
|
||||||
|
:networks (list
|
||||||
|
(network :name \"internal\" :driver :overlay :internal true)
|
||||||
|
(network :name \"public\" :driver :overlay))
|
||||||
|
:volumes (list
|
||||||
|
(volume :name \"pg-data\" :driver :local)
|
||||||
|
(volume :name \"redis-data\" :driver :local))))" "lisp"))
|
||||||
|
|
||||||
|
(~doc-section :title "Deploy as SX Pipeline" :id "deploy"
|
||||||
|
(p "Deployment is an sx-ci pipeline, not a shell script:")
|
||||||
|
(highlight "(define deploy-pipeline
|
||||||
|
(pipeline :name \"deploy\"
|
||||||
|
(stage :build
|
||||||
|
(map (fn (svc)
|
||||||
|
(docker-build
|
||||||
|
:context (str \"./\" (get svc \"name\"))
|
||||||
|
:tag (str \"rose-ash/\" (get svc \"name\") \":\" (git-sha))))
|
||||||
|
(changed-services)))
|
||||||
|
(stage :push
|
||||||
|
(map (fn (svc)
|
||||||
|
(docker-push (service-image svc)))
|
||||||
|
(changed-services)))
|
||||||
|
(stage :deploy
|
||||||
|
(swarm-deploy rose-ash-stack
|
||||||
|
:target (current-deploy-target)
|
||||||
|
:rolling true
|
||||||
|
:health-check-interval \"5s\"))))" "lisp"))
|
||||||
|
|
||||||
|
(~doc-section :title "Swarm Operations as IO" :id "swarm-ops"
|
||||||
|
(p "Swarm management exposed as SX IO primitives:")
|
||||||
|
(highlight ";; boundary.sx additions
|
||||||
|
(io swarm-deploy (stack &key target rolling) dict)
|
||||||
|
(io swarm-status (&key stack service) list)
|
||||||
|
(io swarm-scale (service replicas) dict)
|
||||||
|
(io swarm-rollback (service) dict)
|
||||||
|
(io swarm-logs (service &key since tail follow) list)
|
||||||
|
(io swarm-inspect (service) dict)
|
||||||
|
(io swarm-nodes () list)
|
||||||
|
(io swarm-secrets (&key create delete) dict)
|
||||||
|
(io docker-build (&key context tag dockerfile) dict)
|
||||||
|
(io docker-push (image) dict)" "lisp")
|
||||||
|
(p "These compose with sx-ci and the forge — push to forge triggers CI, "
|
||||||
|
"CI runs tests, tests pass, deploy pipeline executes, swarm updates."))
|
||||||
|
|
||||||
|
(~doc-section :title "Health and Monitoring" :id "monitoring"
|
||||||
|
(p "Service health as live SX components:")
|
||||||
|
(ul :class "space-y-2 text-stone-600 list-disc pl-5"
|
||||||
|
(li (strong "Dashboard") " — defcomp rendering swarm-status, auto-refreshing via sx-get polling")
|
||||||
|
(li (strong "Log viewer") " — swarm-logs streamed via SSE, rendered as SX")
|
||||||
|
(li (strong "Alerts") " — SX predicates on service state, notifications via sx-activity")
|
||||||
|
(li (strong "Rollback") " — one-click rollback via swarm-rollback IO primitive"))
|
||||||
|
(p "The monitoring dashboard is an SX page like any other. "
|
||||||
|
"No Grafana, no separate monitoring stack. Same language, same renderer, same platform."))
|
||||||
|
|
||||||
|
(~doc-section :title "Implementation Path" :id "implementation"
|
||||||
|
(ol :class "space-y-3 text-stone-600 list-decimal pl-5"
|
||||||
|
(li (strong "Phase 1: Stack definition") " — SX data structures for services, networks, volumes. "
|
||||||
|
"Compiler to docker-compose.yml / docker stack deploy format.")
|
||||||
|
(li (strong "Phase 2: Environment macros") " — defmacro for env-for, dev-volumes, prod-volumes. "
|
||||||
|
"Single source file, multiple targets.")
|
||||||
|
(li (strong "Phase 3: Deploy pipeline") " — sx-ci integration. Build, push, deploy as SX stages.")
|
||||||
|
(li (strong "Phase 4: Swarm IO") " — boundary primitives wrapping Docker API. "
|
||||||
|
"Dashboard and log viewer components.")
|
||||||
|
(li (strong "Phase 5: Native orchestration") " — replace Docker Swarm entirely. "
|
||||||
|
"SX-native container scheduling, networking, service discovery. "
|
||||||
|
"The ultimate goal: no Docker, no Swarm — just SX managing containers directly."))
|
||||||
|
(p "Phase 1-3 are pragmatic — compile SX to existing Docker tooling. "
|
||||||
|
"Phase 5 is ambitious — replace Docker tooling with SX. "
|
||||||
|
"The architecture supports both because the abstraction boundary is clean."))))
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
(defcomp ~doc-page (&key title &rest children)
|
(defcomp ~doc-page (&key title &rest children)
|
||||||
(div :class "max-w-4xl mx-auto px-6 py-8"
|
(div :class "max-w-4xl mx-auto px-6 py-8"
|
||||||
(h1 :class "text-4xl font-bold text-stone-900 mb-8" title)
|
(h1 :class "text-4xl font-bold text-stone-900 mb-8 text-center" title)
|
||||||
(div :class "prose prose-stone max-w-none space-y-6" children)))
|
(div :class "prose prose-stone max-w-none space-y-6" children)))
|
||||||
|
|
||||||
(defcomp ~doc-section (&key title id &rest children)
|
(defcomp ~doc-section (&key title id &rest children)
|
||||||
|
|||||||
@@ -513,6 +513,9 @@
|
|||||||
"sx-ci" (~plan-sx-ci-content)
|
"sx-ci" (~plan-sx-ci-content)
|
||||||
"live-streaming" (~plan-live-streaming-content)
|
"live-streaming" (~plan-live-streaming-content)
|
||||||
"sx-web-platform" (~plan-sx-web-platform-content)
|
"sx-web-platform" (~plan-sx-web-platform-content)
|
||||||
|
"sx-forge" (~plan-sx-forge-content)
|
||||||
|
"sx-swarm" (~plan-sx-swarm-content)
|
||||||
|
"sx-proxy" (~plan-sx-proxy-content)
|
||||||
:else (~plans-index-content))))
|
:else (~plans-index-content))))
|
||||||
|
|
||||||
;; ---------------------------------------------------------------------------
|
;; ---------------------------------------------------------------------------
|
||||||
|
|||||||
70
test-sx-web/Dockerfile
Normal file
70
test-sx-web/Dockerfile
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM python:3.11-slim AS base
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONPATH=/app \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
APP_PORT=8000 \
|
||||||
|
APP_MODULE=app:app
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY shared/requirements.txt ./requirements.txt
|
||||||
|
RUN pip install -r requirements.txt && \
|
||||||
|
pip install pytest pytest-json-report
|
||||||
|
|
||||||
|
# Shared code (including tests)
|
||||||
|
COPY shared/ ./shared/
|
||||||
|
|
||||||
|
# App code — test dashboard (from test-sx-web/ in build context)
|
||||||
|
COPY test-sx-web/ ./test-app-tmp/
|
||||||
|
RUN cp -r test-app-tmp/app.py test-app-tmp/path_setup.py \
|
||||||
|
test-app-tmp/bp test-app-tmp/sx test-app-tmp/services \
|
||||||
|
test-app-tmp/runner.py test-app-tmp/__init__.py ./ 2>/dev/null || true && \
|
||||||
|
([ -d test-app-tmp/sxc ] && cp -r test-app-tmp/sxc ./ || true) && \
|
||||||
|
rm -rf test-app-tmp
|
||||||
|
|
||||||
|
# sx_docs app code (for its tests, if any)
|
||||||
|
COPY sx/ ./sx-app-tmp/
|
||||||
|
RUN mkdir -p sx_docs && \
|
||||||
|
([ -d sx-app-tmp/tests ] && cp -r sx-app-tmp/tests sx_docs/ || true) && \
|
||||||
|
([ -d sx-app-tmp/sx ] && cp -r sx-app-tmp/sx sx_docs/sx || true) && \
|
||||||
|
([ -d sx-app-tmp/sxc ] && cp -r sx-app-tmp/sxc sx_docs/sxc || true) && \
|
||||||
|
([ -d sx-app-tmp/content ] && cp -r sx-app-tmp/content sx_docs/content || true) && \
|
||||||
|
([ -f sx-app-tmp/__init__.py ] && cp sx-app-tmp/__init__.py sx_docs/ || true) && \
|
||||||
|
rm -rf sx-app-tmp
|
||||||
|
|
||||||
|
# Sibling models for cross-domain SQLAlchemy imports
|
||||||
|
COPY blog/__init__.py ./blog/__init__.py
|
||||||
|
COPY blog/models/ ./blog/models/
|
||||||
|
COPY market/__init__.py ./market/__init__.py
|
||||||
|
COPY market/models/ ./market/models/
|
||||||
|
COPY cart/__init__.py ./cart/__init__.py
|
||||||
|
COPY cart/models/ ./cart/models/
|
||||||
|
COPY events/__init__.py ./events/__init__.py
|
||||||
|
COPY events/models/ ./events/models/
|
||||||
|
COPY federation/__init__.py ./federation/__init__.py
|
||||||
|
COPY federation/models/ ./federation/models/
|
||||||
|
COPY account/__init__.py ./account/__init__.py
|
||||||
|
COPY account/models/ ./account/models/
|
||||||
|
COPY relations/__init__.py ./relations/__init__.py
|
||||||
|
COPY relations/models/ ./relations/models/
|
||||||
|
COPY likes/__init__.py ./likes/__init__.py
|
||||||
|
COPY likes/models/ ./likes/models/
|
||||||
|
COPY orders/__init__.py ./orders/__init__.py
|
||||||
|
COPY orders/models/ ./orders/models/
|
||||||
|
|
||||||
|
COPY test-sx-web/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
|
RUN useradd -m -u 10001 appuser && chown -R appuser:appuser /app
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE ${APP_PORT}
|
||||||
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
0
test-sx-web/__init__.py
Normal file
0
test-sx-web/__init__.py
Normal file
46
test-sx-web/app.py
Normal file
46
test-sx-web/app.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import path_setup # noqa: F401
|
||||||
|
|
||||||
|
from bp import register_dashboard
|
||||||
|
from services import register_domain_services
|
||||||
|
|
||||||
|
|
||||||
|
async def test_context() -> dict:
|
||||||
|
"""Test app context processor — standalone, no cross-service fragments."""
|
||||||
|
from shared.infrastructure.context import base_context
|
||||||
|
ctx = await base_context()
|
||||||
|
ctx["menu_items"] = []
|
||||||
|
ctx["cart_mini"] = ""
|
||||||
|
ctx["auth_menu"] = ""
|
||||||
|
ctx["nav_tree"] = ""
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> "Quart":
|
||||||
|
from shared.infrastructure.factory import create_base_app
|
||||||
|
app = create_base_app(
|
||||||
|
"test",
|
||||||
|
context_fn=test_context,
|
||||||
|
domain_services_fn=register_domain_services,
|
||||||
|
no_oauth=True,
|
||||||
|
no_db=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load .sx components
|
||||||
|
import os
|
||||||
|
from shared.sx.jinja_bridge import load_service_components
|
||||||
|
load_service_components(os.path.dirname(__file__))
|
||||||
|
|
||||||
|
app.register_blueprint(register_dashboard(url_prefix="/"))
|
||||||
|
|
||||||
|
# Run tests on startup
|
||||||
|
@app.before_serving
|
||||||
|
async def _run_tests_on_startup():
|
||||||
|
import runner
|
||||||
|
import asyncio
|
||||||
|
asyncio.create_task(runner.run_tests())
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
1
test-sx-web/bp/__init__.py
Normal file
1
test-sx-web/bp/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from .dashboard.routes import register as register_dashboard
|
||||||
0
test-sx-web/bp/dashboard/__init__.py
Normal file
0
test-sx-web/bp/dashboard/__init__.py
Normal file
102
test-sx-web/bp/dashboard/routes.py
Normal file
102
test-sx-web/bp/dashboard/routes.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""Test dashboard routes."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from quart import Blueprint, Response, make_response, request
|
||||||
|
|
||||||
|
|
||||||
|
def register(url_prefix: str = "/") -> Blueprint:
|
||||||
|
bp = Blueprint("dashboard", __name__, url_prefix=url_prefix)
|
||||||
|
|
||||||
|
@bp.get("/")
|
||||||
|
async def index():
|
||||||
|
"""Full page dashboard with last results."""
|
||||||
|
from shared.sx.page import get_template_context
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
from sxc.pages.renders import render_dashboard_page_sx
|
||||||
|
import runner
|
||||||
|
|
||||||
|
ctx = await get_template_context()
|
||||||
|
result = runner.get_results()
|
||||||
|
running = runner.is_running()
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
active_filter = request.args.get("filter")
|
||||||
|
active_service = request.args.get("service")
|
||||||
|
|
||||||
|
html = await render_dashboard_page_sx(
|
||||||
|
ctx, result, running, csrf,
|
||||||
|
active_filter=active_filter,
|
||||||
|
active_service=active_service,
|
||||||
|
)
|
||||||
|
return await make_response(html, 200)
|
||||||
|
|
||||||
|
@bp.post("/run")
|
||||||
|
async def run():
|
||||||
|
"""Trigger a test run, redirect to /."""
|
||||||
|
import runner
|
||||||
|
|
||||||
|
if not runner.is_running():
|
||||||
|
asyncio.create_task(runner.run_tests())
|
||||||
|
|
||||||
|
# HX-Redirect for HTMX, regular redirect for non-HTMX
|
||||||
|
if request.headers.get("SX-Request") or request.headers.get("HX-Request"):
|
||||||
|
resp = Response("", status=200)
|
||||||
|
resp.headers["HX-Redirect"] = "/"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
from quart import redirect as qredirect
|
||||||
|
return qredirect("/")
|
||||||
|
|
||||||
|
@bp.get("/test/<path:nodeid>")
|
||||||
|
async def test_detail(nodeid: str):
|
||||||
|
"""Test detail view — full page or sx wire format."""
|
||||||
|
import runner
|
||||||
|
|
||||||
|
test = runner.get_test(nodeid)
|
||||||
|
if not test:
|
||||||
|
from quart import abort
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
is_htmx = bool(request.headers.get("SX-Request") or request.headers.get("HX-Request"))
|
||||||
|
|
||||||
|
if is_htmx:
|
||||||
|
# S-expression wire format — sx.js renders client-side
|
||||||
|
from shared.sx.helpers import sx_response
|
||||||
|
from sxc.pages.renders import test_detail_sx
|
||||||
|
return sx_response(await test_detail_sx(test))
|
||||||
|
|
||||||
|
# Full page render (direct navigation / refresh)
|
||||||
|
from shared.sx.page import get_template_context
|
||||||
|
from sxc.pages.renders import render_test_detail_page_sx
|
||||||
|
|
||||||
|
ctx = await get_template_context()
|
||||||
|
html = await render_test_detail_page_sx(ctx, test)
|
||||||
|
return await make_response(html, 200)
|
||||||
|
|
||||||
|
@bp.get("/results")
|
||||||
|
async def results():
|
||||||
|
"""HTMX partial — poll target for results table."""
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
from sxc.pages.renders import render_results_partial_sx
|
||||||
|
import runner
|
||||||
|
|
||||||
|
result = runner.get_results()
|
||||||
|
running = runner.is_running()
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
active_filter = request.args.get("filter")
|
||||||
|
active_service = request.args.get("service")
|
||||||
|
|
||||||
|
html = await render_results_partial_sx(
|
||||||
|
result, running, csrf,
|
||||||
|
active_filter=active_filter,
|
||||||
|
active_service=active_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = Response(html, status=200, content_type="text/html")
|
||||||
|
# If still running, tell HTMX to keep polling
|
||||||
|
if running:
|
||||||
|
resp.headers["HX-Trigger-After-Swap"] = "test-still-running"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
return bp
|
||||||
25
test-sx-web/entrypoint.sh
Executable file
25
test-sx-web/entrypoint.sh
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# No database — skip DB wait and migrations
|
||||||
|
|
||||||
|
# Clear Redis page cache on deploy
|
||||||
|
if [[ -n "${REDIS_URL:-}" && "${REDIS_URL}" != "no" ]]; then
|
||||||
|
python3 -c "
|
||||||
|
import redis, os
|
||||||
|
r = redis.from_url(os.environ['REDIS_URL'])
|
||||||
|
r.flushdb()
|
||||||
|
" || echo "Redis flush failed (non-fatal), continuing..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start the app
|
||||||
|
RELOAD_FLAG=""
|
||||||
|
if [[ "${RELOAD:-}" == "true" ]]; then
|
||||||
|
RELOAD_FLAG="--reload"
|
||||||
|
python3 -m shared.dev_watcher &
|
||||||
|
fi
|
||||||
|
PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-app:app}" \
|
||||||
|
--bind 0.0.0.0:${PORT:-8000} \
|
||||||
|
--workers ${WORKERS:-1} \
|
||||||
|
--keep-alive 75 \
|
||||||
|
${RELOAD_FLAG}
|
||||||
9
test-sx-web/path_setup.py
Normal file
9
test-sx-web/path_setup.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
_app_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_project_root = os.path.dirname(_app_dir)
|
||||||
|
|
||||||
|
for _p in (_project_root, _app_dir):
|
||||||
|
if _p not in sys.path:
|
||||||
|
sys.path.insert(0, _p)
|
||||||
213
test-sx-web/runner.py
Normal file
213
test-sx-web/runner.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"""Pytest subprocess runner + in-memory result storage."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from collections import OrderedDict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# In-memory state
|
||||||
|
_last_result: dict | None = None
|
||||||
|
_running: bool = False
|
||||||
|
|
||||||
|
# Each service group runs in its own pytest subprocess with its own PYTHONPATH
|
||||||
|
_SERVICE_GROUPS: list[dict] = [
|
||||||
|
{"name": "shared", "dirs": ["shared/tests/", "shared/sx/tests/"],
|
||||||
|
"pythonpath": None},
|
||||||
|
{"name": "sx_docs", "dirs": ["sx_docs/tests/"],
|
||||||
|
"pythonpath": "/app/sx_docs"},
|
||||||
|
]
|
||||||
|
|
||||||
|
_SERVICE_ORDER = [g["name"] for g in _SERVICE_GROUPS]
|
||||||
|
_REPORT_PATH = "/tmp/test-report-{}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_report(path: str) -> tuple[list[dict], dict]:
|
||||||
|
"""Parse a pytest-json-report file."""
|
||||||
|
rp = Path(path)
|
||||||
|
if not rp.exists():
|
||||||
|
return [], {}
|
||||||
|
try:
|
||||||
|
report = json.loads(rp.read_text())
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return [], {}
|
||||||
|
|
||||||
|
summary = report.get("summary", {})
|
||||||
|
tests_raw = report.get("tests", [])
|
||||||
|
|
||||||
|
tests = []
|
||||||
|
for t in tests_raw:
|
||||||
|
tests.append({
|
||||||
|
"nodeid": t.get("nodeid", ""),
|
||||||
|
"outcome": t.get("outcome", "unknown"),
|
||||||
|
"duration": round(t.get("duration", 0), 4),
|
||||||
|
"longrepr": (t.get("call", {}) or {}).get("longrepr", ""),
|
||||||
|
})
|
||||||
|
return tests, summary
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_group(group: dict) -> tuple[list[dict], dict, str]:
|
||||||
|
"""Run pytest for a single service group."""
|
||||||
|
existing = [d for d in group["dirs"] if Path(f"/app/{d}").is_dir()]
|
||||||
|
if not existing:
|
||||||
|
return [], {}, ""
|
||||||
|
|
||||||
|
report_file = _REPORT_PATH.format(group["name"])
|
||||||
|
cmd = [
|
||||||
|
"python3", "-m", "pytest",
|
||||||
|
*existing,
|
||||||
|
"--json-report",
|
||||||
|
f"--json-report-file={report_file}",
|
||||||
|
"-q",
|
||||||
|
"--tb=short",
|
||||||
|
]
|
||||||
|
env = {**os.environ}
|
||||||
|
if group["pythonpath"]:
|
||||||
|
env["PYTHONPATH"] = group["pythonpath"] + ":" + env.get("PYTHONPATH", "")
|
||||||
|
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
|
cwd="/app",
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
stdout, _ = await proc.communicate()
|
||||||
|
stdout_str = (stdout or b"").decode("utf-8", errors="replace")
|
||||||
|
tests, summary = _parse_report(report_file)
|
||||||
|
return tests, summary, stdout_str
|
||||||
|
|
||||||
|
|
||||||
|
async def run_tests() -> dict:
|
||||||
|
"""Run pytest in subprocess, parse JSON report, store results."""
|
||||||
|
global _last_result, _running
|
||||||
|
|
||||||
|
if _running:
|
||||||
|
return {"status": "already_running"}
|
||||||
|
|
||||||
|
_running = True
|
||||||
|
started_at = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
tasks = [_run_group(g) for g in _SERVICE_GROUPS]
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
all_tests: list[dict] = []
|
||||||
|
total_passed = total_failed = total_errors = total_skipped = total_count = 0
|
||||||
|
all_stdout: list[str] = []
|
||||||
|
|
||||||
|
for i, res in enumerate(results):
|
||||||
|
if isinstance(res, Exception):
|
||||||
|
log.error("Group %s failed: %s", _SERVICE_GROUPS[i]["name"], res)
|
||||||
|
continue
|
||||||
|
tests, summary, stdout_str = res
|
||||||
|
all_tests.extend(tests)
|
||||||
|
total_passed += summary.get("passed", 0)
|
||||||
|
total_failed += summary.get("failed", 0)
|
||||||
|
total_errors += summary.get("error", 0)
|
||||||
|
total_skipped += summary.get("skipped", 0)
|
||||||
|
total_count += summary.get("total", len(tests))
|
||||||
|
if stdout_str.strip():
|
||||||
|
all_stdout.append(stdout_str)
|
||||||
|
|
||||||
|
finished_at = time.time()
|
||||||
|
status = "failed" if total_failed > 0 or total_errors > 0 else "passed"
|
||||||
|
|
||||||
|
_last_result = {
|
||||||
|
"status": status,
|
||||||
|
"started_at": started_at,
|
||||||
|
"finished_at": finished_at,
|
||||||
|
"duration": round(finished_at - started_at, 2),
|
||||||
|
"passed": total_passed,
|
||||||
|
"failed": total_failed,
|
||||||
|
"errors": total_errors,
|
||||||
|
"skipped": total_skipped,
|
||||||
|
"total": total_count,
|
||||||
|
"tests": all_tests,
|
||||||
|
"stdout": "\n".join(all_stdout)[-5000:],
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Test run complete: %s (%d passed, %d failed, %d errors, %.1fs)",
|
||||||
|
status, total_passed, total_failed, total_errors,
|
||||||
|
_last_result["duration"],
|
||||||
|
)
|
||||||
|
return _last_result
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
log.exception("Test run failed")
|
||||||
|
finished_at = time.time()
|
||||||
|
_last_result = {
|
||||||
|
"status": "error",
|
||||||
|
"started_at": started_at,
|
||||||
|
"finished_at": finished_at,
|
||||||
|
"duration": round(finished_at - started_at, 2),
|
||||||
|
"passed": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"errors": 1,
|
||||||
|
"skipped": 0,
|
||||||
|
"total": 0,
|
||||||
|
"tests": [],
|
||||||
|
"stdout": "",
|
||||||
|
}
|
||||||
|
return _last_result
|
||||||
|
finally:
|
||||||
|
_running = False
|
||||||
|
|
||||||
|
|
||||||
|
def get_results() -> dict | None:
|
||||||
|
"""Return last run results."""
|
||||||
|
return _last_result
|
||||||
|
|
||||||
|
|
||||||
|
def get_test(nodeid: str) -> dict | None:
|
||||||
|
"""Look up a single test by nodeid."""
|
||||||
|
if not _last_result:
|
||||||
|
return None
|
||||||
|
for t in _last_result["tests"]:
|
||||||
|
if t["nodeid"] == nodeid:
|
||||||
|
return t
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_running() -> bool:
|
||||||
|
"""Check if tests are currently running."""
|
||||||
|
return _running
|
||||||
|
|
||||||
|
|
||||||
|
def _service_from_nodeid(nodeid: str) -> str:
|
||||||
|
"""Extract service name from a test nodeid."""
|
||||||
|
parts = nodeid.split("/")
|
||||||
|
return parts[0] if len(parts) >= 2 else "other"
|
||||||
|
|
||||||
|
|
||||||
|
def group_tests_by_service(tests: list[dict]) -> list[dict]:
|
||||||
|
"""Group tests into ordered sections by service."""
|
||||||
|
buckets: dict[str, list[dict]] = OrderedDict()
|
||||||
|
for svc in _SERVICE_ORDER:
|
||||||
|
buckets[svc] = []
|
||||||
|
for t in tests:
|
||||||
|
svc = _service_from_nodeid(t["nodeid"])
|
||||||
|
if svc not in buckets:
|
||||||
|
buckets[svc] = []
|
||||||
|
buckets[svc].append(t)
|
||||||
|
|
||||||
|
sections = []
|
||||||
|
for svc, svc_tests in buckets.items():
|
||||||
|
if not svc_tests:
|
||||||
|
continue
|
||||||
|
sections.append({
|
||||||
|
"service": svc,
|
||||||
|
"tests": svc_tests,
|
||||||
|
"total": len(svc_tests),
|
||||||
|
"passed": sum(1 for t in svc_tests if t["outcome"] == "passed"),
|
||||||
|
"failed": sum(1 for t in svc_tests if t["outcome"] == "failed"),
|
||||||
|
"errors": sum(1 for t in svc_tests if t["outcome"] == "error"),
|
||||||
|
"skipped": sum(1 for t in svc_tests if t["outcome"] == "skipped"),
|
||||||
|
})
|
||||||
|
return sections
|
||||||
6
test-sx-web/services/__init__.py
Normal file
6
test-sx-web/services/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Test app service registration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def register_domain_services() -> None:
|
||||||
|
"""Register services for the test app (none needed)."""
|
||||||
95
test-sx-web/sx/components.sx
Normal file
95
test-sx-web/sx/components.sx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
;; Test service composition defcomps — replaces Python string concatenation
|
||||||
|
;; in test/sxc/pages/__init__.py.
|
||||||
|
|
||||||
|
;; Service filter nav links
|
||||||
|
(defcomp ~test-service-nav (&key services active-service)
|
||||||
|
(<>
|
||||||
|
(~nav-link :href "/" :label "all"
|
||||||
|
:is-selected (if (not active-service) "true" nil)
|
||||||
|
:select-colours "aria-selected:bg-sky-200 aria-selected:text-sky-900")
|
||||||
|
(map (lambda (svc)
|
||||||
|
(~nav-link :href (str "/?service=" svc) :label svc
|
||||||
|
:is-selected (if (= active-service svc) "true" nil)
|
||||||
|
:select-colours "aria-selected:bg-sky-200 aria-selected:text-sky-900"))
|
||||||
|
services)))
|
||||||
|
|
||||||
|
;; Test header menu row
|
||||||
|
(defcomp ~test-header-row (&key services active-service)
|
||||||
|
(~menu-row-sx :id "test-row" :level 1 :colour "sky"
|
||||||
|
:link-href "/" :link-label "Tests" :icon "fa fa-flask"
|
||||||
|
:nav (~test-service-nav :services services :active-service active-service)
|
||||||
|
:child-id "test-header-child"))
|
||||||
|
|
||||||
|
;; Layout: full page header stack (standalone — no root-header-auto)
|
||||||
|
(defcomp ~test-layout-full (&key services active-service)
|
||||||
|
(~test-header-row :services services :active-service active-service))
|
||||||
|
|
||||||
|
;; Map test dicts to test-row components
|
||||||
|
(defcomp ~test-rows (&key tests)
|
||||||
|
(<> (map (lambda (t)
|
||||||
|
(~test-row
|
||||||
|
:nodeid (get t "nodeid")
|
||||||
|
:outcome (get t "outcome")
|
||||||
|
:duration (str (get t "duration"))
|
||||||
|
:longrepr (or (get t "longrepr") "")))
|
||||||
|
tests)))
|
||||||
|
|
||||||
|
;; Grouped test rows with service headers
|
||||||
|
(defcomp ~test-grouped-rows (&key sections)
|
||||||
|
(<> (map (lambda (sec)
|
||||||
|
(<> (~test-service-header
|
||||||
|
:service (get sec "service")
|
||||||
|
:total (str (get sec "total"))
|
||||||
|
:passed (str (get sec "passed"))
|
||||||
|
:failed (str (get sec "failed")))
|
||||||
|
(~test-rows :tests (get sec "tests"))))
|
||||||
|
sections)))
|
||||||
|
|
||||||
|
;; Results partial: conditional rendering based on running/result state
|
||||||
|
(defcomp ~test-results-partial (&key status summary-data tests sections has-failures)
|
||||||
|
(let* ((state (get summary-data "state")))
|
||||||
|
(<>
|
||||||
|
(~test-summary
|
||||||
|
:status (get summary-data "status")
|
||||||
|
:passed (get summary-data "passed")
|
||||||
|
:failed (get summary-data "failed")
|
||||||
|
:errors (get summary-data "errors")
|
||||||
|
:skipped (get summary-data "skipped")
|
||||||
|
:total (get summary-data "total")
|
||||||
|
:duration (get summary-data "duration")
|
||||||
|
:last-run (get summary-data "last_run")
|
||||||
|
:running (get summary-data "running")
|
||||||
|
:csrf (get summary-data "csrf")
|
||||||
|
:active-filter (get summary-data "active_filter"))
|
||||||
|
(cond
|
||||||
|
((= state "running") (~test-running-indicator))
|
||||||
|
((= state "no-results") (~test-no-results))
|
||||||
|
((= state "empty-filtered") (~test-no-results))
|
||||||
|
(true (~test-results-table
|
||||||
|
:rows (~test-grouped-rows :sections sections)
|
||||||
|
:has-failures has-failures))))))
|
||||||
|
|
||||||
|
;; Wrap results in a div with optional HTMX polling
|
||||||
|
(defcomp ~test-results-wrap (&key running inner)
|
||||||
|
(div :id "test-results" :class "space-y-6 p-4"
|
||||||
|
:sx-get (when running "/results")
|
||||||
|
:sx-trigger (when running "every 2s")
|
||||||
|
:sx-swap (when running "outerHTML")
|
||||||
|
inner))
|
||||||
|
|
||||||
|
;; Test detail section wrapper
|
||||||
|
(defcomp ~test-detail-section (&key test)
|
||||||
|
(section :id "main-panel"
|
||||||
|
:class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
|
||||||
|
(~test-detail
|
||||||
|
:nodeid (get test "nodeid")
|
||||||
|
:outcome (get test "outcome")
|
||||||
|
:duration (str (get test "duration"))
|
||||||
|
:longrepr (or (get test "longrepr") ""))))
|
||||||
|
|
||||||
|
;; Detail page header stack (standalone — no root-header-auto)
|
||||||
|
(defcomp ~test-detail-layout-full (&key services test-nodeid test-label)
|
||||||
|
(<> (~test-header-row :services services)
|
||||||
|
(~menu-row-sx :id "test-detail-row" :level 2 :colour "sky"
|
||||||
|
:link-href (str "/test/" test-nodeid)
|
||||||
|
:link-label test-label)))
|
||||||
148
test-sx-web/sx/dashboard.sx
Normal file
148
test-sx-web/sx/dashboard.sx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
;; Test dashboard components
|
||||||
|
|
||||||
|
(defcomp ~test-status-badge (&key status)
|
||||||
|
(span :class (str "inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium "
|
||||||
|
(if (= status "running") "border-amber-300 bg-amber-50 text-amber-700 animate-pulse"
|
||||||
|
(if (= status "passed") "border-emerald-300 bg-emerald-50 text-emerald-700"
|
||||||
|
(if (= status "failed") "border-rose-300 bg-rose-50 text-rose-700"
|
||||||
|
"border-stone-300 bg-stone-50 text-stone-700"))))
|
||||||
|
status))
|
||||||
|
|
||||||
|
(defcomp ~test-run-button (&key running csrf)
|
||||||
|
(form :method "POST" :action "/run" :class "inline"
|
||||||
|
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||||
|
(button :type "submit"
|
||||||
|
:class (str "rounded bg-stone-800 px-4 py-2 text-sm font-medium text-white hover:bg-stone-700 "
|
||||||
|
"disabled:opacity-50 disabled:cursor-not-allowed transition-colors")
|
||||||
|
:disabled (if running "true" nil)
|
||||||
|
(if running "Running..." "Run Tests"))))
|
||||||
|
|
||||||
|
(defcomp ~test-filter-card (&key href label count colour-border colour-bg colour-text active)
|
||||||
|
(a :href href
|
||||||
|
:sx-get href
|
||||||
|
:sx-target "#main-panel"
|
||||||
|
:sx-select "#main-panel"
|
||||||
|
:sx-swap "outerHTML"
|
||||||
|
:sx-push-url "true"
|
||||||
|
:class (str "block rounded border p-3 text-center transition-colors no-underline hover:opacity-80 "
|
||||||
|
colour-border " " colour-bg " "
|
||||||
|
(if active "ring-2 ring-offset-1 ring-stone-500 " ""))
|
||||||
|
(div :class (str "text-3xl font-bold " colour-text) count)
|
||||||
|
(div :class (str "text-sm " colour-text) label)))
|
||||||
|
|
||||||
|
(defcomp ~test-summary (&key status passed failed errors skipped total duration last-run running csrf active-filter)
|
||||||
|
(div :class "space-y-4"
|
||||||
|
(div :class "flex items-center justify-between flex-wrap gap-3"
|
||||||
|
(div :class "flex items-center gap-3"
|
||||||
|
(h2 :class "text-2xl font-semibold text-stone-800" "Test Results")
|
||||||
|
(when status (~test-status-badge :status status)))
|
||||||
|
(~test-run-button :running running :csrf csrf))
|
||||||
|
(when status
|
||||||
|
(div :class "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3"
|
||||||
|
(~test-filter-card :href "/" :label "Total" :count total
|
||||||
|
:colour-border "border-stone-200" :colour-bg "bg-white"
|
||||||
|
:colour-text "text-stone-800"
|
||||||
|
:active (if (= active-filter nil) "true" nil))
|
||||||
|
(~test-filter-card :href "/?filter=passed" :label "Passed" :count passed
|
||||||
|
:colour-border "border-emerald-200" :colour-bg "bg-emerald-50"
|
||||||
|
:colour-text "text-emerald-700"
|
||||||
|
:active (if (= active-filter "passed") "true" nil))
|
||||||
|
(~test-filter-card :href "/?filter=failed" :label "Failed" :count failed
|
||||||
|
:colour-border "border-rose-200" :colour-bg "bg-rose-50"
|
||||||
|
:colour-text "text-rose-700"
|
||||||
|
:active (if (= active-filter "failed") "true" nil))
|
||||||
|
(~test-filter-card :href "/?filter=errors" :label "Errors" :count errors
|
||||||
|
:colour-border "border-orange-200" :colour-bg "bg-orange-50"
|
||||||
|
:colour-text "text-orange-700"
|
||||||
|
:active (if (= active-filter "errors") "true" nil))
|
||||||
|
(~test-filter-card :href "/?filter=skipped" :label "Skipped" :count skipped
|
||||||
|
:colour-border "border-sky-200" :colour-bg "bg-sky-50"
|
||||||
|
:colour-text "text-sky-700"
|
||||||
|
:active (if (= active-filter "skipped") "true" nil))
|
||||||
|
(~test-filter-card :href "/" :label "Duration" :count (str duration "s")
|
||||||
|
:colour-border "border-stone-200" :colour-bg "bg-white"
|
||||||
|
:colour-text "text-stone-800" :active nil))
|
||||||
|
(div :class "text-sm text-stone-400" (str "Last run: " last-run)))))
|
||||||
|
|
||||||
|
(defcomp ~test-service-header (&key service total passed failed)
|
||||||
|
(tr :class "border-b-2 border-stone-300 bg-stone-100"
|
||||||
|
(td :class "px-3 py-2 text-sm font-bold text-stone-700" :colspan "4"
|
||||||
|
(span service)
|
||||||
|
(span :class "ml-2 text-xs font-normal text-stone-500"
|
||||||
|
(str total " tests, " passed " passed, " failed " failed")))))
|
||||||
|
|
||||||
|
(defcomp ~test-row (&key nodeid outcome duration longrepr)
|
||||||
|
(tr :class (str "border-b border-stone-100 "
|
||||||
|
(if (= outcome "passed") "bg-white"
|
||||||
|
(if (= outcome "failed") "bg-rose-50"
|
||||||
|
(if (= outcome "skipped") "bg-sky-50"
|
||||||
|
"bg-orange-50"))))
|
||||||
|
(td :class "px-3 py-2 text-sm font-mono text-stone-700 max-w-0 truncate" :title nodeid
|
||||||
|
(a :href (str "/test/" nodeid)
|
||||||
|
:sx-get (str "/test/" nodeid)
|
||||||
|
:sx-target "#main-panel"
|
||||||
|
:sx-select "#main-panel"
|
||||||
|
:sx-swap "outerHTML"
|
||||||
|
:sx-push-url "true"
|
||||||
|
:class "hover:underline hover:text-sky-600"
|
||||||
|
nodeid))
|
||||||
|
(td :class "px-3 py-2 text-center"
|
||||||
|
(span :class (str "inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium "
|
||||||
|
(if (= outcome "passed") "border-emerald-300 bg-emerald-50 text-emerald-700"
|
||||||
|
(if (= outcome "failed") "border-rose-300 bg-rose-50 text-rose-700"
|
||||||
|
(if (= outcome "skipped") "border-sky-300 bg-sky-50 text-sky-700"
|
||||||
|
"border-orange-300 bg-orange-50 text-orange-700"))))
|
||||||
|
outcome))
|
||||||
|
(td :class "px-3 py-2 text-right text-sm text-stone-500 tabular-nums" (str duration "s"))
|
||||||
|
(td :class "px-3 py-2 text-sm text-rose-600 font-mono max-w-xs truncate" :title longrepr
|
||||||
|
(when longrepr longrepr))))
|
||||||
|
|
||||||
|
(defcomp ~test-results-table (&key rows has-failures)
|
||||||
|
(div :class "overflow-x-auto rounded border border-stone-200 bg-white"
|
||||||
|
(table :class "w-full text-left"
|
||||||
|
(thead
|
||||||
|
(tr :class "border-b border-stone-200 bg-stone-50"
|
||||||
|
(th :class "px-3 py-2 text-sm font-medium text-stone-600" "Test")
|
||||||
|
(th :class "px-3 py-2 text-xs font-medium text-stone-600 text-center w-24" "Status")
|
||||||
|
(th :class "px-3 py-2 text-xs font-medium text-stone-600 text-right w-20" "Time")
|
||||||
|
(th :class "px-3 py-2 text-xs font-medium text-stone-600 w-48" "Error")))
|
||||||
|
(tbody (when rows rows)))))
|
||||||
|
|
||||||
|
(defcomp ~test-running-indicator ()
|
||||||
|
(div :class "flex items-center justify-center py-12 text-stone-500"
|
||||||
|
(div :class "flex items-center gap-3"
|
||||||
|
(div :class "animate-spin h-6 w-6 border-2 border-stone-300 border-t-stone-600 rounded-full")
|
||||||
|
(span :class "text-sm" "Running tests..."))))
|
||||||
|
|
||||||
|
(defcomp ~test-no-results ()
|
||||||
|
(div :class "flex items-center justify-center py-12 text-stone-400"
|
||||||
|
(div :class "text-center"
|
||||||
|
(div :class "text-4xl mb-2" "?")
|
||||||
|
(div :class "text-sm" "No test results yet. Click Run Tests to start."))))
|
||||||
|
|
||||||
|
(defcomp ~test-detail (&key nodeid outcome duration longrepr)
|
||||||
|
(div :class "space-y-6 p-4"
|
||||||
|
(div :class "flex items-center gap-3"
|
||||||
|
(a :href "/"
|
||||||
|
:sx-get "/"
|
||||||
|
:sx-target "#main-panel"
|
||||||
|
:sx-select "#main-panel"
|
||||||
|
:sx-swap "outerHTML"
|
||||||
|
:sx-push-url "true"
|
||||||
|
:class "text-sky-600 hover:text-sky-800 text-sm"
|
||||||
|
"← Back to results")
|
||||||
|
(span :class (str "inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium "
|
||||||
|
(if (= outcome "passed") "border-emerald-300 bg-emerald-50 text-emerald-700"
|
||||||
|
(if (= outcome "failed") "border-rose-300 bg-rose-50 text-rose-700"
|
||||||
|
(if (= outcome "skipped") "border-sky-300 bg-sky-50 text-sky-700"
|
||||||
|
"border-orange-300 bg-orange-50 text-orange-700"))))
|
||||||
|
outcome))
|
||||||
|
(div :class "rounded border border-stone-200 bg-white p-4 space-y-3"
|
||||||
|
(h2 :class "text-lg font-mono font-semibold text-stone-800 break-all" nodeid)
|
||||||
|
(div :class "flex gap-4 text-sm text-stone-500"
|
||||||
|
(span (str "Duration: " duration "s")))
|
||||||
|
(when longrepr
|
||||||
|
(div :class "mt-4"
|
||||||
|
(h3 :class "text-sm font-semibold text-rose-700 mb-2" "Error Output")
|
||||||
|
(pre :class "bg-stone-50 border border-stone-200 rounded p-3 text-xs text-stone-700 overflow-x-auto whitespace-pre-wrap"
|
||||||
|
longrepr))))))
|
||||||
0
test-sx-web/sxc/__init__.py
Normal file
0
test-sx-web/sxc/__init__.py
Normal file
13
test-sx-web/sxc/pages/__init__.py
Normal file
13
test-sx-web/sxc/pages/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""Test service defpage setup."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def setup_test_pages() -> None:
|
||||||
|
"""Load test page definitions."""
|
||||||
|
_load_test_page_files()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_test_page_files() -> None:
|
||||||
|
import os
|
||||||
|
from shared.sx.pages import load_page_dir
|
||||||
|
load_page_dir(os.path.dirname(__file__), "test")
|
||||||
145
test-sx-web/sxc/pages/renders.py
Normal file
145
test-sx-web/sxc/pages/renders.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""Test service render functions — called from bp routes."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from shared.sx.jinja_bridge import load_service_components
|
||||||
|
from shared.sx.helpers import sx_call, render_to_sx_with_env, full_page_sx
|
||||||
|
|
||||||
|
# Load test-specific .sx components at import time
|
||||||
|
load_service_components(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||||
|
|
||||||
|
|
||||||
|
def _format_time(ts: float | None) -> str:
|
||||||
|
"""Format a unix timestamp for display."""
|
||||||
|
if not ts:
|
||||||
|
return "never"
|
||||||
|
return datetime.fromtimestamp(ts).strftime("%-d %b %Y, %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
_FILTER_MAP = {
|
||||||
|
"passed": "passed",
|
||||||
|
"failed": "failed",
|
||||||
|
"errors": "error",
|
||||||
|
"skipped": "skipped",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_tests(tests: list[dict], active_filter: str | None,
|
||||||
|
active_service: str | None) -> list[dict]:
|
||||||
|
"""Filter tests by outcome and/or service."""
|
||||||
|
from runner import _service_from_nodeid
|
||||||
|
filtered = tests
|
||||||
|
if active_filter and active_filter in _FILTER_MAP:
|
||||||
|
outcome = _FILTER_MAP[active_filter]
|
||||||
|
filtered = [t for t in filtered if t["outcome"] == outcome]
|
||||||
|
if active_service:
|
||||||
|
filtered = [t for t in filtered if _service_from_nodeid(t["nodeid"]) == active_service]
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def _service_list() -> list[str]:
|
||||||
|
from runner import _SERVICE_ORDER
|
||||||
|
return list(_SERVICE_ORDER)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_summary_data(result: dict | None, running: bool, csrf: str,
|
||||||
|
active_filter: str | None) -> dict:
|
||||||
|
"""Prepare summary data dict for the ~test-results-partial defcomp."""
|
||||||
|
if running and not result:
|
||||||
|
return dict(state="running", status="running", passed="0", failed="0",
|
||||||
|
errors="0", skipped="0", total="0", duration="...",
|
||||||
|
last_run="in progress", running=True, csrf=csrf,
|
||||||
|
active_filter=active_filter)
|
||||||
|
if not result:
|
||||||
|
return dict(state="no-results", status=None, passed="0", failed="0",
|
||||||
|
errors="0", skipped="0", total="0", duration="0",
|
||||||
|
last_run="never", running=running, csrf=csrf,
|
||||||
|
active_filter=active_filter)
|
||||||
|
status = "running" if running else result["status"]
|
||||||
|
return dict(
|
||||||
|
state="running" if running else "has-results",
|
||||||
|
status=status,
|
||||||
|
passed=str(result["passed"]),
|
||||||
|
failed=str(result["failed"]),
|
||||||
|
errors=str(result["errors"]),
|
||||||
|
skipped=str(result.get("skipped", 0)),
|
||||||
|
total=str(result["total"]),
|
||||||
|
duration=str(result["duration"]),
|
||||||
|
last_run=_format_time(result["finished_at"]) if not running else "in progress",
|
||||||
|
running=running, csrf=csrf,
|
||||||
|
active_filter=active_filter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_detail_sx(test: dict) -> str:
|
||||||
|
"""Return s-expression wire format for a test detail view."""
|
||||||
|
return sx_call("test-detail-section", test=test)
|
||||||
|
|
||||||
|
|
||||||
|
async def render_dashboard_page_sx(ctx: dict, result: dict | None,
|
||||||
|
running: bool, csrf: str,
|
||||||
|
active_filter: str | None = None,
|
||||||
|
active_service: str | None = None) -> str:
|
||||||
|
"""Full page: test dashboard (sx wire format)."""
|
||||||
|
from runner import group_tests_by_service
|
||||||
|
|
||||||
|
summary_data = _build_summary_data(result, running, csrf, active_filter)
|
||||||
|
sections = []
|
||||||
|
has_failures = "false"
|
||||||
|
if result and not running:
|
||||||
|
tests = _filter_tests(result.get("tests", []), active_filter, active_service)
|
||||||
|
if tests:
|
||||||
|
sections = group_tests_by_service(tests)
|
||||||
|
has_failures = str(result["failed"] > 0 or result["errors"] > 0).lower()
|
||||||
|
else:
|
||||||
|
summary_data["state"] = "empty-filtered"
|
||||||
|
|
||||||
|
inner = sx_call("test-results-partial",
|
||||||
|
summary_data=summary_data, sections=sections, has_failures=has_failures)
|
||||||
|
content = sx_call("test-results-wrap", running=running, inner=inner)
|
||||||
|
hdr = await render_to_sx_with_env("test-layout-full", {},
|
||||||
|
services=_service_list(),
|
||||||
|
active_service=active_service,
|
||||||
|
)
|
||||||
|
return await full_page_sx(ctx, header_rows=hdr, content=content)
|
||||||
|
|
||||||
|
|
||||||
|
async def render_results_partial_sx(result: dict | None, running: bool,
|
||||||
|
csrf: str,
|
||||||
|
active_filter: str | None = None,
|
||||||
|
active_service: str | None = None) -> str:
|
||||||
|
"""HTMX partial: results section (sx wire format)."""
|
||||||
|
from runner import group_tests_by_service
|
||||||
|
|
||||||
|
summary_data = _build_summary_data(result, running, csrf, active_filter)
|
||||||
|
sections = []
|
||||||
|
has_failures = "false"
|
||||||
|
if result and not running:
|
||||||
|
tests = _filter_tests(result.get("tests", []), active_filter, active_service)
|
||||||
|
if tests:
|
||||||
|
sections = group_tests_by_service(tests)
|
||||||
|
has_failures = str(result["failed"] > 0 or result["errors"] > 0).lower()
|
||||||
|
else:
|
||||||
|
summary_data["state"] = "empty-filtered"
|
||||||
|
|
||||||
|
inner = sx_call("test-results-partial",
|
||||||
|
summary_data=summary_data, sections=sections, has_failures=has_failures)
|
||||||
|
return sx_call("test-results-wrap", running=running, inner=inner)
|
||||||
|
|
||||||
|
|
||||||
|
async def render_test_detail_page_sx(ctx: dict, test: dict) -> str:
|
||||||
|
"""Full page: test detail (sx wire format)."""
|
||||||
|
hdr = await render_to_sx_with_env("test-detail-layout-full", {},
|
||||||
|
services=_service_list(),
|
||||||
|
test_nodeid=test["nodeid"],
|
||||||
|
test_label=test["nodeid"].rsplit("::", 1)[-1],
|
||||||
|
)
|
||||||
|
content = sx_call("test-detail",
|
||||||
|
nodeid=test["nodeid"],
|
||||||
|
outcome=test["outcome"],
|
||||||
|
duration=str(test["duration"]),
|
||||||
|
longrepr=test.get("longrepr", ""),
|
||||||
|
)
|
||||||
|
return await full_page_sx(ctx, header_rows=hdr, content=content)
|
||||||
184
test-sx-web/test-signals.js
Normal file
184
test-sx-web/test-signals.js
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const src = fs.readFileSync('shared/static/scripts/sx-browser.js', 'utf8');
|
||||||
|
|
||||||
|
global.window = { addEventListener: function(){}, history: { pushState:function(){}, replaceState:function(){} }, location: { pathname:'/', search:'' } };
|
||||||
|
global.document = {
|
||||||
|
readyState: 'complete',
|
||||||
|
createElement: function() { return { setAttribute:function(){}, appendChild:function(){}, style:{}, addEventListener:function(){} }; },
|
||||||
|
createDocumentFragment: function() { return { appendChild:function(){}, childNodes:[] }; },
|
||||||
|
createTextNode: function(t) { return { textContent: t, nodeType: 3 }; },
|
||||||
|
createElementNS: function() { return { setAttribute:function(){}, appendChild:function(){} }; },
|
||||||
|
head: { querySelector:function(){return null;}, appendChild:function(){} },
|
||||||
|
body: { querySelectorAll:function(){return [];}, querySelector:function(){return null;}, getAttribute:function(){return null;} },
|
||||||
|
querySelectorAll: function(){return [];},
|
||||||
|
querySelector: function(){return null;},
|
||||||
|
addEventListener: function(){},
|
||||||
|
cookie: ''
|
||||||
|
};
|
||||||
|
global.navigator = { serviceWorker: { register: function() { return { then: function(f) { return { catch: function(){} }; } }; } } };
|
||||||
|
global.localStorage = { getItem:function(){return null;}, setItem:function(){}, removeItem:function(){} };
|
||||||
|
global.CustomEvent = function(n,o){ this.type=n; this.detail=(o||{}).detail; };
|
||||||
|
global.MutationObserver = function(){ return { observe:function(){} }; };
|
||||||
|
global.HTMLElement = function(){};
|
||||||
|
global.EventSource = function(){};
|
||||||
|
|
||||||
|
// Prevent module.exports detection so it sets global.Sx
|
||||||
|
var _module = module;
|
||||||
|
module = undefined;
|
||||||
|
var patchedSrc = fs.readFileSync('/tmp/sx-browser-patched.js', 'utf8');
|
||||||
|
eval(patchedSrc);
|
||||||
|
module = _module;
|
||||||
|
var Sx = global.Sx;
|
||||||
|
console.log('Sx loaded:', Sx ? true : false);
|
||||||
|
|
||||||
|
var env = Object.create(Sx.componentEnv);
|
||||||
|
|
||||||
|
// Test 1: computed with SX lambda
|
||||||
|
try {
|
||||||
|
var r = Sx.eval(Sx.parse('(let ((a (signal 3)) (b (computed (fn () (* 2 (deref a)))))) (deref b))')[0], env);
|
||||||
|
console.log('TEST 1 computed:', r, '(expected 6)', r === 6 ? 'PASS' : 'FAIL');
|
||||||
|
} catch(e) {
|
||||||
|
console.log('TEST 1 computed ERROR:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: swap! with dec
|
||||||
|
try {
|
||||||
|
var r2 = Sx.eval(Sx.parse('(let ((s (signal 10))) (swap! s dec) (deref s))')[0], env);
|
||||||
|
console.log('TEST 2 swap!:', r2, '(expected 9)', r2 === 9 ? 'PASS' : 'FAIL');
|
||||||
|
} catch(e) {
|
||||||
|
console.log('TEST 2 swap! ERROR:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: effect with SX lambda
|
||||||
|
try {
|
||||||
|
var r3 = Sx.eval(Sx.parse('(let ((s (signal 0)) (log (list)) (_e (effect (fn () (append! log (deref s)))))) (reset! s 1) (first log))')[0], env);
|
||||||
|
console.log('TEST 3 effect:', r3, '(expected 0)', r3 === 0 ? 'PASS' : 'FAIL');
|
||||||
|
} catch(e) {
|
||||||
|
console.log('TEST 3 effect ERROR:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: effect re-runs on change
|
||||||
|
try {
|
||||||
|
var r4 = Sx.eval(Sx.parse('(let ((s (signal 0)) (log (list)) (_e (effect (fn () (append! log (deref s)))))) (reset! s 5) (last log))')[0], env);
|
||||||
|
console.log('TEST 4 effect re-run:', r4, '(expected 5)', r4 === 5 ? 'PASS' : 'FAIL');
|
||||||
|
} catch(e) {
|
||||||
|
console.log('TEST 4 effect re-run ERROR:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: on-click handler lambda
|
||||||
|
try {
|
||||||
|
var r5 = Sx.eval(Sx.parse('(let ((s (signal 0)) (f (fn (e) (swap! s inc)))) (f nil) (f nil) (deref s))')[0], env);
|
||||||
|
console.log('TEST 5 lambda call:', r5, '(expected 2)', r5 === 2 ? 'PASS' : 'FAIL');
|
||||||
|
} catch(e) {
|
||||||
|
console.log('TEST 5 lambda call ERROR:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: defisland + renderToDom simulation
|
||||||
|
try {
|
||||||
|
// Override DOM stubs with tracking versions
|
||||||
|
var listenCalls = [];
|
||||||
|
// Override methods on the EXISTING document object (don't replace)
|
||||||
|
document.createElement = function(tag) {
|
||||||
|
var el = {
|
||||||
|
tagName: tag,
|
||||||
|
childNodes: [],
|
||||||
|
style: {},
|
||||||
|
setAttribute: function(k, v) { this['_attr_'+k] = v; },
|
||||||
|
getAttribute: function(k) { return this['_attr_'+k]; },
|
||||||
|
appendChild: function(c) { this.childNodes.push(c); return c; },
|
||||||
|
addEventListener: function(name, fn) { listenCalls.push({el: this, name: name, fn: fn}); },
|
||||||
|
removeEventListener: function() {},
|
||||||
|
textContent: '',
|
||||||
|
nodeType: 1
|
||||||
|
};
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
document.createTextNode = function(t) { return { textContent: String(t), nodeType: 3 }; };
|
||||||
|
document.createDocumentFragment = function() {
|
||||||
|
return {
|
||||||
|
childNodes: [],
|
||||||
|
appendChild: function(c) { if (c) this.childNodes.push(c); return c; },
|
||||||
|
nodeType: 11
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
var env2 = Object.create(Sx.componentEnv);
|
||||||
|
|
||||||
|
// Define island
|
||||||
|
Sx.eval(Sx.parse('(defisland ~test-click (&key initial) (let ((count (signal (or initial 0)))) (div (button :on-click (fn (e) (swap! count inc)) "Click") (span (deref count)))))')[0], env2);
|
||||||
|
|
||||||
|
// Patch domListen to trace calls
|
||||||
|
// The domListen is inside the IIFE closure, so we can't patch it directly.
|
||||||
|
// Instead, patch addEventListener on elements by wrapping createElement
|
||||||
|
var origCE = document.createElement;
|
||||||
|
document.createElement = function(tag) {
|
||||||
|
var el = origCE(tag);
|
||||||
|
var origAEL = el.addEventListener;
|
||||||
|
el.addEventListener = function(name, fn) {
|
||||||
|
console.log(' addEventListener called:', tag, name);
|
||||||
|
listenCalls.push({el: el, name: name, fn: fn});
|
||||||
|
origAEL.call(el, name, fn);
|
||||||
|
};
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Temporarily hook into isCallable and domListen for debugging
|
||||||
|
// We can't patch the closure vars directly, but we can test via eval
|
||||||
|
var testLambda = Sx.eval(Sx.parse('(fn (e) e)')[0], env2);
|
||||||
|
console.log(' lambda type:', typeof testLambda, testLambda ? testLambda._lambda : 'no _lambda');
|
||||||
|
console.log(' Sx.isTruthy(lambda):', Sx.isTruthy(testLambda));
|
||||||
|
|
||||||
|
// Test what render-dom-element does with on-click
|
||||||
|
// Simpler test: just a button with on-click, no island
|
||||||
|
var parsed = Sx.parse('(button :on-click (fn (e) nil) "test")')[0];
|
||||||
|
console.log(' parsed expr:', JSON.stringify(parsed, function(k,v) {
|
||||||
|
if (v && v._sym) return 'SYM:' + v.name;
|
||||||
|
if (v && v._kw) return 'KW:' + v.name;
|
||||||
|
return v;
|
||||||
|
}));
|
||||||
|
var simpleTest = Sx.renderToDom(parsed, env2, null);
|
||||||
|
console.log(' simple button rendered:', simpleTest ? simpleTest.tagName : 'null');
|
||||||
|
console.log(' listeners after simple:', listenCalls.length);
|
||||||
|
|
||||||
|
// Render it
|
||||||
|
var dom = Sx.renderToDom(Sx.parse('(~test-click :initial 0)')[0], env2, null);
|
||||||
|
console.log('TEST 6 island rendered:', dom ? 'yes' : 'no');
|
||||||
|
console.log(' listeners attached:', listenCalls.length);
|
||||||
|
|
||||||
|
if (listenCalls.length > 0) {
|
||||||
|
var clickHandler = listenCalls[0];
|
||||||
|
console.log(' event name:', clickHandler.name);
|
||||||
|
// Simulate click
|
||||||
|
clickHandler.fn({type: 'click'});
|
||||||
|
// Find the span's text node
|
||||||
|
var container = dom; // div[data-sx-island]
|
||||||
|
var innerDiv = container.childNodes[0]; // the body div
|
||||||
|
console.log(' container tag:', container.tagName);
|
||||||
|
console.log(' container children:', container.childNodes.length);
|
||||||
|
if (innerDiv && innerDiv.childNodes) {
|
||||||
|
console.log(' innerDiv tag:', innerDiv.tagName);
|
||||||
|
console.log(' innerDiv children:', innerDiv.childNodes.length);
|
||||||
|
var button = innerDiv.childNodes[0];
|
||||||
|
var span = innerDiv.childNodes[1];
|
||||||
|
console.log(' button tag:', button ? button.tagName : 'none');
|
||||||
|
console.log(' span tag:', span ? span.tagName : 'none');
|
||||||
|
if (span && span.childNodes && span.childNodes[0]) {
|
||||||
|
console.log(' span text BEFORE click effect:', span.childNodes[0].textContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Click again
|
||||||
|
clickHandler.fn({type: 'click'});
|
||||||
|
if (innerDiv && innerDiv.childNodes) {
|
||||||
|
var span2 = innerDiv.childNodes[1];
|
||||||
|
if (span2 && span2.childNodes && span2.childNodes[0]) {
|
||||||
|
console.log(' span text AFTER 2 clicks:', span2.childNodes[0].textContent);
|
||||||
|
console.log(' TEST 6:', span2.childNodes[0].textContent === '2' ? 'PASS' : 'FAIL (expected 2, got ' + span2.childNodes[0].textContent + ')');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(' TEST 6: FAIL (no listeners attached)');
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.log('TEST 6 island ERROR:', e.message);
|
||||||
|
console.log(e.stack.split('\n').slice(0,5).join('\n'));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user