From ab1582c275a244e632b2f3e3e8afa64d265c8877 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Mon, 27 Oct 2025 08:07:51 +0200
Subject: [PATCH 01/14] Simplify iOS screenshot pipeline
---
docs/demos/pom.xml | 4 +-
.../tests/HelloCodenameOneUITests.swift.tmpl | 20 +-
scripts/run-ios-ui-tests.sh | 363 +++++-------------
3 files changed, 113 insertions(+), 274 deletions(-)
diff --git a/docs/demos/pom.xml b/docs/demos/pom.xml
index 0170ecd626..68ddde0e1e 100644
--- a/docs/demos/pom.xml
+++ b/docs/demos/pom.xml
@@ -19,8 +19,8 @@
common
- 7.0.208
- 7.0.208
+ 7.0.209
+ 7.0.209
UTF-8
1.8
11
diff --git a/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl b/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl
index 75a90b9d10..5f731a7a84 100644
--- a/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl
+++ b/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl
@@ -37,7 +37,7 @@ final class HelloCodenameOneUITests: XCTestCase {
}
private func captureScreenshot(named name: String) throws {
- let shot = XCUIScreen.main.screenshot()
+ let shot = bestScreenshot()
// Save into sandbox tmp (optional – mainly for local debugging)
let pngURL = outputDirectory.appendingPathComponent("\(name).png")
@@ -52,6 +52,24 @@ final class HelloCodenameOneUITests: XCTestCase {
emitScreenshotPayloads(for: shot, name: name)
}
+ private func bestScreenshot() -> XCUIScreenshot {
+ let screenShot = XCUIScreen.main.screenshot()
+ let appShot = app.screenshot()
+ let screenBytes = screenShot.pngRepresentation.count
+ let appBytes = appShot.pngRepresentation.count
+
+ if appBytes > screenBytes && appBytes > 0 {
+ return appShot
+ }
+ if screenBytes == 0 && appBytes > 0 {
+ return appShot
+ }
+ if appBytes == 0 && screenBytes > 0 {
+ return screenShot
+ }
+ return screenBytes >= appBytes ? screenShot : appShot
+ }
+
/// Wait for foreground + a short settle time
private func waitForStableFrame(timeout: TimeInterval = 30, settle: TimeInterval = 1.2) {
_ = app.wait(for: .runningForeground, timeout: timeout)
diff --git a/scripts/run-ios-ui-tests.sh b/scripts/run-ios-ui-tests.sh
index a9cc289f70..d005aca862 100755
--- a/scripts/run-ios-ui-tests.sh
+++ b/scripts/run-ios-ui-tests.sh
@@ -1,69 +1,39 @@
#!/usr/bin/env bash
-# Run Codename One iOS UI tests on the simulator and compare screenshots
+# Run Codename One iOS UI tests on the simulator and export screenshot attachments
set -euo pipefail
ri_log() { echo "[run-ios-ui-tests] $1"; }
-ensure_dir() { mkdir -p "$1" 2>/dev/null || true; }
-
if [ $# -lt 1 ]; then
- ri_log "Usage: $0 [app_bundle] [scheme]" >&2
+ ri_log "Usage: $0 [scheme]" >&2
exit 2
fi
WORKSPACE_PATH="$1"
-APP_BUNDLE_PATH="${2:-}"
-REQUESTED_SCHEME="${3:-}"
-
-# If $2 isn’t a dir and $3 is empty, treat $2 as the scheme.
-if [ -n "$APP_BUNDLE_PATH" ] && [ ! -d "$APP_BUNDLE_PATH" ] && [ -z "$REQUESTED_SCHEME" ]; then
- REQUESTED_SCHEME="$APP_BUNDLE_PATH"
- APP_BUNDLE_PATH=""
-fi
+REQUESTED_SCHEME="${2:-}"
if [ ! -d "$WORKSPACE_PATH" ]; then
ri_log "Workspace not found at $WORKSPACE_PATH" >&2
exit 3
fi
-if [ -n "$APP_BUNDLE_PATH" ]; then
- ri_log "Using simulator app bundle at $APP_BUNDLE_PATH"
-fi
-
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT"
-CN1SS_MAIN_CLASS="Cn1ssChunkTools"
-PROCESS_SCREENSHOTS_CLASS="ProcessScreenshots"
-RENDER_SCREENSHOT_REPORT_CLASS="RenderScreenshotReport"
-CN1SS_HELPER_SOURCE_DIR="$SCRIPT_DIR/android/tests"
-if [ ! -f "$CN1SS_HELPER_SOURCE_DIR/$CN1SS_MAIN_CLASS.java" ]; then
- ri_log "Missing CN1SS helper: $CN1SS_HELPER_SOURCE_DIR/$CN1SS_MAIN_CLASS.java" >&2
- exit 3
-fi
-
-source "$SCRIPT_DIR/lib/cn1ss.sh"
-cn1ss_log() { ri_log "$1"; }
-
TMPDIR="${TMPDIR:-/tmp}"; TMPDIR="${TMPDIR%/}"
DOWNLOAD_DIR="${TMPDIR}/codenameone-tools"
ENV_DIR="$DOWNLOAD_DIR/tools"
ENV_FILE="$ENV_DIR/env.sh"
-ri_log "Loading workspace environment from $ENV_FILE"
-[ -f "$ENV_FILE" ] || { ri_log "Missing env file: $ENV_FILE"; exit 3; }
-# shellcheck disable=SC1090
-source "$ENV_FILE"
-
-# Use the same Xcode as the build step
-export DEVELOPER_DIR="/Applications/Xcode_16.4.app/Contents/Developer"
-export PATH="$DEVELOPER_DIR/usr/bin:$PATH"
-
-if [ -z "${JAVA17_HOME:-}" ] || [ ! -x "$JAVA17_HOME/bin/java" ]; then
- ri_log "JAVA17_HOME not set correctly" >&2
- exit 3
+if [ -f "$ENV_FILE" ]; then
+ ri_log "Loading workspace environment from $ENV_FILE"
+ # shellcheck disable=SC1090
+ source "$ENV_FILE"
+else
+ ri_log "Workspace environment not found at $ENV_FILE (continuing with current shell env)"
fi
+
if ! command -v xcodebuild >/dev/null 2>&1; then
ri_log "xcodebuild not found" >&2
exit 3
@@ -73,9 +43,9 @@ if ! command -v xcrun >/dev/null 2>&1; then
exit 3
fi
-JAVA17_BIN="$JAVA17_HOME/bin/java"
-
-cn1ss_setup "$JAVA17_BIN" "$CN1SS_HELPER_SOURCE_DIR"
+# Use the same Xcode as the build step when available
+export DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode_16.4.app/Contents/Developer}"
+export PATH="$DEVELOPER_DIR/usr/bin:$PATH"
ARTIFACTS_DIR="${ARTIFACTS_DIR:-${GITHUB_WORKSPACE:-$REPO_ROOT}/artifacts}"
mkdir -p "$ARTIFACTS_DIR"
@@ -91,107 +61,68 @@ fi
SCHEME="$REQUESTED_SCHEME"
ri_log "Using scheme $SCHEME"
-SCREENSHOT_REF_DIR="$SCRIPT_DIR/ios/screenshots"
SCREENSHOT_TMP_DIR="$(mktemp -d "${TMPDIR}/cn1-ios-tests-XXXXXX" 2>/dev/null || echo "${TMPDIR}/cn1-ios-tests")"
-SCREENSHOT_RAW_DIR="$SCREENSHOT_TMP_DIR/raw"
-SCREENSHOT_PREVIEW_DIR="$SCREENSHOT_TMP_DIR/previews"
RESULT_BUNDLE="$SCREENSHOT_TMP_DIR/test-results.xcresult"
-mkdir -p "$SCREENSHOT_RAW_DIR" "$SCREENSHOT_PREVIEW_DIR"
-
-export CN1SS_OUTPUT_DIR="$SCREENSHOT_RAW_DIR"
-export CN1SS_PREVIEW_DIR="$SCREENSHOT_PREVIEW_DIR"
+DERIVED_DATA_DIR="$SCREENSHOT_TMP_DIR/derived"
+rm -rf "$DERIVED_DATA_DIR"
-# Patch scheme env vars to point to our runtime dirs
-SCHEME_FILE="$WORKSPACE_PATH/xcshareddata/xcschemes/$SCHEME.xcscheme"
-if [ -f "$SCHEME_FILE" ]; then
- if sed --version >/dev/null 2>&1; then
- # GNU sed
- sed -i -e "s|__CN1SS_OUTPUT_DIR__|$SCREENSHOT_RAW_DIR|g" \
- -e "s|__CN1SS_PREVIEW_DIR__|$SCREENSHOT_PREVIEW_DIR|g" "$SCHEME_FILE"
- else
- # BSD sed (macOS)
- sed -i '' -e "s|__CN1SS_OUTPUT_DIR__|$SCREENSHOT_RAW_DIR|g" \
- -e "s|__CN1SS_PREVIEW_DIR__|$SCREENSHOT_PREVIEW_DIR|g" "$SCHEME_FILE"
+find_sim_udid() {
+ local desired="${1:-iPhone 16}" json
+ if ! json="$(xcrun simctl list devices --json)"; then
+ return 1
fi
- ri_log "Injected CN1SS_* envs into scheme: $SCHEME_FILE"
-else
- ri_log "Scheme file not found for env injection: $SCHEME_FILE"
-fi
+ python3 - "$desired" <<'PY2'
+import json, sys
-auto_select_destination() {
- if ! command -v python3 >/dev/null 2>&1; then
- return
- fi
+def normalize(name: str) -> str:
+ return name.strip().lower()
+
+target = normalize(sys.argv[1])
+data = json.load(sys.stdin)
+for runtime, devices in (data.get("devices") or {}).items():
+ if "iOS" not in runtime:
+ continue
+ for device in devices or []:
+ if not device.get("isAvailable"):
+ continue
+ if normalize(device.get("name", "")) == target:
+ print(device.get("udid", ""))
+ sys.exit(0)
+print("")
+PY2
+}
- local show_dest selected
- if show_dest="$(xcodebuild -workspace "$WORKSPACE_PATH" -scheme "$SCHEME" -showdestinations 2>/dev/null)"; then
- selected="$(
- printf '%s\n' "$show_dest" | python3 - <<'PY'
-import re, sys
-def parse_version_tuple(v): return tuple(int(p) if p.isdigit() else 0 for p in v.split('.') if p)
-for block in re.findall(r"\{([^}]+)\}", sys.stdin.read()):
- f = dict(s.split(':',1) for s in block.split(',') if ':' in s)
- if f.get('platform')!='iOS Simulator': continue
- name=f.get('name',''); os=f.get('OS') or f.get('os') or ''
- pri=2 if 'iPhone' in name else (1 if 'iPad' in name else 0)
- print(f"__CAND__|{pri}|{'.'.join(map(str,parse_version_tuple(os.replace('latest',''))))}|{name}|{os}|{f.get('id','')}")
-cands=[l.split('|',5) for l in sys.stdin if False]
-PY
- )"
+ensure_booted_device() {
+ local name="$1" udid
+ if ! udid="$(find_sim_udid "$name")"; then
+ ri_log "Failed to query simulator udid for '$name'"
+ return 1
fi
-
- if [ -z "${selected:-}" ]; then
- if command -v xcrun >/dev/null 2>&1; then
- selected="$(
- xcrun simctl list devices --json 2>/dev/null | python3 - <<'PY'
-import json, sys
-def parse_version_tuple(v): return tuple(int(p) if p.isdigit() else 0 for p in v.split('.') if p)
-try: data=json.load(sys.stdin)
-except: sys.exit(0)
-c=[]
-for runtime, entries in (data.get('devices') or {}).items():
- if 'iOS' not in runtime: continue
- ver=runtime.split('iOS-')[-1].replace('-','.')
- vt=parse_version_tuple(ver)
- for e in entries or []:
- if not e.get('isAvailable'): continue
- name=e.get('name') or ''; ident=e.get('udid') or ''
- pri=2 if 'iPhone' in name else (1 if 'iPad' in name else 0)
- c.append((pri, vt, name, ident))
-if c:
- pri, vt, name, ident = sorted(c, reverse=True)[0]
- print(f"platform=iOS Simulator,id={ident}")
-PY
- )"
- fi
+ if [ -z "$udid" ]; then
+ ri_log "Simulator '$name' not found"
+ return 1
fi
-
- if [ -n "${selected:-}" ]; then
- echo "$selected"
+ ri_log "Using simulator '$name' (udid=$udid)"
+ if ! xcrun simctl bootstatus "$udid" -b >/dev/null 2>&1; then
+ ri_log "Booting simulator '$name'"
+ xcrun simctl boot "$udid" >/dev/null
+ xcrun simctl bootstatus "$udid" -b
fi
+ SIM_UDID="$udid"
}
-SIM_DESTINATION="${IOS_SIM_DESTINATION:-}"
-if [ -z "$SIM_DESTINATION" ]; then
- SELECTED_DESTINATION="$(auto_select_destination || true)"
- if [ -n "${SELECTED_DESTINATION:-}" ]; then
- SIM_DESTINATION="$SELECTED_DESTINATION"
- ri_log "Auto-selected simulator destination '$SIM_DESTINATION'"
- else
- ri_log "Simulator auto-selection did not return a destination"
- fi
-fi
-if [ -z "$SIM_DESTINATION" ]; then
- SIM_DESTINATION="platform=iOS Simulator,name=iPhone 16"
- ri_log "Falling back to default simulator destination '$SIM_DESTINATION'"
+SIM_DEVICE_NAME="${IOS_SIM_DEVICE_NAME:-iPhone 16}"
+SIM_UDID=""
+ensure_booted_device "$SIM_DEVICE_NAME" || true
+if [ -z "$SIM_UDID" ]; then
+ ri_log "Falling back to 'booted' simulator for destination"
+ SIM_DESTINATION="platform=iOS Simulator,name=$SIM_DEVICE_NAME"
+else
+ SIM_DESTINATION="id=$SIM_UDID"
fi
ri_log "Running UI tests on destination '$SIM_DESTINATION'"
-DERIVED_DATA_DIR="$SCREENSHOT_TMP_DIR/derived"
-rm -rf "$DERIVED_DATA_DIR"
-
-# Run only the UI test bundle
UI_TEST_TARGET="${UI_TEST_TARGET:-HelloCodenameOneUITests}"
XCODE_TEST_FILTERS=(
-only-testing:"${UI_TEST_TARGET}"
@@ -211,158 +142,48 @@ if ! xcodebuild \
CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
GENERATE_INFOPLIST_FILE=YES \
test | tee "$TEST_LOG"; then
- ri_log "STAGE:XCODE_TEST_FAILED -> See $TEST_LOG"
+ ri_log "xcodebuild test failed – see $TEST_LOG"
exit 10
fi
set +o pipefail
-declare -a CN1SS_SOURCES=()
-if [ -s "$TEST_LOG" ]; then
- CN1SS_SOURCES+=("XCODELOG:$TEST_LOG")
-else
- ri_log "FATAL: Test log missing or empty at $TEST_LOG"
- exit 11
-fi
-
-LOG_CHUNKS="$(cn1ss_count_chunks "$TEST_LOG")"; LOG_CHUNKS="${LOG_CHUNKS//[^0-9]/}"; : "${LOG_CHUNKS:=0}"
-ri_log "Chunk counts -> xcodebuild log: ${LOG_CHUNKS}"
-if [ "${LOG_CHUNKS:-0}" = "0" ]; then
- ri_log "STAGE:MARKERS_NOT_FOUND -> xcodebuild output did not include CN1SS chunks"
- ri_log "---- CN1SS lines (if any) ----"
- (grep "CN1SS:" "$TEST_LOG" || true) | sed 's/^/[CN1SS] /'
- exit 12
-fi
+EXPORT_DIR="$SCREENSHOT_TMP_DIR/xcresult-export"
+rm -rf "$EXPORT_DIR"
+mkdir -p "$EXPORT_DIR"
-TEST_NAMES_RAW="$(cn1ss_list_tests "$TEST_LOG" 2>/dev/null | awk 'NF' | sort -u || true)"
-declare -a TEST_NAMES=()
-if [ -n "$TEST_NAMES_RAW" ]; then
- while IFS= read -r name; do
- [ -n "$name" ] || continue
- TEST_NAMES+=("$name")
- done <<< "$TEST_NAMES_RAW"
-else
- TEST_NAMES+=("default")
+ri_log "Exporting screenshot attachments from $RESULT_BUNDLE"
+if ! xcrun xcresulttool export --path "$RESULT_BUNDLE" --type file --output-path "$EXPORT_DIR" >/dev/null; then
+ ri_log "xcresulttool export failed"
+ exit 11
fi
-ri_log "Detected CN1SS test streams: ${TEST_NAMES[*]}"
-
-PAIR_SEP=$'\037'
-declare -a TEST_OUTPUT_ENTRIES=()
-ensure_dir "$SCREENSHOT_PREVIEW_DIR"
+copy_exported_screenshots() {
+ local src_dir="$1" found=0 safe_name dest
+ while IFS= read -r -d '' shot; do
+ found=1
+ safe_name="$(basename "$shot")"
+ safe_name="${safe_name//[^A-Za-z0-9_.-]/_}"
+ dest="$ARTIFACTS_DIR/$safe_name"
+ cp -f "$shot" "$dest"
+ ri_log "Saved screenshot attachment to $dest"
+ done < <(find "$src_dir" -type f \( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' \) -print0)
-for test in "${TEST_NAMES[@]}"; do
- dest="$SCREENSHOT_TMP_DIR/${test}.png"
- if source_label="$(cn1ss_decode_test_png "$test" "$dest" "${CN1SS_SOURCES[@]}")"; then
- TEST_OUTPUT_ENTRIES+=("${test}${PAIR_SEP}${dest}")
- ri_log "Decoded screenshot for '$test' (source=${source_label}, size: $(cn1ss_file_size "$dest") bytes)"
- preview_dest="$SCREENSHOT_PREVIEW_DIR/${test}.jpg"
- if preview_source="$(cn1ss_decode_test_preview "$test" "$preview_dest" "${CN1SS_SOURCES[@]}")"; then
- ri_log "Decoded preview for '$test' (source=${preview_source}, size: $(cn1ss_file_size "$preview_dest") bytes)"
- else
- rm -f "$preview_dest" 2>/dev/null || true
- fi
- else
- ri_log "FATAL: Failed to extract/decode CN1SS payload for test '$test'"
- RAW_B64_OUT="$SCREENSHOT_TMP_DIR/${test}.raw.b64"
- {
- for entry in "${CN1SS_SOURCES[@]}"; do
- path="${entry#*:}"
- [ -s "$path" ] || continue
- count="$(cn1ss_count_chunks "$path" "$test")"; count="${count//[^0-9]/}"; : "${count:=0}"
- if [ "$count" -gt 0 ]; then cn1ss_extract_base64 "$path" "$test"; fi
- done
- } > "$RAW_B64_OUT" 2>/dev/null || true
- if [ -s "$RAW_B64_OUT" ]; then
- head -c 64 "$RAW_B64_OUT" | sed 's/^/[CN1SS-B64-HEAD] /'
- ri_log "Partial base64 saved at: $RAW_B64_OUT"
- fi
- exit 12
+ if [ "$found" -eq 0 ]; then
+ ri_log "No PNG/JPEG attachments found in exported results"
fi
-done
-
-lookup_test_output() {
- local key="$1" entry prefix
- for entry in "${TEST_OUTPUT_ENTRIES[@]}"; do
- prefix="${entry%%$PAIR_SEP*}"
- if [ "$prefix" = "$key" ]; then
- echo "${entry#*$PAIR_SEP}"
- return 0
- fi
- done
- return 1
}
-COMPARE_ARGS=()
-for test in "${TEST_NAMES[@]}"; do
- if dest="$(lookup_test_output "$test")"; then
- [ -n "$dest" ] || continue
- COMPARE_ARGS+=("--actual" "${test}=${dest}")
- fi
-done
-
-COMPARE_JSON="$SCREENSHOT_TMP_DIR/screenshot-compare.json"
-export CN1SS_PREVIEW_DIR="$SCREENSHOT_PREVIEW_DIR"
-ri_log "STAGE:COMPARE -> Evaluating screenshots against stored references"
-if ! cn1ss_java_run "$PROCESS_SCREENSHOTS_CLASS" \
- --reference-dir "$SCREENSHOT_REF_DIR" \
- --emit-base64 \
- --preview-dir "$SCREENSHOT_PREVIEW_DIR" \
- "${COMPARE_ARGS[@]}" > "$COMPARE_JSON"; then
- ri_log "FATAL: Screenshot comparison helper failed"
- exit 13
-fi
-
-SUMMARY_FILE="$SCREENSHOT_TMP_DIR/screenshot-summary.txt"
-COMMENT_FILE="$SCREENSHOT_TMP_DIR/screenshot-comment.md"
-
-ri_log "STAGE:COMMENT_BUILD -> Rendering summary and PR comment markdown"
-if ! cn1ss_java_run "$RENDER_SCREENSHOT_REPORT_CLASS" \
- --compare-json "$COMPARE_JSON" \
- --comment-out "$COMMENT_FILE" \
- --summary-out "$SUMMARY_FILE"; then
- ri_log "FATAL: Failed to render screenshot summary/comment"
- exit 14
-fi
-
-if [ -s "$SUMMARY_FILE" ]; then
- ri_log " -> Wrote summary entries to $SUMMARY_FILE ($(wc -l < "$SUMMARY_FILE" 2>/dev/null || echo 0) line(s))"
-else
- ri_log " -> No summary entries generated (all screenshots matched stored baselines)"
-fi
-
-if [ -s "$COMMENT_FILE" ]; then
- ri_log " -> Prepared PR comment payload at $COMMENT_FILE (bytes=$(wc -c < "$COMMENT_FILE" 2>/dev/null || echo 0))"
-else
- ri_log " -> No PR comment content produced"
-fi
-
-if [ -s "$SUMMARY_FILE" ]; then
- while IFS='|' read -r status test message copy_flag path preview_note; do
- [ -n "${test:-}" ] || continue
- ri_log "Test '${test}': ${message}"
- if [ "$copy_flag" = "1" ] && [ -n "${path:-}" ] && [ -f "$path" ]; then
- cp -f "$path" "$ARTIFACTS_DIR/${test}.png" 2>/dev/null || true
- ri_log " -> Stored PNG artifact copy at $ARTIFACTS_DIR/${test}.png"
- fi
- if [ "$status" = "equal" ] && [ -n "${path:-}" ]; then
- rm -f "$path" 2>/dev/null || true
- fi
- if [ -n "${preview_note:-}" ]; then
- ri_log " Preview note: ${preview_note}"
- fi
- done < "$SUMMARY_FILE"
-fi
-
-cp -f "$COMPARE_JSON" "$ARTIFACTS_DIR/screenshot-compare.json" 2>/dev/null || true
-if [ -s "$COMMENT_FILE" ]; then
- cp -f "$COMMENT_FILE" "$ARTIFACTS_DIR/screenshot-comment.md" 2>/dev/null || true
-fi
-
-ri_log "STAGE:COMMENT_POST -> Submitting PR feedback"
-comment_rc=0
-if ! cn1ss_post_pr_comment "$COMMENT_FILE" "$SCREENSHOT_PREVIEW_DIR"; then
- comment_rc=$?
-fi
+copy_exported_screenshots "$EXPORT_DIR"
-exit $comment_rc
+SUMMARY_FILE="$ARTIFACTS_DIR/screenshot-summary.txt"
+{
+ echo "iOS UI test screenshots exported on $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
+ echo "Workspace: $WORKSPACE_PATH"
+ echo "Scheme: $SCHEME"
+ echo "Simulator: ${SIM_UDID:-booted}"
+ find "$ARTIFACTS_DIR" -maxdepth 1 -type f \( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' \) -print 2>/dev/null \
+ | sed 's#.*/# - #' || true
+} > "$SUMMARY_FILE"
+ri_log "Wrote summary to $SUMMARY_FILE"
+exit 0
From e5dee3c0d342cc4ba86d591b4d8a4ae0b98a7a51 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Mon, 27 Oct 2025 18:13:07 +0200
Subject: [PATCH 02/14] Fix simulator discovery and scheme selection
---
scripts/run-ios-ui-tests.sh | 36 +++++++++++++++++++++++++++++-------
1 file changed, 29 insertions(+), 7 deletions(-)
diff --git a/scripts/run-ios-ui-tests.sh b/scripts/run-ios-ui-tests.sh
index d005aca862..05e8afa8d6 100755
--- a/scripts/run-ios-ui-tests.sh
+++ b/scripts/run-ios-ui-tests.sh
@@ -5,12 +5,25 @@ set -euo pipefail
ri_log() { echo "[run-ios-ui-tests] $1"; }
if [ $# -lt 1 ]; then
- ri_log "Usage: $0 [scheme]" >&2
+ ri_log "Usage: $0 [app_bundle] [scheme]" >&2
exit 2
fi
WORKSPACE_PATH="$1"
-REQUESTED_SCHEME="${2:-}"
+APP_BUNDLE_PATH="${2:-}"
+REQUESTED_SCHEME="${3:-}"
+
+# Backwards compatibility: if the optional app bundle argument is omitted but the
+# second parameter was historically used for the scheme, treat it as such when it
+# is not a directory path.
+if [ -n "$APP_BUNDLE_PATH" ] && [ ! -d "$APP_BUNDLE_PATH" ] && [ -z "$REQUESTED_SCHEME" ]; then
+ REQUESTED_SCHEME="$APP_BUNDLE_PATH"
+ APP_BUNDLE_PATH=""
+fi
+
+if [ -n "$APP_BUNDLE_PATH" ]; then
+ ri_log "Ignoring deprecated app bundle argument '$APP_BUNDLE_PATH'"
+fi
if [ ! -d "$WORKSPACE_PATH" ]; then
ri_log "Workspace not found at $WORKSPACE_PATH" >&2
@@ -68,17 +81,23 @@ rm -rf "$DERIVED_DATA_DIR"
find_sim_udid() {
local desired="${1:-iPhone 16}" json
- if ! json="$(xcrun simctl list devices --json)"; then
+ if ! json="$(xcrun simctl list devices --json 2>/dev/null)"; then
return 1
fi
- python3 - "$desired" <<'PY2'
-import json, sys
+ SIMCTL_JSON="$json" python3 - "$desired" <<'PY2'
+import json, os, sys
+
def normalize(name: str) -> str:
return name.strip().lower()
+
target = normalize(sys.argv[1])
-data = json.load(sys.stdin)
+try:
+ data = json.loads(os.environ.get("SIMCTL_JSON", "{}"))
+except json.JSONDecodeError:
+ sys.exit(1)
+
for runtime, devices in (data.get("devices") or {}).items():
if "iOS" not in runtime:
continue
@@ -86,8 +105,11 @@ for runtime, devices in (data.get("devices") or {}).items():
if not device.get("isAvailable"):
continue
if normalize(device.get("name", "")) == target:
- print(device.get("udid", ""))
+ udid = device.get("udid", "")
+ if udid:
+ print(udid)
sys.exit(0)
+
print("")
PY2
}
From 3140029865bdb34e1eab86d11e0564a44cd7ea67 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Oct 2025 04:25:13 +0200
Subject: [PATCH 03/14] Add legacy flag when exporting xcresult attachments
---
scripts/run-ios-ui-tests.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/scripts/run-ios-ui-tests.sh b/scripts/run-ios-ui-tests.sh
index 05e8afa8d6..37c617aef2 100755
--- a/scripts/run-ios-ui-tests.sh
+++ b/scripts/run-ios-ui-tests.sh
@@ -174,7 +174,7 @@ rm -rf "$EXPORT_DIR"
mkdir -p "$EXPORT_DIR"
ri_log "Exporting screenshot attachments from $RESULT_BUNDLE"
-if ! xcrun xcresulttool export --path "$RESULT_BUNDLE" --type file --output-path "$EXPORT_DIR" >/dev/null; then
+if ! xcrun xcresulttool export --legacy --path "$RESULT_BUNDLE" --type file --output-path "$EXPORT_DIR" >/dev/null; then
ri_log "xcresulttool export failed"
exit 11
fi
From aec2f614f7957be137b9006d38137c61f20a8e46 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Oct 2025 05:59:12 +0200
Subject: [PATCH 04/14] Recursively export xcresult screenshot attachments
---
scripts/run-ios-ui-tests.sh | 181 +++++++++++++++++++++++++++++++++++-
1 file changed, 180 insertions(+), 1 deletion(-)
diff --git a/scripts/run-ios-ui-tests.sh b/scripts/run-ios-ui-tests.sh
index 37c617aef2..085bab2f60 100755
--- a/scripts/run-ios-ui-tests.sh
+++ b/scripts/run-ios-ui-tests.sh
@@ -174,7 +174,186 @@ rm -rf "$EXPORT_DIR"
mkdir -p "$EXPORT_DIR"
ri_log "Exporting screenshot attachments from $RESULT_BUNDLE"
-if ! xcrun xcresulttool export --legacy --path "$RESULT_BUNDLE" --type file --output-path "$EXPORT_DIR" >/dev/null; then
+if ! python3 - "$RESULT_BUNDLE" "$EXPORT_DIR" <<'PY'
+import json
+import os
+import re
+import subprocess
+import sys
+from collections import deque
+
+
+def ri_log(message: str) -> None:
+ print(f"[run-ios-ui-tests] {message}", file=sys.stderr)
+
+
+def run_xcresult(args):
+ cmd = ["xcrun", "xcresulttool", *args]
+ try:
+ result = subprocess.run(
+ cmd,
+ check=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ except subprocess.CalledProcessError as exc:
+ ri_log(
+ "xcresulttool command failed: {}\n{}".format(
+ " ".join(cmd), exc.stderr.decode("utf-8", "ignore").strip()
+ )
+ )
+ raise
+ return result.stdout.decode("utf-8")
+
+
+def get_json(bundle_path, object_id=None):
+ args = ["get", "--path", bundle_path, "--format", "json"]
+ if object_id:
+ args.extend(["--id", object_id])
+ output = run_xcresult(args)
+ return json.loads(output or "{}")
+
+
+def collect_nodes(node):
+ attachments = []
+ refs = []
+ stack = [node]
+ while stack:
+ current = stack.pop()
+ if isinstance(current, dict):
+ attachment_block = current.get("attachments")
+ if isinstance(attachment_block, dict):
+ for item in attachment_block.get("_values", []):
+ if isinstance(item, dict):
+ attachments.append(item)
+ for key, value in current.items():
+ if key.endswith("Ref") and isinstance(value, dict):
+ ref_id = value.get("id")
+ if isinstance(ref_id, str) and ref_id:
+ refs.append(ref_id)
+ elif key.endswith("Refs") and isinstance(value, dict):
+ for entry in value.get("_values", []):
+ if isinstance(entry, dict):
+ ref_id = entry.get("id")
+ if isinstance(ref_id, str) and ref_id:
+ refs.append(ref_id)
+ if isinstance(value, (dict, list)):
+ stack.append(value)
+ elif isinstance(current, list):
+ stack.extend(current)
+ return attachments, refs
+
+
+def is_image_attachment(attachment):
+ uti = (attachment.get("uniformTypeIdentifier") or "").lower()
+ filename = (attachment.get("filename") or attachment.get("name") or "").lower()
+ if filename.endswith((".png", ".jpg", ".jpeg")):
+ return True
+ if "png" in uti or "jpeg" in uti:
+ return True
+ return False
+
+
+def sanitize_filename(name):
+ safe = re.sub(r"[^A-Za-z0-9_.-]", "_", name)
+ return safe or "attachment"
+
+
+def ensure_extension(name, uti):
+ base, ext = os.path.splitext(name)
+ if ext:
+ return name
+ uti = uti.lower()
+ if "png" in uti:
+ return f"{name}.png"
+ if "jpeg" in uti:
+ return f"{name}.jpg"
+ return name
+
+
+def export_attachment(bundle_path, attachment, destination_dir, used_names):
+ payload = attachment.get("payloadRef") or {}
+ attachment_id = payload.get("id")
+ if not attachment_id:
+ return
+ name = attachment.get("filename") or attachment.get("name") or attachment_id
+ name = ensure_extension(name, attachment.get("uniformTypeIdentifier") or "")
+ name = sanitize_filename(name)
+ candidate = name
+ counter = 1
+ while candidate in used_names:
+ base, ext = os.path.splitext(name)
+ candidate = f"{base}_{counter}{ext}"
+ counter += 1
+ used_names.add(candidate)
+ output_path = os.path.join(destination_dir, candidate)
+ run_xcresult(
+ [
+ "export",
+ "--legacy",
+ "--path",
+ bundle_path,
+ "--id",
+ attachment_id,
+ "--type",
+ "file",
+ "--output-path",
+ output_path,
+ ]
+ )
+ ri_log(f"Exported attachment {candidate}")
+
+
+def main():
+ if len(sys.argv) != 3:
+ ri_log("Expected bundle path and destination directory arguments")
+ return 1
+
+ bundle_path, destination_dir = sys.argv[1:3]
+ os.makedirs(destination_dir, exist_ok=True)
+
+ root = get_json(bundle_path)
+ attachments, refs = collect_nodes(root)
+ queue = deque(refs)
+ seen_refs = set()
+ seen_attachment_ids = set()
+
+ def handle_attachments(items):
+ for attachment in items:
+ payload = attachment.get("payloadRef") or {}
+ attachment_id = payload.get("id")
+ if not attachment_id or attachment_id in seen_attachment_ids:
+ continue
+ if not is_image_attachment(attachment):
+ continue
+ seen_attachment_ids.add(attachment_id)
+ export_attachment(bundle_path, attachment, destination_dir, exported_names)
+
+ exported_names = set()
+ handle_attachments(attachments)
+
+ while queue:
+ ref_id = queue.popleft()
+ if ref_id in seen_refs:
+ continue
+ seen_refs.add(ref_id)
+ data = get_json(bundle_path, ref_id)
+ items, nested_refs = collect_nodes(data)
+ handle_attachments(items)
+ for nested in nested_refs:
+ if nested not in seen_refs:
+ queue.append(nested)
+
+ if not exported_names:
+ ri_log("No screenshot attachments were exported from xcresult bundle")
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
+PY
+then
ri_log "xcresulttool export failed"
exit 11
fi
From e657cf0fed8215f6522c975327a052cec3bf248d Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Oct 2025 05:59:17 +0200
Subject: [PATCH 05/14] Extract xcresult export helper
---
scripts/ios/export_xcresult_attachments.py | 198 +++++++++++++++++++++
scripts/run-ios-ui-tests.sh | 182 +------------------
2 files changed, 200 insertions(+), 180 deletions(-)
create mode 100755 scripts/ios/export_xcresult_attachments.py
diff --git a/scripts/ios/export_xcresult_attachments.py b/scripts/ios/export_xcresult_attachments.py
new file mode 100755
index 0000000000..300d31bf08
--- /dev/null
+++ b/scripts/ios/export_xcresult_attachments.py
@@ -0,0 +1,198 @@
+#!/usr/bin/env python3
+"""Export image attachments from an xcresult bundle."""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import subprocess
+import sys
+from collections import deque
+from typing import Deque, Dict, Iterable, List, Optional, Sequence, Set, Tuple
+
+
+def ri_log(message: str) -> None:
+ """Mirror the bash script's logging prefix."""
+ print(f"[run-ios-ui-tests] {message}", file=sys.stderr)
+
+
+def run_xcresult(args: Sequence[str]) -> str:
+ cmd = ["xcrun", "xcresulttool", *args]
+ try:
+ result = subprocess.run(
+ cmd,
+ check=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ except subprocess.CalledProcessError as exc:
+ stderr = exc.stderr.decode("utf-8", "ignore").strip()
+ ri_log(f"xcresulttool command failed: {' '.join(cmd)}\n{stderr}")
+ raise
+ return result.stdout.decode("utf-8")
+
+
+def get_json(bundle_path: str, object_id: Optional[str] = None) -> Dict:
+ args = ["get", "--path", bundle_path, "--format", "json"]
+ if object_id:
+ args.extend(["--id", object_id])
+ output = run_xcresult(args)
+ return json.loads(output or "{}")
+
+
+def collect_nodes(node) -> Tuple[List[Dict], List[str]]:
+ attachments: List[Dict] = []
+ refs: List[str] = []
+ stack: List = [node]
+ while stack:
+ current = stack.pop()
+ if isinstance(current, dict):
+ attachment_block = current.get("attachments")
+ if isinstance(attachment_block, dict):
+ for item in attachment_block.get("_values", []):
+ if isinstance(item, dict):
+ attachments.append(item)
+ for key, value in current.items():
+ if key.endswith("Ref") and isinstance(value, dict):
+ ref_id = value.get("id")
+ if isinstance(ref_id, str) and ref_id:
+ refs.append(ref_id)
+ elif key.endswith("Refs") and isinstance(value, dict):
+ for entry in value.get("_values", []):
+ if isinstance(entry, dict):
+ ref_id = entry.get("id")
+ if isinstance(ref_id, str) and ref_id:
+ refs.append(ref_id)
+ if isinstance(value, (dict, list)):
+ stack.append(value)
+ elif isinstance(current, list):
+ stack.extend(current)
+ return attachments, refs
+
+
+def is_image_attachment(attachment: Dict) -> bool:
+ uti = (attachment.get("uniformTypeIdentifier") or "").lower()
+ filename = (attachment.get("filename") or attachment.get("name") or "").lower()
+ if filename.endswith((".png", ".jpg", ".jpeg")):
+ return True
+ if "png" in uti or "jpeg" in uti:
+ return True
+ return False
+
+
+def sanitize_filename(name: str) -> str:
+ safe = re.sub(r"[^A-Za-z0-9_.-]", "_", name)
+ return safe or "attachment"
+
+
+def ensure_extension(name: str, uti: str) -> str:
+ base, ext = os.path.splitext(name)
+ if ext:
+ return name
+ uti = uti.lower()
+ if "png" in uti:
+ return f"{name}.png"
+ if "jpeg" in uti:
+ return f"{name}.jpg"
+ return name
+
+
+def export_attachment(
+ bundle_path: str, attachment: Dict, destination_dir: str, used_names: Set[str]
+) -> None:
+ payload = attachment.get("payloadRef") or {}
+ attachment_id = payload.get("id")
+ if not attachment_id:
+ return
+ name = attachment.get("filename") or attachment.get("name") or attachment_id
+ name = ensure_extension(name, attachment.get("uniformTypeIdentifier") or "")
+ name = sanitize_filename(name)
+ candidate = name
+ counter = 1
+ while candidate in used_names:
+ base, ext = os.path.splitext(name)
+ candidate = f"{base}_{counter}{ext}"
+ counter += 1
+ used_names.add(candidate)
+ output_path = os.path.join(destination_dir, candidate)
+ run_xcresult(
+ [
+ "export",
+ "--legacy",
+ "--path",
+ bundle_path,
+ "--id",
+ attachment_id,
+ "--type",
+ "file",
+ "--output-path",
+ output_path,
+ ]
+ )
+ ri_log(f"Exported attachment {candidate}")
+
+
+def handle_attachments(
+ bundle_path: str,
+ items: Iterable[Dict],
+ destination_dir: str,
+ used_names: Set[str],
+ seen_attachment_ids: Set[str],
+) -> None:
+ for attachment in items:
+ payload = attachment.get("payloadRef") or {}
+ attachment_id = payload.get("id")
+ if not attachment_id or attachment_id in seen_attachment_ids:
+ continue
+ if not is_image_attachment(attachment):
+ continue
+ seen_attachment_ids.add(attachment_id)
+ export_attachment(bundle_path, attachment, destination_dir, used_names)
+
+
+def export_bundle(bundle_path: str, destination_dir: str) -> bool:
+ os.makedirs(destination_dir, exist_ok=True)
+
+ root = get_json(bundle_path)
+ attachments, refs = collect_nodes(root)
+ queue: Deque[str] = deque(refs)
+ seen_refs: Set[str] = set()
+ seen_attachment_ids: Set[str] = set()
+ exported_names: Set[str] = set()
+
+ handle_attachments(
+ bundle_path, attachments, destination_dir, exported_names, seen_attachment_ids
+ )
+
+ while queue:
+ ref_id = queue.popleft()
+ if ref_id in seen_refs:
+ continue
+ seen_refs.add(ref_id)
+ data = get_json(bundle_path, ref_id)
+ items, nested_refs = collect_nodes(data)
+ handle_attachments(
+ bundle_path, items, destination_dir, exported_names, seen_attachment_ids
+ )
+ for nested in nested_refs:
+ if nested not in seen_refs:
+ queue.append(nested)
+
+ if not exported_names:
+ ri_log("No screenshot attachments were exported from xcresult bundle")
+ return False
+ return True
+
+
+def main(argv: Sequence[str]) -> int:
+ if len(argv) != 3:
+ ri_log("Expected bundle path and destination directory arguments")
+ return 1
+ _, bundle_path, destination_dir = argv
+ success = export_bundle(bundle_path, destination_dir)
+ return 0 if success else 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv))
diff --git a/scripts/run-ios-ui-tests.sh b/scripts/run-ios-ui-tests.sh
index 085bab2f60..fc533e0748 100755
--- a/scripts/run-ios-ui-tests.sh
+++ b/scripts/run-ios-ui-tests.sh
@@ -174,186 +174,8 @@ rm -rf "$EXPORT_DIR"
mkdir -p "$EXPORT_DIR"
ri_log "Exporting screenshot attachments from $RESULT_BUNDLE"
-if ! python3 - "$RESULT_BUNDLE" "$EXPORT_DIR" <<'PY'
-import json
-import os
-import re
-import subprocess
-import sys
-from collections import deque
-
-
-def ri_log(message: str) -> None:
- print(f"[run-ios-ui-tests] {message}", file=sys.stderr)
-
-
-def run_xcresult(args):
- cmd = ["xcrun", "xcresulttool", *args]
- try:
- result = subprocess.run(
- cmd,
- check=True,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- )
- except subprocess.CalledProcessError as exc:
- ri_log(
- "xcresulttool command failed: {}\n{}".format(
- " ".join(cmd), exc.stderr.decode("utf-8", "ignore").strip()
- )
- )
- raise
- return result.stdout.decode("utf-8")
-
-
-def get_json(bundle_path, object_id=None):
- args = ["get", "--path", bundle_path, "--format", "json"]
- if object_id:
- args.extend(["--id", object_id])
- output = run_xcresult(args)
- return json.loads(output or "{}")
-
-
-def collect_nodes(node):
- attachments = []
- refs = []
- stack = [node]
- while stack:
- current = stack.pop()
- if isinstance(current, dict):
- attachment_block = current.get("attachments")
- if isinstance(attachment_block, dict):
- for item in attachment_block.get("_values", []):
- if isinstance(item, dict):
- attachments.append(item)
- for key, value in current.items():
- if key.endswith("Ref") and isinstance(value, dict):
- ref_id = value.get("id")
- if isinstance(ref_id, str) and ref_id:
- refs.append(ref_id)
- elif key.endswith("Refs") and isinstance(value, dict):
- for entry in value.get("_values", []):
- if isinstance(entry, dict):
- ref_id = entry.get("id")
- if isinstance(ref_id, str) and ref_id:
- refs.append(ref_id)
- if isinstance(value, (dict, list)):
- stack.append(value)
- elif isinstance(current, list):
- stack.extend(current)
- return attachments, refs
-
-
-def is_image_attachment(attachment):
- uti = (attachment.get("uniformTypeIdentifier") or "").lower()
- filename = (attachment.get("filename") or attachment.get("name") or "").lower()
- if filename.endswith((".png", ".jpg", ".jpeg")):
- return True
- if "png" in uti or "jpeg" in uti:
- return True
- return False
-
-
-def sanitize_filename(name):
- safe = re.sub(r"[^A-Za-z0-9_.-]", "_", name)
- return safe or "attachment"
-
-
-def ensure_extension(name, uti):
- base, ext = os.path.splitext(name)
- if ext:
- return name
- uti = uti.lower()
- if "png" in uti:
- return f"{name}.png"
- if "jpeg" in uti:
- return f"{name}.jpg"
- return name
-
-
-def export_attachment(bundle_path, attachment, destination_dir, used_names):
- payload = attachment.get("payloadRef") or {}
- attachment_id = payload.get("id")
- if not attachment_id:
- return
- name = attachment.get("filename") or attachment.get("name") or attachment_id
- name = ensure_extension(name, attachment.get("uniformTypeIdentifier") or "")
- name = sanitize_filename(name)
- candidate = name
- counter = 1
- while candidate in used_names:
- base, ext = os.path.splitext(name)
- candidate = f"{base}_{counter}{ext}"
- counter += 1
- used_names.add(candidate)
- output_path = os.path.join(destination_dir, candidate)
- run_xcresult(
- [
- "export",
- "--legacy",
- "--path",
- bundle_path,
- "--id",
- attachment_id,
- "--type",
- "file",
- "--output-path",
- output_path,
- ]
- )
- ri_log(f"Exported attachment {candidate}")
-
-
-def main():
- if len(sys.argv) != 3:
- ri_log("Expected bundle path and destination directory arguments")
- return 1
-
- bundle_path, destination_dir = sys.argv[1:3]
- os.makedirs(destination_dir, exist_ok=True)
-
- root = get_json(bundle_path)
- attachments, refs = collect_nodes(root)
- queue = deque(refs)
- seen_refs = set()
- seen_attachment_ids = set()
-
- def handle_attachments(items):
- for attachment in items:
- payload = attachment.get("payloadRef") or {}
- attachment_id = payload.get("id")
- if not attachment_id or attachment_id in seen_attachment_ids:
- continue
- if not is_image_attachment(attachment):
- continue
- seen_attachment_ids.add(attachment_id)
- export_attachment(bundle_path, attachment, destination_dir, exported_names)
-
- exported_names = set()
- handle_attachments(attachments)
-
- while queue:
- ref_id = queue.popleft()
- if ref_id in seen_refs:
- continue
- seen_refs.add(ref_id)
- data = get_json(bundle_path, ref_id)
- items, nested_refs = collect_nodes(data)
- handle_attachments(items)
- for nested in nested_refs:
- if nested not in seen_refs:
- queue.append(nested)
-
- if not exported_names:
- ri_log("No screenshot attachments were exported from xcresult bundle")
-
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
-PY
-then
+EXPORT_HELPER="$SCRIPT_DIR/ios/export_xcresult_attachments.py"
+if ! python3 "$EXPORT_HELPER" "$RESULT_BUNDLE" "$EXPORT_DIR"; then
ri_log "xcresulttool export failed"
exit 11
fi
From 3039e2cc5bc8e80968d9892d2998eb991d8cf613 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Oct 2025 07:36:22 +0200
Subject: [PATCH 06/14] Add legacy flag to xcresult get command
---
scripts/ios/export_xcresult_attachments.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/scripts/ios/export_xcresult_attachments.py b/scripts/ios/export_xcresult_attachments.py
index 300d31bf08..2131fb01fb 100755
--- a/scripts/ios/export_xcresult_attachments.py
+++ b/scripts/ios/export_xcresult_attachments.py
@@ -34,7 +34,7 @@ def run_xcresult(args: Sequence[str]) -> str:
def get_json(bundle_path: str, object_id: Optional[str] = None) -> Dict:
- args = ["get", "--path", bundle_path, "--format", "json"]
+ args = ["get", "--legacy", "--path", bundle_path, "--format", "json"]
if object_id:
args.extend(["--id", object_id])
output = run_xcresult(args)
From b9bfafebcde92dd584715527db477204f9d5fc65 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Oct 2025 19:27:31 +0200
Subject: [PATCH 07/14] Improve xcresult attachment detection
---
scripts/ios/export_xcresult_attachments.py | 73 ++++++++++++++++++----
1 file changed, 61 insertions(+), 12 deletions(-)
diff --git a/scripts/ios/export_xcresult_attachments.py b/scripts/ios/export_xcresult_attachments.py
index 2131fb01fb..1c2467e0a9 100755
--- a/scripts/ios/export_xcresult_attachments.py
+++ b/scripts/ios/export_xcresult_attachments.py
@@ -41,6 +41,18 @@ def get_json(bundle_path: str, object_id: Optional[str] = None) -> Dict:
return json.loads(output or "{}")
+def extract_id(ref: object) -> Optional[str]:
+ if isinstance(ref, str):
+ return ref
+ if isinstance(ref, dict):
+ if "id" in ref:
+ return extract_id(ref["id"])
+ if "_value" in ref:
+ value = ref["_value"]
+ return value if isinstance(value, str) else None
+ return None
+
+
def collect_nodes(node) -> Tuple[List[Dict], List[str]]:
attachments: List[Dict] = []
refs: List[str] = []
@@ -55,13 +67,13 @@ def collect_nodes(node) -> Tuple[List[Dict], List[str]]:
attachments.append(item)
for key, value in current.items():
if key.endswith("Ref") and isinstance(value, dict):
- ref_id = value.get("id")
+ ref_id = extract_id(value)
if isinstance(ref_id, str) and ref_id:
refs.append(ref_id)
elif key.endswith("Refs") and isinstance(value, dict):
for entry in value.get("_values", []):
if isinstance(entry, dict):
- ref_id = entry.get("id")
+ ref_id = extract_id(entry)
if isinstance(ref_id, str) and ref_id:
refs.append(ref_id)
if isinstance(value, (dict, list)):
@@ -71,14 +83,41 @@ def collect_nodes(node) -> Tuple[List[Dict], List[str]]:
return attachments, refs
-def is_image_attachment(attachment: Dict) -> bool:
- uti = (attachment.get("uniformTypeIdentifier") or "").lower()
- filename = (attachment.get("filename") or attachment.get("name") or "").lower()
- if filename.endswith((".png", ".jpg", ".jpeg")):
+def _looks_like_image(value: Optional[str]) -> bool:
+ if not value:
+ return False
+ lower = value.lower()
+ if lower.endswith((".png", ".jpg", ".jpeg")):
return True
- if "png" in uti or "jpeg" in uti:
+ keywords = ("png", "jpeg", "image", "screenshot")
+ return any(keyword in lower for keyword in keywords)
+
+
+def is_image_attachment(attachment: Dict) -> bool:
+ filename = attachment.get("filename") or attachment.get("name")
+ if _looks_like_image(filename):
return True
- return False
+
+ uti_candidates = [
+ attachment.get("uniformTypeIdentifier"),
+ attachment.get("contentType"),
+ attachment.get("uti"),
+ ]
+ payload = (
+ attachment.get("payloadRef")
+ or attachment.get("inlinePayloadRef")
+ or attachment.get("payload")
+ )
+ if isinstance(payload, dict):
+ uti_candidates.extend(
+ [
+ payload.get("contentType"),
+ payload.get("uti"),
+ payload.get("uniformTypeIdentifier"),
+ ]
+ )
+
+ return any(_looks_like_image(candidate) for candidate in uti_candidates)
def sanitize_filename(name: str) -> str:
@@ -101,8 +140,13 @@ def ensure_extension(name: str, uti: str) -> str:
def export_attachment(
bundle_path: str, attachment: Dict, destination_dir: str, used_names: Set[str]
) -> None:
- payload = attachment.get("payloadRef") or {}
- attachment_id = payload.get("id")
+ payload = (
+ attachment.get("payloadRef")
+ or attachment.get("inlinePayloadRef")
+ or attachment.get("payload")
+ or {}
+ )
+ attachment_id = extract_id(payload)
if not attachment_id:
return
name = attachment.get("filename") or attachment.get("name") or attachment_id
@@ -141,8 +185,13 @@ def handle_attachments(
seen_attachment_ids: Set[str],
) -> None:
for attachment in items:
- payload = attachment.get("payloadRef") or {}
- attachment_id = payload.get("id")
+ payload = (
+ attachment.get("payloadRef")
+ or attachment.get("inlinePayloadRef")
+ or attachment.get("payload")
+ or {}
+ )
+ attachment_id = extract_id(payload)
if not attachment_id or attachment_id in seen_attachment_ids:
continue
if not is_image_attachment(attachment):
From 83f81471800f25ecd82df5d0377f8e3c44666269 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Tue, 28 Oct 2025 20:54:05 +0200
Subject: [PATCH 08/14] Handle non-JSON xcresult references gracefully
---
scripts/ios/export_xcresult_attachments.py | 39 +++++++++++++++-------
1 file changed, 27 insertions(+), 12 deletions(-)
diff --git a/scripts/ios/export_xcresult_attachments.py b/scripts/ios/export_xcresult_attachments.py
index 1c2467e0a9..274f355a45 100755
--- a/scripts/ios/export_xcresult_attachments.py
+++ b/scripts/ios/export_xcresult_attachments.py
@@ -17,19 +17,25 @@ def ri_log(message: str) -> None:
print(f"[run-ios-ui-tests] {message}", file=sys.stderr)
-def run_xcresult(args: Sequence[str]) -> str:
+def run_xcresult(args: Sequence[str], allow_failure: bool = False) -> Optional[str]:
cmd = ["xcrun", "xcresulttool", *args]
- try:
- result = subprocess.run(
+ result = subprocess.run(
+ cmd,
+ check=False,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ if result.returncode != 0:
+ stderr = result.stderr.decode("utf-8", "ignore").strip()
+ ri_log(f"xcresulttool command failed: {' '.join(cmd)}\n{stderr}")
+ if allow_failure:
+ return None
+ raise subprocess.CalledProcessError(
+ result.returncode,
cmd,
- check=True,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
+ output=result.stdout,
+ stderr=result.stderr,
)
- except subprocess.CalledProcessError as exc:
- stderr = exc.stderr.decode("utf-8", "ignore").strip()
- ri_log(f"xcresulttool command failed: {' '.join(cmd)}\n{stderr}")
- raise
return result.stdout.decode("utf-8")
@@ -37,8 +43,17 @@ def get_json(bundle_path: str, object_id: Optional[str] = None) -> Dict:
args = ["get", "--legacy", "--path", bundle_path, "--format", "json"]
if object_id:
args.extend(["--id", object_id])
- output = run_xcresult(args)
- return json.loads(output or "{}")
+ output = run_xcresult(args, allow_failure=True)
+ if not output:
+ return {}
+ try:
+ return json.loads(output)
+ except json.JSONDecodeError:
+ ri_log(
+ "Failed to decode xcresult JSON payload; "
+ "continuing without structured data"
+ )
+ return {}
def extract_id(ref: object) -> Optional[str]:
From 80a99c19d66f2ac47790a44cd07acbbbbd6f5504 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Oct 2025 20:05:25 +0200
Subject: [PATCH 09/14] Normalize xcresult metadata fields to strings
---
scripts/ios/export_xcresult_attachments.py | 43 +++++++++++++++-------
1 file changed, 29 insertions(+), 14 deletions(-)
diff --git a/scripts/ios/export_xcresult_attachments.py b/scripts/ios/export_xcresult_attachments.py
index 274f355a45..5270028adb 100755
--- a/scripts/ios/export_xcresult_attachments.py
+++ b/scripts/ios/export_xcresult_attachments.py
@@ -98,10 +98,22 @@ def collect_nodes(node) -> Tuple[List[Dict], List[str]]:
return attachments, refs
-def _looks_like_image(value: Optional[str]) -> bool:
- if not value:
+def _as_text(value) -> Optional[str]:
+ if isinstance(value, str):
+ return value
+ if isinstance(value, dict):
+ for key in ("_value", "value", "rawValue", "string", "text"):
+ candidate = value.get(key)
+ if isinstance(candidate, str):
+ return candidate
+ return None
+
+
+def _looks_like_image(value) -> bool:
+ text = _as_text(value)
+ if not text:
return False
- lower = value.lower()
+ lower = text.lower()
if lower.endswith((".png", ".jpg", ".jpeg")):
return True
keywords = ("png", "jpeg", "image", "screenshot")
@@ -109,14 +121,14 @@ def _looks_like_image(value: Optional[str]) -> bool:
def is_image_attachment(attachment: Dict) -> bool:
- filename = attachment.get("filename") or attachment.get("name")
+ filename = _as_text(attachment.get("filename") or attachment.get("name"))
if _looks_like_image(filename):
return True
uti_candidates = [
- attachment.get("uniformTypeIdentifier"),
- attachment.get("contentType"),
- attachment.get("uti"),
+ _as_text(attachment.get("uniformTypeIdentifier")),
+ _as_text(attachment.get("contentType")),
+ _as_text(attachment.get("uti")),
]
payload = (
attachment.get("payloadRef")
@@ -126,9 +138,9 @@ def is_image_attachment(attachment: Dict) -> bool:
if isinstance(payload, dict):
uti_candidates.extend(
[
- payload.get("contentType"),
- payload.get("uti"),
- payload.get("uniformTypeIdentifier"),
+ _as_text(payload.get("contentType")),
+ _as_text(payload.get("uti")),
+ _as_text(payload.get("uniformTypeIdentifier")),
]
)
@@ -140,11 +152,11 @@ def sanitize_filename(name: str) -> str:
return safe or "attachment"
-def ensure_extension(name: str, uti: str) -> str:
+def ensure_extension(name: str, uti: Optional[str]) -> str:
base, ext = os.path.splitext(name)
if ext:
return name
- uti = uti.lower()
+ uti = (uti or "").lower()
if "png" in uti:
return f"{name}.png"
if "jpeg" in uti:
@@ -164,8 +176,11 @@ def export_attachment(
attachment_id = extract_id(payload)
if not attachment_id:
return
- name = attachment.get("filename") or attachment.get("name") or attachment_id
- name = ensure_extension(name, attachment.get("uniformTypeIdentifier") or "")
+ name = (
+ _as_text(attachment.get("filename") or attachment.get("name"))
+ or attachment_id
+ )
+ name = ensure_extension(name, _as_text(attachment.get("uniformTypeIdentifier")))
name = sanitize_filename(name)
candidate = name
counter = 1
From d83930b54483b9973ca435f9eafb8b76a40b93a4 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 29 Oct 2025 20:05:33 +0200
Subject: [PATCH 10/14] Guard xcresult attachment metadata lookups
---
scripts/ios/export_xcresult_attachments.py | 44 +++++++++++++---------
1 file changed, 26 insertions(+), 18 deletions(-)
diff --git a/scripts/ios/export_xcresult_attachments.py b/scripts/ios/export_xcresult_attachments.py
index 5270028adb..cb949dc646 100755
--- a/scripts/ios/export_xcresult_attachments.py
+++ b/scripts/ios/export_xcresult_attachments.py
@@ -120,29 +120,37 @@ def _looks_like_image(value) -> bool:
return any(keyword in lower for keyword in keywords)
+def _first_text(mapping: Optional[Dict], keys: Sequence[str]) -> Optional[str]:
+ if not isinstance(mapping, dict):
+ return None
+ for key in keys:
+ if key in mapping:
+ text = _as_text(mapping.get(key))
+ if text:
+ return text
+ return None
+
+
def is_image_attachment(attachment: Dict) -> bool:
- filename = _as_text(attachment.get("filename") or attachment.get("name"))
+ filename = _first_text(attachment, ["filename", "name"])
if _looks_like_image(filename):
return True
- uti_candidates = [
- _as_text(attachment.get("uniformTypeIdentifier")),
- _as_text(attachment.get("contentType")),
- _as_text(attachment.get("uti")),
- ]
+ uti_candidates: List[str] = []
+ for key in ("uniformTypeIdentifier", "contentType", "uti"):
+ text = _as_text(attachment.get(key))
+ if text:
+ uti_candidates.append(text)
payload = (
attachment.get("payloadRef")
or attachment.get("inlinePayloadRef")
or attachment.get("payload")
)
if isinstance(payload, dict):
- uti_candidates.extend(
- [
- _as_text(payload.get("contentType")),
- _as_text(payload.get("uti")),
- _as_text(payload.get("uniformTypeIdentifier")),
- ]
- )
+ for key in ("contentType", "uti", "uniformTypeIdentifier"):
+ text = _as_text(payload.get(key))
+ if text:
+ uti_candidates.append(text)
return any(_looks_like_image(candidate) for candidate in uti_candidates)
@@ -176,11 +184,11 @@ def export_attachment(
attachment_id = extract_id(payload)
if not attachment_id:
return
- name = (
- _as_text(attachment.get("filename") or attachment.get("name"))
- or attachment_id
- )
- name = ensure_extension(name, _as_text(attachment.get("uniformTypeIdentifier")))
+ name = _first_text(attachment, ["filename", "name"]) or attachment_id
+ uti_hint = _first_text(attachment, ["uniformTypeIdentifier", "contentType", "uti"])
+ if not uti_hint and isinstance(payload, dict):
+ uti_hint = _first_text(payload, ["uniformTypeIdentifier", "contentType", "uti"])
+ name = ensure_extension(name, uti_hint)
name = sanitize_filename(name)
candidate = name
counter = 1
From 5fe915a5ca7fe983219fdae8cdce76fe8093561a Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Thu, 30 Oct 2025 16:14:51 +0200
Subject: [PATCH 11/14] Force CI scheme and bundle for iOS UI tests
---
.../ios/tests/HelloCodenameOneUITests.swift.tmpl | 3 ++-
scripts/run-ios-ui-tests.sh | 15 ++++++++-------
2 files changed, 10 insertions(+), 8 deletions(-)
diff --git a/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl b/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl
index 5f731a7a84..bed8de2a86 100644
--- a/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl
+++ b/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl
@@ -6,12 +6,13 @@ final class HelloCodenameOneUITests: XCTestCase {
private var outputDirectory: URL!
private let chunkSize = 2000
private let previewChannel = "PREVIEW"
+ private let appBundleIdentifier = "com.codenameone.examples.HelloCodenameOne"
private let previewQualities: [CGFloat] = [0.60, 0.50, 0.40, 0.35, 0.30, 0.25, 0.20, 0.18, 0.16, 0.14, 0.12, 0.10, 0.08, 0.06, 0.05, 0.04, 0.03, 0.02, 0.01]
private let maxPreviewBytes = 20 * 1024
override func setUpWithError() throws {
continueAfterFailure = false
- app = XCUIApplication()
+ app = XCUIApplication(bundleIdentifier: appBundleIdentifier)
// Locale for determinism
app.launchArguments += ["-AppleLocale", "en_US", "-AppleLanguages", "(en)"]
diff --git a/scripts/run-ios-ui-tests.sh b/scripts/run-ios-ui-tests.sh
index fc533e0748..41df2b7571 100755
--- a/scripts/run-ios-ui-tests.sh
+++ b/scripts/run-ios-ui-tests.sh
@@ -64,14 +64,15 @@ ARTIFACTS_DIR="${ARTIFACTS_DIR:-${GITHUB_WORKSPACE:-$REPO_ROOT}/artifacts}"
mkdir -p "$ARTIFACTS_DIR"
TEST_LOG="$ARTIFACTS_DIR/xcodebuild-test.log"
+DEFAULT_SCHEME="HelloCodenameOne-CI"
if [ -z "$REQUESTED_SCHEME" ]; then
- if [[ "$WORKSPACE_PATH" == *.xcworkspace ]]; then
- REQUESTED_SCHEME="$(basename "$WORKSPACE_PATH" .xcworkspace)"
- else
- REQUESTED_SCHEME="$(basename "$WORKSPACE_PATH")"
- fi
+ SCHEME="$DEFAULT_SCHEME"
+elif [ "$REQUESTED_SCHEME" != "$DEFAULT_SCHEME" ]; then
+ ri_log "Ignoring requested scheme '$REQUESTED_SCHEME'; forcing $DEFAULT_SCHEME"
+ SCHEME="$DEFAULT_SCHEME"
+else
+ SCHEME="$DEFAULT_SCHEME"
fi
-SCHEME="$REQUESTED_SCHEME"
ri_log "Using scheme $SCHEME"
SCREENSHOT_TMP_DIR="$(mktemp -d "${TMPDIR}/cn1-ios-tests-XXXXXX" 2>/dev/null || echo "${TMPDIR}/cn1-ios-tests")"
@@ -145,7 +146,7 @@ fi
ri_log "Running UI tests on destination '$SIM_DESTINATION'"
-UI_TEST_TARGET="${UI_TEST_TARGET:-HelloCodenameOneUITests}"
+UI_TEST_TARGET="HelloCodenameOneUITests"
XCODE_TEST_FILTERS=(
-only-testing:"${UI_TEST_TARGET}"
-skip-testing:HelloCodenameOneTests
From 300712a5efa52accbdaf0d4fbacb9c4b696731ee Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Thu, 30 Oct 2025 17:22:55 +0200
Subject: [PATCH 12/14] Stabilize iOS UI test bundle selection
---
docs/demos/pom.xml | 4 +-
.../tests/HelloCodenameOneUITests.swift.tmpl | 44 +++++++++++++++++--
scripts/run-ios-ui-tests.sh | 4 ++
3 files changed, 46 insertions(+), 6 deletions(-)
diff --git a/docs/demos/pom.xml b/docs/demos/pom.xml
index 68ddde0e1e..0170ecd626 100644
--- a/docs/demos/pom.xml
+++ b/docs/demos/pom.xml
@@ -19,8 +19,8 @@
common
- 7.0.209
- 7.0.209
+ 7.0.208
+ 7.0.208
UTF-8
1.8
11
diff --git a/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl b/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl
index bed8de2a86..77fcd7a7c9 100644
--- a/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl
+++ b/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl
@@ -6,13 +6,13 @@ final class HelloCodenameOneUITests: XCTestCase {
private var outputDirectory: URL!
private let chunkSize = 2000
private let previewChannel = "PREVIEW"
- private let appBundleIdentifier = "com.codenameone.examples.HelloCodenameOne"
+ private let defaultAppBundleIdentifier = "com.codenameone.examples.HelloCodenameOne"
private let previewQualities: [CGFloat] = [0.60, 0.50, 0.40, 0.35, 0.30, 0.25, 0.20, 0.18, 0.16, 0.14, 0.12, 0.10, 0.08, 0.06, 0.05, 0.04, 0.03, 0.02, 0.01]
private let maxPreviewBytes = 20 * 1024
override func setUpWithError() throws {
continueAfterFailure = false
- app = XCUIApplication(bundleIdentifier: appBundleIdentifier)
+ app = makeApplication()
// Locale for determinism
app.launchArguments += ["-AppleLocale", "en_US", "-AppleLanguages", "(en)"]
@@ -28,8 +28,7 @@ final class HelloCodenameOneUITests: XCTestCase {
}
try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
- app.launch()
- waitForStableFrame()
+ launchAppAndStabilize()
}
override func tearDownWithError() throws {
@@ -77,6 +76,43 @@ final class HelloCodenameOneUITests: XCTestCase {
RunLoop.current.run(until: Date(timeIntervalSinceNow: settle))
}
+ private func launchAppAndStabilize() {
+ let originalArguments = app.launchArguments
+ let originalEnvironment = app.launchEnvironment
+ app.launch()
+ if app.state != .runningForeground {
+ let fallback = XCUIApplication()
+ fallback.launchArguments = originalArguments
+ fallback.launchEnvironment = originalEnvironment
+ fallback.launch()
+ if fallback.state == .runningForeground {
+ print("CN1SS:INFO:using_fallback_application state=\(fallback.state.rawValue)")
+ app = fallback
+ }
+ }
+ if let bundle = app.bundleIdentifier {
+ print("CN1SS:INFO:active_bundle=\(bundle) state=\(app.state.rawValue)")
+ } else {
+ print("CN1SS:INFO:active_bundle=unknown state=\(app.state.rawValue)")
+ }
+ waitForStableFrame()
+ }
+
+ private func makeApplication() -> XCUIApplication {
+ let env = ProcessInfo.processInfo.environment
+ if let identifier = preferredBundleIdentifier(from: env) {
+ return XCUIApplication(bundleIdentifier: identifier)
+ }
+ return XCUIApplication()
+ }
+
+ private func preferredBundleIdentifier(from environment: [String: String]) -> String? {
+ if let explicit = environment["CN1_APP_BUNDLE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines), !explicit.isEmpty {
+ return explicit
+ }
+ return defaultAppBundleIdentifier
+ }
+
/// Tap using normalized coordinates (0...1)
private func tapNormalized(_ dx: CGFloat, _ dy: CGFloat) {
let origin = app.coordinate(withNormalizedOffset: .zero)
diff --git a/scripts/run-ios-ui-tests.sh b/scripts/run-ios-ui-tests.sh
index 41df2b7571..f6befadc0c 100755
--- a/scripts/run-ios-ui-tests.sh
+++ b/scripts/run-ios-ui-tests.sh
@@ -65,6 +65,9 @@ mkdir -p "$ARTIFACTS_DIR"
TEST_LOG="$ARTIFACTS_DIR/xcodebuild-test.log"
DEFAULT_SCHEME="HelloCodenameOne-CI"
+DEFAULT_APP_BUNDLE_ID="com.codenameone.examples.HelloCodenameOne"
+APP_BUNDLE_ID="${CN1_APP_BUNDLE_ID:-$DEFAULT_APP_BUNDLE_ID}"
+export CN1_APP_BUNDLE_ID="$APP_BUNDLE_ID"
if [ -z "$REQUESTED_SCHEME" ]; then
SCHEME="$DEFAULT_SCHEME"
elif [ "$REQUESTED_SCHEME" != "$DEFAULT_SCHEME" ]; then
@@ -74,6 +77,7 @@ else
SCHEME="$DEFAULT_SCHEME"
fi
ri_log "Using scheme $SCHEME"
+ri_log "Targeting app bundle $APP_BUNDLE_ID"
SCREENSHOT_TMP_DIR="$(mktemp -d "${TMPDIR}/cn1-ios-tests-XXXXXX" 2>/dev/null || echo "${TMPDIR}/cn1-ios-tests")"
RESULT_BUNDLE="$SCREENSHOT_TMP_DIR/test-results.xcresult"
From 4111a02822f52693dc86301c49006555bdd5b0b1 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Thu, 30 Oct 2025 18:02:26 +0200
Subject: [PATCH 13/14] Align iOS UI tests with a single bundle identifier
---
scripts/build-ios-app.sh | 27 ++++++++--
.../tests/HelloCodenameOneUITests.swift.tmpl | 12 ++++-
scripts/run-ios-ui-tests.sh | 50 +++++++++++++++++--
3 files changed, 81 insertions(+), 8 deletions(-)
diff --git a/scripts/build-ios-app.sh b/scripts/build-ios-app.sh
index b71d06acd9..10ad8940f5 100755
--- a/scripts/build-ios-app.sh
+++ b/scripts/build-ios-app.sh
@@ -67,6 +67,7 @@ GROUP_ID="com.codenameone.examples"
ARTIFACT_ID="hello-codenameone-ios"
MAIN_NAME="HelloCodenameOne"
PACKAGE_NAME="$GROUP_ID"
+IOS_BUNDLE_ID="com.codenameone.examples.HelloCodenameOne"
SOURCE_PROJECT="$REPO_ROOT/Samples/SampleProjectTemplate"
if [ ! -d "$SOURCE_PROJECT" ]; then
@@ -125,6 +126,7 @@ set_property() {
set_property "codename1.packageName" "$PACKAGE_NAME"
set_property "codename1.mainName" "$MAIN_NAME"
+set_property "codename1.ios.appid" "$IOS_BUNDLE_ID"
# Ensure trailing newline
tail -c1 "$SETTINGS_FILE" | read -r _ || echo >> "$SETTINGS_FILE"
@@ -214,6 +216,9 @@ export XCODEPROJ
bia_log "Using Xcode project: $XCODEPROJ"
# --- Ensure UITests target + CI scheme (save_as gets a PATH, not a Project) ---
+export CN1_APP_BUNDLE_ID="${CN1_APP_BUNDLE_ID:-$IOS_BUNDLE_ID}"
+export CN1_MAIN_NAME="$MAIN_NAME"
+
ruby -rrubygems -rxcodeproj -e '
require "fileutils"
proj_path = ENV["XCODEPROJ"] or abort("XCODEPROJ env not set")
@@ -222,6 +227,21 @@ proj = Xcodeproj::Project.open(proj_path)
app_target = proj.targets.find { |t| t.product_type == "com.apple.product-type.application" } || proj.targets.first
ui_name = "HelloCodenameOneUITests"
ui_target = proj.targets.find { |t| t.name == ui_name }
+bundle_id = ENV.fetch("CN1_APP_BUNDLE_ID", "").strip
+bundle_id = "com.codenameone.examples.HelloCodenameOne" if bundle_id.empty?
+ui_bundle = "#{bundle_id}.uitests"
+main_name = ENV.fetch("CN1_MAIN_NAME", "HelloCodenameOne")
+
+if app_target
+ %w[Debug Release].each do |cfg|
+ xc = app_target.build_configuration_list[cfg]
+ next unless xc
+ bs = xc.build_settings
+ bs["PRODUCT_BUNDLE_IDENTIFIER"] = bundle_id
+ bs["PRODUCT_NAME"] ||= main_name
+ bs["INFOPLIST_KEY_CFBundleDisplayName"] ||= main_name
+ end
+end
unless ui_target
ui_target = proj.new_target(:ui_test_bundle, ui_name, :ios, "18.0")
@@ -253,9 +273,9 @@ ui_target.add_file_references([file_ref]) unless ui_target.source_build_phase.fi
bs["GENERATE_INFOPLIST_FILE"] = "YES"
bs["CODE_SIGNING_ALLOWED"] = "NO"
bs["CODE_SIGNING_REQUIRED"] = "NO"
- bs["PRODUCT_BUNDLE_IDENTIFIER"] ||= "com.codenameone.examples.uitests"
+ bs["PRODUCT_BUNDLE_IDENTIFIER"] = ui_bundle
bs["PRODUCT_NAME"] ||= ui_name
- bs["TEST_TARGET_NAME"] ||= app_target&.name || "HelloCodenameOne"
+ bs["TEST_TARGET_NAME"] ||= app_target&.name || main_name
# Optional but harmless on simulators; avoids other edge cases:
bs["ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES"] = "YES"
bs["TARGETED_DEVICE_FAMILY"] ||= "1,2"
@@ -350,10 +370,11 @@ if [ -n "${GITHUB_OUTPUT:-}" ]; then
{
echo "workspace=$WORKSPACE"
echo "scheme=$SCHEME"
+ echo "bundle_id=$CN1_APP_BUNDLE_ID"
} >> "$GITHUB_OUTPUT"
fi
-bia_log "Emitted outputs -> workspace=$WORKSPACE, scheme=$SCHEME"
+bia_log "Emitted outputs -> workspace=$WORKSPACE, scheme=$SCHEME, bundle_id=$CN1_APP_BUNDLE_ID"
# (Optional) dump xcodebuild -list for debugging
ARTIFACTS_DIR="${ARTIFACTS_DIR:-$REPO_ROOT/artifacts}"
diff --git a/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl b/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl
index 77fcd7a7c9..d69bc6b88b 100644
--- a/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl
+++ b/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl
@@ -101,9 +101,17 @@ final class HelloCodenameOneUITests: XCTestCase {
private func makeApplication() -> XCUIApplication {
let env = ProcessInfo.processInfo.environment
if let identifier = preferredBundleIdentifier(from: env) {
- return XCUIApplication(bundleIdentifier: identifier)
+ print("CN1SS:INFO:preferred_bundle=\(identifier)")
+ let configured = XCUIApplication(bundleIdentifier: identifier)
+ configured.launchEnvironment["CN1_APP_BUNDLE_ID"] = identifier
+ return configured
}
- return XCUIApplication()
+ print("CN1SS:INFO:preferred_bundle=")
+ let fallback = XCUIApplication()
+ if let inferred = env["CN1_APP_BUNDLE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines), !inferred.isEmpty {
+ fallback.launchEnvironment["CN1_APP_BUNDLE_ID"] = inferred
+ }
+ return fallback
}
private func preferredBundleIdentifier(from environment: [String: String]) -> String? {
diff --git a/scripts/run-ios-ui-tests.sh b/scripts/run-ios-ui-tests.sh
index f6befadc0c..b1eefe34fd 100755
--- a/scripts/run-ios-ui-tests.sh
+++ b/scripts/run-ios-ui-tests.sh
@@ -65,9 +65,6 @@ mkdir -p "$ARTIFACTS_DIR"
TEST_LOG="$ARTIFACTS_DIR/xcodebuild-test.log"
DEFAULT_SCHEME="HelloCodenameOne-CI"
-DEFAULT_APP_BUNDLE_ID="com.codenameone.examples.HelloCodenameOne"
-APP_BUNDLE_ID="${CN1_APP_BUNDLE_ID:-$DEFAULT_APP_BUNDLE_ID}"
-export CN1_APP_BUNDLE_ID="$APP_BUNDLE_ID"
if [ -z "$REQUESTED_SCHEME" ]; then
SCHEME="$DEFAULT_SCHEME"
elif [ "$REQUESTED_SCHEME" != "$DEFAULT_SCHEME" ]; then
@@ -77,6 +74,53 @@ else
SCHEME="$DEFAULT_SCHEME"
fi
ri_log "Using scheme $SCHEME"
+
+detect_app_bundle_id() {
+ local workspace="$1" scheme="$2"
+ python3 - "$workspace" "$scheme" <<'PY'
+import json
+import subprocess
+import sys
+
+workspace, scheme = sys.argv[1:3]
+cmd = [
+ "xcodebuild",
+ "-workspace",
+ workspace,
+ "-scheme",
+ scheme,
+ "-showBuildSettings",
+ "-json",
+]
+proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+if proc.returncode != 0:
+ sys.exit(0)
+try:
+ payload = json.loads(proc.stdout.decode("utf-8"))
+except json.JSONDecodeError:
+ sys.exit(0)
+for entry in payload:
+ target = entry.get("target") or ""
+ if target.endswith("UITests"):
+ continue
+ bundle = (entry.get("buildSettings") or {}).get("PRODUCT_BUNDLE_IDENTIFIER")
+ if bundle:
+ print(bundle)
+ break
+PY
+}
+
+DETECTED_APP_BUNDLE_ID=""
+if DETECTED_APP_BUNDLE_ID="$(detect_app_bundle_id "$WORKSPACE_PATH" "$SCHEME" 2>/dev/null)"; then
+ DETECTED_APP_BUNDLE_ID="${DETECTED_APP_BUNDLE_ID//[$'\r\n']}"
+fi
+if [ -n "$DETECTED_APP_BUNDLE_ID" ]; then
+ ri_log "Detected bundle identifier $DETECTED_APP_BUNDLE_ID from build settings"
+fi
+
+DEFAULT_APP_BUNDLE_ID="com.codenameone.examples.HelloCodenameOne"
+APP_BUNDLE_ID="${CN1_APP_BUNDLE_ID:-${IOS_APP_BUNDLE_ID:-${DETECTED_APP_BUNDLE_ID:-$DEFAULT_APP_BUNDLE_ID}}}"
+export CN1_APP_BUNDLE_ID="$APP_BUNDLE_ID"
ri_log "Targeting app bundle $APP_BUNDLE_ID"
SCREENSHOT_TMP_DIR="$(mktemp -d "${TMPDIR}/cn1-ios-tests-XXXXXX" 2>/dev/null || echo "${TMPDIR}/cn1-ios-tests")"
From c8750622363959ec72556c7dc578f5c6c38d6926 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Thu, 30 Oct 2025 19:15:35 +0200
Subject: [PATCH 14/14] Avoid using unavailable bundleIdentifier accessor
---
scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl b/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl
index d69bc6b88b..327983f501 100644
--- a/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl
+++ b/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl
@@ -4,6 +4,7 @@ import UIKit
final class HelloCodenameOneUITests: XCTestCase {
private var app: XCUIApplication!
private var outputDirectory: URL!
+ private var resolvedBundleIdentifier: String?
private let chunkSize = 2000
private let previewChannel = "PREVIEW"
private let defaultAppBundleIdentifier = "com.codenameone.examples.HelloCodenameOne"
@@ -90,10 +91,11 @@ final class HelloCodenameOneUITests: XCTestCase {
app = fallback
}
}
- if let bundle = app.bundleIdentifier {
+ if let bundle = resolvedBundleIdentifier, !bundle.isEmpty {
print("CN1SS:INFO:active_bundle=\(bundle) state=\(app.state.rawValue)")
} else {
- print("CN1SS:INFO:active_bundle=unknown state=\(app.state.rawValue)")
+ let fallbackBundle = ProcessInfo.processInfo.environment["CN1_APP_BUNDLE_ID"] ?? ""
+ print("CN1SS:INFO:active_bundle=\(fallbackBundle) state=\(app.state.rawValue)")
}
waitForStableFrame()
}
@@ -104,12 +106,16 @@ final class HelloCodenameOneUITests: XCTestCase {
print("CN1SS:INFO:preferred_bundle=\(identifier)")
let configured = XCUIApplication(bundleIdentifier: identifier)
configured.launchEnvironment["CN1_APP_BUNDLE_ID"] = identifier
+ resolvedBundleIdentifier = identifier
return configured
}
print("CN1SS:INFO:preferred_bundle=")
let fallback = XCUIApplication()
if let inferred = env["CN1_APP_BUNDLE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines), !inferred.isEmpty {
fallback.launchEnvironment["CN1_APP_BUNDLE_ID"] = inferred
+ resolvedBundleIdentifier = inferred
+ } else {
+ resolvedBundleIdentifier = nil
}
return fallback
}