feat(spinel): own tests, README, DEPS

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 60bb659ef2b8d71bd8fc217f28ac93b64f5c0a6e parent 13cbb62
3 files changed, +257 −0
  • apps/spinel/DEPS +2 −0
  • apps/spinel/README.md +75 −0
  • apps/spinel/test/run.sh +180 −0
diff --git a/apps/spinel/DEPS b/apps/spinel/DEPS
index e69de29..a18bd24 100644
--- a/apps/spinel/DEPS
+++ b/apps/spinel/DEPS
@@ -0,0 +1,2 @@
+tep https://github.com/OriPekelman/tep a27bdcb (patched: patches/)
+sqlite 3.50.4 https://www.sqlite.org/2025/sqlite-amalgamation-3500400.zip
diff --git a/apps/spinel/README.md b/apps/spinel/README.md
new file mode 100644
index 0000000..980e82b
--- /dev/null
+++ b/apps/spinel/README.md
@@ -0,0 +1,75 @@
+# todo-spinel
+
+TodoMVC for the CARLOS bakeoff, written in Ruby and compiled ahead of time
+by [Spinel](https://github.com/matz/spinel), matz's Ruby-to-C compiler. The
+result is one static linux-arm64 binary with SQLite inside it and no Ruby at
+runtime. It's live at
+[https://todo-spinel.bfj.oncarlos.com](https://todo-spinel.bfj.oncarlos.com).
+
+## The stack
+
+**[tep](https://github.com/OriPekelman/tep)** is the web layer. You write a
+Sinatra-style `app.rb`; tep translates it into plain Ruby that Spinel can
+compile, inlines its own HTTP server, and drives Spinel to a binary. It's the
+only web framework that targets Spinel, and it already powers a CARLOS app
+(lpay), so it was the obvious pick. Spinel compiles a subset of Ruby, which is
+why `app.rb` reads a little old-fashioned: `while` loops, string
+concatenation, small top-level functions, no metaprogramming.
+
+**SQLite** comes from the official amalgamation, compiled by the Makefile and
+linked statically. tep's own SQLite shim talks to it. Lists live in one
+`todos` table keyed by the visitor's cookie; migrations are numbered by
+`PRAGMA user_version` and only ever add.
+
+**HTML** is built in Ruby functions in `app.rb`, escaped by hand. tep has
+build-time ERB, but its templates can only be called from a route block and
+every value has to go through a per-request string bag, which made the
+item and fragment renderers clumsier than plain functions.
+
+**JavaScript** is one ES module, `assets/static/app.js`, with no framework.
+The page works fully without it: every action is a real form POST that
+answers 303. With JS on, the script sends the same forms with `fetch` and an
+`X-Fragment: 1` header, and swaps in the `section.main` and `footer.footer`
+the server sends back. The no-JS controls sit invisibly over their TodoMVC
+counterparts and disappear when the script adds the `js` class.
+
+tep is copied into `.cache/` and patched rather than used as-is.
+`patches/1-tep-carlos.patch` is lpay's: the `--socket` contract and a
+`Content-Length` that counts bytes. `patches/2-tep-bakeoff.patch` adds
+`--listen 127.0.0.1:<port>` (tep's `-p` binds every interface), a 5-second
+read timeout so an idle browser connection can't hold the single worker, the
+`303 See Other` reason phrase, and a return-type fix in `Tep::Assets._add`
+that otherwise breaks the C compile. Edit `.cache/x86/tep` and run
+`scripts/regen-patch` to change the second one.
+
+## Build, run, test
+
+```
+source ../../scripts/env.sh
+make build # dist/app, for this machine
+dist/app --listen 127.0.0.1:8080 --db $TMPDIR/todo.db
+make test # test/run.sh: curl checks of the contract
+../../scripts/test-app.sh http://127.0.0.1:8080 # the shared browser suite
+```
+
+`make build` takes about a minute and a half, nearly all of it Spinel.
+
+`make build-arm64` makes `dist/app-linux-arm64`. The host Spinel turns Ruby
+into C natively, then `scripts/arm64-cc` compiles that C inside the emulated
+arm64 root (`scripts/arm64.sh`) and links it statically against the root's
+glibc, OpenSSL (tep's HTTP layer links it) and our SQLite. Running all of
+Spinel under emulation also works but takes three times as long. Expect five
+minutes warm, and another four the first time while SQLite compiles.
+
+`make clean-deps` removes the compiled SQLite and tep copies; the downloaded
+SQLite zip stays in `.cache/src`.
+
+## Deploy
+
+```
+make deploy MESSAGE="what changed"
+```
+
+This ships `dist/app-linux-arm64` to the `todo-spinel` app on account `bfj`.
+The version is the commit's short SHA, so commit before you build. Before a
+first deploy, run `../../scripts/boot-check.sh dist/app-linux-arm64`.
diff --git a/apps/spinel/test/run.sh b/apps/spinel/test/run.sh
new file mode 100755
index 0000000..cd2b931
--- /dev/null
+++ b/apps/spinel/test/run.sh
@@ -0,0 +1,180 @@
+#!/usr/bin/env bash
+# The app's own tests: start the compiled binary, drive it with curl, and
+# check the contract's wire-level rules (status codes, redirects, cookies,
+# escaping, Content-Length, static files, isolation, Origin, the unix
+# socket and a clean SIGTERM exit). The browser-level behaviour is the
+# shared suite's job (scripts/test-app.sh at the repo root).
+#
+# test/run.sh dist/app
+set -uo pipefail
+
+bin=$(cd "$(dirname "${1:?usage: test/run.sh <binary>}")" && pwd)/$(basename "$1")
+here=$(cd "$(dirname "$0")/.." && pwd)
+work=$(mktemp -d -p "${TMPDIR:-/tmp}")
+pid=""
+cleanup() {
+ [ -n "$pid" ] && kill "$pid" 2>/dev/null
+ rm -rf "$work"
+}
+trap cleanup EXIT
+
+pass=0
+fail=0
+ok() { pass=$((pass + 1)); }
+bad() { fail=$((fail + 1)); echo "FAIL: $*"; }
+check() { # check <description> <expected> <actual>
+ if [ "$2" == "$3" ]; then ok; else bad "$1: expected [$2], got [$3]"; fi
+}
+contains() { # contains <description> <needle> <haystack>
+ if [[ "$3" == *"$2"* ]]; then ok; else bad "$1: [$2] not found"; fi
+}
+lacks() {
+ if [[ "$3" != *"$2"* ]]; then ok; else bad "$1: [$2] found"; fi
+}
+
+port=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])')
+base="http://127.0.0.1:$port"
+"$bin" --listen "127.0.0.1:$port" --db "$work/app.db" >"$work/app.log" 2>&1 &
+pid=$!
+for _ in $(seq 50); do curl -sf "$base/healthz" >/dev/null && break; sleep 0.1; done
+
+status() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
+jar="$work/jar"
+other="$work/other"
+
+# --- platform routes
+check "healthz" 200 "$(status "$base/healthz")"
+check "version" 200 "$(status "$base/api/version")"
+check "version body" "$(cat "$here/version.rb" | sed -n 's/^APP_VERSION = "\(.*\)"$/\1/p')" "$(curl -s "$base/api/version")"
+check "unknown path" 404 "$(status "$base/nope")"
+
+# --- listens only where it was told to
+listeners=$(ss -ltnH "sport = :$port" | awk '{print $4}')
+check "bound to 127.0.0.1 only" "127.0.0.1:$port" "$listeners"
+
+# --- first visit: cookie, empty list, no main/footer
+headers=$(curl -s -D - -o "$work/page" -c "$jar" "$base/")
+contains "cookie set" "Set-Cookie: tid=" "$headers"
+contains "cookie HttpOnly" "HttpOnly" "$headers"
+contains "cookie SameSite" "SameSite=Lax" "$headers"
+contains "cookie Path" "Path=/" "$headers"
+lacks "no Secure over http" "Secure" "$headers"
+token=$(awk '$6 == "tid" {print $7}' "$jar")
+check "token is 22 base64url chars" 1 "$([[ "$token" =~ ^[A-Za-z0-9_-]{22}$ ]] && echo 1 || echo 0)"
+page=$(cat "$work/page")
+lacks "empty list hides main" 'class="main"' "$page"
+lacks "empty list hides footer" 'class="footer"' "$page"
+contains "language in footer" "the CARLOS bakeoff</a> · Spinel" "$page"
+
+# --- add: trimmed, blank ignored, 303 to the filter
+post() { curl -s -o /dev/null -w '%{http_code} %{redirect_url}' -b "$jar" -c "$jar" "$@"; }
+check "add" "303 $base/" "$(post --data-urlencode 'title= buy milk ' -d filter=all "$base/todos")"
+check "add from active" "303 $base/active" "$(post --data-urlencode 'title=walk the dog' -d filter=active "$base/todos")"
+check "blank add" "303 $base/completed" "$(post --data-urlencode 'title= ' -d filter=completed "$base/todos")"
+check "unknown filter" "303 $base/" "$(post --data-urlencode 'title=write' -d filter=bogus "$base/todos")"
+page=$(curl -s -b "$jar" "$base/")
+contains "trimmed title" "<label>buy milk</label>" "$page"
+check "three items" 3 "$(grep -c '<li data-id=' <<<"$page")"
+contains "counter" "<strong>3</strong> items left" "$page"
+lacks "no clear-completed yet" "clear-completed" "$page"
+
+id1=$(grep -o '<li data-id="[0-9]*"' <<<"$page" | head -1 | grep -o '[0-9]*')
+id2=$(grep -o '<li data-id="[0-9]*"' <<<"$page" | sed -n 2p | grep -o '[0-9]*')
+
+# --- toggle, filters, toggle-all, clear-completed
+check "toggle" "303 $base/active" "$(post -d filter=active "$base/todos/$id1/toggle")"
+page=$(curl -s -b "$jar" "$base/")
+contains "completed class" "<li data-id=\"$id1\" class=\"completed\">" "$page"
+contains "singular counter" "<strong>2</strong> items left" "$page"
+contains "clear-completed shown" 'class="clear-completed"' "$page"
+active=$(curl -s -b "$jar" "$base/active")
+lacks "active filter hides completed" "data-id=\"$id1\"" "$active"
+contains "active link selected" '<a class="selected" href="/active">' "$active"
+completed=$(curl -s -b "$jar" "$base/completed")
+check "completed filter" 1 "$(grep -c '<li data-id=' <<<"$completed")"
+check "toggle-all" "303 $base/" "$(post -d filter=all "$base/todos/toggle-all")"
+check "all completed" 3 "$(curl -s -b "$jar" "$base/" | grep -c 'class="completed"')"
+contains "toggle-all checked" 'class="toggle-all" type="checkbox" form="toggle-all-form" checked' "$(curl -s -b "$jar" "$base/")"
+post -d filter=all "$base/todos/toggle-all" >/dev/null
+check "toggle-all again clears" 0 "$(curl -s -b "$jar" "$base/" | grep -c 'class="completed"')"
+post -d filter=all "$base/todos/$id1/toggle" >/dev/null
+check "clear-completed" "303 $base/" "$(post -d filter=all "$base/todos/clear-completed")"
+lacks "completed item gone" "data-id=\"$id1\"" "$(curl -s -b "$jar" "$base/")"
+
+# --- edit view, rename, empty rename deletes
+edit=$(curl -s -b "$jar" "$base/active?edit=$id2")
+contains "editing class" "<li data-id=\"$id2\" class=\"editing\">" "$edit"
+contains "edit form" "action=\"/todos/$id2/edit\"" "$edit"
+contains "edit link keeps filter" "href=\"/active?edit=$id2\"" "$edit"
+check "rename" "303 $base/active" "$(post --data-urlencode 'title= walk the cat ' -d filter=active "$base/todos/$id2/edit")"
+contains "renamed" "<label>walk the cat</label>" "$(curl -s -b "$jar" "$base/")"
+check "empty rename" "303 $base/" "$(post --data-urlencode 'title= ' -d filter=all "$base/todos/$id2/edit")"
+lacks "empty rename deletes" "data-id=\"$id2\"" "$(curl -s -b "$jar" "$base/")"
+
+# --- escaping, UTF-8, Content-Length, long titles
+post --data-urlencode 'title=<img src=x onerror=alert(1)> & "q" '"'"'s' -d filter=all "$base/todos" >/dev/null
+post --data-urlencode 'title=naïve 🚀 日本語' -d filter=all "$base/todos" >/dev/null
+headers=$(curl -s -D - -o "$work/page" -b "$jar" "$base/")
+page=$(cat "$work/page")
+contains "escaped title" "&lt;img src=x onerror=alert(1)&gt; &amp; &quot;q&quot; &#39;s" "$page"
+lacks "no raw img" "<img" "$page"
+contains "UTF-8 kept" "naïve 🚀 日本語" "$page"
+declared=$(tr -d '\r' <<<"$headers" | sed -n 's/^Content-Length: //p')
+check "Content-Length is bytes" "$(wc -c <"$work/page")" "$declared"
+long=$(printf 'é%.0s' $(seq 10000))
+post --data-urlencode "title=$long" -d filter=all "$base/todos" >/dev/null
+contains "10,000-char title" "<label>$long</label>" "$(curl -s -b "$jar" "$base/")"
+
+# --- isolation, foreign ids, garbled cookies, Origin
+check "foreign id from another visitor" 404 "$(status -c "$other" -b "$other" -d filter=all "$base/todos/$id1/toggle")"
+own=$(curl -s -b "$jar" "$base/" | grep -o '<li data-id="[0-9]*"' | head -1 | grep -o '[0-9]*')
+check "someone else's toggle" 404 "$(status -c "$other" -b "$other" -d filter=all "$base/todos/$own/toggle")"
+check "someone else's edit" 404 "$(status -b "$other" -d title=x -d filter=all "$base/todos/$own/edit")"
+check "someone else's destroy" 404 "$(status -b "$other" -d filter=all "$base/todos/$own/destroy")"
+lacks "other visitor sees nothing" "<li data-id" "$(curl -s -b "$other" "$base/")"
+check "non-numeric id" 404 "$(status -b "$jar" -d filter=all "$base/todos/abc/toggle")"
+check "garbled cookie" 200 "$(status -b 'tid=%%%' "$base/")"
+lacks "garbled cookie gets empty list" "<li data-id" "$(curl -s -b 'tid=%%%' "$base/")"
+contains "garbled cookie replaced" "Set-Cookie: tid=" "$(curl -s -D - -o /dev/null -b 'tid=%%%' "$base/")"
+check "cross-site POST" 403 "$(status -b "$jar" -H 'Origin: https://evil.example' --data-urlencode title=evil "$base/todos")"
+lacks "cross-site POST changed nothing" "evil" "$(curl -s -b "$jar" "$base/")"
+check "same-origin POST" 303 "$(status -b "$jar" -H "Origin: $base" --data-urlencode title=fine "$base/todos")"
+
+# --- the script's fragment answers
+frag=$(curl -s -b "$jar" -H 'X-Fragment: 1' -d filter=active --data-urlencode title=fragment "$base/todos")
+check "fragment starts with main" '<section class="main">' "$(head -1 <<<"$frag")"
+lacks "fragment is not a page" "<html" "$frag"
+contains "fragment keeps the filter" '<a class="selected" href="/active">' "$frag"
+
+# --- static files, byte for byte
+for f in base.css index.css; do
+ curl -s "$base/static/$f" -o "$work/$f"
+ if cmp -s "$work/$f" "$here/../../shared/$f"; then ok; else bad "/static/$f differs from shared/$f"; fi
+done
+contains "css type" "Content-Type: text/css" "$(curl -s -D - -o /dev/null "$base/static/index.css")"
+
+# --- clean exit on SIGTERM, and the data survives a restart
+kill -TERM "$pid"
+code=0
+for _ in $(seq 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done
+if kill -0 "$pid" 2>/dev/null; then bad "still running after SIGTERM"; else wait "$pid"; code=$?; check "SIGTERM exit status" 0 "$code"; fi
+pid=""
+
+# --- the unix-socket contract, with CARLOS_HOST
+sock="$work/app.sock"
+CARLOS_HOST=todo.example.test "$bin" --socket "$sock" --db "$work/app.db" >>"$work/app.log" 2>&1 &
+pid=$!
+for _ in $(seq 50); do [ -S "$sock" ] && break; sleep 0.1; done
+u=(curl -s --unix-socket "$sock")
+check "socket healthz" 200 "$("${u[@]}" -o /dev/null -w '%{http_code}' http://x/healthz)"
+contains "data survived restart" "<label>fragment</label>" "$("${u[@]}" -H "Cookie: tid=$token" http://x/)"
+contains "Secure cookie on Carloku" "Secure" "$("${u[@]}" -D - -o /dev/null http://x/)"
+check "Carloku origin accepted" 303 "$("${u[@]}" -o /dev/null -w '%{http_code}' -H "Cookie: tid=$token" -H 'Origin: https://todo.example.test' -d title=x http://x/todos)"
+check "local origin refused on Carloku" 403 "$("${u[@]}" -o /dev/null -w '%{http_code}' -H "Cookie: tid=$token" -H "Origin: $base" -d title=x http://x/todos)"
+kill -TERM "$pid"
+for _ in $(seq 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done
+if kill -0 "$pid" 2>/dev/null; then bad "socket server still running after SIGTERM"; else wait "$pid"; check "socket SIGTERM exit status" 0 "$?"; fi
+pid=""
+
+echo "$pass passed, $fail failed"
+[ "$fail" -eq 0 ]