From c52c89ddf5509084a34dd8471ac841a6b4556fd4 Mon Sep 17 00:00:00 2001 From: Caleb <46907094+rotationalphysics495@users.noreply.github.com> Date: Thu, 4 Jun 2026 05:28:36 -0500 Subject: [PATCH 1/4] feat(epic-execute): UI contract - ui: harness schema, preflight, test-id planning Frontend analog of the API/DB contract harness: declare UI user-flow contracts and validate readiness to run them. Per-story Playwright flow execution is the next increment. Harness (contract-harness.sh): - New ui: section (driver, base_url, tests_dir, selector_strategy, roles, flows) in the schema + scaffold template - _ui_preflight checks the browser driver (Playwright/Cypress, presence only), the tests directory, and that each declared role has a session seed - Role-seed commands now feed prerequisite inference, so their env vars and executables are checked like any other harness command Design phase (design-phase.sh): - Frontend lens now requires a stable data-testid on every interactive element and a per-AC user_flow with an allowed/forbidden expectation - Frontend schema gains components[].test_ids and a structured user_flows shape - Critic enforces test-ids + user-flow expectations; validate_domain_completeness warns (advisory) when interactive components lack a data-testid data-testid is adopted incrementally - the lens requires it on the components a story touches, with accessible role/label fallback for pre-existing ones. The "credentials" piece is separate: ui.roles seeds for permission-based checks. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/epic-execute-lib/contract-harness.sh | 128 ++++++++++++++++++- scripts/epic-execute-lib/design-phase.sh | 29 ++++- scripts/epic-execute.sh | 6 +- 3 files changed, 151 insertions(+), 12 deletions(-) diff --git a/scripts/epic-execute-lib/contract-harness.sh b/scripts/epic-execute-lib/contract-harness.sh index 79e4ecfa0..cda9b74b1 100644 --- a/scripts/epic-execute-lib/contract-harness.sh +++ b/scripts/epic-execute-lib/contract-harness.sh @@ -50,13 +50,14 @@ find_contract_harness() { # ============================================================================= # Emit every command string declared in the harness (setup, start, teardown, -# and the datastore verify command), one per line. +# 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 @@ -101,6 +102,89 @@ _derive_files() { } | 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 # ============================================================================= @@ -210,10 +294,14 @@ contract_preflight() { done <<< "$files" fi - # 4. Safety guard on datastore connection + # 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" - # 5. Optional deep connectivity smoke (boots the sample environment) + # 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" @@ -372,6 +460,40 @@ cases: 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" diff --git a/scripts/epic-execute-lib/design-phase.sh b/scripts/epic-execute-lib/design-phase.sh index cabc7ed0f..cbb81520c 100644 --- a/scripts/epic-execute-lib/design-phase.sh +++ b/scripts/epic-execute-lib/design-phase.sh @@ -137,7 +137,12 @@ This story involves UI. Your plan MUST address these in the \"frontend\" object: - Every interactive component's states: loading, empty, error, success, disabled - Accessibility: keyboard navigation, ARIA, focus management, color contrast - Responsive behavior across breakpoints -- Which existing design-system components/tokens to reuse (do not reinvent)" +- Which existing design-system components/tokens to reuse (do not reinvent) +- A stable data-testid for every element a user interacts with (button, input, + link), so UI contract flows can target it reliably. List them in test_ids on + each component. New/modified interactive elements MUST get a data-testid. +- User flows: for each AC, the navigate -> interact -> expected-outcome path, + and whether the action is allowed for all users or restricted by role" local be_lens="## Backend Planning Lens @@ -165,8 +170,8 @@ build_domain_schema() { local domain="$1" local fe_schema=" \"frontend\": { - \"components\": [{\"name\": \"...\", \"new_or_existing\": \"new|existing\", \"states\": [\"loading\",\"empty\",\"error\",\"success\",\"disabled\"]}], - \"user_flows\": [\"...\"], + \"components\": [{\"name\": \"...\", \"new_or_existing\": \"new|existing\", \"states\": [\"loading\",\"empty\",\"error\",\"success\",\"disabled\"], \"test_ids\": [\"kebab-case-testid\"]}], + \"user_flows\": [{\"ac\": \"AC1\", \"expect\": \"allowed|forbidden\", \"role\": \"\", \"steps\": [\"navigate to ...\", \"click ...\", \"expect ...\"]}], \"accessibility\": [\"...\"], \"responsive\": [\"...\"], \"design_system_usage\": [\"...\"] @@ -503,11 +508,13 @@ run_design_critic() { case "$domain" in frontend) domain_checks="- Every interactive component enumerates ALL of its states (loading, empty, error, success, disabled) - Accessibility is addressed (keyboard navigation, ARIA, focus management, contrast) -- Responsive behavior is specified" ;; +- Responsive behavior is specified +- Every new/modified interactive element has a stable data-testid (test_ids) +- Each AC has a user_flow with steps and an allowed/forbidden expectation" ;; backend) domain_checks="- Every state-changing operation has an explicit error path AND defined status codes - Data-model / migration impact is covered (and migration reversibility noted) - Concurrency / idempotency is addressed where state changes" ;; - *) domain_checks="- (Frontend) every interactive component enumerates ALL states (loading/empty/error/success/disabled); accessibility and responsive behavior are addressed + *) domain_checks="- (Frontend) every interactive component enumerates ALL states (loading/empty/error/success/disabled); accessibility and responsive behavior are addressed; interactive elements have data-testid; each AC has a user_flow with an allowed/forbidden expectation - (Backend) every state-changing operation has an explicit error path and defined status codes; data-model/migration impact is covered" ;; esac @@ -662,9 +669,10 @@ validate_domain_completeness() { return 0 fi - # Frontend: every interactive component should enumerate its states + # Frontend: components should enumerate states AND expose test_ids so UI + # contract flows can target them. if [ "$domain" = "frontend" ] || [ "$domain" = "fullstack" ]; then - local comp_count states_missing + local comp_count states_missing testid_missing comp_count=$(echo "$json" | jq '[.frontend.components[]?] | length' 2>/dev/null || echo 0) comp_count=$(echo "$comp_count" | tr -d '[:space:]'); [ -z "$comp_count" ] && comp_count=0 if [ "$comp_count" -gt 0 ]; then @@ -674,6 +682,13 @@ validate_domain_completeness() { log_warn "Design: $states_missing frontend component(s) missing states for $story_id" type add_metrics_issue >/dev/null 2>&1 && add_metrics_issue "$story_id" "design_domain_incomplete" "$states_missing FE component(s) missing states" fi + + testid_missing=$(echo "$json" | jq '[.frontend.components[]? | select((.test_ids | length) == 0)] | length' 2>/dev/null || echo 0) + testid_missing=$(echo "$testid_missing" | tr -d '[:space:]'); [ -z "$testid_missing" ] && testid_missing=0 + if [ "$testid_missing" -gt 0 ]; then + log_warn "Design: $testid_missing frontend component(s) without a data-testid for $story_id" + type add_metrics_issue >/dev/null 2>&1 && add_metrics_issue "$story_id" "design_domain_incomplete" "$testid_missing FE component(s) missing test_ids" + fi fi fi diff --git a/scripts/epic-execute.sh b/scripts/epic-execute.sh index 7ebc0076a..6cba74beb 100755 --- a/scripts/epic-execute.sh +++ b/scripts/epic-execute.sh @@ -994,8 +994,10 @@ FILES: CONTRACT VALIDATION: If a contract-harness.yaml is present, startup runs a preflight that checks every credential, command, and file the harness needs (inferred from the - harness itself). A dry run prints a readiness report and exits non-zero when - anything required is missing, so it works as a CI readiness gate: + harness itself). This covers API/database contracts and, via the ui: section, + frontend user-flow contracts (Playwright driver, tests dir, and role seeds). + A dry run prints a readiness report and exits non-zero when anything required + is missing, so it works as a CI readiness gate: ./epic-execute.sh --dry-run # presence checks only ./epic-execute.sh --dry-run --preflight-deep # + connectivity smoke ./epic-execute.sh --init-harness # scaffold a starter harness From 9c7d850736473a2d7631237864c3392bfc3a9098 Mon Sep 17 00:00:00 2001 From: Caleb <46907094+rotationalphysics495@users.noreply.github.com> Date: Thu, 4 Jun 2026 05:40:12 -0500 Subject: [PATCH 2/4] feat(epic-execute): contract execution engine + backend case execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First half of per-story contract execution (held on branch per the bundle plan). - contract-exec.sh: granularity-agnostic engine the caller can invoke per-story or per-epic - contract_env_up / contract_env_down: bring the sample env up (setup → start → poll readiness) and tear it down - run_backend_cases: for each harness case, call the API (curl), assert status and response body_contains (jq subset match), then verify persistence by invoking datastore.verify_command as ` --table --where `; failures are collected in CONTRACT_EXEC_FAILURES for the fix loop - _json_contains: JSON-subset assertion helper Tested against a local mock API: passing case (status + multi-field body + persistence), status-mismatch failure, and persistence-miss failure all behave correctly; env up/down orchestration smoke-tested. UI flow execution and gate wiring are the next pieces; live end-to-end needs a real app. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/epic-execute-lib/contract-exec.sh | 177 ++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 scripts/epic-execute-lib/contract-exec.sh diff --git a/scripts/epic-execute-lib/contract-exec.sh b/scripts/epic-execute-lib/contract-exec.sh new file mode 100644 index 000000000..7535e014b --- /dev/null +++ b/scripts/epic-execute-lib/contract-exec.sh @@ -0,0 +1,177 @@ +#!/bin/bash +# +# BMAD Epic Execute - Contract Execution Engine +# +# Executes the contract harness against a running sample environment: +# - backend `cases`: call the API and assert response + persistence +# - UI `flows`: drive a real browser and assert allowed/forbidden +# +# The engine is granularity-agnostic: callers decide whether to invoke it +# per-story or once per epic. It assumes contract-harness.sh is also sourced +# (for yq parsing conventions) and reuses the same harness file. +# +# NOTE: end-to-end execution requires a live app (and, for UI flows, browser +# binaries). The deterministic pieces - assertions, JSON-subset matching, and +# spec generation/parsing - are unit-testable without a live stack. +# +# Usage: sourced by epic-execute.sh +# + +# Detail of the most recent execution run (failures), for the fix loop. +CONTRACT_EXEC_FAILURES="" +# PID of the app started by contract_env_up (so contract_env_down can stop it). +CONTRACT_APP_PID="" + +# ============================================================================= +# Environment Lifecycle +# ============================================================================= + +# Bring the sample environment up: run setup, start the app (background), poll +# the readiness URL. Returns 0 when ready, 1 on failure. +contract_env_up() { + local h="$1" + CONTRACT_APP_PID="" + + local cmd + while IFS= read -r cmd; do + [ -z "$cmd" ] && continue + log " setup: $cmd" + if ! ( cd "$PROJECT_ROOT" && eval "$cmd" ) >>"${LOG_FILE:-/dev/null}" 2>&1; then + log_error " setup failed: $cmd" + return 1 + fi + done < <(yq '(.environment.setup // [])[]' "$h" 2>/dev/null) + + local start_cmd + 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:-/dev/null}" 2>&1 & + CONTRACT_APP_PID=$! + fi + + local ready_url timeout waited=0 + 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 + while [ "$waited" -lt "$timeout" ]; do + if curl -sf -o /dev/null "$ready_url" 2>/dev/null; then + log_success " ready: $ready_url" + return 0 + fi + sleep 2 + waited=$((waited + 2)) + done + log_error " not ready after ${timeout}s: $ready_url" + return 1 + fi + + return 0 +} + +# Tear the sample environment down: stop the app, run teardown commands. +contract_env_down() { + local h="$1" + [ -n "$CONTRACT_APP_PID" ] && kill "$CONTRACT_APP_PID" 2>/dev/null || true + CONTRACT_APP_PID="" + + local cmd + while IFS= read -r cmd; do + [ -z "$cmd" ] && continue + log " teardown: $cmd" + ( cd "$PROJECT_ROOT" && eval "$cmd" ) >>"${LOG_FILE:-/dev/null}" 2>&1 || true + done < <(yq '(.environment.teardown // [])[]' "$h" 2>/dev/null) +} + +# ============================================================================= +# Assertion Helpers +# ============================================================================= + +# True if every key/value pair in the expected JSON object ($2) is present and +# equal in the actual JSON ($1). Used for response body_contains assertions. +_json_contains() { + local actual="$1" expected="$2" + jq -e -n --argjson a "$actual" --argjson e "$expected" ' + ($e | to_entries) as $pairs + | all($pairs[]; .key as $k | $a[$k] == .value) + ' >/dev/null 2>&1 +} + +# ============================================================================= +# Backend Case Execution +# ============================================================================= + +# Execute the harness `cases` against the running API. The environment must +# already be up. Appends failures to CONTRACT_EXEC_FAILURES. +# Returns 0 if all cases pass, 1 if any fail. +# +# Persistence contract: the datastore.verify_command is invoked as +# --table --where +# and must exit 0 when the expected row exists. +run_backend_cases() { + local h="$1" + + local base_url verify_command n + base_url=$(yq '.api.base_url // ""' "$h" 2>/dev/null) + verify_command=$(yq '.datastore.verify_command // ""' "$h" 2>/dev/null) + n=$(yq '.cases | length' "$h" 2>/dev/null) + case "$n" in ''|null) n=0 ;; esac + + [ "$n" -eq 0 ] && return 0 + + local failures=0 i + for ((i = 0; i < n; i++)); do + local name method path body exp_status exp_body + name=$(yq ".cases[$i].name // \"case-$i\"" "$h" 2>/dev/null) + method=$(yq ".cases[$i].request.method // \"GET\"" "$h" 2>/dev/null) + path=$(yq ".cases[$i].request.path // \"/\"" "$h" 2>/dev/null) + body=$(yq -o=json -I=0 ".cases[$i].request.body // {}" "$h" 2>/dev/null) + exp_status=$(yq ".cases[$i].expect.status // 0" "$h" 2>/dev/null) + exp_body=$(yq -o=json -I=0 ".cases[$i].expect.body_contains // {}" "$h" 2>/dev/null) + + # Make the call + local resp code rbody + resp=$(curl -s -w $'\n%{http_code}' -X "$method" "$base_url$path" \ + -H 'Content-Type: application/json' --data "$body" 2>/dev/null) + code=$(printf '%s' "$resp" | tail -n1) + rbody=$(printf '%s' "$resp" | sed '$d') + + # Status assertion + if [ "$exp_status" != "0" ] && [ "$code" != "$exp_status" ]; then + failures=$((failures + 1)) + CONTRACT_EXEC_FAILURES+="- [$name] expected status $exp_status, got ${code:-none}"$'\n' + log_error " contract case FAILED: $name (status $code != $exp_status)" + continue + fi + + # Response body_contains assertion + if [ -n "$exp_body" ] && [ "$exp_body" != "{}" ]; then + if ! _json_contains "${rbody:-null}" "$exp_body"; then + failures=$((failures + 1)) + CONTRACT_EXEC_FAILURES+="- [$name] response missing expected fields: $exp_body"$'\n' + log_error " contract case FAILED: $name (body mismatch)" + continue + fi + fi + + # Persistence verification (data landed in the right place) + local vp + vp=$(yq ".cases[$i].verify_persistence // \"\"" "$h" 2>/dev/null) + if [ -n "$vp" ] && [ "$vp" != "null" ] && [ -n "$verify_command" ]; then + local table where + table=$(yq ".cases[$i].verify_persistence.table // \"\"" "$h" 2>/dev/null) + where=$(yq -o=json -I=0 ".cases[$i].verify_persistence.where // {}" "$h" 2>/dev/null) + if ! ( cd "$PROJECT_ROOT" && eval "$verify_command --table '$table' --where '$where'" ) >>"${LOG_FILE:-/dev/null}" 2>&1; then + failures=$((failures + 1)) + CONTRACT_EXEC_FAILURES+="- [$name] persistence check failed (table=$table where=$where)"$'\n' + log_error " contract case FAILED: $name (not persisted)" + continue + fi + fi + + log_success " contract case passed: $name" + done + + [ "$failures" -gt 0 ] && return 1 + return 0 +} From c45d4fa875dd7c024d1a6b09bb9dc3fc4fd95d51 Mon Sep 17 00:00:00 2001 From: Caleb <46907094+rotationalphysics495@users.noreply.github.com> Date: Thu, 4 Jun 2026 05:42:21 -0500 Subject: [PATCH 3/4] feat(epic-execute): UI flow execution - Playwright spec generation + run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of the contract execution engine (held on branch per bundle plan). - generate_playwright_spec: translate ui.flows into a Playwright spec - goto / getByTestId click+fill (with getByLabel/getByRole/getByText fallback), visible/hidden/text/url assertions, role-based storageState for allowed/ forbidden checks; persistence is delegated to backend cases - run_ui_flows: generate the spec, run it via the project's `npx playwright test --reporter=json`, and parse results - parse_playwright_report: read stats.unexpected + failed titles into CONTRACT_EXEC_FAILURES for the fix loop - _pw_locator (testid → label → role → text) and _ts_safe helpers Tested: spec generation for the canonical "create a quote" allowed flow + a "viewer cannot" forbidden flow produces correct TS; report parsing handles pass and fail. Live browser execution needs the real app + browser binaries. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/epic-execute-lib/contract-exec.sh | 174 ++++++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/scripts/epic-execute-lib/contract-exec.sh b/scripts/epic-execute-lib/contract-exec.sh index 7535e014b..27c34782b 100644 --- a/scripts/epic-execute-lib/contract-exec.sh +++ b/scripts/epic-execute-lib/contract-exec.sh @@ -175,3 +175,177 @@ run_backend_cases() { [ "$failures" -gt 0 ] && return 1 return 0 } + +# ============================================================================= +# UI Flow Execution (Playwright) +# ============================================================================= + +# Resolve a Playwright locator expression from a selector node, preferring a +# data-testid and falling back to accessible label/role/text. +# Arguments: +# $1 - harness file +# $2 - yq path to the selector map (e.g. .ui.flows[0].steps[1].click) +_pw_locator() { + local h="$1" node="$2" + local testid label role text + testid=$(yq "${node}.testid // \"\"" "$h" 2>/dev/null) + label=$(yq "${node}.label // \"\"" "$h" 2>/dev/null) + role=$(yq "${node}.role // \"\"" "$h" 2>/dev/null) + text=$(yq "${node}.text // \"\"" "$h" 2>/dev/null) + + if [ -n "$testid" ] && [ "$testid" != "null" ]; then + echo "page.getByTestId('$testid')" + elif [ -n "$label" ] && [ "$label" != "null" ]; then + echo "page.getByLabel('$label')" + elif [ -n "$role" ] && [ "$role" != "null" ]; then + echo "page.getByRole('$role')" + elif [ -n "$text" ] && [ "$text" != "null" ]; then + echo "page.getByText('$text')" + else + echo "page.locator('body')" + fi +} + +# Strip single quotes from a value so it is safe inside a generated TS string. +_ts_safe() { printf '%s' "${1//\'/}"; } + +# Generate a Playwright spec file from the harness ui.flows. +# Arguments: +# $1 - harness file +# $2 - output spec path +generate_playwright_spec() { + local h="$1" out="$2" + local base_url n + base_url=$(yq '.ui.base_url // ""' "$h" 2>/dev/null) + n=$(yq '.ui.flows | length' "$h" 2>/dev/null) + case "$n" in ''|null) n=0 ;; esac + + { + echo "// AUTO-GENERATED by epic-execute contract gate. Do not edit by hand." + echo "import { test, expect } from '@playwright/test';" + echo "" + + local i + for ((i = 0; i < n; i++)); do + local fp="ui.flows[$i]" + local name role expect_kind + name=$(_ts_safe "$(yq ".$fp.name // \"flow-$i\"" "$h" 2>/dev/null)") + role=$(yq ".$fp.as_role // \"\"" "$h" 2>/dev/null) + expect_kind=$(yq ".$fp.expect // \"allowed\"" "$h" 2>/dev/null) + + echo "test.describe('$name', () => {" + if [ -n "$role" ] && [ "$role" != "null" ]; then + # Convention: the ui.roles[].seed produces this auth state. + echo " test.use({ storageState: 'playwright/.auth/$role.json' });" + fi + echo " test('$expect_kind', async ({ page }) => {" + + # Steps + local sn s + sn=$(yq ".$fp.steps | length" "$h" 2>/dev/null); case "$sn" in ''|null) sn=0 ;; esac + for ((s = 0; s < sn; s++)); do + local key + key=$(yq ".$fp.steps[$s] | keys | .[0]" "$h" 2>/dev/null) + case "$key" in + goto) + local p; p=$(_ts_safe "$(yq ".$fp.steps[$s].goto" "$h" 2>/dev/null)") + echo " await page.goto('$base_url$p');" + ;; + click) + echo " await $(_pw_locator "$h" ".$fp.steps[$s].click").click();" + ;; + fill) + local v; v=$(_ts_safe "$(yq ".$fp.steps[$s].fill.value // \"\"" "$h" 2>/dev/null)") + echo " await $(_pw_locator "$h" ".$fp.steps[$s].fill").fill('$v');" + ;; + esac + done + + # Assertions + local an a + an=$(yq ".$fp.assert | length" "$h" 2>/dev/null); case "$an" in ''|null) an=0 ;; esac + for ((a = 0; a < an; a++)); do + local key + key=$(yq ".$fp.assert[$a] | keys | .[0]" "$h" 2>/dev/null) + case "$key" in + visible) echo " await expect($(_pw_locator "$h" ".$fp.assert[$a].visible")).toBeVisible();" ;; + hidden) echo " await expect($(_pw_locator "$h" ".$fp.assert[$a].hidden")).toBeHidden();" ;; + text) + local t; t=$(_ts_safe "$(yq ".$fp.assert[$a].text.value // \"\"" "$h" 2>/dev/null)") + echo " await expect($(_pw_locator "$h" ".$fp.assert[$a].text")).toContainText('$t');" + ;; + url) + local u; u=$(_ts_safe "$(yq ".$fp.assert[$a].url // \"\"" "$h" 2>/dev/null)") + echo " await expect(page).toHaveURL(/$u/);" + ;; + persisted) + echo " // persistence is verified via backend cases (datastore.verify_command)" + ;; + esac + done + + echo " });" + echo "});" + echo "" + done + } > "$out" +} + +# Parse a Playwright JSON report; append failed flow titles to +# CONTRACT_EXEC_FAILURES. Returns 1 if any test failed. +parse_playwright_report() { + local report="$1" + command -v jq >/dev/null 2>&1 || return 0 + [ -f "$report" ] || { CONTRACT_EXEC_FAILURES+="- UI flows: no Playwright report produced"$'\n'; return 1; } + + local unexpected + unexpected=$(jq '.stats.unexpected // 0' "$report" 2>/dev/null || echo 0) + unexpected=$(printf '%s' "$unexpected" | tr -d '[:space:]'); [ -z "$unexpected" ] && unexpected=0 + + if [ "$unexpected" -gt 0 ]; then + local titles + titles=$(jq -r '[.. | objects | select(.ok? == false and .title != null) | .title] | unique[]' "$report" 2>/dev/null || echo "") + while IFS= read -r t; do + [ -z "$t" ] && continue + CONTRACT_EXEC_FAILURES+="- UI flow failed: $t"$'\n' + done <<< "$titles" + return 1 + fi + return 0 +} + +# Generate + run the UI flows via the project's Playwright. Environment must be +# up. Returns 0 if all flows pass, 1 on failure. +run_ui_flows() { + local h="$1" + + local driver + driver=$(yq '.ui.driver // "playwright"' "$h" 2>/dev/null) + if [ "$driver" != "playwright" ]; then + log_warn "UI flow execution supports Playwright only in v1 (driver: $driver) - skipping" + return 0 + fi + + local n + n=$(yq '.ui.flows | length' "$h" 2>/dev/null) + case "$n" in ''|null|0) return 0 ;; esac + + local tests_dir spec report + tests_dir=$(yq '.ui.tests_dir // "e2e"' "$h" 2>/dev/null) + spec="$PROJECT_ROOT/$tests_dir/contract-flows.generated.spec.ts" + report="$PROJECT_ROOT/.contract-pw-report.json" + + mkdir -p "$(dirname "$spec")" + generate_playwright_spec "$h" "$spec" + log "Generated Playwright spec: $spec" + + ( cd "$PROJECT_ROOT" && npx playwright test "$tests_dir/contract-flows.generated.spec.ts" --reporter=json ) \ + > "$report" 2>>"${LOG_FILE:-/dev/null}" || true + + if parse_playwright_report "$report"; then + log_success " UI flows passed" + return 0 + fi + log_error " UI flows failed" + return 1 +} From 34b331c242f5972ad743657eb00a3c223c950dcd Mon Sep 17 00:00:00 2001 From: Caleb <46907094+rotationalphysics495@users.noreply.github.com> Date: Thu, 4 Jun 2026 05:44:40 -0500 Subject: [PATCH 4/4] feat(epic-execute): wire contract validation gate + self-heal fix loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final piece of the contract execution increment. - run_contract_validation: env up → backend cases + UI flows → env down - contract_validation_gate: bounded self-heal loop (execute_contract_fix_phase feeds CONTRACT_EXEC_FAILURES back to a focused fix prompt, commits, re-runs) - Wired as a per-epic gate after the story loop (v1 granularity: the app reflects the whole epic before contracts run), opt-in by harness presence and --skip-contract-validation - Exit-code-honest: CONTRACT_VALIDATION_FAILED makes the epic exit non-zero if contracts never pass, mirroring the preflight gate Tested: orchestrator brings the env up/down and runs backend cases against a live mock server with correct pass/fail + failure detail. Live UI flows and the fix loop's Claude calls need the real app/CLI. This completes the UI-contract + execution work held on this branch; ready to bundle into one PR. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/epic-execute-lib/contract-exec.sh | 115 ++++++++++++++++++++++ scripts/epic-execute.sh | 23 +++++ 2 files changed, 138 insertions(+) diff --git a/scripts/epic-execute-lib/contract-exec.sh b/scripts/epic-execute-lib/contract-exec.sh index 27c34782b..9aeb32bd7 100644 --- a/scripts/epic-execute-lib/contract-exec.sh +++ b/scripts/epic-execute-lib/contract-exec.sh @@ -21,6 +21,10 @@ CONTRACT_EXEC_FAILURES="" # PID of the app started by contract_env_up (so contract_env_down can stop it). CONTRACT_APP_PID="" +# Set true when contract validation ultimately fails (drives the epic exit code). +CONTRACT_VALIDATION_FAILED=false +# Max self-heal attempts for contract failures. +MAX_CONTRACT_FIX_ATTEMPTS="${MAX_CONTRACT_FIX_ATTEMPTS:-2}" # ============================================================================= # Environment Lifecycle @@ -349,3 +353,114 @@ run_ui_flows() { log_error " UI flows failed" return 1 } + +# ============================================================================= +# Orchestration + Fix Loop +# ============================================================================= + +# Run the full contract validation: bring the env up, run backend cases and UI +# flows, tear the env down. Detail of any failures lands in CONTRACT_EXEC_FAILURES. +# Returns 0 if everything passes, 1 otherwise. +run_contract_validation() { + local h="$1" + CONTRACT_EXEC_FAILURES="" + + if [ "${DRY_RUN:-false}" = true ]; then + echo "[DRY RUN] Would run contract validation (backend cases + UI flows)" + return 0 + fi + + log ">>> CONTRACT VALIDATION: bringing up sample environment" + if ! contract_env_up "$h"; then + contract_env_down "$h" + CONTRACT_EXEC_FAILURES="- sample environment failed to start"$'\n' + return 1 + fi + + local rc=0 + run_backend_cases "$h" || rc=1 + run_ui_flows "$h" || rc=1 + + contract_env_down "$h" + return $rc +} + +# A focused fix pass for contract validation failures (epic-level self-heal, +# mirroring the traceability fix loop). Returns 0 if the model reports success. +execute_contract_fix_phase() { + local failures="$1" + local attempt="$2" + + log ">>> CONTRACT FIX: attempt $attempt (addressing contract validation failures)" + + local fix_prompt="You are fixing failures found by automated contract validation - real API calls and browser flows run against a running app. + +## Failures to Fix + + +$failures + + +## Rules +- Fix the IMPLEMENTATION so these contract checks pass +- Do NOT weaken, skip, or delete the contract checks themselves +- Do NOT use 'git add -A' or 'git add .' - stage explicit paths +- This is attempt $attempt of $MAX_CONTRACT_FIX_ATTEMPTS + +## Completion Signal +When done, output exactly: CONTRACT FIX COMPLETE +If you cannot fix everything, output: CONTRACT FIX INCOMPLETE: " + + if [ "${DRY_RUN:-false}" = true ]; then + echo "[DRY RUN] Would run contract fix (attempt $attempt)" + return 0 + fi + + run_claude_to_file "$fix_prompt" + local result + result=$(read_phase_tail) + + if echo "$result" | grep -q "CONTRACT FIX COMPLETE"; then + log_success "Contract fix reported complete (attempt $attempt)" + return 0 + fi + log_warn "Contract fix incomplete (attempt $attempt)" + return 1 +} + +# Top-level gate: run contract validation with a bounded self-heal loop. +# Non-blocking for the run itself, but sets CONTRACT_VALIDATION_FAILED so the +# epic exits non-zero if contracts never pass. +contract_validation_gate() { + local h="$1" + + local attempt=0 + while true; do + if run_contract_validation "$h"; then + log_success "Contract validation passed" + return 0 + fi + + if [ -z "$CONTRACT_EXEC_FAILURES" ]; then + log_warn "Contract validation failed without captured detail - continuing" + CONTRACT_VALIDATION_FAILED=true + return 1 + fi + + attempt=$((attempt + 1)) + if [ "$attempt" -gt "$MAX_CONTRACT_FIX_ATTEMPTS" ]; then + log_error "Contract validation still failing after $MAX_CONTRACT_FIX_ATTEMPTS fix attempt(s)" + type add_metrics_issue >/dev/null 2>&1 && add_metrics_issue "epic-${EPIC_ID:-?}" "contract_validation_failed" "Contract checks failing after $MAX_CONTRACT_FIX_ATTEMPTS attempts" + CONTRACT_VALIDATION_FAILED=true + return 1 + fi + + log_warn "Contract failures found, attempting fix $attempt of $MAX_CONTRACT_FIX_ATTEMPTS" + execute_contract_fix_phase "$CONTRACT_EXEC_FAILURES" "$attempt" || true + + if [ "${NO_COMMIT:-false}" = false ] && [ "${DRY_RUN:-false}" = false ]; then + git -C "$PROJECT_ROOT" add -u 2>/dev/null || true + git -C "$PROJECT_ROOT" commit -m "fix(epic-${EPIC_ID:-0}): contract validation fixes (attempt $attempt)" 2>/dev/null || true + fi + done +} diff --git a/scripts/epic-execute.sh b/scripts/epic-execute.sh index 6cba74beb..a45818a17 100755 --- a/scripts/epic-execute.sh +++ b/scripts/epic-execute.sh @@ -133,6 +133,7 @@ LIB_DIR="$SCRIPT_DIR/epic-execute-lib" [ -f "$LIB_DIR/json-output.sh" ] && source "$LIB_DIR/json-output.sh" [ -f "$LIB_DIR/tdd-flow.sh" ] && source "$LIB_DIR/tdd-flow.sh" [ -f "$LIB_DIR/contract-harness.sh" ] && source "$LIB_DIR/contract-harness.sh" +[ -f "$LIB_DIR/contract-exec.sh" ] && source "$LIB_DIR/contract-exec.sh" STORIES_DIR="$PROJECT_ROOT/docs/stories" SPRINT_ARTIFACTS_DIR="$PROJECT_ROOT/docs/sprint-artifacts" @@ -3192,6 +3193,21 @@ for story_file in "${STORIES[@]}"; do fi done +# ============================================================================= +# Contract Validation (Per-Epic: execute the harness against the live app) +# ============================================================================= +# v1 granularity: runs once after all stories are implemented (the app reflects +# the full epic). Brings the sample env up, runs backend cases + UI flows, and +# self-heals via a bounded fix loop. Non-blocking mid-run, but sets the epic +# exit code if contracts never pass. +if [ "$SKIP_CONTRACT_VALIDATION" != true ] && [ -n "${CONTRACT_HARNESS_FILE:-}" ] && type contract_validation_gate >/dev/null 2>&1; then + echo "" + log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + log "Contract Validation (API + UI)" + log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + contract_validation_gate "$CONTRACT_HARNESS_FILE" || true +fi + # ============================================================================= # Traceability Check (Per-Epic, with Self-Healing) # ============================================================================= @@ -3288,6 +3304,13 @@ if [ "${PREFLIGHT_FAILED:-false}" = true ]; then exit 1 fi +# Contract validation is an exit-code-honest gate: if API/UI contracts never +# passed (after self-heal attempts), fail the epic. +if [ "${CONTRACT_VALIDATION_FAILED:-false}" = true ]; then + log_warn "Contract validation did not pass - see failures above" + exit 1 +fi + if [ $FAILED -gt 0 ]; then log_warn "$FAILED stories failed - check log for details" log "Checkpoint preserved for resume capability"