From c45d4fa875dd7c024d1a6b09bb9dc3fc4fd95d51 Mon Sep 17 00:00:00 2001 From: Caleb <46907094+rotationalphysics495@users.noreply.github.com> Date: Thu, 4 Jun 2026 05:42:21 -0500 Subject: [PATCH] feat(epic-execute): UI flow execution - Playwright spec generation + run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/epic-execute-lib/contract-exec.sh | 174 ++++++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/scripts/epic-execute-lib/contract-exec.sh b/scripts/epic-execute-lib/contract-exec.sh index 7535e014b..27c34782b 100644 --- a/scripts/epic-execute-lib/contract-exec.sh +++ b/scripts/epic-execute-lib/contract-exec.sh @@ -175,3 +175,177 @@ run_backend_cases() { [ "$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[].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 +}