503 lines
18 KiB
Bash
503 lines
18 KiB
Bash
#!/bin/bash
|
|
#
|
|
# BMAD Epic Execute - Contract Harness Preflight Module
|
|
#
|
|
# A project may declare a contract-validation harness (contract-harness.yaml)
|
|
# that describes how to bring up a SAMPLE/TEST environment and verify that the
|
|
# API and database contracts hold (the proper API is called and data lands in
|
|
# the right place).
|
|
#
|
|
# This module does NOT execute the per-story contract checks. It validates at
|
|
# STARTUP that the system has everything it needs to run them - credentials,
|
|
# commands, and files - so a misconfigured harness fails fast (or, in a dry
|
|
# run, produces an exit-code-honest readiness report) instead of blowing up
|
|
# mid-epic.
|
|
#
|
|
# The user never hand-maintains a checklist: prerequisites are inferred from the
|
|
# harness commands themselves (env var references, executables, file paths),
|
|
# with an optional `requires:` block for anything inference cannot see.
|
|
#
|
|
# Usage: sourced by epic-execute.sh
|
|
#
|
|
|
|
# Set true by contract_preflight when a required prerequisite is missing.
|
|
# epic-execute uses this to fail the run / dry-run exit code.
|
|
PREFLIGHT_FAILED=false
|
|
|
|
# =============================================================================
|
|
# Harness Discovery
|
|
# =============================================================================
|
|
|
|
# Locate the harness file (project root, then docs/). Echoes the path or "".
|
|
find_contract_harness() {
|
|
local candidate
|
|
for candidate in \
|
|
"$PROJECT_ROOT/contract-harness.yaml" \
|
|
"$PROJECT_ROOT/contract-harness.yml" \
|
|
"$PROJECT_ROOT/docs/contract-harness.yaml" \
|
|
"$PROJECT_ROOT/docs/contract-harness.yml"; do
|
|
if [ -f "$candidate" ]; then
|
|
echo "$candidate"
|
|
return 0
|
|
fi
|
|
done
|
|
echo ""
|
|
return 0
|
|
}
|
|
|
|
# =============================================================================
|
|
# Prerequisite Inference
|
|
# =============================================================================
|
|
|
|
# Emit every command string declared in the harness (setup, start, teardown,
|
|
# the datastore verify command, and any UI role-seed commands), one per line.
|
|
_harness_commands() {
|
|
local h="$1"
|
|
yq '(.environment.setup // [])[]' "$h" 2>/dev/null
|
|
yq '.environment.start.command // ""' "$h" 2>/dev/null
|
|
yq '(.environment.teardown // [])[]' "$h" 2>/dev/null
|
|
yq '.datastore.verify_command // ""' "$h" 2>/dev/null
|
|
yq '(.ui.roles // {})[].seed // ""' "$h" 2>/dev/null
|
|
}
|
|
|
|
# Derive required environment variables: references inside command strings
|
|
# ($VAR / ${VAR}), the datastore url_env value (itself a var name), and any
|
|
# explicit requires.env entries.
|
|
_derive_env_vars() {
|
|
local h="$1"
|
|
{
|
|
_harness_commands "$h" | grep -oE '\$\{?[A-Za-z_][A-Za-z0-9_]*\}?' | tr -d '${}'
|
|
yq '.datastore.url_env // ""' "$h" 2>/dev/null
|
|
yq '(.requires.env // [])[]' "$h" 2>/dev/null
|
|
} | sed '/^$/d' | sort -u
|
|
}
|
|
|
|
# Derive required executables: the first non-assignment token of each command,
|
|
# plus any explicit requires.commands entries.
|
|
_derive_commands() {
|
|
local h="$1"
|
|
{
|
|
_harness_commands "$h" | while IFS= read -r cmd; do
|
|
[ -z "$cmd" ] && continue
|
|
# shellcheck disable=SC2086
|
|
set -- $cmd
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
*=*) shift ;; # skip leading VAR=value assignments
|
|
*) echo "$1"; break ;;
|
|
esac
|
|
done
|
|
done
|
|
yq '(.requires.commands // [])[]' "$h" 2>/dev/null
|
|
} | sed '/^$/d' | sort -u
|
|
}
|
|
|
|
# Derive referenced files: path-like tokens in command strings (best effort),
|
|
# plus any explicit requires.files entries.
|
|
_derive_files() {
|
|
local h="$1"
|
|
{
|
|
_harness_commands "$h" | tr ' ' '\n' | grep -E '/|\.(ya?ml|json|toml|sh|env)$' 2>/dev/null
|
|
yq '(.requires.files // [])[]' "$h" 2>/dev/null
|
|
} | sed '/^$/d' | sort -u
|
|
}
|
|
|
|
# =============================================================================
|
|
# UI Contract Checks
|
|
# =============================================================================
|
|
|
|
# UI-specific preflight: verify the browser driver, tests directory, and role
|
|
# seeds needed to run UI contract flows are present. Prints report lines and
|
|
# sets UI_PREFLIGHT_MISSING to the number of missing prerequisites.
|
|
UI_PREFLIGHT_MISSING=0
|
|
_ui_preflight() {
|
|
local h="$1"
|
|
UI_PREFLIGHT_MISSING=0
|
|
|
|
# Only run when a ui: section is declared
|
|
local has_ui
|
|
has_ui=$(yq '.ui // ""' "$h" 2>/dev/null)
|
|
[ -z "$has_ui" ] && return 0
|
|
|
|
local check="✓" cross="✗"
|
|
log "UI contract (frontend):"
|
|
|
|
# Browser driver (default: playwright). Presence check only - browser
|
|
# binaries and a live boot are verified by the deep connectivity smoke.
|
|
local driver
|
|
driver=$(yq '.ui.driver // "playwright"' "$h" 2>/dev/null)
|
|
case "$driver" in
|
|
playwright)
|
|
if [ -x "$PROJECT_ROOT/node_modules/.bin/playwright" ] \
|
|
|| ls "$PROJECT_ROOT"/playwright.config.* >/dev/null 2>&1 \
|
|
|| command -v playwright >/dev/null 2>&1; then
|
|
echo " $check playwright driver available"
|
|
else
|
|
echo " $cross playwright not found (no node_modules/.bin/playwright, config, or PATH)"
|
|
UI_PREFLIGHT_MISSING=$((UI_PREFLIGHT_MISSING + 1))
|
|
fi
|
|
;;
|
|
cypress)
|
|
if [ -x "$PROJECT_ROOT/node_modules/.bin/cypress" ] \
|
|
|| ls "$PROJECT_ROOT"/cypress.config.* >/dev/null 2>&1 \
|
|
|| command -v cypress >/dev/null 2>&1; then
|
|
echo " $check cypress driver available"
|
|
else
|
|
echo " $cross cypress not found"
|
|
UI_PREFLIGHT_MISSING=$((UI_PREFLIGHT_MISSING + 1))
|
|
fi
|
|
;;
|
|
*)
|
|
echo " $cross unknown ui.driver '$driver' (expected playwright or cypress)"
|
|
UI_PREFLIGHT_MISSING=$((UI_PREFLIGHT_MISSING + 1))
|
|
;;
|
|
esac
|
|
|
|
# Tests directory (if declared)
|
|
local tests_dir
|
|
tests_dir=$(yq '.ui.tests_dir // ""' "$h" 2>/dev/null)
|
|
if [ -n "$tests_dir" ]; then
|
|
if [ -d "$PROJECT_ROOT/$tests_dir" ]; then
|
|
echo " $check tests_dir $tests_dir"
|
|
else
|
|
echo " $cross tests_dir $tests_dir (not found)"
|
|
UI_PREFLIGHT_MISSING=$((UI_PREFLIGHT_MISSING + 1))
|
|
fi
|
|
fi
|
|
|
|
# Each declared role must define how to obtain a session (seed)
|
|
local roles
|
|
roles=$(yq '(.ui.roles // {}) | keys | .[]' "$h" 2>/dev/null)
|
|
if [ -n "$roles" ]; then
|
|
while IFS= read -r role; do
|
|
[ -z "$role" ] && continue
|
|
local seed
|
|
seed=$(yq ".ui.roles.${role}.seed // \"\"" "$h" 2>/dev/null)
|
|
if [ -n "$seed" ]; then
|
|
echo " $check role '$role' seed declared"
|
|
else
|
|
echo " $cross role '$role' has no seed (cannot establish a session)"
|
|
UI_PREFLIGHT_MISSING=$((UI_PREFLIGHT_MISSING + 1))
|
|
fi
|
|
done <<< "$roles"
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
# =============================================================================
|
|
# Safety Guard
|
|
# =============================================================================
|
|
|
|
# Warn if the declared datastore connection looks like a real/production target.
|
|
# Contract validation writes data, so it must point at a throwaway/test store.
|
|
_check_datastore_safety() {
|
|
local h="$1"
|
|
local url_env
|
|
url_env=$(yq '.datastore.url_env // ""' "$h" 2>/dev/null)
|
|
[ -z "$url_env" ] && return 0
|
|
|
|
case "$url_env" in
|
|
DATABASE_URL|*PROD*|*PRODUCTION*)
|
|
log_warn " ! datastore.url_env='$url_env' looks production-scoped - use a TEST-only variable" ;;
|
|
esac
|
|
|
|
local val="${!url_env:-}"
|
|
if [ -n "$val" ]; then
|
|
case "$val" in
|
|
*localhost*|*127.0.0.1*|*test*) : ;;
|
|
*) log_warn " ! $url_env does not look local/test-scoped - contract validation must never run against a real database" ;;
|
|
esac
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# =============================================================================
|
|
# Preflight (presence checks + readiness report)
|
|
# =============================================================================
|
|
|
|
# Validate that everything needed to run the harness is present.
|
|
# Arguments:
|
|
# $1 - path to the harness file
|
|
# Returns: 0 if ready, 1 if a required prerequisite is missing.
|
|
contract_preflight() {
|
|
local h="$1"
|
|
[ -z "$h" ] && return 0
|
|
|
|
log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
log "Contract Harness Preflight"
|
|
log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
log "Harness: $h"
|
|
|
|
local check="✓" cross="✗"
|
|
local missing=0
|
|
|
|
# yq is required to parse the harness
|
|
if ! command -v yq >/dev/null 2>&1; then
|
|
log_error " $cross yq is required to parse the contract harness (install yq)"
|
|
PREFLIGHT_FAILED=true
|
|
return 1
|
|
fi
|
|
|
|
# Basic schema sanity (advisory)
|
|
local start_cmd
|
|
start_cmd=$(yq '.environment.start.command // ""' "$h" 2>/dev/null)
|
|
[ -z "$start_cmd" ] && log_warn " ! harness declares no environment.start.command"
|
|
|
|
# 1. Credentials / environment variables
|
|
local envs
|
|
envs=$(_derive_env_vars "$h")
|
|
if [ -n "$envs" ]; then
|
|
log "Credentials / environment variables:"
|
|
while IFS= read -r v; do
|
|
[ -z "$v" ] && continue
|
|
if [ -n "${!v:-}" ]; then
|
|
echo " $check $v"
|
|
else
|
|
echo " $cross $v (not set)"
|
|
missing=$((missing + 1))
|
|
fi
|
|
done <<< "$envs"
|
|
fi
|
|
|
|
# 2. Required executables
|
|
local cmds
|
|
cmds=$(_derive_commands "$h")
|
|
if [ -n "$cmds" ]; then
|
|
log "Required commands:"
|
|
while IFS= read -r c; do
|
|
[ -z "$c" ] && continue
|
|
if command -v "$c" >/dev/null 2>&1; then
|
|
echo " $check $c"
|
|
else
|
|
echo " $cross $c (not on PATH)"
|
|
missing=$((missing + 1))
|
|
fi
|
|
done <<< "$cmds"
|
|
fi
|
|
|
|
# 3. Referenced files
|
|
local files
|
|
files=$(_derive_files "$h")
|
|
if [ -n "$files" ]; then
|
|
log "Referenced files:"
|
|
while IFS= read -r f; do
|
|
[ -z "$f" ] && continue
|
|
local path="$f"
|
|
case "$f" in /*) path="$f" ;; *) path="$PROJECT_ROOT/$f" ;; esac
|
|
if [ -e "$path" ]; then
|
|
echo " $check $f"
|
|
else
|
|
echo " $cross $f (not found)"
|
|
missing=$((missing + 1))
|
|
fi
|
|
done <<< "$files"
|
|
fi
|
|
|
|
# 4. UI contract prerequisites (driver, tests dir, role seeds)
|
|
_ui_preflight "$h"
|
|
missing=$((missing + UI_PREFLIGHT_MISSING))
|
|
|
|
# 5. Safety guard on datastore connection
|
|
_check_datastore_safety "$h"
|
|
|
|
# 6. Optional deep connectivity smoke (boots the sample environment)
|
|
if [ "${PREFLIGHT_DEEP:-false}" = true ]; then
|
|
if [ "$missing" -gt 0 ]; then
|
|
log_warn "Skipping deep connectivity smoke - presence checks failed first"
|
|
elif ! contract_preflight_deep "$h"; then
|
|
missing=$((missing + 1))
|
|
fi
|
|
fi
|
|
|
|
if [ "$missing" -gt 0 ]; then
|
|
log_error "Contract preflight: $missing required prerequisite(s) missing"
|
|
PREFLIGHT_FAILED=true
|
|
return 1
|
|
fi
|
|
|
|
log_success "Contract preflight passed - ready to validate contracts"
|
|
return 0
|
|
}
|
|
|
|
# =============================================================================
|
|
# Deep Connectivity Smoke (opt-in: --preflight-deep)
|
|
# =============================================================================
|
|
|
|
# Run teardown commands (best effort, always safe to call).
|
|
_harness_teardown() {
|
|
local h="$1"
|
|
while IFS= read -r cmd; do
|
|
[ -z "$cmd" ] && continue
|
|
log " teardown: $cmd"
|
|
( cd "$PROJECT_ROOT" && eval "$cmd" ) >>"$LOG_FILE" 2>&1 || true
|
|
done < <(yq '(.environment.teardown // [])[]' "$h" 2>/dev/null)
|
|
}
|
|
|
|
# Actually bring the sample environment up, check readiness, then tear it down.
|
|
# Executes the project's own commands (same trust level as package.json scripts).
|
|
# Arguments:
|
|
# $1 - path to the harness file
|
|
# Returns: 0 on success, 1 on any failure.
|
|
contract_preflight_deep() {
|
|
local h="$1"
|
|
log "Deep connectivity smoke (booting the sample environment)..."
|
|
|
|
# Run setup commands
|
|
local rc=0 cmd
|
|
while IFS= read -r cmd; do
|
|
[ -z "$cmd" ] && continue
|
|
log " setup: $cmd"
|
|
if ! ( cd "$PROJECT_ROOT" && eval "$cmd" ) >>"$LOG_FILE" 2>&1; then
|
|
log_error " setup failed: $cmd"
|
|
rc=1
|
|
break
|
|
fi
|
|
done < <(yq '(.environment.setup // [])[]' "$h" 2>/dev/null)
|
|
|
|
if [ "$rc" -ne 0 ]; then
|
|
_harness_teardown "$h"
|
|
return 1
|
|
fi
|
|
|
|
# Start the app in the background (if a start command is declared)
|
|
local start_cmd start_pid=""
|
|
start_cmd=$(yq '.environment.start.command // ""' "$h" 2>/dev/null)
|
|
if [ -n "$start_cmd" ]; then
|
|
log " start: $start_cmd"
|
|
( cd "$PROJECT_ROOT" && eval "$start_cmd" ) >>"$LOG_FILE" 2>&1 &
|
|
start_pid=$!
|
|
fi
|
|
|
|
# Poll the readiness URL (if declared)
|
|
local ready_url timeout ok=1
|
|
ready_url=$(yq '.environment.start.ready.url // ""' "$h" 2>/dev/null)
|
|
timeout=$(yq '.environment.start.ready.timeout_seconds // 30' "$h" 2>/dev/null)
|
|
if [ -n "$ready_url" ]; then
|
|
ok=0
|
|
local waited=0
|
|
while [ "$waited" -lt "$timeout" ]; do
|
|
if curl -sf -o /dev/null "$ready_url" 2>/dev/null; then
|
|
ok=1
|
|
break
|
|
fi
|
|
sleep 2
|
|
waited=$((waited + 2))
|
|
done
|
|
if [ "$ok" -eq 1 ]; then
|
|
log_success " ready: $ready_url"
|
|
else
|
|
log_error " not ready after ${timeout}s: $ready_url"
|
|
fi
|
|
fi
|
|
|
|
# Stop the app and tear down
|
|
[ -n "$start_pid" ] && kill "$start_pid" 2>/dev/null || true
|
|
_harness_teardown "$h"
|
|
|
|
[ "$ok" -eq 1 ] && return 0 || return 1
|
|
}
|
|
|
|
# =============================================================================
|
|
# Scaffolder (--init-harness)
|
|
# =============================================================================
|
|
|
|
# Write a commented contract-harness.yaml template to the project root.
|
|
init_contract_harness() {
|
|
local target="$PROJECT_ROOT/contract-harness.yaml"
|
|
if [ -e "$target" ]; then
|
|
log_warn "Harness already exists: $target (not overwriting)"
|
|
return 0
|
|
fi
|
|
|
|
cat > "$target" <<'YAML'
|
|
# Contract validation harness for epic-execute.
|
|
#
|
|
# Declares how to bring up a SAMPLE/TEST environment and verify that the API and
|
|
# database contracts hold. epic-execute validates at startup that it has
|
|
# everything needed to run this - run `epic-execute <epic> --dry-run` to get an
|
|
# exit-code-honest readiness report (great as a CI gate).
|
|
version: 1
|
|
|
|
environment:
|
|
# Commands to provision the sample environment (DB, migrations, seed data).
|
|
setup:
|
|
- docker compose -f docker-compose.test.yml up -d db
|
|
# - npm run migrate:test
|
|
# - npm run seed:test
|
|
# How to start the app under test.
|
|
start:
|
|
command: npm run start:test
|
|
ready:
|
|
url: http://localhost:3000/health
|
|
timeout_seconds: 60
|
|
# Always run to clean up.
|
|
teardown:
|
|
- docker compose -f docker-compose.test.yml down -v
|
|
|
|
api:
|
|
base_url: http://localhost:3000
|
|
|
|
# How to verify data landed "in the right place". Prefer a command (no DB
|
|
# credentials needed); the system passes the table/where as arguments.
|
|
datastore:
|
|
verify_command: "npm run db:assert --"
|
|
# OR direct query via a TEST-scoped env var (never a real DATABASE_URL):
|
|
# url_env: TEST_DATABASE_URL
|
|
|
|
# Optional explicit prerequisites. The system also INFERS these from the
|
|
# commands above (env var references, executables, and file paths), so you only
|
|
# need to list things inference cannot see (e.g. an API token used at call time).
|
|
requires:
|
|
env: []
|
|
commands: []
|
|
files: []
|
|
|
|
# Contract cases: call the API and assert the response + persistence.
|
|
# (Validated by preflight now; executed by the per-story contract gate.)
|
|
cases:
|
|
- name: "example: create persists a row"
|
|
request: { method: POST, path: /api/example, body: { name: "x" } }
|
|
expect: { status: 201, body_contains: { name: "x" } }
|
|
verify_persistence: { table: example, where: { name: "x" }, exists: true }
|
|
|
|
# UI contract: validate user flows in a real browser (Playwright). Flows can be
|
|
# declared here and/or auto-generated from the design plan's frontend.user_flows.
|
|
# (Preflight validates the driver/tests_dir/role seeds now; flow execution is
|
|
# the next increment.)
|
|
ui:
|
|
driver: playwright # auto-detected from your config if present
|
|
base_url: http://localhost:3000
|
|
tests_dir: e2e/ # where flow specs live
|
|
selector_strategy: data-testid # with role/label fallback
|
|
# How to obtain a session per role (the credentials piece, for forbidden checks):
|
|
roles:
|
|
editor: { seed: "npm run seed:user -- --role editor" }
|
|
viewer: { seed: "npm run seed:user -- --role viewer" }
|
|
flows:
|
|
- name: "editor can create a quote"
|
|
ac: AC1
|
|
expect: allowed # allowed | forbidden
|
|
as_role: editor
|
|
steps:
|
|
- goto: /quotes
|
|
- click: { testid: new-quote-button }
|
|
- fill: { testid: quote-customer, value: "Acme" }
|
|
- click: { testid: save-quote }
|
|
assert:
|
|
- visible: { testid: quote-success }
|
|
- persisted: { table: quotes, where: { customer: "Acme" } } # chains to datastore
|
|
- name: "viewer cannot create a quote"
|
|
ac: AC5
|
|
expect: forbidden
|
|
as_role: viewer
|
|
steps: [ { goto: /quotes } ]
|
|
assert:
|
|
- hidden: { testid: new-quote-button }
|
|
YAML
|
|
|
|
log_success "Created harness template: $target"
|
|
log "Edit it, then run a dry run to validate readiness."
|
|
return 0
|
|
}
|