From 0fce6934cba3c89b5cb04bf95815e2ed4a625554 Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 25 Mar 2026 00:19:35 +0000 Subject: [PATCH] Use dom-on for event handlers; add CI config and stepper Playwright test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web/orchestration.sx, web/signals.sx: dom-listen → dom-on (trampoline wrapper that resolves TCO thunks from Lambda event handlers) - .gitea/: CI workflow and Dockerfile for automated test runs - tests/playwright/stepper.spec.js: stepper widget smoke test - Remove stale artdag .pyc file Co-Authored-By: Claude Opus 4.6 (1M context) --- .dockerignore | 2 +- .gitea/Dockerfile.test | 84 +++++++++++++ .gitea/run-ci-tests.sh | 115 ++++++++++++++++++ .gitea/workflows/ci.yml | 32 ++++- .../__pycache__/auth.cpython-310.pyc | Bin 6751 -> 0 bytes tests/playwright/stepper.spec.js | 27 ++++ web/orchestration.sx | 4 +- web/signals.sx | 4 +- 8 files changed, 259 insertions(+), 9 deletions(-) create mode 100644 .gitea/Dockerfile.test create mode 100755 .gitea/run-ci-tests.sh delete mode 100644 artdag/common/artdag_common/middleware/__pycache__/auth.cpython-310.pyc create mode 100644 tests/playwright/stepper.spec.js diff --git a/.dockerignore b/.dockerignore index 397600b..b74fb4a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,5 @@ .git -.gitea +.gitea/workflows .env _snapshot docs diff --git a/.gitea/Dockerfile.test b/.gitea/Dockerfile.test new file mode 100644 index 0000000..c6bd071 --- /dev/null +++ b/.gitea/Dockerfile.test @@ -0,0 +1,84 @@ +# syntax=docker/dockerfile:1 +# +# CI test image — Python 3 + Node.js + OCaml 5.2 + dune. +# +# Build chain: +# 1. Compile OCaml from checked-in sx_ref.ml — produces sx_server.exe +# 2. Bootstrap JS (sx-browser.js) — OcamlSync transpiler → JS +# 3. Re-bootstrap OCaml (sx_ref.ml) — OcamlSync transpiler → OCaml +# 4. Recompile OCaml with fresh sx_ref.ml — final native binary +# +# Test suites (run at CMD): +# - JS standard + full tests — Node +# - OCaml spec tests — native binary +# - OCaml bridge integration tests — Python + OCaml subprocess +# +# Usage: +# docker build -f .gitea/Dockerfile.test -t sx-test . +# docker run --rm sx-test + +FROM ocaml/opam:debian-12-ocaml-5.2 + +USER root +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 ca-certificates curl xz-utils \ + && rm -rf /var/lib/apt/lists/* +# Node.js — direct binary (avoids the massive Debian nodejs dep tree) +RUN NODE_VERSION=22.22.1 \ + && ARCH=$(dpkg --print-architecture | sed 's/amd64/x64/;s/arm64/arm64/;s/armhf/armv7l/') \ + && curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${ARCH}.tar.xz" \ + | tar -xJ --strip-components=1 -C /usr/local +USER opam + +# Install dune into the opam switch +RUN opam install dune -y + +# Bake the opam switch PATH into the image so dune/ocamlfind work in RUN +ENV PATH="/home/opam/.opam/5.2/bin:${PATH}" + +WORKDIR /home/opam/project + +# Copy OCaml sources first (changes less often → better caching) +COPY --chown=opam:opam hosts/ocaml/dune-project ./hosts/ocaml/ +COPY --chown=opam:opam hosts/ocaml/lib/ ./hosts/ocaml/lib/ +COPY --chown=opam:opam hosts/ocaml/bin/ ./hosts/ocaml/bin/ + +# Copy spec, web, shared (needed by bootstrappers + tests) +COPY --chown=opam:opam spec/ ./spec/ +COPY --chown=opam:opam web/ ./web/ +COPY --chown=opam:opam shared/sx/ ./shared/sx/ +COPY --chown=opam:opam shared/__init__.py ./shared/__init__.py + +# Copy JS host (bootstrapper + test runner) +COPY --chown=opam:opam hosts/javascript/ ./hosts/javascript/ + +# Copy OCaml host (bootstrapper + transpiler) +COPY --chown=opam:opam hosts/ocaml/bootstrap.py ./hosts/ocaml/bootstrap.py +COPY --chown=opam:opam hosts/ocaml/transpiler.sx ./hosts/ocaml/transpiler.sx + +# Create output directory for JS builds +RUN mkdir -p shared/static/scripts + +# Step 1: Compile OCaml from checked-in sx_ref.ml +# → produces sx_server.exe (needed by both JS and OCaml bootstrappers) +RUN cd hosts/ocaml && dune build + +# Step 2: Bootstrap JS (uses sx_server.exe via OcamlSync) +RUN python3 hosts/javascript/cli.py \ + --output shared/static/scripts/sx-browser.js \ + && python3 hosts/javascript/cli.py \ + --extensions continuations --spec-modules types \ + --output shared/static/scripts/sx-full-test.js + +# Step 3: Re-bootstrap OCaml (transpile current spec → fresh sx_ref.ml) +RUN python3 hosts/ocaml/bootstrap.py \ + --output hosts/ocaml/lib/sx_ref.ml + +# Step 4: Recompile OCaml with freshly bootstrapped sx_ref.ml +RUN cd hosts/ocaml && dune build + +# Default: run all tests +COPY --chown=opam:opam .gitea/run-ci-tests.sh ./run-ci-tests.sh +RUN chmod +x run-ci-tests.sh + +CMD ["./run-ci-tests.sh"] diff --git a/.gitea/run-ci-tests.sh b/.gitea/run-ci-tests.sh new file mode 100755 index 0000000..5dec51f --- /dev/null +++ b/.gitea/run-ci-tests.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# =========================================================================== +# run-ci-tests.sh — CI test runner for SX language suite. +# +# Runs JS + OCaml tests. No Python evaluator (eliminated). +# Exit non-zero if any suite fails. +# =========================================================================== +set -euo pipefail + +FAILURES=() +PASSES=() + +run_suite() { + local name="$1" + shift + echo "" + echo "============================================================" + echo " $name" + echo "============================================================" + if "$@"; then + PASSES+=("$name") + else + FAILURES+=("$name") + fi +} + +# ------------------------------------------------------------------- +# 1. JS standard tests +# ------------------------------------------------------------------- +run_suite "JS standard (spec tests)" \ + node hosts/javascript/run_tests.js + +# ------------------------------------------------------------------- +# 2. JS full tests (continuations + types + VM) +# ------------------------------------------------------------------- +run_suite "JS full (spec + continuations + types + VM)" \ + node hosts/javascript/run_tests.js --full + +# ------------------------------------------------------------------- +# 3. OCaml spec tests +# ------------------------------------------------------------------- +run_suite "OCaml (spec tests)" \ + hosts/ocaml/_build/default/bin/run_tests.exe + +# ------------------------------------------------------------------- +# 4. OCaml bridge integration (custom special forms, web-forms.sx) +# ------------------------------------------------------------------- +run_suite "OCaml bridge — custom special forms + web-forms" \ + python3 -c " +from shared.sx.ocaml_sync import OcamlSync +bridge = OcamlSync() +for f in ['spec/parser.sx', 'spec/render.sx', 'web/adapter-html.sx', 'web/adapter-sx.sx', 'web/web-forms.sx', 'spec/freeze.sx']: + bridge.load(f) +ok = 0; fail = 0 +def check(name, expr, expected=None): + global ok, fail + try: + r = bridge.eval(expr) + if expected is not None and r != expected: + print(f' FAIL: {name}: expected {expected!r}, got {r!r}'); fail += 1 + else: + print(f' PASS: {name}'); ok += 1 + except Exception as e: + print(f' FAIL: {name}: {e}'); fail += 1 + +for form in ['defhandler', 'defquery', 'defaction', 'defpage', 'defrelation', 'defstyle', 'deftype', 'defeffect']: + check(f'{form} registered', f'(has-key? *custom-special-forms* \"{form}\")', 'true') + +check('deftype via eval', '(deftype test-t number)', 'nil') +check('defeffect via eval', '(defeffect test-e)', 'nil') +check('defstyle via eval', '(defstyle my-s \"bold\")', 'bold') +check('defhandler via eval', '(has-key? (defhandler test-h (&key x) x) \"__type\")', 'true') + +check('definition-form-extensions populated', '(> (len *definition-form-extensions*) 0)', 'true') +check('RENDER_HTML_FORMS has defstyle', '(contains? RENDER_HTML_FORMS \"defstyle\")', 'true') + +bridge2 = OcamlSync() +bridge2.eval('(register-special-form! \"shadow-test\" (fn (args env) 42))') +bridge2.load('spec/evaluator.sx') +check('custom form survives evaluator.sx load', + bridge2.eval('(has-key? *custom-special-forms* \"shadow-test\")'), 'true') +bridge2.eval('(register-special-form! \"post-load\" (fn (args env) 99))') +check('custom form callable after evaluator.sx load', + bridge2.eval('(post-load 1)'), '99') + +print(f'\nResults: {ok} passed, {fail} failed') +import sys; sys.exit(1 if fail > 0 else 0) +" + +# ------------------------------------------------------------------- +# Summary +# ------------------------------------------------------------------- +echo "" +echo "============================================================" +echo " CI TEST SUMMARY" +echo "============================================================" +for p in "${PASSES[@]}"; do + echo " PASS: $p" +done +for f in "${FAILURES[@]}"; do + echo " FAIL: $f" +done +echo "============================================================" + +if [ ${#FAILURES[@]} -gt 0 ]; then + echo "" + echo " ${#FAILURES[@]} suite(s) FAILED" + echo "" + exit 1 +else + echo "" + echo " All ${#PASSES[@]} suites passed." + echo "" + exit 0 +fi diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index aa18cf8..a58ac87 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Build and Deploy +name: Test, Build, and Deploy on: push: @@ -10,7 +10,7 @@ env: BUILD_DIR: /root/rose-ash-ci jobs: - build-and-deploy: + test-build-deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -29,12 +29,11 @@ jobs: chmod 600 ~/.ssh/id_rsa ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true - - name: Build and deploy changed apps + - name: Sync CI build directory env: DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} run: | ssh "root@$DEPLOY_HOST" " - # --- Build in isolated CI directory (never touch dev working tree) --- BUILD=${{ env.BUILD_DIR }} ORIGIN=\$(git -C ${{ env.APP_DIR }} remote get-url origin) if [ ! -d \"\$BUILD/.git\" ]; then @@ -43,6 +42,31 @@ jobs: cd \"\$BUILD\" git fetch origin git reset --hard origin/${{ github.ref_name }} + " + + - name: Test SX language suite + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + run: | + ssh "root@$DEPLOY_HOST" " + cd ${{ env.BUILD_DIR }} + + echo '=== Building SX test image ===' + docker build \ + -f .gitea/Dockerfile.test \ + -t sx-test:${{ github.sha }} \ + . + + echo '=== Running SX tests ===' + docker run --rm sx-test:${{ github.sha }} + " + + - name: Build and deploy changed apps + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + run: | + ssh "root@$DEPLOY_HOST" " + cd ${{ env.BUILD_DIR }} # Detect changes using push event SHAs (not local checkout state) BEFORE='${{ github.event.before }}' diff --git a/artdag/common/artdag_common/middleware/__pycache__/auth.cpython-310.pyc b/artdag/common/artdag_common/middleware/__pycache__/auth.cpython-310.pyc deleted file mode 100644 index 0a262852ded9cf12140f6429bd9d9817d96a8657..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6751 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 diff --git a/tests/playwright/stepper.spec.js b/tests/playwright/stepper.spec.js new file mode 100644 index 0000000..fd9c2c9 --- /dev/null +++ b/tests/playwright/stepper.spec.js @@ -0,0 +1,27 @@ +const { test, expect } = require('playwright/test'); +const BASE_URL = process.env.SX_TEST_URL || 'http://localhost:8013'; + +test('home page stepper: no raw SX component calls visible', async ({ page }) => { + await page.goto(BASE_URL + '/sx/', { waitUntil: 'networkidle' }); + await page.waitForTimeout(5000); + + const stepper = page.locator('[data-sx-island="home/stepper"]'); + await expect(stepper).toBeVisible({ timeout: 10000 }); + + const text = await stepper.textContent(); + // Should NOT show raw component calls + expect(text).not.toContain('~cssx/tw'); + expect(text).not.toContain(':tokens'); + + // Should show rendered content (colored text) + expect(text.length).toBeGreaterThan(10); + + // Stepper navigation should work + const buttons = stepper.locator('button'); + await expect(buttons).toHaveCount(2); + const textBefore = await stepper.textContent(); + await buttons.last().click(); + await page.waitForTimeout(500); + const textAfter = await stepper.textContent(); + expect(textAfter).not.toBe(textBefore); +}); diff --git a/web/orchestration.sx b/web/orchestration.sx index 63c01ec..fada615 100644 --- a/web/orchestration.sx +++ b/web/orchestration.sx @@ -1107,7 +1107,7 @@ (mark-processed! el (str "on:" event-name)) ;; Parse body as SX, bind handler that evaluates it (let ((exprs (sx-parse body))) - (dom-listen el event-name + (dom-on el event-name (fn (e) (let ((handler-env (env-extend (dict)))) (env-bind! handler-env "event" e) @@ -1211,7 +1211,7 @@ (mark-processed! el "emit") (let ((event-name (dom-get-attr el "data-sx-emit"))) (when event-name - (dom-listen el "click" + (dom-on el "click" (fn (e) (let ((detail-json (dom-get-attr el "data-sx-emit-detail")) (detail (if detail-json (json-parse detail-json) (dict)))) diff --git a/web/signals.sx b/web/signals.sx index 015b26d..7ad1db8 100644 --- a/web/signals.sx +++ b/web/signals.sx @@ -425,7 +425,7 @@ (define on-event :effects [io] (fn (el (event-name :as string) (handler :as lambda)) - (dom-listen el event-name handler))) + (dom-on el event-name handler))) ;; Convenience: create an effect that listens for a DOM event on an ;; element and writes the event detail (or a transformed value) into @@ -436,7 +436,7 @@ (define bridge-event :effects [mutation io] (fn (el (event-name :as string) (target-signal :as signal) transform-fn) (effect (fn () - (let ((remove (dom-listen el event-name + (let ((remove (dom-on el event-name (fn (e) (let ((detail (event-detail e)) (new-val (if transform-fn