From 1bb093a320732fbbc6d2c4a6806b15f8982db8b4 Mon Sep 17 00:00:00 2001 From: David Seveloff Date: Thu, 17 Sep 2026 14:08:38 +0300 Subject: [PATCH 1/8] ci: Add shared Visual proof author and shots actions [ED-25578] Ref: ED-25578 Co-authored-by: Cursor --- actions/visual-proof-author/README.md | 43 ++++++ actions/visual-proof-author/action.yml | 66 +++++++++ actions/visual-proof-shots/README.md | 42 ++++++ actions/visual-proof-shots/action.yml | 67 +++++++++ actions/visual-proof/.gitignore | 2 + actions/visual-proof/README.md | 12 ++ actions/visual-proof/act.sh | 100 +++++++++++++ actions/visual-proof/author-prompt.md | 29 ++++ actions/visual-proof/author.sh | 134 +++++++++++++++++ actions/visual-proof/capture.cjs | 63 ++++++++ actions/visual-proof/examples.md | 28 ++++ actions/visual-proof/package.json | 8 + actions/visual-proof/parse-section.js | 116 +++++++++++++++ actions/visual-proof/playground.cjs | 134 +++++++++++++++++ actions/visual-proof/post-comment.sh | 171 ++++++++++++++++++++++ actions/visual-proof/run.sh | 171 ++++++++++++++++++++++ actions/visual-proof/skill.md | 66 +++++++++ actions/visual-proof/storyboard-prompt.md | 25 ++++ 18 files changed, 1277 insertions(+) create mode 100644 actions/visual-proof-author/README.md create mode 100644 actions/visual-proof-author/action.yml create mode 100644 actions/visual-proof-shots/README.md create mode 100644 actions/visual-proof-shots/action.yml create mode 100644 actions/visual-proof/.gitignore create mode 100644 actions/visual-proof/README.md create mode 100755 actions/visual-proof/act.sh create mode 100644 actions/visual-proof/author-prompt.md create mode 100755 actions/visual-proof/author.sh create mode 100644 actions/visual-proof/capture.cjs create mode 100644 actions/visual-proof/examples.md create mode 100644 actions/visual-proof/package.json create mode 100644 actions/visual-proof/parse-section.js create mode 100644 actions/visual-proof/playground.cjs create mode 100755 actions/visual-proof/post-comment.sh create mode 100755 actions/visual-proof/run.sh create mode 100644 actions/visual-proof/skill.md create mode 100644 actions/visual-proof/storyboard-prompt.md diff --git a/actions/visual-proof-author/README.md b/actions/visual-proof-author/README.md new file mode 100644 index 000000000..3535ce3d1 --- /dev/null +++ b/actions/visual-proof-author/README.md @@ -0,0 +1,43 @@ +# 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 `actions/visual-proof/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. diff --git a/actions/visual-proof-author/action.yml b/actions/visual-proof-author/action.yml new file mode 100644 index 000000000..81b3230ee --- /dev/null +++ b/actions/visual-proof-author/action.yml @@ -0,0 +1,66 @@ +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 }} + run: bash "${{ github.action_path }}/../visual-proof/author.sh" diff --git a/actions/visual-proof-shots/README.md b/actions/visual-proof-shots/README.md new file mode 100644 index 000000000..d7263d6d3 --- /dev/null +++ b/actions/visual-proof-shots/README.md @@ -0,0 +1,42 @@ +# Visual proof shots + +After `playground-preview` succeeds, records Playground PNGs and a short clip. +A Cursor storyboard actor follows **Steps**; if that produces no shots, CI +falls back to Pages → Add New → Edit with Elementor. + +Needs job permissions `contents: write` and `pull-requests: write` so images +can land on `ci/visual-proof-assets` in the **calling** repo. + +## Usage + +```yaml +visual-proof-shots: + name: Visual proof shots + needs: [playground-preview] + if: | + always() && + needs.playground-preview.result == 'success' && + github.event.pull_request.draft == false && + github.event.action != 'ready_for_review' && + github.event.action != 'labeled' && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-22.04 + timeout-minutes: 25 + permissions: + contents: write + pull-requests: write + deployments: read + steps: + - uses: actions/checkout@v6 + + - uses: elementor/elementor-editor-github-actions/actions/visual-proof-shots@main + with: + pr-number: ${{ github.event.pull_request.number }} + head-sha: ${{ github.event.pull_request.head.sha }} + product-name: 'Elementor' + cursor-api-key: ${{ secrets.CURSOR_APIKEY }} + model: ${{ vars.PR_REVIEW_MODEL }} +``` + +Use `product-name: 'Elementor Pro'` in Pro. The environment name must stay +`playground-preview`. diff --git a/actions/visual-proof-shots/action.yml b/actions/visual-proof-shots/action.yml new file mode 100644 index 000000000..378704a58 --- /dev/null +++ b/actions/visual-proof-shots/action.yml @@ -0,0 +1,67 @@ +name: 'Visual proof shots' +description: 'Record Playground screenshots and a short clip from ## Visual proof Steps' + +inputs: + pr-number: + description: 'Pull request number' + required: true + head-sha: + description: 'PR head commit SHA used to resolve playground-preview' + required: true + product-name: + description: 'Product name (Elementor or Elementor Pro)' + required: true + cursor-api-key: + description: 'Cursor Agent API key for the storyboard actor' + required: true + model: + description: 'Cursor Agent model' + required: false + default: '' + github-token: + description: 'Token with contents:write and pull-requests:write' + required: false + default: ${{ github.token }} + +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: Parse Visual proof section + shell: bash + run: node "${{ github.action_path }}/../visual-proof/parse-section.js" --test + + - name: Capture Playground shots and comment + 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 }} + HEAD_SHA: ${{ inputs.head-sha }} + PR_REVIEW_MODEL: ${{ inputs.model }} + PRODUCT_NAME: ${{ inputs.product-name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_RUN_ID: ${{ github.run_id }} + run: bash "${{ github.action_path }}/../visual-proof/run.sh" + + - name: Upload shots artifact + if: hashFiles('visual-proof-shots/*.png') != '' + uses: actions/upload-artifact@v7 + with: + name: visual-proof-shots + path: visual-proof-shots/ + if-no-files-found: ignore + retention-days: 7 diff --git a/actions/visual-proof/.gitignore b/actions/visual-proof/.gitignore new file mode 100644 index 000000000..0efadbe9b --- /dev/null +++ b/actions/visual-proof/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +video-raw/ diff --git a/actions/visual-proof/README.md b/actions/visual-proof/README.md new file mode 100644 index 000000000..3829d0dc9 --- /dev/null +++ b/actions/visual-proof/README.md @@ -0,0 +1,12 @@ +# Visual proof + +Shared author + Playground recorder for `elementor/elementor` and +`elementor/elementor-pro`. + +Callers keep their own `playground-preview` workflow. This folder is the +scripts; the composite entrypoints are: + +- `actions/visual-proof-author` +- `actions/visual-proof-shots` + +See those READMEs for wrapper workflow YAML. diff --git a/actions/visual-proof/act.sh b/actions/visual-proof/act.sh new file mode 100755 index 000000000..8f925ce97 --- /dev/null +++ b/actions/visual-proof/act.sh @@ -0,0 +1,100 @@ +#!/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:actor] $*" +} + +err() { + echo "[visual-proof:actor] $*" >&2 +} + +png_count() { + find "$OUT_DIR" -maxdepth 1 -type f -name '*.png' 2>/dev/null | wc -l | tr -d ' ' +} + +require_env() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + err "${name} is required" + exit 1 + fi +} + +require_env CURSOR_API_KEY +require_env PLAYGROUND_URL +require_env VISUAL_PROOF_SECTION_FILE +require_env VISUAL_PROOF_OUT_DIR + +if [[ ! -f "$VISUAL_PROOF_SECTION_FILE" ]]; then + err "section file missing: ${VISUAL_PROOF_SECTION_FILE}" + exit 1 +fi + +PROMPT_FILE="${SCRIPT_DIR}/storyboard-prompt.md" +if [[ ! -f "$PROMPT_FILE" ]]; then + err "storyboard prompt missing" + exit 1 +fi + +PROMPT=$(cat "$PROMPT_FILE") +SECTION=$(cat "$VISUAL_PROOF_SECTION_FILE") +MODEL="${PR_REVIEW_MODEL:-}" + +DYNAMIC_CONTEXT=$(cat << EOF +## Runtime +- PLAYGROUND_URL: ${PLAYGROUND_URL} +- PLAYGROUND_HELPERS: ${SCRIPT_DIR}/playground.cjs +- PLAYGROUND_NODE_PATH: ${SCRIPT_DIR}/node_modules +- VISUAL_PROOF_OUT_DIR: ${OUT_DIR} +- VISUAL_PROOF_SECTION_FILE: ${VISUAL_PROOF_SECTION_FILE} +- VISUAL_PROOF_OVERLAY_FILE: ${VISUAL_PROOF_OVERLAY_FILE:-} + +## Visual proof section +${SECTION} +EOF +) + +log "step=cursor-agent storyboard actor model=${MODEL:-default}" +cd "$REPO_ROOT" +export PLAYGROUND_HELPERS="${SCRIPT_DIR}/playground.cjs" +export PLAYGROUND_NODE_PATH="${SCRIPT_DIR}/node_modules" + +set +e +if [[ -n "$MODEL" ]]; then + timeout 600 cursor-agent --force --model "$MODEL" --output-format=text --print "$PROMPT" "$DYNAMIC_CONTEXT" +else + timeout 600 cursor-agent --force --output-format=text --print "$PROMPT" "$DYNAMIC_CONTEXT" +fi +AGENT_EXIT=$? +set -e + +if [[ "$AGENT_EXIT" -eq 124 ]]; then + err "step=cursor-agent timed out after 10 minutes" + exit 1 +fi +if [[ "$AGENT_EXIT" -ne 0 ]]; then + err "step=cursor-agent failed exit=${AGENT_EXIT}" + exit "$AGENT_EXIT" +fi + +if [[ "$(png_count)" -eq 0 && -f "${OUT_DIR}/actor.cjs" ]]; then + log "step=run-actor.cjs" + if ! NODE_PATH="${SCRIPT_DIR}/node_modules" node "${OUT_DIR}/actor.cjs"; then + err "step=run-actor.cjs failed" + exit 1 + fi +fi + +COUNT="$(png_count)" +log "step=verify png_count=${COUNT}" +if [[ "$COUNT" -eq 0 ]]; then + err "storyboard actor produced no PNGs" + exit 1 +fi + +log "step=done" diff --git a/actions/visual-proof/author-prompt.md b/actions/visual-proof/author-prompt.md new file mode 100644 index 000000000..c3d1db4be --- /dev/null +++ b/actions/visual-proof/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 `', + '## 1. Problem', + ].join( '\n' ); + assert.ok( ! extractVisualProofSection( withGitstream ).includes( 'Problem' ) ); + + console.log( 'visual-proof parse tests passed' ); +} diff --git a/actions/visual-proof/playground.cjs b/actions/visual-proof/playground.cjs new file mode 100644 index 000000000..b48b499a5 --- /dev/null +++ b/actions/visual-proof/playground.cjs @@ -0,0 +1,134 @@ +'use strict'; + +const { chromium } = require( 'playwright' ); +const fs = require( 'fs' ); +const path = require( 'path' ); + +const OUT_DIR = process.env.VISUAL_PROOF_OUT_DIR || 'visual-proof-shots'; +const OVERLAY_FILE = process.env.VISUAL_PROOF_OVERLAY_FILE || ''; +const BROKEN_CAPTION = process.env.VISUAL_PROOF_BROKEN || ''; + +function log( message ) { + console.log( `[visual-proof:play] ${ message }` ); +} + +function overlayText() { + if ( OVERLAY_FILE && fs.existsSync( OVERLAY_FILE ) ) { + const fromFile = fs.readFileSync( OVERLAY_FILE, 'utf8' ).trim(); + if ( fromFile ) { + return fromFile; + } + } + return BROKEN_CAPTION || 'Playground ready'; +} + +async function waitForWpFrame( page ) { + for ( let attempt = 0; attempt < 45; attempt++ ) { + const frame = page.frame( { name: 'wp' } ); + if ( frame ) { + const url = frame.url(); + const looksReady = url.includes( '/scope:' ) || url.includes( 'wp-admin' ) || url.includes( 'wp-login' ); + if ( looksReady ) { + const hasBody = await frame.evaluate( () => Boolean( document.body && document.body.innerHTML.length > 20 ) ).catch( () => false ); + if ( hasBody ) { + log( `frame ready attempt=${ attempt + 1 } url=${ url }` ); + return frame; + } + } + } + await page.waitForTimeout( 2000 ); + } + + throw new Error( 'Playground WordPress frame did not become ready after 45 attempts' ); +} + +async function addCaption( page, text ) { + if ( ! text ) { + return; + } + + await page.evaluate( ( caption ) => { + let bar = document.getElementById( 'e-visual-proof-caption' ); + if ( ! bar ) { + bar = document.createElement( 'div' ); + bar.id = 'e-visual-proof-caption'; + bar.style.cssText = 'position:fixed;left:0;right:0;bottom:0;z-index:2147483647;padding:12px 16px;background:#1f2937;color:#fff;font:14px/1.4 sans-serif;white-space:pre-wrap;max-height:28%;overflow:hidden;'; + document.body.appendChild( bar ); + } + bar.textContent = caption; + }, text ); +} + +async function clickFirst( frame, role, name ) { + const locator = frame.getByRole( role, { name, exact: false } ); + if ( 0 === await locator.count() ) { + return false; + } + await locator.first().click( { timeout: 15000 } ); + return true; +} + +async function clickFirstInWp( page, role, name ) { + const frame = await waitForWpFrame( page ); + const ok = await clickFirst( frame, role, name ); + log( `click role=${ role } name=${ name } ${ ok ? 'success' : 'fail' }` ); + return ok; +} + +async function launchRecordedBrowser() { + fs.mkdirSync( OUT_DIR, { recursive: true } ); + const videoDir = path.join( OUT_DIR, 'video-raw' ); + fs.mkdirSync( videoDir, { recursive: true } ); + const browser = await chromium.launch( { headless: true } ); + const context = await browser.newContext( { + viewport: { width: 1440, height: 900 }, + recordVideo: { dir: videoDir, size: { width: 1440, height: 900 } }, + } ); + const page = await context.newPage(); + return { browser, context, page }; +} + +async function saveRecording( page ) { + const context = page.context(); + const video = page.video(); + await page.close(); + await context.close(); + if ( ! video ) { + log( 'video recorder not attached' ); + return; + } + + const rawPath = await video.path(); + if ( ! rawPath || ! fs.existsSync( rawPath ) ) { + log( 'video file missing after context close' ); + return; + } + + const dest = path.join( OUT_DIR, 'visual-proof.webm' ); + fs.copyFileSync( rawPath, dest ); + log( `video path=${ dest }` ); +} + +async function screenshot( page, shots, name, maxShots ) { + const cap = maxShots || 6; + if ( shots.length >= cap ) { + log( `screenshot skipped name=${ name } (max ${ cap })` ); + return; + } + const file = path.join( OUT_DIR, `${ String( shots.length + 1 ).padStart( 2, '0' ) }-${ name }.png` ); + await page.screenshot( { path: file, fullPage: false } ); + shots.push( file ); + log( `screenshot path=${ file }` ); +} + +module.exports = { + OUT_DIR, + log, + overlayText, + waitForWpFrame, + addCaption, + clickFirstInWp, + launchRecordedBrowser, + saveRecording, + screenshot, +}; diff --git a/actions/visual-proof/post-comment.sh b/actions/visual-proof/post-comment.sh new file mode 100755 index 000000000..33b6c63d4 --- /dev/null +++ b/actions/visual-proof/post-comment.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Upload PNGs to ci/visual-proof-assets and comment on the PR. +# Modeled on Angie videoproof/post-comment.sh (inline images via raw git URLs). + +ARTIFACTS_DIR="${VISUAL_PROOF_OUT_DIR:-visual-proof-shots}" +MARKER="" +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(\"visual-proof-ci\")) | .id][0] // empty" || true +) + +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/run.sh b/actions/visual-proof/run.sh new file mode 100755 index 000000000..5034bf273 --- /dev/null +++ b/actions/visual-proof/run.sh @@ -0,0 +1,171 @@ +#!/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}/parse-section.js" +} + +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" +log "step=npm-install about to install local package.json playwright in ${SCRIPT_DIR} (workspaces off)" +if ! npm install --no-package-lock --no-fund --workspaces=false --loglevel error; then + err "step=npm-install failed" + exit 1 +fi +log "step=npm-install ok" + +log "step=playwright-install about to run ./node_modules/.bin/playwright install chromium" +if ! ./node_modules/.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="${SCRIPT_DIR}/node_modules" 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/skill.md b/actions/visual-proof/skill.md new file mode 100644 index 000000000..72c309b35 --- /dev/null +++ b/actions/visual-proof/skill.md @@ -0,0 +1,66 @@ +# Visual proof + +This skill is for the **CI author** action. Local Cursor agents should not add +`## Visual proof` on `gh pr create` or `gh pr edit`. Capturing screenshots is +not this skill. + +## What triggers it + +The caller’s `visual-proof-author` workflow runs this action when a same-repo +PR is **opened** or marked **ready for review**, and only if `## Visual proof` +is still missing. Capture is a separate `visual-proof-shots` action after +`playground-preview`. + +## Environment + +The proof target is the PR’s **playground-preview** deployment in the calling +repo (Elementor or Elementor Pro), not a local site. + +Login: `admin` / `password`. Landing page is `/wp-admin`. + +If the bug cannot be shown on that Playground, use `#skip_proof`. + +## When to fill vs skip + +**Fill** (Broken + Where / Steps / Pass / Fail): + +- Jira type `Bug` or `Editor Bug` (or a clear regression fix) +- User-visible **editor** UI (panel, canvas, navigator, Style / Content) +- Can be shown on this repo’s Playground + +**`#skip_proof`** + one sentence: + +- Story / Task / feature +- No editor UI +- Needs a plugin Playground does not install +- Docs or CI + +## Rules + +1. Write **Steps** the actor can click: short, visible labels, stay in the editor. +2. Visible in-app labels only. No file paths, no GitHub, no workflow names. +3. Do not act out the bug. Playground has the **fixed** zip. Put the old + behaviour in `**Broken:**`, then show the fixed path. + +## PR body + +Order: Summary → Test plan → Visual proof → Jira. + +### Editor bug + +```markdown +## Visual proof +**Broken:** +**Where:** +**Steps:** +**Pass:** +**Fail:** +``` + +### Skip + +```markdown +## Visual proof +#skip_proof + +``` diff --git a/actions/visual-proof/storyboard-prompt.md b/actions/visual-proof/storyboard-prompt.md new file mode 100644 index 000000000..ae12e5cc7 --- /dev/null +++ b/actions/visual-proof/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