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