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:
parent
b3a41e577e
commit
c52c89ddf5
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue