- plugins stage: money, fsm, orders, payments, cart, operators, support, currency, models, events, console, api and root suites by name (-short) - postgres stage: every Postgres suite of sm-paymentgateway-plugin, the plan 02 to 06 smokes and the new service, bridge, event, migration, command and converter suites
645 lines
30 KiB
Bash
Executable File
645 lines
30 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# Phase 18 fail-closed gate: payment gateway. The framework pieces in this
|
||
# repository (cabana money field and column, write-only settings secrets,
|
||
# richeditor and tabs on settings forms, danger record actions, the preview
|
||
# Edit rule, the admin SPA), the two plugin repositories (sm-paymentgateway-
|
||
# plugin, sm-pgstripe-plugin, whose proof host is a test inside pgstripe) and
|
||
# 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.
|
||
# 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-phase18.sh postgres
|
||
# Set PHASE18_REQUIRE_POSTGRES=1 to make --all fail instead.
|
||
#
|
||
# Environment overrides (default: the sibling checkouts next to this
|
||
# repository): PHASE18_ROOT (this repository), PG (sm-paymentgateway-plugin),
|
||
# PGSTRIPE (sm-pgstripe-plugin), PG_LAYER (vue-paymentgateway-layer).
|
||
set -euo pipefail
|
||
|
||
unset FORCE_COLOR
|
||
export NO_COLOR=1
|
||
|
||
ROOT="${PHASE18_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||
PG="${PG:-$ROOT/../sm-paymentgateway-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=(TestMoneyFieldCompile TestMoneyFieldColumnGate TestMoneyFieldValidate TestMoneyListCompile TestMoneyListColumn TestSettingsSecretCompile TestSettingsSecretProjection TestSettingsSecretPut TestSettingsTabs TestSettingsRichEditor TestSettingsDropdownMethod TestRecordActionDanger TestPhase10ConformanceInventory)
|
||
|
||
# plugins stage: Docker-free suites (-short), per repository and package.
|
||
PG_MONEY_TESTS=(TestMoneyVectorsSmoke TestAmount4ScanValue TestMoneyVectors TestMoneyAllocate TestMoneyRounding TestAmount4)
|
||
PG_FSM_TESTS=(TestFSMSmoke TestFSM)
|
||
PG_ORDERS_TESTS=(TestOrderMatrix)
|
||
PG_PAYMENTS_TESTS=(TestPaymentMatrix)
|
||
PG_CART_TESTS=(TestLegacyCartGoldens TestCart TestCartTotals TestItem TestDetails TestLegacyFixtures TestCartRegistry)
|
||
PG_OPERATORS_TESTS=(TestOperatorRegistry)
|
||
PG_SUPPORT_TESTS=(TestHashids TestGuestToken TestRedactor TestFormatAmount)
|
||
PG_CURRENCY_TESTS=(TestCurrencyProviders TestConverter)
|
||
PG_MODELS_TESTS=(TestFillable)
|
||
PG_EVENTS_TESTS=(TestEvents TestMailers TestBankDetailsSanitized)
|
||
PG_CONSOLE_TESTS=(TestImportWinter TestPGCommandsDeadline)
|
||
PG_API_TESTS=(TestCustomerAPIAuthz)
|
||
PG_ROOT_TESTS=(TestNotificationRoute TestNotificationSecurity TestHashidsBoot TestAdminPermissions TestAdminCatalogSmoke TestAdminFormatSmoke TestMailCatalog)
|
||
PGSTRIPE_ROOT_TESTS=(TestStripeWebhook TestFixtureAbsentFromProduction)
|
||
|
||
# postgres stage: testcontainers suites.
|
||
PG_CABANA_TESTS=(TestMoneyFieldSmoke TestMoneyListSmoke TestRecordActionDangerSmoke TestSettingsSecretSmoke TestSettingsRichEditorSmoke TestMoneyFieldSave TestSettingsSecretPostgres TestRichEditor TestPhase10OpenAPIConformance)
|
||
PG_PG_TESTS=(TestPaymentGatewayTracer TestRefundBridgeSmoke TestNotificationRouteSmoke TestPGCommandsSmoke TestImportWinterSmoke
|
||
TestOrdersAdminSmoke TestPaymentsAdminSmoke TestShippingsAdminSmoke TestQuoteablesAdminSmoke TestQuotesAdminSmoke
|
||
TestPGSettingsSmoke TestMailerSettingsSmoke TestMailTracer TestMailersMatrixSmoke TestCustomerAPITracer TestCustomerAPI
|
||
TestPaymentsService TestRefundBridge TestOrderAndPaymentEvents TestMigrations TestPGCommands TestCurrencyConverterPostgres)
|
||
PG_PGSTRIPE_TESTS=(TestHostStripeFlow)
|
||
|
||
# admin stage: vitest spec files and full test names (describe + it).
|
||
ADMIN_FILES=(
|
||
tests/form/MoneyField.test.ts
|
||
tests/app/money.test.ts
|
||
tests/form/PasswordField.test.ts
|
||
tests/views/SettingsFormView.test.ts
|
||
tests/form/RecordActions.test.ts
|
||
tests/form/PreviewView.test.ts
|
||
tests/list/CellValue.test.ts
|
||
)
|
||
ADMIN_REQUIRE=(
|
||
"parseMoneyInput accepts a comma or a dot as the separator and strips spaces and no-break spaces"
|
||
"parseMoneyInput keeps a leading minus and refuses a minus anywhere else"
|
||
"parseMoneyInput is null for empty or blank text"
|
||
"parseMoneyInput refuses letters, exponents, two separators and lone separators as invalid"
|
||
"parseMoneyInput refuses more significant fraction digits than the scale, never rounding or truncating"
|
||
"parseMoneyInput accepts and keeps trailing zeros beyond the scale, as the server does"
|
||
"parseMoneyInput keeps amounts beyond the float range digit for digit"
|
||
"trimMoneyDisplay trims trailing fraction zeros down to two decimals and keeps the rest"
|
||
"decimalSeparator is a dot in en and a comma in pl"
|
||
"formatMoney formats en and pl with the ISO code and at least two decimals"
|
||
"formatMoney keeps up to scale decimals and never rounds a four-decimal amount"
|
||
"formatMoney formats a negative amount"
|
||
"formatMoney formats the amount alone without a currency"
|
||
"formatMoney appends an unknown code to the plain amount instead of throwing"
|
||
"money helpers parses typed text into the canonical dot string without a number"
|
||
"money helpers trims trailing zeros down to two decimals for display"
|
||
"money helpers formats through Intl with the string itself, keeping four decimals"
|
||
"money field component (UI-SPEC F1) is a text input in decimal mode with the currency suffix"
|
||
"money field component (UI-SPEC F1) shows a loaded value trimmed, with the locale separator, and does not emit"
|
||
"money field component (UI-SPEC F1) submits an untouched 85.4193 byte-identical: no emit on load or blur"
|
||
"money field component (UI-SPEC F1) emits the canonical dot string for \"1 234,56\" and null for empty"
|
||
"money field component (UI-SPEC F1) refuses 12.34567 at scale 4 on blur, neither rounded nor truncated"
|
||
"money field component (UI-SPEC F1) shows money.invalid for a second separator"
|
||
"money field component (UI-SPEC F1) follows the currencyFrom sibling and omits the suffix without a code"
|
||
"money field component (UI-SPEC F1) renders a read-only box with the formatted amount or an em dash"
|
||
"money field component (UI-SPEC F1) validates on blur only: a malformed draft shows nothing while typing until the first blur"
|
||
"money field component (UI-SPEC F1) marks the input invalid when the form reports a server error for the field"
|
||
"money field component (UI-SPEC F1) accepts a loaded four-decimal value on a scale-2 field without an error on blur"
|
||
"money field component (UI-SPEC F1) replaces the draft when the value is set from outside"
|
||
"settings secret states (UI-SPEC F2, D-13) renders no status line on a record form (no state provided)"
|
||
"settings secret states (UI-SPEC F2, D-13) renders the stored, env and empty lines with the input always empty"
|
||
"settings secret states (UI-SPEC F2, D-13) does not emit for an untouched field, emits null on Remove stored value and \"\" on Undo"
|
||
"settings secret states (UI-SPEC F2, D-13) never shows the typed value after the save response replaces it"
|
||
"preview edit rule (UI-SPEC F6) shows no Edit button and no primary fill when the form holds only partial fields"
|
||
"preview edit rule (UI-SPEC F6) keeps the Edit button when the form has a text field the update context allows"
|
||
"preview edit rule (UI-SPEC F6) hides the Edit button when every value field is preview-only"
|
||
"danger record actions (UI-SPEC F5) gives the dialog confirm button the danger variant for a danger action, the default otherwise"
|
||
"money column (UI-SPEC F1, D-07) formats the decimal string with the row's currencyFrom code in en and pl"
|
||
"money column (UI-SPEC F1, D-07) uses the literal currency of the column"
|
||
"money column (UI-SPEC F1, D-07) shows the amount alone when the row has no currency, and the dash when empty"
|
||
"money column (UI-SPEC F1, D-07) aligns the header and the cells of a money column to the right"
|
||
"settings tabs (UI-SPEC F4) renders no tab strip for an untabbed schema"
|
||
"settings tabs (UI-SPEC F4) renders the tabs with the default tab first and the card inside the panel"
|
||
"settings tabs (UI-SPEC F4) activates the first tab with an error after a 422 and shows its error chip"
|
||
"settings secrets (UI-SPEC F2, D-13) shows each secret state and sends neither an untouched secret nor its empty input"
|
||
"settings secrets (UI-SPEC F2, D-13) sends a typed secret once, then clears the input and shows the stored line from the response"
|
||
"settings secrets (UI-SPEC F2, D-13) sends null for a removed stored value and restores the line on Undo"
|
||
)
|
||
|
||
# layers stage: test files (must exist and match the test script's glob) and
|
||
# TAP test names.
|
||
LAYER_FILES=(tests/checkout.test.ts tests/orderPage.test.ts)
|
||
LAYER_REQUIRE=(
|
||
"a redirect answer calls location.assign once with the URL and the button stays busy"
|
||
"an AwaitingPayment order is polled every 3 s and polling stops at Paid"
|
||
)
|
||
|
||
usage() {
|
||
cat >&2 <<'EOF'
|
||
usage:
|
||
check-phase18.sh --self-test
|
||
check-phase18.sh go | plugins | admin | layers | docs | postgres
|
||
check-phase18.sh --go | --plugins | --admin | --layers | --docs | --postgres
|
||
check-phase18.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 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.
|
||
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":"TestOrderMatrix"}'
|
||
expect_detector detect_go fail 1 '{"Action":"pass","Package":"p","Test":"TestA"}
|
||
{"Action":"fail","Package":"p","Test":"TestPaymentMatrix"}'
|
||
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":"TestHostStripeFlow"}'
|
||
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="TestMoneyVectors TestStripeWebhook" expect_detector detect_go missing-named 5 \
|
||
'{"Action":"pass","Package":"p","Test":"TestMoneyVectors"}'
|
||
REQUIRE_TESTS="TestMoney" expect_detector detect_go prefix-is-not-the-test 5 \
|
||
'{"Action":"pass","Package":"p","Test":"TestMoneyVectors"}'
|
||
REQUIRE_TESTS="TestCart" expect_detector detect_go subtest-is-not-the-test 5 \
|
||
'{"Action":"pass","Package":"p","Test":"TestLegacyFixtures/TestCart"}
|
||
{"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/MoneyField.test.ts","status":"passed","assertionResults":[{"fullName":"money field component (UI-SPEC F1) submits an untouched 85.4193 byte-identical: no emit on load or blur","status":"passed"},{"fullName":"money helpers formats through Intl with the string itself, keeping four decimals","status":"passed"}]}]}'
|
||
expect_detector detect_vitest pass 0 "$vok"
|
||
REQUIRE_FILES='tests/form/MoneyField.test.ts' REQUIRE_NAMES='money field component (UI-SPEC F1) submits an untouched 85.4193 byte-identical: no emit on load or blur' 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/app/money.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: a redirect answer calls location.assign once with the URL and the button stays busy
|
||
ok 1 - a redirect answer calls location.assign once with the URL and the button stays busy
|
||
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=$'a redirect answer calls location.assign once with the URL and the button stays busy\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=$'a redirect answer calls location.assign once with the URL and the button 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 'PHASE18_REQUIRE_POSTGRES' "${BASH_SOURCE[0]}" || {
|
||
echo "refuse: --all does not honour PHASE18_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 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 go passed"
|
||
}
|
||
|
||
run_plugins() {
|
||
require_dir "$PG" "payment gateway plugin"
|
||
require_dir "$PGSTRIPE" "Stripe operator plugin"
|
||
local repo
|
||
for repo in "$PG" "$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 "$PG" ./money -short "${PG_MONEY_TESTS[@]}"
|
||
go_named "$PG" ./fsm -short "${PG_FSM_TESTS[@]}"
|
||
go_named "$PG" ./orders -short "${PG_ORDERS_TESTS[@]}"
|
||
go_named "$PG" ./payments -short "${PG_PAYMENTS_TESTS[@]}"
|
||
go_named "$PG" ./cart -short "${PG_CART_TESTS[@]}"
|
||
go_named "$PG" ./operators -short "${PG_OPERATORS_TESTS[@]}"
|
||
go_named "$PG" ./support -short "${PG_SUPPORT_TESTS[@]}"
|
||
go_named "$PG" ./currency -short "${PG_CURRENCY_TESTS[@]}"
|
||
go_named "$PG" ./models -short "${PG_MODELS_TESTS[@]}"
|
||
go_named "$PG" ./events -short "${PG_EVENTS_TESTS[@]}"
|
||
go_named "$PG" ./console -short "${PG_CONSOLE_TESTS[@]}"
|
||
go_named "$PG" ./api -short "${PG_API_TESTS[@]}"
|
||
go_named "$PG" . -short "${PG_ROOT_TESTS[@]}"
|
||
go_named "$PGSTRIPE" . -short "${PGSTRIPE_ROOT_TESTS[@]}"
|
||
echo "phase18 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"
|
||
echo "phase18 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[@]}")" pnpm_tap "$PG_LAYER"
|
||
echo "phase18 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 admin changes.
|
||
bash "$ROOT/scripts/check-phase10.sh" --hygiene
|
||
echo "phase18 docs passed"
|
||
}
|
||
|
||
docker_up() {
|
||
command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1
|
||
}
|
||
|
||
run_postgres() {
|
||
require_dir "$PG" "payment gateway 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 "$PG" . "-timeout 20m" "${PG_PG_TESTS[@]}"
|
||
go_named "$PGSTRIPE" . "-timeout 20m" "${PG_PGSTRIPE_TESTS[@]}"
|
||
echo "phase18 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 [[ "${PHASE18_REQUIRE_POSTGRES:-0}" == "1" ]]; then
|
||
echo "FAIL postgres: no reachable Docker daemon and PHASE18_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 money, settings
|
||
secrets, richeditor, danger, OpenAPI conformance), sm-paymentgateway-plugin
|
||
(tracer, admin screens, settings, mail, commands, import, customer API,
|
||
migrations) and sm-pgstripe-plugin (TestHostStripeFlow, settings, import)
|
||
did NOT run. Run on a Docker host before /msd-verify-work:
|
||
bash scripts/check-phase18.sh postgres
|
||
************************************************************************
|
||
EOF
|
||
echo "NOT RUN postgres (Docker-host only; see the notice above)"
|
||
fi
|
||
echo "Phase 18 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
|