Files
summercms/scripts/check-phase17.sh

705 lines
33 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# Phase 17 fail-closed gate: blog companions. The framework pieces in this
# repository (pact menu item type contracts, signpost, seaglass, the party
# hook, cabana repeater, dependent dropdown, richeditor, reorder and
# singleton, the admin SPA), the four plugin repositories (Journal menu item
# types, sitemap, menu, FAQ), the grzybyfunkcjonalne host and the two Nuxt
# layers (menu, FAQ).
#
# Stages:
# go go vet ./... here; the Docker-free framework suites by required
# name (-short).
# plugins go vet ./... in Journal, sitemap, menu, FAQ and the host; their
# Docker-free suites by required name (-short).
# admin the admin SPA typecheck and the vitest JSON reports with
# required spec files and test names: the happy-dom suite and the
# browser suite (vitest.browser.config.ts, Playwright Chromium;
# the richeditor read-only display, T-17-10).
# layers pnpm test (node --test, TAP) in both layers with required test
# files and names.
# docs TestDocsTree, summer docs:build --check, check-admin-openapi.sh
# --check, check-admin-dist.sh, and check-phase10.sh --hygiene, so
# admin changes made here cannot silently break Phase 10's gate.
# postgres Docker host only: the testcontainers suites by required name in
# every Go repository. Without a reachable Docker daemon this
# stage fails.
#
# Every stage exits non-zero on a failing command; a go test -json run that
# fails, skips, matches zero tests, prints "no tests to run" or misses a named
# required test; a vitest JSON report with a failed, skipped or todo test, a
# failed file, no passing test or a missing required file or test; a TAP run
# with a "not ok", a fail, cancelled, skipped or todo count, zero passing tests
# or a missing required test; or a missing required layer test file.
# --self-test proves the detectors fail on planted output.
#
# --all runs self-test, go, plugins, admin, layers and docs, then postgres
# when a Docker daemon answers. When none does, --all says so loudly
# (NOT RUN postgres) and the Docker suites stay a manual step for a Docker
# host:
# bash scripts/check-phase17.sh postgres
# Set PHASE17_REQUIRE_POSTGRES=1 to make --all fail instead.
#
# Environment overrides (default: the sibling checkouts next to this
# repository): PHASE17_ROOT (this repository), JOURNAL (sm-journal-plugin),
# SITEMAP (sm-sitemap-plugin), MENU (sm-menu-plugin), FAQ (sm-faq-plugin),
# HOST (sm-grzybyfunkcjonalne-app), MENU_LAYER (vue-menu-layer), FAQ_LAYER
# (vue-faq-layer).
set -euo pipefail
unset FORCE_COLOR
export NO_COLOR=1
ROOT="${PHASE17_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
JOURNAL="${JOURNAL:-$ROOT/../sm-journal-plugin}"
SITEMAP="${SITEMAP:-$ROOT/../sm-sitemap-plugin}"
MENU="${MENU:-$ROOT/../sm-menu-plugin}"
FAQ="${FAQ:-$ROOT/../sm-faq-plugin}"
HOST="${HOST:-$ROOT/../sm-grzybyfunkcjonalne-app}"
MENU_LAYER="${MENU_LAYER:-$ROOT/../vue-menu-layer}"
FAQ_LAYER="${FAQ_LAYER:-$ROOT/../vue-faq-layer}"
# ---------------------------------------------------------------------------
# Required test names. Each list is run with -run '^(a|b|...)$' and every name
# must pass; a name that skips or does not run fails the stage.
# go stage: Docker-free framework suites (-short).
SIGNPOST_TESTS=(TestSignpostSmoke TestRegistryDuplicateKey TestRegistryActivate TestRegistryHostOverrideUnknown TestRegistryPatternLayers TestReferenceOptions)
SEAGLASS_TESTS=(TestSeaglassSmoke TestSanitizeByteStable TestSanitizeReport TestSanitizeBypassCorpus TestCheck)
PACT_TESTS=(TestOptionDepthOmitEmpty TestMenuItemTypeContracts)
PARTY_TESTS=(TestActivateMenuItemTypesOrder)
CABANA_SHORT_TESTS=(TestRepeaterCompile TestRepeaterDecode TestDependentDropdownCompile TestDependentValues TestRichEditorCompile TestReorderConfigCompile TestSingletonCompile TestPhase10ConformanceInventory)
# plugins stage: Docker-free plugin and host suites (-short), per package.
JOURNAL_CLASSES_TESTS=(TestMenuItemTypesPatterns)
SITEMAP_CLASSES_TESTS=(TestSitemapGolden TestSitemapTiersConfig TestURLTo TestXMLEscaping TestTiers TestRewrite TestHooks TestManifest TestURLCap TestOnlyHTTPLocs TestAllowedItemURL TestRewriteUnsafeFrontendURL)
SITEMAP_MODELS_TESTS=(TestFillable TestDefinitionItemJSON TestAllowedFrontendURL TestSettingsBeforeValidate)
SITEMAP_ROOT_TESTS=(TestBackendURL)
SITEMAP_CONTROLLERS_TESTS=(TestCheckItems)
SITEMAP_CONSOLE_TESTS=(TestWinterSettingsFrontendURL)
MENU_CLASSES_TESTS=(TestAllowedURL TestMenuTree)
MENU_MODELS_TESTS=(TestMenuEntryModel)
FAQ_CONSOLE_TESTS=(TestFaqContentDecode)
FAQ_MODELS_TESTS=(TestFaqModel)
FAQ_API_TESTS=(TestFaqSerialize)
HOST_SHORT_TESTS=(TestBootUserTranslateJournal TestBackendURIGfadmin TestGrzybyCommandsRegistered TestCORS)
# postgres stage: testcontainers suites.
PG_CABANA_TESTS=(TestCompanionsDependentSmoke TestCompanionsRichEditorSmoke TestCompanionsRepeaterSmoke TestCompanionsReorderSmoke TestCompanionsSingletonSmoke TestRepeaterSave TestDependentRecheck TestRichEditor TestReorderPostgres TestSingleton TestPhase10OpenAPIConformance)
PG_JOURNAL_TESTS=(TestMenuItemTypesSmoke TestMenuItemTypes)
PG_SITEMAP_TESTS=(TestSitemapTracer TestSitemapImportSmoke TestSitemapAdminSmoke TestImportWinter TestSitemapCacheAndThrottle)
PG_MENU_TESTS=(TestMenuAdminSmoke TestMenuImportSmoke TestMenuAPISmoke TestMenuEntryBeforeSave TestMenuAPI TestMenuImport)
PG_FAQ_TESTS=(TestFaqAdminSmoke TestFaqImportSmoke TestFaqAPISmoke TestFaqAPI TestFaqImport)
PG_HOST_TESTS=(TestCompanionsHost)
# admin stage: vitest spec files and full test names (describe + it).
ADMIN_FILES=(
tests/form/RichEditorField.test.ts
tests/form/RepeaterField.test.ts
tests/form/DependentDropdown.test.ts
tests/views/ReorderView.test.ts
tests/form/SingletonForm.test.ts
)
ADMIN_REQUIRE=(
"DependentDropdown DependentDropdown loads options for the sibling value and clears a stale value"
"DependentDropdown waits for the sibling, and offers a retry when loading fails"
"DependentDropdown states shows loading while the options load, then the empty state naming the type"
"DependentDropdown states drops a late answer for an old sibling value: the last request wins"
"DependentDropdown states shows the placeholder for a stored value no option offers, without clearing it"
"DependentDropdown states a dropdown inside a repeater row asks with the row path and its row sibling"
"RepeaterField RepeaterField tracer: add, edit nested, reorder and remove change form state only"
"RepeaterField expands rows with path errors, shows the chip and focuses the first invalid nested control"
"RepeaterField shows the empty state with the prompt"
"RepeaterField rows enforces minItems and maxItems on remove, add and the counter"
"RepeaterField rows asks before removing a row with a value, never for an empty or default row, and keeps it on cancel"
"RepeaterField rows reorders by the arrow keys and the buttons, announces repeater.moved and stops at the ends"
"RepeaterField rows collapsed rows do not mount their richeditors and keep their values"
"RepeaterField rows read-only shows every row expanded with no controls"
"RichEditorField RichEditorField tracer: untouched value is submitted byte-identical"
"RichEditorField shows the lossy note for markup outside the editor schema and keeps the HTML view lossless"
"RichEditorField sends the editor HTML after an edit and shows what the server removed"
"RichEditorField RichEditorField edit emits editor HTML"
"RichEditorField renders read-only values through the display policy, never as stored"
"richeditor policy helpers accepts policy links only"
"richeditor policy helpers compares tag names case-insensitively for the lossy note"
"RichEditorField value contract an empty paragraph is the empty string, any content its HTML"
"RichEditorField value contract a value from outside replaces the document and leaves the field untouched"
"RichEditorField value contract shows the lossy note only for markup outside the editor schema"
"RichEditorField value contract reads empty and read-only values"
"richeditor link popover refuses a URL outside the allowlist and applies a policy URL on Enter"
"richeditor link popover starts from the current link and offers Remove link inside one"
"richeditor notices callout lists every notice of the field in one warning callout after a save"
"RichEditorField chunk failure falls back to the HTML view with the load_failed note, and saving still works"
"SingletonForm opens through /singleton from settings, has no Delete, and Save and close returns to settings"
"SingletonForm back returns to the settings index"
"SingletonForm a link to a controller that is not a singleton opens its list"
"SingletonForm a modified click opens the link as it is, without asking for the record"
"SingletonForm a form without the singleton flag keeps Delete and returns to its list"
"ReorderView ReorderView tracer: a keyboard move sends one debounced POST with the full node set"
"ReorderView restores the saved tree when a save fails and reloads on a stale set"
"ReorderView shows the empty state and the 403 alert"
"list Reorder button links to the reorder page with the filtered partition"
"singleton form opens the one record from settings, without Delete, and returns to the settings index"
"ReorderView moves announces every keyboard move and moves children with their parent"
"ReorderView moves shows the loading skeleton, then the load failure alert"
"ReorderView moves a partition switch sends the pending save first, then loads the chosen partition"
"reorder tree rules builds the tree by sort order and puts unknown parents and cycles at the top"
"reorder tree rules refuses a drop into the node itself or its own subtree and leaves the tree as it was"
"reorder tree rules moves only where a sibling or parent exists, and flattens pre-order from 1"
)
# admin stage, browser suite (vitest.browser.config.ts): spec files and full
# test names, copied from a --reporter=json run.
ADMIN_BROWSER_FILES=(tests/browser/richeditor-display.test.ts)
ADMIN_BROWSER_REQUIRE=(
"richeditor display in a real browser engine runs where form controls clobber form properties"
"richeditor display in a real browser engine drops clobbered forms with everything inside them"
"richeditor display in a real browser engine keeps a form control named localName from breaking the view"
"richeditor display in a real browser engine drops an off-origin form, iframes, meta refresh and disallowed links"
"richeditor display in a real browser engine parses into an inert document, so nothing loads before the value is bound"
"richeditor policy helpers unwraps unknown elements and drops scripts for display"
"RichEditorField renders read-only values exactly through the display policy"
)
# layers stage: test files (must exist and match the test script's glob) and
# TAP test names.
MENU_LAYER_FILES=(tests/menuLinks.test.ts tests/client.test.ts)
MENU_LAYER_REQUIRE=(
"menuPath encodes the code"
"getMenu calls /menus/<code> and passes locale only when set"
"the fetcher base plus the path is the API URL"
"a 404 resolves to null; other errors are thrown"
"a body without data is null; missing items become an empty list"
"isNotFound reads status, statusCode and response.status"
"the server base is used on the server, with the visitor forwarded"
"the public base is used in the browser, without forwarded headers"
"the allowlist takes http, https, mailto, tel, / and #"
"javascript: and data: URLs render as text with no href"
"link kinds: router for site paths, anchor for absolute, mailto, tel and fragments"
"_blank links get rel noopener noreferrer"
"paths normalize: query, hash and trailing slash ignored, decoded"
"active matching compares paths; absolute URLs only on the request host"
"the trail marks ancestors of the active entry"
"fillTitle fills :title"
"rendered: unsafe URLs are a span without href, _blank links carry rel"
"rendered: an entry with children and a URL keeps its link and gets a labelled toggle"
"every refused scheme and spacing renders as text without an href"
"protocol-relative URLs are never site paths: an anchor, never active"
"rel is added only for _blank, whatever the URL kind"
"active matching ignores query, hash and a trailing slash, except that / is only /"
"an absolute URL is active only on the same host"
"the trail marks ancestors only, never the active entry itself"
)
FAQ_LAYER_FILES=(tests/client.test.ts)
FAQ_LAYER_REQUIRE=(
"faqPath encodes the slug"
"getFaq calls /faqs/<slug> and passes locale only when set"
"the fetcher base plus the path is the API URL"
"a 404 resolves to null; other errors are thrown"
"a body without data is null; a null description stays null"
"faqItems keeps only items with a string question and answer"
"isNotFound reads status, statusCode and response.status"
"the slug is encoded, the locale passed as it is"
"a description that is not a string is null; content that is not a list is empty"
"the server base is used on the server, with the visitor forwarded"
"the public base is used in the browser, without forwarded headers"
)
usage() {
cat >&2 <<'EOF'
usage:
check-phase17.sh --self-test
check-phase17.sh go | plugins | admin | layers | docs | postgres
check-phase17.sh --go | --plugins | --admin | --layers | --docs | --postgres
check-phase17.sh --all
EOF
exit 2
}
# detect_go reads go test -json. Exit 1 fail/build, 2 skip, 3 zero/no-tests,
# 4 non-JSON, 5 missing required name.
detect_go() {
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" and "no tests to run" in text:
print(f"refuse: no tests to run in {pkg}", file=sys.stderr)
sys.exit(3)
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)
missing = [n for n in require if n not in passed]
if missing:
print("refuse: required tests did not pass: " + ", ".join(missing), file=sys.stderr)
sys.exit(5)
PY
}
# detect_vitest reads a vitest --reporter=json report. Exit 1 a failed test
# or file or an unsuccessful run, 2 a skipped or todo test, 3 no passing
# test, 4 an unreadable report, 5 a missing required file or test.
# REQUIRE_FILES and REQUIRE_NAMES hold one entry per line.
detect_vitest() {
python3 - "$1" <<'PY'
import json, os, sys
path = sys.argv[1]
files = [n for n in os.environ.get("REQUIRE_FILES", "").split("\n") if n.strip()]
names = [n for n in os.environ.get("REQUIRE_NAMES", "").split("\n") if n.strip()]
try:
with open(path, encoding="utf-8") as fh:
report = json.load(fh)
except (OSError, ValueError) as err:
print(f"refuse: unreadable vitest report: {err}", file=sys.stderr)
sys.exit(4)
results = report.get("testResults") or []
bad = [r.get("name", "?") for r in results if r.get("status") != "passed"]
if bad or report.get("numFailedTests", 0) or report.get("numFailedTestSuites", 0) or report.get("success") is not True:
print("refuse: failed " + ", ".join(bad or ["run"]), file=sys.stderr)
sys.exit(1)
if report.get("numPendingTests", 0) or report.get("numTodoTests", 0):
print(f"refuse: {report.get('numPendingTests', 0)} skipped, {report.get('numTodoTests', 0)} todo", file=sys.stderr)
sys.exit(2)
passed = set()
by_file = {}
for r in results:
for a in r.get("assertionResults") or []:
status = a.get("status")
if status != "passed":
print(f"refuse: {status} {a.get('fullName')}", file=sys.stderr)
sys.exit(2 if status in ("skipped", "pending", "todo") else 1)
passed.add(a.get("fullName", ""))
by_file[r.get("name", "")] = by_file.get(r.get("name", ""), 0) + 1
if report.get("numPassedTests", 0) < 1 or not passed:
print("refuse: no passing tests", file=sys.stderr)
sys.exit(3)
missing = [f for f in files if not any(name.endswith("/" + f) and count > 0 for name, count in by_file.items())]
missing += [n for n in names if n not in passed]
if missing:
print("refuse: required files or tests did not pass: " + "; ".join(missing), file=sys.stderr)
sys.exit(5)
print(f"vitest: {report.get('numPassedTests')} passed in {len(results)} files")
PY
}
# detect_tap reads node --test --test-reporter=tap output. Exit 1 "not ok" or
# a fail/cancelled count, 2 a skip/todo directive or count, 3 no passing test,
# 5 a missing required name. REQUIRE_NAMES holds one test name per line.
detect_tap() {
python3 - "$1" <<'PY'
import os, re, sys
path = sys.argv[1]
require = [n for n in os.environ.get("REQUIRE_NAMES", "").split("\n") if n.strip()]
passed = set()
counts = {}
with open(path, encoding="utf-8", errors="replace") as fh:
for raw in fh:
line = raw.rstrip("\n")
body = line.lstrip()
if body.startswith("not ok"):
print(f"refuse: {body}", file=sys.stderr)
sys.exit(1)
m = re.match(r"ok \d+ - (.*?)(\s+#\s*(SKIP|TODO)\b.*)?$", body, re.I)
if m:
if m.group(2):
print(f"refuse: {body}", file=sys.stderr)
sys.exit(2)
passed.add(m.group(1).replace("\\#", "#"))
continue
m = re.match(r"# (tests|suites|pass|fail|cancelled|skipped|todo) (\d+)$", line)
if m:
counts[m.group(1)] = int(m.group(2))
for key in ("fail", "cancelled"):
if counts.get(key, 0) > 0:
print(f"refuse: {counts[key]} {key}", file=sys.stderr)
sys.exit(1)
for key in ("skipped", "todo"):
if counts.get(key, 0) > 0:
print(f"refuse: {counts[key]} {key}", file=sys.stderr)
sys.exit(2)
if counts.get("pass", 0) < 1 or not passed:
print("refuse: no passing tests", file=sys.stderr)
sys.exit(3)
missing = [n for n in require if n not in passed]
if missing:
print("refuse: required tests did not pass: " + "; ".join(missing), file=sys.stderr)
sys.exit(5)
PY
}
# detect_files fails (exit 5) unless every REQUIRE_FILES entry (one per line,
# relative to the directory given) is a non-empty regular file whose name
# ends in .test.ts, the glob of the layers' test script.
detect_files() {
python3 - "$1" <<'PY'
import os, sys
base = sys.argv[1]
files = [n for n in os.environ.get("REQUIRE_FILES", "").split("\n") if n.strip()]
missing = [f for f in files if not (f.startswith("tests/") and f.endswith(".test.ts") and os.path.isfile(os.path.join(base, f)) and os.path.getsize(os.path.join(base, f)) > 0)]
if missing:
print("refuse: required test files missing in " + base + ": " + ", ".join(missing), file=sys.stderr)
sys.exit(5)
PY
}
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_go "$log" || dc=$?
if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then
cat "$err" >&2 || true
grep -v '^{' "$log" | tail -n 40 >&2 || true
python3 -c 'import json,sys
for raw in open(sys.argv[1], errors="replace"):
try: ev = json.loads(raw)
except ValueError: continue
if ev.get("Action") == "output" and ("FAIL" in ev.get("Output", "") or "_test.go:" in ev.get("Output", "")):
sys.stderr.write(ev["Output"])' "$log" 2>&1 | tail -n 40 >&2 || true
rm -f "$log" "$err"
echo "refuse: go test $* in $dir (test=$rc detect=$dc)" >&2
return 1
fi
echo "go: $(grep -c '"Action":"pass","Package":"[^"]*","Test":"[^"/]*","Elapsed"' "$log") top-level tests passed in $dir ($*)"
rm -f "$log" "$err"
}
# go_named DIR PACKAGES FLAGS NAME... runs exactly the named top-level tests
# and requires every one of them to pass. PACKAGES and FLAGS are word-split.
go_named() {
local dir="$1" pkgs="$2" flags="$3"
shift 3
local names=("$@") regex
regex="^($(IFS='|'; echo "${names[*]}"))\$"
# shellcheck disable=SC2086
REQUIRE_TESTS="${names[*]}" go_json "$dir" $flags -count=1 -run "$regex" $pkgs
}
# vitest_json DIR [VITEST ARGS...]: extra arguments (such as --config) go to vitest.
vitest_json() {
local dir="$1" report out rc=0 dc=0
shift
report="$(mktemp)"
out="$(mktemp)"
(cd "$dir" && ./node_modules/.bin/vitest run "$@" --reporter=json --outputFile="$report") >"$out" 2>&1 || rc=$?
detect_vitest "$report" || dc=$?
if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then
grep -E 'FAIL|Error|×' "$out" | tail -n 40 >&2 || true
rm -f "$report" "$out"
echo "refuse: vitest in $dir (test=$rc detect=$dc)" >&2
return 1
fi
rm -f "$report" "$out"
}
# pnpm_tap runs `pnpm -C <dir> test` (node --test, TAP) and checks the
# required files on disk and the TAP output.
pnpm_tap() {
local dir="$1" log rc=0 dc=0 fc=0
REQUIRE_FILES="${REQUIRE_FILES:-}" detect_files "$dir" || fc=$?
log="$(mktemp)"
pnpm -C "$dir" test >"$log" 2>&1 || rc=$?
detect_tap "$log" || dc=$?
if [[ "$rc" -ne 0 || "$dc" -ne 0 || "$fc" -ne 0 ]]; then
grep -E '^\s*(not ok|# )|Error' "$log" | tail -n 40 >&2 || true
rm -f "$log"
echo "refuse: pnpm test in $dir (test=$rc detect=$dc files=$fc)" >&2
return 1
fi
echo "tap: $(grep -E '^# (tests|pass|fail) ' "$log" | tr '\n' ' ')in $dir"
rm -f "$log"
}
expect_detector() {
local detector="$1" name="$2" want="$3" payload="$4" log dc=0
log="$(mktemp)"
printf '%s\n' "$payload" >"$log"
"$detector" "$log" >/dev/null 2>&1 || dc=$?
rm -f "$log"
if [[ "$dc" -ne "$want" ]]; then
echo "refuse: self-test $detector $name: exit $dc, want $want" >&2
return 1
fi
}
expect_files() {
local name="$1" want="$2" dir="$3" dc=0
detect_files "$dir" >/dev/null 2>&1 || dc=$?
if [[ "$dc" -ne "$want" ]]; then
echo "refuse: self-test detect_files $name: exit $dc, want $want" >&2
return 1
fi
}
run_self_test() {
bash -n "${BASH_SOURCE[0]}"
expect_detector detect_go pass 0 '{"Action":"pass","Package":"p","Test":"TestRegistryDuplicateKey"}'
expect_detector detect_go fail 1 '{"Action":"pass","Package":"p","Test":"TestA"}
{"Action":"fail","Package":"p","Test":"TestSanitizeByteStable"}'
expect_detector detect_go package-fail 1 '{"Action":"pass","Package":"p","Test":"TestA"}
{"Action":"fail","Package":"p"}'
expect_detector detect_go build 1 '{"Action":"build-fail","ImportPath":"p"}'
expect_detector detect_go skip 2 '{"Action":"pass","Package":"p","Test":"TestA"}
{"Action":"skip","Package":"p","Test":"TestMenuAPISmoke"}'
expect_detector detect_go zero 3 '{"Action":"pass","Package":"p"}'
expect_detector detect_go no-tests 3 '{"Action":"output","Package":"p","Output":"testing: warning: no tests to run\n"}
{"Action":"pass","Package":"p"}'
expect_detector detect_go nonjson 4 '{"Action":"pass",'
REQUIRE_TESTS="TestSitemapGolden TestFaqAPISmoke" expect_detector detect_go missing-named 5 \
'{"Action":"pass","Package":"p","Test":"TestSitemapGolden"}'
REQUIRE_TESTS="TestSitemap" expect_detector detect_go prefix-is-not-the-test 5 \
'{"Action":"pass","Package":"p","Test":"TestSitemapGolden"}'
REQUIRE_TESTS="TestRepeater" expect_detector detect_go subtest-is-not-the-test 5 \
'{"Action":"pass","Package":"p","Test":"TestRepeaterSave/TestRepeater"}
{"Action":"pass","Package":"p","Test":"TestOther"}'
local vok='{"success":true,"numPassedTests":2,"numFailedTests":0,"numFailedTestSuites":0,"numPendingTests":0,"numTodoTests":0,"testResults":[{"name":"/r/tests/form/RichEditorField.test.ts","status":"passed","assertionResults":[{"fullName":"RichEditorField RichEditorField tracer: untouched value is submitted byte-identical","status":"passed"},{"fullName":"RichEditorField RichEditorField edit emits editor HTML","status":"passed"}]}]}'
expect_detector detect_vitest pass 0 "$vok"
REQUIRE_FILES='tests/form/RichEditorField.test.ts' REQUIRE_NAMES='RichEditorField RichEditorField tracer: untouched value is submitted byte-identical' expect_detector detect_vitest named 0 "$vok"
expect_detector detect_vitest failed-test 1 '{"success":false,"numPassedTests":1,"numFailedTests":1,"testResults":[{"name":"/r/a.test.ts","status":"failed","assertionResults":[{"fullName":"x","status":"failed"}]}]}'
expect_detector detect_vitest failed-file 1 '{"success":false,"numPassedTests":0,"numFailedTests":0,"numFailedTestSuites":1,"testResults":[{"name":"/r/a.test.ts","status":"failed","assertionResults":[]}]}'
expect_detector detect_vitest unsuccessful 1 '{"success":false,"numPassedTests":1,"numFailedTests":0,"testResults":[{"name":"/r/a.test.ts","status":"passed","assertionResults":[{"fullName":"x","status":"passed"}]}]}'
expect_detector detect_vitest skipped 2 '{"success":true,"numPassedTests":1,"numPendingTests":1,"testResults":[{"name":"/r/a.test.ts","status":"passed","assertionResults":[{"fullName":"x","status":"passed"},{"fullName":"y","status":"skipped"}]}]}'
expect_detector detect_vitest todo 2 '{"success":true,"numPassedTests":1,"numTodoTests":1,"testResults":[{"name":"/r/a.test.ts","status":"passed","assertionResults":[{"fullName":"x","status":"passed"}]}]}'
expect_detector detect_vitest zero 3 '{"success":true,"numPassedTests":0,"testResults":[]}'
expect_detector detect_vitest unreadable 4 'not json'
REQUIRE_FILES='tests/views/ReorderView.test.ts' expect_detector detect_vitest missing-file 5 "$vok"
REQUIRE_NAMES='missing case' expect_detector detect_vitest missing-name 5 "$vok"
local tok='TAP version 13
# Subtest: javascript: and data: URLs render as text with no href
ok 1 - javascript: and data: URLs render as text with no href
ok 2 - the allowlist takes http, https, mailto, tel, / and \#
1..2
# tests 2
# suites 0
# pass 2
# fail 0
# cancelled 0
# skipped 0
# todo 0'
expect_detector detect_tap pass 0 "$tok"
REQUIRE_NAMES=$'javascript: and data: URLs render as text with no href\nthe allowlist takes http, https, mailto, tel, / and #' expect_detector detect_tap named 0 "$tok"
expect_detector detect_tap not-ok 1 'ok 1 - a
not ok 2 - b
# pass 1
# fail 1'
expect_detector detect_tap nested-not-ok 1 ' not ok 1 - sub
ok 1 - a
# pass 1
# fail 0'
expect_detector detect_tap fail-count 1 'ok 1 - a
# pass 1
# fail 1'
expect_detector detect_tap cancelled 1 'ok 1 - a
# pass 1
# cancelled 1'
expect_detector detect_tap skip 2 'ok 1 - a # SKIP later
# pass 1'
expect_detector detect_tap todo-count 2 'ok 1 - a
# pass 1
# todo 1'
expect_detector detect_tap zero 3 '# tests 0
# pass 0
# fail 0'
expect_detector detect_tap no-output 3 ''
REQUIRE_NAMES=$'javascript: and data: URLs render as text with no href\nmissing' expect_detector detect_tap missing-named 5 "$tok"
local tmp
tmp="$(mktemp -d)"
mkdir -p "$tmp/tests"
printf 'test\n' >"$tmp/tests/a.test.ts"
: >"$tmp/tests/empty.test.ts"
REQUIRE_FILES='tests/a.test.ts' expect_files present 0 "$tmp" || { rm -rf "$tmp"; return 1; }
REQUIRE_FILES=$'tests/a.test.ts\ntests/missing.test.ts' expect_files missing 5 "$tmp" || { rm -rf "$tmp"; return 1; }
REQUIRE_FILES='tests/empty.test.ts' expect_files empty 5 "$tmp" || { rm -rf "$tmp"; return 1; }
REQUIRE_FILES='tests/a.ts' expect_files outside-glob 5 "$tmp" || { rm -rf "$tmp"; return 1; }
rm -rf "$tmp"
grep -qF 'bash "$ROOT/scripts/check-phase10.sh" --hygiene' "${BASH_SOURCE[0]}" || {
echo "refuse: the docs stage does not run check-phase10.sh --hygiene" >&2
return 1
}
local mode
for mode in self-test go plugins admin layers docs postgres all; do
grep -q -- "^ --$mode" "${BASH_SOURCE[0]}" || {
echo "refuse: missing mode --$mode" >&2
return 1
}
done
echo "phase17 self-test passed"
}
require_dir() {
[[ -d "$1" ]] || {
echo "refuse: $2 $1 not found" >&2
return 1
}
}
run_go() {
(cd "$ROOT" && go vet ./...)
go_named "$ROOT" ./modules/signpost -short "${SIGNPOST_TESTS[@]}"
go_named "$ROOT" ./modules/seaglass -short "${SEAGLASS_TESTS[@]}"
go_named "$ROOT" ./modules/pact -short "${PACT_TESTS[@]}"
go_named "$ROOT" ./modules/party -short "${PARTY_TESTS[@]}"
go_named "$ROOT" ./modules/cabana -short "${CABANA_SHORT_TESTS[@]}"
echo "phase17 go passed"
}
run_plugins() {
require_dir "$JOURNAL" "Journal plugin"
require_dir "$SITEMAP" "sitemap plugin"
require_dir "$MENU" "menu plugin"
require_dir "$FAQ" "FAQ plugin"
require_dir "$HOST" "host application"
local repo
for repo in "$JOURNAL" "$SITEMAP" "$MENU" "$FAQ" "$HOST"; do
go -C "$repo" vet ./...
echo "vet: $repo"
done
go_named "$JOURNAL" ./classes -short "${JOURNAL_CLASSES_TESTS[@]}"
go_named "$SITEMAP" ./classes -short "${SITEMAP_CLASSES_TESTS[@]}"
go_named "$SITEMAP" ./models -short "${SITEMAP_MODELS_TESTS[@]}"
go_named "$SITEMAP" . -short "${SITEMAP_ROOT_TESTS[@]}"
go_named "$SITEMAP" ./controllers -short "${SITEMAP_CONTROLLERS_TESTS[@]}"
go_named "$SITEMAP" ./console -short "${SITEMAP_CONSOLE_TESTS[@]}"
go_named "$MENU" ./classes -short "${MENU_CLASSES_TESTS[@]}"
go_named "$MENU" ./models -short "${MENU_MODELS_TESTS[@]}"
go_named "$FAQ" ./console -short "${FAQ_CONSOLE_TESTS[@]}"
go_named "$FAQ" ./models -short "${FAQ_MODELS_TESTS[@]}"
go_named "$FAQ" ./controllers/api -short "${FAQ_API_TESTS[@]}"
go_named "$HOST" . -short "${HOST_SHORT_TESTS[@]}"
echo "phase17 plugins passed"
}
run_admin() {
(cd "$ROOT/admin" && ./node_modules/.bin/vue-tsc --noEmit)
REQUIRE_FILES="$(printf '%s\n' "${ADMIN_FILES[@]}")" REQUIRE_NAMES="$(printf '%s\n' "${ADMIN_REQUIRE[@]}")" vitest_json "$ROOT/admin"
REQUIRE_FILES="$(printf '%s\n' "${ADMIN_BROWSER_FILES[@]}")" REQUIRE_NAMES="$(printf '%s\n' "${ADMIN_BROWSER_REQUIRE[@]}")" \
vitest_json "$ROOT/admin" --config vitest.browser.config.ts
echo "phase17 admin passed"
}
run_layers() {
require_dir "$MENU_LAYER" "menu layer"
require_dir "$FAQ_LAYER" "FAQ layer"
REQUIRE_FILES="$(printf '%s\n' "${MENU_LAYER_FILES[@]}")" REQUIRE_NAMES="$(printf '%s\n' "${MENU_LAYER_REQUIRE[@]}")" pnpm_tap "$MENU_LAYER"
REQUIRE_FILES="$(printf '%s\n' "${FAQ_LAYER_FILES[@]}")" REQUIRE_NAMES="$(printf '%s\n' "${FAQ_LAYER_REQUIRE[@]}")" pnpm_tap "$FAQ_LAYER"
echo "phase17 layers passed"
}
run_docs() {
go_named "$ROOT" ./cmd/summer "" TestDocsTree
(cd "$ROOT" && go run ./cmd/summer docs:build --check)
bash "$ROOT/scripts/check-admin-openapi.sh" --check
bash "$ROOT/scripts/check-admin-dist.sh"
# Cross-phase: Phase 10's SPA hygiene (raw-HTML sinks, dist origins, every
# module imported by a test) must stay green after Phase 17 admin changes.
bash "$ROOT/scripts/check-phase10.sh" --hygiene
echo "phase17 docs passed"
}
docker_up() {
command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1
}
run_postgres() {
require_dir "$JOURNAL" "Journal plugin"
require_dir "$SITEMAP" "sitemap plugin"
require_dir "$MENU" "menu plugin"
require_dir "$FAQ" "FAQ plugin"
require_dir "$HOST" "host application"
if ! docker_up; then
echo "refuse: the postgres stage needs a reachable Docker daemon (testcontainers); run it on a Docker host" >&2
return 1
fi
go_named "$ROOT" ./modules/cabana "-timeout 20m" "${PG_CABANA_TESTS[@]}"
go_named "$JOURNAL" . "-timeout 20m" "${PG_JOURNAL_TESTS[@]}"
go_named "$SITEMAP" . "-timeout 20m" "${PG_SITEMAP_TESTS[@]}"
go_named "$MENU" . "-timeout 20m" "${PG_MENU_TESTS[@]}"
go_named "$FAQ" . "-timeout 20m" "${PG_FAQ_TESTS[@]}"
go_named "$HOST" . "-timeout 20m" "${PG_HOST_TESTS[@]}"
echo "phase17 postgres passed"
}
run_all() {
local stage
for stage in self-test go plugins admin layers docs; do
if bash "${BASH_SOURCE[0]}" "--$stage"; then
echo "PASS $stage"
else
echo "FAIL $stage"
exit 1
fi
done
if docker_up; then
if bash "${BASH_SOURCE[0]}" --postgres; then
echo "PASS postgres"
else
echo "FAIL postgres"
exit 1
fi
elif [[ "${PHASE17_REQUIRE_POSTGRES:-0}" == "1" ]]; then
echo "FAIL postgres: no reachable Docker daemon and PHASE17_REQUIRE_POSTGRES=1"
exit 1
else
cat >&2 <<'EOF'
************************************************************************
NOT RUN postgres: no reachable Docker daemon on this host.
The testcontainers suites of the framework (cabana companions, reorder,
singleton, OpenAPI conformance), Journal (menu item types), sitemap, menu,
FAQ and the host (TestCompanionsHost) did NOT run.
Run on a Docker host before /msd-verify-work:
bash scripts/check-phase17.sh postgres
************************************************************************
EOF
echo "NOT RUN postgres (Docker-host only; see the notice above)"
fi
echo "Phase 17 gate passed"
}
case "${1:---all}" in
--self-test | self-test) run_self_test ;;
--go | go) run_go ;;
--plugins | plugins) run_plugins ;;
--admin | admin) run_admin ;;
--layers | layers) run_layers ;;
--docs | docs) run_docs ;;
--postgres | postgres) run_postgres ;;
--all | all) run_all ;;
*) usage ;;
esac