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 }