Each scenario in test/scenarios/ checks one behavior end to end, through the real hooks and the ssh forced command, in a throwaway forge of its own (test/lib.sh): a temp root, a fixed clock, an isolated git config. test/run.sh runs them in parallel, or the ones matching a word, so a regression names itself without replaying the whole demo journey. The first scenarios pin down who may push what, push-to-create, the review log, the approval gate, landing over ssh, and a pages build. CI runs them before the demo.
- commit
- 9ca2d2565203f3b066d1fed0f50662b3bc360c9f
- parent
- b10421e1e036b434da77b80265b008e173d64ca4
- author
- cat <admin@dev.site>
- kind
- test risk score 0.5 of 3, Jev's estimate
| M | .forge/ | 2 | + |
| A | test/ | 164 | ++++++++++++++++++++++++++++++ |
| A | test/ | 48 | +++++++++ |
| A | test/ | 26 | +++++ |
| A | test/ | 30 | +++++ |
| A | test/ | 14 | +++ |
| A | test/ | 12 | ++ |
| A | test/ | 23 | ++++ |
| A | test/ | 24 | ++++ |
@@ -4,6 +4,8 @@ set -e
44 sh -n bin/forge demo/make-demo.sh55 npm ci --silent --no-audit --no-fund66 npx tsc -p .7+# Scenarios: one behavior each, in a throwaway forge of its own (test/lib.sh).8+sh test/run.sh79 # Strict templates: printing an undefined or null value fails the build.810 FORGE_STRICT=1 sh demo/make-demo.sh >/dev/null911 test -f demo/root/.forge/www/dami/greet/reviews/3/index.html@@ -0,0 +1,164 @@
1+# Scenario helpers. Each scenario is a script in test/scenarios/ that sources2+# this file, which gives it a throwaway forge of its own: a root in a temp3+# directory, a git config that ignores yours, and a fixed clock. Pushes go4+# over the local file transport, so the real hooks run; `ssh_as` and5+# `clone_ssh` go through `forge shell`, the forced command every key gets.6+# Nothing is shared between scenarios, so they run in any order or at once.7+#8+# user ana a user with a fresh ed25519 key9+# repo dami/greet [desc] a bare repo, as forge repo new makes it10+# clone dami dami/greet w a working clone as dami (file transport)11+# at w commit "msg" [file] commit in w (a new file each time by default)12+# push_as ana w origin x:y git push from w as ana; hooks see FORGE_USER13+# ssh_as ana land dami/greet 2 what `ssh git@host land ...` runs for ana14+# clone_ssh ana dami/greet w clone over the forced command, as ana15+# build [dami/greet] write pages (11ty) into $FORGE_WWW16+#17+# Assertions print what failed and stop the scenario:18+# ok cmd... cmd succeeds fails cmd... cmd fails19+# fails_with re cmd cmd fails, its output matches the regex re20+# has file re file matches re lacks file re it doesn't21+# exists path missing path eq got want22+set -eu23+24+T_HOME=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)25+FORGE=$T_HOME/bin/forge26+T=$(mktemp -d "${TMPDIR:-/tmp}/forge-scenario.XXXXXX")27+T=$(CDPATH= cd -- "$T" && pwd -P)28+# Processes a scenario starts in the background go in T_PIDS, to be stopped.29+T_PIDS=30+trap 'st=$?; [ -z "$T_PIDS" ] || kill $T_PIDS 2>/dev/null; [ -n "${FORGE_TEST_KEEP:-}" ] && echo "kept $T" >&2 || rm -rf "$T"; exit $st' EXIT31+32+export FORGE_ROOT=$T/root33+export FORGE_WWW=$FORGE_ROOT/.forge/www34+export FORGE_SSH=git@forge.test35+export FORGE_NO_BUILD=136+export FORGE_JEV=037+export GIT_CONFIG_GLOBAL=$T/gitconfig38+export GIT_CONFIG_NOSYSTEM=139+export FORGE_CACHE=$T/cache40+unset FORGE_USER FORGE_BASE_URL FORGE_PAGES_DOMAIN TYPESAFE_API_KEY 2>/dev/null || true41+42+cat >"$GIT_CONFIG_GLOBAL" <<'EOF'43+[init]44+ defaultBranch = main45+[advice]46+ detachedHead = false47+[user]48+ name = Test49+ email = test@example.com50+EOF51+52+# A fixed clock: commits and review logs get these times.53+T_NOW=178822080054+tick() {55+ T_NOW=$((T_NOW + ${1:-60}))56+ export GIT_AUTHOR_DATE="@$T_NOW +0000" GIT_COMMITTER_DATE="@$T_NOW +0000" FORGE_NOW=$T_NOW57+}58+tick 059+60+T_LOG=$T/out61+note() { printf ' %s\n' "$*" >&2; }62+fail() {63+ printf 'FAIL: %s\n' "$*" >&264+ if [ -s "$T_LOG" ]; then sed 's/^/ | /' "$T_LOG" | tail -n 20 >&2; fi65+ exit 166+}67+68+# run cmd...: run quietly, keep its output in $T_LOG for the assertions.69+run() { "$@" >"$T_LOG" 2>&1; }70+ok() { run "$@" || fail "expected success: $*"; }71+fails() { if run "$@"; then fail "expected failure: $*"; fi; }72+fails_with() {73+ re=$174+ shift75+ if run "$@"; then fail "expected failure: $*"; fi76+ grep -Eq -- "$re" "$T_LOG" || fail "output of '$*' doesn't match /$re/"77+}78+output() { cat "$T_LOG"; }79+has() { [ -f "$1" ] || fail "no file $1"; grep -Eq -- "$2" "$1" || fail "$1 doesn't match /$2/"; }80+lacks() { if [ -f "$1" ] && grep -Eq -- "$2" "$1"; then fail "$1 matches /$2/"; fi; }81+exists() { [ -e "$1" ] || [ -L "$1" ] || fail "missing $1"; }82+missing() { if [ -e "$1" ] || [ -L "$1" ]; then fail "unexpected $1"; fi; }83+eq() { [ "$1" = "$2" ] || fail "got '$1', want '$2'"; }84+85+forge() { "$FORGE" "$@"; }86+87+# A fresh forge: hooks, git-shell commands, keys.88+"$FORGE" server init >/dev/null 2>&189+mkdir -p "$T/keys" "$T/w"90+91+user() {92+ ssh-keygen -q -t ed25519 -N '' -C "$1@test" -f "$T/keys/$1"93+ "$FORGE" user add "$1" "$T/keys/$1.pub" >/dev/null 2>&194+}95+96+repo() { "$FORGE" repo new "$@" >/dev/null 2>&1; }97+98+# The client side of ssh, without sshd: what authorized_keys runs for the99+# key's user, with HOME at the forge, where git-shell finds its commands.100+cat >"$T/ssh" <<EOF101+#!/bin/sh102+while :; do case \$1 in -o | -p | -i) shift 2 ;; -*) shift ;; *) break ;; esac; done103+shift104+HOME="$FORGE_ROOT" SSH_ORIGINAL_COMMAND=\$* exec "$FORGE" shell "\${FORGE_USER:?}"105+EOF106+chmod +x "$T/ssh"107+108+ssh_as() {109+ u=$1110+ shift111+ (cd "$FORGE_ROOT" && HOME=$FORGE_ROOT FORGE_USER=$u SSH_ORIGINAL_COMMAND=$* "$FORGE" shell "$u")112+}113+114+_client() { # dir user115+ git -C "$1" config user.name "$2"116+ git -C "$1" config user.email "$2@example.com"117+ git -C "$1" config core.sshCommand "$T/ssh"118+ git -C "$1" config forge.signingkey "$T/keys/$2"119+ git -C "$1" config forge.allowedsigners "$FORGE_ROOT/.forge/allowed_signers"120+}121+122+# clone user slug dir: over the file transport (no read check; for setup).123+clone() {124+ git clone -q "$FORGE_ROOT/$2.git" "$3" 2>/dev/null125+ _client "$3" "$1"126+}127+128+# clone_ssh user slug dir: through forge shell, as a real client would.129+clone_ssh() {130+ FORGE_USER=$1 git -c core.sshCommand="$T/ssh" clone -q "$FORGE_SSH:$2.git" "$3" && _client "$3" "$1"131+}132+133+# fetch_ssh user slug: ls-remote through forge shell (a read).134+fetch_ssh() { FORGE_USER=$1 git -c core.sshCommand="$T/ssh" ls-remote "$FORGE_SSH:$2.git"; }135+136+# at dir cmd...: run cmd in a working clone.137+at() { d=$1; shift; (cd "$d" && "$@"); }138+139+T_N=0140+commit() {141+ tick142+ T_N=$((T_N + 1))143+ f=${2:-file$T_N.txt}144+ mkdir -p "$(dirname "$f")"145+ printf '%s %s\n' "$1" "$T_N" >>"$f"146+ git add -A147+ git commit -q -m "$1"148+}149+150+# push_as user dir args...: the hooks see FORGE_USER, as forge shell sets it.151+push_as() {152+ u=$1 d=$2153+ shift 2154+ (cd "$d" && FORGE_USER=$u git push -q "$@")155+}156+157+# push_ssh user dir args...: through the forced command (push-to-create, read checks).158+push_ssh() {159+ u=$1 d=$2160+ shift 2161+ (cd "$d" && FORGE_USER=$u git -c core.sshCommand="$T/ssh" push -q "$@")162+}163+164+build() { "$FORGE" build "$@" >"$T_LOG" 2>&1 || fail "build ${*:-all} failed"; }@@ -0,0 +1,48 @@
1+2+# Run the scenarios in test/scenarios/, each in a throwaway forge of its own3+# (test/lib.sh), several at once. A scenario is one behavior, end to end4+# through the real hooks and forced command, so a regression names itself.5+#6+# sh test/run.sh every scenario7+# sh test/run.sh private release the ones whose names contain a word8+# FORGE_TEST_JOBS=1 sh test/run.sh one at a time9+# FORGE_TEST_KEEP=1 sh test/run.sh x keep the temp forge to look at10+set -eu11+here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)12+jobs=${FORGE_TEST_JOBS:-$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)}13+out=$(mktemp -d "${TMPDIR:-/tmp}/forge-tests.XXXXXX")14+trap 'rm -rf "$out"' EXIT15+16+list=17+for s in "$here"/scenarios/*.sh; do18+ n=$(basename "$s" .sh)19+ if [ $# -gt 0 ]; then20+ hit=021+ for w in "$@"; do case $n in *"$w"*) hit=1 ;; esac; done22+ [ "$hit" = 1 ] || continue23+ fi24+ list="$list$s25+"26+done27+[ -n "$list" ] || { echo "no scenarios match: $*" >&2; exit 1; }28+29+# One scenario: its result line, and its output kept for a failure.30+export out31+printf '%s' "$list" | xargs -P "$jobs" -I{} sh -c '32+ s=$1 n=$(basename "$1" .sh) t0=$(date +%s)33+ if sh "$s" >"$out/$n.log" 2>&1; then r=ok; else r=FAIL; fi34+ printf "%-4s %s (%ss)\n" "$r" "$n" "$(($(date +%s) - t0))"35+ [ "$r" = ok ] || : >"$out/$n.failed"36+' sh {}37+38+failed=$(cd "$out" && ls ./*.failed 2>/dev/null | sed 's|^\./||; s|\.failed$||') || true39+total=$(printf '%s' "$list" | grep -c .)40+if [ -n "$failed" ]; then41+ for n in $failed; do42+ printf '\n--- %s\n' "$n"43+ cat "$out/$n.log"44+ done45+ printf '\n%s of %s scenarios failed\n' "$(printf '%s\n' "$failed" | wc -l | tr -d ' ')" "$total"46+ exit 147+fi48+printf '%s scenarios passed\n' "$total"@@ -0,0 +1,26 @@
1+# forge.requireapproval: main moves only to a tree someone other than the2+# review's author approved; approvals are keyed by tree.3+. "$(dirname "$0")/../lib.sh"4+user dami5+user ana6+repo dami/greet7+git --git-dir="$FORGE_ROOT/dami/greet.git" config forge.requireapproval true8+clone dami dami/greet "$T/w/dami"9+at "$T/w/dami" commit "first"10+# The first push of main has no review to approve, so it needs one too.11+fails_with "is not approved" push_as dami "$T/w/dami" origin main12+git --git-dir="$FORGE_ROOT/dami/greet.git" config forge.requireapproval false13+ok push_as dami "$T/w/dami" origin main14+git --git-dir="$FORGE_ROOT/dami/greet.git" config forge.requireapproval true15+16+at "$T/w/dami" git switch -q -c dami/feature17+at "$T/w/dami" commit "feature"18+ok push_as dami "$T/w/dami" origin dami/feature19+fails_with "an approval has to come from someone else" ssh_as dami approve dami/greet 120+fails_with "is not approved" push_as dami "$T/w/dami" origin dami/feature:main21+ok ssh_as ana approve dami/greet 122+has "$FORGE_ROOT/dami/greet.git/forge/reviews/1" '^approve [0-9]+ ana '23+# Rewording keeps the tree, so the approval still counts.24+at "$T/w/dami" git commit -q --amend -m "feature, reworded"25+ok push_as dami "$T/w/dami" -f origin dami/feature26+ok push_as dami "$T/w/dami" origin dami/feature:main@@ -0,0 +1,30 @@
1+# ssh land: the forge fast-forwards the target itself, deletes the branch,2+# and refuses what isn't a fast-forward, changing nothing.3+. "$(dirname "$0")/../lib.sh"4+user dami5+user ana6+repo dami/greet7+clone dami dami/greet "$T/w/dami"8+at "$T/w/dami" commit "first"9+push_as dami "$T/w/dami" origin main10+clone ana dami/greet "$T/w/ana"11+at "$T/w/ana" commit "ana's change"12+push_as ana "$T/w/ana" origin HEAD:ana/change13+tip=$(git -C "$T/w/ana" rev-parse HEAD)14+fails_with "only dami can push main" ssh_as ana land dami/greet 115+ok ssh_as dami land dami/greet 116+eq "$(git --git-dir="$FORGE_ROOT/dami/greet.git" rev-parse main)" "$tip"17+fails git --git-dir="$FORGE_ROOT/dami/greet.git" rev-parse -q --verify refs/heads/ana/change18+has "$FORGE_ROOT/dami/greet.git/forge/reviews/1" '^deleted '19+20+# Not a fast-forward: main moved on under the review.21+at "$T/w/ana" git fetch -q origin22+at "$T/w/ana" git switch -q -c ana/two origin/main23+at "$T/w/ana" commit "two"24+push_as ana "$T/w/ana" origin ana/two25+at "$T/w/dami" git pull -q origin main26+at "$T/w/dami" commit "meanwhile"27+push_as dami "$T/w/dami" origin main28+before=$(git --git-dir="$FORGE_ROOT/dami/greet.git" rev-parse main)29+fails_with "doesn't fast-forward" ssh_as dami land dami/greet 230+eq "$(git --git-dir="$FORGE_ROOT/dami/greet.git" rev-parse main)" "$before"@@ -0,0 +1,14 @@
1+# A build writes a repo's pages, the index, and the search indexes.2+. "$(dirname "$0")/../lib.sh"3+user dami4+repo dami/greet "say hello"5+clone dami dami/greet "$T/w/dami"6+printf '# greet\n\nHello.\n' >"$T/w/dami/README.md"7+at "$T/w/dami" commit "first" greet.sh8+push_as dami "$T/w/dami" origin main9+build10+exists "$FORGE_WWW/index.html"11+has "$FORGE_WWW/dami/greet/index.html" 'Hello\.'12+exists "$FORGE_WWW/dami/greet/blob/main/greet.sh/index.html"13+has "$FORGE_WWW/repos.json" '"dami/greet","say hello"'14+exists "$FORGE_WWW/dami/greet/search.json"@@ -0,0 +1,12 @@
1+# Pushing to a repo that doesn't exist creates it, under your own name only.2+. "$(dirname "$0")/../lib.sh"3+user dami4+user ana5+mkdir "$T/w/notes" && at "$T/w/notes" git init -q6+at "$T/w/notes" commit "notes"7+at "$T/w/notes" git config user.name dami8+ok push_ssh dami "$T/w/notes" "$FORGE_SSH:dami/notes.git" main9+exists "$FORGE_ROOT/dami/notes.git/hooks/pre-receive"10+eq "$(git --git-dir="$FORGE_ROOT/dami/notes.git" rev-parse main)" "$(git -C "$T/w/notes" rev-parse main)"11+fails_with "only dami can create repos under dami/" push_ssh ana "$T/w/notes" "$FORGE_SSH:dami/other.git" main12+missing "$FORGE_ROOT/dami/other.git"@@ -0,0 +1,23 @@
1+# Who may push what: the owner anything; others only branches under their2+# own name; nobody refs/forge; main never force-pushed or deleted.3+. "$(dirname "$0")/../lib.sh"4+user dami5+user ana6+repo dami/greet7+clone dami dami/greet "$T/w/dami"8+at "$T/w/dami" commit "first"9+ok push_as dami "$T/w/dami" origin main10+11+clone ana dami/greet "$T/w/ana"12+at "$T/w/ana" commit "ana's idea"13+fails_with "only dami can push main" push_as ana "$T/w/ana" origin HEAD:main14+fails_with "push under refs/heads/ana/" push_as ana "$T/w/ana" origin HEAD:dami/topic15+ok push_as ana "$T/w/ana" origin HEAD:ana/topic16+at "$T/w/ana" git tag v917+fails_with "only dami can update this" push_as ana "$T/w/ana" origin v918+fails_with "is internal" push_as dami "$T/w/dami" origin HEAD:refs/forge/keep/x19+20+# main: no force push, no delete.21+at "$T/w/dami" git commit -q --amend -m "rewritten"22+fails_with "does not accept force pushes" push_as dami "$T/w/dami" -f origin main23+fails_with "refusing to delete main" push_as dami "$T/w/dami" origin :main@@ -0,0 +1,24 @@
1+# A pushed branch is a review: created, each push a revision, deletion logged,2+# and every revision pinned under refs/forge/keep but hidden from clients.3+. "$(dirname "$0")/../lib.sh"4+user dami5+user ana6+repo dami/greet7+clone dami dami/greet "$T/w/dami"8+at "$T/w/dami" commit "first"9+push_as dami "$T/w/dami" origin main10+clone ana dami/greet "$T/w/ana"11+at "$T/w/ana" commit "idea"12+ok push_as ana "$T/w/ana" -o title=Idea origin HEAD:ana/idea13+log=$FORGE_ROOT/dami/greet.git/forge/reviews/114+has "$log" '^branch ana/idea$'15+has "$log" '^created [0-9]+ ana$'16+has "$log" '^opt title=Idea$'17+eq "$(grep -c '^rev ' "$log")" 118+at "$T/w/ana" git commit -q --amend -m "idea, again"19+ok push_as ana "$T/w/ana" -f origin HEAD:ana/idea20+eq "$(grep -c '^rev ' "$log")" 221+eq "$(git --git-dir="$FORGE_ROOT/dami/greet.git" for-each-ref refs/forge/keep | wc -l | tr -d ' ')" 222+eq "$(git ls-remote "$FORGE_ROOT/dami/greet.git" | grep -c refs/forge || true)" 023+ok push_as ana "$T/w/ana" origin :ana/idea24+has "$log" '^deleted [0-9]+ ana$'