diff --git a/.github/workflows/input-validation.yml b/.github/workflows/input-validation.yml new file mode 100644 index 0000000000..d09ab8e7c8 --- /dev/null +++ b/.github/workflows/input-validation.yml @@ -0,0 +1,202 @@ +name: Input validation gesture suite + +# Fast-running iOS suite that asserts physical taps / drags / long-presses +# reach Component listeners end-to-end. Built to catch regressions in the +# input chain (the class of bug PR #5003 fixed -- a window-level +# UITapGestureRecognizer eating every tap was invisible to the existing +# screenshot-only tests because none of them actually depended on a touch +# event firing). +# +# Only iOS for now. The JavaScript port wires its pointer listeners from +# inside a Web Worker via a host-bridge proxy; synthetic events from the +# test driver don't traverse that proxy the way real OS events would, so +# end-to-end input validation on JS needs port-side work first. See +# scripts/input-validation-app/README.adoc for the "not yet covered" list. + +on: + pull_request: + paths: + - '.github/workflows/input-validation.yml' + - 'scripts/input-validation-app/**' + - 'scripts/build-ios-app.sh' + - 'CodenameOne/src/com/codename1/ui/Component.java' + - 'CodenameOne/src/com/codename1/ui/Form.java' + - 'CodenameOne/src/com/codename1/ui/Button.java' + - 'Ports/iOSPort/nativeSources/CN1TapGestureRecognizer.m' + - 'Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m' + push: + branches: [ master ] + paths: + - '.github/workflows/input-validation.yml' + - 'scripts/input-validation-app/**' + - 'scripts/build-ios-app.sh' + workflow_dispatch: {} + +jobs: + build-port: + # Reused by `ios` so the iOS port .jar / native sources are available in + # the cache the build-ios-app step relies on. Same reusable workflow that + # scripts-ios.yml depends on -- piggybacking on its cache key. + uses: ./.github/workflows/_build-ios-port.yml + + ios: + needs: build-port + permissions: + contents: read + runs-on: macos-15 + timeout-minutes: 35 + concurrency: + group: mac-ci-${{ github.workflow }}-ios-${{ github.ref_name }} + cancel-in-progress: true + env: + GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} + GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} + CN1_APP_DIR: scripts/input-validation-app + steps: + - uses: actions/checkout@v4 + + - name: Cache CocoaPods and user gems + uses: actions/cache@v4 + with: + path: | + ~/.gem + ~/Library/Caches/CocoaPods + ~/.cocoapods/repos + key: ${{ runner.os }}-pods-v1-${{ hashFiles('scripts/setup-workspace.sh') }} + restore-keys: | + ${{ runner.os }}-pods-v1- + + - name: Ensure CocoaPods tooling + run: | + mkdir -p ~/.codenameone + cp maven/UpdateCodenameOne.jar ~/.codenameone/ + set -euo pipefail + GEM_USER_DIR="$(ruby -e 'print Gem.user_dir')" + export PATH="$GEM_USER_DIR/bin:$PATH" + if ! command -v pod >/dev/null 2>&1; then + gem install cocoapods xcodeproj --no-document --user-install + fi + pod --version + + - name: Compute setup-workspace hash + id: setup_hash + run: echo "hash=$(shasum -a 256 scripts/setup-workspace.sh | awk '{print $1}')" >> "$GITHUB_OUTPUT" + + - name: Compute CN1 source hash + id: src_hash + run: | + set -euo pipefail + SRC_HASH=$(find CodenameOne/src Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes native-themes \ + -type f \( -name '*.java' -o -name '*.m' -o -name '*.h' -o -name '*.xml' -o -name '*.properties' -o -name '*.css' \) 2>/dev/null \ + | sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}') + POM_HASH=$(find . -name 'pom.xml' -not -path './scripts/*' 2>/dev/null \ + | sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}') + SCRIPT_HASH=$(shasum -a 256 \ + scripts/setup-workspace.sh \ + scripts/build-ios-port.sh \ + scripts/build-native-themes.sh \ + .github/workflows/_build-ios-port.yml \ + | shasum -a 256 | awk '{print $1}') + echo "hash=${SRC_HASH:0:16}-${POM_HASH:0:16}-${SCRIPT_HASH:0:16}" >> "$GITHUB_OUTPUT" + + - name: Set TMPDIR + run: echo "TMPDIR=${{ runner.temp }}" >> $GITHUB_ENV + + - name: Cache codenameone-tools + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/codenameone-tools + key: ${{ runner.os }}-cn1-tools-${{ steps.setup_hash.outputs.hash }} + restore-keys: | + ${{ runner.os }}-cn1-tools- + + - name: Cache Maven repository + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2- + + - name: Restore cn1-binaries cache + uses: actions/cache@v4 + with: + path: ../cn1-binaries + key: cn1-binaries-${{ runner.os }}-${{ steps.setup_hash.outputs.hash }} + restore-keys: | + cn1-binaries-${{ runner.os }}- + + - name: Restore built CN1 + iOS port artifacts + uses: actions/cache/restore@v4 + with: + path: | + ~/.m2/repository/com/codenameone + Themes + Ports/iOSPort/nativeSources + key: cn1-built-${{ runner.os }}-${{ steps.src_hash.outputs.hash }} + fail-on-cache-miss: true + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Build CN1 input-validation iOS Xcode project + id: build-ios-app + run: ./scripts/build-ios-app.sh -q -DskipTests + timeout-minutes: 25 + + - name: Build .app bundle for simulator + id: build-app-bundle + run: | + set -euo pipefail + WORKSPACE='${{ steps.build-ios-app.outputs.workspace }}' + SCHEME='${{ steps.build-ios-app.outputs.scheme }}' + DERIVED_DATA="${{ runner.temp }}/cn1iv-dd" + rm -rf "$DERIVED_DATA" + # Force ARCHS to match the host. IOSSimd.m uses ARM NEON intrinsics + # under #if defined(__aarch64__) -- without ONLY_ACTIVE_ARCH=YES the + # generic simulator destination triggers a universal build whose + # x86_64 slice fails to compile. Matches what run-ios-ui-tests.sh + # does for the hellocodenameone build. + HOST_ARCH="$(uname -m)" + case "$HOST_ARCH" in + arm64|x86_64) BUILD_ARCH="$HOST_ARCH" ;; + *) BUILD_ARCH=arm64 ;; + esac + XCB_CONTAINER_FLAG="-workspace" + if [[ "$WORKSPACE" != *.xcworkspace ]]; then + XCB_CONTAINER_FLAG="-project" + fi + xcodebuild $XCB_CONTAINER_FLAG "$WORKSPACE" -scheme "$SCHEME" \ + -sdk iphonesimulator \ + -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath "$DERIVED_DATA" \ + ARCHS=$BUILD_ARCH ONLY_ACTIVE_ARCH=YES \ + EXCLUDED_ARCHS="armv7 armv7s" \ + CODE_SIGNING_ALLOWED=NO build + APP_BUNDLE="$(find "$DERIVED_DATA/Build/Products" -maxdepth 3 -type d -name "${SCHEME}.app" | head -n 1)" + if [ -z "$APP_BUNDLE" ]; then + echo "Failed to locate .app bundle under $DERIVED_DATA" >&2 + find "$DERIVED_DATA/Build/Products" -maxdepth 4 -type d -print >&2 || true + exit 1 + fi + echo "app_bundle=$APP_BUNDLE" >> "$GITHUB_OUTPUT" + timeout-minutes: 15 + + - name: Drive gestures via XCUITest + env: + ARTIFACTS_DIR: ${{ github.workspace }}/artifacts/input-validation-ios + run: | + mkdir -p "$ARTIFACTS_DIR" + ./scripts/input-validation-app/drivers/run-ios.sh \ + '${{ steps.build-app-bundle.outputs.app_bundle }}' + timeout-minutes: 15 + + - name: Upload iOS artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: input-validation-ios + path: artifacts/input-validation-ios + if-no-files-found: warn + retention-days: 14 diff --git a/scripts/build-ios-app.sh b/scripts/build-ios-app.sh index 2bf7da8baf..469cd84188 100755 --- a/scripts/build-ios-app.sh +++ b/scripts/build-ios-app.sh @@ -78,12 +78,21 @@ if [ -n "${IOS_DEPENDENCY_ARGS:-}" ]; then bia_log "Applying extra iOS build args: ${IOS_DEPENDENCY_ARGS}" fi -APP_DIR="scripts/hellocodenameone" +APP_DIR="${CN1_APP_DIR:-scripts/hellocodenameone}" + +# Derive the iOS project / scheme name from the app's own CN1 settings so +# this script can build any CN1 app, not just hellocodenameone. +CN1_SETTINGS_FILE="$REPO_ROOT/$APP_DIR/common/codenameone_settings.properties" +if [ -f "$CN1_SETTINGS_FILE" ]; then + MAIN_NAME_FROM_SETTINGS="$(awk -F= '/^codename1.mainName=/{print $2; exit}' "$CN1_SETTINGS_FILE" | tr -d '\r')" +fi +APP_MAIN_NAME="${CN1_APP_MAIN_NAME:-${MAIN_NAME_FROM_SETTINGS:-HelloCodenameOne}}" +bia_log "Using APP_DIR=$APP_DIR APP_MAIN_NAME=$APP_MAIN_NAME" xcodebuild -version bia_log "Building iOS Xcode project using Codename One port" -cd $APP_DIR +cd "$REPO_ROOT/$APP_DIR" VM_START=$(date +%s) ARTIFACTS_DIR="${ARTIFACTS_DIR:-$REPO_ROOT/artifacts}" @@ -175,11 +184,11 @@ stage_bytecode_translator_sources() { bia_log "Created archive $zip_file" } -bia_log "Running HelloCodenameOne Maven build with JAVA_HOME=$JAVA17_HOME" +bia_log "Running $APP_MAIN_NAME Maven build with JAVA_HOME=$JAVA17_HOME" ( export JAVA_HOME="$JAVA17_HOME" export PATH="$JAVA_HOME/bin:$MAVEN_HOME/bin:$BASE_PATH" - MVN_IOS_LOG="$ARTIFACTS_DIR/hellocn1-ios-build.log" + MVN_IOS_LOG="$ARTIFACTS_DIR/cn1-ios-build.log" MVN_CMD=( ./mvnw package -DskipTests @@ -211,7 +220,7 @@ bia_log "Running HelloCodenameOne Maven build with JAVA_HOME=$JAVA17_HOME" ) VM_END=$(date +%s) VM_TIME=$((VM_END - VM_START)) -cd ../.. +cd "$REPO_ROOT" echo "$VM_TIME" > "$ARTIFACTS_DIR/vm_time.txt" bia_log "VM translation time: ${VM_TIME}s (saved to $ARTIFACTS_DIR/vm_time.txt)" @@ -269,22 +278,22 @@ else bia_log "Podfile not found in generated project; skipping pod install" fi -WORKSPACE_XML=' +WORKSPACE_XML=" + version = \"1.0\"> + location = \"group:${APP_MAIN_NAME}.xcodeproj\"> -' -if [ ! -d "$PROJECT_DIR/HelloCodenameOne.xcworkspace" ] && [ -d "$PROJECT_DIR/HelloCodenameOne.xcodeproj" ]; then +" +if [ ! -d "$PROJECT_DIR/${APP_MAIN_NAME}.xcworkspace" ] && [ -d "$PROJECT_DIR/${APP_MAIN_NAME}.xcodeproj" ]; then bia_log "Creating fallback xcworkspace for generated Xcode project" - mkdir -p "$PROJECT_DIR/HelloCodenameOne.xcworkspace" - printf '%s\n' "$WORKSPACE_XML" > "$PROJECT_DIR/HelloCodenameOne.xcworkspace/contents.xcworkspacedata" + mkdir -p "$PROJECT_DIR/${APP_MAIN_NAME}.xcworkspace" + printf '%s\n' "$WORKSPACE_XML" > "$PROJECT_DIR/${APP_MAIN_NAME}.xcworkspace/contents.xcworkspacedata" fi -if [ -d "$PROJECT_DIR/HelloCodenameOne.xcodeproj" ]; then +if [ -d "$PROJECT_DIR/${APP_MAIN_NAME}.xcodeproj" ]; then bia_log "Ensuring shared Xcode scheme exists" - "$REPO_ROOT/scripts/ios/create-shared-scheme.py" "$PROJECT_DIR" HelloCodenameOne + "$REPO_ROOT/scripts/ios/create-shared-scheme.py" "$PROJECT_DIR" "$APP_MAIN_NAME" fi # Locate workspace or project for the next step @@ -315,11 +324,11 @@ bia_log "Found Xcode entrypoint: $WORKSPACE" if [ -n "${GITHUB_OUTPUT:-}" ]; then { echo "workspace=$WORKSPACE" - echo "scheme=HelloCodenameOne" + echo "scheme=$APP_MAIN_NAME" } >> "$GITHUB_OUTPUT" fi -bia_log "Emitted outputs -> workspace=$WORKSPACE, scheme=HelloCodenameOne" +bia_log "Emitted outputs -> workspace=$WORKSPACE, scheme=$APP_MAIN_NAME" # (Optional) dump xcodebuild -list for debugging ARTIFACTS_DIR="${ARTIFACTS_DIR:-$REPO_ROOT/artifacts}" diff --git a/scripts/input-validation-app/.mvn/jvm.config b/scripts/input-validation-app/.mvn/jvm.config new file mode 100644 index 0000000000..e69de29bb2 diff --git a/scripts/input-validation-app/.mvn/wrapper/MavenWrapperDownloader.java b/scripts/input-validation-app/.mvn/wrapper/MavenWrapperDownloader.java new file mode 100644 index 0000000000..b901097f2d --- /dev/null +++ b/scripts/input-validation-app/.mvn/wrapper/MavenWrapperDownloader.java @@ -0,0 +1,117 @@ +/* + * Copyright 2007-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import java.net.*; +import java.io.*; +import java.nio.channels.*; +import java.util.Properties; + +public class MavenWrapperDownloader { + + private static final String WRAPPER_VERSION = "0.5.6"; + /** + * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided. + */ + private static final String DEFAULT_DOWNLOAD_URL = "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/" + + WRAPPER_VERSION + "/maven-wrapper-" + WRAPPER_VERSION + ".jar"; + + /** + * Path to the maven-wrapper.properties file, which might contain a downloadUrl property to + * use instead of the default one. + */ + private static final String MAVEN_WRAPPER_PROPERTIES_PATH = + ".mvn/wrapper/maven-wrapper.properties"; + + /** + * Path where the maven-wrapper.jar will be saved to. + */ + private static final String MAVEN_WRAPPER_JAR_PATH = + ".mvn/wrapper/maven-wrapper.jar"; + + /** + * Name of the property which should be used to override the default download url for the wrapper. + */ + private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl"; + + public static void main(String args[]) { + System.out.println("- Downloader started"); + File baseDirectory = new File(args[0]); + System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath()); + + // If the maven-wrapper.properties exists, read it and check if it contains a custom + // wrapperUrl parameter. + File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH); + String url = DEFAULT_DOWNLOAD_URL; + if(mavenWrapperPropertyFile.exists()) { + FileInputStream mavenWrapperPropertyFileInputStream = null; + try { + mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile); + Properties mavenWrapperProperties = new Properties(); + mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream); + url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url); + } catch (IOException e) { + System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'"); + } finally { + try { + if(mavenWrapperPropertyFileInputStream != null) { + mavenWrapperPropertyFileInputStream.close(); + } + } catch (IOException e) { + // Ignore ... + } + } + } + System.out.println("- Downloading from: " + url); + + File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH); + if(!outputFile.getParentFile().exists()) { + if(!outputFile.getParentFile().mkdirs()) { + System.out.println( + "- ERROR creating output directory '" + outputFile.getParentFile().getAbsolutePath() + "'"); + } + } + System.out.println("- Downloading to: " + outputFile.getAbsolutePath()); + try { + downloadFileFromURL(url, outputFile); + System.out.println("Done"); + System.exit(0); + } catch (Throwable e) { + System.out.println("- Error downloading"); + e.printStackTrace(); + System.exit(1); + } + } + + private static void downloadFileFromURL(String urlString, File destination) throws Exception { + if (System.getenv("MVNW_USERNAME") != null && System.getenv("MVNW_PASSWORD") != null) { + String username = System.getenv("MVNW_USERNAME"); + char[] password = System.getenv("MVNW_PASSWORD").toCharArray(); + Authenticator.setDefault(new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + return new PasswordAuthentication(username, password); + } + }); + } + URL website = new URL(urlString); + ReadableByteChannel rbc; + rbc = Channels.newChannel(website.openStream()); + FileOutputStream fos = new FileOutputStream(destination); + fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); + fos.close(); + rbc.close(); + } + +} diff --git a/scripts/input-validation-app/.mvn/wrapper/maven-wrapper.properties b/scripts/input-validation-app/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000000..642d572ce9 --- /dev/null +++ b/scripts/input-validation-app/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.6.3/apache-maven-3.6.3-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar diff --git a/scripts/input-validation-app/README.adoc b/scripts/input-validation-app/README.adoc new file mode 100644 index 0000000000..c27929fd81 --- /dev/null +++ b/scripts/input-validation-app/README.adoc @@ -0,0 +1,114 @@ += CN1 Input Validation App + +A minimal Codename One app whose only purpose is to assert that physical +input events (tap, drag, long-press) reach Component listeners end-to-end +on iOS. Driven by XCUITest on the iOS simulator. + +== Why a separate pipeline? + +The existing `scripts/hellocodenameone` test suite builds every form +programmatically and emits screenshots from `runTest()` without ever +depending on a touch event reaching `Component.pointerPressed`. That +gap let three independent input-chain regressions ship on iOS 26 (see +https://github.com/codenameone/CodenameOne/pull/5003): a window-level +`UITapGestureRecognizer` ate every tap, a hover recognizer cancelled +touches on the simulator, and a class-literal comparison NPE fired from +the background painter on every form show. + +This pipeline closes that gap by making input flow the only thing the +app does. There is no theme, no resource bundle, no screenshot +comparison -- the assertion is the stream of `CN1IV:EVENT:*` log lines +emitted when each gesture fires. + +== Suite + +Each step waits up to 8 seconds for its expected event, then auto-advances +on success or timeout. The XCUITest driver issues the actual OS input at +the expected time. + +[cols="1,3"] +|=== +| Step | What it asserts + +| `tap` +| `Button.actionPerformed` fires for a single tap. The PR #5003 + regression. + +| `drag` +| `pointerDragged` is dispatched continuously between `pointerPressed` + and `pointerReleased`, with at least 3 intermediate samples. Catches + both the iOS 26 hover-recognizer regression and the empty-pointer-array + NPE fixed in 2fef7187. + +| `longpress` +| `addLongPressListener` fires for a press-and-hold. Routes through the + same touch chain as tap, so a recognizer that cancels touches mid-press + causes this step to time out. +|=== + +== Log markers + +Everything the driver cares about is a single line on stdout: + +---- +CN1IV:SUITE:STARTED platform= w= h= +CN1IV:READY: # step is armed and waiting for input +CN1IV:EVENT::
# input arrived; advancing to next step +CN1IV:TIMEOUT: # 8s elapsed without input -- step failed +CN1IV:SUITE:FINISHED +---- + +A successful run contains one `READY:` + one `EVENT:` per step and no +`TIMEOUT:` lines. The driver greps for these and fails non-zero on any miss. + +== Layout + +---- +scripts/input-validation-app/ +├── pom.xml # parent Maven project +├── common/ # CN1 Lifecycle + gesture screens +├── ios/ # CN1 iOS module (codename1.platform=ios) +├── ios-tests/ # XCUITest target (XcodeGen-managed) +│ ├── HostStub/ # minimal UIApplication host so the test +│ │ # runner has somewhere to attach -- the +│ │ # actual tests target the CN1 app by bundle id +│ └── Sources/ # XCUITest Swift code +└── drivers/ + └── run-ios.sh # boot sim, install, drive gestures, assert log +---- + +== Running locally + +---- +# 1. Build the CN1 iOS .app bundle. +cd scripts/input-validation-app +mvn -P ios package +APP_BUNDLE=$(find ios/target -name '*.app' -type d | head -n 1) + +# 2. Drive the gesture suite on whatever iPhone simulator is available +# (override with CN1IV_DEVICE_NAME / CN1IV_DEVICE_RUNTIME). +brew install xcodegen # one-time +./drivers/run-ios.sh "$APP_BUNDLE" +---- + +== CI + +`.github/workflows/input-validation.yml` runs the iOS job on every PR. It +reuses the `_build-ios-port.yml` reusable workflow so it shares the same +port-build cache as the screenshot-based scripts-ios pipeline. + +The build script `scripts/build-ios-app.sh` accepts a +`CN1_APP_DIR=scripts/input-validation-app` override so the same script +can build either app; the default stays pointed at hellocodenameone so +the existing CI keeps passing. + +== Not yet covered (follow-up PRs) + +* JavaScript port. Synthetic DOM events reach main-thread listeners but + the CN1 JS port registers its pointer listeners through a host-bridge + proxy from inside a Web Worker; synthetic events don't traverse that + proxy the way real OS events do. End-to-end input validation here + needs port-side changes that don't belong in this PR. +* Android (UIAutomator) on Linux. +* JavaSE / desktop (`java.awt.Robot`). +* Status-bar tap-to-top, soft-keyboard show/dismiss, multi-touch. diff --git a/scripts/input-validation-app/common/codenameone_settings.properties b/scripts/input-validation-app/common/codenameone_settings.properties new file mode 100644 index 0000000000..4973a52e4a --- /dev/null +++ b/scripts/input-validation-app/common/codenameone_settings.properties @@ -0,0 +1,27 @@ +codename1.android.keystore= +codename1.android.keystoreAlias= +codename1.android.keystorePassword= +codename1.arg.android.useAndroidX=true +codename1.arg.ios.newStorageLocation=true +codename1.arg.ios.uiscene=true +codename1.arg.java.version=17 +codename1.cssTheme=false +codename1.displayName=CN1InputValidation +codename1.icon=icon.png +codename1.ios.appid= +codename1.ios.certificate= +codename1.ios.certificatePassword= +codename1.ios.debug.certificate= +codename1.ios.debug.certificatePassword= +codename1.ios.debug.provision= +codename1.ios.provision= +codename1.ios.release.certificate= +codename1.ios.release.certificatePassword= +codename1.ios.release.provision= +codename1.kotlin=false +codename1.languageLevel=5 +codename1.mainName=InputValidationApp +codename1.packageName=com.codenameone.inputvalidation +codename1.secondaryTitle=CN1 Input Validation +codename1.vendor=CodenameOne +codename1.version=1.0 diff --git a/scripts/input-validation-app/common/icon.png b/scripts/input-validation-app/common/icon.png new file mode 100644 index 0000000000..1f4fa5dd25 Binary files /dev/null and b/scripts/input-validation-app/common/icon.png differ diff --git a/scripts/input-validation-app/common/pom.xml b/scripts/input-validation-app/common/pom.xml new file mode 100644 index 0000000000..8f37ff5f64 --- /dev/null +++ b/scripts/input-validation-app/common/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + + com.codenameone.examples.inputvalidation + cn1-input-validation + 1.0-SNAPSHOT + + com.codenameone.examples.inputvalidation + cn1-input-validation-common + 1.0-SNAPSHOT + jar + + + + com.codenameone + codenameone-core + provided + + + diff --git a/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/InputValidationApp.java b/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/InputValidationApp.java new file mode 100644 index 0000000000..f52515a4f2 --- /dev/null +++ b/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/InputValidationApp.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + */ +package com.codenameone.inputvalidation; + +import com.codename1.system.Lifecycle; +import com.codenameone.inputvalidation.gestures.GestureSuite; + +/// Lifecycle entry point for the input-validation CN1 app. The whole app does +/// one thing: it runs `GestureSuite` once and exits. No theme, no resources, +/// no asset bundle -- by design, so a regression in input handling can never +/// hide behind a missing texture, a slow startup, or a stale screenshot +/// baseline. +public class InputValidationApp extends Lifecycle { + @Override + public void runApp() { + new Thread(() -> new GestureSuite().start(), "CN1IV-Suite").start(); + } +} diff --git a/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/DragStep.java b/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/DragStep.java new file mode 100644 index 0000000000..c350251f89 --- /dev/null +++ b/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/DragStep.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + */ +package com.codenameone.inputvalidation.gestures; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Font; +import com.codename1.ui.Form; +import com.codename1.ui.Graphics; +import com.codename1.ui.Label; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.layouts.BorderLayout; + +/// Validates that pointerDragged is dispatched continuously between +/// pointerPressed and pointerReleased. Covers (a) the iOS 26 hover-recognizer +/// regression in PR #5003 (which cancelled the drag mid-stream) and (b) the +/// empty-array NPE fix in 2fef7187 (`pointerDragged guard against empty pointer arrays`). +/// The detector requires at least DRAG_MIN_SAMPLES intermediate samples so a +/// recognizer that fires only the first or last point still fails. +public final class DragStep implements GestureStep { + private static final int DRAG_MIN_SAMPLES = 3; + + @Override + public String name() { + return "drag"; + } + + @Override + public void install(Container target, Callback callback) { + final DragSurface surface = new DragSurface(); + surface.setName("cn1iv-drag-target"); + Label hint = new Label("Drag horizontally across this area"); + Container col = new Container(new BorderLayout()); + col.add(BorderLayout.NORTH, hint); + col.add(BorderLayout.CENTER, surface); + target.add(BorderLayout.CENTER, col); + + final int[] samples = {0}; + final int[] firstXY = {Integer.MIN_VALUE, Integer.MIN_VALUE}; + final int[] lastXY = {Integer.MIN_VALUE, Integer.MIN_VALUE}; + final boolean[] fired = {false}; + + ActionListener dragListener = evt -> { + if (firstXY[0] == Integer.MIN_VALUE) { + firstXY[0] = evt.getX(); + firstXY[1] = evt.getY(); + } + lastXY[0] = evt.getX(); + lastXY[1] = evt.getY(); + samples[0]++; + surface.update(evt.getX(), evt.getY(), samples[0]); + }; + ActionListener releaseListener = evt -> { + if (fired[0]) { + return; + } + if (samples[0] >= DRAG_MIN_SAMPLES) { + fired[0] = true; + callback.onDetected("samples=" + samples[0] + + ",from=" + firstXY[0] + "x" + firstXY[1] + + ",to=" + lastXY[0] + "x" + lastXY[1]); + } + }; + + // Form-level listeners catch every drag sample regardless of which child + // happens to be under the finger. + Form parent = CN.getCurrentForm(); + if (parent != null) { + parent.addPointerDraggedListener(dragListener); + parent.addPointerReleasedListener(releaseListener); + } + } + + private static final class DragSurface extends Component { + private int lastX = -1; + private int lastY = -1; + private int samples; + + DragSurface() { + getAllStyles().setBgColor(0x1f2937); + getAllStyles().setBgTransparency(255); + getAllStyles().setFgColor(0xfbbf24); + getAllStyles().setMargin(16, 16, 16, 16); + getAllStyles().setFont(Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_LARGE)); + } + + void update(int absX, int absY, int sampleCount) { + this.lastX = absX - getAbsoluteX(); + this.lastY = absY - getAbsoluteY(); + this.samples = sampleCount; + repaint(); + } + + @Override + protected com.codename1.ui.geom.Dimension calcPreferredSize() { + return new com.codename1.ui.geom.Dimension( + CN.convertToPixels(60f), + CN.convertToPixels(40f)); + } + + @Override + public void paint(Graphics g) { + g.setColor(0x1f2937); + g.fillRect(getX(), getY(), getWidth(), getHeight()); + if (this.lastX >= 0) { + g.setColor(0xfbbf24); + int r = CN.convertToPixels(3f); + g.fillArc(getX() + this.lastX - r, getY() + this.lastY - r, r * 2, r * 2, 0, 360); + } + g.setColor(0xfbbf24); + g.drawString("samples=" + this.samples, getX() + 8, getY() + 8); + } + } +} diff --git a/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/GestureStep.java b/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/GestureStep.java new file mode 100644 index 0000000000..24c68c8256 --- /dev/null +++ b/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/GestureStep.java @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + */ +package com.codenameone.inputvalidation.gestures; + +import com.codename1.ui.Container; + +/// One gesture under test. {@link #install} populates the supplied target +/// container with whatever UI it needs and arms its detection logic. When the +/// gesture fires, the step calls {@link Callback#onDetected(String)} exactly +/// once with optional details (e.g. sample count for a drag). +public interface GestureStep { + String name(); + + void install(Container target, Callback callback); + + interface Callback { + void onDetected(String details); + } +} diff --git a/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/GestureSuite.java b/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/GestureSuite.java new file mode 100644 index 0000000000..1b58d95cef --- /dev/null +++ b/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/GestureSuite.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + */ +package com.codenameone.inputvalidation.gestures; + +import com.codename1.ui.CN; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.Container; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.util.UITimer; + +/// Drives a fixed sequence of input-event tests on a single Form. Each step waits +/// for its expected gesture (tap, drag, long-press) and logs structured CN1IV:* +/// markers that the platform driver script asserts against. The state machine +/// auto-advances on either success or timeout so a broken gesture fails fast +/// without blocking the rest of the suite. +public final class GestureSuite { + private static final long DEFAULT_STEP_TIMEOUT_MS = 8000L; + private static final long SUITE_EXIT_DELAY_MS = 1500L; + + private final GestureStep[] steps; + private final Form form; + private final Label statusLabel; + private final Container targetArea; + private int index = -1; + private UITimer activeTimeout; + + public GestureSuite() { + this.steps = new GestureStep[] { + new TapStep(), + new DragStep(), + new LongPressStep() + }; + this.form = new Form("Input Validation", new BorderLayout()); + this.statusLabel = new Label("Initializing"); + this.statusLabel.setName("cn1iv-status"); + Container top = new Container(BoxLayout.y()); + top.add(this.statusLabel); + this.targetArea = new Container(new BorderLayout()); + this.targetArea.setName("cn1iv-target"); + this.form.add(BorderLayout.NORTH, top); + this.form.add(BorderLayout.CENTER, this.targetArea); + } + + public void start() { + log("CN1IV:SUITE:STARTED platform=" + CN.getPlatformName() + + " w=" + Display.getInstance().getDisplayWidth() + + " h=" + Display.getInstance().getDisplayHeight()); + this.form.show(); + CN.callSerially(this::advance); + } + + private void advance() { + cancelTimeout(); + this.index++; + if (this.index >= this.steps.length) { + finishSuite(); + return; + } + final GestureStep step = this.steps[this.index]; + this.statusLabel.setText("Step " + (this.index + 1) + "/" + this.steps.length + + ": " + step.name()); + this.targetArea.removeAll(); + step.install(this.targetArea, new GestureStep.Callback() { + @Override + public void onDetected(String details) { + log("CN1IV:EVENT:" + step.name() + (details == null ? "" : ":" + details)); + CN.callSerially(GestureSuite.this::advance); + } + }); + this.targetArea.revalidate(); + log("CN1IV:READY:" + step.name()); + this.activeTimeout = UITimer.timer((int) DEFAULT_STEP_TIMEOUT_MS, false, this.form, () -> { + log("CN1IV:TIMEOUT:" + step.name()); + advance(); + }); + } + + private void cancelTimeout() { + if (this.activeTimeout != null) { + this.activeTimeout.cancel(); + this.activeTimeout = null; + } + } + + private void finishSuite() { + log("CN1IV:SUITE:FINISHED"); + UITimer.timer((int) SUITE_EXIT_DELAY_MS, false, this.form, () -> { + try { + Display.getInstance().exitApplication(); + } catch (Throwable ignored) { + } + }); + } + + private static void log(String line) { + System.out.println(line); + } +} diff --git a/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/LongPressStep.java b/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/LongPressStep.java new file mode 100644 index 0000000000..0ce29c6cfa --- /dev/null +++ b/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/LongPressStep.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + */ +package com.codenameone.inputvalidation.gestures; + +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Font; +import com.codename1.ui.layouts.BorderLayout; + +/// Validates that addLongPressListener fires for a press-and-hold gesture. +/// Long-press uses the same touch chain as tap on iOS; if a window-level +/// recognizer cancels touches mid-press (the PR #5003 path), the long-press +/// listener never fires and this step times out. +public final class LongPressStep implements GestureStep { + @Override + public String name() { + return "longpress"; + } + + @Override + public void install(Container target, Callback callback) { + Button btn = new Button("Long-press me"); + btn.setName("cn1iv-longpress-target"); + btn.getAllStyles().setFont(Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_BOLD, Font.SIZE_LARGE)); + btn.getAllStyles().setPadding(48, 48, 48, 48); + btn.getAllStyles().setMargin(48, 48, 48, 48); + btn.getAllStyles().setBgColor(0x16a34a); + btn.getAllStyles().setBgTransparency(255); + btn.getAllStyles().setFgColor(0xffffff); + final long[] pressedAt = {0L}; + btn.addPointerPressedListener(evt -> pressedAt[0] = System.currentTimeMillis()); + btn.addLongPressListener(evt -> { + long elapsed = pressedAt[0] == 0L ? -1 : (System.currentTimeMillis() - pressedAt[0]); + callback.onDetected("durMs=" + elapsed); + }); + target.add(BorderLayout.CENTER, btn); + } +} diff --git a/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/TapStep.java b/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/TapStep.java new file mode 100644 index 0000000000..cca4892ade --- /dev/null +++ b/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/TapStep.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + */ +package com.codenameone.inputvalidation.gestures; + +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Font; +import com.codename1.ui.layouts.BorderLayout; + +/// Validates that a single tap dispatches Button.actionPerformed end-to-end. +/// This is the regression PR #5003 fixed -- a window-level UITapGestureRecognizer +/// on iOS 26 was eating the touch before CN1TapGestureRecognizer saw it, so +/// every button tap silently did nothing. +public final class TapStep implements GestureStep { + @Override + public String name() { + return "tap"; + } + + @Override + public void install(Container target, Callback callback) { + Button btn = new Button("Tap me"); + btn.setName("cn1iv-tap-target"); + btn.getAllStyles().setFont(Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_BOLD, Font.SIZE_LARGE)); + btn.getAllStyles().setPadding(48, 48, 48, 48); + btn.getAllStyles().setMargin(48, 48, 48, 48); + btn.getAllStyles().setBgColor(0x2563eb); + btn.getAllStyles().setBgTransparency(255); + btn.getAllStyles().setFgColor(0xffffff); + btn.addActionListener(evt -> callback.onDetected("x=" + evt.getX() + ",y=" + evt.getY())); + target.add(BorderLayout.CENTER, btn); + } +} diff --git a/scripts/input-validation-app/drivers/run-ios.sh b/scripts/input-validation-app/drivers/run-ios.sh new file mode 100755 index 0000000000..22a0ec14af --- /dev/null +++ b/scripts/input-validation-app/drivers/run-ios.sh @@ -0,0 +1,218 @@ +#!/usr/bin/env bash +# Drive the CN1 input-validation app through tap / drag / long-press on an +# iOS simulator and assert the expected CN1IV:EVENT log lines appear. +# +# Usage: +# run-ios.sh +# +# The .app bundle is produced by `mvn -P ios package` against the parent POM +# in scripts/input-validation-app and the cn1-builder build server (or local +# iOS build chain). This script is intentionally lean -- no screenshot +# decoding, no chunked Base64, no comparison report. The only thing it cares +# about is whether the OS-level taps reached Component listeners. +set -euo pipefail + +iv_log() { echo "[run-ios] $1"; } + +if [ $# -lt 1 ]; then + iv_log "Usage: $0 " >&2 + exit 2 +fi + +APP_BUNDLE="$1" +if [ ! -d "$APP_BUNDLE" ]; then + iv_log "App bundle not found: $APP_BUNDLE" >&2 + exit 3 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +TESTS_DIR="$APP_DIR/ios-tests" +ARTIFACTS_DIR="${ARTIFACTS_DIR:-${GITHUB_WORKSPACE:-$APP_DIR}/artifacts/input-validation-ios}" +mkdir -p "$ARTIFACTS_DIR" +LOG_FILE="$ARTIFACTS_DIR/device.log" +XCODEBUILD_LOG="$ARTIFACTS_DIR/xcodebuild-test.log" + +if ! command -v xcrun >/dev/null 2>&1; then iv_log "xcrun not on PATH" >&2; exit 3; fi +if ! command -v xcodebuild >/dev/null 2>&1; then iv_log "xcodebuild not on PATH" >&2; exit 3; fi +if ! command -v xcodegen >/dev/null 2>&1; then + iv_log "xcodegen not on PATH. Install with: brew install xcodegen" >&2 + exit 3 +fi + +# Read the app's actual bundle identifier from its Info.plist so we don't +# guess wrong if the CN1 generator changes its default. +BUNDLE_ID="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP_BUNDLE/Info.plist" 2>/dev/null || true)" +if [ -z "$BUNDLE_ID" ]; then + iv_log "Could not read CFBundleIdentifier from $APP_BUNDLE/Info.plist" >&2 + exit 3 +fi +iv_log "Bundle id: $BUNDLE_ID" + +DEVICE_NAME="${CN1IV_DEVICE_NAME:-}" +DEVICE_RUNTIME="${CN1IV_DEVICE_RUNTIME:-}" + +# Build a sorted (name, runtime, udid) list of available simulators. Newer +# iOS runtimes sort last so we pick them by default. +read_devices() { + xcrun simctl list devices available -j \ + | python3 -c ' +import json, sys +data = json.load(sys.stdin) +rows = [] +for runtime, devs in data.get("devices", {}).items(): + if "iOS-" not in runtime: + continue + for d in devs: + if d.get("isAvailable"): + rows.append((runtime, d["name"], d["udid"])) +rows.sort() +for r in rows: + print("\t".join(r)) +' +} + +SIM_UDID="" +if [ -n "$DEVICE_NAME" ]; then + iv_log "Locating simulator by name: $DEVICE_NAME" + while IFS=$'\t' read -r runtime name udid; do + if [ "$name" = "$DEVICE_NAME" ] && { [ -z "$DEVICE_RUNTIME" ] || [ "$runtime" = "$DEVICE_RUNTIME" ]; }; then + SIM_UDID="$udid" + break + fi + done < <(read_devices) +fi + +if [ -z "$SIM_UDID" ]; then + # Fall back to the newest available iPhone (any model). XCode 16.4 only has + # iPhone 16; XCode 26 has iPhone 17. We'd rather adapt than fail-fast on a + # CI runner that doesn't have the exact device name we'd prefer. + iv_log "No exact device match -- picking the newest available iPhone" + while IFS=$'\t' read -r runtime name udid; do + case "$name" in + iPhone*) SIM_UDID="$udid"; DEVICE_NAME="$name"; DEVICE_RUNTIME="$runtime" ;; + esac + done < <(read_devices) +fi + +if [ -z "$SIM_UDID" ]; then + iv_log "No iOS simulator available on this host" >&2 + xcrun simctl list devices available >&2 || true + exit 3 +fi +iv_log "Selected simulator: $DEVICE_NAME ($DEVICE_RUNTIME)" +iv_log "Using simulator $SIM_UDID" + +# Boot the simulator if needed. `bootstatus -b` blocks until SpringBoard is up. +xcrun simctl boot "$SIM_UDID" >/dev/null 2>&1 || true +xcrun simctl bootstatus "$SIM_UDID" -b + +# Install the app fresh -- uninstall first so a stale bundle doesn't shadow the +# new one when the bundle identifier collides. +xcrun simctl uninstall "$SIM_UDID" "$BUNDLE_ID" >/dev/null 2>&1 || true +iv_log "Installing $APP_BUNDLE" +xcrun simctl install "$SIM_UDID" "$APP_BUNDLE" + +# Start streaming os_log lines that came from the CN1 process (printf -> NSLog +# on the iOS port routes through unified logging). Capture in the background; +# we'll wait on the file after the XCUITest run. +iv_log "Starting log stream -> $LOG_FILE" +: > "$LOG_FILE" +xcrun simctl spawn "$SIM_UDID" log stream \ + --style compact --level debug \ + --predicate '(processImagePath CONTAINS[c] "'"$BUNDLE_ID"'") OR (eventMessage CONTAINS "CN1IV:")' \ + > "$LOG_FILE" 2>&1 & +LOG_PID=$! +cleanup() { + kill "$LOG_PID" 2>/dev/null || true + wait "$LOG_PID" 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +# Generate the XCUITest Xcode project on demand. We don't check in pbxproj. +iv_log "Generating XCUITest project via xcodegen" +( cd "$TESTS_DIR" && xcodegen generate >> "$XCODEBUILD_LOG" 2>&1 ) + +# Run the XCUITest suite. The Swift code uses XCUIApplication(bundleIdentifier:) +# with a hard-coded id that mirrors common/codenameone_settings.properties -- +# xcodebuild `KEY=VALUE` args are build settings, not runtime env vars, so we +# can't pass the bundle id through here. The driver verifies the installed +# bundle id matches what the test will request before launching xcodebuild, +# rather than trying to thread it into the test process. +EXPECTED_BUNDLE_ID="com.codenameone.inputvalidation" +if [ "$BUNDLE_ID" != "$EXPECTED_BUNDLE_ID" ]; then + iv_log "WARNING: installed bundle id ($BUNDLE_ID) does not match the value" + iv_log "WARNING: hard-coded in InputValidationUITests.swift ($EXPECTED_BUNDLE_ID)." + iv_log "WARNING: Update both or XCUITest will fail to launch the app." +fi +# `-resultBundlePath` captures the .xcresult so we can extract the actual +# test failure reason post-hoc (without it, xcodebuild just prints +# `** TEST FAILED **`). +XCRESULT_BUNDLE="$ARTIFACTS_DIR/test.xcresult" +rm -rf "$XCRESULT_BUNDLE" +iv_log "Running XCUITest" +set +e +xcodebuild test \ + -project "$TESTS_DIR/CN1InputValidationUITests.xcodeproj" \ + -scheme CN1InputValidationUITests \ + -destination "platform=iOS Simulator,id=$SIM_UDID" \ + -resultBundlePath "$XCRESULT_BUNDLE" \ + CODE_SIGNING_ALLOWED=NO \ + | tee -a "$XCODEBUILD_LOG" +XCB_RC=${PIPESTATUS[0]} +set -e +iv_log "xcodebuild test exit=$XCB_RC" + +# Extract the human-readable failure summary if the result bundle is present +# so the artifact upload has something searchable beyond the opaque +# "** TEST FAILED **" line in xcodebuild-test.log. +if [ -d "$XCRESULT_BUNDLE" ]; then + iv_log "Extracting xcresult diagnostics" + xcrun xcresulttool get test-results summary --path "$XCRESULT_BUNDLE" --format json \ + > "$ARTIFACTS_DIR/xcresult-summary.json" 2>/dev/null || true + xcrun xcresulttool get log --type action --path "$XCRESULT_BUNDLE" \ + > "$ARTIFACTS_DIR/xcresult-action.log" 2>/dev/null || true +fi + +# Give the log stream a beat to flush the final CN1IV:SUITE:FINISHED line. +sleep 2 +cleanup +trap - EXIT INT TERM + +# Assertion: each expected event must appear at least once in the log. +REQUIRED_EVENTS=( + "CN1IV:READY:tap" + "CN1IV:EVENT:tap" + "CN1IV:READY:drag" + "CN1IV:EVENT:drag" + "CN1IV:READY:longpress" + "CN1IV:EVENT:longpress" + "CN1IV:SUITE:FINISHED" +) +FAILED=0 +for needle in "${REQUIRED_EVENTS[@]}"; do + if grep -q "$needle" "$LOG_FILE"; then + iv_log "OK $needle" + else + iv_log "MISS $needle" + FAILED=1 + fi +done + +if grep -qE 'CN1IV:TIMEOUT:' "$LOG_FILE"; then + iv_log "Gesture timeouts detected in device log:" + grep -E 'CN1IV:TIMEOUT:' "$LOG_FILE" | sed 's/^/ /' + FAILED=1 +fi + +if [ "$XCB_RC" -ne 0 ]; then + iv_log "xcodebuild test failed (rc=$XCB_RC) -- see $XCODEBUILD_LOG" + FAILED=1 +fi + +if [ "$FAILED" -ne 0 ]; then + iv_log "Input-validation suite FAILED -- see $LOG_FILE" + exit 1 +fi + +iv_log "Input-validation suite PASSED" diff --git a/scripts/input-validation-app/ios-tests/HostStub/HostStubApp.swift b/scripts/input-validation-app/ios-tests/HostStub/HostStubApp.swift new file mode 100644 index 0000000000..85bb62cbfd --- /dev/null +++ b/scripts/input-validation-app/ios-tests/HostStub/HostStubApp.swift @@ -0,0 +1,26 @@ +// Minimal UIKit host app for the XCUITest target. Application-only UI +// testing (no host app, USES_XCTRUNNER) errored with an opaque +// `** TEST FAILED **` under Xcode 16.4. Giving the test bundle a regular +// host -- even one that does nothing -- is the standard XCUITest setup and +// removes that failure mode. The actual UI tests attach to the +// already-installed CN1 input-validation app by bundle id via +// XCUIApplication(bundleIdentifier:), so what this stub does is irrelevant +// -- it just needs to be a launchable app the test runner can host into. + +import UIKit + +@UIApplicationMain +final class HostStubAppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + let w = UIWindow(frame: UIScreen.main.bounds) + let vc = UIViewController() + vc.view.backgroundColor = .black + w.rootViewController = vc + w.makeKeyAndVisible() + self.window = w + return true + } +} diff --git a/scripts/input-validation-app/ios-tests/Sources/InputValidationUITests.swift b/scripts/input-validation-app/ios-tests/Sources/InputValidationUITests.swift new file mode 100644 index 0000000000..725871a2a4 --- /dev/null +++ b/scripts/input-validation-app/ios-tests/Sources/InputValidationUITests.swift @@ -0,0 +1,74 @@ +// Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. +// DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. +// +// XCUITest target that drives the CN1 input-validation app through tap, +// drag, and long-press gestures on the iOS simulator. We rely on coordinate +// taps rather than accessibility queries because the CN1 iOS port does not +// surface child Components as XCUIElements -- the whole CN1 form renders into +// one GL/Metal-backed view from XCUITest's perspective. The driver shell +// script asserts the CN1IV:EVENT:* lines appear in the os_log stream; this +// file only sequences the physical inputs. + +import XCTest + +final class InputValidationUITests: XCTestCase { + // Bundle identifier of the CN1-built iOS app under test. The CN1 maven + // plugin derives the iOS CFBundleIdentifier from + // `codename1.packageName` in common/codenameone_settings.properties, so + // keeping that property and this default in sync is enough. The + // CN1IV_BUNDLE_ID env var override is for local runs against an app + // built with a different packageName. + private var bundleIdentifier: String { + return ProcessInfo.processInfo.environment["CN1IV_BUNDLE_ID"] + ?? "com.codenameone.inputvalidation" + } + + private var stepDelaySeconds: TimeInterval { + if let raw = ProcessInfo.processInfo.environment["CN1IV_STEP_DELAY_SEC"], + let v = Double(raw) { + return v + } + return 3.0 + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testGestureSuite() throws { + let app = XCUIApplication(bundleIdentifier: bundleIdentifier) + app.launch() + // Wait for the form to render before driving inputs. The CN1 EDT needs + // a moment after process launch to mount the GLViewController, run the + // first paint, and start dispatching pointer events. Without this delay + // taps fire into the splash screen. + Thread.sleep(forTimeInterval: 2.5) + + try driveTap(app: app) + Thread.sleep(forTimeInterval: stepDelaySeconds) + + try driveDrag(app: app) + Thread.sleep(forTimeInterval: stepDelaySeconds) + + try driveLongPress(app: app) + Thread.sleep(forTimeInterval: stepDelaySeconds) + } + + private func driveTap(app: XCUIApplication) throws { + let center = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + center.tap() + } + + private func driveDrag(app: XCUIApplication) throws { + // Sweep horizontally across the middle band so the CN1 drag detector + // collects enough pointerDragged samples to exceed its 3-sample floor. + let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.55)) + let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.55)) + start.press(forDuration: 0.05, thenDragTo: end) + } + + private func driveLongPress(app: XCUIApplication) throws { + let center = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + center.press(forDuration: 1.5) + } +} diff --git a/scripts/input-validation-app/ios-tests/project.yml b/scripts/input-validation-app/ios-tests/project.yml new file mode 100644 index 0000000000..e4bc533fa1 --- /dev/null +++ b/scripts/input-validation-app/ios-tests/project.yml @@ -0,0 +1,69 @@ +# XcodeGen project spec for the CN1 input-validation XCUITest target. +# +# We don't check in a .xcodeproj -- pbxproj diffs are unreadable and break +# on every Xcode update. drivers/run-ios.sh runs `xcodegen generate` against +# this file to materialise the project on demand. +# +# Setup: +# - HostStub: a minimal UIApplication target the XCUITest bundle hosts +# into. Application-only UI testing (TEST_TARGET_NAME="") errors out +# opaquely under Xcode 16.4, so we give the test bundle a regular host. +# The stub does nothing -- the UI tests attach to the already-installed +# CN1 input-validation app via XCUIApplication(bundleIdentifier:). +# - CN1InputValidationUITests: the actual UI test bundle. TEST_HOST points +# at HostStub. Tests run by xcodebuild build the stub + the test bundle, +# install them, and then the Swift code launches the CN1 app by bundle id. + +name: CN1InputValidationUITests +options: + deploymentTarget: + iOS: "16.0" + minimumXcodeGenVersion: "2.38.0" + bundleIdPrefix: com.codenameone.cn1iv + createIntermediateGroups: true + +settings: + base: + SWIFT_VERSION: "5.0" + CODE_SIGNING_ALLOWED: NO + CODE_SIGN_IDENTITY: "" + +targets: + HostStub: + type: application + platform: iOS + sources: + - HostStub + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.codenameone.cn1iv.hoststub + INFOPLIST_KEY_UILaunchStoryboardName: "" + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES + INFOPLIST_KEY_UISupportedInterfaceOrientations: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" + GENERATE_INFOPLIST_FILE: YES + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: YES + + CN1InputValidationUITests: + type: bundle.ui-testing + platform: iOS + sources: + - Sources + dependencies: + - target: HostStub + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.codenameone.cn1iv.uitests + TEST_TARGET_NAME: HostStub + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: YES + +schemes: + CN1InputValidationUITests: + build: + targets: + HostStub: [test] + CN1InputValidationUITests: [test] + test: + targets: + - CN1InputValidationUITests + gatherCoverageData: false + parallelizable: false diff --git a/scripts/input-validation-app/ios/pom.xml b/scripts/input-validation-app/ios/pom.xml new file mode 100644 index 0000000000..fa3e8b18fc --- /dev/null +++ b/scripts/input-validation-app/ios/pom.xml @@ -0,0 +1,50 @@ + + + 4.0.0 + + com.codenameone.examples.inputvalidation + cn1-input-validation + 1.0-SNAPSHOT + + com.codenameone.examples.inputvalidation + cn1-input-validation-ios + 1.0-SNAPSHOT + + + UTF-8 + 17 + 17 + ios + ios + ios-device + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + build-ios + package + + build + + + + + + + + + + ${project.groupId} + ${cn1app.name}-common + ${project.version} + + + diff --git a/scripts/input-validation-app/mvnw b/scripts/input-validation-app/mvnw new file mode 100755 index 0000000000..b2a8f18832 --- /dev/null +++ b/scripts/input-validation-app/mvnw @@ -0,0 +1,327 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +is_java17_home() { + if [ -z "$1" ] || [ ! -x "$1/bin/java" ]; then + return 1 + fi + "$1/bin/java" -version 2>&1 | head -n 1 | grep -q '"17' +} + +if ! is_java17_home "$JAVA_HOME" ; then + for candidate in /usr/lib/jvm/java-17-openjdk-amd64 /usr/lib/jvm/java-17-openjdk /usr/lib/jvm/jdk-17 /usr/lib/jvm/*17*; do + if is_java17_home "$candidate" ; then + JAVA_HOME="$candidate" + export JAVA_HOME + break + fi + done +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`which java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + jarUrl="$MVNW_REPOURL/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" + else + jarUrl="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" + fi + while IFS="=" read key value; do + case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + fi + + if command -v wget > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget "$jarUrl" -O "$wrapperJarPath" + else + wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl -o "$wrapperJarPath" "$jarUrl" -f + else + curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + fi + + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaClass=`cygpath --path --windows "$javaClass"` + fi + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/scripts/input-validation-app/pom.xml b/scripts/input-validation-app/pom.xml new file mode 100644 index 0000000000..b48c037d48 --- /dev/null +++ b/scripts/input-validation-app/pom.xml @@ -0,0 +1,90 @@ + + + 4.0.0 + com.codenameone.examples.inputvalidation + cn1-input-validation + 1.0-SNAPSHOT + pom + cn1-input-validation + + Minimal CN1 app whose only job is to validate that input events + (tap, drag, long-press) reach Component listeners end-to-end on each + port. Driven by per-platform OS-level automation (XCUITest, Playwright). + + + + common + + + + 8.0-SNAPSHOT + 8.0-SNAPSHOT + UTF-8 + 17 + 3.8.0 + 17 + 17 + cn1-input-validation + + + + + + com.codenameone + java-runtime + ${cn1.version} + + + com.codenameone + codenameone-core + ${cn1.version} + + + com.codenameone + codenameone-javase + ${cn1.version} + + + com.codenameone + codenameone-buildclient + ${cn1.version} + system + ${user.home}/.codenameone/CodeNameOneBuildClient.jar + + + + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + + + + + + + ios + + + codename1.platform + ios + + + + ios + + + +