diff --git a/.github/workflows/scripts-ios.yml b/.github/workflows/scripts-ios.yml index d4c0795cc5..7391dccd15 100644 --- a/.github/workflows/scripts-ios.yml +++ b/.github/workflows/scripts-ios.yml @@ -7,8 +7,11 @@ on: - 'scripts/setup-workspace.sh' - 'scripts/build-ios-port.sh' - 'scripts/build-ios-app.sh' + - 'scripts/run-ios-simulator-tests.sh' - 'scripts/templates/**' - '!scripts/templates/**/*.md' + - 'scripts/ios/**' + - '!scripts/ios/**/*.md' - 'CodenameOne/src/**' - '!CodenameOne/src/**/*.md' - 'Ports/iOSPort/**' @@ -24,8 +27,11 @@ on: - 'scripts/setup-workspace.sh' - 'scripts/build-ios-port.sh' - 'scripts/build-ios-app.sh' + - 'scripts/run-ios-simulator-tests.sh' - 'scripts/templates/**' - '!scripts/templates/**/*.md' + - 'scripts/ios/**' + - '!scripts/ios/**/*.md' - 'CodenameOne/src/**' - '!CodenameOne/src/**/*.md' - 'Ports/iOSPort/**' @@ -37,11 +43,18 @@ on: jobs: build-ios: + permissions: + contents: read + pull-requests: write + issues: write runs-on: macos-15 # pinning macos-15 avoids surprises during the cutover window timeout-minutes: 60 # allow enough time for dependency installs and full build concurrency: # ensure only one mac build runs at once group: mac-ci cancel-in-progress: false # queue new ones instead of canceling in-flight + env: + GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} + GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} steps: - uses: actions/checkout@v4 @@ -75,6 +88,21 @@ jobs: timeout-minutes: 25 - name: Build sample iOS app and compile workspace + id: build-ios-app run: ./scripts/build-ios-app.sh -q -DskipTests timeout-minutes: 30 + - name: Run iOS simulator automation tests + run: ./scripts/run-ios-simulator-tests.sh "${{ steps.build-ios-app.outputs.app_bundle }}" + timeout-minutes: 20 + + - name: Upload simulator artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: ios-simulator-artifacts + path: artifacts + if-no-files-found: warn + retention-days: 14 + compression-level: 6 + diff --git a/scripts/build-android-app.sh b/scripts/build-android-app.sh index 2d90cdc52c..fcea7581ac 100755 --- a/scripts/build-android-app.sh +++ b/scripts/build-android-app.sh @@ -235,6 +235,17 @@ sed -e "s|@PACKAGE@|$PACKAGE_NAME|g" \ -e "s|@MAIN_NAME@|$MAIN_NAME|g" \ "$TEMPLATE" > "$MAIN_FILE" +AUTOMATION_TEMPLATE="$SCRIPT_DIR/templates/HelloCodenameOneAutomation.java.tmpl" +AUTOMATION_FILE="$JAVA_DIR/${MAIN_NAME}Automation.java" +if [ -f "$AUTOMATION_TEMPLATE" ]; then + sed -e "s|@PACKAGE@|$PACKAGE_NAME|g" \ + -e "s|@MAIN_NAME@|$MAIN_NAME|g" \ + "$AUTOMATION_TEMPLATE" > "$AUTOMATION_FILE" + ba_log "Wrote automation harness to $AUTOMATION_FILE" +else + ba_log "Automation template not found at $AUTOMATION_TEMPLATE" +fi + # --- Ensure codename1.mainName is set --- ba_log "Setting codename1.mainName to $MAIN_NAME" if grep -q '^codename1.mainName=' "$SETTINGS_FILE"; then diff --git a/scripts/build-ios-app.sh b/scripts/build-ios-app.sh index 8c7a55b4d4..0458a80874 100755 --- a/scripts/build-ios-app.sh +++ b/scripts/build-ios-app.sh @@ -119,6 +119,7 @@ set_property() { set_property "codename1.packageName" "$PACKAGE_NAME" set_property "codename1.mainName" "$MAIN_NAME" +set_property "automation.platform" "ios" # Ensure trailing newline tail -c1 "$SETTINGS_FILE" | read -r _ || echo >> "$SETTINGS_FILE" @@ -139,6 +140,17 @@ sed -e "s|@PACKAGE@|$PACKAGE_NAME|g" \ bia_log "Wrote main application class to $MAIN_FILE" +AUTOMATION_TEMPLATE="$SCRIPT_DIR/templates/HelloCodenameOneAutomation.java.tmpl" +AUTOMATION_FILE="$JAVA_DIR/${MAIN_NAME}Automation.java" +if [ -f "$AUTOMATION_TEMPLATE" ]; then + sed -e "s|@PACKAGE@|$PACKAGE_NAME|g" \ + -e "s|@MAIN_NAME@|$MAIN_NAME|g" \ + "$AUTOMATION_TEMPLATE" > "$AUTOMATION_FILE" + bia_log "Wrote automation harness to $AUTOMATION_FILE" +else + bia_log "Automation template not found at $AUTOMATION_TEMPLATE" +fi + # --- Build iOS project --- DERIVED_DATA_DIR="${TMPDIR}/codenameone-ios-derived" rm -rf "$DERIVED_DATA_DIR" diff --git a/scripts/ios/screenshots/README.md b/scripts/ios/screenshots/README.md new file mode 100644 index 0000000000..5df2cf27c5 --- /dev/null +++ b/scripts/ios/screenshots/README.md @@ -0,0 +1,13 @@ +# iOS Simulator Automation Screenshots + +This directory stores reference PNG files for the Codename One iOS simulator +automation tests. + +Each PNG file should be named after the CN1SS test stream that emits the +screenshot (for example `MainActivity.png` or `BrowserComponent.png`). The +`run-ios-simulator-tests.sh` script compares simulator output against these +references and reports any differences in the pull request summary. + +When a reference image is missing or the pixels differ, the GitHub Actions +workflow posts a pull request comment that includes the updated screenshot so it +can be reviewed and promoted to the baseline as needed. diff --git a/scripts/run-ios-simulator-tests.sh b/scripts/run-ios-simulator-tests.sh new file mode 100755 index 0000000000..b2002e3c8b --- /dev/null +++ b/scripts/run-ios-simulator-tests.sh @@ -0,0 +1,229 @@ +#!/usr/bin/env bash +# Run Codename One iOS automation tests in the simulator and compare screenshots +set -euo pipefail + +ris_log() { echo "[run-ios-simulator-tests] $1"; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +CN1SS_BUILD_SCRIPT="$SCRIPT_DIR/tools/cn1ss-java/build-cn1ss-tools.sh" +CN1SS_JAR="$SCRIPT_DIR/tools/cn1ss-java/cn1ss-tools.jar" +SCREENSHOT_REF_DIR="$SCRIPT_DIR/ios/screenshots" + +if [ ! -x "$CN1SS_BUILD_SCRIPT" ]; then + ris_log "CN1SS build helper not found at $CN1SS_BUILD_SCRIPT" >&2 + exit 1 +fi + +"$CN1SS_BUILD_SCRIPT" + +if [ ! -f "$CN1SS_JAR" ]; then + ris_log "Failed to build CN1SS helper jar at $CN1SS_JAR" >&2 + exit 1 +fi + +CN1SS_TOOL_CMD=(java -cp "$CN1SS_JAR" com.codename1.tools.cn1ss.CN1SSTool) + +mkdir -p "$SCREENSHOT_REF_DIR" 2>/dev/null || true + +APP_BUNDLE_PATH="${1:-}" +if [ -z "$APP_BUNDLE_PATH" ]; then + ris_log "Usage: $0 " >&2 + exit 2 +fi +if [ ! -d "$APP_BUNDLE_PATH" ]; then + ris_log "App bundle not found: $APP_BUNDLE_PATH" >&2 + exit 2 +fi + +INFO_PLIST="$APP_BUNDLE_PATH/Info.plist" +if [ ! -f "$INFO_PLIST" ]; then + ris_log "Info.plist not found inside app bundle: $INFO_PLIST" >&2 + exit 2 +fi + +if ! command -v /usr/libexec/PlistBuddy >/dev/null 2>&1; then + ris_log "PlistBuddy command not available" >&2 + exit 2 +fi + +BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' "$INFO_PLIST" 2>/dev/null || true) +if [ -z "$BUNDLE_ID" ]; then + ris_log "Unable to determine bundle identifier from $INFO_PLIST" >&2 + exit 2 +fi +ris_log "Detected bundle identifier $BUNDLE_ID" + +DEVICE_NAME="${IOS_SIMULATOR_DEVICE:-iPhone 15}" + +if ! command -v xcrun >/dev/null 2>&1; then + ris_log "xcrun command not available" >&2 + exit 2 +fi + +TMP_ROOT="${TMPDIR:-/tmp}" +WORK_DIR="$(mktemp -d "$TMP_ROOT/cn1-ios-tests.XXXXXX")" +DEVICE_UDID="" +DELETE_DEVICE=0 +cleanup() { + rm -rf "$WORK_DIR" >/dev/null 2>&1 || true + if [ "$DELETE_DEVICE" -eq 1 ] && [ -n "$DEVICE_UDID" ]; then + xcrun simctl delete "$DEVICE_UDID" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +ARTIFACTS_DIR="${ARTIFACTS_DIR:-$REPO_ROOT/artifacts}" +mkdir -p "$ARTIFACTS_DIR" + +LOG_FILE="$WORK_DIR/simulator.log" +SCREENSHOT_TMP_DIR="$WORK_DIR/screenshots" +SCREENSHOT_PREVIEW_DIR="$WORK_DIR/preview" +mkdir -p "$SCREENSHOT_TMP_DIR" "$SCREENSHOT_PREVIEW_DIR" + +DEVICE_RUNTIME=$(xcrun simctl list runtimes --json | "${CN1SS_TOOL_CMD[@]}" simctl best-runtime) +if [ -z "$DEVICE_RUNTIME" ]; then + ris_log "Failed to determine available iOS runtime" >&2 + exit 3 +fi +ris_log "Using simulator runtime $DEVICE_RUNTIME" + +DEVICE_TYPE=$(xcrun simctl list devicetypes --json | "${CN1SS_TOOL_CMD[@]}" simctl device-type --device-name "$DEVICE_NAME") +if [ -z "$DEVICE_TYPE" ]; then + ris_log "Simulator device type '$DEVICE_NAME' not available" >&2 + exit 3 +fi +ris_log "Using simulator device type $DEVICE_TYPE" + +DEVICE_INFO=$(xcrun simctl list devices --json | "${CN1SS_TOOL_CMD[@]}" simctl device-info --runtime "$DEVICE_RUNTIME" --device-name "$DEVICE_NAME") + +DEVICE_STATE="" +DEVICE_UDID="" +if [ -n "$DEVICE_INFO" ]; then + DEVICE_UDID="${DEVICE_INFO%%|*}" + DEVICE_STATE="${DEVICE_INFO##*|}" +fi + +if [ -z "$DEVICE_UDID" ]; then + DEVICE_LABEL="CN1-Automation-$(date +%s)" + DEVICE_UDID=$(xcrun simctl create "$DEVICE_LABEL" "$DEVICE_TYPE" "$DEVICE_RUNTIME") + DEVICE_STATE="Shutdown" + DELETE_DEVICE=1 + ris_log "Created temporary simulator $DEVICE_LABEL ($DEVICE_UDID)" +else + ris_log "Reusing existing simulator $DEVICE_NAME ($DEVICE_UDID)" +fi + +RIS_SHUTDOWN=0 +if [ "$DEVICE_STATE" = "Booted" ]; then + ris_log "Simulator already booted" +else + xcrun simctl boot "$DEVICE_UDID" >/dev/null + RIS_SHUTDOWN=1 +fi +xcrun simctl bootstatus "$DEVICE_UDID" -b >/dev/null + +ris_log "Installing app bundle" +xcrun simctl install "$DEVICE_UDID" "$APP_BUNDLE_PATH" + +ris_log "Launching $BUNDLE_ID in simulator" +set +e +xcrun simctl launch "$DEVICE_UDID" --console "$BUNDLE_ID" >"$LOG_FILE" 2>&1 +LAUNCH_RC=$? +set -e +ris_log "Simulator launch completed with status $LAUNCH_RC" + +cp -f "$LOG_FILE" "$ARTIFACTS_DIR/simulator.log" 2>/dev/null || true + +if [ "$RIS_SHUTDOWN" -eq 1 ]; then + xcrun simctl shutdown "$DEVICE_UDID" >/dev/null || true +fi + +TEST_NAMES=() +if [ -s "$LOG_FILE" ]; then + while IFS= read -r line; do + [ -n "$line" ] || continue + TEST_NAMES+=("$line") + done < <("${CN1SS_TOOL_CMD[@]}" chunks tests "$LOG_FILE" 2>/dev/null || true) +fi + +if [ "${#TEST_NAMES[@]}" -eq 0 ]; then + ris_log "No CN1SS screenshot streams detected in simulator output" >&2 + exit 4 +fi + +ris_log "Detected CN1SS test streams: ${TEST_NAMES[*]}" + +COMPARE_ARGS=() +for test in "${TEST_NAMES[@]}"; do + sanitized="$test" + output="$SCREENSHOT_TMP_DIR/${sanitized}.png" + if "${CN1SS_TOOL_CMD[@]}" chunks extract --decode --test "$test" "$LOG_FILE" >"$output" 2>/dev/null; then + if [ -s "$output" ]; then + COMPARE_ARGS+=("--actual" "${sanitized}=${output}") + cp -f "$output" "$ARTIFACTS_DIR/${sanitized}.png" 2>/dev/null || true + continue + fi + fi + ris_log "Failed to decode screenshot payload for test '$test'" >&2 + rm -f "$output" 2>/dev/null || true +done + +if [ "${#COMPARE_ARGS[@]}" -eq 0 ]; then + ris_log "No screenshots decoded from simulator output" >&2 + exit 4 +fi + +COMPARE_JSON="$WORK_DIR/screenshot-compare.json" +COMMENT_FILE="$WORK_DIR/screenshot-comment.md" +SUMMARY_FILE="$WORK_DIR/screenshot-summary.txt" + +export CN1SS_PREVIEW_DIR="$SCREENSHOT_PREVIEW_DIR" +"${CN1SS_TOOL_CMD[@]}" compare \ + --reference-dir "$SCREENSHOT_REF_DIR" \ + --emit-base64 \ + --preview-dir "$SCREENSHOT_PREVIEW_DIR" \ + --preview-source-dir "$SCREENSHOT_PREVIEW_DIR" \ + --json-out "$COMPARE_JSON" \ + --summary-out "$SUMMARY_FILE" \ + --comment-out "$COMMENT_FILE" \ + --platform "iOS" \ + "${COMPARE_ARGS[@]}" + +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 +if [ -d "$SCREENSHOT_PREVIEW_DIR" ]; then + tar -C "$SCREENSHOT_PREVIEW_DIR" -czf "$ARTIFACTS_DIR/preview-images.tgz" . 2>/dev/null || true +fi + +if [ -s "$SUMMARY_FILE" ]; then + while IFS='|' read -r status test message copy_flag path preview_note; do + [ -n "${test:-}" ] || continue + ris_log "Test '${test}': ${message}" + if [ "$status" = "equal" ] && [ -n "${path:-}" ]; then + rm -f "$path" 2>/dev/null || true + fi + if [ -n "${preview_note:-}" ]; then + ris_log " Preview note: ${preview_note}" + fi + done <"$SUMMARY_FILE" +else + ris_log "All simulator screenshots matched stored references" +fi + +comment_rc=0 +if [ -s "$COMMENT_FILE" ]; then + ris_log "Posting PR comment for screenshot differences" + if ! "${CN1SS_TOOL_CMD[@]}" comment --body "$COMMENT_FILE" --preview-dir "$SCREENSHOT_PREVIEW_DIR"; then + ris_log "PR comment post failed" + comment_rc=1 + else + ris_log "Posted screenshot comparison comment" + fi +else + ris_log "No screenshot differences detected; skipping PR comment" +fi + +exit $comment_rc diff --git a/scripts/templates/HelloCodenameOne.java.tmpl b/scripts/templates/HelloCodenameOne.java.tmpl index c1f42a6ee2..8a2db34e88 100644 --- a/scripts/templates/HelloCodenameOne.java.tmpl +++ b/scripts/templates/HelloCodenameOne.java.tmpl @@ -20,6 +20,8 @@ public class @MAIN_NAME@ { Form helloForm = new Form("Hello Codename One", new BorderLayout()); helloForm.add(BorderLayout.CENTER, new Label("Hello Codename One")); helloForm.show(); + + @MAIN_NAME@Automation.schedule(); } public void stop() { @@ -29,4 +31,4 @@ public class @MAIN_NAME@ { public void destroy() { // Nothing to clean up for this sample } -} \ No newline at end of file +} diff --git a/scripts/templates/HelloCodenameOneAutomation.java.tmpl b/scripts/templates/HelloCodenameOneAutomation.java.tmpl new file mode 100644 index 0000000000..2bb024fa3f --- /dev/null +++ b/scripts/templates/HelloCodenameOneAutomation.java.tmpl @@ -0,0 +1,298 @@ +package @PACKAGE@; + +import com.codename1.components.BrowserComponent; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.Image; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.util.ImageIO; +import com.codename1.ui.util.UITimer; +import com.codename1.util.Base64; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +/** + * Automation harness that emits CN1SS screenshot streams when enabled via + * {@code automation.platform=ios} in codenameone_settings.properties. + */ +public final class @MAIN_NAME@Automation { + private static final int CHUNK_SIZE = 2000; + private static final int[] PREVIEW_QUALITIES = new int[] {60, 50, 40, 30, 20, 10}; + private static final int MAX_PREVIEW_BYTES = 20 * 1024; + private static boolean running; + + private @MAIN_NAME@Automation() {} + + public static void schedule() { + Display.getInstance().callSerially(() -> { + if (running) { + return; + } + if (!shouldRun()) { + return; + } + running = true; + log("CN1SS:INFO:automation=start"); + runMainTest(() -> runBrowserTest(@MAIN_NAME@Automation::finish)); + }); + } + + private static boolean shouldRun() { + Display d = Display.getInstance(); + String platform = d.getProperty("automation.platform", null); + if (platform == null) { + return false; + } + String lower = platform.toLowerCase(); + return lower.indexOf("ios") >= 0 || lower.indexOf("simulator") >= 0; + } + + private static void runMainTest(Runnable next) { + Display.getInstance().callSerially(() -> { + Form current = ensureForm("Main Screen"); + Container content = new Container(BoxLayout.y()); + Style s = content.getAllStyles(); + s.setBgColor(0x1f2937); + s.setBgTransparency(255); + s.setPadding(6, 6, 6, 6); + s.setFgColor(0xf9fafb); + + Label heading = new Label("Hello Codename One"); + heading.getAllStyles().setFgColor(0x38bdf8); + heading.getAllStyles().setMargin(0, 4, 0, 0); + + Label body = new Label("iOS automation preview"); + body.getAllStyles().setFgColor(0xf9fafb); + + content.removeAll(); + content.add(heading); + content.add(body); + + current.removeAll(); + current.setTitle("Main Screen"); + current.setLayout(new BorderLayout()); + current.add(BorderLayout.CENTER, content); + current.revalidate(); + + UITimer.timer(600, false, current, () -> captureAndEmit("MainActivity", next)); + }); + } + + private static void runBrowserTest(Runnable next) { + Display.getInstance().callSerially(() -> { + Form current = ensureForm("Browser Test"); + current.removeAll(); + current.setLayout(new BorderLayout()); + + BrowserComponent browser = new BrowserComponent(); + if (!BrowserComponent.isNativeBrowserSupported()) { + log("CN1SS:WARN:test=BrowserComponent native_browser_unsupported=1"); + emitChannel(null, "BrowserComponent", ""); + if (next != null) { + Display.getInstance().callSerially(next); + } + return; + } + final boolean[] captured = new boolean[] {false}; + final Runnable captureTask = () -> captureAndEmit("BrowserComponent", next); + + String html = "" + + "" + + "

