#!/usr/bin/env bash # Phase 18.1 fail-closed gate: payment gateway subscriptions. The framework # pieces in this repository (the cabana taglist form field and the flat # Sortable reorder, with their admin SPA parts), the plugin repository # sm-paymentgatewaysubscriptions-plugin (the gateway-neutral subscriptions # core, its admin screens and its customer API), sm-pgstripe-plugin (the root # plugin's subscription signing secrets and its subscriptions package, the # Stripe subscription gateway and webhook, whose proof host is a test inside # that package) and the subscription pieces of the Nuxt layer # vue-paymentgateway-layer. # # Stages: # go go vet ./... here; the Docker-free framework suites by required # name (-short). # plugins go vet ./... and go test -short ./... in both plugins; their # Docker-free suites by required name and package (-short). # admin the admin SPA typecheck and the vitest JSON report with required # spec files and test names. # layers pnpm test (node --test, TAP) in the layer with required test # files and names, and a minimum number of passing tests whose # name starts with "subscriptions:". # 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 and package. 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, a missing required test or too few tests with the required prefix; # 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-phase18.1.sh postgres # Set PHASE181_REQUIRE_POSTGRES=1 to make --all fail instead. # # Environment overrides (default: the sibling checkouts next to this # repository): PHASE181_ROOT (this repository), PGSUB # (sm-paymentgatewaysubscriptions-plugin), PGSTRIPE (sm-pgstripe-plugin), # PG_LAYER (vue-paymentgateway-layer). set -euo pipefail unset FORCE_COLOR export NO_COLOR=1 ROOT="${PHASE181_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" PGSUB="${PGSUB:-$ROOT/../sm-paymentgatewaysubscriptions-plugin}" PGSTRIPE="${PGSTRIPE:-$ROOT/../sm-pgstripe-plugin}" PG_LAYER="${PG_LAYER:-$ROOT/../vue-paymentgateway-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). CABANA_SHORT_TESTS=( TestTaglistFieldCompile TestTaglistFieldValidate TestTaglistFieldHydrate TestReorderFlatCompile TestReorderFlatNodes ) # plugins stage: Docker-free suites (-short), per repository and package. # An empty list means the package is covered by vet and the -short run only. PGSUB_MODELS_TESTS=(TestSubscriptionChecks TestPlanPrices TestFeatureList) PGSUB_CONTRACTS_TESTS=(TestRegistry) PGSUB_ROOT_TESTS=(TestLangParity TestAdminLangParity TestAdminCatalog TestRedirectURLs TestFixtureAbsentFromProduction) PGSTRIPE_ROOT_TESTS=(TestSDKPin TestStripeDataHandling TestStripeAuthentication TestFixtureAbsentFromProduction) PGSTRIPE_SUBS_TESTS=(TestDecodeSubscription TestDecodeInvoice TestJobArgsMinimal) # postgres stage: testcontainers suites, per repository and package. PG_CABANA_TESTS=(TestTaglistFieldSmoke TestReorderFlatSmoke TestReorderPostgres) PG_PGSUB_TESTS=( TestSubscriptionsTracer TestSubscriptionsCheckout TestSubscriptionsCoreSmoke TestPlanJSONBColumns TestAdminPlansSmoke TestAdminSubscriptionsSmoke TestCustomerAPISmoke TestApplyEvents TestClaimEventConcurrent TestUserSubscribable TestBaselineSchema TestServiceAPI TestAdminSyncActions TestCustomerAPIAuthz ) PG_PGSTRIPE_ROOT_TESTS=( TestStripeSettingsSmoke TestStripeImportWinterSmoke TestStripeSubscriptionSecrets TestHostStripeFlow ) PG_PGSTRIPE_SUBS_TESTS=( TestHostSubscriptionFlow TestSubscriptionWebhookSmoke TestSubscriptionJob TestSyncPlan TestSubscriptionGateway TestStripeSubscriptionReliability TestSubscriptionAuthentication ) # admin stage: vitest spec files and full test names (describe + it). ADMIN_FILES=( tests/form/TaglistField.test.ts tests/views/ReorderView.test.ts ) ADMIN_REQUIRE=( "TaglistField renders a loaded list as chips and does not re-emit it untouched" "TaglistField adds the typed tag on Enter and ignores a duplicate or empty text" "TaglistField adds a tag on typing a comma" "TaglistField removes a chip through its labelled remove button" "TaglistField replaces the chips when the value changes from outside" "TaglistField renders read-only chips without an input or remove buttons" "TaglistField renders a tag holding markup as text" "TaglistField splits and joins a string-mode value on its space separator" "TaglistField joins a string-mode value with a comma by default" "TaglistField refuses a tag outside the options when customTags is false" "TaglistField renders the tags joined as text on the preview screen" "TaglistField coverage renders a tag holding a script element as text and runs nothing" "TaglistField coverage removes the last chip on Backspace in an empty input, and nothing while text is typed" "TaglistField coverage adds the draft on blur" "TaglistField coverage adds every complete tag of a pasted list and keeps the unfinished rest as the draft" "TaglistField coverage offers the options as datalist suggestions bound to the input, and none without options" "TaglistField coverage with customTags false ignores a free tag typed with the separator but adds a suggested one" "TaglistField coverage splits a comma string-mode value, trims and drops duplicates, and emits the joined string" "TaglistField coverage reads a string as no tags in array mode" "TaglistField coverage marks the input required and invalid for the form" "ReorderView flat offers no indent or outdent, ignores ArrowRight, drops a middle drag after the row and sends parent_id null" "ReorderView flat moves by ArrowUp and ArrowDown on the handles, keeps focus and announces, then sends one save with null parents" "ReorderView flat maps the top third of a row to before, the bottom third to after and the middle to after on a flat list" "ReorderView flat maps the inside zone to after, keeps sibling moves and flattens with null parents" ) # layers stage: test files (must exist and match the test script's glob), TAP # test names, and the minimum number of passing tests named "subscriptions: ...". LAYER_FILES=( tests/subscriptions.test.ts tests/subscribe.test.ts tests/subscriptionComponents.test.ts tests/i18n.test.ts ) LAYER_REQUIRE=( 'subscriptions: pl and en carry the same pg.subscriptions keys and placeholders' 'subscriptions: every status and interval is labelled and every pg.subscriptions key used in app/ exists' 'subscriptions: subscribe assigns checkout_url once and stays busy' 'subscriptions: a second click while the checkout runs sends no request' 'subscriptions: each refused status maps to its key and enables the button again' 'subscriptions: an answer without an http(s) URL is never followed' 'subscriptions: PgPlans lists the plans with a working PgSubscribeButton per paid plan' 'subscriptions: subscribe without a currency sends none, and a retry after a refusal clears the error' 'subscriptions: PgSubscribeButton and PgManageSubscriptionButton render enabled with their copy and fetch nothing before a click' 'subscriptions: PgPlans renders two plans with exact prices and features, and no subscribe button for a free plan' 'subscriptions: PgPlans keeps the API order, filters by planCodes and falls back to the first price' 'subscriptions: a plan text with markup renders as text' 'subscriptions: PgPlans shows skeleton cards, the load error with Retry, and the empty copy' 'subscriptions: PgMySubscription without a subscription shows the none copy and the plans link' 'subscriptions: an active subscription shows the status, period end, days until renewal, access, Manage and Cancel' 'subscriptions: a scheduled cancel shows the will-cancel notice with the date and no Cancel button' 'subscriptions: a past_due subscription shows the grace notice and the warning tone, without Cancel' 'subscriptions: PgMySubscription shows the login copy on a 401 and the load error with Retry otherwise' 'subscriptions: useSubscription turns the 404 into null and keeps other failures' 'subscriptions: the manage flow assigns portal_url once and maps a 404 to no_billing_account' 'subscriptions: the cancel flow resolves the updated subscription, or null with the mapped error' 'subscriptions: the plans currency prop picks the matching price in any case, and no currency shows the first' 'subscriptions: the confirmed none answer (404 Subscription not found) renders the no-subscription state' 'subscriptions: the cancel button shows only for an active or trialing subscription with no cancel scheduled' 'subscriptions: the manage flow maps 401 to the login key and 502 to the provider key on open and cancel' 'subscriptions: a second cancel while one runs sends nothing, and a cancel after a portal redirect is refused' 'subscriptions: plans GETs /plans and unwraps {data}; a body without a list is empty' 'subscriptions: every route hits its path with its method and body' 'subscriptions: the client never sends a redirect URL, a price, a subscriber or a subscription id' 'subscriptions: hasNoSubscription is a 404 only' 'subscriptions: planPrice picks the currency, else the first price; status and interval keys' 'subscriptions: on the server the composable uses the internal base, forwards the visitor and sends the bearer' 'subscriptions: in the browser the composable uses the public base and the bearer token only' 'subscriptions: without a token from the hook no authorization header is sent' 'subscriptions: a refused request rejects with the fetcher error unchanged on every route' 'subscriptions: usePlans reads GET /plans under the key pg:subscriptions:plans' ) LAYER_PREFIX='subscriptions:' LAYER_PREFIX_MIN=36 usage() { cat >&2 <<'EOF' usage: check-phase18.1.sh --self-test check-phase18.1.sh go | plugins | admin | layers | docs | postgres check-phase18.1.sh --go | --plugins | --admin | --layers | --docs | --postgres check-phase18.1.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 or fewer than REQUIRE_PREFIX_MIN passing tests # whose name starts with REQUIRE_PREFIX. 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()] prefix = os.environ.get("REQUIRE_PREFIX", "") prefix_min = int(os.environ.get("REQUIRE_PREFIX_MIN", "0") or "0") 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) if prefix: got = sum(1 for n in passed if n.startswith(prefix)) if got < prefix_min: print(f"refuse: {got} passing tests named '{prefix} ...', want at least {prefix_min}", file=sys.stderr) sys.exit(5) print(f"tap: {got} passing tests named '{prefix} ...' (minimum {prefix_min})") 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 layer's 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. # Without names it does nothing (the package is covered by another run). go_named() { local dir="$1" pkgs="$2" flags="$3" shift 3 if [[ "$#" -eq 0 ]]; then return 0 fi 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 test` (node --test, TAP) and checks the # required files on disk and the TAP output. It never installs anything: when # pnpm refuses to run because node_modules was installed against another # store, it prints the offline, frozen-lockfile recovery and fails. 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=$? if grep -q 'ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY' "$log"; then rm -f "$log" cat >&2 < then run this stage again. EOF return 1 fi 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":"TestApplyEvents"}' expect_detector detect_go fail 1 '{"Action":"pass","Package":"p","Test":"TestA"} {"Action":"fail","Package":"p","Test":"TestSubscriptionJob"}' 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":"TestHostSubscriptionFlow"}' 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="TestTaglistFieldCompile TestSDKPin" expect_detector detect_go missing-named 5 \ '{"Action":"pass","Package":"p","Test":"TestTaglistFieldCompile"}' REQUIRE_TESTS="TestTaglist" expect_detector detect_go prefix-is-not-the-test 5 \ '{"Action":"pass","Package":"p","Test":"TestTaglistFieldCompile"}' REQUIRE_TESTS="TestRegistry" expect_detector detect_go subtest-is-not-the-test 5 \ '{"Action":"pass","Package":"p","Test":"TestSubscriptionsCoreSmoke/TestRegistry"} {"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/TaglistField.test.ts","status":"passed","assertionResults":[{"fullName":"taglist field renders a tag with markup as text","status":"passed"},{"fullName":"taglist field adds a tag on Enter","status":"passed"}]}]}' expect_detector detect_vitest pass 0 "$vok" REQUIRE_FILES='tests/form/TaglistField.test.ts' REQUIRE_NAMES='taglist field renders a tag with markup as text' 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: subscriptions: subscribe assigns checkout_url once and stays busy ok 1 - subscriptions: subscribe assigns checkout_url once and stays busy ok 2 - subscriptions: the allowlist takes http, https and \# ok 3 - money: a plain test 1..3 # tests 3 # suites 0 # pass 3 # fail 0 # cancelled 0 # skipped 0 # todo 0' expect_detector detect_tap pass 0 "$tok" REQUIRE_NAMES=$'subscriptions: subscribe assigns checkout_url once and stays busy\nsubscriptions: the allowlist takes http, https and #' expect_detector detect_tap named 0 "$tok" REQUIRE_PREFIX='subscriptions:' REQUIRE_PREFIX_MIN=2 expect_detector detect_tap prefix-enough 0 "$tok" REQUIRE_PREFIX='subscriptions:' REQUIRE_PREFIX_MIN=3 expect_detector detect_tap prefix-too-few 5 "$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=$'subscriptions: subscribe assigns checkout_url once and stays busy\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 } grep -qF '"${PHASE181_REQUIRE_POSTGRES:-0}" == "1"' "${BASH_SOURCE[0]}" || { echo "refuse: --all does not honour PHASE181_REQUIRE_POSTGRES" >&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 "phase18.1 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/cabana -short "${CABANA_SHORT_TESTS[@]}" echo "phase18.1 go passed" } run_plugins() { require_dir "$PGSUB" "subscriptions plugin" require_dir "$PGSTRIPE" "Stripe operator plugin" local repo for repo in "$PGSUB" "$PGSTRIPE"; do go -C "$repo" vet ./... echo "vet: $repo" # Every package of the plugin compiles and its Docker-free tests pass; # the Docker suites skip here and run by name in the postgres stage. go -C "$repo" test -count=1 -short ./... >/dev/null echo "short: $repo" done go_named "$PGSUB" ./models -short ${PGSUB_MODELS_TESTS[@]+"${PGSUB_MODELS_TESTS[@]}"} go_named "$PGSUB" ./contracts -short ${PGSUB_CONTRACTS_TESTS[@]+"${PGSUB_CONTRACTS_TESTS[@]}"} go_named "$PGSUB" . -short ${PGSUB_ROOT_TESTS[@]+"${PGSUB_ROOT_TESTS[@]}"} go_named "$PGSTRIPE" . -short ${PGSTRIPE_ROOT_TESTS[@]+"${PGSTRIPE_ROOT_TESTS[@]}"} go_named "$PGSTRIPE" ./subscriptions -short "${PGSTRIPE_SUBS_TESTS[@]}" echo "phase18.1 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[@]+"${ADMIN_REQUIRE[@]}"})" \ vitest_json "$ROOT/admin" echo "phase18.1 admin passed" } run_layers() { require_dir "$PG_LAYER" "payment gateway layer" REQUIRE_FILES="$(printf '%s\n' "${LAYER_FILES[@]}")" \ REQUIRE_NAMES="$(printf '%s\n' ${LAYER_REQUIRE[@]+"${LAYER_REQUIRE[@]}"})" \ REQUIRE_PREFIX="$LAYER_PREFIX" REQUIRE_PREFIX_MIN="$LAYER_PREFIX_MIN" \ pnpm_tap "$PG_LAYER" echo "phase18.1 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 18.1 admin changes. bash "$ROOT/scripts/check-phase10.sh" --hygiene echo "phase18.1 docs passed" } docker_up() { command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1 } run_postgres() { require_dir "$PGSUB" "subscriptions plugin" require_dir "$PGSTRIPE" "Stripe operator plugin" 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 "$PGSUB" . "-timeout 20m" "${PG_PGSUB_TESTS[@]}" go_named "$PGSTRIPE" . "-timeout 20m" "${PG_PGSTRIPE_ROOT_TESTS[@]}" go_named "$PGSTRIPE" ./subscriptions "-timeout 20m" "${PG_PGSTRIPE_SUBS_TESTS[@]}" echo "phase18.1 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 [[ "${PHASE181_REQUIRE_POSTGRES:-0}" == "1" ]]; then echo "FAIL postgres: no reachable Docker daemon and PHASE181_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 taglist and flat reorder), sm-paymentgatewaysubscriptions-plugin (baseline, apply events, claim, service, admin screens, customer API) and sm-pgstripe-plugin (settings and import, TestHostSubscriptionFlow, webhook, job, plan sync, gateway) did NOT run. Run on a Docker host before /msd-verify-work: bash scripts/check-phase18.1.sh postgres ************************************************************************ EOF echo "NOT RUN postgres (Docker-host only; see the notice above)" fi echo "Phase 18.1 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