diff --git a/.github/workflows/scripts-android.yml b/.github/workflows/scripts-android.yml index 6f19f272ee..e3b9a08dd0 100644 --- a/.github/workflows/scripts-android.yml +++ b/.github/workflows/scripts-android.yml @@ -1,7 +1,7 @@ --- name: Test Android build scripts -'on': +on: pull_request: paths: - '.github/workflows/scripts-android.yml' @@ -90,12 +90,87 @@ jobs: sudo udevadm trigger --name-match=kvm - name: Run Android instrumentation tests uses: reactivecircus/android-emulator-runner@v2 + env: + CN1SS_SKIP_COMMENT: "1" + RUN_CONNECTED_TESTS: "1" 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: Collect Android coverage artifacts + if: always() + run: ./scripts/android/collect-android-coverage-artifacts.sh + - name: Upload Android coverage artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: android-coverage-artifacts + path: android-quality-artifacts + if-no-files-found: ignore + - name: Publish Android coverage preview + if: ${{ always() && github.server_url == 'https://github.com' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + id: publish-android-coverage + env: + SERVER_URL: ${{ github.server_url }} + REPOSITORY: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + run: ./scripts/android/publish-android-coverage-preview.sh + - name: Generate Android test report comment + if: always() + env: + COVERAGE_HTML_URL: ${{ steps.publish-android-coverage.outputs.coverage_url }} + ANDROID_PREVIEW_BASE_URL: ${{ steps.publish-android-coverage.outputs.preview_base }} + run: python3 ./scripts/android/generate-android-report-comment.py + - name: Upload Android report comment + if: always() + uses: actions/upload-artifact@v4 + with: + name: android-report-comment + path: android-comment.md + - name: Publish Android test report comment + if: ${{ github.event_name == 'pull_request' }} + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const marker = ''; + const commentPath = 'android-comment.md'; + if (!fs.existsSync(commentPath)) { + core.warning('android-comment.md was not generated.'); + return; + } + const body = fs.readFileSync(commentPath, 'utf8'); + if (!body.includes(marker)) { + core.warning('Comment marker missing from android-comment.md.'); + return; + } + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const { data: comments } = await github.rest.issues.listComments({ + owner, + repo, + issue_number, + per_page: 100, + }); + const existing = comments.find(comment => comment.body && comment.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + } - name: Upload emulator screenshot if: always() # still collect it if tests fail uses: actions/upload-artifact@v4 diff --git a/scripts/android/collect-android-coverage-artifacts.sh b/scripts/android/collect-android-coverage-artifacts.sh new file mode 100755 index 0000000000..3a99474fb1 --- /dev/null +++ b/scripts/android/collect-android-coverage-artifacts.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ARTIFACT_ROOT="android-quality-artifacts/coverage" +SOURCE_ROOT="artifacts/android-coverage" + +mkdir -p "${ARTIFACT_ROOT}" + +if [ -d "${SOURCE_ROOT}/site/jacoco" ]; then + mkdir -p "${ARTIFACT_ROOT}/html" + cp -R "${SOURCE_ROOT}/site/jacoco/." "${ARTIFACT_ROOT}/html/" +fi + +if [ -d "artifacts/android-previews" ]; then + mkdir -p "${ARTIFACT_ROOT}/previews" + cp -R "artifacts/android-previews/." "${ARTIFACT_ROOT}/previews/" +fi + +for file in coverage.ec coverage.json jacoco-report.log; do + if [ -f "${SOURCE_ROOT}/${file}" ]; then + cp "${SOURCE_ROOT}/${file}" "${ARTIFACT_ROOT}/" + fi +done diff --git a/scripts/android/generate-android-report-comment.py b/scripts/android/generate-android-report-comment.py new file mode 100755 index 0000000000..af0fb2748c --- /dev/null +++ b/scripts/android/generate-android-report-comment.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +import json +import os +import re +from pathlib import Path + +marker = "" +comment_lines = [marker, "### Android screenshot tests", ""] + +screenshot_path = Path("artifacts/screenshot-comment.md") + +# This is set by publish-android-coverage-preview.sh via workflow outputs +preview_base = os.environ.get("ANDROID_PREVIEW_BASE_URL", "").strip() +if preview_base.endswith("/"): + preview_base = preview_base.rstrip("/") + +# ---- 1) Screenshot section ---- +if screenshot_path.is_file(): + screenshot_text = screenshot_path.read_text().strip() + # Strip the inner screenshot marker if present + screenshot_text = screenshot_text.replace("", "").strip() + if not screenshot_text: + screenshot_text = "✅ Native Android screenshot tests passed." +else: + screenshot_text = "✅ Native Android screenshot tests passed." + +# If we have a preview_base, replace (attachment:foo.jpg) with a raw.githubusercontent.com URL +if screenshot_text and preview_base: + pattern = re.compile(r"\(attachment:([^)]+)\)") + + def replace_attachment(match: re.Match) -> str: + name = match.group(1).strip() + # Result: (https://raw.githubusercontent.com/.../android-runs/.../previews/name) + return f"({preview_base}/{name})" + + screenshot_text = pattern.sub(replace_attachment, screenshot_text) + +comment_lines.append(screenshot_text) +comment_lines.append("") +comment_lines.append("### Android coverage") +comment_lines.append("") + +# ---- 2) Coverage section ---- +coverage_section = "Coverage report was not generated." +coverage_path = Path("artifacts/android-coverage/coverage.json") + +if coverage_path.is_file(): + data = json.loads(coverage_path.read_text()) + if data.get("available"): + lines = data.get("lines", {}) + covered = lines.get("covered", 0) + total = lines.get("total", 0) + percent = data.get("percent", 0.0) + detail = f"{covered}/{total} lines" if total else "0/0 lines" + + coverage_html = os.environ.get("COVERAGE_HTML_URL", "").strip() + if coverage_html: + coverage_section = ( + f"- 📊 **Line coverage:** {percent:.2f}% ({detail}) " + f"([HTML report]({coverage_html}))" + ) + else: + coverage_section = f"- 📊 **Line coverage:** {percent:.2f}% ({detail})" + else: + note = data.get("note") + if note: + coverage_section = f"Coverage report is unavailable ({note})." + else: + coverage_section = "Coverage report is unavailable." + +comment_lines.append(coverage_section) +comment_lines.append("") + +Path("android-comment.md").write_text("\n".join(comment_lines) + "\n") \ No newline at end of file diff --git a/scripts/android/lib/PatchGradleFiles.java b/scripts/android/lib/PatchGradleFiles.java index 549da1dbf5..311ba5b005 100644 --- a/scripts/android/lib/PatchGradleFiles.java +++ b/scripts/android/lib/PatchGradleFiles.java @@ -106,6 +106,10 @@ private static boolean patchAppBuildGradle(Path path, int compileSdk, int target content = r.content(); changed |= r.changed(); + r = ensureCoverageEnabled(content); + content = r.content(); + changed |= r.changed(); + if (changed) { Files.writeString(path, ensureTrailingNewline(content), StandardCharsets.UTF_8); } @@ -247,6 +251,24 @@ private static Result ensureTestDependencies(String content) { return new Result(content + block, true); } + private static Result ensureCoverageEnabled(String content) { + if (content.contains("testCoverageEnabled")) { + return new Result(content, false); + } + StringBuilder builder = new StringBuilder(content); + if (!content.endsWith("\n")) { + builder.append('\n'); + } + builder.append("android {\n") + .append(" buildTypes {\n") + .append(" debug {\n") + .append(" testCoverageEnabled true\n") + .append(" }\n") + .append(" }\n") + .append("}\n"); + return new Result(builder.toString(), true); + } + private static String ensureTrailingNewline(String content) { return content.endsWith("\n") ? content : content + "\n"; } diff --git a/scripts/android/publish-android-coverage-preview.sh b/scripts/android/publish-android-coverage-preview.sh new file mode 100755 index 0000000000..2970d6620d --- /dev/null +++ b/scripts/android/publish-android-coverage-preview.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +HTML_DIR="artifacts/android-coverage/site/jacoco" +PREVIEW_DIR="artifacts/android-previews" + +if [ ! -d "${HTML_DIR}" ] && { [ ! -d "${PREVIEW_DIR}" ] || ! find "${PREVIEW_DIR}" -type f \( -name '*.png' -o -name '*.jpg' -o -name '*.jpeg' \) -print -quit >/dev/null; }; then + echo "No coverage HTML report or screenshot previews generated; skipping preview publication." + exit 0 +fi + +tmp_dir=$(mktemp -d) +run_dir="android-runs/${RUN_ID:-manual}-${RUN_ATTEMPT:-0}" +dest_dir="${tmp_dir}/${run_dir}" +mkdir -p "${dest_dir}" + +if [ -d "${HTML_DIR}" ]; then + mkdir -p "${dest_dir}/coverage" + cp -R "${HTML_DIR}/." "${dest_dir}/coverage/" +fi + +if [ -d "${PREVIEW_DIR}" ]; then + if find "${PREVIEW_DIR}" -type f \( -name '*.png' -o -name '*.jpg' -o -name '*.jpeg' \) -print -quit >/dev/null; then + mkdir -p "${dest_dir}/previews" + cp -R "${PREVIEW_DIR}/." "${dest_dir}/previews/" + fi +fi + +cat <<'README' > "${tmp_dir}/README.md" +# Android quality previews + +This branch is automatically managed by the Android CI workflow and may be force-pushed. +README + +git -C "${tmp_dir}" init -b previews >/dev/null +git -C "${tmp_dir}" config user.name "github-actions[bot]" +git -C "${tmp_dir}" config user.email "github-actions[bot]@users.noreply.github.com" +git -C "${tmp_dir}" add . +git -C "${tmp_dir}" commit -m "Publish Android quality previews for run ${RUN_ID} (attempt ${RUN_ATTEMPT})" >/dev/null + +remote_url="${SERVER_URL}/${REPOSITORY}.git" +token_remote_url="${remote_url/https:\/\//https://x-access-token:${GITHUB_TOKEN}@}" +git -C "${tmp_dir}" push --force "${token_remote_url}" previews:quality-report-previews >/dev/null + +commit_sha=$(git -C "${tmp_dir}" rev-parse HEAD) +raw_base="https://raw.githubusercontent.com/${REPOSITORY}/${commit_sha}/${run_dir}" +preview_base="https://htmlpreview.github.io/?${raw_base}" + +if [ -d "${dest_dir}/coverage" ]; then + echo "coverage_commit=${commit_sha}" >> "$GITHUB_OUTPUT" + echo "coverage_url=${preview_base}/coverage/index.html" >> "$GITHUB_OUTPUT" +fi + +if [ -d "${dest_dir}/previews" ]; then + echo "preview_base=${raw_base}/previews" >> "$GITHUB_OUTPUT" +fi diff --git a/scripts/android/tests/Cn1ssChunkTools.java b/scripts/android/tests/Cn1ssChunkTools.java index 7fd03c0ab8..c71ad20e1c 100644 --- a/scripts/android/tests/Cn1ssChunkTools.java +++ b/scripts/android/tests/Cn1ssChunkTools.java @@ -100,16 +100,30 @@ private static void runExtract(String[] args) throws IOException { if (path == null) { throw new IllegalArgumentException("Path is required for extract"); } + if (path == null) { + throw new IllegalArgumentException("Path is required for extract"); + } String targetTest = test != null ? test : DEFAULT_TEST_NAME; - List chunks = new ArrayList<>(); - for (Chunk chunk : iterateChunks(path, Optional.ofNullable(targetTest), Optional.ofNullable(channel))) { - chunks.add(chunk); + +// Collect chunks by index so that if the same test/channel was emitted multiple +// times (e.g. test re-runs), only the last payload for a given index is kept. + java.util.Map byIndex = new java.util.HashMap<>(); + for (Chunk chunk : iterateChunks(path, + Optional.ofNullable(targetTest), + Optional.ofNullable(channel))) { + // For duplicate indices, later entries overwrite earlier ones. + byIndex.put(chunk.index, chunk.payload); } - Collections.sort(chunks); + + // Rebuild the base64 payload in ascending index order + List indices = new ArrayList<>(byIndex.keySet()); + Collections.sort(indices); + StringBuilder payload = new StringBuilder(); - for (Chunk chunk : chunks) { - payload.append(chunk.payload); + for (int idx : indices) { + payload.append(byIndex.get(idx)); } + if (decode) { byte[] data; try { diff --git a/scripts/android/tests/HelloCodenameOneInstrumentedTest.java b/scripts/android/tests/HelloCodenameOneInstrumentedTest.java index a72a94d9b1..5da5a25c0d 100644 --- a/scripts/android/tests/HelloCodenameOneInstrumentedTest.java +++ b/scripts/android/tests/HelloCodenameOneInstrumentedTest.java @@ -25,17 +25,17 @@ import org.junit.Assume; import org.junit.Test; import org.junit.runner.RunWith; +import org.junit.AfterClass; import java.io.ByteArrayOutputStream; import java.util.Locale; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -import com.codename1.io.Log; @RunWith(AndroidJUnit4.class) public class HelloCodenameOneInstrumentedTest { - private static final int CHUNK_SIZE = 2000; + private static final int CHUNK_SIZE = 768; private static final String PREVIEW_CHANNEL = "PREVIEW"; private static final int[] PREVIEW_JPEG_QUALITIES = new int[] {60, 50, 40, 35, 30, 25, 20, 18, 16, 14, 12, 10, 8, 6, 5, 4, 3, 2, 1}; @@ -190,7 +190,7 @@ private static ScreenshotCapture captureScreenshot(ActivityScenario sc } } catch (Throwable t) { println("CN1SS:ERR:test=" + testName + " " + t); - Log.e(t); + com.codename1.io.Log.e(t); } finally { latch.countDown(); } @@ -242,18 +242,20 @@ private static void emitScreenshotChannel(byte[] bytes, String testName, String for (int pos = 0; pos < b64.length(); pos += CHUNK_SIZE) { int end = Math.min(pos + CHUNK_SIZE, b64.length()); String chunk = b64.substring(pos, end); - System.out.println( + String line = prefix + ":" + safeName + ":" + String.format(Locale.US, "%06d", pos) + ":" - + chunk); + + chunk; + System.out.println(line); count++; } println("CN1SS:INFO:test=" + safeName + " chunks=" + count + " total_b64_len=" + b64.length()); - System.out.println(prefix + ":END:" + safeName); + String endLine = prefix + ":END:" + safeName; + System.out.println(endLine); System.out.flush(); } @@ -379,4 +381,10 @@ public void testBrowserComponentScreenshot() throws Exception { emitScreenshot(capture, BROWSER_TEST); } + + @AfterClass + public static void suiteFinished() { + println("CN1SS:SUITE:FINISHED"); + System.out.flush(); + } } diff --git a/scripts/android/tests/ProcessScreenshots.java b/scripts/android/tests/ProcessScreenshots.java index c7cd8e2ab4..5baf1ef993 100644 --- a/scripts/android/tests/ProcessScreenshots.java +++ b/scripts/android/tests/ProcessScreenshots.java @@ -87,6 +87,14 @@ static Map buildResults( } catch (Exception ex) { record.put("status", "error"); record.put("message", ex.getMessage()); + if (emitBase64 && Files.exists(actualPath)) { + try { + CommentPayload payload = loadPreviewOrBuild(testName, actualPath, previewDir); + recordPayload(record, payload, actualPath.getFileName().toString(), previewDir); + } catch (Exception ignored) { + // Don't let preview generation failure hide the comparison error + } + } } } results.add(record); @@ -113,13 +121,22 @@ private static CommentPayload loadPreviewOrBuild(String testName, Path actualPat private static CommentPayload loadExternalPreviewPayload(String testName, Path previewDir) throws IOException { String slug = slugify(testName); - Path jpg = previewDir.resolve(slug + ".jpg"); - Path jpeg = previewDir.resolve(slug + ".jpeg"); - Path png = previewDir.resolve(slug + ".png"); + List baseNames = new ArrayList<>(); + if (slug != null && !slug.isEmpty()) { + baseNames.add(slug); + } + if (testName != null && !testName.isEmpty() && baseNames.stream().noneMatch(s -> s.equals(testName))) { + baseNames.add(testName); + } List candidates = new ArrayList<>(); - if (Files.exists(jpg)) candidates.add(jpg); - if (Files.exists(jpeg)) candidates.add(jpeg); - if (Files.exists(png)) candidates.add(png); + for (String base : baseNames) { + Path jpg = previewDir.resolve(base + ".jpg"); + Path jpeg = previewDir.resolve(base + ".jpeg"); + Path png = previewDir.resolve(base + ".png"); + if (Files.exists(jpg)) candidates.add(jpg); + if (Files.exists(jpeg)) candidates.add(jpeg); + if (Files.exists(png)) candidates.add(png); + } if (candidates.isEmpty()) { return null; } diff --git a/scripts/android/tests/RenderScreenshotReport.java b/scripts/android/tests/RenderScreenshotReport.java index 0276ec5748..6b86364164 100644 --- a/scripts/android/tests/RenderScreenshotReport.java +++ b/scripts/android/tests/RenderScreenshotReport.java @@ -100,11 +100,19 @@ private static SummaryAndComment buildSummaryAndComment(Map data base64Quality, base64Note, test + ".png")); } case "error" -> { - message = "Comparison error: " + stringValue(result.get("message"), "unknown error"); + String raw = stringValue(result.get("message"), "unknown error"); + message = "Comparison failed: " + raw; copyFlag = "1"; - commentEntries.add(commentEntry(test, "comparison error", message, previewName, previewPath, previewMime, - previewNote, previewQuality, null, base64Omitted, base64Length, base64Mime, base64Codec, - base64Quality, base64Note, test + ".png")); + commentEntries.add(commentEntry( + test, + "comparison error", + message, + previewName, previewPath, previewMime, + previewNote, previewQuality, + base64, base64Omitted, base64Length, base64Mime, base64Codec, + base64Quality, base64Note, + test + ".png" + )); } case "missing_actual" -> { message = "Actual screenshot missing (test did not produce output)."; @@ -188,6 +196,17 @@ private static void addPreviewSection(List lines, Map en String previewNote = stringValue(entry.get("preview_note"), null); String base64Note = stringValue(entry.get("base64_note"), null); String previewMime = stringValue(entry.get("preview_mime"), null); + String base64 = stringValue(entry.get("base64"), null); + String base64Mime = stringValue(entry.get("base64_mime"), "image/png"); + String previewPath = stringValue(entry.get("preview_path"), null); + if ((previewName == null || previewName.isEmpty()) && previewPath != null && !previewPath.isEmpty()) { + try { + previewName = java.nio.file.Path.of(previewPath).getFileName().toString(); + entry.put("preview_name", previewName); + } catch (Exception ignored) { + // Fall back to data URI if the path cannot be parsed. + } + } List notes = new ArrayList<>(); if ("image/jpeg".equals(previewMime) && previewQuality != null) { notes.add("JPEG preview quality " + previewQuality); @@ -204,9 +223,9 @@ private static void addPreviewSection(List lines, Map en if (!notes.isEmpty()) { lines.add(" _Preview info: " + String.join("; ", notes) + "._"); } - } else if (entry.get("base64") != null) { + } else if (base64 != null && !base64.isEmpty()) { lines.add(""); - lines.add(" _Preview generated but could not be published; see workflow artifacts for JPEG preview._"); + lines.add(" ![" + entry.get("test") + "](data:" + base64Mime + ";base64," + base64 + ")"); if (!notes.isEmpty()) { lines.add(" _Preview info: " + String.join("; ", notes) + "._"); } diff --git a/scripts/build-android-app.sh b/scripts/build-android-app.sh index d7be421dff..bf9d86e4ae 100755 --- a/scripts/build-android-app.sh +++ b/scripts/build-android-app.sh @@ -303,6 +303,32 @@ echo "----- app/build.gradle tail -----" tail -n 80 "$APP_BUILD_GRADLE" | sed 's/^/| /' echo "---------------------------------" +INSTRUMENTATION_TEMPLATE="$SCRIPT_DIR/android/tests/HelloCodenameOneInstrumentedTest.java" +if [ -f "$INSTRUMENTATION_TEMPLATE" ]; then + ANDROID_TEST_DIR="$GRADLE_PROJECT_DIR/app/src/androidTest/java/${PACKAGE_PATH}" + mkdir -p "$ANDROID_TEST_DIR" + ba_log "Installing Android instrumentation tests into $ANDROID_TEST_DIR" + python3 - <<'PY' "$INSTRUMENTATION_TEMPLATE" "$PACKAGE_NAME" "$ANDROID_TEST_DIR/HelloCodenameOneInstrumentedTest.java" +import pathlib +import sys + +template_path = pathlib.Path(sys.argv[1]) +package_name = sys.argv[2] +output_path = pathlib.Path(sys.argv[3]) + +text = template_path.read_text() +output_path.write_text(text.replace("@PACKAGE@", package_name)) +PY +else + ba_log "WARNING: Instrumentation test template missing at $INSTRUMENTATION_TEMPLATE" +fi + +# Remove any stock ExampleInstrumentedTest that the template may have created +DEFAULT_TESTS_DIR="$GRADLE_PROJECT_DIR/app/src/androidTest/java" +if [ -d "$DEFAULT_TESTS_DIR" ]; then + find "$DEFAULT_TESTS_DIR" -type f -name 'ExampleInstrumentedTest.java' -print -delete || true +fi + ba_log "Invoking Gradle build in $GRADLE_PROJECT_DIR" chmod +x "$GRADLE_PROJECT_DIR/gradlew" ORIGINAL_JAVA_HOME="$JAVA_HOME" diff --git a/scripts/lib/cn1ss.sh b/scripts/lib/cn1ss.sh index ca68390dee..f47fce1242 100644 --- a/scripts/lib/cn1ss.sh +++ b/scripts/lib/cn1ss.sh @@ -203,22 +203,48 @@ cn1ss_decode_test_asset() { local entry source_type source_path count rm -f "$dest" 2>/dev/null || true + for entry in "$@"; do source_type="${entry%%:*}" source_path="${entry#*:}" [ -s "$source_path" ] || continue + count="$(cn1ss_count_chunks "$source_path" "$test" "$channel")" count="${count//[^0-9]/}"; : "${count:=0}" [ "$count" -gt 0 ] || continue + cn1ss_log "Reassembling test '$test' from ${source_type} source: $source_path (chunks=$count)" - if cn1ss_decode_binary "$source_path" "$test" "$channel" > "$dest" 2>/dev/null; then + + # --- Primary path: let Cn1ssChunkTools do the decode --- + if cn1ss_decode_binary "$source_path" "$test" "$channel" > "$dest"; then if [ -z "$verifier" ] || "$verifier" "$dest"; then echo "${source_type}:$(basename "$source_path")" return 0 fi + # If verifier failed, drop the broken file before trying fallback + rm -f "$dest" 2>/dev/null || true + fi + + # --- Fallback path: extract raw base64 and decode with coreutils base64 --- + if command -v base64 >/dev/null 2>&1; then + local tmp_b64="${dest}.b64" + + if cn1ss_extract_base64 "$source_path" "$test" "$channel" > "$tmp_b64" 2>/dev/null && [ -s "$tmp_b64" ]; then + if base64 -d "$tmp_b64" > "$dest" 2>/dev/null; then + if [ -z "$verifier" ] || "$verifier" "$dest"; then + cn1ss_log "Fallback base64 decoder succeeded for test '$test' from ${source_type}" + rm -f "$tmp_b64" 2>/dev/null || true + echo "${source_type}:$(basename "$source_path")" + return 0 + fi + fi + fi + + rm -f "$tmp_b64" "$dest" 2>/dev/null || true fi done - rm -f "$dest" 2>/dev/null || true + + # If we got here, both decode paths failed for all sources return 1 } @@ -252,6 +278,12 @@ cn1ss_file_size() { cn1ss_post_pr_comment() { local body_file="$1" local preview_dir="$2" + + if [ "${CN1SS_SKIP_COMMENT:-0}" != "0" ]; then + cn1ss_log "Skipping PR comment post (CN1SS_SKIP_COMMENT=${CN1SS_SKIP_COMMENT})" + return 0 + fi + if [ -z "$body_file" ] || [ ! -s "$body_file" ]; then cn1ss_log "Skipping PR comment post (no content)." return 0 diff --git a/scripts/run-android-instrumentation-tests.sh b/scripts/run-android-instrumentation-tests.sh index bac4b3a7bc..6f1666f958 100755 --- a/scripts/run-android-instrumentation-tests.sh +++ b/scripts/run-android-instrumentation-tests.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Run instrumentation tests and reconstruct screenshot emitted as chunked Base64 (NO ADB) -set -euo pipefail +set -o pipefail ra_log() { echo "[run-android-instrumentation-tests] $1"; } @@ -8,6 +8,20 @@ ra_log() { echo "[run-android-instrumentation-tests] $1"; } ensure_dir() { mkdir -p "$1" 2>/dev/null || true; } +# simple downloader used by coverage setup +download_with_tools() { + local url="$1" dest="$2" tmp="$dest.tmp" + rm -f "$tmp" 2>/dev/null || true + if command -v curl >/dev/null 2>&1; then + if curl -fsSL "$url" -o "$tmp"; then mv "$tmp" "$dest"; return 0; fi + fi + if command -v wget >/dev/null 2>&1; then + if wget -q -O "$tmp" "$url"; then mv "$tmp" "$dest"; return 0; fi + fi + rm -f "$tmp" 2>/dev/null || true + return 1 +} + # CN1SS helpers are implemented in Java for easier maintenance CN1SS_MAIN_CLASS="Cn1ssChunkTools" POST_COMMENT_CLASS="PostPrComment" @@ -47,6 +61,7 @@ SCREENSHOT_REF_DIR="$SCRIPT_DIR/android/screenshots" SCREENSHOT_TMP_DIR="$(mktemp -d "${TMPDIR}/cn1ss-XXXXXX" 2>/dev/null || echo "${TMPDIR}/cn1ss-tmp")" ensure_dir "$SCREENSHOT_TMP_DIR" SCREENSHOT_PREVIEW_DIR="$SCREENSHOT_TMP_DIR/previews" +CLASSFILE_TMP_DIR="" ra_log "Loading workspace environment from $ENV_FILE" [ -f "$ENV_FILE" ] || { ra_log "Missing env file: $ENV_FILE"; exit 3; } @@ -69,7 +84,10 @@ 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" -# ---- Prepare app + emulator state ----------------------------------------- +# ---- Either run connected tests (opt-in) OR do manual APK install/launch --- + +GRADLEW="$GRADLE_PROJECT_DIR/gradlew" +if [ ! -x "$GRADLEW" ]; then chmod +x "$GRADLEW"; fi APK_PATH="${2:-}" if [ -z "$APK_PATH" ]; then @@ -126,6 +144,9 @@ cleanup() { wait "$LOGCAT_PID" 2>/dev/null || true fi "$ADB_BIN" shell am force-stop "$PACKAGE_NAME" >/dev/null 2>&1 || true + if [ -n "${CLASSFILE_TMP_DIR:-}" ] && [ -d "$CLASSFILE_TMP_DIR" ]; then + rm -rf "$CLASSFILE_TMP_DIR" 2>/dev/null || true + fi } trap cleanup EXIT @@ -134,40 +155,71 @@ ra_log "Capturing device logcat to $TEST_LOG" 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" +if [ "${RUN_CONNECTED_TESTS:-0}" = "1" ]; then + # Run the Gradle instrumentation tests (produces coverage.ec when configured) + ra_log "Running connected Android instrumentation tests (Gradle)" + ORIGINAL_JAVA_HOME="${JAVA_HOME:-}" + export JAVA_HOME="$JAVA17_HOME" + if ! ( + cd "$GRADLE_PROJECT_DIR" && + { + if command -v sdkmanager >/dev/null 2>&1; then + yes | sdkmanager --licenses >/dev/null 2>&1 || true + elif [ -n "${ANDROID_SDK_ROOT:-}" ] && [ -x "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" ]; then + yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true + fi + ./gradlew --no-daemon connectedDebugAndroidTest + } + ); then + ra_log "WARN: connectedDebugAndroidTest failed (see Gradle output); continuing to parse CN1SS output if present" + fi + export JAVA_HOME="$ORIGINAL_JAVA_HOME" +else + # Legacy/manual: install and launch the APK, wait for CN1SS completion marker + 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 - else - ra_log "FATAL: Unable to determine launchable activity" - exit 10 - fi -fi - -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 + 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 - sleep 5 -done - -sleep 3 + 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 + sleep 3 +fi declare -a CN1SS_SOURCES=("LOGCAT:$TEST_LOG") @@ -203,15 +255,19 @@ ra_log "Detected CN1SS test streams: ${TEST_NAMES[*]}" declare -A TEST_OUTPUTS=() declare -A TEST_SOURCES=() declare -A PREVIEW_OUTPUTS=() +declare -A TEST_DECODE_OK=() ensure_dir "$SCREENSHOT_PREVIEW_DIR" +decode_rc=0 + 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_OUTPUTS["$test"]="$dest" TEST_SOURCES["$test"]="$source_label" ra_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 PREVIEW_OUTPUTS["$test"]="$preview_dest" @@ -219,8 +275,10 @@ for test in "${TEST_NAMES[@]}"; do else rm -f "$preview_dest" 2>/dev/null || true fi + + TEST_DECODE_OK["$test"]=1 else - ra_log "FATAL: Failed to extract/decode CN1SS payload for test '$test'" + ra_log "ERROR: Failed to extract/decode CN1SS payload for test '$test'" RAW_B64_OUT="$SCREENSHOT_TMP_DIR/${test}.raw.b64" if cn1ss_extract_base64 "$TEST_LOG" "$test" > "$RAW_B64_OUT" 2>/dev/null; then if [ -s "$RAW_B64_OUT" ]; then @@ -228,16 +286,19 @@ for test in "${TEST_NAMES[@]}"; do ra_log "Partial base64 saved at: $RAW_B64_OUT" fi fi - exit 12 + decode_rc=12 fi done # ---- Compare against stored references ------------------------------------ - COMPARE_ARGS=() for test in "${TEST_NAMES[@]}"; do - dest="${TEST_OUTPUTS[$test]:-}" - [ -n "$dest" ] || continue + # Safe under set -u: default empty if key not present + dest="${TEST_OUTPUTS[$test]-}" + if [ -z "$dest" ]; then + # Use a non-existent path so ProcessScreenshots reports missing_actual + dest="$SCREENSHOT_TMP_DIR/${test}.missing.png" + fi COMPARE_ARGS+=("--actual" "${test}=${dest}") done @@ -294,22 +355,212 @@ if [ -s "$SUMMARY_FILE" ]; then done < "$SUMMARY_FILE" fi +### ---- Code coverage (JaCoCo) --------------------------------------------- +ensure_dir "$ARTIFACTS_DIR/android-coverage" +COVERAGE_ARTIFACT_DIR="$ARTIFACTS_DIR/android-coverage" +COVERAGE_JSON="$COVERAGE_ARTIFACT_DIR/coverage.json" +CLASSFILE_TMP_DIR="$(mktemp -d "${TMPDIR}/cn1ss-classfiles-XXXXXX" 2>/dev/null || mktemp -d)" +PORT_PACKAGE_PATH="com/codename1/impl/android" +PORT_PACKAGE_GLOB="*${PORT_PACKAGE_PATH}*" +printf '{"available": false}\n' > "$COVERAGE_JSON" + +declare -a CLASSFILE_ARGS=() +declare -a CLASSFILE_SOURCES=() +declare -A CLASSFILE_SEEN=() +is_instrumented_class_path() { + case "$1" in + *jacocoDebug*|*jacoco-instrumented*|*jacocoInstrumented*|*jacoco/classes*|*jacoco-classes*|*jacoco/test*|*jacoco/androidTest*|*jacoco-coverage*|*jacoco/*) return 0;; + esac + return 1 +} +write_coverage_unavailable() { + python3 - "$COVERAGE_JSON" "$1" <<'PY' +import json, sys +from pathlib import Path +Path(sys.argv[1]).write_text(json.dumps({"available": False, "note": sys.argv[2]})+"\n") +PY +} +add_classfile_source() { + local path="$1" + [ -n "$path" ] || return + if is_instrumented_class_path "$path"; then + ra_log "Skipping instrumented class path: $path" + return + fi + + # We'll track the actual key we add (may differ from $path for directories) + local key="$path" + + if [ -d "$path" ]; then + # Prefer the specific package subtree, if present + local candidate="$path" + if [ -d "$path/$PORT_PACKAGE_PATH" ]; then + candidate="$path/$PORT_PACKAGE_PATH" + fi + key="$candidate" + [ -z "${CLASSFILE_SEEN[$key]:-}" ] || return + + if find "$candidate" -type f -name '*.class' -path "$PORT_PACKAGE_GLOB" -print -quit >/dev/null 2>&1; then + CLASSFILE_ARGS+=(--classfiles "$candidate") + CLASSFILE_SOURCES+=("$candidate (directory)") + CLASSFILE_SEEN[$key]=1 + fi + + elif [ -f "$path" ]; then + # JAR: extract only com/codename1/impl/android/* into a temp dir, as you already do + [ -z "${CLASSFILE_SEEN[$path]:-}" ] || return + if unzip -l "$path" "${PORT_PACKAGE_PATH}/*" >/dev/null 2>&1; then + local jar_dir="$CLASSFILE_TMP_DIR/jar-$(( ${#CLASSFILE_SOURCES[@]} + 1 ))" + rm -rf "$jar_dir" 2>/dev/null || true + ensure_dir "$jar_dir" + if unzip -qo "$path" "${PORT_PACKAGE_PATH}/*" -d "$jar_dir" >/dev/null 2>&1; then + if find "$jar_dir" -type f -name '*.class' -path "$PORT_PACKAGE_GLOB" -print -quit >/dev/null 2>&1; then + CLASSFILE_ARGS+=(--classfiles "$jar_dir") + CLASSFILE_SOURCES+=("$path (jar extracted to $jar_dir)") + CLASSFILE_SEEN[$path]=1 + else + rm -rf "$jar_dir" 2>/dev/null || true + fi + else + rm -rf "$jar_dir" 2>/dev/null || true + ra_log "WARN: Failed to extract Codename One classes from jar $path" + fi + fi + fi +} + +COVERAGE_EXEC="$(find "$GRADLE_PROJECT_DIR" \( -path '*/outputs/code_coverage/*/connected/*.ec' -o -name 'coverage.ec' \) -type f | head -n 1 || true)" +if [ -n "$COVERAGE_EXEC" ] && [ -f "$COVERAGE_EXEC" ]; then + ra_log "Detected instrumentation coverage file at $COVERAGE_EXEC" + PORT_CLASSES_DIR="$REPO_ROOT/maven/android/target/classes" + if [ ! -d "$PORT_CLASSES_DIR" ]; then + ALT_CLASSES_DIR="$(find "$REPO_ROOT/maven/android" -maxdepth 4 -type d -name classes | head -n 1 || true)" + [ -n "$ALT_CLASSES_DIR" ] && [ -d "$ALT_CLASSES_DIR" ] && PORT_CLASSES_DIR="$ALT_CLASSES_DIR" || PORT_CLASSES_DIR="" + fi + PORT_JAR="$(find "$REPO_ROOT/maven/android" -maxdepth 2 -type f -name '*.jar' -print 2>/dev/null | head -n 1 || true)" + add_classfile_source "$PORT_CLASSES_DIR" + add_classfile_source "$PORT_JAR" + while IFS= read -r -d '' classfile; do + is_instrumented_class_path "$classfile" && continue + package_needle="${PORT_PACKAGE_PATH}/"; class_root="${classfile%%$package_needle*}" + if [ -n "$class_root" ] && [ "$class_root" != "$classfile" ]; then add_classfile_source "${class_root%/}"; else add_classfile_source "$(dirname "$classfile")"; fi + done < <(find "$GRADLE_PROJECT_DIR/app/build" -type f -name '*.class' -path "$PORT_PACKAGE_GLOB" -print0 2>/dev/null) + while IFS= read -r candidate; do + is_instrumented_class_path "$candidate" && continue + add_classfile_source "$candidate" + done < <(find "$GRADLE_PROJECT_DIR/app/build" -maxdepth 6 -type f \( -name 'classes.jar' -o -name '*.jar' \) 2>/dev/null) + + if [ ${#CLASSFILE_ARGS[@]} -eq 0 ]; then + ra_log "WARN: Port class files not found; skipping coverage report generation" + write_coverage_unavailable "Port class files not found in Gradle outputs" + else + JACOCO_VERSION="${JACOCO_VERSION:-0.8.11}" + JACOCO_CACHE_DIR="$DOWNLOAD_DIR/jacoco/$JACOCO_VERSION"; ensure_dir "$JACOCO_CACHE_DIR" + JACOCO_NODEPS_JAR="$JACOCO_CACHE_DIR/org.jacoco.cli-$JACOCO_VERSION-nodeps.jar" + JACOCO_CLI_JAR="$JACOCO_CACHE_DIR/org.jacoco.cli-$JACOCO_VERSION.jar" + ARGS4J_VERSION="${ARGS4J_VERSION:-2.33}" + JACOCO_ARGS4J_JAR="$JACOCO_CACHE_DIR/args4j-$ARGS4J_VERSION.jar" + if [ ! -f "$JACOCO_NODEPS_JAR" ]; then + ra_log "Downloading JaCoCo CLI (nodeps) $JACOCO_VERSION" + download_with_tools "https://repo1.maven.org/maven2/org/jacoco/org.jacoco.cli/$JACOCO_VERSION/org.jacoco.cli-$JACOCO_VERSION-nodeps.jar" "$JACOCO_NODEPS_JAR" || true + fi + if [ ! -f "$JACOCO_CLI_JAR" ]; then + ra_log "Downloading JaCoCo CLI $JACOCO_VERSION" + download_with_tools "https://repo1.maven.org/maven2/org/jacoco/org.jacoco.cli/$JACOCO_VERSION/org.jacoco.cli-$JACOCO_VERSION.jar" "$JACOCO_CLI_JAR" || true + fi + JACOCO_CMD=() + if [ -f "$JACOCO_NODEPS_JAR" ]; then + JACOCO_CMD=("$JAVA17_BIN" "-jar" "$JACOCO_NODEPS_JAR") + elif [ -f "$JACOCO_CLI_JAR" ]; then + if unzip -p "$JACOCO_CLI_JAR" META-INF/MANIFEST.MF 2>/dev/null | grep -qi 'Main-Class:'; then + JACOCO_CMD=("$JAVA17_BIN" "-jar" "$JACOCO_CLI_JAR") + else + if [ ! -f "$JACOCO_ARGS4J_JAR" ]; then + ra_log "Downloading args4j dependency $ARGS4J_VERSION for JaCoCo CLI" + download_with_tools "https://repo1.maven.org/maven2/org/kohsuke/args4j/$ARGS4J_VERSION/args4j-$ARGS4J_VERSION.jar" "$JACOCO_ARGS4J_JAR" || true + fi + if [ -f "$JACOCO_ARGS4J_JAR" ]; then + case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) sep=';';; *) sep=':';; esac + JACOCO_CMD=("$JAVA17_BIN" "-cp" "$JACOCO_CLI_JAR${sep}$JACOCO_ARGS4J_JAR" "org.jacoco.cli.internal.Main") + fi + fi + fi + if [ ${#JACOCO_CMD[@]} -gt 0 ]; then + COVERAGE_SITE_DIR="$COVERAGE_ARTIFACT_DIR/site/jacoco"; ensure_dir "$COVERAGE_SITE_DIR" + COVERAGE_XML="$COVERAGE_SITE_DIR/jacoco.xml" + JACOCO_LOG="$COVERAGE_ARTIFACT_DIR/jacoco-report.log" + ra_log "Generating JaCoCo report for Android port sources" + if "${JACOCO_CMD[@]}" report "$COVERAGE_EXEC" \ + --name "CodenameOneAndroidPort" \ + "${CLASSFILE_ARGS[@]}" \ + --sourcefiles "$REPO_ROOT/Ports/Android/src" \ + --html "$COVERAGE_SITE_DIR" \ + --xml "$COVERAGE_XML" >"$JACOCO_LOG" 2>&1; then + ra_log "JaCoCo report generated at $COVERAGE_SITE_DIR" + python3 - <<'PY' "$COVERAGE_XML" "$COVERAGE_JSON" +import json, sys, xml.etree.ElementTree as ET +from pathlib import Path +xml_path=Path(sys.argv[1]); out=Path(sys.argv[2]) +if not xml_path.is_file(): out.write_text(json.dumps({"available": False})+"\n"); raise SystemExit +root=ET.parse(xml_path).getroot() +covered=missed=0 +for c in root.findall('counter'): + if c.attrib.get('type')=='LINE': + covered+=int(c.attrib.get('covered','0')); missed+=int(c.attrib.get('missed','0')) +total=covered+missed; percent=(covered/total*100.0) if total>0 else 0.0 +out.write_text(json.dumps({"available":True,"lines":{"covered":covered,"missed":missed,"total":total,"percent":percent}})+"\n") +PY + else + ra_log "WARN: JaCoCo report generation failed (see $JACOCO_LOG)" + write_coverage_unavailable "JaCoCo report generation failed (see jacoco-report.log)" + fi + else + ra_log "WARN: JaCoCo CLI executable unavailable; coverage report not generated" + write_coverage_unavailable "JaCoCo CLI unavailable" + fi + cp -f "$COVERAGE_EXEC" "$COVERAGE_ARTIFACT_DIR/coverage.ec" 2>/dev/null || true + fi +else + ra_log "WARN: Coverage execution data not found; skipping report generation" + write_coverage_unavailable "Coverage execution (.ec) file missing" +fi +rm -rf "$CLASSFILE_TMP_DIR" 2>/dev/null || true +CLASSFILE_TMP_DIR="" + 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 +# also export previews as artifacts for GH Actions convenience +PREVIEW_ARTIFACT_DIR="$ARTIFACTS_DIR/android-previews" +if [ -d "$SCREENSHOT_PREVIEW_DIR" ]; then + if find "$SCREENSHOT_PREVIEW_DIR" -maxdepth 1 -type f \( -name '*.png' -o -name '*.jpg' -o -name '*.jpeg' \) -print -quit >/dev/null; then + rm -rf "$PREVIEW_ARTIFACT_DIR" 2>/dev/null || true + mkdir -p "$PREVIEW_ARTIFACT_DIR" + cp -R "$SCREENSHOT_PREVIEW_DIR"/. "$PREVIEW_ARTIFACT_DIR/" + fi +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]" export CN1SS_PREVIEW_SUBDIR="android" + +# Best-effort: never fail the script because comment POST failed if ! cn1ss_post_pr_comment "$COMMENT_FILE" "$SCREENSHOT_PREVIEW_DIR"; then - comment_rc=$? + ra_log "STAGE:COMMENT_POST_FAILED (see stderr for details)" fi # Copy useful artifacts for GH Actions 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 +ra_log "FINAL: decode_rc=${decode_rc:-0}" + +# --- Final status: fail ONLY if screenshot decoding failed --- +if [ "${decode_rc:-0}" -ne 0 ]; then + exit "$decode_rc" +fi + +exit 0 \ No newline at end of file diff --git a/scripts/run-android-tests.sh b/scripts/run-android-tests.sh new file mode 100755 index 0000000000..80e04481dc --- /dev/null +++ b/scripts/run-android-tests.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Backwards-compatible entry point for Android UI testing. +# Delegates to run-android-instrumentation-tests.sh, which now handles +# screenshots and coverage. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "${SCRIPT_DIR}/run-android-instrumentation-tests.sh" "$@"