Codename One

" + + "

BrowserComponent automation test content.

"; + + browser.addWebEventListener(BrowserComponent.onLoad, evt -> { + if (captured[0]) { + return; + } + captured[0] = true; + UITimer.timer(700, false, current, captureTask); + }); + browser.setPage(html, null); + + UITimer.timer(10000, false, current, () -> { + if (!captured[0]) { + log("CN1SS:WARN:test=BrowserComponent timed_out=1"); + captureTask.run(); + } + }); + + current.add(BorderLayout.CENTER, browser); + current.revalidate(); + }); + } + + private static Form ensureForm(String title) { + Form current = Display.getInstance().getCurrent(); + if (current == null) { + current = new Form(title, new BorderLayout()); + current.show(); + return current; + } + current.setTitle(title); + current.setLayout(new BorderLayout()); + return current; + } + + private static void captureAndEmit(String testName, Runnable next) { + try { + ScreenshotCapture capture = captureScreenshot(testName); + emitScreenshot(capture, testName); + } catch (Throwable t) { + log("CN1SS:ERR:test=" + sanitize(testName) + " " + t); + } finally { + if (next != null) { + Display.getInstance().callSerially(next); + } + } + } + + private static ScreenshotCapture captureScreenshot(String testName) throws IOException { + Form current = Display.getInstance().getCurrent(); + if (current == null) { + return new ScreenshotCapture(null, null, 0, 0, 0); + } + int w = Math.max(1, current.getWidth()); + int h = Math.max(1, current.getHeight()); + + Image screenshot = Display.getInstance().captureScreen(); + if (screenshot == null) { + screenshot = Image.createImage(w, h); + current.paintComponent(screenshot.getGraphics(), true); + } + + ImageIO io = ImageIO.getImageIO(); + if (io == null || !io.isFormatSupported(ImageIO.FORMAT_PNG)) { + log("CN1SS:ERR:test=" + sanitize(testName) + " image_io_unavailable=1"); + return new ScreenshotCapture(null, null, 0, w, h); + } + + ByteArrayOutputStream pngOut = new ByteArrayOutputStream(Math.max(1024, w * h)); + io.save(screenshot, pngOut, ImageIO.FORMAT_PNG, 1f); + byte[] png = pngOut.toByteArray(); + + byte[] preview = null; + int previewQuality = 0; + if (io.isFormatSupported(ImageIO.FORMAT_JPEG)) { + int smallest = Integer.MAX_VALUE; + for (int quality : PREVIEW_QUALITIES) { + ByteArrayOutputStream jpegOut = new ByteArrayOutputStream(Math.max(512, w * h / 2)); + io.save(screenshot, jpegOut, ImageIO.FORMAT_JPEG, quality / 100f); + byte[] candidate = jpegOut.toByteArray(); + if (candidate.length < smallest) { + smallest = candidate.length; + preview = candidate; + previewQuality = quality; + } + if (candidate.length <= MAX_PREVIEW_BYTES) { + break; + } + } + } + + return new ScreenshotCapture(png, preview, previewQuality, w, h); + } + + private static void emitScreenshot(ScreenshotCapture capture, String testName) { + if (capture == null || capture.png == null || capture.png.length == 0) { + log("CN1SS:ERR:test=" + sanitize(testName) + " png_bytes=0"); + emitChannel(null, testName, ""); + return; + } + log("CN1SS:INFO:test=" + sanitize(testName) + " png_bytes=" + capture.png.length + + " dims=" + capture.width + "x" + capture.height); + emitChannel(capture.png, testName, ""); + if (capture.preview != null && capture.preview.length > 0) { + log("CN1SS:INFO:test=" + sanitize(testName) + " preview_bytes=" + capture.preview.length + + " preview_quality=" + capture.previewQuality); + emitChannel(capture.preview, testName, "PREVIEW"); + } + } + + private static void emitChannel(byte[] data, String testName, String channel) { + String prefix = "CN1SS"; + if (channel != null && channel.length() > 0) { + prefix += channel; + } + String safe = sanitize(testName); + if (data == null || data.length == 0) { + log(prefix + ":END:" + safe); + return; + } + String b64 = Base64.encodeNoNewline(data); + int chunkCount = (b64.length() + CHUNK_SIZE - 1) / CHUNK_SIZE; + log(prefix + ":INFO:test=" + safe + " chunks=" + chunkCount + " total_b64_len=" + b64.length()); + for (int idx = 0; idx < chunkCount; idx++) { + int start = idx * CHUNK_SIZE; + int end = Math.min(start + CHUNK_SIZE, b64.length()); + String chunk = b64.substring(start, end); + String index = formatIndex(idx); + log(prefix + ":" + safe + ":" + index + ":" + chunk); + } + log(prefix + ":END:" + safe); + } + + private static String formatIndex(int idx) { + String s = String.valueOf(idx); + if (s.length() >= 6) { + return s; + } + StringBuffer buffer = new StringBuffer(6); + for (int i = s.length(); i < 6; i++) { + buffer.append('0'); + } + buffer.append(s); + return buffer.toString(); + } + + private static String sanitize(String name) { + if (name == null) { + return ""; + } + StringBuilder sb = new StringBuilder(name.length()); + for (int i = 0; i < name.length(); i++) { + char ch = name.charAt(i); + if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') + || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' || ch == '.') { + sb.append(ch); + } else { + sb.append('_'); + } + } + return sb.toString(); + } + + private static void finish() { + Display.getInstance().callSerially(() -> { + log("CN1SS:INFO:automation=done"); + Display.getInstance().exitApplication(); + running = false; + }); + } + + private static void log(String s) { + System.out.println(s); + } + + private static final class ScreenshotCapture { + final byte[] png; + final byte[] preview; + final int previewQuality; + final int width; + final int height; + + ScreenshotCapture(byte[] png, byte[] preview, int previewQuality, int width, int height) { + this.png = png; + this.preview = preview; + this.previewQuality = previewQuality; + this.width = width; + this.height = height; + } + } +} diff --git a/scripts/tools/cn1ss-java/.gitignore b/scripts/tools/cn1ss-java/.gitignore new file mode 100644 index 0000000000..732a8fa47e --- /dev/null +++ b/scripts/tools/cn1ss-java/.gitignore @@ -0,0 +1,2 @@ +/build/ +/cn1ss-tools.jar diff --git a/scripts/tools/cn1ss-java/build-cn1ss-tools.sh b/scripts/tools/cn1ss-java/build-cn1ss-tools.sh new file mode 100755 index 0000000000..455cc9a17b --- /dev/null +++ b/scripts/tools/cn1ss-java/build-cn1ss-tools.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SRC_DIR="$SCRIPT_DIR/src" +BUILD_DIR="$SCRIPT_DIR/build" +CLASSES_DIR="$BUILD_DIR/classes" +JAR_PATH="$SCRIPT_DIR/cn1ss-tools.jar" +mkdir -p "$BUILD_DIR" +rm -rf "$CLASSES_DIR" +mkdir -p "$CLASSES_DIR" +find "$SRC_DIR" -name '*.java' >"$BUILD_DIR/sources.list" +if [ ! -s "$BUILD_DIR/sources.list" ]; then + echo "No Java sources found in $SRC_DIR" >&2 + exit 1 +fi +javac -encoding UTF-8 -d "$CLASSES_DIR" @"$BUILD_DIR/sources.list" +jar --create --file "$JAR_PATH" -C "$CLASSES_DIR" . diff --git a/scripts/tools/cn1ss-java/src/com/codename1/tools/cn1ss/CN1SSTool.java b/scripts/tools/cn1ss-java/src/com/codename1/tools/cn1ss/CN1SSTool.java new file mode 100644 index 0000000000..12be1eac60 --- /dev/null +++ b/scripts/tools/cn1ss-java/src/com/codename1/tools/cn1ss/CN1SSTool.java @@ -0,0 +1,345 @@ +package com.codename1.tools.cn1ss; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public final class CN1SSTool { + private CN1SSTool() { + } + + public static void main(String[] args) throws Exception { + if (args.length == 0) { + usage(); + System.exit(2); + } + String command = args[0]; + String[] rest = Arrays.copyOfRange(args, 1, args.length); + int exitCode; + switch (command) { + case "chunks": + exitCode = handleChunks(rest); + break; + case "compare": + exitCode = handleCompare(rest); + break; + case "comment": + exitCode = handleComment(rest); + break; + case "simctl": + exitCode = handleSimctl(rest); + break; + default: + usage(); + exitCode = 2; + break; + } + if (exitCode != 0) { + System.exit(exitCode); + } + } + + private static int handleChunks(String[] args) throws IOException { + if (args.length == 0) { + usage(); + return 2; + } + String sub = args[0]; + String[] rest = Arrays.copyOfRange(args, 1, args.length); + switch (sub) { + case "tests": { + if (rest.length != 1) { + System.err.println("Usage: chunks tests "); + return 2; + } + Path path = Path.of(rest[0]); + for (String test : ChunkParser.listTests(path)) { + System.out.println(test); + } + return 0; + } + case "extract": { + boolean decode = false; + String testName = "default"; + String channel = ""; + Path path = null; + for (int i = 0; i < rest.length; i++) { + String arg = rest[i]; + switch (arg) { + case "--decode": + decode = true; + break; + case "--test": + if (i + 1 >= rest.length) { + System.err.println("Missing value for --test"); + return 2; + } + testName = rest[++i]; + break; + case "--channel": + if (i + 1 >= rest.length) { + System.err.println("Missing value for --channel"); + return 2; + } + channel = rest[++i]; + break; + default: + path = Path.of(arg); + break; + } + } + if (path == null) { + System.err.println("Usage: chunks extract [--decode] [--test name] [--channel name] "); + return 2; + } + byte[] decoded = ChunkParser.decode(path, testName, channel); + if (decode) { + try (OutputStream out = System.out) { + out.write(decoded); + } + } else { + String base64 = java.util.Base64.getEncoder().encodeToString(decoded); + System.out.print(base64); + } + return 0; + } + case "count": { + if (rest.length != 1) { + System.err.println("Usage: chunks count "); + return 2; + } + Path path = Path.of(rest[0]); + int count = ChunkParser.listTests(path).size(); + System.out.println(count); + return 0; + } + default: + System.err.println("Unknown chunks subcommand: " + sub); + return 2; + } + } + + private static int handleCompare(String[] args) throws IOException { + Path referenceDir = null; + boolean emitBase64 = false; + Path previewDir = null; + Path previewSourceDir = null; + Path jsonOut = null; + Path summaryOut = null; + Path commentOut = null; + String platform = "iOS"; + List actualEntries = new ArrayList<>(); + + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + switch (arg) { + case "--reference-dir": + if (i + 1 >= args.length) { + return missingValue(arg); + } + referenceDir = Path.of(args[++i]); + break; + case "--emit-base64": + emitBase64 = true; + break; + case "--preview-dir": + if (i + 1 >= args.length) { + return missingValue(arg); + } + previewDir = Path.of(args[++i]); + break; + case "--preview-source-dir": + if (i + 1 >= args.length) { + return missingValue(arg); + } + previewSourceDir = Path.of(args[++i]); + break; + case "--json-out": + if (i + 1 >= args.length) { + return missingValue(arg); + } + jsonOut = Path.of(args[++i]); + break; + case "--summary-out": + if (i + 1 >= args.length) { + return missingValue(arg); + } + summaryOut = Path.of(args[++i]); + break; + case "--comment-out": + if (i + 1 >= args.length) { + return missingValue(arg); + } + commentOut = Path.of(args[++i]); + break; + case "--platform": + if (i + 1 >= args.length) { + return missingValue(arg); + } + platform = args[++i]; + break; + case "--actual": + if (i + 1 >= args.length) { + return missingValue(arg); + } + String mapping = args[++i]; + int idx = mapping.indexOf('='); + if (idx <= 0) { + System.err.println("Invalid --actual mapping: " + mapping); + return 2; + } + String name = mapping.substring(0, idx); + Path path = Path.of(mapping.substring(idx + 1)); + actualEntries.add(new ScreenshotComparator.ActualEntry(name, path)); + break; + default: + System.err.println("Unknown compare option: " + arg); + return 2; + } + } + + if (referenceDir == null) { + System.err.println("--reference-dir is required"); + return 2; + } + + ScreenshotComparator.ComparisonReport report = ScreenshotComparator.buildReport( + referenceDir, + actualEntries, + emitBase64, + previewDir, + previewSourceDir + ); + + if (jsonOut != null) { + Path parent = jsonOut.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.writeString(jsonOut, report.toJson(), StandardCharsets.UTF_8); + } else { + System.out.println(report.toJson()); + } + + CommentRenderer.RenderResult renderResult = CommentRenderer.build(report.results(), platform); + + if (summaryOut != null) { + Path parent = summaryOut.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.writeString(summaryOut, String.join(System.lineSeparator(), renderResult.summaryLines), StandardCharsets.UTF_8); + } + if (commentOut != null) { + Path parent = commentOut.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.writeString(commentOut, renderResult.commentBody == null ? "" : renderResult.commentBody, StandardCharsets.UTF_8); + } + return 0; + } + + private static int handleComment(String[] args) throws Exception { + Path bodyPath = null; + Path previewDir = null; + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + switch (arg) { + case "--body": + if (i + 1 >= args.length) { + return missingValue(arg); + } + bodyPath = Path.of(args[++i]); + break; + case "--preview-dir": + if (i + 1 >= args.length) { + return missingValue(arg); + } + previewDir = Path.of(args[++i]); + break; + default: + System.err.println("Unknown comment option: " + arg); + return 2; + } + } + if (bodyPath == null) { + System.err.println("--body is required for comment command"); + return 2; + } + return PrCommentPublisher.publish(bodyPath, previewDir); + } + + private static int handleSimctl(String[] args) throws IOException { + if (args.length == 0) { + System.err.println("Usage: simctl [options]"); + return 2; + } + String sub = args[0]; + String input = new String(System.in.readAllBytes(), StandardCharsets.UTF_8); + switch (sub) { + case "best-runtime": { + String platform = "iOS"; + for (int i = 1; i < args.length; i++) { + if ("--platform".equals(args[i]) && i + 1 < args.length) { + platform = args[++i]; + } + } + String identifier = SimctlParser.bestRuntime(input, platform); + if (!identifier.isEmpty()) { + System.out.println(identifier); + return 0; + } + return 1; + } + case "device-type": { + String deviceName = "iPhone 15"; + for (int i = 1; i < args.length; i++) { + if ("--device-name".equals(args[i]) && i + 1 < args.length) { + deviceName = args[++i]; + } + } + String identifier = SimctlParser.findDeviceType(input, deviceName); + if (!identifier.isEmpty()) { + System.out.println(identifier); + return 0; + } + return 1; + } + case "device-info": { + String runtime = ""; + String deviceName = "iPhone 15"; + for (int i = 1; i < args.length; i++) { + if ("--runtime".equals(args[i]) && i + 1 < args.length) { + runtime = args[++i]; + } else if ("--device-name".equals(args[i]) && i + 1 < args.length) { + deviceName = args[++i]; + } + } + String info = SimctlParser.findDeviceInfo(input, runtime, deviceName); + if (!info.isEmpty()) { + System.out.println(info); + return 0; + } + return 0; + } + default: + System.err.println("Unknown simctl subcommand: " + sub); + return 2; + } + } + + private static int missingValue(String option) { + System.err.println("Missing value for " + option); + return 2; + } + + private static void usage() { + System.err.println("Usage: CN1SSTool [options]"); + } +} diff --git a/scripts/tools/cn1ss-java/src/com/codename1/tools/cn1ss/ChunkParser.java b/scripts/tools/cn1ss-java/src/com/codename1/tools/cn1ss/ChunkParser.java new file mode 100644 index 0000000000..2beecbc1c5 --- /dev/null +++ b/scripts/tools/cn1ss-java/src/com/codename1/tools/cn1ss/ChunkParser.java @@ -0,0 +1,107 @@ +package com.codename1.tools.cn1ss; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Utilities for parsing CN1SS chunk streams from simulator and instrumentation logs. + */ +final class ChunkParser { + private static final Pattern CHUNK_PATTERN = Pattern.compile( + "CN1SS(?:(?[A-Z]+))?:(?:(?[A-Za-z0-9_.-]+):)?(?\\d{6}):(?.*)" + ); + + private ChunkParser() { + } + + static List listTests(Path logFile) throws IOException { + Set tests = new LinkedHashSet<>(); + for (Chunk chunk : iterate(logFile, null, "")) { + tests.add(chunk.testName); + } + List sorted = new ArrayList<>(tests); + Collections.sort(sorted); + return sorted; + } + + static byte[] decode(Path logFile, String testName, String channel) throws IOException { + List chunks = iterate(logFile, testName, channel); + if (chunks.isEmpty()) { + return new byte[0]; + } + Collections.sort(chunks, Comparator.comparingInt(c -> c.index)); + StringBuilder builder = new StringBuilder(); + for (Chunk chunk : chunks) { + builder.append(chunk.payload); + } + String data = builder.toString(); + if (data.isEmpty()) { + return new byte[0]; + } + try { + return Base64.getDecoder().decode(data); + } catch (IllegalArgumentException ex) { + return new byte[0]; + } + } + + private static List iterate(Path logFile, String testFilter, String channelFilter) throws IOException { + List result = new ArrayList<>(); + for (String line : Files.readAllLines(logFile, StandardCharsets.UTF_8)) { + Matcher matcher = CHUNK_PATTERN.matcher(line); + if (!matcher.find()) { + continue; + } + String test = matcher.group("test"); + if (test == null || test.isEmpty()) { + test = "default"; + } + if (testFilter != null && !test.equals(testFilter)) { + continue; + } + String channel = matcher.group("channel"); + if (channel == null) { + channel = ""; + } + if (channelFilter != null && !channel.equals(channelFilter)) { + continue; + } + String payload = matcher.group("payload"); + if (payload == null) { + continue; + } + payload = payload.replaceAll("[^A-Za-z0-9+/=]", ""); + if (payload.isEmpty()) { + continue; + } + int index = Integer.parseInt(matcher.group("index")); + result.add(new Chunk(test, channel, index, payload)); + } + return result; + } + + private static final class Chunk { + final String testName; + final String channel; + final int index; + final String payload; + + Chunk(String testName, String channel, int index, String payload) { + this.testName = testName; + this.channel = channel; + this.index = index; + this.payload = payload; + } + } +} diff --git a/scripts/tools/cn1ss-java/src/com/codename1/tools/cn1ss/CommentRenderer.java b/scripts/tools/cn1ss-java/src/com/codename1/tools/cn1ss/CommentRenderer.java new file mode 100644 index 0000000000..50a357a8ea --- /dev/null +++ b/scripts/tools/cn1ss-java/src/com/codename1/tools/cn1ss/CommentRenderer.java @@ -0,0 +1,287 @@ +package com.codename1.tools.cn1ss; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +final class CommentRenderer { + static final String MARKER = ""; + + private CommentRenderer() { + } + + static RenderResult build(List> results, String platformLabel) { + List summary = new ArrayList<>(); + List> commentEntries = new ArrayList<>(); + + for (Map result : results) { + String test = stringValue(result.get("test"), "unknown"); + String status = stringValue(result.get("status"), "unknown"); + String actualPath = stringValue(result.get("actual_path"), ""); + String expectedPath = stringValue(result.get("expected_path"), ""); + @SuppressWarnings("unchecked") + Map details = (Map) result.get("details"); + String message = ""; + String copyFlag = "0"; + + @SuppressWarnings("unchecked") + Map preview = (Map) result.get("preview"); + String previewName = preview != null ? stringValue(preview.get("name"), null) : null; + String previewPath = preview != null ? stringValue(preview.get("path"), null) : null; + String previewMime = preview != null ? stringValue(preview.get("mime"), null) : null; + Object previewQualityObj = preview != null ? preview.get("quality") : null; + Integer previewQuality = previewQualityObj instanceof Number ? ((Number) previewQualityObj).intValue() : null; + String previewNote = preview != null ? stringValue(preview.get("note"), null) : null; + + String base64 = stringValue(result.get("base64"), null); + String base64Omitted = stringValue(result.get("base64_omitted"), null); + Integer base64Length = toInteger(result.get("base64_length")); + String base64Mime = stringValue(result.get("base64_mime"), null); + String base64Codec = stringValue(result.get("base64_codec"), null); + Integer base64Quality = toInteger(result.get("base64_quality")); + String base64Note = stringValue(result.get("base64_note"), null); + + switch (status) { + case "equal": + message = "Matches stored reference."; + break; + case "missing_expected": + message = "Reference screenshot missing at " + expectedPath + "."; + copyFlag = "1"; + commentEntries.add(buildEntry( + test, + "missing reference", + message, + previewName, + previewPath, + previewMime, + previewQuality, + previewNote, + base64, + base64Omitted, + base64Length, + base64Mime, + base64Codec, + base64Quality, + base64Note + )); + break; + case "different": + String dims = ""; + if (details != null && details.containsKey("width") && details.containsKey("height")) { + dims = String.format( + " (%sx%s px, bit depth %s)", + details.get("width"), + details.get("height"), + details.get("bit_depth") + ); + } + message = "Screenshot differs" + dims + "."; + copyFlag = "1"; + commentEntries.add(buildEntry( + test, + "updated screenshot", + message, + previewName, + previewPath, + previewMime, + previewQuality, + previewNote, + base64, + base64Omitted, + base64Length, + base64Mime, + base64Codec, + base64Quality, + base64Note + )); + break; + case "error": + message = "Comparison error: " + stringValue(result.get("message"), "unknown error"); + copyFlag = "1"; + commentEntries.add(buildEntry( + test, + "comparison error", + message, + previewName, + previewPath, + previewMime, + previewQuality, + previewNote, + null, + base64Omitted, + base64Length, + base64Mime, + base64Codec, + base64Quality, + base64Note + )); + break; + case "missing_actual": + message = "Actual screenshot missing (test did not produce output)."; + copyFlag = "1"; + commentEntries.add(buildEntry( + test, + "missing actual screenshot", + message, + previewName, + previewPath, + previewMime, + previewQuality, + previewNote, + null, + base64Omitted, + base64Length, + base64Mime, + base64Codec, + base64Quality, + base64Note + )); + break; + default: + message = "Status: " + status + "."; + break; + } + + String noteColumn = previewNote != null ? previewNote : (base64Note != null ? base64Note : ""); + summary.add(String.join("|", new String[] {status, test, message, copyFlag, actualPath, noteColumn})); + } + + String commentBody = buildComment(commentEntries, platformLabel); + return new RenderResult(summary, commentBody); + } + + private static Map buildEntry( + String test, + String status, + String message, + String previewName, + String previewPath, + String previewMime, + Integer previewQuality, + String previewNote, + String base64, + String base64Omitted, + Integer base64Length, + String base64Mime, + String base64Codec, + Integer base64Quality, + String base64Note + ) { + Map entry = new LinkedHashMap<>(); + entry.put("test", test); + entry.put("status", status); + entry.put("message", message); + entry.put("artifact_name", previewName != null ? previewName : test + ".png"); + entry.put("preview_name", previewName); + entry.put("preview_path", previewPath); + entry.put("preview_mime", previewMime); + entry.put("preview_quality", previewQuality); + entry.put("preview_note", previewNote); + entry.put("base64", base64); + entry.put("base64_omitted", base64Omitted); + entry.put("base64_length", base64Length); + entry.put("base64_mime", base64Mime); + entry.put("base64_codec", base64Codec); + entry.put("base64_quality", base64Quality); + entry.put("base64_note", base64Note); + return entry; + } + + private static String buildComment(List> entries, String platformLabel) { + if (entries.isEmpty()) { + return ""; + } + List lines = new ArrayList<>(); + lines.add("### " + platformLabel + " screenshot updates"); + lines.add(""); + for (Map entry : entries) { + StringBuilder header = new StringBuilder(); + header.append("- **").append(stringValue(entry.get("test"), "unknown")) + .append("** — ") + .append(stringValue(entry.get("status"), "update")) + .append(". ") + .append(stringValue(entry.get("message"), "")); + lines.add(header.toString()); + + String previewName = stringValue(entry.get("preview_name"), null); + String previewMime = stringValue(entry.get("preview_mime"), null); + Integer previewQuality = toInteger(entry.get("preview_quality")); + String previewNote = stringValue(entry.get("preview_note"), null); + String base64 = stringValue(entry.get("base64"), null); + String base64Omitted = stringValue(entry.get("base64_omitted"), null); + Integer base64Length = toInteger(entry.get("base64_length")); + String base64Codec = stringValue(entry.get("base64_codec"), null); + Integer base64Quality = toInteger(entry.get("base64_quality")); + String base64Note = stringValue(entry.get("base64_note"), null); + + List notes = new ArrayList<>(); + if ("image/jpeg".equals(previewMime) && previewQuality != null) { + notes.add("JPEG preview quality " + previewQuality); + } + if (previewNote != null && !previewNote.isEmpty()) { + notes.add(previewNote); + } + if (base64Note != null && !base64Note.equals(previewNote)) { + notes.add(base64Note); + } + + if (previewName != null) { + lines.add(""); + lines.add(" ![" + stringValue(entry.get("test"), "preview") + "](attachment:" + previewName + ")"); + if (!notes.isEmpty()) { + lines.add(" _Preview info: " + String.join("; ", notes) + "._"); + } + } else if (base64 != null) { + lines.add(""); + lines.add(" _Preview generated but could not be published; see workflow artifacts for JPEG preview._"); + if (!notes.isEmpty()) { + lines.add(" _Preview info: " + String.join("; ", notes) + "._"); + } + } else if ("too_large".equals(base64Omitted)) { + StringBuilder extra = new StringBuilder(); + if (base64Length != null) { + extra.append(" (base64 length ≈ ").append(String.format("%,d", base64Length)).append(" chars)"); + } + if ("jpeg".equals(base64Codec) && base64Quality != null) { + notes.add("attempted JPEG quality " + base64Quality); + } + if (base64Note != null && !base64Note.isEmpty()) { + notes.add(base64Note); + } + lines.add(""); + lines.add(" _Preview omitted" + extra + ". " + String.join("; ", notes) + "._"); + } + } + + lines.add(""); + lines.add(MARKER); + lines.add(""); + return String.join("\n", lines).trim(); + } + + private static String stringValue(Object value, String fallback) { + if (value == null) { + return fallback; + } + return String.valueOf(value); + } + + private static Integer toInteger(Object value) { + if (value instanceof Number) { + return ((Number) value).intValue(); + } + return null; + } + + static final class RenderResult { + final List summaryLines; + final String commentBody; + + RenderResult(List summaryLines, String commentBody) { + this.summaryLines = summaryLines; + this.commentBody = commentBody; + } + } +} diff --git a/scripts/tools/cn1ss-java/src/com/codename1/tools/cn1ss/FileUtils.java b/scripts/tools/cn1ss-java/src/com/codename1/tools/cn1ss/FileUtils.java new file mode 100644 index 0000000000..7a91b13c00 --- /dev/null +++ b/scripts/tools/cn1ss-java/src/com/codename1/tools/cn1ss/FileUtils.java @@ -0,0 +1,32 @@ +package com.codename1.tools.cn1ss; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; + +final class FileUtils { + private FileUtils() { + } + + static void deleteRecursive(Path path) throws IOException { + if (!Files.exists(path)) { + return; + } + Files.walkFileTree(path, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.deleteIfExists(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + Files.deleteIfExists(dir); + return FileVisitResult.CONTINUE; + } + }); + } +} diff --git a/scripts/tools/cn1ss-java/src/com/codename1/tools/cn1ss/Json.java b/scripts/tools/cn1ss-java/src/com/codename1/tools/cn1ss/Json.java new file mode 100644 index 0000000000..9ef7f358f1 --- /dev/null +++ b/scripts/tools/cn1ss-java/src/com/codename1/tools/cn1ss/Json.java @@ -0,0 +1,308 @@ +package com.codename1.tools.cn1ss; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +final class Json { + private Json() { + } + + static String stringify(Object value) { + StringBuilder builder = new StringBuilder(); + write(value, builder); + return builder.toString(); + } + + private static void write(Object value, StringBuilder builder) { + if (value == null) { + builder.append("null"); + } else if (value instanceof String) { + builder.append('"').append(escape((String) value)).append('"'); + } else if (value instanceof Number || value instanceof Boolean) { + builder.append(value.toString()); + } else if (value instanceof Map) { + builder.append('{'); + boolean first = true; + for (Map.Entry entry : ((Map) value).entrySet()) { + if (!first) { + builder.append(','); + } + first = false; + write(entry.getKey().toString(), builder); + builder.append(':'); + write(entry.getValue(), builder); + } + builder.append('}'); + } else if (value instanceof Iterable) { + builder.append('['); + boolean first = true; + for (Object item : (Iterable) value) { + if (!first) { + builder.append(','); + } + first = false; + write(item, builder); + } + builder.append(']'); + } else { + write(value.toString(), builder); + } + } + + private static String escape(String text) { + StringBuilder builder = new StringBuilder(text.length() + 16); + for (char ch : text.toCharArray()) { + switch (ch) { + case '\\': + builder.append("\\\\"); + break; + case '"': + builder.append("\\\""); + break; + case '\b': + builder.append("\\b"); + break; + case '\f': + builder.append("\\f"); + break; + case '\n': + builder.append("\\n"); + break; + case '\r': + builder.append("\\r"); + break; + case '\t': + builder.append("\\t"); + break; + default: + if (ch < 0x20) { + builder.append(String.format("\\u%04x", (int) ch)); + } else { + builder.append(ch); + } + } + } + return builder.toString(); + } + + static Object parse(String json) { + Parser parser = new Parser(json); + Object value = parser.parseValue(); + parser.skipWhitespace(); + if (!parser.isEnd()) { + throw new IllegalArgumentException("Unexpected trailing data in JSON"); + } + return value; + } + + private static final class Parser { + private final String text; + private int index; + + Parser(String text) { + this.text = text; + this.index = 0; + } + + Object parseValue() { + skipWhitespace(); + if (isEnd()) { + throw new IllegalArgumentException("Unexpected end of JSON"); + } + char ch = text.charAt(index); + switch (ch) { + case '{': + return parseObject(); + case '[': + return parseArray(); + case '"': + return parseString(); + case 't': + expect("true"); + return Boolean.TRUE; + case 'f': + expect("false"); + return Boolean.FALSE; + case 'n': + expect("null"); + return null; + default: + if (ch == '-' || Character.isDigit(ch)) { + return parseNumber(); + } + throw new IllegalArgumentException("Unexpected character in JSON: " + ch); + } + } + + private Map parseObject() { + Map map = new LinkedHashMap<>(); + index++; // skip { + skipWhitespace(); + if (peek('}')) { + index++; + return map; + } + while (true) { + skipWhitespace(); + String key = parseString(); + skipWhitespace(); + expect(':'); + Object value = parseValue(); + map.put(key, value); + skipWhitespace(); + if (peek('}')) { + index++; + break; + } + expect(','); + } + return map; + } + + private List parseArray() { + List list = new ArrayList<>(); + index++; // skip [ + skipWhitespace(); + if (peek(']')) { + index++; + return list; + } + while (true) { + Object value = parseValue(); + list.add(value); + skipWhitespace(); + if (peek(']')) { + index++; + break; + } + expect(','); + } + return list; + } + + private String parseString() { + if (!peek('"')) { + throw new IllegalArgumentException("Expected string"); + } + index++; // skip opening quote + StringBuilder builder = new StringBuilder(); + while (index < text.length()) { + char ch = text.charAt(index++); + if (ch == '"') { + break; + } + if (ch == '\\') { + if (index >= text.length()) { + throw new IllegalArgumentException("Incomplete escape sequence"); + } + char esc = text.charAt(index++); + switch (esc) { + case '"': + case '\\': + case '/': + builder.append(esc); + break; + case 'b': + builder.append('\b'); + break; + case 'f': + builder.append('\f'); + break; + case 'n': + builder.append('\n'); + break; + case 'r': + builder.append('\r'); + break; + case 't': + builder.append('\t'); + break; + case 'u': + if (index + 4 > text.length()) { + throw new IllegalArgumentException("Invalid unicode escape"); + } + String hex = text.substring(index, index + 4); + index += 4; + builder.append((char) Integer.parseInt(hex, 16)); + break; + default: + throw new IllegalArgumentException("Invalid escape sequence: \\" + esc); + } + } else { + builder.append(ch); + } + } + return builder.toString(); + } + + private Number parseNumber() { + int start = index; + if (peek('-')) { + index++; + } + while (index < text.length() && Character.isDigit(text.charAt(index))) { + index++; + } + if (peek('.')) { + index++; + while (index < text.length() && Character.isDigit(text.charAt(index))) { + index++; + } + } + if (peek('e') || peek('E')) { + index++; + if (peek('+') || peek('-')) { + index++; + } + while (index < text.length() && Character.isDigit(text.charAt(index))) { + index++; + } + } + String slice = text.substring(start, index); + if (slice.indexOf('.') >= 0 || slice.indexOf('e') >= 0 || slice.indexOf('E') >= 0) { + return Double.valueOf(slice); + } + try { + return Long.valueOf(slice); + } catch (NumberFormatException ex) { + return Double.valueOf(slice); + } + } + + private void expect(char ch) { + skipWhitespace(); + if (isEnd() || text.charAt(index) != ch) { + throw new IllegalArgumentException("Expected '" + ch + "'"); + } + index++; + } + + private void expect(String literal) { + skipWhitespace(); + if (!text.startsWith(literal, index)) { + throw new IllegalArgumentException("Expected '" + literal + "'"); + } + index += literal.length(); + } + + void skipWhitespace() { + while (index < text.length()) { + char ch = text.charAt(index); + if (!Character.isWhitespace(ch)) { + break; + } + index++; + } + } + + boolean isEnd() { + return index >= text.length(); + } + + private boolean peek(char ch) { + return index < text.length() && text.charAt(index) == ch; + } + } +} diff --git a/scripts/tools/cn1ss-java/src/com/codename1/tools/cn1ss/PrCommentPublisher.java b/scripts/tools/cn1ss-java/src/com/codename1/tools/cn1ss/PrCommentPublisher.java new file mode 100644 index 0000000000..9ba8756521 --- /dev/null +++ b/scripts/tools/cn1ss-java/src/com/codename1/tools/cn1ss/PrCommentPublisher.java @@ -0,0 +1,421 @@ +package com.codename1.tools.cn1ss; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +final class PrCommentPublisher { + private static final String LOG_PREFIX = "[run-ios-simulator-tests]"; + private static final Pattern ATTACHMENT_PATTERN = Pattern.compile("\\(attachment:([^)]+)\\)"); + + private PrCommentPublisher() { + } + + static int publish(Path bodyPath, Path previewDir) throws Exception { + if (!Files.isRegularFile(bodyPath)) { + return 0; + } + String rawBody = Files.readString(bodyPath, StandardCharsets.UTF_8); + String body = rawBody.trim(); + if (body.isEmpty()) { + return 0; + } + if (!body.contains(CommentRenderer.MARKER)) { + body = body + System.lineSeparator() + System.lineSeparator() + CommentRenderer.MARKER; + } + String bodyWithoutMarker = body.replace(CommentRenderer.MARKER, "").trim(); + if (bodyWithoutMarker.isEmpty()) { + return 0; + } + + String eventPathEnv = System.getenv("GITHUB_EVENT_PATH"); + String repo = System.getenv("GITHUB_REPOSITORY"); + String token = System.getenv("GITHUB_TOKEN"); + if (eventPathEnv == null || repo == null || token == null) { + return 0; + } + Path eventPath = Path.of(eventPathEnv); + if (!Files.isRegularFile(eventPath)) { + return 0; + } + + Object eventData = Json.parse(Files.readString(eventPath, StandardCharsets.UTF_8)); + if (!(eventData instanceof Map)) { + return 0; + } + @SuppressWarnings("unchecked") + Map event = (Map) eventData; + Integer prNumber = findPrNumber(event); + if (prNumber == null) { + return 0; + } + + boolean isFork = isForkedPr(event); + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(20)) + .build(); + + Map existing = findExistingComment(client, repo, prNumber, token); + Integer commentId = existing != null ? toInt(existing.get("id")) : null; + boolean createdPlaceholder = false; + if (commentId == null) { + commentId = createPlaceholder(client, repo, prNumber, token); + if (commentId == null) { + return 1; + } + createdPlaceholder = true; + log("Created new screenshot comment placeholder (id=" + commentId + ")"); + } + + Map attachmentUrls = new HashMap<>(); + if (body.contains("(attachment:")) { + try { + attachmentUrls = publishPreviews(previewDir, repo, prNumber, token, !isFork); + for (Map.Entry entry : attachmentUrls.entrySet()) { + log("Preview available for " + entry.getKey() + ": " + entry.getValue()); + } + } catch (Exception ex) { + err("Preview publishing failed: " + ex.getMessage()); + return 1; + } + } + + List missing = new ArrayList<>(); + String finalBody = replaceAttachments(body, attachmentUrls, missing); + if (!missing.isEmpty() && !isFork) { + err("Failed to resolve preview URLs for: " + String.join(", ", missing)); + return 1; + } + if (!missing.isEmpty() && isFork) { + log("Preview URLs unavailable in forked PR context; placeholders left as-is"); + } + + String jsonBody = Json.stringify(Map.of("body", finalBody)); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("https://api.github.com/repos/" + repo + "/issues/comments/" + commentId)) + .header("Authorization", "token " + token) + .header("Accept", "application/vnd.github+json") + .header("Content-Type", "application/json") + .method("PATCH", HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8)) + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() / 100 != 2) { + err("PR comment update failed with status " + response.statusCode() + ": " + response.body()); + return 1; + } + log("PR comment " + (createdPlaceholder ? "posted" : "updated") + " (status=" + response.statusCode() + ")"); + return 0; + } + + private static Integer findPrNumber(Map event) { + Integer number = toInt(getNested(event, "pull_request", "number")); + if (number != null) { + return number; + } + Object issueObj = event.get("issue"); + if (issueObj instanceof Map) { + Map issue = (Map) issueObj; + if (issue.get("pull_request") != null) { + return toInt(issue.get("number")); + } + } + return null; + } + + private static boolean isForkedPr(Map event) { + Object pullRequest = event.get("pull_request"); + if (pullRequest instanceof Map) { + Object head = ((Map) pullRequest).get("head"); + if (head instanceof Map) { + Object repo = ((Map) head).get("repo"); + if (repo instanceof Map) { + Object fork = ((Map) repo).get("fork"); + if (fork instanceof Boolean) { + return (Boolean) fork; + } + } + } + } + return false; + } + + private static Map findExistingComment(HttpClient client, String repo, int prNumber, String token) throws Exception { + String url = "https://api.github.com/repos/" + repo + "/issues/" + prNumber + "/comments?per_page=100"; + String actor = Optional.ofNullable(System.getenv("GITHUB_ACTOR")).orElse(""); + while (url != null) { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", "token " + token) + .header("Accept", "application/vnd.github+json") + .GET() + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() / 100 != 2) { + throw new IOException("Failed to list comments: status=" + response.statusCode()); + } + Object data = Json.parse(response.body()); + if (!(data instanceof List)) { + break; + } + @SuppressWarnings("unchecked") + List comments = (List) data; + Map preferred = null; + for (Object item : comments) { + if (!(item instanceof Map)) { + continue; + } + @SuppressWarnings("unchecked") + Map comment = (Map) item; + String body = stringValue(comment.get("body")); + if (body != null && body.contains(CommentRenderer.MARKER)) { + Map user = getMap(comment, "user"); + String login = user != null ? stringValue(user.get("login")) : null; + if (preferred == null) { + preferred = comment; + } + if (login != null) { + if (login.equals(actor) || login.equals("github-actions[bot]")) { + return comment; + } + } + } + } + if (preferred != null) { + return preferred; + } + url = nextLink(response.headers().firstValue("Link").orElse(null)); + } + return null; + } + + private static Integer createPlaceholder(HttpClient client, String repo, int prNumber, String token) throws Exception { + Map payload = Map.of("body", CommentRenderer.MARKER); + String json = Json.stringify(payload); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("https://api.github.com/repos/" + repo + "/issues/" + prNumber + "/comments")) + .header("Authorization", "token " + token) + .header("Accept", "application/vnd.github+json") + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8)) + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() / 100 != 2) { + err("Failed to create PR comment placeholder: status=" + response.statusCode()); + return null; + } + Object data = Json.parse(response.body()); + if (data instanceof Map) { + return toInt(((Map) data).get("id")); + } + return null; + } + + private static Map publishPreviews( + Path previewDir, + String repo, + int prNumber, + String token, + boolean allowPush + ) throws IOException, InterruptedException { + Map urls = new HashMap<>(); + if (previewDir == null || !Files.isDirectory(previewDir)) { + return urls; + } + List images = new ArrayList<>(); + try (var stream = Files.list(previewDir)) { + stream.filter(path -> Files.isRegularFile(path)) + .filter(path -> { + String name = path.getFileName().toString().toLowerCase(Locale.ROOT); + return name.endsWith(".jpg") || name.endsWith(".jpeg") || name.endsWith(".png"); + }) + .sorted() + .forEach(images::add); + } + if (images.isEmpty() || !allowPush || repo == null || repo.isEmpty() || token == null || token.isEmpty()) { + return urls; + } + + Path workspace = Optional.ofNullable(System.getenv("GITHUB_WORKSPACE")) + .map(Path::of) + .orElse(Path.of(".")) + .toAbsolutePath(); + Path worktree = workspace.resolve(".cn1ss-previews-pr-" + prNumber); + if (Files.exists(worktree)) { + FileUtils.deleteRecursive(worktree); + } + Files.createDirectories(worktree); + + try { + runGit(worktree, List.of("init")); + String actor = Optional.ofNullable(System.getenv("GITHUB_ACTOR")).orElse("github-actions"); + runGit(worktree, List.of("config", "user.name", actor)); + runGit(worktree, List.of("config", "user.email", "github-actions@users.noreply.github.com")); + String remoteUrl = "https://x-access-token:" + token + "@github.com/" + repo + ".git"; + runGit(worktree, List.of("remote", "add", "origin", remoteUrl)); + + ProcessResult lsRemote = runGit(worktree, List.of("ls-remote", "--heads", "origin", "cn1ss-previews"), false); + if (lsRemote.exitCode == 0 && !lsRemote.stdout.isBlank()) { + runGit(worktree, List.of("fetch", "origin", "cn1ss-previews")); + runGit(worktree, List.of("checkout", "cn1ss-previews")); + } else { + runGit(worktree, List.of("checkout", "--orphan", "cn1ss-previews")); + } + + Path dest = worktree.resolve("pr-" + prNumber); + if (Files.exists(dest)) { + FileUtils.deleteRecursive(dest); + } + Files.createDirectories(dest); + for (Path image : images) { + Files.copy(image, dest.resolve(image.getFileName())); + } + + runGit(worktree, List.of("add", "-A", ".")); + ProcessResult status = runGit(worktree, List.of("status", "--porcelain"), false); + if (!status.stdout.isBlank()) { + runGit(worktree, List.of("commit", "-m", "Add previews for PR #" + prNumber)); + ProcessResult push = runGit(worktree, List.of("push", "origin", "HEAD:cn1ss-previews"), false); + if (push.exitCode != 0) { + throw new IOException(push.stderr.isBlank() ? push.stdout.trim() : push.stderr.trim()); + } + log("Published " + images.size() + " preview(s) to cn1ss-previews/pr-" + prNumber); + } else { + log("Preview branch already up-to-date for PR #" + prNumber); + } + + String rawBase = "https://raw.githubusercontent.com/" + repo + "/cn1ss-previews/pr-" + prNumber; + try (var stream = Files.list(dest)) { + stream.filter(Files::isRegularFile) + .sorted() + .forEach(path -> urls.put(path.getFileName().toString(), rawBase + "/" + path.getFileName())); + } + } finally { + FileUtils.deleteRecursive(worktree); + } + return urls; + } + + private static String replaceAttachments(String body, Map urls, List missing) { + Matcher matcher = ATTACHMENT_PATTERN.matcher(body); + StringBuffer sb = new StringBuffer(); + while (matcher.find()) { + String name = matcher.group(1); + String url = urls.get(name); + if (url != null) { + matcher.appendReplacement(sb, Matcher.quoteReplacement("(" + url + ")")); + } else { + missing.add(name); + log("Preview URL missing for " + name + "; leaving placeholder"); + matcher.appendReplacement(sb, Matcher.quoteReplacement("(#)")); + } + } + matcher.appendTail(sb); + return sb.toString(); + } + + private static String nextLink(String header) { + if (header == null) { + return null; + } + String[] parts = header.split(","); + for (String part : parts) { + String segment = part.trim(); + if (segment.endsWith("rel=\"next\"")) { + int start = segment.indexOf('<'); + int end = segment.indexOf('>'); + if (start >= 0 && end > start) { + return segment.substring(start + 1, end); + } + } + } + return null; + } + + private static void log(String message) { + System.out.println(LOG_PREFIX + " " + message); + } + + private static void err(String message) { + System.err.println(LOG_PREFIX + " " + message); + } + + private static String stringValue(Object value) { + return value != null ? value.toString() : null; + } + + private static Integer toInt(Object value) { + if (value instanceof Number) { + return ((Number) value).intValue(); + } + return null; + } + + @SuppressWarnings("unchecked") + private static Map getMap(Map map, String key) { + Object value = map.get(key); + if (value instanceof Map) { + return (Map) value; + } + return null; + } + + private static Object getNested(Map map, String... keys) { + Object current = map; + for (String key : keys) { + if (!(current instanceof Map)) { + return null; + } + current = ((Map) current).get(key); + } + return current; + } + + private static ProcessResult runGit(Path workdir, List args) throws IOException, InterruptedException { + return runGit(workdir, args, true); + } + + private static ProcessResult runGit(Path workdir, List args, boolean check) throws IOException, InterruptedException { + List command = new ArrayList<>(); + command.add("git"); + command.addAll(args); + ProcessBuilder builder = new ProcessBuilder(command); + Map env = builder.environment(); + env.putIfAbsent("GIT_TERMINAL_PROMPT", "0"); + builder.directory(workdir.toFile()); + Process process = builder.start(); + int exit = process.waitFor(); + String stdout = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + String stderr = new String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); + if (check && exit != 0) { + throw new IOException("git command failed: " + String.join(" ", args) + " -> " + stderr.trim()); + } + return new ProcessResult(exit, stdout, stderr); + } + + private static final class ProcessResult { + final int exitCode; + final String stdout; + final String stderr; + + ProcessResult(int exitCode, String stdout, String stderr) { + this.exitCode = exitCode; + this.stdout = Objects.requireNonNullElse(stdout, ""); + this.stderr = Objects.requireNonNullElse(stderr, ""); + } + } +} diff --git a/scripts/tools/cn1ss-java/src/com/codename1/tools/cn1ss/ScreenshotComparator.java b/scripts/tools/cn1ss-java/src/com/codename1/tools/cn1ss/ScreenshotComparator.java new file mode 100644 index 0000000000..87d97a24ce --- /dev/null +++ b/scripts/tools/cn1ss-java/src/com/codename1/tools/cn1ss/ScreenshotComparator.java @@ -0,0 +1,432 @@ +package com.codename1.tools.cn1ss; + +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.awt.image.ColorModel; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import javax.imageio.IIOImage; +import javax.imageio.ImageIO; +import javax.imageio.ImageWriteParam; +import javax.imageio.ImageWriter; +import javax.imageio.stream.ImageOutputStream; + +final class ScreenshotComparator { + private static final int MAX_COMMENT_BASE64 = 60_000; + private static final float[] SCALES = new float[] {1.0f, 0.7f, 0.5f, 0.35f, 0.25f}; + private static final int[] JPEG_QUALITIES = new int[] {70, 60, 50, 40, 30, 20, 10}; + + private ScreenshotComparator() { + } + + static ComparisonReport buildReport( + Path referenceDir, + List actualEntries, + boolean emitBase64, + Path previewDir, + Path previewSourceDir + ) throws IOException { + List> results = new ArrayList<>(); + for (ActualEntry entry : actualEntries) { + Map record = new LinkedHashMap<>(); + record.put("test", entry.testName); + record.put("actual_path", entry.path.toString()); + Path expected = referenceDir.resolve(entry.testName + ".png"); + record.put("expected_path", expected.toString()); + + if (!Files.exists(entry.path)) { + record.put("status", "missing_actual"); + record.put("message", "Actual screenshot not found"); + results.add(record); + continue; + } + + if (!Files.exists(expected)) { + record.put("status", "missing_expected"); + if (emitBase64) { + CommentPayload payload = loadExternalPreview(entry.testName, previewSourceDir); + if (payload == null) { + payload = buildCommentPayload(readImage(entry.path)); + } + recordPayload(record, payload, entry.path.getFileName().toString(), previewDir); + } + results.add(record); + continue; + } + + try { + BufferedImage actual = readImage(entry.path); + BufferedImage expectedImage = readImage(expected); + ComparisonDetails details = compare(expectedImage, actual); + if (details.equal) { + record.put("status", "equal"); + } else { + record.put("status", "different"); + record.put("details", details.toMap()); + if (emitBase64) { + CommentPayload payload = loadExternalPreview(entry.testName, previewSourceDir); + if (payload == null) { + payload = buildCommentPayload(actual); + } + recordPayload(record, payload, entry.path.getFileName().toString(), previewDir); + } + } + } catch (Exception ex) { + record.put("status", "error"); + record.put("message", ex.getMessage()); + } + results.add(record); + } + Map payload = new HashMap<>(); + payload.put("results", results); + return new ComparisonReport(results, payload); + } + + private static BufferedImage readImage(Path path) throws IOException { + byte[] data = Files.readAllBytes(path); + try (ByteArrayInputStream in = new ByteArrayInputStream(data)) { + BufferedImage image = ImageIO.read(in); + if (image == null) { + throw new IOException("Unsupported image format: " + path); + } + if (image.getType() == BufferedImage.TYPE_INT_ARGB) { + return image; + } + BufferedImage converted = new BufferedImage( + image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_ARGB + ); + Graphics2D g = converted.createGraphics(); + try { + g.drawImage(image, 0, 0, null); + } finally { + g.dispose(); + } + return converted; + } + } + + private static ComparisonDetails compare(BufferedImage expected, BufferedImage actual) { + boolean equal = expected.getWidth() == actual.getWidth() + && expected.getHeight() == actual.getHeight(); + int width = actual.getWidth(); + int height = actual.getHeight(); + + if (equal) { + outer: + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + if (expected.getRGB(x, y) != actual.getRGB(x, y)) { + equal = false; + break outer; + } + } + } + } + + int bitDepth = estimateBitDepth(actual.getColorModel()); + int colorType = actual.getColorModel().getColorSpace().getType(); + return new ComparisonDetails(equal, width, height, bitDepth, colorType); + } + + private static int estimateBitDepth(ColorModel model) { + if (model == null) { + return 8; + } + int components = Math.max(1, model.getNumComponents()); + int bits = model.getPixelSize(); + if (components > 0 && bits > 0) { + return Math.max(1, bits / components); + } + return Math.max(1, model.getComponentSize(0)); + } + + private static CommentPayload loadExternalPreview(String testName, Path previewSourceDir) throws IOException { + if (previewSourceDir == null) { + return null; + } + if (!Files.isDirectory(previewSourceDir)) { + return null; + } + String slug = slugify(testName); + Path[] candidates = new Path[] { + previewSourceDir.resolve(slug + ".jpg"), + previewSourceDir.resolve(slug + ".jpeg"), + previewSourceDir.resolve(slug + ".png"), + }; + for (Path candidate : candidates) { + if (!Files.exists(candidate)) { + continue; + } + byte[] data = Files.readAllBytes(candidate); + String encoded = Base64.getEncoder().encodeToString(data); + if (encoded.length() <= MAX_COMMENT_BASE64) { + return new CommentPayload(encoded, encoded.length(), mimeFor(candidate), codecFor(candidate), null, "Preview provided by instrumentation", data); + } + return new CommentPayload(null, encoded.length(), mimeFor(candidate), codecFor(candidate), null, "Preview provided by instrumentation", data, "too_large"); + } + return null; + } + + private static String mimeFor(Path path) { + String name = path.getFileName().toString().toLowerCase(); + if (name.endsWith(".jpg") || name.endsWith(".jpeg")) { + return "image/jpeg"; + } + return "image/png"; + } + + private static String codecFor(Path path) { + String name = path.getFileName().toString().toLowerCase(); + if (name.endsWith(".jpg") || name.endsWith(".jpeg")) { + return "jpeg"; + } + return "png"; + } + + private static CommentPayload buildCommentPayload(BufferedImage image) throws IOException { + CommentPayload fallback = buildPngPayload(image, null); + BufferedImage rgb = convertToRgb(image); + for (float scale : SCALES) { + BufferedImage candidate = rgb; + if (scale < 0.999f) { + int width = Math.max(1, Math.round(rgb.getWidth() * scale)); + int height = Math.max(1, Math.round(rgb.getHeight() * scale)); + candidate = resize(rgb, width, height); + } + for (int quality : JPEG_QUALITIES) { + byte[] data; + try { + data = encodeJpeg(candidate, quality / 100f); + } catch (IOException ex) { + continue; + } + String encoded = Base64.getEncoder().encodeToString(data); + if (encoded.length() <= MAX_COMMENT_BASE64) { + String note = "JPEG preview quality " + quality; + if (scale < 0.999f) { + note += "; downscaled to " + candidate.getWidth() + "x" + candidate.getHeight(); + } + return new CommentPayload(encoded, encoded.length(), "image/jpeg", "jpeg", quality, note, data); + } + fallback = new CommentPayload(null, encoded.length(), "image/jpeg", "jpeg", quality, "All JPEG previews exceeded limit", data, "too_large"); + } + } + return fallback; + } + + private static CommentPayload buildPngPayload(BufferedImage image, String note) throws IOException { + byte[] data = encodePng(image); + String encoded = Base64.getEncoder().encodeToString(data); + if (encoded.length() <= MAX_COMMENT_BASE64) { + return new CommentPayload(encoded, encoded.length(), "image/png", "png", null, note, data); + } + return new CommentPayload(null, encoded.length(), "image/png", "png", null, note, data, "too_large"); + } + + private static BufferedImage convertToRgb(BufferedImage src) { + if (src.getType() == BufferedImage.TYPE_INT_RGB) { + return src; + } + BufferedImage image = new BufferedImage(src.getWidth(), src.getHeight(), BufferedImage.TYPE_INT_RGB); + Graphics2D g = image.createGraphics(); + try { + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + g.drawImage(src, 0, 0, null); + } finally { + g.dispose(); + } + return image; + } + + private static BufferedImage resize(BufferedImage src, int width, int height) { + BufferedImage image = new BufferedImage(width, height, src.getType()); + Graphics2D g = image.createGraphics(); + try { + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + g.drawImage(src, 0, 0, width, height, null); + } finally { + g.dispose(); + } + return image; + } + + private static byte[] encodeJpeg(BufferedImage image, float quality) throws IOException { + var writers = ImageIO.getImageWritersByFormatName("jpeg"); + if (!writers.hasNext()) { + throw new IOException("No JPEG encoder available"); + } + ImageWriter writer = writers.next(); + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageOutputStream stream = ImageIO.createImageOutputStream(baos)) { + writer.setOutput(stream); + ImageWriteParam param = writer.getDefaultWriteParam(); + if (param.canWriteCompressed()) { + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + param.setCompressionQuality(Math.max(0.05f, Math.min(1.0f, quality))); + } + writer.write(null, new IIOImage(image, null, null), param); + return baos.toByteArray(); + } finally { + writer.dispose(); + } + } + + private static byte[] encodePng(BufferedImage image) throws IOException { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + ImageIO.write(image, "png", baos); + return baos.toByteArray(); + } + } + + private static void recordPayload( + Map record, + CommentPayload payload, + String defaultName, + Path previewDir + ) throws IOException { + if (payload == null) { + return; + } + if (payload.base64 != null) { + record.put("base64", payload.base64); + } else { + record.put("base64_omitted", payload.omittedReason); + record.put("base64_length", payload.base64Length); + } + record.put("base64_mime", payload.mime); + record.put("base64_codec", payload.codec); + if (payload.quality != null) { + record.put("base64_quality", payload.quality); + } + if (payload.note != null) { + record.put("base64_note", payload.note); + } + if (previewDir != null && payload.data != null) { + Files.createDirectories(previewDir); + String suffix = payload.mime.equals("image/jpeg") ? ".jpg" : ".png"; + String base = slugify(defaultName.replaceFirst("\\.[^.]+$", "")); + Path target = previewDir.resolve(base + suffix); + Files.write(target, payload.data); + Map preview = new LinkedHashMap<>(); + preview.put("path", target.toString()); + preview.put("name", target.getFileName().toString()); + preview.put("mime", payload.mime); + preview.put("codec", payload.codec); + if (payload.quality != null) { + preview.put("quality", payload.quality); + } + if (payload.note != null) { + preview.put("note", payload.note); + } + record.put("preview", preview); + } + } + + static String slugify(String name) { + StringBuilder builder = new StringBuilder(name.length()); + for (char ch : name.toCharArray()) { + if (Character.isLetterOrDigit(ch)) { + builder.append(ch); + } else { + builder.append('_'); + } + } + return builder.toString(); + } + + static final class ActualEntry { + final String testName; + final Path path; + + ActualEntry(String testName, Path path) { + this.testName = testName; + this.path = path; + } + } + + static final class CommentPayload { + final String base64; + final int base64Length; + final String mime; + final String codec; + final Integer quality; + final String note; + final byte[] data; + final String omittedReason; + + CommentPayload(String base64, int base64Length, String mime, String codec, Integer quality, String note, byte[] data) { + this(base64, base64Length, mime, codec, quality, note, data, null); + } + + CommentPayload(String base64, int base64Length, String mime, String codec, Integer quality, String note, byte[] data, String omittedReason) { + this.base64 = base64; + this.base64Length = base64Length; + this.mime = mime; + this.codec = codec; + this.quality = quality; + this.note = note; + this.data = data; + this.omittedReason = omittedReason; + } + } + + private static final class ComparisonDetails { + final boolean equal; + final int width; + final int height; + final int bitDepth; + final int colorType; + + ComparisonDetails(boolean equal, int width, int height, int bitDepth, int colorType) { + this.equal = equal; + this.width = width; + this.height = height; + this.bitDepth = bitDepth; + this.colorType = colorType; + } + + Map toMap() { + Map map = new LinkedHashMap<>(); + map.put("equal", equal); + map.put("width", width); + map.put("height", height); + map.put("bit_depth", bitDepth); + map.put("color_type", colorType); + return map; + } + } + + static final class ComparisonReport { + private final List> results; + private final Map payload; + + ComparisonReport(List> results, Map payload) { + this.results = Collections.unmodifiableList(results); + this.payload = payload; + } + + List> results() { + return results; + } + + Map payload() { + return payload; + } + + String toJson() { + return Json.stringify(payload); + } + } +} diff --git a/scripts/tools/cn1ss-java/src/com/codename1/tools/cn1ss/SimctlParser.java b/scripts/tools/cn1ss-java/src/com/codename1/tools/cn1ss/SimctlParser.java new file mode 100644 index 0000000000..e320ea7b3b --- /dev/null +++ b/scripts/tools/cn1ss-java/src/com/codename1/tools/cn1ss/SimctlParser.java @@ -0,0 +1,177 @@ +package com.codename1.tools.cn1ss; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +final class SimctlParser { + private SimctlParser() { + } + + static String bestRuntime(String json, String platform) { + Object parsed = Json.parse(json); + if (!(parsed instanceof Map)) { + return ""; + } + @SuppressWarnings("unchecked") + Map root = (Map) parsed; + Object runtimesObj = root.get("runtimes"); + if (!(runtimesObj instanceof List)) { + return ""; + } + @SuppressWarnings("unchecked") + List runtimes = (List) runtimesObj; + String targetPlatform = platform == null ? "iOS" : platform; + List bestVersion = Collections.emptyList(); + String bestIdentifier = ""; + for (Object item : runtimes) { + if (!(item instanceof Map)) { + continue; + } + @SuppressWarnings("unchecked") + Map runtime = (Map) item; + String runtimePlatform = stringValue(runtime.get("platform")); + if (!targetPlatform.equals(runtimePlatform)) { + continue; + } + if (!isAvailable(runtime.get("isAvailable"), runtime.get("availability"))) { + continue; + } + List version = parseVersion(stringValue(runtime.get("version"))); + if (compareVersions(version, bestVersion) > 0) { + bestVersion = version; + bestIdentifier = stringValue(runtime.get("identifier")); + } + } + return bestIdentifier == null ? "" : bestIdentifier; + } + + static String findDeviceType(String json, String deviceName) { + Object parsed = Json.parse(json); + if (!(parsed instanceof Map)) { + return ""; + } + @SuppressWarnings("unchecked") + Map root = (Map) parsed; + Object typesObj = root.get("devicetypes"); + if (!(typesObj instanceof List)) { + return ""; + } + @SuppressWarnings("unchecked") + List types = (List) typesObj; + for (Object item : types) { + if (!(item instanceof Map)) { + continue; + } + @SuppressWarnings("unchecked") + Map type = (Map) item; + String name = stringValue(type.get("name")); + if (deviceName.equals(name)) { + return stringValue(type.get("identifier")); + } + } + return ""; + } + + static String findDeviceInfo(String json, String runtimeId, String deviceName) { + Object parsed = Json.parse(json); + if (!(parsed instanceof Map)) { + return ""; + } + @SuppressWarnings("unchecked") + Map root = (Map) parsed; + Object devicesObj = root.get("devices"); + if (!(devicesObj instanceof Map)) { + return ""; + } + @SuppressWarnings("unchecked") + Map devicesMap = (Map) devicesObj; + for (Map.Entry entry : devicesMap.entrySet()) { + if (runtimeId != null && !runtimeId.isEmpty() && !runtimeId.equals(entry.getKey())) { + continue; + } + Object listObj = entry.getValue(); + if (!(listObj instanceof List)) { + continue; + } + @SuppressWarnings("unchecked") + List devices = (List) listObj; + for (Object item : devices) { + if (!(item instanceof Map)) { + continue; + } + @SuppressWarnings("unchecked") + Map device = (Map) item; + if (!isAvailable(device.get("isAvailable"), device.get("availability"))) { + continue; + } + String name = stringValue(device.get("name")); + if (!deviceName.equals(name)) { + continue; + } + String udid = stringValue(device.get("udid")); + String state = stringValue(device.get("state")); + if (state == null || state.isEmpty()) { + state = "Unknown"; + } + if (udid == null) { + return ""; + } + return udid + "|" + state; + } + } + return ""; + } + + private static boolean isAvailable(Object flag, Object legacy) { + if (flag instanceof Boolean) { + return (Boolean) flag; + } + if (legacy instanceof String) { + return "(available)".equals(legacy); + } + return false; + } + + private static List parseVersion(String text) { + if (text == null || text.isEmpty()) { + return Collections.emptyList(); + } + String normalized = text.replace('-', '.'); + String[] parts = normalized.split("\\."); + List result = new ArrayList<>(parts.length); + for (String part : parts) { + if (part.isEmpty()) { + continue; + } + int value = 0; + try { + value = Integer.parseInt(part); + } catch (NumberFormatException ex) { + break; + } + result.add(value); + } + return result; + } + + private static int compareVersions(List a, List b) { + int length = Math.max(a.size(), b.size()); + for (int i = 0; i < length; i++) { + int ai = i < a.size() ? a.get(i) : 0; + int bi = i < b.size() ? b.get(i) : 0; + if (ai != bi) { + return Integer.compare(ai, bi); + } + } + return 0; + } + + private static String stringValue(Object value) { + if (value == null) { + return ""; + } + return String.valueOf(value); + } +}