#!/usr/bin/env bash # test-dormancy.sh — exercise the mailbox dormancy predicate (dormant_unless). # # Why this exists as a script and not a note: `evaluateDormancy` decides whether # an ask is SHOWN to the agent, so a silent regression there does not look like a # bug — it looks like an empty mailbox. The positive arms matter as much as the # negative ones: a predicate that never fires makes the feature a no-op, and a # predicate that fires too eagerly hides real work. Both arms run here. # # It cannot import extensions/pi/mempalace.ts in place, because that file imports # `typebox`, which pi provides at RUNTIME and this repo has no node_modules for. # So it copies the file into a temp tree with the resolved deps symlinked beside # it. The copy is made BY this script on every run, so it cannot drift from the # source the way a vendored duplicate would. # # Fixtures are created here rather than read from the host: the first draft used # /etc/hostname and /etc/pi-devbox/build-manifest.json, which made the positive # arms pass only on a pi-devbox container and silently flip to "not dormant" # anywhere else — a device-dependent test that reports success by doing nothing. # # Exit codes, deliberately distinct: # 0 every case behaved as specified # 1 at least one case FAILED (a real regression) # 2 INCONCLUSIVE — deps could not be resolved, so nothing was proven # (never 0: a test that skips quietly is the failure mode it should catch) set -uo pipefail # ── Args ────────────────────────────────────────────────────────────────────── if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then sed -n '2,25p' "$0" | sed 's/^# \?//' exit 0 fi REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SRC="$REPO_ROOT/extensions/pi/mempalace.ts" [[ -r "$SRC" ]] || { echo "INCONCLUSIVE: cannot read $SRC" >&2 exit 2 } # ── Resolve pi's runtime deps (discovered, never hardcoded) ─────────────────── GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)" PI_PKG="" for cand in \ "${GLOBAL_ROOT:+$GLOBAL_ROOT/@earendil-works/pi-coding-agent}" \ "/usr/lib/node_modules/@earendil-works/pi-coding-agent" \ "/usr/local/lib/node_modules/@earendil-works/pi-coding-agent"; do [[ -n "$cand" && -d "$cand" ]] && { PI_PKG="$cand" break } done [[ -n "$PI_PKG" && -d "$PI_PKG/node_modules/typebox" ]] || { echo "INCONCLUSIVE: pi-coding-agent / typebox not resolvable (looked under 'npm root -g')." >&2 echo " Nothing was proven. Install pi, or run this where pi is installed." >&2 exit 2 } # Node must be able to strip types from a .ts entry point (Node >= 22.6). node -e 'process.exit(0)' 2>/dev/null || { echo "INCONCLUSIVE: no usable node" >&2 exit 2 } # ── Build the throwaway tree ────────────────────────────────────────────────── WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT mkdir -p "$WORK/extensions/pi" "$WORK/node_modules/@earendil-works" cp "$SRC" "$WORK/extensions/pi/mempalace.ts" ln -s "$PI_PKG/node_modules/typebox" "$WORK/node_modules/typebox" ln -s "$PI_PKG" "$WORK/node_modules/@earendil-works/pi-coding-agent" printf '{"type":"module"}\n' > "$WORK/package.json" # Prove the copy is the source, so a PASS cannot be about a stale file. if ! cmp -s "$SRC" "$WORK/extensions/pi/mempalace.ts"; then echo "INCONCLUSIVE: the copy differs from the source" >&2 exit 2 fi # ── Fixtures, owned by this test ────────────────────────────────────────────── printf '{"release_tag":"v1.9.4","nested":{"deep":{"leaf":"found"}},"count":3}\n' > "$WORK/manifest.json" printf 'not json at all\n' > "$WORK/notjson.txt" : > "$WORK/stamp" # ── The truth table ─────────────────────────────────────────────────────────── cat > "$WORK/run.mjs" <<'EOF' import { statSync } from "node:fs"; import { evaluateDormancy } from "./extensions/pi/mempalace.ts"; const W = process.env.WORK; const STAMP = `${W}/stamp`; const MAN = `${W}/manifest.json`; // Floor to whole seconds: that is the contract, and the residue below proves // why the implementation must do the same. const atBaseline = new Date(Math.floor(statSync(STAMP).mtimeMs / 1000) * 1000).toISOString(); const mt = (baseline, path = STAMP) => ({ kind: "file_mtime", path, baseline }); const jf = (field, baseline, path = MAN) => ({ kind: "json_field", path, field, baseline }); const cases = [ // No predicate => behave exactly as before the feature existed. ["no metadata", undefined, false], ["metadata without the key", { foo: "bar" }, false], ["dormant_unless is a string", { dormant_unless: "release != x" }, false], ["dormant_unless is empty", { dormant_unless: [] }, false], ["more than 8 conditions", { dormant_unless: Array(9).fill(mt(atBaseline)) }, false], // POSITIVE arms. If these ever read false the feature is inert. ["file_mtime at baseline", { dormant_unless: [mt(atBaseline)] }, true], ["json_field at baseline", { dormant_unless: [jf("release_tag", "v1.9.4")] }, true], ["both at baseline", { dormant_unless: [jf("release_tag", "v1.9.4"), mt(atBaseline)] }, true], ["dotted field path", { dormant_unless: [jf("nested.deep.leaf", "found")] }, true], ["number vs string baseline", { dormant_unless: [jf("count", "3")] }, true], // The trigger firing: ANY condition differing wakes the ask. ["file_mtime moved", { dormant_unless: [mt("2020-01-01T00:00:00Z")] }, false], ["json_field moved", { dormant_unless: [jf("release_tag", "v1.9.5")] }, false], ["one same, one moved", { dormant_unless: [mt(atBaseline), jf("release_tag", "v1.9.5")] }, false], // FAIL-VISIBLE arms: dormancy unproven => announce. ["missing file", { dormant_unless: [mt(atBaseline, "/nonexistent/path")] }, false], ["unparseable baseline", { dormant_unless: [mt("not-a-date")] }, false], ["relative path", { dormant_unless: [{ kind: "file_mtime", path: "etc/hostname", baseline: atBaseline }] }, false], ["unknown kind", { dormant_unless: [{ kind: "uptime_lt", path: "/etc/hostname", baseline: "1d" }] }, false], ["missing json field", { dormant_unless: [jf("no_such_field", "x")] }, false], ["file is not JSON", { dormant_unless: [jf("a", "b", `${W}/notjson.txt`)] }, false], ["field is not scalar", { dormant_unless: [jf("nested", "x")] }, false], ["condition is not an object", { dormant_unless: ["release_tag"] }, false], ["baseline is an object", { dormant_unless: [{ kind: "file_mtime", path: STAMP, baseline: {} }] }, false], ]; let pass = 0; const failed = []; for (const [name, meta, want] of cases) { const v = evaluateDormancy(meta); const ok = v.dormant === want; if (ok) pass++; else failed.push(name); console.log( ` ${ok ? "PASS" : "FAIL"} dormant=${String(v.dormant).padEnd(5)} want=${String(want).padEnd(5)} ` + `${name.padEnd(28)} :: ${v.reason}`, ); } console.log(`\n ${pass}/${cases.length} passed`); if (failed.length) console.log(` FAILED: ${failed.join(", ")}`); // Recorded because it is the reason file_mtime floors to seconds: a real mtime // carries sub-second residue that an ISO baseline does not. console.log(` (stamp mtimeMs=${statSync(STAMP).mtimeMs}, baseline=${atBaseline})`); process.exit(failed.length === 0 ? 0 : 1); EOF cd "$WORK" || exit 2 export WORK node "$WORK/run.mjs" exit $?