Merge pull request #4 from rotationalphysics495/feat/design-phase-hardening
UI contract + contract execution engine (API + Playwright flows)
This commit is contained in:
commit
3d6aae746c
|
|
@ -0,0 +1,466 @@
|
||||||
|
#!/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=""
|
||||||
|
# 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
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# <verify_command> --table <name> --where <json>
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 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[<role>].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
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 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>
|
||||||
|
$failures
|
||||||
|
</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: <what remains>"
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -50,13 +50,14 @@ find_contract_harness() {
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Emit every command string declared in the harness (setup, start, teardown,
|
# 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() {
|
_harness_commands() {
|
||||||
local h="$1"
|
local h="$1"
|
||||||
yq '(.environment.setup // [])[]' "$h" 2>/dev/null
|
yq '(.environment.setup // [])[]' "$h" 2>/dev/null
|
||||||
yq '.environment.start.command // ""' "$h" 2>/dev/null
|
yq '.environment.start.command // ""' "$h" 2>/dev/null
|
||||||
yq '(.environment.teardown // [])[]' "$h" 2>/dev/null
|
yq '(.environment.teardown // [])[]' "$h" 2>/dev/null
|
||||||
yq '.datastore.verify_command // ""' "$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
|
# Derive required environment variables: references inside command strings
|
||||||
|
|
@ -101,6 +102,89 @@ _derive_files() {
|
||||||
} | sed '/^$/d' | sort -u
|
} | 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
|
# Safety Guard
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -210,10 +294,14 @@ contract_preflight() {
|
||||||
done <<< "$files"
|
done <<< "$files"
|
||||||
fi
|
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"
|
_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 [ "${PREFLIGHT_DEEP:-false}" = true ]; then
|
||||||
if [ "$missing" -gt 0 ]; then
|
if [ "$missing" -gt 0 ]; then
|
||||||
log_warn "Skipping deep connectivity smoke - presence checks failed first"
|
log_warn "Skipping deep connectivity smoke - presence checks failed first"
|
||||||
|
|
@ -372,6 +460,40 @@ cases:
|
||||||
request: { method: POST, path: /api/example, body: { name: "x" } }
|
request: { method: POST, path: /api/example, body: { name: "x" } }
|
||||||
expect: { status: 201, body_contains: { name: "x" } }
|
expect: { status: 201, body_contains: { name: "x" } }
|
||||||
verify_persistence: { table: example, where: { name: "x" }, exists: true }
|
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
|
YAML
|
||||||
|
|
||||||
log_success "Created harness template: $target"
|
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
|
- Every interactive component's states: loading, empty, error, success, disabled
|
||||||
- Accessibility: keyboard navigation, ARIA, focus management, color contrast
|
- Accessibility: keyboard navigation, ARIA, focus management, color contrast
|
||||||
- Responsive behavior across breakpoints
|
- 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
|
local be_lens="## Backend Planning Lens
|
||||||
|
|
||||||
|
|
@ -165,8 +170,8 @@ build_domain_schema() {
|
||||||
local domain="$1"
|
local domain="$1"
|
||||||
|
|
||||||
local fe_schema=" \"frontend\": {
|
local fe_schema=" \"frontend\": {
|
||||||
\"components\": [{\"name\": \"...\", \"new_or_existing\": \"new|existing\", \"states\": [\"loading\",\"empty\",\"error\",\"success\",\"disabled\"]}],
|
\"components\": [{\"name\": \"...\", \"new_or_existing\": \"new|existing\", \"states\": [\"loading\",\"empty\",\"error\",\"success\",\"disabled\"], \"test_ids\": [\"kebab-case-testid\"]}],
|
||||||
\"user_flows\": [\"...\"],
|
\"user_flows\": [{\"ac\": \"AC1\", \"expect\": \"allowed|forbidden\", \"role\": \"<role or any>\", \"steps\": [\"navigate to ...\", \"click ...\", \"expect ...\"]}],
|
||||||
\"accessibility\": [\"...\"],
|
\"accessibility\": [\"...\"],
|
||||||
\"responsive\": [\"...\"],
|
\"responsive\": [\"...\"],
|
||||||
\"design_system_usage\": [\"...\"]
|
\"design_system_usage\": [\"...\"]
|
||||||
|
|
@ -503,11 +508,13 @@ run_design_critic() {
|
||||||
case "$domain" in
|
case "$domain" in
|
||||||
frontend) domain_checks="- Every interactive component enumerates ALL of its states (loading, empty, error, success, disabled)
|
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)
|
- 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
|
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)
|
- Data-model / migration impact is covered (and migration reversibility noted)
|
||||||
- Concurrency / idempotency is addressed where state changes" ;;
|
- 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" ;;
|
- (Backend) every state-changing operation has an explicit error path and defined status codes; data-model/migration impact is covered" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|
@ -662,9 +669,10 @@ validate_domain_completeness() {
|
||||||
return 0
|
return 0
|
||||||
fi
|
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
|
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 "$json" | jq '[.frontend.components[]?] | length' 2>/dev/null || echo 0)
|
||||||
comp_count=$(echo "$comp_count" | tr -d '[:space:]'); [ -z "$comp_count" ] && comp_count=0
|
comp_count=$(echo "$comp_count" | tr -d '[:space:]'); [ -z "$comp_count" ] && comp_count=0
|
||||||
if [ "$comp_count" -gt 0 ]; then
|
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"
|
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"
|
type add_metrics_issue >/dev/null 2>&1 && add_metrics_issue "$story_id" "design_domain_incomplete" "$states_missing FE component(s) missing states"
|
||||||
fi
|
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
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/json-output.sh" ] && source "$LIB_DIR/json-output.sh"
|
||||||
[ -f "$LIB_DIR/tdd-flow.sh" ] && source "$LIB_DIR/tdd-flow.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-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"
|
STORIES_DIR="$PROJECT_ROOT/docs/stories"
|
||||||
SPRINT_ARTIFACTS_DIR="$PROJECT_ROOT/docs/sprint-artifacts"
|
SPRINT_ARTIFACTS_DIR="$PROJECT_ROOT/docs/sprint-artifacts"
|
||||||
|
|
@ -994,8 +995,10 @@ FILES:
|
||||||
CONTRACT VALIDATION:
|
CONTRACT VALIDATION:
|
||||||
If a contract-harness.yaml is present, startup runs a preflight that checks
|
If a contract-harness.yaml is present, startup runs a preflight that checks
|
||||||
every credential, command, and file the harness needs (inferred from the
|
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
|
harness itself). This covers API/database contracts and, via the ui: section,
|
||||||
anything required is missing, so it works as a CI readiness gate:
|
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 # presence checks only
|
||||||
./epic-execute.sh <id> --dry-run --preflight-deep # + connectivity smoke
|
./epic-execute.sh <id> --dry-run --preflight-deep # + connectivity smoke
|
||||||
./epic-execute.sh --init-harness # scaffold a starter harness
|
./epic-execute.sh --init-harness # scaffold a starter harness
|
||||||
|
|
@ -3190,6 +3193,21 @@ for story_file in "${STORIES[@]}"; do
|
||||||
fi
|
fi
|
||||||
done
|
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)
|
# Traceability Check (Per-Epic, with Self-Healing)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -3286,6 +3304,13 @@ if [ "${PREFLIGHT_FAILED:-false}" = true ]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
if [ $FAILED -gt 0 ]; then
|
||||||
log_warn "$FAILED stories failed - check log for details"
|
log_warn "$FAILED stories failed - check log for details"
|
||||||
log "Checkpoint preserved for resume capability"
|
log "Checkpoint preserved for resume capability"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue