fed-sx-m1: Step 9b-pure — reactive smoke test in-process (trigger match+derive end-to-end) + 12 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 32s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 32s
This commit is contained in:
148
next/tests/smoke_app_pure.sh
Executable file
148
next/tests/smoke_app_pure.sh
Executable file
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env bash
|
||||
# next/tests/smoke_app_pure.sh — Step 9b-pure smoke test.
|
||||
#
|
||||
# Mirrors §Step 9b structurally without TCP/curl/JSON. A trigger
|
||||
# projection (Erlang fun) matches Note activities tagged
|
||||
# "smoketest", constructs a derived TestEcho activity carrying
|
||||
# the Note's CID via :echoes, and captures it into projection
|
||||
# state. Proves the reactive-application mechanism — match-then-
|
||||
# derive — works end-to-end through nx_kernel's broadcast.
|
||||
#
|
||||
# Cascade publication (the trigger actually publishing the
|
||||
# derived activity back through outbox) is sidestepped to avoid
|
||||
# gen_server reentrancy; the projection state is the proof point.
|
||||
# 12 cases.
|
||||
|
||||
set -uo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}"
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe"
|
||||
fi
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
echo "ERROR: sx_server.exe not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERBOSE="${1:-}"
|
||||
PASS=0; FAIL=0; ERRORS=""
|
||||
TMPFILE=$(mktemp); trap "rm -f $TMPFILE" EXIT
|
||||
|
||||
# Shared prelude — KM/KS/AS, the Match function (Note +
|
||||
# smoketest tag), the trigger fold body, and various activity
|
||||
# proplists.
|
||||
PRELUDE='KM = <<1,2,3,4>>, KS = [{key_id,k1},{algorithm,ed25519},{value,KM}], AS = [{public_keys,[[{id,k1},{created,0},{value,KM}]]}], Match = fun (Act) -> case envelope:get_field(type, Act) of {ok, note} -> case envelope:get_field(object, Act) of {ok, Obj} -> case envelope:get_field(tags, Obj) of {ok, Tags} -> lists:member(smoketest, Tags); _ -> false end; _ -> false end; _ -> false end end, TrigFold = fun (Act, {Captured, Count}) -> case Match(Act) of true -> {ok, Id} = envelope:get_field(id, Act), Derived = [{type, test_echo}, {object, [{echoes, Id}]}], {[Derived | Captured], Count + 1}; false -> {Captured, Count} end end, projection:start_link(trig, {[], 0}, TrigFold), nx_kernel:start_link(alice, KS, AS), nx_kernel:with_projections([trig]), MatchNote = [{type, note}, {object, [{content, hi}, {tags, [smoketest]}]}], NoMatchNote = [{type, note}, {object, [{content, plain}, {tags, [other]}]}],'
|
||||
|
||||
cat > "$TMPFILE" <<EPOCHS
|
||||
(epoch 1)
|
||||
(load "lib/erlang/tokenizer.sx")
|
||||
(load "lib/erlang/parser.sx")
|
||||
(load "lib/erlang/parser-core.sx")
|
||||
(load "lib/erlang/parser-expr.sx")
|
||||
(load "lib/erlang/parser-module.sx")
|
||||
(load "lib/erlang/transpile.sx")
|
||||
(load "lib/erlang/runtime.sx")
|
||||
(load "lib/erlang/vm/dispatcher.sx")
|
||||
(epoch 2)
|
||||
(eval "(er-load-gen-server!)")
|
||||
(epoch 3)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)")
|
||||
(epoch 4)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/log.erl\")) :name)")
|
||||
(epoch 5)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)")
|
||||
(epoch 6)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/projection.erl\")) :name)")
|
||||
(epoch 7)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)")
|
||||
(epoch 8)
|
||||
(eval "(get (erlang-load-module (file-read \"next/kernel/nx_kernel.erl\")) :name)")
|
||||
|
||||
;; Initial: no triggers fired
|
||||
(epoch 10)
|
||||
(eval "(erlang-eval-ast \"${PRELUDE} {_, Count} = projection:query(trig), Count\")")
|
||||
|
||||
;; Matching Note fires the trigger once
|
||||
(epoch 11)
|
||||
(eval "(erlang-eval-ast \"${PRELUDE} nx_kernel:publish(MatchNote), {_, Count} = projection:query(trig), Count\")")
|
||||
|
||||
;; Non-matching Note does NOT fire trigger
|
||||
(epoch 12)
|
||||
(eval "(erlang-eval-ast \"${PRELUDE} nx_kernel:publish(NoMatchNote), {_, Count} = projection:query(trig), Count\")")
|
||||
|
||||
;; Mix: one match + one non-match -> trigger fires exactly once
|
||||
(epoch 13)
|
||||
(eval "(erlang-eval-ast \"${PRELUDE} nx_kernel:publish(MatchNote), nx_kernel:publish(NoMatchNote), {_, Count} = projection:query(trig), Count\")")
|
||||
|
||||
;; Trigger captures the derived TestEcho
|
||||
(epoch 14)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} nx_kernel:publish(MatchNote), {[Derived], _} = projection:query(trig), envelope:get_field(type, Derived) =:= {ok, test_echo}\") :name)")
|
||||
|
||||
;; Derived TestEcho :echoes points at the Note's :id (CID)
|
||||
(epoch 15)
|
||||
(eval "(get (erlang-eval-ast \"${PRELUDE} nx_kernel:publish(MatchNote), {[Derived], _} = projection:query(trig), {ok, Obj} = envelope:get_field(object, Derived), {ok, EchoesId} = envelope:get_field(echoes, Obj), [Logged] = log:entries(nx_kernel:log_state(nx_kernel:query())), {ok, LoggedId} = envelope:get_field(id, Logged), EchoesId =:= LoggedId\") :name)")
|
||||
|
||||
;; Two matching Notes -> trigger fires twice, captures both derived
|
||||
(epoch 16)
|
||||
(eval "(erlang-eval-ast \"${PRELUDE} nx_kernel:publish(MatchNote), MatchNote2 = [{type, note}, {object, [{content, hello}, {tags, [smoketest]}]}], nx_kernel:publish(MatchNote2), {Captured, Count} = projection:query(trig), {length(Captured), Count}\")")
|
||||
|
||||
;; Trigger ignores non-Note activities even if they have :tags
|
||||
(epoch 17)
|
||||
(eval "(erlang-eval-ast \"${PRELUDE} OtherType = [{type, pin}, {object, [{tags, [smoketest]}, {path, p}, {cid, c}]}], nx_kernel:publish(OtherType), {_, Count} = projection:query(trig), Count\")")
|
||||
|
||||
;; Trigger ignores Note without :tags
|
||||
(epoch 18)
|
||||
(eval "(erlang-eval-ast \"${PRELUDE} NoTag = [{type, note}, {object, [{content, hi}]}], nx_kernel:publish(NoTag), {_, Count} = projection:query(trig), Count\")")
|
||||
|
||||
;; Multiple tags including smoketest -> matches
|
||||
(epoch 19)
|
||||
(eval "(erlang-eval-ast \"${PRELUDE} Many = [{type, note}, {object, [{content, hi}, {tags, [smoketest, foo, bar]}]}], nx_kernel:publish(Many), {_, Count} = projection:query(trig), Count\")")
|
||||
|
||||
;; Sig-failed publish doesn't reach the trigger
|
||||
(epoch 20)
|
||||
(eval "(erlang-eval-ast \"OtherKM = <<9,9,9,9>>, BadKS = [{key_id,k1},{algorithm,ed25519},{value,OtherKM}], AS = [{public_keys,[[{id,k1},{created,0},{value,<<1,2,3,4>>}]]}], Match = fun (Act) -> case envelope:get_field(type, Act) of {ok, note} -> case envelope:get_field(object, Act) of {ok, Obj} -> case envelope:get_field(tags, Obj) of {ok, Tags} -> lists:member(smoketest, Tags); _ -> false end; _ -> false end; _ -> false end end, TrigFold = fun (Act, {Captured, Count}) -> case Match(Act) of true -> {ok, Id} = envelope:get_field(id, Act), Derived = [{type, test_echo}, {object, [{echoes, Id}]}], {[Derived | Captured], Count + 1}; false -> {Captured, Count} end end, projection:start_link(trig, {[], 0}, TrigFold), nx_kernel:start_link(alice, BadKS, AS), nx_kernel:with_projections([trig]), MatchNote = [{type, note}, {object, [{content, hi}, {tags, [smoketest]}]}], nx_kernel:publish(MatchNote), {_, Count} = projection:query(trig), Count\")")
|
||||
EPOCHS
|
||||
|
||||
OUTPUT=$(timeout 300 "$SX_SERVER" < "$TMPFILE" 2>/dev/null)
|
||||
|
||||
check() {
|
||||
local epoch="$1" desc="$2" expected="$3"
|
||||
local actual
|
||||
actual=$(echo "$OUTPUT" | awk -v e="$epoch" '
|
||||
$0 ~ "^\\(ok-len " e " " { getline; print; exit }
|
||||
$0 ~ "^\\(ok " e " " { print; exit }
|
||||
$0 ~ "^\\(error " e " " { print; exit }
|
||||
')
|
||||
[ -z "$actual" ] && actual="<no output for epoch $epoch>"
|
||||
if echo "$actual" | grep -qF -- "$expected"; then
|
||||
PASS=$((PASS+1))
|
||||
[ "$VERBOSE" = "-v" ] && echo " ok $desc"
|
||||
else
|
||||
FAIL=$((FAIL+1))
|
||||
ERRORS+=" FAIL [$desc] (epoch $epoch) expected: $expected | actual: $actual
|
||||
"
|
||||
fi
|
||||
}
|
||||
|
||||
check 8 "nx_kernel module loaded" "nx_kernel"
|
||||
check 10 "initial Count = 0" "0"
|
||||
check 11 "Match fires once" "1"
|
||||
check 12 "Non-match does NOT fire" "0"
|
||||
check 13 "Mix: only match fires" "1"
|
||||
check 14 "Derived type = test_echo" "true"
|
||||
check 15 "Derived :echoes = Note's :id" "true"
|
||||
check 16 "Two matches -> 2 derived, count 2" "(2 2)"
|
||||
check 17 "Non-Note ignored" "0"
|
||||
check 18 "Note without tags ignored" "0"
|
||||
check 19 "Multi-tag includes smoketest" "1"
|
||||
check 20 "Sig failure -> no trigger" "0"
|
||||
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo "ok $PASS/$TOTAL next/tests/smoke_app_pure.sh passed"
|
||||
else
|
||||
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||
echo "$ERRORS"
|
||||
fi
|
||||
[ $FAIL -eq 0 ]
|
||||
@@ -579,7 +579,8 @@ Auth on `POST /activity`: bearer token from env var `NEXT_PUBLISH_TOKEN`.
|
||||
- [x] **9-pre-fold** — In-process end-to-end test of the HTTP → publish → broadcast → projection-fold chain. Proves the full vertical works without a real TCP socket. `next/tests/http_publish_fold.sh` (10 cases). Step 9a/b proper need TCP (Step 8b-start).
|
||||
- [x] **9a-pure** — In-process Pin smoke test mirroring the §Step 9a flow. Wires `define_registry:fold_fn/0` + an Erlang-fun pin-state fold into nx_kernel via `with_projections/1`. Publishes Create{DefineActivity{name: pin}} → registry update; publishes Pin{path: ..., cid: ...} → pin_state update. Order-independent; ignores Note + other types. `next/tests/smoke_pin_pure.sh` (13 cases).
|
||||
- [ ] **9a-tcp** — Same flow under curl over Step 8b-start once TCP listening lands.
|
||||
- [ ] **9b** — Reactive smoke test (TCP-driven, curl) — needs DefineSubscription / DefineTrigger eval.
|
||||
- [x] **9b-pure** — In-process reactive smoke test. A trigger projection (Erlang-fun fold) matches Note activities tagged `smoketest`, constructs a derived `TestEcho{echoes: <Note CID>}`, and captures it into projection state. Order-independent; non-Note + non-smoketest + sig-failed all suppressed correctly. `next/tests/smoke_app_pure.sh` (12 cases). Cascade publish via outbox sidestepped — reentrancy proof is a v2 concern.
|
||||
- [ ] **9b-tcp** — Same flow under curl over Step 8b-start + cascade publish through outbox.
|
||||
|
||||
**The proof points.** Two end-to-end smoke tests demonstrate, between them, that
|
||||
fed-sx is genuinely a substrate for distributed reactive applications expressed
|
||||
@@ -1001,6 +1002,7 @@ A few things still under-specified; resolve as work begins.
|
||||
Newest first. One line per sub-deliverable commit. Erlang conformance gate
|
||||
(`bash lib/erlang/conformance.sh`) must remain 729/729 on every entry.
|
||||
|
||||
- **2026-05-28** — Step 9b-pure: **reactive application extensibility, proven end-to-end.** Mirrors §Step 9b structurally without TCP/curl/JSON. A trigger projection (Erlang-fun fold over `{Captured, Count}` state) matches Note activities whose `:object :tags` contains `smoketest`, constructs a derived `TestEcho` activity with `:object :echoes` pointing at the Note's `:id`, and captures it into projection state. Order-independent; non-Note + non-smoketest + Note-without-tags + sig-failed publishes all suppressed correctly. Multi-tag (e.g. `[smoketest, foo, bar]`) still matches. Cascade publish (the trigger actually publishing the derived activity back through outbox) is deferred — the gen_server reentrancy that introduces is a v2 concern; the projection-state capture is sufficient proof of the match-then-derive mechanism. `next/tests/smoke_app_pure.sh` 12/12. Erlang conformance 729/729.
|
||||
- **2026-05-28** — Step 9a-pure: **the first verb-extensibility smoke test, proven end-to-end.** Mirrors §Step 9a structurally without TCP/curl/JSON. Two projections wired into `nx_kernel:with_projections([define_reg, pin_state])` — `define_reg` uses `define_registry:fold_fn/0` (Step 5d-pure), `pin_state` uses an Erlang-fun fold that records `{Path, Cid}` from Pin activities. Publish `Create{DefineActivity{name: pin}}` → registry update visible via `registry:lookup(activity_types, pin, projection:query(define_reg))`; publish `Pin{path: docs_intro, cid: qm_cid_1}` → `projection:query(pin_state) =:= [{docs_intro, qm_cid_1}]`. Order-independent (DefineActivity-then-Pin and Pin-then-DefineActivity both succeed); Note + non-Define types are pass-throughs in both projections. The TCP/curl variant (Step 9a-tcp) layers on Step 8b-start. `next/tests/smoke_pin_pure.sh` 13/13. Erlang conformance 729/729.
|
||||
- **2026-05-28** — Step 5d-pure: `next/kernel/define_registry.erl` — the meta-projection fold body, in pure Erlang. State shape mirrors `registry:new()` exactly; `fold/2` dispatches Create{Define*} to `registry:register/4` keyed by `define_kind/1` (define_activity → activity_types, define_object → object_types, …). Non-Create + Create{non-Define} + Define{no :name} are all pass-throughs. Override re-registration preserves a single entry per name. `fold_fn/0` plugs the fold into `projection:start_link/3` — verified end-to-end: activity → projection async_fold → query state → registry:lookup returns the registered Object. The SX `define-registry.sx` body will replace this once an SX-source eval bridge exists; the Erlang shape proves the wiring is correct. `next/tests/define_registry_pure.sh` 16/16. Erlang conformance 729/729.
|
||||
- **2026-05-28** — Step 6c-schema-pure: `pipeline:stage_schema/2` accepts (Activity, SchemaLookup) where SchemaLookup is a caller-supplied callback `fun(Type) -> {ok, SchemaFn} | not_found`. Open-world default — unregistered types resolve to ok so the pipeline doesn't block activities the kernel hasn't yet learned about (tightened to strict-world in milestone 2). Activities without `:object` skip the schema check. `stage_schema/1` returns a 1-arity stage fun closed over SchemaLookup for composition with run_stages. Halt order verified end-to-end: envelope-shape errors precede schema; envelope-ok + schema-fail surfaces `schema_mismatch`. The Erlang-fun shape is the substrate-friendly stand-in for the SX `:schema` bodies in genesis; same stage shape will dispatch through an SX-source eval bridge once it exists. `next/tests/pipeline_schema.sh` 14/14. Erlang conformance 729/729.
|
||||
|
||||
Reference in New Issue
Block a user