Skip to content

Commit 92fd2b4

Browse files
iangmaiaclaude
andauthored
Add Buildkite pipeline for AI E2E tests (simulator-llm-pilot gem) (#25444)
* Add Buildkite pipeline for AI E2E tests using simulator-llm-pilot gem The gem provides a sandboxed agent that drives the simulator through a fixed set of tools (tap, swipe, type, REST API) with no arbitrary code execution. It handles WDA lifecycle, session management, context window compression, and verification/cleanup enforcement internally. The Buildkite step: - Checks for "Testing" label (skips if missing) - Downloads build artifacts and installs app on simulator - Installs the simulator-llm-pilot gem from GitHub - Runs all test cases in Tests/AgentTests/ui-tests/ Ref: AINFRA-2176 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Use [[ instead of [ for conditional tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix label check: BUILDKITE_PULL_REQUEST_LABELS is comma-separated Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix gem build: run from inside cloned directory gem build resolves spec file paths relative to cwd, so bin/simulator-llm-pilot wasn't found when building from the wordpress-ios repo root. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Clone and build WebDriverAgent if not present on CI agent Extract WDA build to a separate build-wda.sh script for clarity. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Export SIMULATOR_NAME so build-wda.sh can read it Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Harden gem-backed AI E2E runner * Use APP_BUNDLE_ID consistently * Normalize CI site URLs for simulator runs * Extend AI E2E timeout to 60 minutes * Fix Rubocop errors * Pass app instructions and name to simulator-llm-pilot The gem no longer hardcodes WordPress login flow in its system prompt. Add app-instructions.md with the WordPress/Jetpack login flow and pass it via --app-instructions-file. Also pass --app-name so the LLM knows the app's display name. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 40dbb59 commit 92fd2b4

8 files changed

Lines changed: 336 additions & 3 deletions

File tree

.buildkite/commands/build-wda.sh

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
#!/usr/bin/env bash
2+
# Clone and build WebDriverAgent for iOS Simulator testing.
3+
#
4+
# Skips the build only when a usable build-for-testing artifact already exists.
5+
#
6+
# Required (one of):
7+
# SIMULATOR_UDID Simulator UDID for the build destination
8+
# SIMULATOR_NAME Simulator name for the build destination (e.g., iPhone 16)
9+
#
10+
# Optional:
11+
# WEBDRIVERAGENT_REPO_URL Repo URL (default: appium/WebDriverAgent)
12+
# WEBDRIVERAGENT_REF Git ref or commit to build (default: current remote HEAD / existing checkout)
13+
14+
set -euo pipefail
15+
16+
if [[ -z "${SIMULATOR_UDID:-}" && -z "${SIMULATOR_NAME:-}" ]]; then
17+
echo "Error: set SIMULATOR_UDID or SIMULATOR_NAME" >&2
18+
exit 1
19+
fi
20+
21+
WDA_DIR=".build/WebDriverAgent"
22+
WDA_PROJECT="${WDA_DIR}/WebDriverAgent.xcodeproj"
23+
WDA_DERIVED_DATA="${WDA_DIR}/DerivedData"
24+
WEBDRIVERAGENT_REPO_URL="${WEBDRIVERAGENT_REPO_URL:-https://github.com/appium/WebDriverAgent.git}"
25+
WEBDRIVERAGENT_REF="${WEBDRIVERAGENT_REF:-}"
26+
27+
if [[ -n "${SIMULATOR_UDID:-}" ]]; then
28+
DESTINATION="platform=iOS Simulator,id=${SIMULATOR_UDID}"
29+
else
30+
DESTINATION="platform=iOS Simulator,name=${SIMULATOR_NAME}"
31+
fi
32+
33+
ensure_wda_checkout() {
34+
mkdir -p .build
35+
36+
if [[ ! -d "${WDA_DIR}/.git" ]]; then
37+
git clone --depth 1 "${WEBDRIVERAGENT_REPO_URL}" "${WDA_DIR}"
38+
fi
39+
40+
if [[ -n "${WEBDRIVERAGENT_REF}" ]]; then
41+
git -C "${WDA_DIR}" fetch --depth 1 origin "${WEBDRIVERAGENT_REF}"
42+
git -C "${WDA_DIR}" checkout --detach "${WEBDRIVERAGENT_REF}"
43+
fi
44+
}
45+
46+
has_built_artifacts() {
47+
[[ -d "${WDA_DERIVED_DATA}/Build/Products" ]] && \
48+
find "${WDA_DERIVED_DATA}/Build/Products" -name '*.xctestrun' -print -quit | grep -q .
49+
}
50+
51+
ensure_wda_checkout
52+
53+
if [[ -d "$WDA_PROJECT" ]] && has_built_artifacts; then
54+
echo "WebDriverAgent already built, skipping."
55+
exit 0
56+
fi
57+
58+
xcodebuild build-for-testing \
59+
-project "$WDA_PROJECT" \
60+
-scheme WebDriverAgentRunner \
61+
-destination "$DESTINATION" \
62+
-derivedDataPath "$WDA_DERIVED_DATA" \
63+
CODE_SIGNING_ALLOWED=NO \
64+
| tail -1
65+
66+
if ! has_built_artifacts; then
67+
echo "Error: WebDriverAgent build completed without an .xctestrun artifact" >&2
68+
exit 1
69+
fi
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
#!/usr/bin/env bash
2+
# Run AI-driven E2E tests on an iOS Simulator using simulator-llm-pilot.
3+
#
4+
# This script manages the full lifecycle:
5+
# 1. Check for "Testing" label on PR (Buildkite only, skips if missing)
6+
# 2. Download build artifacts and install app (Buildkite only)
7+
# 3. Install the simulator-llm-pilot gem from GitHub
8+
# 4. Run tests (gem handles simulator, WDA, agent loop, and results)
9+
#
10+
# The gem provides a sandboxed agent that drives the simulator through a
11+
# fixed set of tools (tap, swipe, type, REST API, etc.) — no arbitrary
12+
# code execution, no shell access.
13+
#
14+
# Required environment variables:
15+
# ANTHROPIC_API_KEY Claude API key
16+
# SIMULATOR_LLM_PILOT_SITE_URL WordPress test site URL
17+
# SIMULATOR_LLM_PILOT_USERNAME WordPress username
18+
# SIMULATOR_LLM_PILOT_APP_PASSWORD WordPress application password
19+
#
20+
# Optional environment variables:
21+
# APP wordpress | jetpack (default: jetpack)
22+
# SIMULATOR_NAME Simulator to boot if none running (default: iPhone 16)
23+
# TEST_DIR Test directory (default: Tests/AgentTests/ui-tests)
24+
# SIMULATOR_LLM_PILOT_REPO_URL Remote repo URL for simulator-llm-pilot
25+
# SIMULATOR_LLM_PILOT_SOURCE_PATH Local source checkout override for simulator-llm-pilot
26+
27+
set -euo pipefail
28+
29+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
30+
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
31+
cd "$REPO_ROOT"
32+
33+
normalize_site_url() {
34+
local site_url="$1"
35+
if [[ "$site_url" == http://* || "$site_url" == https://* ]]; then
36+
printf '%s' "$site_url"
37+
else
38+
printf 'https://%s' "$site_url"
39+
fi
40+
}
41+
42+
# ── Label gate (Buildkite only) ─────────────────────────────────────
43+
if [[ -n "${BUILDKITE_PULL_REQUEST_LABELS:-}" ]]; then
44+
echo "--- Checking for 'Testing' label"
45+
46+
if ! echo ",${BUILDKITE_PULL_REQUEST_LABELS}," | grep -qF ",Testing,"; then
47+
echo "PR does not have the 'Testing' label. Skipping."
48+
echo "Add the label and re-run this step to trigger AI E2E tests."
49+
exit 0
50+
fi
51+
echo "'Testing' label found."
52+
fi
53+
54+
# ── Required env vars ────────────────────────────────────────────────
55+
: "${ANTHROPIC_API_KEY:?Set ANTHROPIC_API_KEY}"
56+
: "${SIMULATOR_LLM_PILOT_SITE_URL:?Set SIMULATOR_LLM_PILOT_SITE_URL}"
57+
: "${SIMULATOR_LLM_PILOT_USERNAME:?Set SIMULATOR_LLM_PILOT_USERNAME}"
58+
: "${SIMULATOR_LLM_PILOT_APP_PASSWORD:?Set SIMULATOR_LLM_PILOT_APP_PASSWORD}"
59+
export SIMULATOR_LLM_PILOT_SITE_URL="$(normalize_site_url "$SIMULATOR_LLM_PILOT_SITE_URL")"
60+
61+
# ── Defaults ─────────────────────────────────────────────────────────
62+
APP="${APP:-jetpack}"
63+
export SIMULATOR_NAME="${SIMULATOR_NAME:-iPhone 16}"
64+
TEST_DIR="${TEST_DIR:-Tests/AgentTests/ui-tests}"
65+
SIMULATOR_LLM_PILOT_REPO_URL="${SIMULATOR_LLM_PILOT_REPO_URL:-https://github.com/Automattic/simulator-llm-pilot.git}"
66+
SIMULATOR_LLM_PILOT_SOURCE_PATH="${SIMULATOR_LLM_PILOT_SOURCE_PATH:-}"
67+
68+
case "$APP" in
69+
wordpress) APP_BUNDLE_ID="org.wordpress"; APP_DISPLAY_NAME="WordPress" ;;
70+
jetpack) APP_BUNDLE_ID="com.automattic.jetpack"; APP_DISPLAY_NAME="Jetpack" ;;
71+
*) echo "Error: APP must be 'wordpress' or 'jetpack', got '$APP'" >&2; exit 1 ;;
72+
esac
73+
74+
APP_INSTRUCTIONS_FILE="${REPO_ROOT}/Tests/AgentTests/app-instructions.md"
75+
76+
# ── Artifact download (Buildkite only) ───────────────────────────────
77+
if [[ -n "${BUILDKITE:-}" ]]; then
78+
echo "--- Downloading Build Artifacts"
79+
download_artifact "build-products-${APP}.tar"
80+
tar -xf "build-products-${APP}.tar"
81+
82+
echo "--- Setting up Gems"
83+
install_gems
84+
fi
85+
86+
# ── Install simulator-llm-pilot ──────────────────────────────────────
87+
echo "--- Installing simulator-llm-pilot"
88+
bash Scripts/ci/install-simulator-llm-pilot.sh
89+
echo "simulator-llm-pilot $(simulator-llm-pilot version)"
90+
91+
# ── Resolve simulator and install app (Buildkite only) ───────────────
92+
echo "--- Setting up Simulator"
93+
94+
UDID="$(ruby Scripts/ci/find-booted-simulator.rb "$SIMULATOR_NAME" 2>/dev/null || true)"
95+
if [[ -z "$UDID" ]]; then
96+
echo "No booted simulator named '$SIMULATOR_NAME' found. Booting..."
97+
xcrun simctl boot "$SIMULATOR_NAME" 2>/dev/null || true
98+
UDID="$(ruby Scripts/ci/find-booted-simulator.rb "$SIMULATOR_NAME" 30 1 2>/dev/null || true)"
99+
fi
100+
101+
if [[ -z "$UDID" ]]; then
102+
echo "Error: could not find a booted simulator named '$SIMULATOR_NAME'" >&2
103+
exit 1
104+
fi
105+
106+
export SIMULATOR_UDID="$UDID"
107+
echo "Simulator UDID: $UDID"
108+
109+
if [[ -n "${BUILDKITE:-}" ]]; then
110+
APP_PATH=$(find DerivedData/Build/Products -name "${APP_DISPLAY_NAME}.app" -path "*Debug-iphonesimulator*" | head -1)
111+
if [[ -z "$APP_PATH" ]]; then
112+
echo "Error: ${APP_DISPLAY_NAME}.app not found in build products" >&2
113+
exit 1
114+
fi
115+
echo "Installing $APP_PATH on simulator..."
116+
xcrun simctl install "$UDID" "$APP_PATH"
117+
fi
118+
119+
# ── Build WebDriverAgent (if not present) ────────────────────────────
120+
echo "--- Building WebDriverAgent"
121+
"$(dirname "$0")/build-wda.sh"
122+
123+
# ── Run tests ────────────────────────────────────────────────────────
124+
echo "--- Running AI E2E Tests"
125+
126+
TIMESTAMP="$(date +%Y-%m-%d-%H%M)"
127+
RESULTS_DIR="Tests/AgentTests/results/${TIMESTAMP}"
128+
129+
EXIT_CODE=0
130+
simulator-llm-pilot run "$TEST_DIR" \
131+
--app-bundle-id "$APP_BUNDLE_ID" \
132+
--app-name "$APP_DISPLAY_NAME" \
133+
--app-instructions-file "$APP_INSTRUCTIONS_FILE" \
134+
--simulator-udid "$UDID" \
135+
--results-dir "$RESULTS_DIR" \
136+
|| EXIT_CODE=$?
137+
138+
# ── Report results ───────────────────────────────────────────────────
139+
echo "--- Results"
140+
RESULTS_FILE="${RESULTS_DIR}/results.md"
141+
if [[ -f "$RESULTS_FILE" ]]; then
142+
cat "$RESULTS_FILE"
143+
else
144+
echo "Warning: no results.md found at $RESULTS_FILE"
145+
fi
146+
147+
exit "$EXIT_CODE"

.buildkite/pipeline.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,24 @@ steps:
138138
command: .buildkite/commands/lint-localized-strings-format.sh
139139
plugins: [$CI_TOOLKIT_PLUGIN]
140140

141+
#################
142+
# AI E2E Tests (requires "Testing" label on PR)
143+
#################
144+
- label: "🤖 AI E2E Tests"
145+
command: .buildkite/commands/run-ai-e2e-tests.sh
146+
depends_on: "build_jetpack"
147+
if: "build.pull_request.id != null"
148+
soft_fail: true
149+
timeout_in_minutes: 60
150+
plugins: [$CI_TOOLKIT_PLUGIN]
151+
env:
152+
APP: jetpack
153+
artifact_paths:
154+
- "Tests/AgentTests/results/**/*"
155+
notify:
156+
- github_commit_status:
157+
context: "AI E2E Tests"
158+
141159
#################
142160
# Claude Build Analysis - dynamically uploaded so Build result conditions evaluate at runtime after the wait
143161
#################

.claude/skills/ai-test-runner/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ Use the ios-sim-navigation skill for WDA interaction reference.
9696
9797
## Context
9898
99-
- App Bundle ID: <BUNDLE_ID>
99+
- App Bundle ID: <APP_BUNDLE_ID>
100100
- WDA Session ID: <SESSION_ID>
101101
- Simulator UDID: <UDID>
102102
- Test file: <TEST_FILE_PATH> (absolute path)
@@ -117,7 +117,7 @@ Use the ios-sim-navigation skill for WDA interaction reference.
117117
2. **Relaunch the app** for a clean state:
118118
119119
```bash
120-
xcrun simctl launch --terminate-running-process <UDID> <BUNDLE_ID> \
120+
xcrun simctl launch --terminate-running-process <UDID> <APP_BUNDLE_ID> \
121121
-ui-test-site-url <SITE_URL> \
122122
-ui-test-site-user <USERNAME> \
123123
-ui-test-site-pass <APPLICATION_PASSWORD>

.claude/skills/ios-sim-navigation/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ If actions consistently fail or the tree looks unexpected, the app may have cras
366366
xcrun simctl list devices booted
367367

368368
# Re-launch the app
369-
xcrun simctl launch <UDID> <BUNDLE_ID>
369+
xcrun simctl launch <UDID> <APP_BUNDLE_ID>
370370
```
371371

372372
After re-launching, create a new WDA session before continuing.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
require 'json'
5+
require 'open3'
6+
7+
requested_name = ARGV[0].to_s
8+
wait_seconds = ARGV[1].to_f
9+
poll_interval = ARGV[2].to_f
10+
poll_interval = 1.0 if poll_interval <= 0
11+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + [wait_seconds, 0].max
12+
13+
loop do
14+
output, status = Open3.capture2('xcrun', 'simctl', 'list', 'devices', 'booted', '-j')
15+
exit 1 unless status.success?
16+
17+
data = JSON.parse(output)
18+
devices = data.fetch('devices', {}).each_value.flat_map do |list|
19+
list.select { |device| device['state'] == 'Booted' }
20+
end
21+
22+
device = if requested_name.empty?
23+
devices.first
24+
else
25+
devices.find { |entry| entry['name'] == requested_name }
26+
end
27+
28+
if device
29+
print(device['udid'])
30+
exit 0
31+
end
32+
33+
break if wait_seconds <= 0 || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
34+
35+
sleep poll_interval
36+
end
37+
38+
exit 1
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
5+
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
6+
DEFAULT_LOCAL_GEM_PATH="$(cd "$REPO_ROOT/.." && pwd)/simulator-llm-pilot"
7+
8+
SIMULATOR_LLM_PILOT_REPO_URL="${SIMULATOR_LLM_PILOT_REPO_URL:-https://github.com/Automattic/simulator-llm-pilot.git}"
9+
SIMULATOR_LLM_PILOT_SOURCE_PATH="${SIMULATOR_LLM_PILOT_SOURCE_PATH:-}"
10+
11+
build_dir="$(mktemp -d)"
12+
trap 'rm -rf "$build_dir"' EXIT
13+
14+
source_path="${SIMULATOR_LLM_PILOT_SOURCE_PATH}"
15+
if [[ -z "$source_path" && -f "${DEFAULT_LOCAL_GEM_PATH}/simulator-llm-pilot.gemspec" ]]; then
16+
source_path="${DEFAULT_LOCAL_GEM_PATH}"
17+
fi
18+
19+
if [[ -n "$source_path" ]]; then
20+
echo "Using local simulator-llm-pilot source at ${source_path}"
21+
if [[ -d "${source_path}/.git" ]]; then
22+
source_revision="$(git -C "${source_path}" rev-parse HEAD)"
23+
git -C "${source_path}" archive HEAD | tar -x -C "$build_dir"
24+
else
25+
source_revision="local-filesystem"
26+
tar -cf - -C "${source_path}" . | tar -xf - -C "$build_dir"
27+
fi
28+
else
29+
echo "Cloning simulator-llm-pilot from ${SIMULATOR_LLM_PILOT_REPO_URL}"
30+
git clone --depth 1 "${SIMULATOR_LLM_PILOT_REPO_URL}" "$build_dir"
31+
source_revision="$(git -C "$build_dir" rev-parse HEAD)"
32+
fi
33+
34+
pushd "$build_dir" >/dev/null
35+
gem build simulator-llm-pilot.gemspec >/dev/null
36+
shopt -s nullglob
37+
gem_files=(simulator-llm-pilot-*.gem)
38+
shopt -u nullglob
39+
40+
if [[ ${#gem_files[@]} -ne 1 ]]; then
41+
echo "Error: expected exactly one built simulator-llm-pilot gem, found ${#gem_files[@]}" >&2
42+
exit 1
43+
fi
44+
45+
gem install --no-document --force "./${gem_files[0]}"
46+
popd >/dev/null
47+
48+
echo "Installed simulator-llm-pilot from ${source_revision}"
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
## Login
2+
3+
This app uses a self-hosted WordPress site login flow. The app password is
4+
passed via launch arguments — NEVER type a password manually.
5+
6+
- NEVER tap "Continue with WordPress.com", NEVER enter WordPress.com
7+
email/password, and NEVER request a login link.
8+
- Tap "Enter your existing site address", then enter the site host first
9+
(without scheme, for example `example.com`). If the app rejects the
10+
host-only form, try the full site URL once.
11+
- If you reach any WordPress.com email/password screen, back out and
12+
return to the self-hosted flow.
13+
- If the app is already logged in (e.g., My Site tab visible), skip login.

0 commit comments

Comments
 (0)