With SUMMER_HTTP__ACCESS_LOG off the Go log has no http request records, and the 5xx scan passed on an empty signal. scan_go_log now also refuses unless the part of go.log written during this --e2e run (after the offset taken once the stack is up) has an http request record. Self-test cases: no records, an empty log, records only before the offset.
641 lines
26 KiB
Bash
Executable File
641 lines
26 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Phase 14.2 fail-closed gate: the local vue-fonoteka-app on the local
|
|
# fonoteka.go SummerCMS backend. Every stage exits non-zero on a failing
|
|
# command, a go test run that fails, skips, matches zero tests, prints "no
|
|
# tests to run", a named required test that did not pass or a data race.
|
|
#
|
|
# Modes:
|
|
# --self-test the go test detector, the Playwright verdict
|
|
# (dev-stack.sh e2e-verdict), the go.log scan and the summary
|
|
# checks fail closed on planted inputs; every mode exists
|
|
# --go go vet and go test ./... in summercms.go, in the fonoteka.go
|
|
# workspace root module and in the fonoteka plugin module,
|
|
# then the phase's named tests through the detector
|
|
# --docs TestDocsTree and summer docs:build --check
|
|
# --parity TestParityCorpus with the 175/175 summary
|
|
# --contracts the phase's Nuxt contract test (blocking) and the whole
|
|
# pnpm test:contracts suite (counts reported, not blocking:
|
|
# missing-catalog.test.mjs fails on HEAD before this phase,
|
|
# see the phase's deferred-items.md)
|
|
# --guard planted env files fed to dev-stack.sh guard: every case
|
|
# must print "refuse:" and exit non-zero; a fake SummerCMS
|
|
# marker must pass (control case)
|
|
# --e2e the running local stack (started with up --detach when it
|
|
# is not up, restarted when dev-stack.sh fresh finds a source
|
|
# newer than its binary, and refused unless fresh passes
|
|
# after up), the guard on the real env file, the Go-gate
|
|
# Playwright specs through dev-stack.sh e2e (any skip,
|
|
# unexpected, flaky or zero expected fails), the D-15
|
|
# report-only specs (counts printed, never blocking) and the
|
|
# Go log scan (search engine failure or a 5xx record fails,
|
|
# and so does a run that wrote no http request record)
|
|
# --all every stage in that order; the last line is
|
|
# "Phase 14.2 gate passed"
|
|
#
|
|
# Paths: PHASE142_ROOT (this checkout), PHASE142_APP (default
|
|
# $ROOT/../fonoteka.go), PHASE142_NUXT (the vue-fonoteka-app checkout).
|
|
# Postgres integration runs through testcontainers: an unavailable Docker
|
|
# daemon fails the gate, and -short is never final evidence. A missing host
|
|
# service (Postgres, Typesense, Centrifugo) fails --e2e through the
|
|
# dev-stack.sh refusal; nothing is downgraded to a skip.
|
|
#
|
|
# TMPDIR: the gate leaves it as set by the caller (PHASE142_TMPDIR
|
|
# overrides). It must not point inside a go.work tree such as fonoteka.go:
|
|
# the scaffold tests in internal/build then edit that workspace's go.work,
|
|
# and `go test ./...` there picks up the build directories as packages.
|
|
# dev-stack.sh gives the Go processes and Playwright their own TMPDIR on the
|
|
# app's device (the file bucket renames across devices otherwise).
|
|
set -euo pipefail
|
|
|
|
unset FORCE_COLOR
|
|
|
|
ROOT="${PHASE142_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
|
APP="${PHASE142_APP:-$ROOT/../fonoteka.go}"
|
|
NUXT="${PHASE142_NUXT:-/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app}"
|
|
DEV_STACK="$APP/scripts/dev-stack.sh"
|
|
PLUGIN_DIR="$APP/plugins/golem15/fonoteka"
|
|
GO_LOG="$APP/tmp/dev-stack/go.log"
|
|
|
|
if [[ -n "${PHASE142_TMPDIR:-}" ]]; then
|
|
mkdir -p "$PHASE142_TMPDIR"
|
|
export TMPDIR="$PHASE142_TMPDIR"
|
|
fi
|
|
|
|
CONSOLE_REQUIRE=(TestDevSeedRefusesProduction TestDevSeedRefusesForeignRows TestDevSeedShape TestDevSeedIdempotent TestReindexCommand)
|
|
APP_REQUIRE=(TestDevStackSmoke TestDevSeedOpensDatabase TestReindexOpensDatabase TestCommandsOpenDatabase TestDevMarkerRoute)
|
|
FRAMEWORK_REQUIRE=(TestUploadsMount TestAccessLog TestAccessLogSmoke)
|
|
GATE_SPECS=(
|
|
auth.setup.ts login.spec.ts smoke.spec.ts fonoteka-item.spec.ts public-share.spec.ts
|
|
realtime.spec.ts i18n.spec.ts album-edit-rate-cover.spec.ts album-search.spec.ts wishlist.spec.ts
|
|
)
|
|
REPORT_SPECS=(
|
|
fonoteka-ai-assist.spec.ts fonoteka-bulk-add.spec.ts offline.spec.ts
|
|
registration-invitation.spec.ts responsive.spec.ts landing.spec.ts
|
|
)
|
|
PHASE_CONTRACTS=(tests/contracts/e2e-backend-switch.test.mjs)
|
|
PARITY_SUMMARY="recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0"
|
|
|
|
usage() {
|
|
cat >&2 <<'EOF'
|
|
usage:
|
|
check-phase14.2.sh --self-test
|
|
check-phase14.2.sh --go
|
|
check-phase14.2.sh --docs
|
|
check-phase14.2.sh --parity
|
|
check-phase14.2.sh --contracts
|
|
check-phase14.2.sh --guard
|
|
check-phase14.2.sh --e2e
|
|
check-phase14.2.sh --all
|
|
EOF
|
|
exit 2
|
|
}
|
|
|
|
# detect reads go test -json. Exit 1 fail/build, 2 skip, 3 zero/no-tests,
|
|
# 4 non-JSON, 5 missing required name, 6 data race.
|
|
detect() {
|
|
python3 - "$1" <<'PY'
|
|
import json, os, sys
|
|
path = sys.argv[1]
|
|
require = [n for n in os.environ.get("REQUIRE_TESTS", "").split() if n]
|
|
passed = set()
|
|
failed = []
|
|
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
for raw in fh:
|
|
line = raw.strip()
|
|
if not line.startswith("{"):
|
|
continue
|
|
try:
|
|
ev = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
print("refuse: non-json test output", file=sys.stderr)
|
|
sys.exit(4)
|
|
action = ev.get("Action")
|
|
test = ev.get("Test") or ""
|
|
pkg = ev.get("Package") or ev.get("ImportPath") or ""
|
|
if action == "build-fail" or (action == "fail" and ev.get("FailedBuild")):
|
|
print(f"refuse: build failed {pkg}", file=sys.stderr)
|
|
sys.exit(1)
|
|
text = ev.get("Output") or ""
|
|
if action == "output":
|
|
if "no tests to run" in text:
|
|
print(f"refuse: no tests to run in {pkg}", file=sys.stderr)
|
|
sys.exit(3)
|
|
if "WARNING: DATA RACE" in text:
|
|
print(f"refuse: data race in {pkg} {test}", file=sys.stderr)
|
|
sys.exit(6)
|
|
if action == "skip" and test:
|
|
print(f"refuse: skipped {pkg} {test}", file=sys.stderr)
|
|
sys.exit(2)
|
|
if action == "fail":
|
|
failed.append(f"{pkg} {test}".strip())
|
|
if action == "pass" and test:
|
|
passed.add(test)
|
|
if failed:
|
|
print("refuse: failed " + ", ".join(failed), file=sys.stderr)
|
|
sys.exit(1)
|
|
if not passed:
|
|
print("refuse: zero tests", file=sys.stderr)
|
|
sys.exit(3)
|
|
top = {name for name in passed if "/" not in name}
|
|
missing = [n for n in require if n not in top and not any(p.startswith(n + "/") or p == n for p in passed)]
|
|
if missing:
|
|
print("refuse: required tests did not pass: " + ", ".join(missing), file=sys.stderr)
|
|
sys.exit(5)
|
|
PY
|
|
}
|
|
|
|
# go_json DIR ARGS... runs go test -json in DIR through detect. With
|
|
# GO_JSON_KEEP set the raw log is kept at that path.
|
|
go_json() {
|
|
local dir="$1"
|
|
shift
|
|
local log err rc=0 dc=0
|
|
log="$(mktemp)"
|
|
err="$(mktemp)"
|
|
(cd "$dir" && go test -json "$@") >"$log" 2>"$err" || rc=$?
|
|
detect "$log" || dc=$?
|
|
if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then
|
|
cat "$err" >&2 || true
|
|
grep -v '^{' "$log" | tail -n 40 >&2 || true
|
|
rm -f "$log" "$err"
|
|
echo "refuse: go test $* in $dir (test=$rc detect=$dc)" >&2
|
|
return 1
|
|
fi
|
|
if [[ -n "${GO_JSON_KEEP:-}" ]]; then
|
|
mv "$log" "$GO_JSON_KEEP"
|
|
else
|
|
rm -f "$log"
|
|
fi
|
|
rm -f "$err"
|
|
}
|
|
|
|
expect_detect() {
|
|
local name="$1" want="$2" payload="$3" log dc=0
|
|
log="$(mktemp)"
|
|
printf '%s\n' "$payload" >"$log"
|
|
detect "$log" 2>/dev/null || dc=$?
|
|
rm -f "$log"
|
|
if [[ "$dc" -ne "$want" ]]; then
|
|
echo "refuse: self-test $name: detector exit $dc, want $want" >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# run_regex builds an anchored -run alternation from test names.
|
|
run_regex() {
|
|
local IFS='|'
|
|
printf '^(%s)$' "$*"
|
|
}
|
|
|
|
# scan_go_log FILE [OFFSET] refuses a silent search fallback or a 5xx
|
|
# request record anywhere in FILE (research G8, D-12). A 499 (the surf
|
|
# access log's status for a 5xx caused by the client going away) is not a
|
|
# 5xx; a 5xx with client_gone=true is. It also refuses unless the part of
|
|
# FILE after OFFSET bytes (the output of this run; default the whole file)
|
|
# has an "http request" record: with the access log off a clean scan would
|
|
# prove nothing (WR-08).
|
|
scan_go_log() {
|
|
local file="$1" offset="${2:-0}" hits
|
|
[[ -f "$file" ]] || {
|
|
echo "refuse: Go log $file not found" >&2
|
|
return 1
|
|
}
|
|
if ! tail -c "+$((offset + 1))" "$file" | grep -q 'msg="http request"'; then
|
|
echo "refuse: $file has no http request record from this run; set SUMMER_HTTP__ACCESS_LOG=true in the env file" >&2
|
|
return 1
|
|
fi
|
|
hits="$(grep -nE 'album search engine failed|status=5[0-9][0-9]([^0-9]|$)' "$file" || true)"
|
|
if [[ -n "$hits" ]]; then
|
|
printf '%s\n' "$hits" | tail -n 20 >&2
|
|
echo "refuse: $file has a search engine failure or a 5xx record (rotate the log only after reading it)" >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# tap_clean FILE refuses a node --test TAP summary unless it has tests > 0
|
|
# and fail, cancelled, skipped and todo all 0.
|
|
tap_clean() {
|
|
python3 - "$1" <<'PY'
|
|
import re, sys
|
|
text = open(sys.argv[1], encoding="utf-8", errors="replace").read()
|
|
counts = {}
|
|
for key in ("tests", "pass", "fail", "cancelled", "skipped", "todo"):
|
|
found = re.findall(rf"^# {key} (\d+)$", text, re.M)
|
|
if not found:
|
|
print(f"refuse: TAP summary has no '# {key}' line", file=sys.stderr)
|
|
sys.exit(1)
|
|
counts[key] = int(found[-1])
|
|
if counts["tests"] == 0 or counts["pass"] != counts["tests"]:
|
|
print(f"refuse: TAP summary {counts}", file=sys.stderr)
|
|
sys.exit(1)
|
|
for key in ("fail", "cancelled", "skipped", "todo"):
|
|
if counts[key] != 0:
|
|
print(f"refuse: TAP summary {counts}", file=sys.stderr)
|
|
sys.exit(1)
|
|
print("tests={tests} pass={pass} fail={fail} skipped={skipped}".format(**counts))
|
|
PY
|
|
}
|
|
|
|
expect_status() {
|
|
local name="$1" want="$2" got=0
|
|
shift 2
|
|
"$@" >/dev/null 2>&1 || got=$?
|
|
if { [[ "$want" == 0 ]] && [[ "$got" -ne 0 ]]; } || { [[ "$want" != 0 ]] && [[ "$got" -eq 0 ]]; }; then
|
|
echo "refuse: self-test $name: exit $got, want $([[ "$want" == 0 ]] && echo 0 || echo non-zero)" >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# expect_verdict NAME WANT JSON: dev-stack.sh e2e-verdict on a planted
|
|
# report must refuse (WANT=refuse, non-zero and a "refuse:" line) or pass.
|
|
expect_verdict() {
|
|
local name="$1" want="$2" payload="$3" dir out rc=0
|
|
dir="$(mktemp -d)"
|
|
printf '%s\n' "$payload" >"$dir/report.json"
|
|
out="$(env -i PATH="$PATH" HOME="$HOME" bash "$DEV_STACK" e2e-verdict "$dir/report.json" 2>&1)" || rc=$?
|
|
rm -rf "$dir"
|
|
if [[ "$want" == refuse ]]; then
|
|
if [[ "$rc" -eq 0 ]] || ! grep -q '^refuse:' <<<"$out"; then
|
|
echo "refuse: self-test verdict $name was not refused (exit $rc): $out" >&2
|
|
return 1
|
|
fi
|
|
elif [[ "$rc" -ne 0 ]] || ! grep -q '^e2e-verdict: passed' <<<"$out"; then
|
|
echo "refuse: self-test verdict $name was refused (exit $rc): $out" >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
run_self_test() {
|
|
bash -n "${BASH_SOURCE[0]}"
|
|
[[ -f "$DEV_STACK" ]] || {
|
|
echo "refuse: $DEV_STACK not found" >&2
|
|
return 1
|
|
}
|
|
bash -n "$DEV_STACK"
|
|
|
|
expect_detect pass 0 '{"Action":"pass","Package":"p","Test":"TestDevSeedRefusesProduction"}'
|
|
expect_detect fail 1 '{"Action":"pass","Package":"p","Test":"TestA"}
|
|
{"Action":"fail","Package":"p","Test":"TestDevSeedShape"}'
|
|
expect_detect package-fail 1 '{"Action":"pass","Package":"p","Test":"TestA"}
|
|
{"Action":"fail","Package":"p"}'
|
|
expect_detect build 1 '{"Action":"build-fail","ImportPath":"p"}'
|
|
expect_detect skip 2 '{"Action":"skip","Package":"p","Test":"TestUploadsMount"}'
|
|
expect_detect subtest-skip 2 '{"Action":"pass","Package":"p","Test":"TestUploadsMount/off"}
|
|
{"Action":"skip","Package":"p","Test":"TestUploadsMount/db"}
|
|
{"Action":"pass","Package":"p","Test":"TestUploadsMount"}'
|
|
expect_detect zero 3 '{"Action":"pass","Package":"p"}'
|
|
expect_detect no-tests 3 '{"Action":"output","Package":"p","Output":"testing: warning: no tests to run\n"}
|
|
{"Action":"pass","Package":"p"}'
|
|
expect_detect nonjson 4 '{"Action":"pass",'
|
|
expect_detect race 6 '{"Action":"output","Package":"p","Test":"TestA","Output":"WARNING: DATA RACE\n"}
|
|
{"Action":"pass","Package":"p","Test":"TestA"}'
|
|
REQUIRE_TESTS="TestDevSeedRefusesProduction TestDevMarkerRoute" expect_detect missing-named 5 \
|
|
'{"Action":"pass","Package":"p","Test":"TestDevSeedRefusesProduction"}'
|
|
|
|
# Playwright verdicts (T-14.2-17).
|
|
expect_verdict clean pass '{"stats":{"expected":17,"unexpected":0,"skipped":0,"flaky":0}}'
|
|
expect_verdict skipped refuse '{"stats":{"expected":16,"unexpected":0,"skipped":1,"flaky":0}}'
|
|
expect_verdict unexpected refuse '{"stats":{"expected":16,"unexpected":1,"skipped":0,"flaky":0}}'
|
|
expect_verdict flaky refuse '{"stats":{"expected":16,"unexpected":0,"skipped":0,"flaky":1}}'
|
|
expect_verdict zero-expected refuse '{"stats":{"expected":0,"unexpected":0,"skipped":0,"flaky":0}}'
|
|
expect_verdict malformed refuse '{"stats":{"expected":17,'
|
|
expect_verdict no-stats refuse '{"suites":[]}'
|
|
local out rc=0
|
|
out="$(env -i PATH="$PATH" HOME="$HOME" bash "$DEV_STACK" e2e-verdict "$(mktemp -u)" 2>&1)" || rc=$?
|
|
if [[ "$rc" -eq 0 ]] || ! grep -q '^refuse:' <<<"$out"; then
|
|
echo "refuse: self-test verdict missing-report was not refused" >&2
|
|
return 1
|
|
fi
|
|
|
|
# The seed's minted API token secret never reaches the terminal (WR-06).
|
|
local redacted
|
|
redacted="$(printf '%s\n' 'user: created' 'api token: created inv_selftestSecret-0123 (shown once)' |
|
|
env -i PATH="$PATH" HOME="$HOME" bash "$DEV_STACK" redact-seed)"
|
|
if grep -q 'inv_selftestSecret' <<<"$redacted" || ! grep -q '^api token: created' <<<"$redacted" || ! grep -q '^user: created$' <<<"$redacted"; then
|
|
echo "refuse: self-test redact-seed did not hide the token secret: $redacted" >&2
|
|
return 1
|
|
fi
|
|
|
|
# dev-stack.sh fresh on a planted app and framework tree (WR-07): a
|
|
# stack whose stamp is newer than every source passes; a newer source,
|
|
# or no stamp, is refused; docs, planning files and tests are ignored.
|
|
local tree
|
|
tree="$(mktemp -d)"
|
|
mkdir -p "$tree/app/scripts" "$tree/app/tmp/dev-stack" "$tree/app/.planning" "$tree/fw/modules/a" "$tree/fw/docs"
|
|
cp "$DEV_STACK" "$tree/app/scripts/dev-stack.sh"
|
|
touch -d '2 minutes ago' "$tree/app/scripts/dev-stack.sh" "$tree/app/main.go" "$tree/fw/modules/a/a.go"
|
|
touch -d '1 minute ago' "$tree/app/tmp/dev-stack/build.stamp"
|
|
fresh_case() {
|
|
env -i PATH="$PATH" HOME="$HOME" DEV_STACK_FRAMEWORK_DIR="$tree/fw" bash "$tree/app/scripts/dev-stack.sh" fresh
|
|
}
|
|
expect_status fresh-clean 0 fresh_case
|
|
touch "$tree/fw/docs/page.md" "$tree/app/.planning/STATE.md" "$tree/fw/modules/a/a_test.go"
|
|
expect_status fresh-ignored 0 fresh_case
|
|
touch "$tree/fw/modules/a/a.go"
|
|
expect_status fresh-stale-framework 1 fresh_case
|
|
touch -d '2 minutes ago' "$tree/fw/modules/a/a.go"
|
|
touch "$tree/app/main.go"
|
|
expect_status fresh-stale-app 1 fresh_case
|
|
touch -d '2 minutes ago' "$tree/app/main.go"
|
|
rm -f "$tree/app/tmp/dev-stack/build.stamp"
|
|
expect_status fresh-no-stamp 1 fresh_case
|
|
rm -rf "$tree"
|
|
|
|
# Go log scan (T-14.2-20) and summaries.
|
|
local dir
|
|
dir="$(mktemp -d)"
|
|
printf '%s\n' '[go] level=INFO msg="http request" method=GET pattern="GET /a" status=200 duration_ms=1' \
|
|
'[go] level=INFO msg="http request" method=GET pattern="GET /b" status=499 duration_ms=3 client_gone=true' >"$dir/clean.log"
|
|
printf '%s\n' '[go] level=INFO msg="http request" method=GET pattern="GET /a" status=503 duration_ms=1' >"$dir/5xx.log"
|
|
printf '%s\n' '[go] level=INFO msg="http request" method=GET pattern="GET /a" status=500 duration_ms=1 client_gone=true' >"$dir/5xx-gone.log"
|
|
printf '%s\n' '[go] level=WARN msg="album search engine failed" err=timeout' >"$dir/search.log"
|
|
printf '%s\n' '[go] level=INFO msg="serve: listening" addr=127.0.0.1:8423' >"$dir/no-records.log"
|
|
expect_status go-log-clean 0 scan_go_log "$dir/clean.log"
|
|
expect_status go-log-5xx 1 scan_go_log "$dir/5xx.log"
|
|
expect_status go-log-5xx-client-gone 1 scan_go_log "$dir/5xx-gone.log"
|
|
expect_status go-log-search 1 scan_go_log "$dir/search.log"
|
|
expect_status go-log-missing 1 scan_go_log "$dir/absent.log"
|
|
expect_status go-log-no-records 1 scan_go_log "$dir/no-records.log"
|
|
expect_status go-log-empty 1 scan_go_log /dev/null
|
|
# Request records only before this run's offset: the run logged none.
|
|
cat "$dir/clean.log" "$dir/no-records.log" >"$dir/old-records.log"
|
|
expect_status go-log-old-records 1 scan_go_log "$dir/old-records.log" "$(stat -c '%s' "$dir/clean.log")"
|
|
cat "$dir/no-records.log" "$dir/clean.log" >"$dir/new-records.log"
|
|
expect_status go-log-new-records 0 scan_go_log "$dir/new-records.log" "$(stat -c '%s' "$dir/no-records.log")"
|
|
printf '# tests 9\n# pass 9\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n' >"$dir/tap-clean"
|
|
printf '# tests 9\n# pass 8\n# fail 1\n# cancelled 0\n# skipped 0\n# todo 0\n' >"$dir/tap-fail"
|
|
printf '# tests 9\n# pass 8\n# fail 0\n# cancelled 0\n# skipped 1\n# todo 0\n' >"$dir/tap-skip"
|
|
printf '# tests 0\n# pass 0\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n' >"$dir/tap-zero"
|
|
printf 'ok 1 - x\n' >"$dir/tap-none"
|
|
expect_status tap-clean 0 tap_clean "$dir/tap-clean"
|
|
expect_status tap-fail 1 tap_clean "$dir/tap-fail"
|
|
expect_status tap-skip 1 tap_clean "$dir/tap-skip"
|
|
expect_status tap-zero 1 tap_clean "$dir/tap-zero"
|
|
expect_status tap-none 1 tap_clean "$dir/tap-none"
|
|
rm -rf "$dir"
|
|
|
|
local flag
|
|
for flag in --self-test --go --docs --parity --contracts --guard --e2e --all; do
|
|
grep -q -- "^ $flag)" "${BASH_SOURCE[0]}" || {
|
|
echo "refuse: missing mode $flag" >&2
|
|
return 1
|
|
}
|
|
done
|
|
echo "phase14.2 self-test passed"
|
|
}
|
|
|
|
run_go() {
|
|
[[ -d "$APP" && -d "$PLUGIN_DIR" ]] || {
|
|
echo "refuse: fonoteka.go workspace $APP not found" >&2
|
|
return 1
|
|
}
|
|
(cd "$ROOT" && go vet ./... && go test ./... -count=1 -timeout 60m)
|
|
go -C "$APP" vet ./...
|
|
go -C "$APP" test ./... -count=1 -timeout 60m
|
|
go -C "$PLUGIN_DIR" vet ./...
|
|
go -C "$PLUGIN_DIR" test ./... -count=1 -timeout 60m
|
|
REQUIRE_TESTS="${CONSOLE_REQUIRE[*]}" go_json "$APP" ./plugins/golem15/fonoteka/console \
|
|
-count=1 -timeout 20m -run "$(run_regex "${CONSOLE_REQUIRE[@]}")"
|
|
REQUIRE_TESTS="${APP_REQUIRE[*]}" go_json "$APP" ./plugins/golem15/fonoteka \
|
|
-count=1 -timeout 20m -run "$(run_regex "${APP_REQUIRE[@]}")"
|
|
REQUIRE_TESTS="${FRAMEWORK_REQUIRE[*]}" go_json "$ROOT" ./modules/surf \
|
|
-count=1 -timeout 20m -run "$(run_regex "${FRAMEWORK_REQUIRE[@]}")"
|
|
echo "phase14.2 go passed"
|
|
}
|
|
|
|
run_docs() {
|
|
REQUIRE_TESTS="TestDocsTree" go_json "$ROOT" ./cmd/summer -count=1 -run '^TestDocsTree$'
|
|
local out
|
|
out="$(cd "$ROOT" && go run ./cmd/summer docs:build --check 2>&1)" || {
|
|
echo "$out" >&2
|
|
echo "refuse: docs:build --check failed" >&2
|
|
return 1
|
|
}
|
|
echo "phase14.2 docs passed"
|
|
}
|
|
|
|
run_parity() {
|
|
local log
|
|
log="$(mktemp)"
|
|
GO_JSON_KEEP="$log" REQUIRE_TESTS="TestParityCorpus" go_json "$APP" ./parity \
|
|
-count=1 -run '^TestParityCorpus$' -timeout 30m -v
|
|
if ! grep -qF "$PARITY_SUMMARY" "$log"; then
|
|
grep -oE 'recorded [0-9]+/[0-9]+[^"\\]*' "$log" | tail -n 3 >&2 || true
|
|
rm -f "$log"
|
|
echo "refuse: TestParityCorpus did not report \"$PARITY_SUMMARY\"" >&2
|
|
return 1
|
|
fi
|
|
rm -f "$log"
|
|
echo "phase14.2 parity passed ($PARITY_SUMMARY)"
|
|
}
|
|
|
|
run_contracts() {
|
|
[[ -f "$NUXT/package.json" ]] || {
|
|
echo "refuse: Nuxt checkout $NUXT not found" >&2
|
|
return 1
|
|
}
|
|
local log rc=0 counts
|
|
log="$(mktemp)"
|
|
(cd "$NUXT" && node --test "${PHASE_CONTRACTS[@]}") >"$log" 2>&1 || rc=$?
|
|
if [[ "$rc" -ne 0 ]] || ! counts="$(tap_clean "$log")"; then
|
|
grep -E '^not ok|^# ' "$log" >&2 || true
|
|
rm -f "$log"
|
|
echo "refuse: phase contract tests ${PHASE_CONTRACTS[*]} (exit $rc)" >&2
|
|
return 1
|
|
fi
|
|
echo "contracts (blocking) ${PHASE_CONTRACTS[*]}: $counts"
|
|
# The whole suite, reported only: missing-catalog.test.mjs fails on
|
|
# vue-fonoteka-app HEAD from before this phase (deferred-items.md).
|
|
rc=0
|
|
pnpm --dir "$NUXT" test:contracts >"$log" 2>&1 || rc=$?
|
|
echo "contracts (report only) pnpm test:contracts exit $rc: $(grep -E '^# (tests|pass|fail|skipped) ' "$log" | paste -sd ' ')"
|
|
grep -E '^not ok' "$log" | sed 's/^/contracts (report only) /' || true
|
|
rm -f "$log"
|
|
echo "phase14.2 contracts passed"
|
|
}
|
|
|
|
# guard_case NAME WANT FILE [ENV...]: dev-stack.sh guard on a planted env
|
|
# file in a clean environment. WANT is the reason the refusal must name.
|
|
guard_case() {
|
|
local name="$1" want="$2" file="$3" out rc=0
|
|
shift 3
|
|
out="$(env -i PATH="$PATH" HOME="$HOME" DEV_STACK_ENV_FILE="$file" "$@" bash "$DEV_STACK" guard 2>&1)" || rc=$?
|
|
if [[ "$rc" -eq 0 ]] || ! grep -q '^refuse:' <<<"$out" || ! grep -qF -- "$want" <<<"$out"; then
|
|
echo "$out" >&2
|
|
echo "refuse: guard case $name was not refused for \"$want\" (exit $rc)" >&2
|
|
return 1
|
|
fi
|
|
echo "guard $name: $(grep '^refuse:' <<<"$out" | head -n 1)"
|
|
}
|
|
|
|
GUARD_PIDS=()
|
|
stop_fakes() {
|
|
local pid
|
|
for pid in "${GUARD_PIDS[@]}"; do
|
|
kill "$pid" 2>/dev/null || true
|
|
wait "$pid" 2>/dev/null || true
|
|
done
|
|
GUARD_PIDS=()
|
|
}
|
|
|
|
# fake_backend DIR STATUS BODY [HEADER]: a python3 http.server on a free
|
|
# loopback port, recorded in GUARD_PIDS; its port goes to FAKE_PORT.
|
|
FAKE_PORT=""
|
|
fake_backend() {
|
|
local dir="$1" status="$2" body="$3" header="${4:-}" portfile i
|
|
portfile="$dir/port-$RANDOM-$RANDOM"
|
|
python3 - "$portfile" "$status" "$body" "$header" >/dev/null 2>&1 <<'PY' &
|
|
import http.server, sys
|
|
portfile, status, body, header = sys.argv[1], int(sys.argv[2]), sys.argv[3].encode(), sys.argv[4]
|
|
class H(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
self.send_response(status)
|
|
if header:
|
|
k, v = header.split(":", 1)
|
|
self.send_header(k.strip(), v.strip())
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
def log_message(self, *args):
|
|
pass
|
|
srv = http.server.HTTPServer(("127.0.0.1", 0), H)
|
|
with open(portfile + ".tmp", "w") as fh:
|
|
fh.write(str(srv.server_address[1]))
|
|
import os
|
|
os.rename(portfile + ".tmp", portfile)
|
|
srv.serve_forever()
|
|
PY
|
|
GUARD_PIDS+=("$!")
|
|
for ((i = 0; i < 50; i++)); do
|
|
[[ -s "$portfile" ]] && break
|
|
sleep 0.1
|
|
done
|
|
[[ -s "$portfile" ]] || {
|
|
echo "refuse: fake backend did not start" >&2
|
|
return 1
|
|
}
|
|
FAKE_PORT="$(cat "$portfile")"
|
|
}
|
|
|
|
run_guard() {
|
|
[[ -f "$DEV_STACK" ]] || {
|
|
echo "refuse: $DEV_STACK not found" >&2
|
|
return 1
|
|
}
|
|
local dir port
|
|
dir="$(mktemp -d)"
|
|
GUARD_DIR="$dir"
|
|
trap 'stop_fakes; rm -rf "$GUARD_DIR"' EXIT
|
|
|
|
printf 'NUXT_DEV_BACKEND_ORIGIN=http://127.0.0.1:8422\nNUXT_INTERNAL_API_ORIGIN=http://127.0.0.1:8422\n' >"$dir/guard-php-port.conf"
|
|
guard_case php-port "port 8422" "$dir/guard-php-port.conf"
|
|
|
|
printf 'NUXT_DEV_BACKEND_ORIGIN=http://127.0.0.1:8423\n' >"$dir/guard-internal-unset.conf"
|
|
guard_case internal-unset "NUXT_INTERNAL_API_ORIGIN is unset" "$dir/guard-internal-unset.conf"
|
|
|
|
printf 'NUXT_DEV_BACKEND_ORIGIN=http://127.0.0.1:8423\nNUXT_INTERNAL_API_ORIGIN=http://localhost:8423\n' >"$dir/guard-mismatch.conf"
|
|
guard_case mismatch "differ" "$dir/guard-mismatch.conf"
|
|
|
|
printf 'NUXT_DEV_BACKEND_ORIGIN=https://plytarium.com\nNUXT_INTERNAL_API_ORIGIN=https://plytarium.com\n' >"$dir/guard-production.conf"
|
|
guard_case production "not loopback" "$dir/guard-production.conf"
|
|
|
|
fake_backend "$dir" 404 '{}' 'X-Powered-By: PHP/8.5.10'
|
|
port="$FAKE_PORT"
|
|
printf 'NUXT_DEV_BACKEND_ORIGIN=http://127.0.0.1:%s\nNUXT_INTERNAL_API_ORIGIN=http://127.0.0.1:%s\n' "$port" "$port" >"$dir/guard-fake-php.conf"
|
|
guard_case fake-php "X-Powered-By: PHP" "$dir/guard-fake-php.conf" DEV_STACK_GO_ADDR="127.0.0.1:$port"
|
|
|
|
fake_backend "$dir" 200 '{"backend":"winter","app":"fonoteka","env":"local"}'
|
|
port="$FAKE_PORT"
|
|
printf 'NUXT_DEV_BACKEND_ORIGIN=http://127.0.0.1:%s\nNUXT_INTERNAL_API_ORIGIN=http://127.0.0.1:%s\n' "$port" "$port" >"$dir/guard-other-marker.conf"
|
|
guard_case other-marker 'did not answer "backend":"summercms"' "$dir/guard-other-marker.conf" DEV_STACK_GO_ADDR="127.0.0.1:$port"
|
|
|
|
# Control: the same rules accept a SummerCMS marker, so the refusals
|
|
# above are the cases', not a guard that refuses everything.
|
|
fake_backend "$dir" 200 '{"backend":"summercms","app":"fonoteka","env":"local"}'
|
|
port="$FAKE_PORT"
|
|
printf 'NUXT_DEV_BACKEND_ORIGIN=http://127.0.0.1:%s\nNUXT_INTERNAL_API_ORIGIN=http://127.0.0.1:%s\n' "$port" "$port" >"$dir/guard-control.conf"
|
|
local out rc=0
|
|
out="$(env -i PATH="$PATH" HOME="$HOME" DEV_STACK_ENV_FILE="$dir/guard-control.conf" DEV_STACK_GO_ADDR="127.0.0.1:$port" bash "$DEV_STACK" guard 2>&1)" || rc=$?
|
|
if [[ "$rc" -ne 0 ]] || grep -q '^refuse:' <<<"$out"; then
|
|
echo "$out" >&2
|
|
echo "refuse: guard control case (a SummerCMS marker) was refused" >&2
|
|
return 1
|
|
fi
|
|
echo "guard control: $out"
|
|
echo "phase14.2 guard passed"
|
|
}
|
|
|
|
run_e2e() {
|
|
[[ -f "$DEV_STACK" ]] || {
|
|
echo "refuse: $DEV_STACK not found" >&2
|
|
return 1
|
|
}
|
|
local was_up=0 rc=0
|
|
if bash "$DEV_STACK" status >/dev/null 2>&1; then
|
|
was_up=1
|
|
echo "e2e: the local stack is already up; it is left running"
|
|
# A stack started before the latest source change would test old
|
|
# code (WR-07): restart it.
|
|
if ! bash "$DEV_STACK" fresh 2>&1 | sed 's/^/e2e: /'; then
|
|
echo "e2e: the running binary is older than its sources; restarting the stack"
|
|
bash "$DEV_STACK" down
|
|
fi
|
|
fi
|
|
if ((was_up == 0)); then
|
|
trap 'bash "$DEV_STACK" down || true' EXIT
|
|
fi
|
|
bash "$DEV_STACK" up --detach
|
|
bash "$DEV_STACK" fresh
|
|
seed_out_private
|
|
local log_start=0
|
|
[[ ! -f "$GO_LOG" ]] || log_start="$(stat -c '%s' "$GO_LOG")"
|
|
bash "$DEV_STACK" guard
|
|
bash "$DEV_STACK" e2e "${GATE_SPECS[@]}"
|
|
echo "e2e (report only, D-15): ${REPORT_SPECS[*]}"
|
|
bash "$DEV_STACK" e2e --report-only "${REPORT_SPECS[@]}" 2>&1 | sed 's/^/e2e (report only) /' || rc=$?
|
|
((rc == 0)) || echo "e2e (report only) run exited $rc (not blocking)"
|
|
scan_go_log "$GO_LOG" "$log_start"
|
|
echo "phase14.2 e2e passed"
|
|
}
|
|
|
|
# seed_out_private refuses unless tmp/dev-stack/seed.out, which can hold a
|
|
# minted API token secret, is readable by its owner only (WR-06).
|
|
seed_out_private() {
|
|
local file="$APP/tmp/dev-stack/seed.out" mode
|
|
[[ -f "$file" ]] || {
|
|
echo "refuse: $file not found" >&2
|
|
return 1
|
|
}
|
|
mode="$(stat -c '%a' "$file")"
|
|
[[ "$mode" == 600 ]] || {
|
|
echo "refuse: $file has mode $mode, want 600 (it can hold an API token secret)" >&2
|
|
return 1
|
|
}
|
|
}
|
|
|
|
run_all() {
|
|
local stage
|
|
for stage in self-test go docs parity contracts guard e2e; do
|
|
if bash "${BASH_SOURCE[0]}" "--$stage"; then
|
|
echo "PASS $stage"
|
|
else
|
|
echo "FAIL $stage"
|
|
exit 1
|
|
fi
|
|
done
|
|
echo "Phase 14.2 gate passed"
|
|
}
|
|
|
|
case "${1:---all}" in
|
|
--self-test) run_self_test ;;
|
|
--go) run_go ;;
|
|
--docs) run_docs ;;
|
|
--parity) run_parity ;;
|
|
--contracts) run_contracts ;;
|
|
--guard) run_guard ;;
|
|
--e2e) run_e2e ;;
|
|
--all) run_all ;;
|
|
*) usage ;;
|
|
esac
|