10 Commits

Author SHA1 Message Date
6417d15e60 Merge branch 'ocaml-vm'
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Has been cancelled
2026-03-25 01:21:00 +00:00
99e2009c2b Fix sx_docs Dockerfile: install dune + set PATH for OCaml build
The opam base image has dune in the switch but not on PATH.
RUN eval $(opam env) doesn't persist across layers. Install dune
explicitly and set PATH so dune is available in build steps.

Also fix run-tests.sh to respect QUICK env var from caller
(was being overwritten to false).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 01:20:48 +00:00
73810d249d Merge branch 'ocaml-vm'
All checks were successful
Test, Build, and Deploy / test-build-deploy (push) Successful in 5m58s
2026-03-25 00:59:50 +00:00
1ae5906ff6 Skip Playwright in deploy (needs running server) 2026-03-25 00:49:50 +00:00
2bc1aee888 Merge branch 'ocaml-vm'
All checks were successful
Test, Build, and Deploy / test-build-deploy (push) Successful in 2m25s
2026-03-25 00:36:57 +00:00
4dfaf09e04 Add lib/ to CI test Dockerfile
Missed during spec/lib split — CI image copied spec/ and web/
but not lib/ (compiler, freeze, vm, etc.).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:36:57 +00:00
7ac026eccb Merge branch 'ocaml-vm'
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 14s
2026-03-25 00:35:06 +00:00
b174a57c9c Fix spec/freeze.sx → lib/freeze.sx in CI test scripts
Missed during spec/lib split — the OCaml bridge test loaded
freeze.sx from the old spec/ path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:35:06 +00:00
1b5d3e8eb1 Add spec/, lib/, web/ to sx_docs Docker image
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 16s
The Dockerfile was missing COPY lines for the SX source files loaded
by the OCaml kernel at runtime (parser, render, compiler, adapters,
signals, freeze). This caused CI test failures and production deploy
to run without the spec/lib split or web adapters.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:31:56 +00:00
0fce6934cb Use dom-on for event handlers; add CI config and stepper Playwright test
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m5s
- 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) <noreply@anthropic.com>
2026-03-25 00:19:35 +00:00
11 changed files with 273 additions and 15 deletions

View File

@@ -1,5 +1,5 @@
.git .git
.gitea .gitea/workflows
.env .env
_snapshot _snapshot
docs docs

85
.gitea/Dockerfile.test Normal file
View File

@@ -0,0 +1,85 @@
# 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, lib, web, shared (needed by bootstrappers + tests)
COPY --chown=opam:opam spec/ ./spec/
COPY --chown=opam:opam lib/ ./lib/
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"]

115
.gitea/run-ci-tests.sh Executable file
View File

@@ -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', 'lib/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

View File

@@ -1,4 +1,4 @@
name: Build and Deploy name: Test, Build, and Deploy
on: on:
push: push:
@@ -10,7 +10,7 @@ env:
BUILD_DIR: /root/rose-ash-ci BUILD_DIR: /root/rose-ash-ci
jobs: jobs:
build-and-deploy: test-build-deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -29,12 +29,11 @@ jobs:
chmod 600 ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Build and deploy changed apps - name: Sync CI build directory
env: env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: | run: |
ssh "root@$DEPLOY_HOST" " ssh "root@$DEPLOY_HOST" "
# --- Build in isolated CI directory (never touch dev working tree) ---
BUILD=${{ env.BUILD_DIR }} BUILD=${{ env.BUILD_DIR }}
ORIGIN=\$(git -C ${{ env.APP_DIR }} remote get-url origin) ORIGIN=\$(git -C ${{ env.APP_DIR }} remote get-url origin)
if [ ! -d \"\$BUILD/.git\" ]; then if [ ! -d \"\$BUILD/.git\" ]; then
@@ -43,6 +42,31 @@ jobs:
cd \"\$BUILD\" cd \"\$BUILD\"
git fetch origin git fetch origin
git reset --hard origin/${{ github.ref_name }} 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) # Detect changes using push event SHAs (not local checkout state)
BEFORE='${{ github.event.before }}' BEFORE='${{ github.event.before }}'

View File

@@ -53,8 +53,8 @@ fi
echo "Building: ${BUILD[*]}" echo "Building: ${BUILD[*]}"
echo "" echo ""
# --- Run all tests before deploying --- # --- Run unit tests before deploying (skip Playwright — needs running server) ---
if ! ./run-tests.sh; then if ! QUICK=true ./run-tests.sh; then
exit 1 exit 1
fi fi

View File

