diff --git a/.github/workflows/scripts-android.yml b/.github/workflows/scripts-android.yml index b1ba7def43..5d71510ddf 100644 --- a/.github/workflows/scripts-android.yml +++ b/.github/workflows/scripts-android.yml @@ -22,4 +22,27 @@ jobs: - name: Build Android port run: ./scripts/build-android-port.sh -q -DskipTests - name: Build Hello Codename One Android app + id: build-android-app run: ./scripts/build-android-app.sh -q -DskipTests + - name: Enable KVM for Android emulator + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Run Android instrumentation tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 31 + arch: x86_64 + target: google_apis + script: | + ./scripts/run-android-instrumentation-tests.sh "${{ steps.build-android-app.outputs.gradle_project_dir }}" + - name: Upload emulator screenshot + if: always() # still collect it if tests fail + uses: actions/upload-artifact@v4 + with: + name: emulator-screenshot + path: artifacts/*.png + if-no-files-found: warn + retention-days: 14 + compression-level: 6 \ No newline at end of file diff --git a/scripts/build-android-app.sh b/scripts/build-android-app.sh index f424546247..79e1374f96 100755 --- a/scripts/build-android-app.sh +++ b/scripts/build-android-app.sh @@ -201,6 +201,7 @@ xmlstarlet sel -N "$NS" -t -c "/mvn:project/mvn:build/mvn:plugins" -n "$ROOT_POM [ -f "$APP_DIR/build.sh" ] && chmod +x "$APP_DIR/build.sh" 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 --- @@ -265,6 +266,246 @@ if [ -z "$GRADLE_PROJECT_DIR" ]; then exit 1 fi +ba_log "Configuring instrumentation test sources in $GRADLE_PROJECT_DIR" + +# Ensure AndroidX flags in gradle.properties +# --- BEGIN: robust Gradle patch for AndroidX tests --- +GRADLE_PROPS="$GRADLE_PROJECT_DIR/gradle.properties" +grep -q '^android.useAndroidX=' "$GRADLE_PROPS" 2>/dev/null || echo 'android.useAndroidX=true' >> "$GRADLE_PROPS" +grep -q '^android.enableJetifier=' "$GRADLE_PROPS" 2>/dev/null || echo 'android.enableJetifier=true' >> "$GRADLE_PROPS" + +APP_BUILD_GRADLE="$GRADLE_PROJECT_DIR/app/build.gradle" +ROOT_BUILD_GRADLE="$GRADLE_PROJECT_DIR/build.gradle" + +# Ensure repos in both root and app +for F in "$ROOT_BUILD_GRADLE" "$APP_BUILD_GRADLE"; do + if [ -f "$F" ]; then + if ! grep -qE '^\s*repositories\s*{' "$F"; then + cat >> "$F" <<'EOS' + +repositories { + google() + mavenCentral() +} +EOS + else + grep -q 'google()' "$F" || sed -E -i '0,/repositories[[:space:]]*\{/s//repositories {\n google()\n mavenCentral()/' "$F" + grep -q 'mavenCentral()' "$F" || sed -E -i '0,/repositories[[:space:]]*\{/s//repositories {\n google()\n mavenCentral()/' "$F" + fi + fi +done + +# Edit app/build.gradle +python3 - "$APP_BUILD_GRADLE" <<'PY' +import sys, re, pathlib +p = pathlib.Path(sys.argv[1]); txt = p.read_text(); orig = txt; changed = False + +def strip_block(name, s): + return re.sub(rf'(?ms)^\s*{name}\s*\{{.*?\}}\s*', '', s) + +module_view = strip_block('buildscript', strip_block('pluginManagement', txt)) + +# 1) android { compileSdkVersion/targetSdkVersion } +def ensure_sdk(body): + # If android { ... } exists, update/insert inside defaultConfig and the android block + if re.search(r'(?m)^\s*android\s*\{', body): + # compileSdkVersion + if re.search(r'(?m)^\s*compileSdkVersion\s+\d+', body) is None: + body = re.sub(r'(?m)(^\s*android\s*\{)', r'\1\n compileSdkVersion 33', body, count=1) + else: + body = re.sub(r'(?m)^\s*compileSdkVersion\s+\d+', ' compileSdkVersion 33', body) + # targetSdkVersion + if re.search(r'(?ms)^\s*defaultConfig\s*\{.*?^\s*\}', body): + dc = re.search(r'(?ms)^\s*defaultConfig\s*\{.*?^\s*\}', body) + block = dc.group(0) + if re.search(r'(?m)^\s*targetSdkVersion\s+\d+', block): + block2 = re.sub(r'(?m)^\s*targetSdkVersion\s+\d+', ' targetSdkVersion 33', block) + else: + block2 = re.sub(r'(\{\s*)', r'\1\n targetSdkVersion 33', block, count=1) + body = body[:dc.start()] + block2 + body[dc.end():] + else: + body = re.sub(r'(?m)(^\s*android\s*\{)', r'\1\n defaultConfig {\n targetSdkVersion 33\n }', body, count=1) + else: + # No android block at all: add minimal + body += '\n\nandroid {\n compileSdkVersion 33\n defaultConfig { targetSdkVersion 33 }\n}\n' + return body + +txt2 = ensure_sdk(txt) +if txt2 != txt: txt = txt2; module_view = strip_block('buildscript', strip_block('pluginManagement', txt)); changed = True + +# 2) testInstrumentationRunner -> AndroidX +if "androidx.test.runner.AndroidJUnitRunner" not in module_view: + t2, n = re.subn(r'(?m)^\s*testInstrumentationRunner\s*".*?"\s*$', ' testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"', txt) + if n == 0: + t2, n = re.subn(r'(?m)(^\s*defaultConfig\s*\{)', r'\1\n testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"', txt, count=1) + if n == 0: + t2, n = re.subn(r'(?ms)(^\s*android\s*\{)', r'\1\n defaultConfig {\n testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"\n }', txt, count=1) + if n: txt = t2; module_view = strip_block('buildscript', strip_block('pluginManagement', txt)); changed = True + +# 3) remove legacy useLibrary lines +t2, n = re.subn(r'(?m)^\s*useLibrary\s+\'android\.test\.(base|mock|runner)\'\s*$', '', txt) +if n: txt = t2; module_view = strip_block('buildscript', strip_block('pluginManagement', txt)); changed = True + +# 4) deps: choose androidTestImplementation vs androidTestCompile +uses_modern = re.search(r'(?m)^\s*(implementation|api|testImplementation|androidTestImplementation)\b', module_view) is not None +conf = "androidTestImplementation" if uses_modern else "androidTestCompile" +need = [ + ("androidx.test.ext:junit:1.1.5", conf), # AndroidJUnit4 + ("androidx.test:runner:1.5.2", conf), + ("androidx.test:core:1.5.0", conf), + ("androidx.test.services:storage:1.4.2", conf), +] +to_add = [(c, k) for (c, k) in need if c not in module_view] + +if to_add: + block = "\n\ndependencies {\n" + "".join([f" {k} \"{c}\"\n" for c, k in to_add]) + "}\n" + txt = txt.rstrip() + block + changed = True + +if changed and txt != orig: + if not txt.endswith("\n"): txt += "\n" + p.write_text(txt) + print(f"Patched app/build.gradle (SDK=33; deps via {conf})") +else: + print("No changes needed in app/build.gradle") +PY +# --- END: robust Gradle patch --- + +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" +cat >"$TEST_CLASS" <<'EOF' +package @PACKAGE@; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.util.Base64; +import android.util.DisplayMetrics; +import android.view.View; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.ByteArrayOutputStream; + +@RunWith(AndroidJUnit4.class) +public class HelloCodenameOneInstrumentedTest { + + private static void println(String s) { System.out.println(s); } + + @Test + public void testUseAppContext_andEmitScreenshot() throws Exception { + Context ctx = ApplicationProvider.getApplicationContext(); + String pkg = "@PACKAGE@"; + Assert.assertEquals("Package mismatch", pkg, ctx.getPackageName()); + + // Resolve real launcher intent (don’t hard-code activity) + Intent launch = ctx.getPackageManager().getLaunchIntentForPackage(pkg); + if (launch == null) { + // Fallback MAIN/LAUNCHER inside this package + Intent q = new Intent(Intent.ACTION_MAIN); + q.addCategory(Intent.CATEGORY_LAUNCHER); + q.setPackage(pkg); + launch = q; + } + launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + println("CN1SS:INFO: about to launch Activity"); + byte[] pngBytes = null; + + try (ActivityScenario scenario = ActivityScenario.launch(launch)) { + // give the activity a tiny moment to layout + Thread.sleep(750); + + println("CN1SS:INFO: activity launched"); + + final byte[][] holder = new byte[1][]; + scenario.onActivity(activity -> { + try { + View root = activity.getWindow().getDecorView().getRootView(); + int w = root.getWidth(); + int h = root.getHeight(); + if (w <= 0 || h <= 0) { + DisplayMetrics dm = activity.getResources().getDisplayMetrics(); + w = Math.max(1, dm.widthPixels); + h = Math.max(1, dm.heightPixels); + int sw = View.MeasureSpec.makeMeasureSpec(w, View.MeasureSpec.EXACTLY); + int sh = View.MeasureSpec.makeMeasureSpec(h, View.MeasureSpec.EXACTLY); + root.measure(sw, sh); + root.layout(0, 0, w, h); + println("CN1SS:INFO: forced layout to " + w + "x" + h); + } else { + println("CN1SS:INFO: natural layout " + w + "x" + h); + } + + Bitmap bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); + Canvas c = new Canvas(bmp); + root.draw(c); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(Math.max(1024, w * h / 2)); + boolean ok = bmp.compress(Bitmap.CompressFormat.PNG, 100, baos); + if (!ok) throw new RuntimeException("Bitmap.compress returned false"); + holder[0] = baos.toByteArray(); + println("CN1SS:INFO: png_bytes=" + holder[0].length); + } catch (Throwable t) { + println("CN1SS:ERR: onActivity " + t); + t.printStackTrace(System.out); + } + }); + + pngBytes = holder[0]; + } catch (Throwable t) { + println("CN1SS:ERR: launch " + t); + t.printStackTrace(System.out); + } + + if (pngBytes == null || pngBytes.length == 0) { + println("CN1SS:END"); // terminator for the runner parser + Assert.fail("Screenshot capture produced 0 bytes"); + return; + } + + // Chunk & emit (safe for Gradle/logcat capture) + String b64 = Base64.encodeToString(pngBytes, Base64.NO_WRAP); + final int CHUNK = 2000; + int count = 0; + for (int pos = 0; pos < b64.length(); pos += CHUNK) { + int end = Math.min(pos + CHUNK, b64.length()); + System.out.println("CN1SS:" + String.format("%06d", pos) + ":" + b64.substring(pos, end)); + count++; + } + println("CN1SS:INFO: chunks=" + count + " total_b64_len=" + b64.length()); + System.out.println("CN1SS:END"); + System.out.flush(); + } +} +EOF +sed -i "s|@PACKAGE@|$PACKAGE_NAME|g" "$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" @@ -282,4 +523,13 @@ export JAVA_HOME="$ORIGINAL_JAVA_HOME" APK_PATH=$(find "$GRADLE_PROJECT_DIR" -path "*/outputs/apk/debug/*.apk" | head -n 1 || true) [ -n "$APK_PATH" ] || { ba_log "Gradle build completed but no APK was found" >&2; exit 1; } -ba_log "Successfully built Android APK at $APK_PATH" \ No newline at end of file +ba_log "Successfully built Android APK at $APK_PATH" + +if [ -n "${GITHUB_OUTPUT:-}" ]; then + { + echo "gradle_project_dir=$GRADLE_PROJECT_DIR" + echo "apk_path=$APK_PATH" + echo "instrumentation_test_class=$PACKAGE_NAME.HelloCodenameOneInstrumentedTest" + } >> "$GITHUB_OUTPUT" + ba_log "Published GitHub Actions outputs for downstream steps" +fi diff --git a/scripts/run-android-instrumentation-tests.sh b/scripts/run-android-instrumentation-tests.sh new file mode 100755 index 0000000000..72de2769d9 --- /dev/null +++ b/scripts/run-android-instrumentation-tests.sh @@ -0,0 +1,241 @@ +#!/usr/bin/env bash +# Run instrumentation tests and reconstruct screenshot emitted as chunked Base64 (NO ADB) +set -euo pipefail + +ra_log() { echo "[run-android-instrumentation-tests] $1"; } + +# ---- Helpers --------------------------------------------------------------- + +ensure_dir() { mkdir -p "$1" 2>/dev/null || true; } + +# Count CN1SS chunk lines in a file +count_chunks() { + local f="${1:-}" n="0" + if [ -n "$f" ] && [ -r "$f" ]; then + n="$(grep -cE 'CN1SS:[0-9]{6}:' "$f" 2>/dev/null || echo 0)" + fi + n="${n//[^0-9]/}"; [ -z "$n" ] && n="0" + printf '%s\n' "$n" +} + +# Extract ordered CN1SS payload (by 6-digit index) and decode to PNG +# Usage: extract_cn1ss_stream > +# Extract ordered CN1SS payload (by 6-digit index) from ANYWHERE in the line +# Usage: extract_cn1ss_stream > +extract_cn1ss_stream() { + local f="$1" + awk ' + { + # Find CN1SS:<6digits>: anywhere in the line + if (match($0, /CN1SS:[0-9][0-9][0-9][0-9][0-9][0-9]:/)) { + # Extract the 6-digit index just after CN1SS: + idx = substr($0, RSTART + 6, 6) + 0 + # Payload is everything after the matched token + payload = substr($0, RSTART + RLENGTH) + gsub(/[ \t\r\n]/, "", payload) + gsub(/[^A-Za-z0-9+\/=]/, "", payload) + printf "%06d %s\n", idx, payload + next + } + # Pass through CN1SS meta lines for debugging (INFO/END/etc), even if prefixed + if (index($0, "CN1SS:") > 0) { + print "#META " $0 > "/dev/stderr" + } + } + ' "$f" \ + | sort -n \ + | awk '{ $1=""; sub(/^ /,""); printf "%s", $0 }' \ + | tr -d "\r\n" +} + +# Verify PNG signature + non-zero size +verify_png() { + local f="$1" + [ -s "$f" ] || return 1 + head -c 8 "$f" | od -An -t x1 | tr -d ' \n' | grep -qi '^89504e470d0a1a0a$' +} + +# ---- Args & environment ---------------------------------------------------- + +if [ $# -lt 1 ]; then + ra_log "Usage: $0 " >&2 + exit 2 +fi +GRADLE_PROJECT_DIR="$1" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$REPO_ROOT" + +TMPDIR="${TMPDIR:-/tmp}"; TMPDIR="${TMPDIR%/}" +DOWNLOAD_DIR="${TMPDIR}/codenameone-tools" +ENV_DIR="$DOWNLOAD_DIR/tools" +ENV_FILE="$ENV_DIR/env.sh" + +ARTIFACTS_DIR="${ARTIFACTS_DIR:-${GITHUB_WORKSPACE:-$REPO_ROOT}/artifacts}" +ensure_dir "$ARTIFACTS_DIR" +TEST_LOG="$ARTIFACTS_DIR/connectedAndroidTest.log" +SCREENSHOT_OUT="$ARTIFACTS_DIR/emulator-screenshot.png" + +ra_log "Loading workspace environment from $ENV_FILE" +[ -f "$ENV_FILE" ] || { ra_log "Missing env file: $ENV_FILE"; exit 3; } +# shellcheck disable=SC1090 +source "$ENV_FILE" + +[ -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 LOGCATS < <( + find "$RESULTS_ROOT" -type f -name 'logcat-*.txt' -print 2>/dev/null +) || LOGCATS=() + +# 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="" + +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" +fi + +# Pick first logcat if any +LOGCAT_FILE="${LOGCATS[0]:-}" +if [ -z "${LOGCAT_FILE:-}" ] || [ ! -s "$LOGCAT_FILE" ]; then + ra_log "FATAL: No logcat-*.txt produced by connectedDebugAndroidTest (cannot extract CN1SS chunks)." + exit 12 +fi + +# ---- Chunk accounting (diagnostics) --------------------------------------- + +XML_CHUNKS_TOTAL=0 +for x in "${XMLS[@]}"; do + c="$(count_chunks "$x")"; c="${c//[^0-9]/}"; : "${c:=0}" + XML_CHUNKS_TOTAL=$(( XML_CHUNKS_TOTAL + c )) +done +LOGCAT_CHUNKS="$(count_chunks "$LOGCAT_FILE")"; LOGCAT_CHUNKS="${LOGCAT_CHUNKS//[^0-9]/}"; : "${LOGCAT_CHUNKS:=0}" +EXECLOG_CHUNKS="$(count_chunks "${TEST_EXEC_LOG:-}")"; EXECLOG_CHUNKS="${EXECLOG_CHUNKS//[^0-9]/}"; : "${EXECLOG_CHUNKS:=0}" + +ra_log "Chunk counts -> XML: ${XML_CHUNKS_TOTAL} | logcat: ${LOGCAT_CHUNKS} | test-results.log: ${EXECLOG_CHUNKS}" + +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 +fi + +# ---- Reassemble (prefer XML → logcat → exec log) -------------------------- + +: > "$SCREENSHOT_OUT" +SOURCE="" + +if [ "${#XMLS[@]}" -gt 0 ] && [ "${XML_CHUNKS_TOTAL:-0}" -gt 0 ]; then + for x in "${XMLS[@]}"; do + c="$(count_chunks "$x")"; c="${c//[^0-9]/}"; : "${c:=0}" + [ "$c" -gt 0 ] || continue + ra_log "Reassembling from XML: $x (chunks=$c)" + if extract_cn1ss_stream "$x" | base64 -d > "$SCREENSHOT_OUT" 2>/dev/null; then + if verify_png "$SCREENSHOT_OUT"; then SOURCE="XML"; break; fi + fi + done +fi + +if [ -z "$SOURCE" ] && [ "${LOGCAT_CHUNKS:-0}" -gt 0 ]; then + ra_log "Reassembling from logcat: $LOGCAT_FILE (chunks=$LOGCAT_CHUNKS)" + if extract_cn1ss_stream "$LOGCAT_FILE" | base64 -d > "$SCREENSHOT_OUT" 2>/dev/null; then + if verify_png "$SCREENSHOT_OUT"; then SOURCE="LOGCAT"; fi + fi +fi + +if [ -z "$SOURCE" ] && [ -n "${TEST_EXEC_LOG:-}" ] && [ "${EXECLOG_CHUNKS:-0}" -gt 0 ]; then + ra_log "Reassembling from test-results.log: $TEST_EXEC_LOG (chunks=$EXECLOG_CHUNKS)" + if extract_cn1ss_stream "$TEST_EXEC_LOG" | base64 -d > "$SCREENSHOT_OUT" 2>/dev/null; then + if verify_png "$SCREENSHOT_OUT"; then SOURCE="EXECLOG"; fi + fi +fi + +# ---- Final validation / failure paths ------------------------------------- + +if [ -z "$SOURCE" ]; then + ra_log "FATAL: Failed to extract/decode CN1SS payload from any source" + # Keep partial for debugging + RAW_B64_OUT="${SCREENSHOT_OUT}.raw.b64" + { + # Try to emit concatenated base64 from whichever had chunks (priority logcat, then XML, then exec) + if [ "${LOGCAT_CHUNKS:-0}" -gt 0 ]; then extract_cn1ss_stream "$LOGCAT_FILE"; fi + if [ "${XML_CHUNKS_TOTAL:-0}" -gt 0 ] && [ "${LOGCAT_CHUNKS:-0}" -eq 0 ]; then + # concatenate all XMLs + for x in "${XMLS[@]}"; do + if [ "$(count_chunks "$x")" -gt 0 ]; then extract_cn1ss_stream "$x"; fi + done + fi + if [ -n "${TEST_EXEC_LOG:-}" ] && [ "${EXECLOG_CHUNKS:-0}" -gt 0 ] && [ "${LOGCAT_CHUNKS:-0}" -eq 0 ] && [ "${XML_CHUNKS_TOTAL:-0}" -eq 0 ]; then + extract_cn1ss_stream "$TEST_EXEC_LOG" + 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 + # Emit contextual INFO lines + grep -n 'CN1SS:INFO' "$LOGCAT_FILE" 2>/dev/null || true + exit 12 +fi + +# Size & signature check (belt & suspenders) +if ! verify_png "$SCREENSHOT_OUT"; then + ra_log "STAGE:BAD_PNG_SIGNATURE -> Not a PNG" + file "$SCREENSHOT_OUT" || true + exit 14 +fi + +ra_log "SUCCESS -> screenshot saved (${SOURCE}), size: $(stat -c '%s' "$SCREENSHOT_OUT") bytes at $SCREENSHOT_OUT" + +# Copy useful artifacts for GH Actions +cp -f "$LOGCAT_FILE" "$ARTIFACTS_DIR/$(basename "$LOGCAT_FILE")" 2>/dev/null || true +for x in "${XMLS[@]}"; do + cp -f "$x" "$ARTIFACTS_DIR/$(basename "$x")" 2>/dev/null || true +done +[ -n "${TEST_EXEC_LOG:-}" ] && cp -f "$TEST_EXEC_LOG" "$ARTIFACTS_DIR/test-results.log" 2>/dev/null || true + +exit 0 \ No newline at end of file