diff --git a/.prettierignore b/.prettierignore index db3efa46e..71be0cdfd 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,5 @@ **/dist/** **/__snapshots__/** package-lock.json +actions/visual-proof*/** + diff --git a/actions/visual-proof-author/README.md b/actions/visual-proof-author/README.md new file mode 100644 index 000000000..f0744acde --- /dev/null +++ b/actions/visual-proof-author/README.md @@ -0,0 +1,50 @@ +# Visual proof author + +Writes `## Visual proof` on a PR body when that section is still missing. +Ported from Core so Elementor and Elementor Pro share one implementation. + +If the caller checkout has `.cursor/skills/visual-proof/SKILL.md`, that file +wins. Otherwise the bundled skill in this action’s `skill.md` is used. + +## Usage + +```yaml +name: Visual proof author + +on: + pull_request: + types: [opened, ready_for_review] + +permissions: + contents: read + pull-requests: write + +jobs: + author: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-22.04 + timeout-minutes: 8 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - uses: elementor/elementor-editor-github-actions/actions/visual-proof-author@main + with: + pr-number: ${{ github.event.pull_request.number }} + pr-head-sha: ${{ github.event.pull_request.head.sha }} + pr-base-sha: ${{ github.event.pull_request.base.sha }} + product-name: 'Elementor' + cursor-api-key: ${{ secrets.CURSOR_APIKEY }} + model: ${{ vars.PR_REVIEW_MODEL }} +``` + +Use `product-name: 'Elementor Pro'` in Pro. + +## Intentional non-blocking behavior + +The action runs with `continue-on-error: true` because the **Visual proof** +section is optional for contributors. Author failures (timeout, missing skill, +API errors) do not block the PR workflow. The shots action will fall back to +a generic Playground walk when the section is missing or marked `#skip_proof`. diff --git a/actions/visual-proof-author/action.yml b/actions/visual-proof-author/action.yml new file mode 100644 index 000000000..05dae965c --- /dev/null +++ b/actions/visual-proof-author/action.yml @@ -0,0 +1,67 @@ +name: 'Visual proof author' +description: 'Write ## Visual proof on a PR body when the section is still missing' + +inputs: + pr-number: + description: 'Pull request number' + required: true + pr-head-sha: + description: 'PR head commit SHA' + required: true + pr-base-sha: + description: 'PR base commit SHA' + required: true + product-name: + description: 'Product name (Elementor or Elementor Pro)' + required: true + cursor-api-key: + description: 'Cursor Agent API key' + required: true + model: + description: 'Cursor Agent model' + required: false + default: '' + github-token: + description: 'Token with pull-requests:write' + required: false + default: ${{ github.token }} + skill-file: + description: 'Optional path to a skill markdown file in the caller workspace' + required: false + default: '' + examples-file: + description: 'Optional path to examples markdown in the caller workspace' + required: false + default: '' + +runs: + using: composite + steps: + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24.15.0 + package-manager-cache: false + + - name: Install Cursor CLI + shell: bash + run: | + curl https://cursor.com/install -fsS | bash + echo "$HOME/.cursor/bin" >> "$GITHUB_PATH" + + - name: Author ## Visual proof if missing + shell: bash + continue-on-error: true + env: + GH_TOKEN: ${{ inputs.github-token }} + CURSOR_API_KEY: ${{ inputs.cursor-api-key }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ inputs.pr-number }} + PR_HEAD_SHA: ${{ inputs.pr-head-sha }} + PR_BASE_SHA: ${{ inputs.pr-base-sha }} + PR_REVIEW_MODEL: ${{ inputs.model }} + PRODUCT_NAME: ${{ inputs.product-name }} + VISUAL_PROOF_SKILL_FILE: ${{ inputs.skill-file }} + VISUAL_PROOF_EXAMPLES_FILE: ${{ inputs.examples-file }} + ACTION_PATH: ${{ github.action_path }} + run: bash "$ACTION_PATH/author.sh" diff --git a/actions/visual-proof-author/author-prompt.md b/actions/visual-proof-author/author-prompt.md new file mode 100644 index 000000000..c3d1db4be --- /dev/null +++ b/actions/visual-proof-author/author-prompt.md @@ -0,0 +1,29 @@ +# Visual proof author (CI) + +You are the only Cursor job that **writes** `## Visual proof` (including **Steps**). You run when that section is still missing on PR **opened** or **ready for review**. You do not review code, capture screenshots, or change git files. + +Follow the Visual proof skill and examples pasted below. The product name is in the runtime context (Elementor or Elementor Pro). + +## Context (CI fills this) + +The extra message after this prompt has repository, PR number, and SHAs. + +## Steps + +1. `gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json title,body,labels` — read title, body, labels. +2. If the body already has a line that is exactly `## Visual proof`, stop. Do not rewrite it. +3. `git diff --stat "$PR_BASE_SHA"..."$PR_HEAD_SHA"` and skim the diff. Decide: + - Editor Bug you can show on this repo’s Playground preview → Broken + Where / Steps / Pass / Fail. + - Anything else (Task, docs, CI, missing plugin on Playground, no editor UI) → heading + `#skip_proof` + one sentence. +4. Insert the section **after** `## Test plan` and **before** `## Jira` or `" +ASSETS_BRANCH="ci/visual-proof-assets" +MAX_SHOTS="${VISUAL_PROOF_MAX_SHOTS:-6}" +MAX_VIDEO_BYTES="${VISUAL_PROOF_MAX_VIDEO_BYTES:-40000000}" + +log() { + echo "[visual-proof:comment] $*" +} + +err() { + echo "[visual-proof:comment] $*" >&2 +} + +gh_api() { + local err_file + err_file=$(mktemp) + if ! gh api "$@" 2>"$err_file"; then + err "gh api failed: $*" + err "$(cat "$err_file")" + rm -f "$err_file" + return 1 + fi + rm -f "$err_file" +} + +require_env() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + err "${name} is required" + exit 1 + fi +} + +require_env GITHUB_REPOSITORY +require_env GITHUB_SERVER_URL +require_env GITHUB_RUN_ID +require_env PR_NUMBER + +run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + +log "step=start PR_NUMBER=${PR_NUMBER} assets_branch=${ASSETS_BRANCH} ARTIFACTS_DIR=${ARTIFACTS_DIR}" + +png_count=0 +if [[ -d "$ARTIFACTS_DIR" ]]; then + png_count=$(find "$ARTIFACTS_DIR" -type f -name '*.png' | wc -l | tr -d ' ') +fi +log "step=scan png_count=${png_count}" + +if [[ ! -d "$ARTIFACTS_DIR" ]] || [[ -z "$(find "$ARTIFACTS_DIR" -type f -name '*.png' -print -quit)" ]]; then + log "No PNG shots in ${ARTIFACTS_DIR}; skipping comment" + exit 0 +fi + +ensure_assets_branch() { + if gh_api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${ASSETS_BRANCH}" &>/dev/null; then + log "step=assets-branch exists=${ASSETS_BRANCH}" + return 0 + fi + log "step=assets-branch creating ${ASSETS_BRANCH}" + local default_branch base_sha + default_branch=$(gh_api "repos/${GITHUB_REPOSITORY}" --jq .default_branch) + base_sha=$(gh_api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${default_branch}" --jq .object.sha) + gh_api "repos/${GITHUB_REPOSITORY}/git/refs" \ + -f ref="refs/heads/${ASSETS_BRANCH}" \ + -f sha="${base_sha}" >/dev/null +} + +upload_blob() { + local local_path="$1" + local repo_path="$2" + local message="$3" + local b64_file payload_file + + b64_file=$(mktemp) + payload_file=$(mktemp) + base64 -w 0 "$local_path" >"$b64_file" + jq -n \ + --arg message "$message" \ + --arg branch "${ASSETS_BRANCH}" \ + --rawfile content "$b64_file" \ + '{message: $message, content: $content, branch: $branch}' >"$payload_file" + if ! gh_api "repos/${GITHUB_REPOSITORY}/contents/${repo_path}" -X PUT --input "$payload_file" >/dev/null; then + rm -f "$b64_file" "$payload_file" + return 1 + fi + rm -f "$b64_file" "$payload_file" + echo "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/raw/${ASSETS_BRANCH}/${repo_path}" +} + +log "step=assets-branch ensuring ${ASSETS_BRANCH}" +ensure_assets_branch + +COMMENT_SECTIONS=() +COMMENT_SECTIONS+=("${MARKER}") +COMMENT_SECTIONS+=("## Visual proof") +COMMENT_SECTIONS+=("") +if [[ "${VISUAL_PROOF_SOURCE:-fallback}" == "actor" ]]; then + COMMENT_SECTIONS+=("Shots and a short recording from this PR’s Playground preview. A storyboard actor followed **Steps** from the PR’s **Visual proof** section (happy path / Pass). Overlay text includes **Broken**; CI does not recreate the bug.") +else + COMMENT_SECTIONS+=("Shots and a short recording from this PR’s Playground preview. Generic walk: WP Admin → Pages → Add New → Edit with Elementor (storyboard actor unavailable or produced no shots). Overlay text comes from the PR’s **Visual proof** section.") +fi +COMMENT_SECTIONS+=("") + +count=0 +while IFS= read -r -d '' file; do + count=$((count + 1)) + name=$(basename "$file") + object_path="ci/visual-proof/pr-${PR_NUMBER}/run-${GITHUB_RUN_ID}/${name}" + log "step=upload-png name=${name}" + if url=$(upload_blob "$file" "$object_path" "Visual proof shot (PR #${PR_NUMBER}, run ${GITHUB_RUN_ID})"); then + COMMENT_SECTIONS+=("**${name}**") + COMMENT_SECTIONS+=("![${name}](${url})") + COMMENT_SECTIONS+=("") + fi + if [[ "$count" -ge "$MAX_SHOTS" ]]; then + break + fi +done < <(find "$ARTIFACTS_DIR" -type f -name '*.png' -print0 | sort -z) + +video_file="" +if [[ -f "${ARTIFACTS_DIR}/visual-proof.mp4" ]]; then + video_file="${ARTIFACTS_DIR}/visual-proof.mp4" +elif [[ -f "${ARTIFACTS_DIR}/visual-proof.webm" ]]; then + video_file="${ARTIFACTS_DIR}/visual-proof.webm" +fi + +if [[ -n "$video_file" ]]; then + video_size=$(wc -c < "$video_file" | tr -d ' ') + log "step=upload-video file=$(basename "$video_file") bytes=${video_size}" + if [[ "$video_size" -le "$MAX_VIDEO_BYTES" ]]; then + vname=$(basename "$video_file") + vpath="ci/visual-proof/pr-${PR_NUMBER}/run-${GITHUB_RUN_ID}/${vname}" + if vurl=$(upload_blob "$video_file" "$vpath" "Visual proof video (PR #${PR_NUMBER}, run ${GITHUB_RUN_ID})"); then + COMMENT_SECTIONS+=("**Recording** ([${vname}](${vurl})) — GitHub may not play this inline; download if needed.") + COMMENT_SECTIONS+=("") + fi + else + err "step=upload-video skipped (over ${MAX_VIDEO_BYTES} bytes); see workflow artifacts" + fi +fi + +COMMENT_SECTIONS+=("_Also on the [workflow run](${run_url})._") + +COMMENT_BODY=$(printf '%s\n' "${COMMENT_SECTIONS[@]}") + +log "step=find-comment looking for existing visual-proof-ci marker" +existing_id=$( + gh_api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate \ + --jq "[.[] | select(.body | contains(\"${MARKER}\")) | .id][0] // empty" +) + +payload=$(jq -n --arg body "$COMMENT_BODY" '{body: $body}') + +if [[ -n "${existing_id:-}" && "${existing_id}" != "null" ]]; then + log "step=upsert mode=update comment_id=${existing_id}" + echo "$payload" | gh_api "repos/${GITHUB_REPOSITORY}/issues/comments/${existing_id}" -X PATCH --input - >/dev/null + log "Updated visual-proof comment ${existing_id} on PR #${PR_NUMBER}" +else + log "step=upsert mode=create" + echo "$payload" | gh_api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --input - >/dev/null + log "Posted visual-proof comment on PR #${PR_NUMBER}" +fi + +log "step=done" diff --git a/actions/visual-proof-shots/run.sh b/actions/visual-proof-shots/run.sh new file mode 100755 index 000000000..b3938c428 --- /dev/null +++ b/actions/visual-proof-shots/run.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="${GITHUB_WORKSPACE:-$(pwd)}" +OUT_DIR="${VISUAL_PROOF_OUT_DIR:-${REPO_ROOT}/visual-proof-shots}" + +log() { + echo "[visual-proof] $*" +} + +err() { + echo "[visual-proof] $*" >&2 +} + +require_env() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + err "step=require-env error=${name} is required" + exit 1 + fi +} + +require_env GH_TOKEN +require_env GITHUB_REPOSITORY +require_env PR_NUMBER +require_env HEAD_SHA + +parse_visual_proof_section() { + gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json body --jq .body | node -e ' + const { shouldCaptureVisualProof, extractBrokenCaption, buildOverlayCaption } = require(process.argv[1]); + const fs = require("fs"); + const body = fs.readFileSync(0, "utf8"); + const result = shouldCaptureVisualProof(body); + const broken = extractBrokenCaption(result.section); + const overlay = buildOverlayCaption(result.section); + process.stdout.write(JSON.stringify({ ...result, broken, overlay })); + ' "${SCRIPT_DIR}/../visual-proof/parse-section.cjs" +} + +log "step=start PR_NUMBER=${PR_NUMBER} HEAD_SHA=${HEAD_SHA} GITHUB_REPOSITORY=${GITHUB_REPOSITORY}" + +log "step=parse-section about to read PR body via gh|node" +DECISION=$(parse_visual_proof_section) + +# Author job runs in parallel on opened / ready_for_review. Wait for it to +# write ## Visual proof before skipping capture as missing-section. +if [[ "$(echo "$DECISION" | jq -r .reason)" == "missing-section" ]]; then + log "step=wait-author polling PR body for Visual proof (up to 4 minutes)" + for _ in 1 2 3 4 5 6 7 8; do + sleep 30 + DECISION=$(parse_visual_proof_section) + REASON=$(echo "$DECISION" | jq -r .reason) + log "step=wait-author reason=${REASON}" + if [[ "$REASON" != "missing-section" ]]; then + break + fi + done +fi + +REASON=$(echo "$DECISION" | jq -r .reason) +CAPTURE=$(echo "$DECISION" | jq -r .capture) +BROKEN=$(echo "$DECISION" | jq -r .broken) +OVERLAY=$(echo "$DECISION" | jq -r '.overlay // empty') + +log "step=parse-section reason=${REASON} capture=${CAPTURE}" +echo "Visual proof decision: ${REASON}" + +if [[ "$CAPTURE" != "true" ]]; then + log "step=skip Skipping capture (${REASON})" + exit 0 +fi + +log "step=resolve-deployment about to list playground-preview deployments for ${HEAD_SHA}" +DEPLOYMENT_ID=$( + gh api "repos/${GITHUB_REPOSITORY}/deployments?environment=playground-preview&sha=${HEAD_SHA}&per_page=5" \ + --jq '.[0].id // empty' +) + +if [[ -z "${DEPLOYMENT_ID}" ]]; then + err "step=resolve-deployment deployment_id=empty" + err "No playground-preview deployment for ${HEAD_SHA}" + exit 1 +fi + +log "step=resolve-deployment deployment_id=${DEPLOYMENT_ID}" + +log "step=resolve-url about to list success statuses for deployment ${DEPLOYMENT_ID}" +PLAYGROUND_URL=$( + gh api "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + --jq '[.[] | select(.state == "success" and .environment_url != null and .environment_url != "")][0].environment_url // empty' +) + +if [[ -z "$PLAYGROUND_URL" ]]; then + err "step=resolve-url PLAYGROUND_URL=empty" + err "playground-preview deployment ${DEPLOYMENT_ID} has no success environment_url" + exit 1 +fi + +log "step=resolve-url PLAYGROUND_URL=${PLAYGROUND_URL}" + +mkdir -p "$OUT_DIR" +printf '%s' "$OVERLAY" > "${OUT_DIR}/overlay.txt" +echo "$DECISION" | jq -r '.section // empty' > "${OUT_DIR}/section.md" +export PLAYGROUND_URL +export VISUAL_PROOF_OUT_DIR="$OUT_DIR" +export VISUAL_PROOF_BROKEN="$BROKEN" +export VISUAL_PROOF_OVERLAY_FILE="${OUT_DIR}/overlay.txt" +export VISUAL_PROOF_SECTION_FILE="${OUT_DIR}/section.md" +export VISUAL_PROOF_SOURCE="fallback" + +png_count() { + find "$OUT_DIR" -maxdepth 1 -type f -name '*.png' 2>/dev/null | wc -l | tr -d ' ' +} + +cd "$SCRIPT_DIR" +PLAYWRIGHT_DIR="${SCRIPT_DIR}/.npm-playwright" +rm -rf "$PLAYWRIGHT_DIR" +mkdir -p "$PLAYWRIGHT_DIR" +printf '%s\n' '{"private":true}' > "${PLAYWRIGHT_DIR}/package.json" +log "step=npm-install about to install playwright in ${PLAYWRIGHT_DIR}" +if ! ( cd "$PLAYWRIGHT_DIR" && npm install --no-package-lock --no-fund --loglevel error playwright@1.55.1 ); then + err "step=npm-install failed" + exit 1 +fi +log "step=npm-install ok" + +export PLAYGROUND_NODE_PATH="${PLAYWRIGHT_DIR}/node_modules" + +log "step=playwright-install about to run playwright install chromium" +if ! "${PLAYGROUND_NODE_PATH}/.bin/playwright" install chromium; then + err "step=playwright-install failed" + exit 1 +fi +log "step=playwright-install ok" + +cd "$REPO_ROOT" + +if [[ -n "${CURSOR_API_KEY:-}" ]]; then + log "step=actor about to run act.sh" + if bash "${SCRIPT_DIR}/act.sh"; then + export VISUAL_PROOF_SOURCE="actor" + log "step=actor ok png_count=$(png_count)" + else + err "step=actor failed; falling back to generic walk" + fi +else + log "step=actor skipped (CURSOR_API_KEY empty)" +fi + +if [[ "$(png_count)" -eq 0 ]]; then + log "step=capture about to run capture.cjs OUT_DIR=${OUT_DIR}" + if ! NODE_PATH="${PLAYGROUND_NODE_PATH}" node "${SCRIPT_DIR}/capture.cjs"; then + err "step=capture failed" + exit 1 + fi + export VISUAL_PROOF_SOURCE="fallback" + log "step=capture ok" +fi + +if command -v ffmpeg >/dev/null 2>&1 && [[ -f "${OUT_DIR}/visual-proof.webm" ]]; then + log "step=ffmpeg about to transcode visual-proof.webm to mp4" + if ffmpeg -y -i "${OUT_DIR}/visual-proof.webm" -an -c:v libx264 -pix_fmt yuv420p "${OUT_DIR}/visual-proof.mp4" >/dev/null 2>&1; then + log "step=ffmpeg ok" + else + err "step=ffmpeg failed (keeping webm)" + fi +fi + +export GITHUB_SERVER_URL="${GITHUB_SERVER_URL:-https://github.com}" +export GITHUB_RUN_ID="${GITHUB_RUN_ID:-0}" +log "step=post-comment about to run post-comment.sh GITHUB_RUN_ID=${GITHUB_RUN_ID}" +if ! bash "${SCRIPT_DIR}/post-comment.sh"; then + err "step=post-comment failed" + exit 1 +fi +log "step=done" diff --git a/actions/visual-proof-shots/storyboard-prompt.md b/actions/visual-proof-shots/storyboard-prompt.md new file mode 100644 index 000000000..ae12e5cc7 --- /dev/null +++ b/actions/visual-proof-shots/storyboard-prompt.md @@ -0,0 +1,25 @@ +# Visual proof storyboard actor (CI) + +You drive WordPress Playground to **act out the PR’s Visual proof Steps** (the happy path / Pass). You do not recreate the bug. Playground already has this PR’s **fixed** zip. + +## Environment + +- `PLAYGROUND_URL` — open this. WordPress lives in iframe `name="wp"`. Re-resolve that frame before every click (`waitForWpFrame`). +- Login only if you see wp-login: `admin` / `password`. +- Helpers: require `PLAYGROUND_HELPERS` (CommonJS). Use `launchRecordedBrowser`, `waitForWpFrame`, `addCaption`, `clickFirstInWp`, `screenshot`, `saveRecording`, `overlayText`. +- Storyboard file: `VISUAL_PROOF_SECTION_FILE` (Broken / Where / Steps / Pass / Fail). +- Output: `VISUAL_PROOF_OUT_DIR`. Write `01-*.png` … (max 6) and finish so `saveRecording` can write `visual-proof.webm`. +- Playwright is already installed. Run with: + `NODE_PATH="$PLAYGROUND_NODE_PATH" node