@@ -11,8 +11,8 @@ set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
QUICK=false QUICK="${QUICK:-false}"
SX_ONLY=false SX_ONLY="${SX_ONLY:-false}"
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
--quick) QUICK=true ;; --quick) QUICK=true ;;
@@ -73,7 +73,7 @@ run_suite "OCaml bridge — custom special forms + web-forms" \
from shared.sx.ocaml_sync import OcamlSync from shared.sx.ocaml_sync import OcamlSync
bridge = OcamlSync() bridge = OcamlSync()
# Load exactly what the server does (no evaluator.sx!) # Load exactly what the server does (no evaluator.sx!)
for f in ['spec/parser.sx', 'spec/render.sx', 'web/adapter-html.sx', 'web/adapter-sx.sx', 'web/web-forms.sx', 'spec/freeze.sx']: for f in ['spec/parser.sx', 'spec/render.sx', 'web/adapter-html.sx', 'web/adapter-sx.sx', 'web/web-forms.sx', 'lib/freeze.sx']:
bridge.load(f) bridge.load(f)
ok = 0; fail = 0 ok = 0; fail = 0
def check(name, expr, expected=None): def check(name, expr, expected=None):

View File

@@ -3,12 +3,14 @@
# --- Stage 1: Build OCaml SX kernel --- # --- Stage 1: Build OCaml SX kernel ---
FROM ocaml/opam:debian-12-ocaml-5.2 AS ocaml-build FROM ocaml/opam:debian-12-ocaml-5.2 AS ocaml-build
USER opam USER opam
RUN opam install dune -y
ENV PATH="/home/opam/.opam/5.2/bin:${PATH}"
WORKDIR /home/opam/sx WORKDIR /home/opam/sx
COPY --chown=opam:opam hosts/ocaml/dune-project ./ COPY --chown=opam:opam hosts/ocaml/dune-project ./
COPY --chown=opam:opam hosts/ocaml/lib/ ./lib/ COPY --chown=opam:opam hosts/ocaml/lib/ ./lib/
COPY --chown=opam:opam hosts/ocaml/bin/dune hosts/ocaml/bin/run_tests.ml \ COPY --chown=opam:opam hosts/ocaml/bin/dune hosts/ocaml/bin/run_tests.ml \
hosts/ocaml/bin/debug_set.ml hosts/ocaml/bin/sx_server.ml ./bin/ hosts/ocaml/bin/debug_set.ml hosts/ocaml/bin/sx_server.ml ./bin/
RUN eval $(opam env) && dune build bin/sx_server.exe RUN dune build bin/sx_server.exe
# --- Stage 2: Python app --- # --- Stage 2: Python app ---
FROM python:3.11-slim AS base FROM python:3.11-slim AS base
@@ -60,6 +62,11 @@ COPY likes/models/ ./likes/models/
COPY orders/__init__.py ./orders/__init__.py COPY orders/__init__.py ./orders/__init__.py
COPY orders/models/ ./orders/models/ COPY orders/models/ ./orders/models/
# SX spec + library + web adapter files (loaded by OCaml kernel at runtime)
COPY spec/ ./spec/
COPY lib/ ./lib/
COPY web/ ./web/
# OCaml SX kernel binary # OCaml SX kernel binary
COPY --from=ocaml-build /home/opam/sx/_build/default/bin/sx_server.exe /app/bin/sx_server COPY --from=ocaml-build /home/opam/sx/_build/default/bin/sx_server.exe /app/bin/sx_server

View File

@@ -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);
});

View File

@@ -1107,7 +1107,7 @@
(mark-processed! el (str "on:" event-name)) (mark-processed! el (str "on:" event-name))
;; Parse body as SX, bind handler that evaluates it ;; Parse body as SX, bind handler that evaluates it
(let ((exprs (sx-parse body))) (let ((exprs (sx-parse body)))
(dom-listen el event-name (dom-on el event-name
(fn (e) (fn (e)
(let ((handler-env (env-extend (dict)))) (let ((handler-env (env-extend (dict))))
(env-bind! handler-env "event" e) (env-bind! handler-env "event" e)
@@ -1211,7 +1211,7 @@
(mark-processed! el "emit") (mark-processed! el "emit")
(let ((event-name (dom-get-attr el "data-sx-emit"))) (let ((event-name (dom-get-attr el "data-sx-emit")))
(when event-name (when event-name
(dom-listen el "click" (dom-on el "click"
(fn (e) (fn (e)
(let ((detail-json (dom-get-attr el "data-sx-emit-detail")) (let ((detail-json (dom-get-attr el "data-sx-emit-detail"))
(detail (if detail-json (json-parse detail-json) (dict)))) (detail (if detail-json (json-parse detail-json) (dict))))

View File

@@ -425,7 +425,7 @@
(define on-event :effects [io] (define on-event :effects [io]
(fn (el (event-name :as string) (handler :as lambda)) (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 ;; Convenience: create an effect that listens for a DOM event on an
;; element and writes the event detail (or a transformed value) into ;; element and writes the event detail (or a transformed value) into
@@ -436,7 +436,7 @@
(define bridge-event :effects [mutation io] (define bridge-event :effects [mutation io]
(fn (el (event-name :as string) (target-signal :as signal) transform-fn) (fn (el (event-name :as string) (target-signal :as signal) transform-fn)
(effect (fn () (effect (fn ()
(let ((remove (dom-listen el event-name (let ((remove (dom-on el event-name
(fn (e) (fn (e)
(let ((detail (event-detail e)) (let ((detail (event-detail e))
(new-val (if transform-fn (new-val (if transform-fn