diff --git a/scripts/README.md b/scripts/README.md index 4427b304b9..7d08f75b63 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -12,21 +12,26 @@ workflows to build and validate Codename One ports. - `build-android-app.sh` / `build-ios-app.sh` – generate a "Hello Codename One" sample application and build it against the freshly compiled port. - `run-android-instrumentation-tests.sh` – launches the Android emulator, - executes instrumentation tests, and prepares screenshot reports for pull - requests. + drives the Codename One DeviceRunner suite, and prepares screenshot + reports for pull requests. +- `run-ios-ui-tests.sh` – runs the Codename One DeviceRunner suite on the iOS + simulator and emits matching screenshot reports. ## Subdirectories -- `android/` – Python helpers, baseline screenshots, and utilities that power - the Android instrumentation test workflow. - - `android/lib/` – library-style Python modules shared across Android - automation scripts. +- `android/` – Java helpers, baseline screenshots, and utilities that power the + Android DeviceRunner test workflow. + - `android/lib/` – standalone Java sources invoked directly by the shell + scripts for Gradle patching and similar tasks. - `android/tests/` – command-line tools used by CI for processing screenshots and posting feedback to pull requests. - `android/screenshots/` – reference images used when comparing emulator output. -- `templates/` – code templates consumed by the sample app builders. +- `ios/` – Helpers and screenshot baselines used by the iOS DeviceRunner + workflow. +- `device-runner-app/` – Java sources for the shared sample application and its + DeviceRunner UI tests. These scripts are designed so that shell logic focuses on orchestration, while -Python modules encapsulate the heavier data processing steps. This separation -keeps the entry points easy to follow and simplifies maintenance. +small Java utilities encapsulate the heavier data processing steps. This +separation keeps the entry points easy to follow and simplifies maintenance. diff --git a/scripts/android/screenshots/BrowserComponent.png b/scripts/android/screenshots/BrowserComponent.png index af6ce4456d..6fabe3f28e 100644 Binary files a/scripts/android/screenshots/BrowserComponent.png and b/scripts/android/screenshots/BrowserComponent.png differ diff --git a/scripts/android/screenshots/MainActivity.png b/scripts/android/screenshots/MainActivity.png index b3ade26931..413005d1a9 100644 Binary files a/scripts/android/screenshots/MainActivity.png and b/scripts/android/screenshots/MainActivity.png differ diff --git a/scripts/android/tests/Cn1ssChunkTools.java b/scripts/android/tests/Cn1ssChunkTools.java index 8386c8410e..7fd03c0ab8 100644 --- a/scripts/android/tests/Cn1ssChunkTools.java +++ b/scripts/android/tests/Cn1ssChunkTools.java @@ -14,7 +14,7 @@ public class Cn1ssChunkTools { private static final String DEFAULT_TEST_NAME = "default"; private static final String DEFAULT_CHANNEL = ""; private static final Pattern CHUNK_PATTERN = Pattern.compile( - "CN1SS(?:(?[A-Z]+))?:(?:(?[A-Za-z0-9_.-]+):)?(?\\d{6}):(?.*)"); + "CN1SS(?:(?[A-Z]+))?:(?:(?[A-Za-z0-9_.-]+):)?(?\\d{6,}):(?.*)"); public static void main(String[] args) throws Exception { if (args.length == 0) { diff --git a/scripts/android/tests/PostPrComment.java b/scripts/android/tests/PostPrComment.java index 943e50bebb..53c4612370 100644 --- a/scripts/android/tests/PostPrComment.java +++ b/scripts/android/tests/PostPrComment.java @@ -381,13 +381,28 @@ private static String readStream(java.io.InputStream stream) throws IOException } private static AttachmentReplacement replaceAttachments(String body, Map urls) { + // Build a case-insensitive lookup alongside the original map + Map urlsCI = new HashMap<>(); + for (Map.Entry e : urls.entrySet()) { + if (e.getKey() != null && e.getValue() != null) { + urlsCI.put(e.getKey().toLowerCase(), e.getValue()); + } + } + Pattern pattern = Pattern.compile("\\(attachment:([^\\)]+)\\)"); Matcher matcher = pattern.matcher(body); StringBuffer sb = new StringBuffer(); List missing = new ArrayList<>(); while (matcher.find()) { String name = matcher.group(1); + if (name != null) { + name = name.trim(); + } String url = urls.get(name); + if (url == null && name != null) { + // try case-insensitive match + url = urlsCI.get(name.toLowerCase()); + } if (url != null) { matcher.appendReplacement(sb, Matcher.quoteReplacement("(" + url + ")")); } else { diff --git a/scripts/build-android-app.sh b/scripts/build-android-app.sh index 0c2d8f6f2d..d7be421dff 100755 --- a/scripts/build-android-app.sh +++ b/scripts/build-android-app.sh @@ -82,6 +82,7 @@ rm -rf "$WORK_DIR"; mkdir -p "$WORK_DIR" GROUP_ID="com.codenameone.examples" ARTIFACT_ID="hello-codenameone" +PACKAGE_NAME="com.codenameone.examples.hellocodenameone" MAIN_NAME="HelloCodenameOne" SOURCE_PROJECT="$REPO_ROOT/Samples/SampleProjectTemplate" @@ -204,48 +205,49 @@ SETTINGS_FILE="$APP_DIR/common/codenameone_settings.properties" echo "codename1.arg.android.useAndroidX=true" >> "$SETTINGS_FILE" [ -f "$SETTINGS_FILE" ] || { ba_log "codenameone_settings.properties not found at $SETTINGS_FILE" >&2; exit 1; } -# --- Read settings --- -read_prop() { grep -E "^$1=" "$SETTINGS_FILE" | head -n1 | cut -d'=' -f2- | sed 's/^[[:space:]]*//'; } - -PACKAGE_NAME="$(read_prop 'codename1.packageName' || true)" -CURRENT_MAIN_NAME="$(read_prop 'codename1.mainName' || true)" - -if [ -z "$PACKAGE_NAME" ]; then - PACKAGE_NAME="$GROUP_ID" - ba_log "Package name not found in settings. Falling back to groupId $PACKAGE_NAME" -fi -if [ -z "$CURRENT_MAIN_NAME" ]; then - CURRENT_MAIN_NAME="$MAIN_NAME" - ba_log "Main class name not found in settings. Falling back to target $CURRENT_MAIN_NAME" -fi +set_prop() { + local key="$1" value="$2" + if grep -q "^${key}=" "$SETTINGS_FILE"; then + if sed --version >/dev/null 2>&1; then + sed -i -E "s|^${key}=.*$|${key}=${value}|" "$SETTINGS_FILE" + else + sed -i '' -E "s|^${key}=.*$|${key}=${value}|" "$SETTINGS_FILE" + fi + else + printf '\n%s=%s\n' "$key" "$value" >> "$SETTINGS_FILE" + fi +} -# --- Generate Java from external template --- +# --- Install Codename One application sources --- PACKAGE_PATH="${PACKAGE_NAME//.//}" JAVA_DIR="$APP_DIR/common/src/main/java/${PACKAGE_PATH}" mkdir -p "$JAVA_DIR" -MAIN_FILE="$JAVA_DIR/${MAIN_NAME}.java" - -TEMPLATE="$SCRIPT_DIR/templates/HelloCodenameOne.java.tmpl" -if [ ! -f "$TEMPLATE" ]; then - ba_log "Template not found: $TEMPLATE" >&2 +MAIN_FILE_SOURCE="$SCRIPT_DIR/device-runner-app/main/${MAIN_NAME}.java" +if [ ! -f "$MAIN_FILE_SOURCE" ]; then + ba_log "Sample application source not found: $MAIN_FILE_SOURCE" >&2 exit 1 fi +cp "$MAIN_FILE_SOURCE" "$JAVA_DIR/${MAIN_NAME}.java" -sed -e "s|@PACKAGE@|$PACKAGE_NAME|g" \ - -e "s|@MAIN_NAME@|$MAIN_NAME|g" \ - "$TEMPLATE" > "$MAIN_FILE" - -# --- Ensure codename1.mainName is set --- -ba_log "Setting codename1.mainName to $MAIN_NAME" -if grep -q '^codename1.mainName=' "$SETTINGS_FILE"; then - # GNU sed in CI: in-place edit without backup - sed -E -i 's|^codename1\.mainName=.*$|codename1.mainName='"$MAIN_NAME"'|' "$SETTINGS_FILE" -else - printf '\ncodename1.mainName=%s\n' "$MAIN_NAME" >> "$SETTINGS_FILE" -fi +ba_log "Setting Codename One application metadata" +set_prop "codename1.packageName" "$PACKAGE_NAME" +set_prop "codename1.mainName" "$MAIN_NAME" +# DeviceRunner integration is handled inside the copied sources, so unit test +# build mode is not required (and is unsupported for local Android builds). # Ensure trailing newline tail -c1 "$SETTINGS_FILE" | read -r _ || echo >> "$SETTINGS_FILE" +# --- Install DeviceRunner UI tests --- +TEST_SOURCE_DIR="$SCRIPT_DIR/device-runner-app/tests" +TEST_JAVA_DIR="$APP_DIR/common/src/main/java/${PACKAGE_PATH}/tests" +mkdir -p "$TEST_JAVA_DIR" +if [ ! -d "$TEST_SOURCE_DIR" ]; then + ba_log "DeviceRunner test sources not found: $TEST_SOURCE_DIR" >&2 + exit 1 +fi +cp "$TEST_SOURCE_DIR"/*.java "$TEST_JAVA_DIR"/ +ba_log "Installed DeviceRunner UI tests in $TEST_JAVA_DIR" + # --- Normalize Codename One versions (use Maven Versions Plugin) --- ba_log "Normalizing Codename One Maven coordinates to $CN1_VERSION" @@ -266,7 +268,7 @@ if [ -z "$GRADLE_PROJECT_DIR" ]; then exit 1 fi -ba_log "Configuring instrumentation test sources in $GRADLE_PROJECT_DIR" +ba_log "Normalizing Android Gradle project in $GRADLE_PROJECT_DIR" # Ensure AndroidX flags in gradle.properties # --- BEGIN: robust Gradle patch for AndroidX tests --- @@ -301,30 +303,6 @@ echo "----- app/build.gradle tail -----" tail -n 80 "$APP_BUILD_GRADLE" | sed 's/^/| /' echo "---------------------------------" -TEST_SRC_DIR="$GRADLE_PROJECT_DIR/app/src/androidTest/java/${PACKAGE_PATH}" -mkdir -p "$TEST_SRC_DIR" -TEST_CLASS="$TEST_SRC_DIR/HelloCodenameOneInstrumentedTest.java" -TEST_TEMPLATE="$SCRIPT_DIR/android/tests/HelloCodenameOneInstrumentedTest.java" - -if [ ! -f "$TEST_TEMPLATE" ]; then - ba_log "Missing instrumentation test template: $TEST_TEMPLATE" >&2 - exit 1 -fi - -sed "s|@PACKAGE@|$PACKAGE_NAME|g" "$TEST_TEMPLATE" > "$TEST_CLASS" -ba_log "Created instrumentation test at $TEST_CLASS" - -DEFAULT_ANDROID_TEST="$GRADLE_PROJECT_DIR/app/src/androidTest/java/com/example/myapplication2/ExampleInstrumentedTest.java" -if [ -f "$DEFAULT_ANDROID_TEST" ]; then - rm -f "$DEFAULT_ANDROID_TEST" - ba_log "Removed default instrumentation stub at $DEFAULT_ANDROID_TEST" - DEFAULT_ANDROID_TEST_DIR="$(dirname "$DEFAULT_ANDROID_TEST")" - DEFAULT_ANDROID_TEST_PARENT="$(dirname "$DEFAULT_ANDROID_TEST_DIR")" - rmdir "$DEFAULT_ANDROID_TEST_DIR" 2>/dev/null || true - rmdir "$DEFAULT_ANDROID_TEST_PARENT" 2>/dev/null || true - rmdir "$(dirname "$DEFAULT_ANDROID_TEST_PARENT")" 2>/dev/null || true -fi - ba_log "Invoking Gradle build in $GRADLE_PROJECT_DIR" chmod +x "$GRADLE_PROJECT_DIR/gradlew" ORIGINAL_JAVA_HOME="$JAVA_HOME" @@ -348,7 +326,7 @@ if [ -n "${GITHUB_OUTPUT:-}" ]; then { echo "gradle_project_dir=$GRADLE_PROJECT_DIR" echo "apk_path=$APK_PATH" - echo "instrumentation_test_class=$PACKAGE_NAME.HelloCodenameOneInstrumentedTest" + echo "package_name=$PACKAGE_NAME" } >> "$GITHUB_OUTPUT" ba_log "Published GitHub Actions outputs for downstream steps" fi diff --git a/scripts/build-ios-app.sh b/scripts/build-ios-app.sh index b71d06acd9..833f5d3b52 100755 --- a/scripts/build-ios-app.sh +++ b/scripts/build-ios-app.sh @@ -66,7 +66,7 @@ rm -rf "$WORK_DIR"; mkdir -p "$WORK_DIR" GROUP_ID="com.codenameone.examples" ARTIFACT_ID="hello-codenameone-ios" MAIN_NAME="HelloCodenameOne" -PACKAGE_NAME="$GROUP_ID" +PACKAGE_NAME="com.codenameone.examples.hellocodenameone" SOURCE_PROJECT="$REPO_ROOT/Samples/SampleProjectTemplate" if [ ! -d "$SOURCE_PROJECT" ]; then @@ -104,6 +104,79 @@ APP_DIR="$WORK_DIR/$ARTIFACT_ID" [ -d "$APP_DIR" ] || { bia_log "Failed to create Codename One application project" >&2; exit 1; } [ -f "$APP_DIR/build.sh" ] && chmod +x "$APP_DIR/build.sh" +# --- Normalize Codename One versions in generated iOS project POMs --- +ROOT_POM="$APP_DIR/pom.xml" +NS="mvn=http://maven.apache.org/POM/4.0.0" + +# Ensure xmlstarlet is available (macOS runners use Homebrew) +if ! command -v xmlstarlet >/dev/null 2>&1; then + if command -v brew >/dev/null 2>&1; then + brew install xmlstarlet + elif command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -y && sudo apt-get install -y xmlstarlet + else + bia_log "xmlstarlet not found and no installer available"; exit 1 + fi +fi + +# Helpers +x() { xmlstarlet ed -L -N "$NS" "$@"; } +q() { xmlstarlet sel -N "$NS" "$@"; } + +# 1) Ensure ${CN1_VERSION} +if [ "$(q -t -v 'count(/mvn:project/mvn:properties)' "$ROOT_POM" 2>/dev/null || echo 0)" = "0" ]; then + x -s "/mvn:project" -t elem -n properties -v "" "$ROOT_POM" +fi +if [ "$(q -t -v 'count(/mvn:project/mvn:properties/mvn:codenameone.version)' "$ROOT_POM" 2>/dev/null || echo 0)" = "0" ]; then + x -s "/mvn:project/mvn:properties" -t elem -n codenameone.version -v "$CN1_VERSION" "$ROOT_POM" +else + x -u "/mvn:project/mvn:properties/mvn:codenameone.version" -v "$CN1_VERSION" "$ROOT_POM" +fi + +# 2) Force the com.codenameone parent to a literal version (no property) +while IFS= read -r -d '' P; do + x -u "/mvn:project[mvn:parent/mvn:groupId='com.codenameone' and mvn:parent/mvn:artifactId='codenameone-maven-parent']/mvn:parent/mvn:version" -v "$CN1_VERSION" "$P" || true +done < <(find "$APP_DIR" -type f -name pom.xml -print0) + +# 3) Point all com.codenameone deps/plugins to ${codenameone.version} +while IFS= read -r -d '' P; do + x -u "/mvn:project//mvn:dependencies/mvn:dependency[starts-with(mvn:groupId,'com.codenameone')]/mvn:version" -v '${codenameone.version}' "$P" 2>/dev/null || true + x -u "/mvn:project//mvn:build/mvn:plugins/mvn:plugin[starts-with(mvn:groupId,'com.codenameone')]/mvn:version" -v '${codenameone.version}' "$P" 2>/dev/null || true + x -u "/mvn:project//mvn:build/mvn:pluginManagement/mvn:plugins/mvn:plugin[starts-with(mvn:groupId,'com.codenameone')]/mvn:version" -v '${codenameone.version}' "$P" 2>/dev/null || true +done < <(find "$APP_DIR" -type f -name pom.xml -print0) + +# 4) Ensure common Maven plugins have versions (helps before parent resolves) +declare -A PIN=( + [org.apache.maven.plugins:maven-compiler-plugin]=3.11.0 + [org.apache.maven.plugins:maven-resources-plugin]=3.3.1 + [org.apache.maven.plugins:maven-surefire-plugin]=3.2.5 + [org.apache.maven.plugins:maven-failsafe-plugin]=3.2.5 + [org.apache.maven.plugins:maven-jar-plugin]=3.3.0 + [org.apache.maven.plugins:maven-clean-plugin]=3.3.2 + [org.apache.maven.plugins:maven-deploy-plugin]=3.1.2 + [org.apache.maven.plugins:maven-install-plugin]=3.1.2 + [org.apache.maven.plugins:maven-assembly-plugin]=3.6.0 + [org.apache.maven.plugins:maven-site-plugin]=4.0.0-M15 + [com.codenameone:codenameone-maven-plugin]='${codenameone.version}' +) +add_version_if_missing() { + local pom="$1" g="$2" a="$3" v="$4" + if [ "$(q -t -v "count(/mvn:project/mvn:build/mvn:plugins/mvn:plugin[mvn:groupId='$g' and mvn:artifactId='$a']/mvn:version)" "$pom" 2>/dev/null || echo 0)" = "0" ] && + [ "$(q -t -v "count(/mvn:project/mvn:build/mvn:plugins/mvn:plugin[mvn:groupId='$g' and mvn:artifactId='$a'])" "$pom" 2>/dev/null || echo 0)" != "0" ]; then + x -s "/mvn:project/mvn:build/mvn:plugins/mvn:plugin[mvn:groupId='$g' and mvn:artifactId='$a']" -t elem -n version -v "$v" "$pom" || true + fi + if [ "$(q -t -v "count(/mvn:project/mvn:build/mvn:pluginManagement/mvn:plugins/mvn:plugin[mvn:groupId='$g' and mvn:artifactId='$a']/mvn:version)" "$pom" 2>/dev/null || echo 0)" = "0" ] && + [ "$(q -t -v "count(/mvn:project/mvn:build/mvn:pluginManagement/mvn:plugins/mvn:plugin[mvn:groupId='$g' and mvn:artifactId='$a'])" "$pom" 2>/dev/null || echo 0)" != "0" ]; then + x -s "/mvn:project/mvn:build/mvn:pluginManagement/mvn:plugins/mvn:plugin[mvn:groupId='$g' and mvn:artifactId='$a']" -t elem -n version -v "$v" "$pom" || true + fi +} +while IFS= read -r -d '' P; do + for ga in "${!PIN[@]}"; do add_version_if_missing "$P" "${ga%%:*}" "${ga##*:}" "${PIN[$ga]}"; done +done < <(find "$APP_DIR" -type f -name pom.xml -print0) + +# 5) Also pass the property when building (already present below) +EXTRA_MVN_ARGS+=("-Dcodenameone.version=${CN1_VERSION}") + SETTINGS_FILE="$APP_DIR/common/codenameone_settings.properties" if [ ! -f "$SETTINGS_FILE" ]; then bia_log "codenameone_settings.properties not found at $SETTINGS_FILE" >&2 @@ -132,16 +205,23 @@ tail -c1 "$SETTINGS_FILE" | read -r _ || echo >> "$SETTINGS_FILE" PACKAGE_PATH="${PACKAGE_NAME//.//}" JAVA_DIR="$APP_DIR/common/src/main/java/${PACKAGE_PATH}" mkdir -p "$JAVA_DIR" -MAIN_FILE="$JAVA_DIR/${MAIN_NAME}.java" -TEMPLATE="$SCRIPT_DIR/templates/HelloCodenameOne.java.tmpl" -if [ ! -f "$TEMPLATE" ]; then - bia_log "Template not found: $TEMPLATE" >&2 +MAIN_FILE_SOURCE="$SCRIPT_DIR/device-runner-app/main/${MAIN_NAME}.java" +if [ ! -f "$MAIN_FILE_SOURCE" ]; then + bia_log "Sample application source not found: $MAIN_FILE_SOURCE" >&2 exit 1 fi -sed -e "s|@PACKAGE@|$PACKAGE_NAME|g" \ - -e "s|@MAIN_NAME@|$MAIN_NAME|g" \ - "$TEMPLATE" > "$MAIN_FILE" -bia_log "Wrote main application class to $MAIN_FILE" +cp "$MAIN_FILE_SOURCE" "$JAVA_DIR/${MAIN_NAME}.java" +bia_log "Wrote main application class to $JAVA_DIR/${MAIN_NAME}.java" + +TEST_SOURCE_DIR="$SCRIPT_DIR/device-runner-app/tests" +TEST_JAVA_DIR="$APP_DIR/common/src/main/java/${PACKAGE_PATH}/tests" +mkdir -p "$TEST_JAVA_DIR" +if [ ! -d "$TEST_SOURCE_DIR" ]; then + bia_log "DeviceRunner test sources not found: $TEST_SOURCE_DIR" >&2 + exit 1 +fi +cp "$TEST_SOURCE_DIR"/*.java "$TEST_JAVA_DIR"/ +bia_log "Installed DeviceRunner UI tests in $TEST_JAVA_DIR" # --- Build iOS project (ios-source) --- DERIVED_DATA_DIR="${TMPDIR}/codenameone-ios-derived" @@ -178,138 +258,6 @@ if [ -z "$PROJECT_DIR" ]; then fi bia_log "Found generated iOS project at $PROJECT_DIR" -# --- Ensure a real UITest source file exists on disk --- -UITEST_TEMPLATE="$SCRIPT_DIR/ios/tests/HelloCodenameOneUITests.swift.tmpl" -UITEST_DIR="$PROJECT_DIR/HelloCodenameOneUITests" -UITEST_SWIFT="$UITEST_DIR/HelloCodenameOneUITests.swift" -if [ -f "$UITEST_TEMPLATE" ]; then - mkdir -p "$UITEST_DIR" - cp -f "$UITEST_TEMPLATE" "$UITEST_SWIFT" - bia_log "Installed UITest source: $UITEST_SWIFT" -else - bia_log "UITest template missing at $UITEST_TEMPLATE"; exit 1 -fi - -# --- Ruby/gem environment (xcodeproj) --- -if ! command -v ruby >/dev/null; then - bia_log "ruby not found on PATH"; exit 1 -fi -USER_GEM_BIN="$(ruby -e 'print Gem.user_dir')/bin" -export PATH="$USER_GEM_BIN:$PATH" -if ! ruby -rrubygems -e 'exit(Gem::Specification.find_all_by_name("xcodeproj").empty? ? 1 : 0)'; then - bia_log "Installing xcodeproj gem for current ruby" - gem install xcodeproj --no-document --user-install -fi -ruby -rrubygems -e 'abort("xcodeproj gem still missing") if Gem::Specification.find_all_by_name("xcodeproj").empty?' - -# --- Locate the .xcodeproj and pass its path to Ruby --- -XCODEPROJ="$PROJECT_DIR/HelloCodenameOne.xcodeproj" -if [ ! -d "$XCODEPROJ" ]; then - XCODEPROJ="$(/bin/ls -1d "$PROJECT_DIR"/*.xcodeproj 2>/dev/null | head -n1 || true)" -fi -if [ -z "$XCODEPROJ" ] || [ ! -d "$XCODEPROJ" ]; then - bia_log "Failed to locate .xcodeproj under $PROJECT_DIR"; exit 1 -fi -export XCODEPROJ -bia_log "Using Xcode project: $XCODEPROJ" - -# --- Ensure UITests target + CI scheme (save_as gets a PATH, not a Project) --- -ruby -rrubygems -rxcodeproj -e ' -require "fileutils" -proj_path = ENV["XCODEPROJ"] or abort("XCODEPROJ env not set") -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 } - -unless ui_target - ui_target = proj.new_target(:ui_test_bundle, ui_name, :ios, "18.0") - ui_target.product_reference.name = "#{ui_name}.xctest" - ui_target.add_dependency(app_target) if app_target -end - - -# Ensure a group and file reference exist, then add to the UITest target -proj_dir = File.dirname(proj_path) -ui_dir = File.join(proj_dir, ui_name) -ui_file = File.join(ui_dir, "#{ui_name}.swift") -ui_group = proj.main_group.find_subpath(ui_name, true) -ui_group.set_source_tree("") -file_ref = ui_group.files.find { |f| File.expand_path(f.path, proj_dir) == ui_file } -file_ref ||= ui_group.new_file(ui_file) -ui_target.add_file_references([file_ref]) unless ui_target.source_build_phase.files_references.include?(file_ref) - -# -# Required settings so Xcode creates a non-empty .xctest and a proper "-Runner.app" -# PRODUCT_NAME feeds the bundle name; TEST_TARGET_NAME feeds the runner name. -# We also keep signing off and auto-Info.plist for simulator CI. -# -%w[Debug Release].each do |cfg| - xc = ui_target.build_configuration_list[cfg] - next unless xc - bs = xc.build_settings - bs["SWIFT_VERSION"] = "5.0" - 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_NAME"] ||= ui_name - bs["TEST_TARGET_NAME"] ||= app_target&.name || "HelloCodenameOne" - # Optional but harmless on simulators; avoids other edge cases: - bs["ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES"] = "YES" - bs["TARGETED_DEVICE_FAMILY"] ||= "1,2" -end - -proj.save - -ws_dir = File.join(File.dirname(proj_path), "HelloCodenameOne.xcworkspace") -schemes_root = if File.directory?(ws_dir) - File.join(ws_dir, "xcshareddata", "xcschemes") -else - File.join(File.dirname(proj_path), "xcshareddata", "xcschemes") -end -FileUtils.mkdir_p(schemes_root) - -scheme = Xcodeproj::XCScheme.new -scheme.build_action.entries = [] -scheme.add_build_target(app_target) if app_target -scheme.test_action = Xcodeproj::XCScheme::TestAction.new -scheme.test_action.xml_element.elements.delete_all("EnvironmentVariables") -envs = Xcodeproj::XCScheme::EnvironmentVariables.new -envs.assign_variable(key: "CN1SS_OUTPUT_DIR", value: "__CN1SS_OUTPUT_DIR__", enabled: true) -envs.assign_variable(key: "CN1SS_PREVIEW_DIR", value: "__CN1SS_PREVIEW_DIR__", enabled: true) -scheme.test_action.environment_variables = envs -scheme.test_action.xml_element.elements.delete_all("Testables") -scheme.add_test_target(ui_target) -scheme.launch_action.build_configuration = "Debug" -scheme.test_action.build_configuration = "Debug" - -save_root = File.directory?(ws_dir) ? ws_dir : File.dirname(proj_path) -scheme.save_as(save_root, "HelloCodenameOne-CI", true) -' - -# Show which scheme file we ended up with -WS_XCSCHEME="$PROJECT_DIR/HelloCodenameOne.xcworkspace/xcshareddata/xcschemes/HelloCodenameOne-CI.xcscheme" -PRJ_XCSCHEME="$PROJECT_DIR/xcshareddata/xcschemes/HelloCodenameOne-CI.xcscheme" -if [ -f "$WS_XCSCHEME" ]; then - bia_log "CI scheme (workspace): $WS_XCSCHEME"; grep -n "BlueprintName" "$WS_XCSCHEME" || true -elif [ -f "$PRJ_XCSCHEME" ]; then - bia_log "CI scheme (project): $PRJ_XCSCHEME"; grep -n "BlueprintName" "$PRJ_XCSCHEME" || true -else - bia_log "Warning: CI scheme not found after generation" -fi - -# Patch PBX TEST_HOST (remove any "-src" suffix that can break unit-tests) -PBXPROJ="$PROJECT_DIR/HelloCodenameOne.xcodeproj/project.pbxproj" -if [ -f "$PBXPROJ" ]; then - bia_log "Patching TEST_HOST in $PBXPROJ (remove '-src' suffix)" - cp "$PBXPROJ" "$PBXPROJ.bak" - perl -0777 -pe 's/(TEST_HOST = .*?\.app\/)([^"\/]+)-src(";\n)/$1$2$3/s' \ - "$PBXPROJ.bak" > "$PBXPROJ" - grep -n "TEST_HOST =" "$PBXPROJ" || true -fi - # CocoaPods (project contains a Podfile but usually empty — fine) if [ -f "$PROJECT_DIR/Podfile" ]; then bia_log "Installing CocoaPods dependencies" @@ -324,10 +272,6 @@ else bia_log "Podfile not found in generated project; skipping pod install" fi -# Remove any user schemes that could shadow the shared CI scheme -rm -rf "$PROJECT_DIR"/xcuserdata 2>/dev/null || true -find "$PROJECT_DIR" -maxdepth 1 -name "*.xcworkspace" -type d -exec rm -rf {}/xcuserdata \; 2>/dev/null || true - # Locate workspace for the next step WORKSPACE="" for candidate in "$PROJECT_DIR"/*.xcworkspace; do @@ -343,7 +287,7 @@ if [ -z "$WORKSPACE" ]; then fi bia_log "Found xcworkspace: $WORKSPACE" -SCHEME="${MAIN_NAME}-CI" +SCHEME="$MAIN_NAME" # Make these visible to the next GH Actions step if [ -n "${GITHUB_OUTPUT:-}" ]; then diff --git a/scripts/templates/HelloCodenameOne.java.tmpl b/scripts/device-runner-app/main/HelloCodenameOne.java similarity index 84% rename from scripts/templates/HelloCodenameOne.java.tmpl rename to scripts/device-runner-app/main/HelloCodenameOne.java index 32656416ee..92aec4afd7 100644 --- a/scripts/templates/HelloCodenameOne.java.tmpl +++ b/scripts/device-runner-app/main/HelloCodenameOne.java @@ -1,5 +1,6 @@ -package @PACKAGE@; +package com.codenameone.examples.hellocodenameone; +import com.codename1.testing.TestReporting; import com.codename1.ui.Button; import com.codename1.ui.BrowserComponent; import com.codename1.ui.Container; @@ -10,12 +11,16 @@ import com.codename1.ui.layouts.BorderLayout; import com.codename1.ui.layouts.BoxLayout; -public class @MAIN_NAME@ { +import com.codenameone.examples.hellocodenameone.tests.Cn1ssDeviceRunner; +import com.codenameone.examples.hellocodenameone.tests.Cn1ssDeviceRunnerReporter; + +public class HelloCodenameOne { private Form current; private Form mainForm; + private static boolean deviceRunnerExecuted; public void init(Object context) { - // No special initialization required for this sample + TestReporting.setInstance(new Cn1ssDeviceRunnerReporter()); } public void start() { @@ -23,6 +28,10 @@ public void start() { current.show(); return; } + if (!deviceRunnerExecuted) { + deviceRunnerExecuted = true; + new Cn1ssDeviceRunner().runSuite(); + } showMainForm(); } diff --git a/scripts/device-runner-app/tests/BrowserComponentScreenshotTest.java b/scripts/device-runner-app/tests/BrowserComponentScreenshotTest.java new file mode 100644 index 0000000000..2038b40aec --- /dev/null +++ b/scripts/device-runner-app/tests/BrowserComponentScreenshotTest.java @@ -0,0 +1,61 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.testing.AbstractTest; +import com.codename1.testing.TestUtils; +import com.codename1.ui.BrowserComponent; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; + +public class BrowserComponentScreenshotTest extends AbstractTest { + @Override + public boolean runTest() throws Exception { + final boolean[] supported = new boolean[1]; + Cn1ssDeviceRunnerHelper.runOnEdtSync(() -> supported[0] = BrowserComponent.isNativeBrowserSupported()); + if (!supported[0]) { + TestUtils.log("BrowserComponent native support unavailable; skipping screenshot test"); + return true; + } + + final boolean[] loadFinished = new boolean[1]; + final Form[] formHolder = new Form[1]; + Cn1ssDeviceRunnerHelper.runOnEdtSync(() -> { + Form form = new Form("Browser Test", new BorderLayout()); + BrowserComponent browser = new BrowserComponent(); + browser.addWebEventListener(BrowserComponent.onLoad, evt -> loadFinished[0] = true); + browser.setPage(buildHtml(), null); + form.add(BorderLayout.CENTER, browser); + formHolder[0] = form; + form.show(); + }); + + for (int elapsed = 0; elapsed < 15000 && !loadFinished[0]; elapsed += 200) { + TestUtils.waitFor(200); + } + if (!loadFinished[0]) { + TestUtils.log("BrowserComponent content did not finish loading in time"); + return false; + } + + Cn1ssDeviceRunnerHelper.waitForMillis(3000); + + final boolean[] result = new boolean[1]; + Cn1ssDeviceRunnerHelper.runOnEdtSync(() -> { + Form current = formHolder[0]; + if (current != null) { + current.revalidate(); + current.repaint(); + } + result[0] = Cn1ssDeviceRunnerHelper.emitCurrentFormScreenshot("BrowserComponent"); + }); + return result[0]; + } + + private static String buildHtml() { + return "" + + "" + + "

Codename One

" + + "

BrowserComponent instrumentation test content.

"; + } +} diff --git a/scripts/device-runner-app/tests/Cn1ssDeviceRunner.java b/scripts/device-runner-app/tests/Cn1ssDeviceRunner.java new file mode 100644 index 0000000000..d88ab85eee --- /dev/null +++ b/scripts/device-runner-app/tests/Cn1ssDeviceRunner.java @@ -0,0 +1,45 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.testing.DeviceRunner; +import com.codename1.testing.TestReporting; +import com.codename1.ui.Display; +import com.codename1.ui.Form; + +public final class Cn1ssDeviceRunner extends DeviceRunner { + private static final String[] TEST_CLASSES = new String[] { + MainScreenScreenshotTest.class.getName(), + BrowserComponentScreenshotTest.class.getName() + }; + + public void runSuite() { + for (String testClass : TEST_CLASSES) { + runTest(testClass); + } + TestReporting.getInstance().testExecutionFinished(getClass().getName()); + } + + @Override + protected void startApplicationInstance() { + Cn1ssDeviceRunnerHelper.runOnEdtSync(() -> { + Form current = Display.getInstance().getCurrent(); + if (current != null) { + current.revalidate(); + } else { + new Form().show(); + } + }); + Cn1ssDeviceRunnerHelper.waitForMillis(200); + } + + @Override + protected void stopApplicationInstance() { + Cn1ssDeviceRunnerHelper.runOnEdtSync(() -> { + Form current = Display.getInstance().getCurrent(); + if (current != null) { + current.removeAll(); + current.revalidate(); + } + }); + Cn1ssDeviceRunnerHelper.waitForMillis(200); + } +} diff --git a/scripts/device-runner-app/tests/Cn1ssDeviceRunnerHelper.java b/scripts/device-runner-app/tests/Cn1ssDeviceRunnerHelper.java new file mode 100644 index 0000000000..2948690181 --- /dev/null +++ b/scripts/device-runner-app/tests/Cn1ssDeviceRunnerHelper.java @@ -0,0 +1,186 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.io.Util; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.Image; +import com.codename1.ui.util.ImageIO; +import com.codename1.util.Base64; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +final class Cn1ssDeviceRunnerHelper { + private static final int CHUNK_SIZE = 900; + private static final int MAX_PREVIEW_BYTES = 20 * 1024; + private static final String PREVIEW_CHANNEL = "PREVIEW"; + private static final int[] PREVIEW_QUALITIES = new int[] {60, 50, 40, 35, 30, 25, 20, 18, 16, 14, 12, 10, 8, 6, 5, 4, 3, 2, 1}; + + private Cn1ssDeviceRunnerHelper() { + } + + static void runOnEdtSync(Runnable runnable) { + Display display = Display.getInstance(); + if (display.isEdt()) { + runnable.run(); + } else { + display.callSeriallyAndWait(runnable); + } + } + + static void waitForMillis(long millis) { + int duration = (int) Math.max(1, Math.min(Integer.MAX_VALUE, millis)); + Util.sleep(duration); + } + + static boolean emitCurrentFormScreenshot(String testName) { + String safeName = sanitizeTestName(testName); + Form current = Display.getInstance().getCurrent(); + if (current == null) { + println("CN1SS:ERR:test=" + safeName + " message=Current form is null"); + println("CN1SS:END:" + safeName); + return false; + } + int width = Math.max(1, current.getWidth()); + int height = Math.max(1, current.getHeight()); + Image[] img = new Image[1]; + Display.getInstance().screenshot(screen -> img[0] = screen); + long time = System.currentTimeMillis(); + Display.getInstance().invokeAndBlock(() -> { + while(img[0] == null) { + Util.sleep(50); + // timeout + if (System.currentTimeMillis() - time > 2000) { + return; + } + } + }); + if (img[0] == null) { + println("CN1SS:ERR:test=" + safeName + " message=Screenshot process timed out"); + println("CN1SS:END:" + safeName); + return false; + } + Image screenshot = img[0]; + current.paintComponent(screenshot.getGraphics(), true); + try { + ImageIO io = ImageIO.getImageIO(); + if (io == null || !io.isFormatSupported(ImageIO.FORMAT_PNG)) { + println("CN1SS:ERR:test=" + safeName + " message=PNG encoding unavailable"); + println("CN1SS:END:" + safeName); + return false; + } + ByteArrayOutputStream pngOut = new ByteArrayOutputStream(Math.max(1024, width * height / 2)); + io.save(screenshot, pngOut, ImageIO.FORMAT_PNG, 1f); + byte[] pngBytes = pngOut.toByteArray(); + println("CN1SS:INFO:test=" + safeName + " png_bytes=" + pngBytes.length); + emitChannel(pngBytes, safeName, ""); + + byte[] preview = encodePreview(io, screenshot, safeName); + if (preview != null && preview.length > 0) { + emitChannel(preview, safeName, PREVIEW_CHANNEL); + } else { + println("CN1SS:INFO:test=" + safeName + " preview_jpeg_bytes=0 preview_quality=0"); + } + return true; + } catch (IOException ex) { + println("CN1SS:ERR:test=" + safeName + " message=" + ex); + ex.printStackTrace(); + println("CN1SS:END:" + safeName); + return false; + } finally { + screenshot.dispose(); + } + } + + private static byte[] encodePreview(ImageIO io, Image screenshot, String safeName) throws IOException { + byte[] chosenPreview = null; + int chosenQuality = 0; + int smallestBytes = Integer.MAX_VALUE; + for (int quality : PREVIEW_QUALITIES) { + ByteArrayOutputStream previewOut = new ByteArrayOutputStream(Math.max(512, screenshot.getWidth() * screenshot.getHeight() / 4)); + io.save(screenshot, previewOut, ImageIO.FORMAT_JPEG, quality / 100f); + byte[] previewBytes = previewOut.toByteArray(); + if (previewBytes.length == 0) { + continue; + } + if (previewBytes.length < smallestBytes) { + smallestBytes = previewBytes.length; + chosenPreview = previewBytes; + chosenQuality = quality; + } + if (previewBytes.length <= MAX_PREVIEW_BYTES) { + break; + } + } + if (chosenPreview != null) { + println("CN1SS:INFO:test=" + safeName + " preview_jpeg_bytes=" + chosenPreview.length + " preview_quality=" + chosenQuality); + if (chosenPreview.length > MAX_PREVIEW_BYTES) { + println("CN1SS:WARN:test=" + safeName + " preview_exceeds_limit_bytes=" + chosenPreview.length + " max_preview_bytes=" + MAX_PREVIEW_BYTES); + } + } + return chosenPreview; + } + + private static void emitChannel(byte[] bytes, String safeName, String channel) { + String prefix = channel != null && channel.length() > 0 ? "CN1SS" + channel : "CN1SS"; + if (bytes == null || bytes.length == 0) { + println(prefix + ":END:" + safeName); + System.out.flush(); + return; + } + String base64 = Base64.encodeNoNewline(bytes); + int count = 0; + for (int pos = 0; pos < base64.length(); pos += CHUNK_SIZE) { + int end = Math.min(pos + CHUNK_SIZE, base64.length()); + String chunk = base64.substring(pos, end); + println(prefix + ":" + safeName + ":" + zeroPad(pos, 6) + ":" + chunk); + count++; + } + println("CN1SS:INFO:test=" + safeName + " chunks=" + count + " total_b64_len=" + base64.length()); + println(prefix + ":END:" + safeName); + System.out.flush(); + } + + static String sanitizeTestName(String testName) { + if (testName == null || testName.length() == 0) { + return "default"; + } + StringBuffer sanitized = new StringBuffer(testName.length()); + for (int i = 0; i < testName.length(); i++) { + char ch = testName.charAt(i); + if (isSafeChar(ch)) { + sanitized.append(ch); + } else { + sanitized.append('_'); + } + } + return sanitized.toString(); + } + + private static boolean isSafeChar(char ch) { + if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z')) { + return true; + } + if (ch >= '0' && ch <= '9') { + return true; + } + return ch == '_' || ch == '.' || ch == '-'; + } + + private static String zeroPad(int value, int width) { + String text = Integer.toString(value); + if (text.length() >= width) { + return text; + } + StringBuffer builder = new StringBuffer(width); + for (int i = text.length(); i < width; i++) { + builder.append('0'); + } + builder.append(text); + return builder.toString(); + } + + private static void println(String line) { + System.out.println(line); + } +} diff --git a/scripts/device-runner-app/tests/Cn1ssDeviceRunnerReporter.java b/scripts/device-runner-app/tests/Cn1ssDeviceRunnerReporter.java new file mode 100644 index 0000000000..647eb8e738 --- /dev/null +++ b/scripts/device-runner-app/tests/Cn1ssDeviceRunnerReporter.java @@ -0,0 +1,41 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.testing.TestReporting; + +public class Cn1ssDeviceRunnerReporter extends TestReporting { + @Override + public void startingTestCase(String testName) { + super.startingTestCase(testName); + System.out.println("CN1SS:INFO:test=" + Cn1ssDeviceRunnerHelper.sanitizeTestName(testName) + " status=START"); + } + + @Override + public void finishedTestCase(String testName, boolean passed) { + super.finishedTestCase(testName, passed); + String status = passed ? "PASSED" : "FAILED"; + System.out.println("CN1SS:INFO:test=" + Cn1ssDeviceRunnerHelper.sanitizeTestName(testName) + " status=" + status); + } + + @Override + public void logMessage(String message) { + super.logMessage(message); + if (message != null && message.length() > 0) { + System.out.println("CN1SS:INFO:message=" + message); + } + } + + @Override + public void logException(Throwable err) { + super.logException(err); + System.out.println("CN1SS:ERR:exception=" + err); + if (err != null) { + err.printStackTrace(); + } + } + + @Override + public void testExecutionFinished(String suiteName) { + super.testExecutionFinished(suiteName); + System.out.println("CN1SS:SUITE:FINISHED"); + } +} diff --git a/scripts/device-runner-app/tests/MainScreenScreenshotTest.java b/scripts/device-runner-app/tests/MainScreenScreenshotTest.java new file mode 100644 index 0000000000..770ed1f85b --- /dev/null +++ b/scripts/device-runner-app/tests/MainScreenScreenshotTest.java @@ -0,0 +1,42 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.testing.AbstractTest; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; + +public class MainScreenScreenshotTest extends AbstractTest { + @Override + public boolean runTest() throws Exception { + Cn1ssDeviceRunnerHelper.runOnEdtSync(() -> { + Form form = new Form("Main Screen", new BorderLayout()); + + Container content = new Container(BoxLayout.y()); + content.getAllStyles().setBgColor(0x1f2937); + content.getAllStyles().setBgTransparency(255); + content.getAllStyles().setPadding(6, 6, 6, 6); + content.getAllStyles().setFgColor(0xf9fafb); + + Label heading = new Label("Hello Codename One"); + heading.getAllStyles().setFgColor(0x38bdf8); + heading.getAllStyles().setMargin(0, 4, 0, 0); + + Label body = new Label("Instrumentation main activity preview"); + body.getAllStyles().setFgColor(0xf9fafb); + + content.add(heading); + content.add(body); + + form.add(BorderLayout.CENTER, content); + form.show(); + }); + + Cn1ssDeviceRunnerHelper.waitForMillis(500); + + final boolean[] result = new boolean[1]; + Cn1ssDeviceRunnerHelper.runOnEdtSync(() -> result[0] = Cn1ssDeviceRunnerHelper.emitCurrentFormScreenshot("MainActivity")); + return result[0]; + } +} diff --git a/scripts/ios/screenshots/BrowserComponent.png b/scripts/ios/screenshots/BrowserComponent.png new file mode 100644 index 0000000000..ce0c96eb2a Binary files /dev/null and b/scripts/ios/screenshots/BrowserComponent.png differ diff --git a/scripts/ios/screenshots/MainActivity.png b/scripts/ios/screenshots/MainActivity.png new file mode 100644 index 0000000000..a5abbcd7e9 Binary files /dev/null and b/scripts/ios/screenshots/MainActivity.png differ diff --git a/scripts/lib/cn1ss.sh b/scripts/lib/cn1ss.sh index 0c1dc236f6..5761227865 100644 --- a/scripts/lib/cn1ss.sh +++ b/scripts/lib/cn1ss.sh @@ -271,9 +271,17 @@ cn1ss_post_pr_comment() { fi body_size=$(wc -c < "$body_file" 2>/dev/null || echo 0) cn1ss_log "Attempting to post PR comment (payload bytes=${body_size})" + local -a extra_args=() + if [ -n "${CN1SS_COMMENT_MARKER:-}" ]; then + extra_args+=(--marker "${CN1SS_COMMENT_MARKER}") + fi + if [ -n "${CN1SS_COMMENT_LOG_PREFIX:-}" ]; then + extra_args+=(--log-prefix "${CN1SS_COMMENT_LOG_PREFIX}") + fi GITHUB_TOKEN="$comment_token" cn1ss_java_run "$CN1SS_POST_COMMENT_CLASS" \ --body "$body_file" \ - --preview-dir "$preview_dir" + --preview-dir "$preview_dir" \ + "${extra_args[@]}" local rc=$? if [ $rc -eq 0 ]; then cn1ss_log "Posted screenshot comparison comment to PR" diff --git a/scripts/run-android-instrumentation-tests.sh b/scripts/run-android-instrumentation-tests.sh index 56f8de7bc8..5662460457 100755 --- a/scripts/run-android-instrumentation-tests.sh +++ b/scripts/run-android-instrumentation-tests.sh @@ -69,142 +69,135 @@ cn1ss_setup "$JAVA17_BIN" "$CN1SS_HELPER_SOURCE_DIR" [ -d "$GRADLE_PROJECT_DIR" ] || { ra_log "Gradle project directory not found: $GRADLE_PROJECT_DIR"; exit 4; } [ -x "$GRADLE_PROJECT_DIR/gradlew" ] || chmod +x "$GRADLE_PROJECT_DIR/gradlew" -# ---- Run tests ------------------------------------------------------------- - -set -o pipefail -ra_log "Running instrumentation tests (stdout -> $TEST_LOG; stderr -> terminal)" -( - cd "$GRADLE_PROJECT_DIR" - ORIG_JAVA_HOME="${JAVA_HOME:-}" - export JAVA_HOME="${JAVA17_HOME:?JAVA17_HOME not set}" - ./gradlew --no-daemon --console=plain connectedDebugAndroidTest | tee "$TEST_LOG" - export JAVA_HOME="$ORIG_JAVA_HOME" -) || { ra_log "STAGE:GRADLE_TEST_FAILED (see $TEST_LOG)"; exit 10; } - -echo -ra_log "==== Begin connectedAndroidTest.log (tail -n 200) ====" -tail -n 200 "$TEST_LOG" || true -ra_log "==== End connectedAndroidTest.log ====" -echo - -# ---- Locate outputs (NO ADB) ---------------------------------------------- - -RESULTS_ROOT="$GRADLE_PROJECT_DIR/app/build/outputs/androidTest-results/connected" -ra_log "Listing connected test outputs under: $RESULTS_ROOT" -find "$RESULTS_ROOT" -maxdepth 4 -printf '%y %p\n' 2>/dev/null | sed 's/^/[run-android-instrumentation-tests] /' || true - -# Arrays must be declared for set -u safety -declare -a XMLS=() -declare -a LOGCATS=() -TEST_EXEC_LOG="" - -# XML result candidates (new + old formats), mtime desc -mapfile -t XMLS < <( - find "$RESULTS_ROOT" -type f \( -name 'test-result.xml' -o -name 'TEST-*.xml' \) \ - -printf '%T@ %p\n' 2>/dev/null | sort -nr | awk '{ $1=""; sub(/^ /,""); print }' -) || XMLS=() - -# logcat files produced by AGP -mapfile -t LOGCAT_FILES < <( - find "$RESULTS_ROOT" -type f -name 'logcat-*.txt' -print 2>/dev/null -) || LOGCAT_FILES=() - -# execution log (use first if present) -TEST_EXEC_LOG="$(find "$RESULTS_ROOT" -type f -path '*/testlog/test-results.log' -print -quit 2>/dev/null || true)" -[ -n "${TEST_EXEC_LOG:-}" ] || TEST_EXEC_LOG="" - -declare -a CN1SS_SOURCES=() -for x in "${XMLS[@]}"; do - CN1SS_SOURCES+=("XML:$x") -done -for logcat in "${LOGCAT_FILES[@]}"; do - CN1SS_SOURCES+=("LOGCAT:$logcat") -done -if [ -n "${TEST_EXEC_LOG:-}" ]; then - CN1SS_SOURCES+=("EXEC:$TEST_EXEC_LOG") -fi +# ---- Prepare app + emulator state ----------------------------------------- -if [ "${#XMLS[@]}" -gt 0 ]; then - ra_log "Found ${#XMLS[@]} test result file(s). First candidate: ${XMLS[0]}" -else - ra_log "No test result XML files found under $RESULTS_ROOT" +APK_PATH="${2:-}" +if [ -z "$APK_PATH" ]; then + APK_PATH="$(find "$GRADLE_PROJECT_DIR" -type f -path '*/outputs/apk/debug/*.apk' | head -n 1 || true)" fi - -if [ "${#LOGCAT_FILES[@]}" -eq 0 ]; then - ra_log "FATAL: No logcat-*.txt produced by connectedDebugAndroidTest (cannot extract CN1SS chunks)." - exit 12 +if [ -z "$APK_PATH" ] || [ ! -f "$APK_PATH" ]; then + ra_log "FATAL: Unable to locate debug APK under $GRADLE_PROJECT_DIR" >&2 + exit 10 fi +ra_log "Using APK: $APK_PATH" +MANIFEST="$GRADLE_PROJECT_DIR/app/src/main/AndroidManifest.xml" +if [ ! -f "$MANIFEST" ]; then + ra_log "FATAL: AndroidManifest.xml not found at $MANIFEST" >&2 + exit 10 +fi +PACKAGE_NAME="$(sed -n 's/.*package="\([^"]*\)".*/\1/p' "$MANIFEST" | head -n1)" +if [ -z "$PACKAGE_NAME" ]; then + ra_log "FATAL: Unable to determine package name from AndroidManifest.xml" >&2 + exit 10 +fi +ra_log "Detected application package: $PACKAGE_NAME" -# ---- Chunk accounting (diagnostics) --------------------------------------- +if ! command -v adb >/dev/null 2>&1; then + ra_log "FATAL: adb not found on PATH" >&2 + exit 10 +fi -XML_CHUNKS_TOTAL=0 -for x in "${XMLS[@]}"; do - c="$(cn1ss_count_chunks "$x")"; c="${c//[^0-9]/}"; : "${c:=0}" - XML_CHUNKS_TOTAL=$(( XML_CHUNKS_TOTAL + c )) -done -LOGCAT_CHUNKS=0 -for logcat in "${LOGCAT_FILES[@]}"; do - c="$(cn1ss_count_chunks "$logcat")"; c="${c//[^0-9]/}"; : "${c:=0}" - LOGCAT_CHUNKS=$(( LOGCAT_CHUNKS + c )) -done -EXECLOG_CHUNKS="$(cn1ss_count_chunks "${TEST_EXEC_LOG:-}")"; EXECLOG_CHUNKS="${EXECLOG_CHUNKS//[^0-9]/}"; : "${EXECLOG_CHUNKS:=0}" +ADB_BIN="$(command -v adb)" +"$ADB_BIN" start-server >/dev/null 2>&1 || true +"$ADB_BIN" wait-for-device +ra_log "ADB connected devices:" +"$ADB_BIN" devices -l | sed 's/^/[run-android-instrumentation-tests] /' + +ra_log "Installing APK onto device" +"$ADB_BIN" shell am force-stop "$PACKAGE_NAME" >/dev/null 2>&1 || true +"$ADB_BIN" uninstall "$PACKAGE_NAME" >/dev/null 2>&1 || true +if ! "$ADB_BIN" install -r "$APK_PATH"; then + ra_log "adb install failed; retrying after explicit uninstall" + "$ADB_BIN" uninstall "$PACKAGE_NAME" >/dev/null 2>&1 || true + if ! "$ADB_BIN" install "$APK_PATH"; then + ra_log "FATAL: adb install failed after retry" + exit 10 + fi +fi -ra_log "Chunk counts -> XML: ${XML_CHUNKS_TOTAL} | logcat: ${LOGCAT_CHUNKS} | test-results.log: ${EXECLOG_CHUNKS}" +ra_log "Clearing logcat buffer" +"$ADB_BIN" logcat -c || true -if [ "${LOGCAT_CHUNKS:-0}" = "0" ] && [ "${XML_CHUNKS_TOTAL:-0}" = "0" ] && [ "${EXECLOG_CHUNKS:-0}" = "0" ]; then - ra_log "STAGE:MARKERS_NOT_FOUND -> The test did not emit CN1SS chunks" - ra_log "Hints:" - ra_log " • Ensure the test actually ran (check FAILED vs SUCCESS in $TEST_LOG)" - ra_log " • Check for CN1SS:ERR or CN1SS:INFO lines below" - ra_log "---- CN1SS lines from any result files ----" - (grep -R "CN1SS:" "$RESULTS_ROOT" || true) | sed 's/^/[CN1SS] /' - exit 12 +LOGCAT_PID=0 +cleanup() { + if [ "$LOGCAT_PID" -ne 0 ]; then + kill "$LOGCAT_PID" >/dev/null 2>&1 || true + wait "$LOGCAT_PID" 2>/dev/null || true + fi + "$ADB_BIN" shell am force-stop "$PACKAGE_NAME" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +ra_log "Capturing device logcat to $TEST_LOG" +"$ADB_BIN" logcat -v threadtime > "$TEST_LOG" 2>&1 & +LOGCAT_PID=$! +sleep 2 + +ra_log "Launching Codename One DeviceRunner" +"$ADB_BIN" shell pm clear "$PACKAGE_NAME" >/dev/null 2>&1 || true +if ! "$ADB_BIN" shell monkey -p "$PACKAGE_NAME" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1; then + ra_log "monkey launch failed; attempting am start fallback" + MAIN_ACTIVITY="$("$ADB_BIN" shell cmd package resolve-activity --brief "$PACKAGE_NAME" 2>/dev/null | head -n 1 | tr -d '\r' | sed 's/ .*//')" + if [[ "$MAIN_ACTIVITY" == */* ]]; then + if ! "$ADB_BIN" shell am start -n "$MAIN_ACTIVITY" >/dev/null 2>&1; then + ra_log "FATAL: Failed to start application via am start" + exit 10 + fi + else + ra_log "FATAL: Unable to determine launchable activity" + exit 10 + fi fi -# ---- Identify CN1SS test streams ----------------------------------------- +END_MARKER="CN1SS:SUITE:FINISHED" +TIMEOUT_SECONDS=300 +START_TIME="$(date +%s)" +ra_log "Waiting for DeviceRunner completion marker ($END_MARKER)" +while true; do + if grep -q "$END_MARKER" "$TEST_LOG"; then + ra_log "Detected DeviceRunner completion marker" + break + fi + NOW="$(date +%s)" + if [ $(( NOW - START_TIME )) -ge $TIMEOUT_SECONDS ]; then + ra_log "STAGE:TIMEOUT -> DeviceRunner did not emit completion marker within ${TIMEOUT_SECONDS}s" + break + fi + sleep 5 +done -declare -A TEST_NAME_SET=() +sleep 3 -if [ "${#XMLS[@]}" -gt 0 ]; then - for x in "${XMLS[@]}"; do - while IFS= read -r name; do - [ -n "$name" ] || continue - TEST_NAME_SET["$name"]=1 - done < <(cn1ss_list_tests "$x" 2>/dev/null || true) - done -fi +declare -a CN1SS_SOURCES=("LOGCAT:$TEST_LOG") -for logcat in "${LOGCAT_FILES[@]}"; do - [ -s "$logcat" ] || continue - while IFS= read -r name; do - [ -n "$name" ] || continue - TEST_NAME_SET["$name"]=1 - done < <(cn1ss_list_tests "$logcat" 2>/dev/null || true) -done -if [ -n "${TEST_EXEC_LOG:-}" ] && [ -s "$TEST_EXEC_LOG" ]; then - while IFS= read -r name; do - [ -n "$name" ] || continue - TEST_NAME_SET["$name"]=1 - done < <(cn1ss_list_tests "$TEST_EXEC_LOG" 2>/dev/null || true) -fi +# ---- Chunk accounting (diagnostics) --------------------------------------- -if [ "${#TEST_NAME_SET[@]}" -eq 0 ] && { [ "${LOGCAT_CHUNKS:-0}" -gt 0 ] || [ "${XML_CHUNKS_TOTAL:-0}" -gt 0 ] || [ "${EXECLOG_CHUNKS:-0}" -gt 0 ]; }; then - TEST_NAME_SET["default"]=1 -fi +LOGCAT_CHUNKS="$(cn1ss_count_chunks "$TEST_LOG")" +LOGCAT_CHUNKS="${LOGCAT_CHUNKS//[^0-9]/}"; : "${LOGCAT_CHUNKS:=0}" + +ra_log "Chunk counts -> logcat: ${LOGCAT_CHUNKS}" -if [ "${#TEST_NAME_SET[@]}" -eq 0 ]; then - ra_log "FATAL: Could not determine any CN1SS test streams" +if [ "${LOGCAT_CHUNKS:-0}" = "0" ]; then + ra_log "STAGE:MARKERS_NOT_FOUND -> DeviceRunner output did not include CN1SS chunks" + ra_log "---- CN1SS lines from logcat ----" + (grep "CN1SS:" "$TEST_LOG" || true) | sed 's/^/[CN1SS] /' exit 12 fi +# ---- Identify CN1SS test streams ----------------------------------------- + +TEST_NAMES_RAW="$(cn1ss_list_tests "$TEST_LOG" 2>/dev/null | awk 'NF' | sort -u || true)" declare -a TEST_NAMES=() -for name in "${!TEST_NAME_SET[@]}"; do - TEST_NAMES+=("$name") -done -IFS=$'\n' TEST_NAMES=($(printf '%s\n' "${TEST_NAMES[@]}" | sort)) -unset IFS +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 ra_log "Detected CN1SS test streams: ${TEST_NAMES[*]}" declare -A TEST_OUTPUTS=() @@ -229,27 +222,11 @@ for test in "${TEST_NAMES[@]}"; do else ra_log "FATAL: Failed to extract/decode CN1SS payload for test '$test'" RAW_B64_OUT="$SCREENSHOT_TMP_DIR/${test}.raw.b64" - { - local count - for logcat in "${LOGCAT_FILES[@]}"; do - [ -s "$logcat" ] || continue - count="$(cn1ss_count_chunks "$logcat" "$test")"; count="${count//[^0-9]/}"; : "${count:=0}" - if [ "$count" -gt 0 ]; then cn1ss_extract_base64 "$logcat" "$test"; fi - done - if [ "${#XMLS[@]}" -gt 0 ]; then - for x in "${XMLS[@]}"; do - count="$(cn1ss_count_chunks "$x" "$test")"; count="${count//[^0-9]/}"; : "${count:=0}" - if [ "$count" -gt 0 ]; then cn1ss_extract_base64 "$x" "$test"; fi - done - fi - if [ -n "${TEST_EXEC_LOG:-}" ] && [ -s "$TEST_EXEC_LOG" ]; then - count="$(cn1ss_count_chunks "$TEST_EXEC_LOG" "$test")"; count="${count//[^0-9]/}"; : "${count:=0}" - if [ "$count" -gt 0 ]; then cn1ss_extract_base64 "$TEST_EXEC_LOG" "$test"; fi + if cn1ss_extract_base64 "$TEST_LOG" "$test" > "$RAW_B64_OUT" 2>/dev/null; then + if [ -s "$RAW_B64_OUT" ]; then + head -c 64 "$RAW_B64_OUT" | sed 's/^/[CN1SS-B64-HEAD] /' + ra_log "Partial base64 saved at: $RAW_B64_OUT" fi - } > "$RAW_B64_OUT" 2>/dev/null || true - if [ -s "$RAW_B64_OUT" ]; then - head -c 64 "$RAW_B64_OUT" | sed 's/^/[CN1SS-B64-HEAD] /' - ra_log "Partial base64 saved at: $RAW_B64_OUT" fi exit 12 fi @@ -324,17 +301,14 @@ fi ra_log "STAGE:COMMENT_POST -> Submitting PR feedback" comment_rc=0 +export CN1SS_COMMENT_MARKER="" +export CN1SS_COMMENT_LOG_PREFIX="[run-android-device-tests]" if ! cn1ss_post_pr_comment "$COMMENT_FILE" "$SCREENSHOT_PREVIEW_DIR"; then comment_rc=$? fi # Copy useful artifacts for GH Actions -for logcat in "${LOGCAT_FILES[@]}"; do - cp -f "$logcat" "$ARTIFACTS_DIR/$(basename "$logcat")" 2>/dev/null || true -done -for x in "${XMLS[@]}"; do - cp -f "$x" "$ARTIFACTS_DIR/$(basename "$x")" 2>/dev/null || true -done +cp -f "$TEST_LOG" "$ARTIFACTS_DIR/device-runner-logcat.txt" 2>/dev/null || true [ -n "${TEST_EXEC_LOG:-}" ] && cp -f "$TEST_EXEC_LOG" "$ARTIFACTS_DIR/test-results.log" 2>/dev/null || true exit $comment_rc diff --git a/scripts/run-ios-ui-tests.sh b/scripts/run-ios-ui-tests.sh index a9cc289f70..727a9493e7 100755 --- a/scripts/run-ios-ui-tests.sh +++ b/scripts/run-ios-ui-tests.sh @@ -79,7 +79,7 @@ cn1ss_setup "$JAVA17_BIN" "$CN1SS_HELPER_SOURCE_DIR" ARTIFACTS_DIR="${ARTIFACTS_DIR:-${GITHUB_WORKSPACE:-$REPO_ROOT}/artifacts}" mkdir -p "$ARTIFACTS_DIR" -TEST_LOG="$ARTIFACTS_DIR/xcodebuild-test.log" +TEST_LOG="$ARTIFACTS_DIR/device-runner.log" if [ -z "$REQUESTED_SCHEME" ]; then if [[ "$WORKSPACE_PATH" == *.xcworkspace ]]; then @@ -95,7 +95,6 @@ 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" @@ -118,59 +117,192 @@ else ri_log "Scheme file not found for env injection: $SCHEME_FILE" fi +MAX_SIM_OS_MAJOR=20 + +trim_whitespace() { + local value="$1" + value="${value#${value%%[![:space:]]*}}" + value="${value%${value##*[![:space:]]}}" + printf '%s' "$value" +} + +normalize_destination() { + local raw="$1" + IFS=',' read -r -a parts <<< "$raw" + local platform="" id="" os="" name="" + local extras=() + for part in "${parts[@]}"; do + part="$(trim_whitespace "$part")" + case "$part" in + platform=*) platform="${part#platform=}" ;; + id=*) id="${part#id=}" ;; + OS=*) os="${part#OS=}" ;; + os=*) os="${part#os=}" ;; + name=*) name="${part#name=}" ;; + '') ;; + *) extras+=("$part") ;; + esac + done + [ -z "$platform" ] && platform="iOS Simulator" + + local components=("platform=$platform") + [ -n "$id" ] && components+=("id=$id") + [ -n "$os" ] && components+=("OS=$os") + [ -n "$name" ] && components+=("name=$name") + if [ ${#extras[@]} -gt 0 ]; then + for extra in "${extras[@]}"; do + [ -n "$extra" ] && components+=("$extra") + done + fi + + local joined="${components[0]}" part + for part in "${components[@]:1}"; do + joined+=",$part" + done + + printf '%s\n' "$joined" +} + auto_select_destination() { - if ! command -v python3 >/dev/null 2>&1; then + local show_dest rc=0 best_line="" best_key="" line payload platform id name os priority key part value + set +e + show_dest="$(xcodebuild -workspace "$WORKSPACE_PATH" -scheme "$SCHEME" -sdk iphonesimulator -showdestinations 2>/dev/null)" + rc=$? + set -e + + [ -z "${show_dest:-}" ] && return $rc + + local section="" + while IFS= read -r line; do + case "$line" in + *"Available destinations"*) section="available"; continue ;; + *"Ineligible destinations"*|*"Local destinations"*) section="other"; continue ;; + esac + [ "$section" != "available" ] && continue + case "$line" in + *{*}*) + payload="$(printf '%s\n' "$line" | sed -n 's/.*{\(.*\)}/\1/p')" + [ -z "$payload" ] && continue + platform=""; id=""; name=""; os="" + IFS=',' read -r -a parts <<< "$payload" + for part in "${parts[@]}"; do + part="$(trim_whitespace "$part")" + key="${part%%:*}" + value="${part#*:}" + [ "$value" = "$part" ] && continue + key="$(trim_whitespace "$key")" + value="$(trim_whitespace "$value")" + case "$key" in + platform) platform="$value" ;; + id) id="$value" ;; + name) name="$value" ;; + OS|os|"OS version") os="$value" ;; + esac + done + [ "$platform" != "iOS Simulator" ] && continue + [ -z "$id" ] && continue + priority=0 + case "$(printf '%s' "$name" | tr 'A-Z' 'a-z')" in + *iphone*) priority=2 ;; + *ipad*) priority=1 ;; + esac + validity=1 + major="${os%%.*}" + case "$major" in ''|*[!0-9]*) major=0 ;; esac + if [ "$major" -gt "$MAX_SIM_OS_MAJOR" ] 2>/dev/null; then + validity=0 + fi + IFS='.' read -r v1 v2 v3 <<< "$os" + v1=${v1:-0}; v2=${v2:-0}; v3=${v3:-0} + key=$(printf '%d-%d-%03d-%03d-%03d' "$validity" "$priority" "$v1" "$v2" "$v3") + if [ -z "$best_key" ] || [[ "$key" > "$best_key" ]]; then + best_key="$key" + best_line="platform=iOS Simulator,id=$id" + [ -n "$os" ] && best_line="$best_line,OS=$os" + [ -n "$name" ] && best_line="$best_line,name=$name" + fi + ;; + esac + done <<< "$show_dest" + + [ -n "$best_line" ] && printf '%s\n' "$best_line" + [ -n "$best_line" ] && return 0 + + return $rc +} + +fallback_sim_destination() { + if ! command -v xcrun >/dev/null 2>&1; then return fi - 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 - )" - fi + local best_line="" best_key="" current_version="" lower_name="" lower_state="" + + while IFS= read -r raw_line; do + case "$raw_line" in + --\ iOS\ *--) + current_version="$(printf '%s\n' "$raw_line" | sed -n 's/.*-- iOS \([0-9.]*\) --.*/\1/p')" + continue + ;; + --\ *--) + current_version="" + continue + ;; + esac + + [ -z "$current_version" ] && continue + line="${raw_line#${raw_line%%[![:space:]]*}}" + [ -z "$line" ] && continue + + name="${line%% (*}" + rest="${line#* (}" + [ "$rest" = "$line" ] && continue + id="${rest%%)*}" + state="${line##*(}" + state="${state%)}" + name="$(trim_whitespace "$name")" + id="$(trim_whitespace "$id")" + state="$(trim_whitespace "$state")" + [ -z "$name" ] && continue + [ -z "$id" ] && continue + + lower_name="$(printf '%s' "$name" | tr 'A-Z' 'a-z')" + case "$lower_name" in + *iphone*) priority=2 ;; + *ipad*) priority=1 ;; + *) priority=0 ;; + esac + + lower_state="$(printf '%s' "$state" | tr 'A-Z' 'a-z')" + boot=0 + [ "$lower_state" = "booted" ] && boot=1 + + validity=1 + major="${current_version%%.*}" + case "$major" in ''|*[!0-9]*) major=0 ;; esac + if [ "$major" -gt "$MAX_SIM_OS_MAJOR" ] 2>/dev/null; then + validity=0 + 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 - )" + IFS='.' read -r v1 v2 v3 <<< "$current_version" + v1=${v1:-0}; v2=${v2:-0}; v3=${v3:-0} + + candidate_key=$(printf '%d-%d-%03d-%03d-%03d-%d' "$validity" "$priority" "$v1" "$v2" "$v3" "$boot") + if [ -z "$best_key" ] || [[ "$candidate_key" > "$best_key" ]]; then + best_key="$candidate_key" + best_line="platform=iOS Simulator,id=$id" + [ -n "$current_version" ] && best_line="$best_line,OS=$current_version" + best_line="$best_line,name=$name" fi - fi + done < <(xcrun simctl list devices 2>/dev/null) - if [ -n "${selected:-}" ]; then - echo "$selected" + if [ -n "$best_line" ]; then + printf '%s\n' "$best_line" fi } + + SIM_DESTINATION="${IOS_SIM_DESTINATION:-}" if [ -z "$SIM_DESTINATION" ]; then SELECTED_DESTINATION="$(auto_select_destination || true)" @@ -182,52 +314,213 @@ if [ -z "$SIM_DESTINATION" ]; then 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'" + FALLBACK_DESTINATION="$(fallback_sim_destination || true)" + if [ -n "${FALLBACK_DESTINATION:-}" ]; then + SIM_DESTINATION="$FALLBACK_DESTINATION" + ri_log "Using fallback simulator destination '$SIM_DESTINATION'" + else + SIM_DESTINATION="platform=iOS Simulator,name=iPhone 16" + ri_log "Falling back to default simulator destination '$SIM_DESTINATION'" + fi fi -ri_log "Running UI tests on destination '$SIM_DESTINATION'" +SIM_DESTINATION="$(normalize_destination "$SIM_DESTINATION")" + +# Extract UDID and prefer id-only destination to avoid OS/SDK mismatches +SIM_UDID="$(printf '%s\n' "$SIM_DESTINATION" | sed -n 's/.*id=\([^,]*\).*/\1/p' | tr -d '\r[:space:]')" +if [ -n "$SIM_UDID" ]; then + ri_log "Booting simulator $SIM_UDID" + xcrun simctl boot "$SIM_UDID" >/dev/null 2>&1 || true + xcrun simctl bootstatus "$SIM_UDID" -b + SIM_DESTINATION="id=$SIM_UDID" +fi +ri_log "Running DeviceRunner on destination '$SIM_DESTINATION'" DERIVED_DATA_DIR="$SCREENSHOT_TMP_DIR/derived" rm -rf "$DERIVED_DATA_DIR" +BUILD_LOG="$ARTIFACTS_DIR/xcodebuild-build.log" -# Run only the UI test bundle -UI_TEST_TARGET="${UI_TEST_TARGET:-HelloCodenameOneUITests}" -XCODE_TEST_FILTERS=( - -only-testing:"${UI_TEST_TARGET}" - -skip-testing:HelloCodenameOneTests -) - -set -o pipefail +ri_log "Building simulator app with xcodebuild" if ! xcodebuild \ -workspace "$WORKSPACE_PATH" \ -scheme "$SCHEME" \ -sdk iphonesimulator \ -configuration Debug \ -destination "$SIM_DESTINATION" \ + -destination-timeout 120 \ -derivedDataPath "$DERIVED_DATA_DIR" \ - -resultBundlePath "$RESULT_BUNDLE" \ - "${XCODE_TEST_FILTERS[@]}" \ - 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" + build | tee "$BUILD_LOG"; then + ri_log "STAGE:XCODE_BUILD_FAILED -> See $BUILD_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" + +BUILD_SETTINGS="$(xcodebuild -workspace "$WORKSPACE_PATH" -scheme "$SCHEME" -sdk iphonesimulator -configuration Debug -showBuildSettings 2>/dev/null || true)" +TARGET_BUILD_DIR="$(printf '%s\n' "$BUILD_SETTINGS" | awk -F' = ' '/ TARGET_BUILD_DIR /{print $2; exit}')" +WRAPPER_NAME="$(printf '%s\n' "$BUILD_SETTINGS" | awk -F' = ' '/ WRAPPER_NAME /{print $2; exit}')" +if [ -z "$WRAPPER_NAME" ]; then + ri_log "FATAL: Unable to determine build wrapper name" + exit 11 +fi +if [ -z "$APP_BUNDLE_PATH" ]; then + CANDIDATE_BUNDLE="$DERIVED_DATA_DIR/Build/Products/Debug-iphonesimulator/$WRAPPER_NAME" + if [ -d "$CANDIDATE_BUNDLE" ]; then + APP_BUNDLE_PATH="$CANDIDATE_BUNDLE" + fi +fi +if [ -z "$APP_BUNDLE_PATH" ] && [ -n "$TARGET_BUILD_DIR" ]; then + CANDIDATE_BUNDLE="$TARGET_BUILD_DIR/$WRAPPER_NAME" + if [ -d "$CANDIDATE_BUNDLE" ]; then + APP_BUNDLE_PATH="$CANDIDATE_BUNDLE" + fi +fi +if [ -z "$APP_BUNDLE_PATH" ]; then + CANDIDATE_BUNDLE="$(find "$DERIVED_DATA_DIR" -path "*/Debug-iphonesimulator/$WRAPPER_NAME" -type d -print -quit 2>/dev/null || true)" + if [ -d "$CANDIDATE_BUNDLE" ]; then + APP_BUNDLE_PATH="$CANDIDATE_BUNDLE" + fi +fi +if [ -z "$APP_BUNDLE_PATH" ]; then + ri_log "FATAL: Simulator app bundle missing for wrapper $WRAPPER_NAME" + exit 11 +fi +if [ ! -d "$APP_BUNDLE_PATH" ]; then + ri_log "FATAL: Simulator app bundle missing at $APP_BUNDLE_PATH" + exit 11 +fi +BUNDLE_IDENTIFIER="$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' "$APP_BUNDLE_PATH/Info.plist" 2>/dev/null || true)" +if [ -z "$BUNDLE_IDENTIFIER" ]; then + ri_log "FATAL: Unable to determine CFBundleIdentifier" exit 11 fi +APP_PROCESS_NAME="${WRAPPER_NAME%.app}" + + SIM_DEVICE_ID="" + SIM_DEVICE_ID="$(printf '%s\n' "$SIM_DESTINATION" | sed -n 's/.*id=\([^,]*\).*/\1/p' | tr -d '\r' | sed 's/[[:space:]]//g')" + if [ -z "$SIM_DEVICE_ID" ] || [ "$SIM_DEVICE_ID" = "$SIM_DESTINATION" ]; then + SIM_DEVICE_NAME="$(printf '%s\n' "$SIM_DESTINATION" | sed -n 's/.*name=\([^,]*\).*/\1/p')" + SIM_DEVICE_NAME="$(trim_whitespace "${SIM_DEVICE_NAME:-}")" + if [ -n "$SIM_DEVICE_NAME" ]; then + resolved_id="" + in_ios=0 + while IFS= read -r raw_line; do + case "$raw_line" in + --\ iOS\ *--) in_ios=1; continue ;; + --\ *--) in_ios=0; continue ;; + esac + [ "$in_ios" -eq 0 ] && continue + line="${raw_line#${raw_line%%[![:space:]]*}}" + [ -z "$line" ] && continue + name_part="${line%% (*}" + rest="${line#* (}" + [ "$rest" = "$line" ] && continue + id_part="${rest%%)*}" + name_candidate="$(trim_whitespace "$name_part")" + if [ "$name_candidate" = "$SIM_DEVICE_NAME" ]; then + resolved_id="$(trim_whitespace "$id_part")" + break + fi + done < <(xcrun simctl list devices 2>/dev/null) + SIM_DEVICE_ID="$resolved_id" + fi + fi + + if [ -n "$SIM_DEVICE_ID" ]; then + ri_log "Booting simulator $SIM_DEVICE_ID" + xcrun simctl boot "$SIM_DEVICE_ID" >/dev/null 2>&1 || true + xcrun simctl bootstatus "$SIM_DEVICE_ID" -b + else + ri_log "Warning: simulator UDID not resolved; relying on default booted device" + xcrun simctl bootstatus booted -b || true + fi + + LOG_STREAM_PID=0 + cleanup() { + if [ "$LOG_STREAM_PID" -ne 0 ]; then + kill "$LOG_STREAM_PID" >/dev/null 2>&1 || true + wait "$LOG_STREAM_PID" 2>/dev/null || true + fi + if [ -n "$SIM_DEVICE_ID" ] && [ -n "$BUNDLE_IDENTIFIER" ]; then + xcrun simctl terminate "$SIM_DEVICE_ID" "$BUNDLE_IDENTIFIER" >/dev/null 2>&1 || true + fi + } + trap cleanup EXIT + + ri_log "Streaming simulator logs to $TEST_LOG" + if [ -n "$SIM_DEVICE_ID" ]; then + xcrun simctl terminate "$SIM_DEVICE_ID" "$BUNDLE_IDENTIFIER" >/dev/null 2>&1 || true + xcrun simctl uninstall "$SIM_DEVICE_ID" "$BUNDLE_IDENTIFIER" >/dev/null 2>&1 || true + + xcrun simctl spawn "$SIM_DEVICE_ID" \ + log stream --style json --level debug \ + --predicate 'eventMessage CONTAINS "CN1SS"' \ + > "$TEST_LOG" 2>&1 & + else + xcrun simctl spawn booted log stream --style compact --level debug --predicate 'composedMessage CONTAINS "CN1SS"' > "$TEST_LOG" 2>&1 & + fi + LOG_STREAM_PID=$! + sleep 2 + + ri_log "Installing simulator app bundle" + if [ -n "$SIM_DEVICE_ID" ]; then + if ! xcrun simctl install "$SIM_DEVICE_ID" "$APP_BUNDLE_PATH"; then + ri_log "FATAL: simctl install failed" + exit 11 + fi + if ! xcrun simctl launch "$SIM_DEVICE_ID" "$BUNDLE_IDENTIFIER" >/dev/null 2>&1; then + ri_log "FATAL: simctl launch failed" + exit 11 + fi + else + if ! xcrun simctl install booted "$APP_BUNDLE_PATH"; then + ri_log "FATAL: simctl install failed" + exit 11 + fi + if ! xcrun simctl launch booted "$BUNDLE_IDENTIFIER" >/dev/null 2>&1; then + ri_log "FATAL: simctl launch failed" + exit 11 + fi + fi + +END_MARKER="CN1SS:SUITE:FINISHED" +TIMEOUT_SECONDS=300 +START_TIME="$(date +%s)" +ri_log "Waiting for DeviceRunner completion marker ($END_MARKER)" +while true; do + if grep -q "$END_MARKER" "$TEST_LOG"; then + ri_log "Detected DeviceRunner completion marker" + break + fi + NOW="$(date +%s)" + if [ $(( NOW - START_TIME )) -ge $TIMEOUT_SECONDS ]; then + ri_log "STAGE:TIMEOUT -> DeviceRunner did not emit completion marker within ${TIMEOUT_SECONDS}s" + break + fi + sleep 5 +done + +sleep 3 + +kill "$LOG_STREAM_PID" >/dev/null 2>&1 || true +wait "$LOG_STREAM_PID" 2>/dev/null || true +LOG_STREAM_PID=0 + +FALLBACK_LOG="$ARTIFACTS_DIR/device-runner-fallback.log" +xcrun simctl spawn "$SIM_DEVICE_ID" \ + log show --style syslog --last 30m \ + --predicate 'eventMessage CONTAINS "CN1SS"' \ + > "$FALLBACK_LOG" 2>/dev/null || true + +if [ -n "$SIM_DEVICE_ID" ]; then + xcrun simctl terminate "$SIM_DEVICE_ID" "$BUNDLE_IDENTIFIER" >/dev/null 2>&1 || true +fi + +declare -a CN1SS_SOURCES=("SIMLOG:$TEST_LOG") LOG_CHUNKS="$(cn1ss_count_chunks "$TEST_LOG")"; LOG_CHUNKS="${LOG_CHUNKS//[^0-9]/}"; : "${LOG_CHUNKS:=0}" -ri_log "Chunk counts -> xcodebuild log: ${LOG_CHUNKS}" +ri_log "Chunk counts -> simulator log: ${LOG_CHUNKS}" if [ "${LOG_CHUNKS:-0}" = "0" ]; then - ri_log "STAGE:MARKERS_NOT_FOUND -> xcodebuild output did not include CN1SS chunks" + ri_log "STAGE:MARKERS_NOT_FOUND -> simulator output did not include CN1SS chunks" ri_log "---- CN1SS lines (if any) ----" (grep "CN1SS:" "$TEST_LOG" || true) | sed 's/^/[CN1SS] /' exit 12 @@ -262,21 +555,26 @@ for test in "${TEST_NAMES[@]}"; do 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" + ri_log "Primary decode failed for '$test'; trying fallback log" + if [ -s "$FALLBACK_LOG" ] && source_label="$(cn1ss_decode_test_png "$test" "$dest" "SIMLOG:$FALLBACK_LOG")"; then + ri_log "Decoded screenshot for '$test' from fallback (size: $(cn1ss_file_size "$dest") bytes)" + 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[@]}" "SIMLOG:$FALLBACK_LOG"; 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 fi - exit 12 fi done @@ -317,6 +615,8 @@ 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" \ + --title "iOS screenshot updates" \ + --success-message "✅ Native iOS screenshot tests passed." \ --compare-json "$COMPARE_JSON" \ --comment-out "$COMMENT_FILE" \ --summary-out "$SUMMARY_FILE"; then @@ -357,9 +657,13 @@ cp -f "$COMPARE_JSON" "$ARTIFACTS_DIR/screenshot-compare.json" 2>/dev/null || tr if [ -s "$COMMENT_FILE" ]; then cp -f "$COMMENT_FILE" "$ARTIFACTS_DIR/screenshot-comment.md" 2>/dev/null || true fi +cp -f "$BUILD_LOG" "$ARTIFACTS_DIR/xcodebuild-build.log" 2>/dev/null || true +cp -f "$TEST_LOG" "$ARTIFACTS_DIR/device-runner.log" 2>/dev/null || true ri_log "STAGE:COMMENT_POST -> Submitting PR feedback" comment_rc=0 +export CN1SS_COMMENT_MARKER="" +export CN1SS_COMMENT_LOG_PREFIX="[run-ios-device-tests]" if ! cn1ss_post_pr_comment "$COMMENT_FILE" "$SCREENSHOT_PREVIEW_DIR"; then comment_rc=$? fi