diff --git a/CHANGELOG.md b/CHANGELOG.md index df3d67a..0d37083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. ## Unreleased +- perf(ui-loop): **`test-stack-up.sh` recognises a live `mxcli run --local` as this project's app, and `--restart` no longer builds twice.** The script read only Docker labels, so an app started with `./mxcli run --local --watch` (mxcli's own ~20s page loop) was found by port scan, published as `APP_OWNERSHIP=unverified`, and refused by the e2e harness unless `ALLOW_UNVERIFIED_APP=1`. It now reads mxcli's `.mxcli/run-local.json` handshake (v0.24+; beside the `.mpr` or the `-p` path on a two-tree checkout), checks its pid is alive, and publishes `verified` with a new `APP_SOURCE=run-local` line in `stack.env` (`docker` / `scan` otherwise). A model edited after boot reads as applied under `--watch` and as STALE without it — the script never restarts a loop it did not start. Docker bring-up and restart now wait on `mxcli docker run --wait` instead of a 240s poll loop, and still prove HTTP; `--restart` used to fall through into the bring-up block and run `docker run` a second time. New fixture `tests/wave2/test-stack-up-local.sh` over a redacted capture of the handshake. Field run 2026-10-07, existing app: same app, same port, `unverified` → `verified`; a touched model read `watch`; stopping the loop removed the handshake and the script fell back. — MendixMau - perf(routing): **CLAUDE.local.md routing carries triggers only.** The `baseline` view (the copy loaded into every session of every project) now cuts each row's "when" cell at its first " — " and replaces the three-paragraph preamble with two lines pointing at `ROUTING.md` for the full text; `readme-baseline` (README, runbook) is unchanged. Fresh scaffold: CLAUDE.local.md 17.5 KB → 11.7 KB (−33%). Field run 2026-10-07 (existing app, one main session per run, 2 runs per side, an entity + 2 microflows + 2 pages): 10/10 steps applied each side, new mx errors 5 vs 5 (the same CE2421 page-binding slip in both), cost $1.39 vs $1.38; the slim lanes opened the MDL pre-flight skill as often or more. Also drops two stray merge-artifact lines from this section. — MendixMau - perf(ui-loop): **fewer mxbuilds, no fixed sleeps, legible screenshots.** Field run 2026-10-07 (a requirements-driven build): `exec.sh` was 22% of tool time at two mxbuilds per exec, journeys and page waits slept a fixed time, and screenshots were 11.5% of re-read tokens. `exec.sh` now skips the pre-flight baseline mxbuild when the model is byte-identical to its last *clean* pass: the verification stamp gains an `errors:` field (`model-stamp.sh check --clean` accepts only `errors: 0`, so a delta-gate pass over pre-existing errors never counts; `SKIP_BASELINE=0` is unchanged). New `tests/e2e/settle.js` waits for the page's own state (no visible Mendix progress/loading indicator, DOM size stable) instead of a fixed time; `journey-runner.js` uses it after each action (an explicit `settleMs` still wins) and `page-audit.js` per page. Probed in a real browser: on a page that loads in 1.8 s the old 1200 ms sleep and networkidle+600 both captured it half-loaded, settle waited for it; on a fast page settle took 775 ms vs 1200. `SETTLE_MODE=fixed` restores the old waits for an A/B run. `page-audit.js` also saves a viewport shot `page-audit-.top.png` beside the full-page one, and `ui-loop.md` says to look at viewport shots: a tall full-page shot reaches the model at ~1024 px high, text unreadable. `shrink-image-read.sh` now works off macOS (ImageMagick or Pillow, python when jq is absent; it was a silent no-op without `sips`). — MendixMau - perf(ui-loop): **a stale app reloads instead of restarting, journeys stop sleeping, worst pages are looked at first.** Field run 2026-10-07, existing app. `test-stack-up.sh` records which model the running app was built from (`.claude/loop/served-model`); when the model has changed since, it hot-reloads with `mxcli docker reload` (adding `--skip-check` when the exec.sh gate already passed this model) and restarts only if that fails: 93 s instead of 179 s. `ui-loop.md` and the script header carry the measured table and point at `mxcli run --local --watch` as the fast loop (~18 s per page change). `journey-runner.js` waits on page state after a combobox action instead of fixed pauses, and a combobox step now opens the widget it names: it used to open the page's first combobox, so on a form with three every step but the first failed. `page-audit.js` clicks the first copy of a nav item a user could actually click, not just the first match: with the sidebar collapsed, every in-nav page faulted while the same item sat in the top menu bar. It also writes `page-audit-look-order.txt`, all pages worst first (`module-review.md` §4a: order, not selection, every page is still looked at). `design-audit.js` drops its fixed 1.2 s wait. Same 7-step journey on the same app: 56 s with `SETTLE_MODE=fixed`, 24 s with settle, 11/11 pass both ways, 3 runs each. — MendixMau diff --git a/project-bin/test-stack-up.sh b/project-bin/test-stack-up.sh index 09abf59..01f467a 100755 --- a/project-bin/test-stack-up.sh +++ b/project-bin/test-stack-up.sh @@ -48,6 +48,16 @@ # proof-of-ownership step. `docker reload --css` is NOT a theme loop: it copies the theme without # compiling SCSS, so a main.scss edit does not show. # +# RUN --LOCAL IS RECOGNISED, NOT REPLACED. `mxcli run --local` (v0.24+) publishes +# /.mxcli/run-local.json while it serves — pid and app port, written when the app is ready, +# removed on exit. A live pid in OUR project's directory plus a Mendix answer on its port is the same +# kind of claim as the compose label: it cannot be true of two projects at once. So that app is +# ownership `verified` (APP_SOURCE=run-local), where it used to scan as `unverified` and the e2e +# config refused it — the recommended fast loop was the one the harness would not test. This script +# never restarts or reloads it (it did not start it): `--watch` applies each change itself, and +# without --watch a model newer than the handshake is reported stale with the restart to run. Only +# pid and appPort are read; the file also holds the admin password and boot config, never copied. +# # Exit codes: 0 = required stack is up · 1 = not up and could not fix · 2 = usage/env error # # PROJECT CONFIGURATION — no project name, port or service path is hardcoded here. Put anything @@ -176,7 +186,7 @@ EOF return 1 } -# find_app_port emits "|" on stdout, NOT a bare port. +# find_app_port emits "||" on stdout, NOT a bare port. # # It is called in a command substitution, so it runs in a subshell and cannot set a variable in the # parent — the ownership verdict has to travel on stdout with the port or it is lost. @@ -189,11 +199,13 @@ EOF # someone else's software. find_app_port() { local p owned + # 0. A live `mxcli run --local` of this project (its handshake names the port). + if owned="$(local_loop_port)" && mendix_at "$owned"; then echo "$owned|verified|run-local"; return 0; fi # 1. Ownership: which port does THIS project's container publish? if owned="$(owned_app_port)" && [ -n "$owned" ]; then - if mendix_at "$owned"; then echo "$owned|verified"; return 0; fi + if mendix_at "$owned"; then echo "$owned|verified|docker"; return 0; fi warn "our container publishes :$owned but it is not serving Mendix yet" >&2 - echo "$owned|verified"; return 1 + echo "$owned|verified|docker"; return 1 fi # 2. No container of ours is running (SP-hosted run, or docker unavailable). Fall back to the # scan — but say so, because a hit here is UNVERIFIED ownership: it proves a Mendix answered, @@ -203,10 +215,62 @@ find_app_port() { warn "no container owned by $ROOT/.docker is running; :$p matched by scan — ownership UNVERIFIED" >&2 warn " → recorded as APP_OWNERSHIP=unverified in stack.env. A test harness should REFUSE an" >&2 warn " unverified port unless ALLOW_UNVERIFIED_APP=1, because :$p may be another project." >&2 - echo "$p|unverified"; return 0 + echo "$p|unverified|scan"; return 0 fi done - echo "|none"; return 1 + echo "|none|none"; return 1 +} + +# A live `mxcli run --local` serving THIS project: echoes its app port. See RUN --LOCAL above. +# Fields are read at MarshalIndent's two-space top level, so a key inside bootConfig cannot match. +MPR_DIR="$(cd "$(dirname "$MPR")" && pwd)" +# A two-tree checkout may carry a root symlink to app/X.mpr. mprcontents/ sits beside the real +# file, and mxcli writes the handshake beside whichever path -p was given — so look in both. +MODEL_DIR="$MPR_DIR" +if [ -L "$MPR" ]; then + _t="$(readlink "$MPR")" + case "$_t" in /*) ;; *) _t="$MPR_DIR/$_t" ;; esac + MODEL_DIR="$(cd "$(dirname "$_t")" 2>/dev/null && pwd -P)" || MODEL_DIR="$MPR_DIR" +fi +LOCAL_HS="$MPR_DIR/.mxcli/run-local.json" +pick_local_hs() { + local d + for d in "$MPR_DIR" "$MODEL_DIR"; do + [ -f "$d/.mxcli/run-local.json" ] && { LOCAL_HS="$d/.mxcli/run-local.json"; return 0; } + done + return 1 +} +pick_local_hs || true +hs_field() { sed -n "s/^ \"$1\": *\([0-9][0-9]*\).*/\1/p" "$LOCAL_HS" 2>/dev/null | head -1; } +pid_alive() { + [ -n "$1" ] || return 1 + case "$(mxtk_platform)" in + # mxcli.exe records a Windows pid; Git Bash's kill only knows MSYS pids. + windows) tasklist //FI "PID eq $1" //NH 2>/dev/null | grep -q " $1 " ;; + *) kill -0 "$1" 2>/dev/null ;; + esac +} +local_loop_port() { + local pid port + pick_local_hs || return 1 + pid="$(hs_field pid)"; port="$(hs_field appPort)" + [ -n "$port" ] && pid_alive "$pid" || return 1 + echo "$port" +} +# yes | no | unknown — was the loop started with --watch? Read from its command line where ps can +# show one; Windows processes do not expose it to Git Bash. +local_loop_watch() { + local pid args + pid="$(hs_field pid)" + args="$(ps -o args= -p "$pid" 2>/dev/null)" || { echo unknown; return; } + [ -n "$args" ] || { echo unknown; return; } + case "$args" in *--watch*) echo yes ;; *) echo no ;; esac +} +# Did the model change since the loop booted? The handshake is written when the app is ready, so +# its mtime is the boot's model; any .mpr or mprcontents file newer than it is a later edit. +local_model_changed() { + [ -n "$(find "$MODEL_DIR" -maxdepth 1 -name '*.mpr' -newer "$LOCAL_HS" 2>/dev/null | head -1)" ] && return 0 + [ -d "$MODEL_DIR/mprcontents" ] && [ -n "$(find "$MODEL_DIR/mprcontents" -type f -newer "$LOCAL_HS" 2>/dev/null | head -1)" ] } # Single source of truth for the discovered ports. This script is the only thing that PROVES which @@ -215,7 +279,7 @@ find_app_port() { # 8081 and a skill said 8080 — three "truths", and a spec pointed at a dead port reports the app as # broken rather than as unreachable. STACK_ENV="${STACK_ENV:-$ROOT/.claude/loop/stack.env}" -# publish_stack_env +# publish_stack_env # APP_OWNERSHIP is not decoration: it is the difference between "this port is ours" and "a Mendix # answered here". Consumers must be able to tell those apart from the file alone, because the stderr # warning that distinguishes them is gone by the time anyone reads it. @@ -225,6 +289,7 @@ publish_stack_env() { echo "# written by bin/test-stack-up.sh — $(date -u +%Y-%m-%dT%H:%M:%SZ). Do not edit." echo "APP_PORT=$1" echo "APP_OWNERSHIP=${3:-unknown}" + echo "APP_SOURCE=${4:-unknown}" [ -n "$MOCK_PORT" ] && echo "MOCK_PORT=$MOCK_PORT" [ "$2" -eq 0 ] && echo "JAEGER_PORT=$JAEGER_PORT" || echo "JAEGER_PORT=" } > "$STACK_ENV" @@ -246,6 +311,13 @@ record_served() { # Echoes fresh | stale | unknown. served_state() { local want have + # run --local: mxcli rebuilt nothing for us to record; the handshake's mtime is the boot. + if [ "${APP_SOURCE:-}" = "run-local" ]; then + pick_local_hs || true + if ! local_model_changed; then echo fresh + else case "$(local_loop_watch)" in yes) echo watch ;; no) echo stale ;; *) echo changed ;; esac; fi + return + fi have="$(head -1 "$SERVED_FILE" 2>/dev/null)" want="$(model_fp)" || want="" if [ -z "$have" ] || [ -z "$want" ]; then echo unknown @@ -253,6 +325,19 @@ served_state() { else echo stale; fi } +# After `docker run --wait` returned: prove over HTTP that OUR app answers (design rule 3). mxcli's +# wait reads the runtime log, so this normally passes on the first probe. Sets APP_* in this shell. +prove_app() { + local waited=0 + while :; do + APP_FIND="$(find_app_port)" && break + [ $waited -ge 30 ] && break + sleep 3; waited=$((waited + 3)) + done + IFS='|' read -r APP_PORT APP_OWNERSHIP APP_SOURCE <<<"$APP_FIND" + mendix_at "$APP_PORT" +} + echo "── Test stack: $PROJ ──────────────────────────────────" # --- Studio Pro: report only, never act ------------------------------------- @@ -277,10 +362,11 @@ fi # --- App -------------------------------------------------------------------- APP_FIND="$(find_app_port)" APP_FOUND=$? -APP_PORT="${APP_FIND%%|*}" -APP_OWNERSHIP="${APP_FIND##*|}" +IFS='|' read -r APP_PORT APP_OWNERSHIP APP_SOURCE <<<"$APP_FIND" if [ $APP_FOUND -eq 0 ]; then - if [ "$APP_OWNERSHIP" = "verified" ]; then + if [ "$APP_SOURCE" = "run-local" ]; then + ok "App serving on :$APP_PORT (ownership verified — this project's mxcli run --local)" + elif [ "$APP_OWNERSHIP" = "verified" ]; then ok "App serving on :$APP_PORT (ownership verified — our container)" else warn "App serving on :$APP_PORT — ownership $APP_OWNERSHIP, this may be another project" @@ -293,7 +379,10 @@ if [ $APP_FOUND -eq 0 ]; then SERVED="$(served_state)" case "$SERVED" in fresh) ok "App serves the current model" ;; - stale) warn "App serves an OLDER model — the model changed since it was built; tests would measure the old build" ;; + stale) warn "App serves an OLDER model — the model changed since it was built; tests would measure the old build" + [ "$APP_SOURCE" = "run-local" ] && warn " → run --local was started without --watch: stop it and start ./mxcli run --local --watch -p $(basename "$MPR")" ;; + watch) ok "Model changed since boot — run --local --watch applies it (~20s a page change, ~60s security/navigation)" ;; + changed) warn "Model changed since run --local booted; cannot see whether it runs with --watch. Without --watch it serves the old model" ;; unknown) warn "App freshness unknown — not built by this script (Studio Pro / manual run); it may serve an old model" ;; esac fi @@ -346,7 +435,7 @@ fi # Publish as soon as the port is MEASURED, not only when the whole stack is READY. A down mock makes # REST-fed specs invalid; it does not change which port the app answers on, and the non-REST specs # still need to know it. -[ $APP_FOUND -eq 0 ] && publish_stack_env "$APP_PORT" "$JAEGER_OK" "$APP_OWNERSHIP" +[ $APP_FOUND -eq 0 ] && publish_stack_env "$APP_PORT" "$JAEGER_OK" "$APP_OWNERSHIP" "$APP_SOURCE" if [ "$MODE" = "check" ]; then echo "────────────────────────────────────────────────────────" @@ -354,6 +443,10 @@ if [ "$MODE" = "check" ]; then echo "APP UP, BUT REST-FED SPECS WILL BE INVALID — see the container warning above" exit 1 fi + if [ "$SERVED" = "stale" ] && [ "$APP_SOURCE" = "run-local" ]; then + echo "APP UP BUT STALE — restart mxcli run --local with --watch (this script does not restart a loop it did not start)" + exit 1 + fi if [ "$SERVED" = "stale" ] && [ "$APP_OWNERSHIP" = "verified" ]; then echo "APP UP BUT STALE — re-run without --check to hot-reload the current model" exit 1 @@ -383,9 +476,10 @@ if [ $MOCK_REQUIRED -eq 1 ] && [ $MOCK_OK -ne 0 ]; then fi # --- Warm loop: refresh a running app instead of restarting it -------------- -# Only for OUR container (ownership verified): a reload swaps the model in a runtime, and doing that -# to an unverified port could be another project's app or a Studio Pro run. -if [ $APP_FOUND -eq 0 ] && [ "$APP_OWNERSHIP" = "verified" ] && [ -n "$MXCLI" ] \ +# Only for OUR container (ownership verified, source docker): a reload swaps the model in a runtime, +# and doing that to an unverified port could be another project's app or a Studio Pro run. A +# run --local loop is never reloaded from here — --watch does that, inside the process that owns it. +if [ $APP_FOUND -eq 0 ] && [ "$APP_SOURCE" = "docker" ] && [ -n "$MXCLI" ] \ && [ "${MXTK_WARM_RELOAD:-on}" != "off" ] && [ "$MODE" != "nodocker" ] \ && { [ "$SERVED" != "fresh" ] || [ "$MODE" = "restart" ]; }; then T0=$(date +%s) @@ -419,18 +513,25 @@ if [ $APP_FOUND -eq 0 ] && [ "$APP_OWNERSHIP" = "verified" ] && [ -n "$MXCLI" ] fi if [ $RELOADED -ne 0 ]; then echo "→ Rebuilding and restarting the container..." - "$MXCLI" docker run -p "$MPR" --wait >"$DOCKER_LOG" 2>&1 + "$MXCLI" docker run -p "$MPR" --wait --wait-timeout "$BOOT_TIMEOUT" >"$DOCKER_LOG" 2>&1 if [ $? -ne 0 ]; then bad "mxcli docker run failed — see $DOCKER_LOG"; tail -20 "$DOCKER_LOG"; exit 1 fi - APP_FOUND=1 # re-proved below by the boot-wait loop + # Proved here, not by falling into "Bring up the app" below: that block runs `docker run` + # again, and did — every restart built and booted the app twice. + if prove_app; then + record_served; SERVED=fresh + ok "Restarted in $(( $(date +%s) - T0 ))s — app serves the current model" + else + bad "mxcli reported the runtime started, but no Mendix answers over HTTP — see $DOCKER_LOG"; exit 1 + fi fi fi # --- Bring up the app ------------------------------------------------------- if [ $APP_FOUND -ne 0 ]; then if [ "$MODE" = "nodocker" ]; then - bad "App down and --no-docker given. Click Run Locally in Studio Pro (or, with no Studio Pro, ./mxcli run --local), then re-run --check." + bad "App down and --no-docker given. Click Run Locally in Studio Pro (or, with no Studio Pro, ./mxcli run --local --watch), then re-run --check." exit 1 fi # No reachable Docker (or Podman) daemon: say so and name the Docker-free route, instead of letting @@ -450,7 +551,8 @@ if [ $APP_FOUND -ne 0 ]; then bad "App down and no Docker/Podman reachable, so this script cannot build the app container." echo " Normal in a cloud container, and fine on a desktop without one. Run the app without it:" echo " Studio Pro: Run Locally. No Studio Pro:" - echo " ./mxcli run --local -p $(basename "$MPR") # flags: skills/cloud-dev-environment.md" + echo " ./mxcli run --local --watch -p $(basename "$MPR") # flags: skills/cloud-dev-environment.md" + echo " This script then sees it as yours (verified) and --watch keeps it current." echo " Snapshot first — mxcli run --local consolidates a split-model .mpr. Then re-run with --check." exit 1 fi @@ -463,9 +565,9 @@ if [ $APP_FOUND -ne 0 ]; then echo " This can take several minutes on a cold build." # Do NOT pipe mxcli: reading $? through a pipe measures the pipe, not the command. - # (this script runs without `set -e` on purpose — the boot-wait loop below relies on - # find_app_port returning non-zero repeatedly without killing the script) - "$MXCLI" docker run -p "$MPR" >"$DOCKER_LOG" 2>&1 + # --wait: mxcli follows the runtime log until it reports started, so the boot wait is mxcli's. + # The loop below is the HTTP proof (design rule 3) and normally passes on its first probe. + "$MXCLI" docker run -p "$MPR" --wait --wait-timeout "$BOOT_TIMEOUT" >"$DOCKER_LOG" 2>&1 DOCKER_RC=$? if [ $DOCKER_RC -ne 0 ]; then bad "mxcli docker run failed (rc=$DOCKER_RC) — see $DOCKER_LOG" @@ -473,33 +575,28 @@ if [ $APP_FOUND -ne 0 ]; then exit 1 fi - echo "→ Waiting for the app to answer (timeout ${BOOT_TIMEOUT}s)..." - WAITED=0 - while [ $WAITED -lt "$BOOT_TIMEOUT" ]; do - APP_FIND="$(find_app_port)" && { APP_FOUND=0; break; } - sleep 3; WAITED=$((WAITED + 3)) - [ $((WAITED % 30)) -eq 0 ] && echo " ...${WAITED}s" - done - APP_PORT="${APP_FIND%%|*}" - APP_OWNERSHIP="${APP_FIND##*|}" - - if [ $APP_FOUND -eq 0 ]; then + if prove_app; then + APP_FOUND=0 record_served - ok "App serving on :$APP_PORT after ${WAITED}s (ownership $APP_OWNERSHIP)" + ok "App serving on :$APP_PORT (ownership $APP_OWNERSHIP)" else - bad "App still not answering after ${BOOT_TIMEOUT}s — see $DOCKER_LOG" + bad "mxcli reported the runtime started, but no Mendix answers over HTTP — see $DOCKER_LOG" "$MXCLI" docker status -p "$MPR" 2>&1 | tail -5 exit 1 fi fi echo "────────────────────────────────────────────────────────" +if [ "$APP_SOURCE" = "run-local" ] && [ "$SERVED" = "stale" ]; then + echo "NOT READY — the run --local app serves an older model; restart it with --watch" + exit 1 +fi if [ $APP_FOUND -eq 0 ] && [ $MOCK_OK -eq 0 ]; then # re-publish: both the port AND the ownership can change across a Docker boot — before the boot no # container of ours was running (so any hit was an unverified scan); after it, one is. - publish_stack_env "$APP_PORT" "$JAEGER_OK" "$APP_OWNERSHIP" + publish_stack_env "$APP_PORT" "$JAEGER_OK" "$APP_OWNERSHIP" "$APP_SOURCE" echo "READY — ports published to ${STACK_ENV#$ROOT/}; the e2e config reads them automatically" - echo " app :$APP_PORT (ownership $APP_OWNERSHIP)" + echo " app :$APP_PORT (ownership $APP_OWNERSHIP, $APP_SOURCE)" [ -n "$MOCK_PORT" ] && echo " mock :$MOCK_PORT" echo " jaeger $([ $JAEGER_OK -eq 0 ] && echo ":$JAEGER_PORT" || echo 'DOWN — Trace assertions will not run')" exit 0 diff --git a/project-tests/e2e/config.js b/project-tests/e2e/config.js index 8b626a5..23c54cb 100644 --- a/project-tests/e2e/config.js +++ b/project-tests/e2e/config.js @@ -34,7 +34,8 @@ const ROOT = PROJ.root; // // test-stack-up.sh now records APP_OWNERSHIP: `verified` means the port is published // by a container whose compose working_dir is THIS project (the one identity claim -// that cannot be true of two projects at once); `unverified` means a port scan found +// that cannot be true of two projects at once), or by a live `mxcli run --local` whose +// .mxcli/run-local.json sits beside THIS project's .mpr; `unverified` means a port scan found // a Mendix and nothing more. We refuse `unverified` rather than warn about it — // this whole harness exists because a warning on stderr is not a guard. function resolveStack() { diff --git a/skills/harness-architecture.md b/skills/harness-architecture.md index 08969e0..59925ca 100644 --- a/skills/harness-architecture.md +++ b/skills/harness-architecture.md @@ -34,11 +34,13 @@ APP_PORT= APP_OWNERSHIP=verified | unverified | asserted | unknown ``` -`verified` means a container owned by this project's `.docker` is serving that port. `unverified` +`verified` means a container owned by this project's `.docker` is serving that port, or a live +`mxcli run --local` of this project is (its `.mxcli/run-local.json` names the port; `APP_SOURCE` +says which: `docker` | `run-local` | `scan`). `unverified` means a Mendix answered a port from the `APP_PORTS` fallback scan list — *a* Mendix, not necessarily yours. **A harness must refuse an unverified port** unless the operator sets `ALLOW_UNVERIFIED_APP=1`, and must record the ownership it acted on. **VERIFIED** -(`project-bin/test-stack-up.sh:150-198`). +(`project-bin/test-stack-up.sh`, `owned_app_port` / `local_loop_port` / `find_app_port`). This is not defensive decoration. On one measured occasion a published `stack.env` named a port that belonged to a *different* project's Mendix, which answered 200 with a real login page. Every diff --git a/skills/report-schema.md b/skills/report-schema.md index f805475..b564068 100644 --- a/skills/report-schema.md +++ b/skills/report-schema.md @@ -492,7 +492,7 @@ reported normally. | Value | Means | Basis | |---|---|---| -| `verified` | a container published this port **and** its compose `working_dir` label is this project — the one identity claim that cannot be true of two projects at once | `APP_OWNERSHIP=verified` in `.claude/loop/stack.env`, written by `bin/test-stack-up.sh` | +| `verified` | a container published this port **and** its compose `working_dir` label is this project — the one identity claim that cannot be true of two projects at once; or a live `mxcli run --local` whose `.mxcli/run-local.json`, beside this project's `.mpr`, names the port (`APP_SOURCE=run-local`) | `APP_OWNERSHIP=verified` in `.claude/loop/stack.env`, written by `bin/test-stack-up.sh` | | `asserted` | an operator said so and nothing checked | an `APP_PORT` env var, or the project's deployment config | | `unknown` | a Mendix answered and nothing more | a guess, or a `stack.env` predating the marker | diff --git a/skills/ui-loop.md b/skills/ui-loop.md index 78b5e64..2efc2b4 100644 --- a/skills/ui-loop.md +++ b/skills/ui-loop.md @@ -183,6 +183,12 @@ So keep `run --local --watch` running beside the session; `test-stack-up.sh` not Docker app and reloads it, but that path is five times slower. `docker reload --css` does not compile SCSS: a `main.scss` edit needs the build. +`test-stack-up.sh` recognises the watch loop as this project's app: `mxcli run --local` (v0.24+) +writes `.mxcli/run-local.json` beside the `.mpr` while it serves, and a live pid there plus a +Mendix answer on its port publishes `APP_OWNERSHIP=verified`, `APP_SOURCE=run-local`, so the e2e +config tests it without `ALLOW_UNVERIFIED_APP=1`. It never reloads or restarts that loop. Started +without `--watch`, a model edited since boot is reported stale with the restart to run. + ## When it finds something — which side is actually wrong The symptom is easy to see. **Where the fix goes is not, and the instinct is usually wrong.** diff --git a/tests/wave2/fixtures/run-local/run-local.json b/tests/wave2/fixtures/run-local/run-local.json new file mode 100644 index 0000000..bd34346 --- /dev/null +++ b/tests/wave2/fixtures/run-local/run-local.json @@ -0,0 +1,22 @@ +{ + "project": "/work/Fixture/Fixture.mpr", + "pid": 4242, + "appPort": 8180, + "adminPort": 8190, + "adminPass": "REDACTED", + "bootConfig": { + "BasePath": "/work/Fixture/deployment", + "DTAPMode": "D", + "DatabaseHost": "", + "DatabaseName": "default", + "DatabasePassword": "REDACTED", + "DatabaseType": "HSQLDB", + "DatabaseUserName": "sa", + "MicroflowConstants": { + "MyFirstModule.LogNode": "REDACTED", + "MyFirstModule.ServiceUrl": "REDACTED" + }, + "RuntimePath": "/opt/mendix/runtime" + }, + "started": "2026-10-07T12:27:57Z" +} \ No newline at end of file diff --git a/tests/wave2/test-stack-up-local.sh b/tests/wave2/test-stack-up-local.sh new file mode 100755 index 0000000..369bbd7 --- /dev/null +++ b/tests/wave2/test-stack-up-local.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# Fixtures for test-stack-up.sh recognising a live `mxcli run --local` as THIS project's app. +# +# mxcli (v0.24+) writes /.mxcli/run-local.json when the app is ready and removes it on +# exit. Before this change the script never read it: a run --local app was found by port scan, +# published as APP_OWNERSHIP=unverified, and the e2e harness refused it unless the operator set +# ALLOW_UNVERIFIED_APP=1. Field run 2026-10-07, existing app: same app, same port, now `verified`. +# +# The handshake is fixtures/run-local/run-local.json — a real capture from that field run, every +# value replaced (project path, pid, ports, passwords, constants), byte layout kept. Each case +# rewrites only pid/appPort/adminPass. The "app" is a python http.server serving a login page with +# a Mendix marker; the "mxcli" is `sleep` with its argv[0] set, so `ps` shows the command line the +# script reads --watch from. docker/podman are stubbed out so no real container can answer. +# Not fixtured: the Windows tasklist branch of pid_alive (CI is Linux). +# +# usage: test-stack-up-local.sh /path/to/test-stack-up.sh +set -uo pipefail + +SRC="${1:?usage: test-stack-up-local.sh /path/to/test-stack-up.sh}" +case "$SRC" in /*) ;; *) SRC="$PWD/$SRC" ;; esac +GOLDEN="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fixtures/run-local/run-local.json" +[ -f "$SRC" ] && [ -f "$(dirname "$SRC")/_common.sh" ] && [ -f "$GOLDEN" ] \ + || { echo "FIXTURE ERROR: need $SRC, its _common.sh, and $GOLDEN"; exit 2; } +. "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/bin/lib/portable.sh" +require_py +command -v curl >/dev/null 2>&1 || { echo "FIXTURE ERROR: needs curl"; exit 2; } + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/stackup-local.XXXXXX")" +PIDS="" +cleanup() { for p in $PIDS; do kill "$p" 2>/dev/null; done; rm -rf "$WORK"; } +trap cleanup EXIT +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; } +bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | sed 's/^/ FAIL-detail: /'; } + +# --- no container runtime: only our fake app can answer ---------------------------------- +mkdir -p "$WORK/stubs" +for t in docker podman; do printf '#!/bin/sh\nexit 1\n' > "$WORK/stubs/$t"; chmod +x "$WORK/stubs/$t"; done +export PATH="$WORK/stubs:$PATH" +unset PROJECT_ROOT MPR_FILE STACK_ENV STACK_CONF SERVED_FILE + +# --- the fake app ------------------------------------------------------------------------- +mkdir -p "$WORK/www" +printf '\n' > "$WORK/www/login.html" +"$PY" -I - "$WORK/www" "$WORK/port" >/dev/null 2>&1 <<'EOF' & +import functools, http.server, sys +H = functools.partial(http.server.SimpleHTTPRequestHandler, directory=sys.argv[1]) +s = http.server.ThreadingHTTPServer(("127.0.0.1", 0), H) +open(sys.argv[2], "w").write(str(s.server_address[1])) +s.serve_forever() +EOF +PIDS="$PIDS $!" +for _ in $(seq 1 50); do [ -s "$WORK/port" ] && break; sleep 0.1; done +APP="$(cat "$WORK/port" 2>/dev/null)" +[ -n "$APP" ] && curl -s --max-time 4 "http://localhost:$APP/login.html" | grep -q mxui \ + || { echo "FIXTURE ERROR: fake app did not come up"; exit 2; } + +# A process whose command line reads like mxcli's: fake_mxcli . +fake_mxcli() { + bash -c "exec -a 'mxcli $2' sleep 600" >/dev/null 2>&1 & + PIDS="$PIDS $!"; printf -v "$1" '%s' "$!" +} +fake_mxcli WATCH_PID 'run --local --watch -p Fixture.mpr' +fake_mxcli PLAIN_PID 'run --local -p Fixture.mpr' +sleep 0.3 # let exec -a replace bash, so ps shows the mxcli command line +sleep 0 & DEAD_PID=$!; wait "$DEAD_PID" 2>/dev/null + +ADMIN_MARK="fixture-admin-marker-7c1" # stands in for the adminPass value +OLD=202001010000 +BOOT=202101010000 # the handshake: newer than the model, older than any edit a case makes + +# mkproj -> echoes the project root +mkproj() { + local p="$WORK/$1" m + mkdir -p "$p/bin" "$p/.claude/loop" + cp "$SRC" "$p/bin/test-stack-up.sh"; cp "$(dirname "$SRC")/_common.sh" "$p/bin/" + # Nothing scanned or traced may answer: the run --local handshake is the only way in. + printf 'APP_PORTS="1"\nJAEGER_PORT="1"\n' > "$p/.claude/loop/stack.conf" + case "$2" in + root) m="$p" ;; + app) m="$p/app" ;; + symlink) m="$p/app"; ln -s app/Fixture.mpr "$p/Fixture.mpr" ;; + esac + mkdir -p "$m/mprcontents"; printf 'mpr' > "$m/Fixture.mpr"; printf 'u' > "$m/mprcontents/a.mxunit" + touch -t "$OLD" "$m/Fixture.mpr" "$m/mprcontents/a.mxunit" + echo "$p" +} +# handshake -> the golden, pointed at our fake app, written into /.mxcli/ +handshake() { + mkdir -p "$1/.mxcli" + sed -e "s/^ \"pid\": [0-9]*/ \"pid\": $2/" \ + -e "s/^ \"appPort\": [0-9]*/ \"appPort\": $APP/" \ + -e "s/^ \"adminPass\": \"[^\"]*\"/ \"adminPass\": \"$ADMIN_MARK\"/" "$GOLDEN" > "$1/.mxcli/run-local.json" + # Between the model (OLD) and now: a write and an edit in the same clock tick share an mtime, + # so an edit made right after this line would not read as newer (35/50 on a probe here). + touch -t "$BOOT" "$1/.mxcli/run-local.json" +} +run() { (cd "$1" && bash bin/test-stack-up.sh --check 2>&1); } +envv() { sed -n "s/^$2=//p" "$1/.claude/loop/stack.env" 2>/dev/null; } + +echo "== T0: the golden is what the script's reader expects ==" +grep -q '^ "pid": 4242,$' "$GOLDEN" && grep -q '^ "appPort": 8180,$' "$GOLDEN" \ + && ok "golden carries pid/appPort at MarshalIndent's two-space top level" || bad "golden shape changed" +grep -q "$ADMIN_MARK" "$GOLDEN" && bad "golden carries the test secret" || ok "golden carries no live secret" + +echo "== T1: live --watch loop, model unchanged -> verified, run-local, fresh ==" +P="$(mkproj t1 root)"; handshake "$P" "$WATCH_PID" +OUT="$(run "$P")"; RC=$? +[ "$RC" -eq 0 ] && ok "--check exits 0" || bad "--check rc=$RC" "$OUT" +printf '%s' "$OUT" | grep -q "ownership verified — this project's mxcli run --local" && ok "reported as this project's run --local" || bad "not reported as run --local" "$OUT" +printf '%s' "$OUT" | grep -q "App serves the current model" && ok "fresh" || bad "not fresh" "$OUT" +[ "$(envv "$P" APP_PORT)" = "$APP" ] && [ "$(envv "$P" APP_OWNERSHIP)" = verified ] && [ "$(envv "$P" APP_SOURCE)" = run-local ] \ + && ok "stack.env: APP_PORT=$APP APP_OWNERSHIP=verified APP_SOURCE=run-local" || bad "stack.env wrong" "$(cat "$P/.claude/loop/stack.env" 2>&1)" +grep -rq "$ADMIN_MARK" "$P/.claude" && bad "adminPass leaked into .claude/" || ok "adminPass not copied anywhere under .claude/" +printf '%s' "$OUT" | grep -q "$ADMIN_MARK" && bad "adminPass printed" || ok "adminPass not printed" + +echo "== T2: dead pid -> the handshake is ignored ==" +P="$(mkproj t2 root)"; handshake "$P" "$DEAD_PID" +OUT="$(run "$P")"; RC=$? +[ "$RC" -ne 0 ] && printf '%s' "$OUT" | grep -q "App not serving" && ok "not trusted: app reported not serving" || bad "dead-pid handshake trusted (rc=$RC)" "$OUT" +[ "$(envv "$P" APP_SOURCE)" = run-local ] && bad "stack.env says run-local for a dead loop" || ok "stack.env not claimed for a dead loop" + +echo "== T3: model edited, loop without --watch -> stale, exit 1 ==" +P="$(mkproj t3 root)"; handshake "$P" "$PLAIN_PID"; touch "$P/Fixture.mpr" +OUT="$(run "$P")"; RC=$? +[ "$RC" -eq 1 ] && ok "--check exits 1" || bad "--check rc=$RC" "$OUT" +printf '%s' "$OUT" | grep -q "APP UP BUT STALE — restart mxcli run --local with --watch" && ok "says restart with --watch" || bad "no stale verdict" "$OUT" + +echo "== T4: model edited, loop WITH --watch -> watch applies it, ready ==" +P="$(mkproj t4 root)"; handshake "$P" "$WATCH_PID"; touch "$P/mprcontents/a.mxunit" +OUT="$(run "$P")"; RC=$? +[ "$RC" -eq 0 ] && printf '%s' "$OUT" | grep -q "run --local --watch applies it" && ok "watch state, exit 0" || bad "watch not recognised (rc=$RC)" "$OUT" + +echo "== T5: two-tree (app/), handshake beside app/Fixture.mpr ==" +P="$(mkproj t5 app)"; handshake "$P/app" "$PLAIN_PID" +OUT="$(run "$P")"; RC=$? +[ "$RC" -eq 0 ] && [ "$(envv "$P" APP_SOURCE)" = run-local ] && ok "found under app/" || bad "two-tree handshake missed (rc=$RC)" "$OUT" +touch "$P/app/mprcontents/a.mxunit" +OUT="$(run "$P")"; RC=$? +[ "$RC" -eq 1 ] && printf '%s' "$OUT" | grep -q "APP UP BUT STALE" && ok "an app/mprcontents edit reads stale" || bad "app/ edit not seen (rc=$RC)" "$OUT" + +echo "== T6: two-tree with a root symlink, run as -p Fixture.mpr from the root ==" +P="$(mkproj t6 symlink)"; handshake "$P" "$PLAIN_PID" +OUT="$(run "$P")"; RC=$? +[ "$RC" -eq 0 ] && [ "$(envv "$P" APP_SOURCE)" = run-local ] && ok "found beside the symlink" || bad "symlinked handshake missed (rc=$RC)" "$OUT" +touch "$P/app/mprcontents/a.mxunit" +OUT="$(run "$P")"; RC=$? +[ "$RC" -eq 1 ] && printf '%s' "$OUT" | grep -q "APP UP BUT STALE" && ok "edit behind the symlink reads stale" || bad "edit behind symlink not seen (rc=$RC)" "$OUT" + +echo +echo "stack-up-local: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ]