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) <noreply@anthropic.com>
This commit is contained in:
Caleb 2026-06-04 05:28:36 -05:00
parent b3a41e577e
commit c52c89ddf5
3 changed files with 151 additions and 12 deletions

View File

@ -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"

View File

@ -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\": \"<role or any>\", \"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

View File

@ -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 <id> --dry-run # presence checks only
./epic-execute.sh <id> --dry-run --preflight-deep # + connectivity smoke
./epic-execute.sh --init-harness # scaffold a starter harness