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/export_xcresult_attachments.py b/scripts/ios/export_xcresult_attachments.py new file mode 100755 index 0000000000..cb949dc646 --- /dev/null +++ b/scripts/ios/export_xcresult_attachments.py @@ -0,0 +1,285 @@ +#!/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], allow_failure: bool = False) -> Optional[str]: + cmd = ["xcrun", "xcresulttool", *args] + 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, + output=result.stdout, + stderr=result.stderr, + ) + return result.stdout.decode("utf-8") + + +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, 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]: + 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] = [] + 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 = 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 = extract_id(entry) + 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 _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 = text.lower() + if lower.endswith((".png", ".jpg", ".jpeg")): + return True + keywords = ("png", "jpeg", "image", "screenshot") + 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 = _first_text(attachment, ["filename", "name"]) + if _looks_like_image(filename): + return True + + 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): + 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) + + +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: Optional[str]) -> str: + base, ext = os.path.splitext(name) + if ext: + return name + uti = (uti or "").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.get("inlinePayloadRef") + or attachment.get("payload") + or {} + ) + attachment_id = extract_id(payload) + if not attachment_id: + return + 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 + 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.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): + 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/ios/tests/HelloCodenameOneUITests.swift.tmpl b/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl index 75a90b9d10..327983f501 100644 --- a/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl +++ b/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl @@ -4,14 +4,16 @@ 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" 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 = makeApplication() // Locale for determinism app.launchArguments += ["-AppleLocale", "en_US", "-AppleLanguages", "(en)"] @@ -27,8 +29,7 @@ final class HelloCodenameOneUITests: XCTestCase { } try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true) - app.launch() - waitForStableFrame() + launchAppAndStabilize() } override func tearDownWithError() throws { @@ -37,7 +38,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,12 +53,80 @@ 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) 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 = resolvedBundleIdentifier, !bundle.isEmpty { + print("CN1SS:INFO:active_bundle=\(bundle) state=\(app.state.rawValue)") + } else { + let fallbackBundle = ProcessInfo.processInfo.environment["CN1_APP_BUNDLE_ID"] ?? "" + print("CN1SS:INFO:active_bundle=\(fallbackBundle) state=\(app.state.rawValue)") + } + waitForStableFrame() + } + + private func makeApplication() -> XCUIApplication { + let env = ProcessInfo.processInfo.environment + if let identifier = preferredBundleIdentifier(from: env) { + 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 + } + + 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 a9cc289f70..b1eefe34fd 100755 --- a/scripts/run-ios-ui-tests.sh +++ b/scripts/run-ios-ui-tests.sh @@ -1,11 +1,9 @@ #!/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 exit 2 @@ -15,55 +13,40 @@ 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. +# 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 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,126 +56,145 @@ 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" 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_REF_DIR="$SCRIPT_DIR/ios/screenshots" +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")" -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" - -# 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" - fi - ri_log "Injected CN1SS_* envs into scheme: $SCHEME_FILE" -else - ri_log "Scheme file not found for env injection: $SCHEME_FILE" -fi +DERIVED_DATA_DIR="$SCREENSHOT_TMP_DIR/derived" +rm -rf "$DERIVED_DATA_DIR" -auto_select_destination() { - if ! command -v python3 >/dev/null 2>&1; then - return +find_sim_udid() { + local desired="${1:-iPhone 16}" json + if ! json="$(xcrun simctl list devices --json 2>/dev/null)"; then + return 1 fi + SIMCTL_JSON="$json" python3 - "$desired" <<'PY2' +import json, os, sys + + +def normalize(name: str) -> str: + return name.strip().lower() + + +target = normalize(sys.argv[1]) +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 + for device in devices or []: + if not device.get("isAvailable"): + continue + if normalize(device.get("name", "")) == target: + udid = device.get("udid", "") + if udid: + print(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}" +UI_TEST_TARGET="HelloCodenameOneUITests" XCODE_TEST_FILTERS=( -only-testing:"${UI_TEST_TARGET}" -skip-testing:HelloCodenameOneTests @@ -211,158 +213,49 @@ 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}" +EXPORT_DIR="$SCREENSHOT_TMP_DIR/xcresult-export" +rm -rf "$EXPORT_DIR" +mkdir -p "$EXPORT_DIR" -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 +ri_log "Exporting screenshot attachments from $RESULT_BUNDLE" +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 -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") -fi -ri_log "Detected CN1SS test streams: ${TEST_NAMES[*]}" - -PAIR_SEP=$'\037' -declare -a TEST_OUTPUT_ENTRIES=() - -ensure_dir "$SCREENSHOT_PREVIEW_DIR" - -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 +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) + + 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