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 0a26285..0000000 Binary files a/artdag/common/artdag_common/middleware/__pycache__/auth.cpython-310.pyc and /dev/null differ 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