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:
parent
9c7d850736
commit
c45d4fa875
|
|
@ -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[<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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue