From 31a6e708fca8cbcfeb439341a58a32c950846187 Mon Sep 17 00:00:00 2001 From: giles Date: Mon, 9 Mar 2026 18:07:23 +0000 Subject: [PATCH] more plans --- shared/static/scripts/sx-browser.js | 4 +- shared/sx/jinja_bridge.py | 4 + shared/sx/templates/shell.sx | 62 ++++++++ sx/sx/docs-content.sx | 18 ++- sx/sx/layouts.sx | 13 +- sx/sx/nav-data.sx | 8 +- sx/sx/plans/sx-forge.sx | 157 ++++++++++++++++++++ sx/sx/plans/sx-proxy.sx | 186 +++++++++++++++++++++++ sx/sx/plans/sx-swarm.sx | 171 +++++++++++++++++++++ sx/sxc/docs.sx | 2 +- sx/sxc/pages/docs.sx | 3 + test-sx-web/Dockerfile | 70 +++++++++ test-sx-web/__init__.py | 0 test-sx-web/app.py | 46 ++++++ test-sx-web/bp/__init__.py | 1 + test-sx-web/bp/dashboard/__init__.py | 0 test-sx-web/bp/dashboard/routes.py | 102 +++++++++++++ test-sx-web/entrypoint.sh | 25 ++++ test-sx-web/path_setup.py | 9 ++ test-sx-web/runner.py | 213 +++++++++++++++++++++++++++ test-sx-web/services/__init__.py | 6 + test-sx-web/sx/components.sx | 95 ++++++++++++ test-sx-web/sx/dashboard.sx | 148 +++++++++++++++++++ test-sx-web/sxc/__init__.py | 0 test-sx-web/sxc/pages/__init__.py | 13 ++ test-sx-web/sxc/pages/renders.py | 145 ++++++++++++++++++ test-sx-web/test-signals.js | 184 +++++++++++++++++++++++ 27 files changed, 1670 insertions(+), 15 deletions(-) create mode 100644 shared/sx/templates/shell.sx create mode 100644 sx/sx/plans/sx-forge.sx create mode 100644 sx/sx/plans/sx-proxy.sx create mode 100644 sx/sx/plans/sx-swarm.sx create mode 100644 test-sx-web/Dockerfile create mode 100644 test-sx-web/__init__.py create mode 100644 test-sx-web/app.py create mode 100644 test-sx-web/bp/__init__.py create mode 100644 test-sx-web/bp/dashboard/__init__.py create mode 100644 test-sx-web/bp/dashboard/routes.py create mode 100755 test-sx-web/entrypoint.sh create mode 100644 test-sx-web/path_setup.py create mode 100644 test-sx-web/runner.py create mode 100644 test-sx-web/services/__init__.py create mode 100644 test-sx-web/sx/components.sx create mode 100644 test-sx-web/sx/dashboard.sx create mode 100644 test-sx-web/sxc/__init__.py create mode 100644 test-sx-web/sxc/pages/__init__.py create mode 100644 test-sx-web/sxc/pages/renders.py create mode 100644 test-sx-web/test-signals.js diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index ed28552..da369ec 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -6904,7 +6904,9 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { _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 --- diff --git a/shared/sx/jinja_bridge.py b/shared/sx/jinja_bridge.py index c64c8d3..86ca398 100644 --- a/shared/sx/jinja_bridge.py +++ b/shared/sx/jinja_bridge.py @@ -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})") elif isinstance(val, Component): 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) if val.has_children: param_strs.extend(["&rest", "children"]) diff --git a/shared/sx/templates/shell.sx b/shared/sx/templates/shell.sx new file mode 100644 index 0000000..0a75c89 --- /dev/null +++ b/shared/sx/templates/shell.sx @@ -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: +;; - (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! "") + (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)))))) diff --git a/sx/sx/docs-content.sx b/sx/sx/docs-content.sx index 8b2ddad..cb01f76 100644 --- a/sx/sx/docs-content.sx +++ b/sx/sx/docs-content.sx @@ -1,11 +1,19 @@ ;; Docs page content — fully self-contained, no Python intermediaries (defcomp ~sx-home-content () - (div :id "main-content" - (~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")) - (~sx-philosophy) - (~sx-how-it-works) - (~sx-credits))) + (div :id "main-content" :class "max-w-3xl mx-auto px-4 py-6" + (highlight "(defcomp ~sx-header () + (a :href \"/\" + :sx-get \"/\" :sx-target \"#main-panel\" + :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\" + \"()\") + (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 () (~doc-page :title "Introduction" diff --git a/sx/sx/layouts.sx b/sx/sx/layouts.sx index d8d43bf..70f171d 100644 --- a/sx/sx/layouts.sx +++ b/sx/sx/layouts.sx @@ -11,12 +11,11 @@ ;; Logo + tagline + copyright — always shown at top of page area. (defcomp ~sx-header () - (div :class "max-w-3xl mx-auto px-4 pt-8 pb-4 text-center" - (a :href "/" - :sx-get "/" :sx-target "#main-panel" :sx-select "#main-panel" - :sx-swap "outerHTML" :sx-push-url "true" - :class "block mb-2" - (span :class "text-4xl font-bold font-mono text-violet-700" "()")) + (a :href "/" + :sx-get "/" :sx-target "#main-panel" :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" "()") (p :class "text-lg text-stone-500 mb-1" "Framework free reactive hypermedia") (p :class "text-xs text-stone-400" @@ -70,7 +69,7 @@ ;; 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 "/")))) (<> (div :id "sx-nav" :class "mb-6" diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx index ba7f426..4b3006f 100644 --- a/sx/sx/nav-data.sx +++ b/sx/sx/nav-data.sx @@ -188,7 +188,13 @@ (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.") (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 (dict :label "Overview" :href "/reactive-islands/" diff --git a/sx/sx/plans/sx-forge.sx b/sx/sx/plans/sx-forge.sx new file mode 100644 index 0000000..49d3f72 --- /dev/null +++ b/sx/sx/plans/sx-forge.sx @@ -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."))))) diff --git a/sx/sx/plans/sx-proxy.sx b/sx/sx/plans/sx-proxy.sx new file mode 100644 index 0000000..070a85d --- /dev/null +++ b/sx/sx/plans/sx-proxy.sx @@ -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.")))) diff --git a/sx/sx/plans/sx-swarm.sx b/sx/sx/plans/sx-swarm.sx new file mode 100644 index 0000000..44a671b --- /dev/null +++ b/sx/sx/plans/sx-swarm.sx @@ -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.")))) diff --git a/sx/sxc/docs.sx b/sx/sxc/docs.sx index f1a9c17..1598c70 100644 --- a/sx/sxc/docs.sx +++ b/sx/sxc/docs.sx @@ -2,7 +2,7 @@ (defcomp ~doc-page (&key title &rest children) (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))) (defcomp ~doc-section (&key title id &rest children) diff --git a/sx/sxc/pages/docs.sx b/sx/sxc/pages/docs.sx index 93be11f..4070a49 100644 --- a/sx/sxc/pages/docs.sx +++ b/sx/sxc/pages/docs.sx @@ -513,6 +513,9 @@ "sx-ci" (~plan-sx-ci-content) "live-streaming" (~plan-live-streaming-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)))) ;; --------------------------------------------------------------------------- diff --git a/test-sx-web/Dockerfile b/test-sx-web/Dockerfile new file mode 100644 index 0000000..966b906 --- /dev/null +++ b/test-sx-web/Dockerfile @@ -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"] diff --git a/test-sx-web/__init__.py b/test-sx-web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test-sx-web/app.py b/test-sx-web/app.py new file mode 100644 index 0000000..7072e1b --- /dev/null +++ b/test-sx-web/app.py @@ -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() diff --git a/test-sx-web/bp/__init__.py b/test-sx-web/bp/__init__.py new file mode 100644 index 0000000..3b38a14 --- /dev/null +++ b/test-sx-web/bp/__init__.py @@ -0,0 +1 @@ +from .dashboard.routes import register as register_dashboard diff --git a/test-sx-web/bp/dashboard/__init__.py b/test-sx-web/bp/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test-sx-web/bp/dashboard/routes.py b/test-sx-web/bp/dashboard/routes.py new file mode 100644 index 0000000..b44a0eb --- /dev/null +++ b/test-sx-web/bp/dashboard/routes.py @@ -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/") + 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 diff --git a/test-sx-web/entrypoint.sh b/test-sx-web/entrypoint.sh new file mode 100755 index 0000000..951752a --- /dev/null +++ b/test-sx-web/entrypoint.sh @@ -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} diff --git a/test-sx-web/path_setup.py b/test-sx-web/path_setup.py new file mode 100644 index 0000000..c7166f7 --- /dev/null +++ b/test-sx-web/path_setup.py @@ -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) diff --git a/test-sx-web/runner.py b/test-sx-web/runner.py new file mode 100644 index 0000000..339f0e8 --- /dev/null +++ b/test-sx-web/runner.py @@ -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 diff --git a/test-sx-web/services/__init__.py b/test-sx-web/services/__init__.py new file mode 100644 index 0000000..329f544 --- /dev/null +++ b/test-sx-web/services/__init__.py @@ -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).""" diff --git a/test-sx-web/sx/components.sx b/test-sx-web/sx/components.sx new file mode 100644 index 0000000..83b38b7 --- /dev/null +++ b/test-sx-web/sx/components.sx @@ -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))) diff --git a/test-sx-web/sx/dashboard.sx b/test-sx-web/sx/dashboard.sx new file mode 100644 index 0000000..6b8aa8b --- /dev/null +++ b/test-sx-web/sx/dashboard.sx @@ -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)))))) diff --git a/test-sx-web/sxc/__init__.py b/test-sx-web/sxc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test-sx-web/sxc/pages/__init__.py b/test-sx-web/sxc/pages/__init__.py new file mode 100644 index 0000000..3d0ba3a --- /dev/null +++ b/test-sx-web/sxc/pages/__init__.py @@ -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") diff --git a/test-sx-web/sxc/pages/renders.py b/test-sx-web/sxc/pages/renders.py new file mode 100644 index 0000000..fe198ad --- /dev/null +++ b/test-sx-web/sxc/pages/renders.py @@ -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) diff --git a/test-sx-web/test-signals.js b/test-sx-web/test-signals.js new file mode 100644 index 0000000..ccd3abd --- /dev/null +++ b/test-sx-web/test-signals.js @@ -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')); +}