feat(epic-execute): UI flow execution - Playwright spec generation + run

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) <noreply@anthropic.com>
This commit is contained in:
Caleb 2026-06-04 05:42:21 -05:00
parent 9c7d850736
commit c45d4fa875
1 changed files with 174 additions and 0 deletions

View File

@ -175,3 +175,177 @@ run_backend_cases() {
[ "$failures" -gt 0 ] && return 1 [ "$failures" -gt 0 ] && return 1
return 0 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
}