#!/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 --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 }