diff --git a/.github/workflows/scripts-ios.yml b/.github/workflows/scripts-ios.yml index d4c0795cc5..600a8b630e 100644 --- a/.github/workflows/scripts-ios.yml +++ b/.github/workflows/scripts-ios.yml @@ -7,6 +7,9 @@ on: - 'scripts/setup-workspace.sh' - 'scripts/build-ios-port.sh' - 'scripts/build-ios-app.sh' + - 'scripts/run-ios-ui-tests.sh' + - 'scripts/ios/tests/**' + - 'scripts/ios/screenshots/**' - 'scripts/templates/**' - '!scripts/templates/**/*.md' - 'CodenameOne/src/**' @@ -24,6 +27,9 @@ on: - 'scripts/setup-workspace.sh' - 'scripts/build-ios-port.sh' - 'scripts/build-ios-app.sh' + - 'scripts/run-ios-ui-tests.sh' + - 'scripts/ios/tests/**' + - 'scripts/ios/screenshots/**' - 'scripts/templates/**' - '!scripts/templates/**/*.md' - 'CodenameOne/src/**' @@ -37,11 +43,19 @@ 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 + group: mac-ci-${{ github.workflow }}-${{ github.ref_name }} + cancel-in-progress: true + + env: + GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} + GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} steps: - uses: actions/checkout@v4 @@ -49,12 +63,12 @@ jobs: - name: Ensure CocoaPods tooling run: | set -euo pipefail - if ! command -v pod >/dev/null 2>&1; then - sudo gem install cocoapods --no-document - fi - if ! ruby -rrubygems -e "exit(Gem::Specification::find_all_by_name('xcodeproj').empty? ? 1 : 0)"; then - sudo gem install xcodeproj --no-document + if ! command -v ruby >/dev/null; then + echo "ruby not found"; exit 1 fi + GEM_USER_DIR="$(ruby -e 'print Gem.user_dir')" + export PATH="$GEM_USER_DIR/bin:$PATH" + gem install cocoapods xcodeproj --no-document --user-install pod --version - name: Restore cn1-binaries cache @@ -75,6 +89,32 @@ 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 UI screenshot tests + env: + ARTIFACTS_DIR: ${{ github.workspace }}/artifacts + run: | + set -euo pipefail + mkdir -p "${ARTIFACTS_DIR}" + + echo "workspace='${{ steps.build-ios-app.outputs.workspace }}'" + echo "scheme='${{ steps.build-ios-app.outputs.scheme }}'" + + ./scripts/run-ios-ui-tests.sh \ + "${{ steps.build-ios-app.outputs.workspace }}" \ + "" \ + "${{ steps.build-ios-app.outputs.scheme }}" + timeout-minutes: 30 + + - name: Upload iOS artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: ios-ui-tests + path: artifacts + if-no-files-found: warn + retention-days: 14 + diff --git a/scripts/android/tests/PostPrComment.java b/scripts/android/tests/PostPrComment.java index dab82e1d1d..943e50bebb 100644 --- a/scripts/android/tests/PostPrComment.java +++ b/scripts/android/tests/PostPrComment.java @@ -22,8 +22,10 @@ import java.util.regex.Pattern; public class PostPrComment { - private static final String MARKER = ""; - private static final String LOG_PREFIX = "[run-android-instrumentation-tests]"; + private static final String DEFAULT_MARKER = ""; + private static final String DEFAULT_LOG_PREFIX = "[run-android-instrumentation-tests]"; + private static String marker = DEFAULT_MARKER; + private static String logPrefix = DEFAULT_LOG_PREFIX; public static void main(String[] args) throws Exception { int exitCode = execute(args); @@ -35,6 +37,9 @@ private static int execute(String[] args) throws Exception { if (arguments == null) { return 2; } + marker = arguments.marker != null ? arguments.marker : DEFAULT_MARKER; + logPrefix = arguments.logPrefix != null ? arguments.logPrefix : DEFAULT_LOG_PREFIX; + Path bodyPath = arguments.body; if (!Files.isRegularFile(bodyPath)) { return 0; @@ -44,10 +49,10 @@ private static int execute(String[] args) throws Exception { if (body.isEmpty()) { return 0; } - if (!body.contains(MARKER)) { - body = body.stripTrailing() + "\n\n" + MARKER; + if (!body.contains(marker)) { + body = body.stripTrailing() + "\n\n" + marker; } - String bodyWithoutMarker = body.replace(MARKER, "").trim(); + String bodyWithoutMarker = body.replace(marker, "").trim(); if (bodyWithoutMarker.isEmpty()) { return 0; } @@ -154,7 +159,7 @@ private static CommentContext locateExistingComment(HttpClient client, Map commentMap = JsonUtil.asObject(comment); String bodyText = stringValue(commentMap.get("body"), ""); - if (bodyText.contains(MARKER)) { + if (bodyText.contains(marker)) { existingComment = commentMap; Map user = JsonUtil.asObject(commentMap.get("user")); String login = stringValue(user.get("login"), null); @@ -181,7 +186,7 @@ private static CommentContext locateExistingComment(HttpClient client, Map java.util.stream.Stream.of(e.getKey(), e.getValue())).toArray(String[]::new)) - .POST(HttpRequest.BodyPublishers.ofString(JsonUtil.stringify(Map.of("body", MARKER)))) + .POST(HttpRequest.BodyPublishers.ofString(JsonUtil.stringify(Map.of("body", marker)))) .build(); HttpResponse createResponse = client.send(createRequest, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); if (createResponse.statusCode() >= 200 && createResponse.statusCode() < 300) { @@ -406,11 +411,11 @@ private static String stringValue(Object value, String fallback) { } private static void log(String message) { - System.out.println(LOG_PREFIX + " " + message); + System.out.println(logPrefix + " " + message); } private static void err(String message) { - System.err.println(LOG_PREFIX + " " + message); + System.err.println(logPrefix + " " + message); } private record CommentContext(long commentId, boolean createdPlaceholder) { @@ -422,15 +427,21 @@ private record AttachmentReplacement(String body, List missing) { private static class Arguments { final Path body; final Path previewDir; + final String marker; + final String logPrefix; - private Arguments(Path body, Path previewDir) { + private Arguments(Path body, Path previewDir, String marker, String logPrefix) { this.body = body; this.previewDir = previewDir; + this.marker = marker; + this.logPrefix = logPrefix; } static Arguments parse(String[] args) { Path body = null; Path previewDir = null; + String marker = null; + String logPrefix = null; for (int i = 0; i < args.length; i++) { String arg = args[i]; switch (arg) { @@ -448,6 +459,20 @@ static Arguments parse(String[] args) { } previewDir = Path.of(args[i]); } + case "--marker" -> { + if (++i >= args.length) { + System.err.println("Missing value for --marker"); + return null; + } + marker = args[i]; + } + case "--log-prefix" -> { + if (++i >= args.length) { + System.err.println("Missing value for --log-prefix"); + return null; + } + logPrefix = args[i]; + } default -> { System.err.println("Unknown argument: " + arg); return null; @@ -458,7 +483,7 @@ static Arguments parse(String[] args) { System.err.println("--body is required"); return null; } - return new Arguments(body, previewDir); + return new Arguments(body, previewDir, marker, logPrefix); } } diff --git a/scripts/android/tests/RenderScreenshotReport.java b/scripts/android/tests/RenderScreenshotReport.java index 791f62451f..0276ec5748 100644 --- a/scripts/android/tests/RenderScreenshotReport.java +++ b/scripts/android/tests/RenderScreenshotReport.java @@ -8,7 +8,9 @@ import java.util.Map; public class RenderScreenshotReport { - private static final String MARKER = ""; + private static final String DEFAULT_MARKER = ""; + private static final String DEFAULT_TITLE = "Android screenshot updates"; + private static final String DEFAULT_SUCCESS_MESSAGE = "✅ Native Android screenshot tests passed."; public static void main(String[] args) throws Exception { Arguments arguments = Arguments.parse(args); @@ -24,7 +26,11 @@ public static void main(String[] args) throws Exception { String text = Files.readString(comparePath, StandardCharsets.UTF_8); Object parsed = JsonUtil.parse(text); Map data = JsonUtil.asObject(parsed); - SummaryAndComment output = buildSummaryAndComment(data); + String marker = arguments.marker != null ? arguments.marker : DEFAULT_MARKER; + String title = arguments.title != null ? arguments.title : DEFAULT_TITLE; + String successMessage = arguments.successMessage != null ? arguments.successMessage : DEFAULT_SUCCESS_MESSAGE; + + SummaryAndComment output = buildSummaryAndComment(data, title, marker, successMessage); writeLines(arguments.summaryOut, output.summaryLines); writeLines(arguments.commentOut, output.commentLines); } @@ -43,7 +49,7 @@ private static void writeLines(Path path, List lines) throws IOException Files.writeString(path, sb.toString(), StandardCharsets.UTF_8); } - private static SummaryAndComment buildSummaryAndComment(Map data) { + private static SummaryAndComment buildSummaryAndComment(Map data, String title, String marker, String successMessage) { List summaryLines = new ArrayList<>(); List commentLines = new ArrayList<>(); Object resultsObj = data.get("results"); @@ -114,8 +120,10 @@ private static SummaryAndComment buildSummaryAndComment(Map data } if (!commentEntries.isEmpty()) { - commentLines.add("### Android screenshot updates"); - commentLines.add(""); + if (title != null && !title.isEmpty()) { + commentLines.add("### " + title); + commentLines.add(""); + } for (Map entry : commentEntries) { String test = stringValue(entry.get("test"), ""); String status = stringValue(entry.get("status"), ""); @@ -127,11 +135,11 @@ private static SummaryAndComment buildSummaryAndComment(Map data if (!commentLines.isEmpty() && !commentLines.get(commentLines.size() - 1).isEmpty()) { commentLines.add(""); } - commentLines.add(MARKER); + commentLines.add(marker); } else { - commentLines.add("✅ Native Android screenshot tests passed."); + commentLines.add(successMessage != null ? successMessage : DEFAULT_SUCCESS_MESSAGE); commentLines.add(""); - commentLines.add(MARKER); + commentLines.add(marker); } return new SummaryAndComment(summaryLines, commentLines); } @@ -258,17 +266,26 @@ private static class Arguments { final Path compareJson; final Path commentOut; final Path summaryOut; + final String marker; + final String title; + final String successMessage; - private Arguments(Path compareJson, Path commentOut, Path summaryOut) { + private Arguments(Path compareJson, Path commentOut, Path summaryOut, String marker, String title, String successMessage) { this.compareJson = compareJson; this.commentOut = commentOut; this.summaryOut = summaryOut; + this.marker = marker; + this.title = title; + this.successMessage = successMessage; } static Arguments parse(String[] args) { Path compare = null; Path comment = null; Path summary = null; + String marker = null; + String title = null; + String successMessage = null; for (int i = 0; i < args.length; i++) { String arg = args[i]; switch (arg) { @@ -293,6 +310,27 @@ static Arguments parse(String[] args) { } summary = Path.of(args[i]); } + case "--marker" -> { + if (++i >= args.length) { + System.err.println("Missing value for --marker"); + return null; + } + marker = args[i]; + } + case "--title" -> { + if (++i >= args.length) { + System.err.println("Missing value for --title"); + return null; + } + title = args[i]; + } + case "--success-message" -> { + if (++i >= args.length) { + System.err.println("Missing value for --success-message"); + return null; + } + successMessage = args[i]; + } default -> { System.err.println("Unknown argument: " + arg); return null; @@ -303,7 +341,7 @@ static Arguments parse(String[] args) { System.err.println("--compare-json, --comment-out, and --summary-out are required"); return null; } - return new Arguments(compare, comment, summary); + return new Arguments(compare, comment, summary, marker, title, successMessage); } } } diff --git a/scripts/build-ios-app.sh b/scripts/build-ios-app.sh index 8c7a55b4d4..b71d06acd9 100755 --- a/scripts/build-ios-app.sh +++ b/scripts/build-ios-app.sh @@ -4,12 +4,16 @@ set -euo pipefail bia_log() { echo "[build-ios-app] $1"; } +# Pin Xcode so CN1’s Java subprocess sees xcodebuild +export DEVELOPER_DIR="/Applications/Xcode_16.4.app/Contents/Developer" +export PATH="$DEVELOPER_DIR/usr/bin:$PATH" + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$REPO_ROOT" TMPDIR="${TMPDIR:-/tmp}"; TMPDIR="${TMPDIR%/}" -DOWNLOAD_DIR="${TMPDIR%/}/codenameone-tools" +DOWNLOAD_DIR="${TMPDIR}/codenameone-tools" ENV_DIR="$DOWNLOAD_DIR/tools" EXTRA_MVN_ARGS=("$@") @@ -71,9 +75,11 @@ if [ ! -d "$SOURCE_PROJECT" ]; then fi bia_log "Using source project template at $SOURCE_PROJECT" +# Local Maven repo + command wrapper (define BEFORE using it) LOCAL_MAVEN_REPO="${LOCAL_MAVEN_REPO:-$HOME/.m2/repository}" bia_log "Using local Maven repository at $LOCAL_MAVEN_REPO" mkdir -p "$LOCAL_MAVEN_REPO" + MAVEN_CMD=( "$MAVEN_HOME/bin/mvn" -B -ntp -Dmaven.repo.local="$LOCAL_MAVEN_REPO" @@ -132,17 +138,16 @@ if [ ! -f "$TEMPLATE" ]; then bia_log "Template not found: $TEMPLATE" >&2 exit 1 fi - sed -e "s|@PACKAGE@|$PACKAGE_NAME|g" \ -e "s|@MAIN_NAME@|$MAIN_NAME|g" \ "$TEMPLATE" > "$MAIN_FILE" - bia_log "Wrote main application class to $MAIN_FILE" -# --- Build iOS project --- +# --- Build iOS project (ios-source) --- DERIVED_DATA_DIR="${TMPDIR}/codenameone-ios-derived" -rm -rf "$DERIVED_DATA_DIR" -mkdir -p "$DERIVED_DATA_DIR" +rm -rf "$DERIVED_DATA_DIR"; mkdir -p "$DERIVED_DATA_DIR" + +xcodebuild -version bia_log "Building iOS Xcode project using Codename One port" "${MAVEN_CMD[@]}" -q -f "$APP_DIR/pom.xml" package \ @@ -171,9 +176,141 @@ if [ -z "$PROJECT_DIR" ]; then find "$IOS_TARGET_DIR" -type d -print >&2 || true exit 1 fi - bia_log "Found generated iOS project at $PROJECT_DIR" +# --- Ensure a real UITest source file exists on disk --- +UITEST_TEMPLATE="$SCRIPT_DIR/ios/tests/HelloCodenameOneUITests.swift.tmpl" +UITEST_DIR="$PROJECT_DIR/HelloCodenameOneUITests" +UITEST_SWIFT="$UITEST_DIR/HelloCodenameOneUITests.swift" +if [ -f "$UITEST_TEMPLATE" ]; then + mkdir -p "$UITEST_DIR" + cp -f "$UITEST_TEMPLATE" "$UITEST_SWIFT" + bia_log "Installed UITest source: $UITEST_SWIFT" +else + bia_log "UITest template missing at $UITEST_TEMPLATE"; exit 1 +fi + +# --- Ruby/gem environment (xcodeproj) --- +if ! command -v ruby >/dev/null; then + bia_log "ruby not found on PATH"; exit 1 +fi +USER_GEM_BIN="$(ruby -e 'print Gem.user_dir')/bin" +export PATH="$USER_GEM_BIN:$PATH" +if ! ruby -rrubygems -e 'exit(Gem::Specification.find_all_by_name("xcodeproj").empty? ? 1 : 0)'; then + bia_log "Installing xcodeproj gem for current ruby" + gem install xcodeproj --no-document --user-install +fi +ruby -rrubygems -e 'abort("xcodeproj gem still missing") if Gem::Specification.find_all_by_name("xcodeproj").empty?' + +# --- Locate the .xcodeproj and pass its path to Ruby --- +XCODEPROJ="$PROJECT_DIR/HelloCodenameOne.xcodeproj" +if [ ! -d "$XCODEPROJ" ]; then + XCODEPROJ="$(/bin/ls -1d "$PROJECT_DIR"/*.xcodeproj 2>/dev/null | head -n1 || true)" +fi +if [ -z "$XCODEPROJ" ] || [ ! -d "$XCODEPROJ" ]; then + bia_log "Failed to locate .xcodeproj under $PROJECT_DIR"; exit 1 +fi +export XCODEPROJ +bia_log "Using Xcode project: $XCODEPROJ" + +# --- Ensure UITests target + CI scheme (save_as gets a PATH, not a Project) --- +ruby -rrubygems -rxcodeproj -e ' +require "fileutils" +proj_path = ENV["XCODEPROJ"] or abort("XCODEPROJ env not set") +proj = Xcodeproj::Project.open(proj_path) + +app_target = proj.targets.find { |t| t.product_type == "com.apple.product-type.application" } || proj.targets.first +ui_name = "HelloCodenameOneUITests" +ui_target = proj.targets.find { |t| t.name == ui_name } + +unless ui_target + ui_target = proj.new_target(:ui_test_bundle, ui_name, :ios, "18.0") + ui_target.product_reference.name = "#{ui_name}.xctest" + ui_target.add_dependency(app_target) if app_target +end + + +# Ensure a group and file reference exist, then add to the UITest target +proj_dir = File.dirname(proj_path) +ui_dir = File.join(proj_dir, ui_name) +ui_file = File.join(ui_dir, "#{ui_name}.swift") +ui_group = proj.main_group.find_subpath(ui_name, true) +ui_group.set_source_tree("") +file_ref = ui_group.files.find { |f| File.expand_path(f.path, proj_dir) == ui_file } +file_ref ||= ui_group.new_file(ui_file) +ui_target.add_file_references([file_ref]) unless ui_target.source_build_phase.files_references.include?(file_ref) + +# +# Required settings so Xcode creates a non-empty .xctest and a proper "-Runner.app" +# PRODUCT_NAME feeds the bundle name; TEST_TARGET_NAME feeds the runner name. +# We also keep signing off and auto-Info.plist for simulator CI. +# +%w[Debug Release].each do |cfg| + xc = ui_target.build_configuration_list[cfg] + next unless xc + bs = xc.build_settings + bs["SWIFT_VERSION"] = "5.0" + bs["GENERATE_INFOPLIST_FILE"] = "YES" + bs["CODE_SIGNING_ALLOWED"] = "NO" + bs["CODE_SIGNING_REQUIRED"] = "NO" + bs["PRODUCT_BUNDLE_IDENTIFIER"] ||= "com.codenameone.examples.uitests" + bs["PRODUCT_NAME"] ||= ui_name + bs["TEST_TARGET_NAME"] ||= app_target&.name || "HelloCodenameOne" + # Optional but harmless on simulators; avoids other edge cases: + bs["ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES"] = "YES" + bs["TARGETED_DEVICE_FAMILY"] ||= "1,2" +end + +proj.save + +ws_dir = File.join(File.dirname(proj_path), "HelloCodenameOne.xcworkspace") +schemes_root = if File.directory?(ws_dir) + File.join(ws_dir, "xcshareddata", "xcschemes") +else + File.join(File.dirname(proj_path), "xcshareddata", "xcschemes") +end +FileUtils.mkdir_p(schemes_root) + +scheme = Xcodeproj::XCScheme.new +scheme.build_action.entries = [] +scheme.add_build_target(app_target) if app_target +scheme.test_action = Xcodeproj::XCScheme::TestAction.new +scheme.test_action.xml_element.elements.delete_all("EnvironmentVariables") +envs = Xcodeproj::XCScheme::EnvironmentVariables.new +envs.assign_variable(key: "CN1SS_OUTPUT_DIR", value: "__CN1SS_OUTPUT_DIR__", enabled: true) +envs.assign_variable(key: "CN1SS_PREVIEW_DIR", value: "__CN1SS_PREVIEW_DIR__", enabled: true) +scheme.test_action.environment_variables = envs +scheme.test_action.xml_element.elements.delete_all("Testables") +scheme.add_test_target(ui_target) +scheme.launch_action.build_configuration = "Debug" +scheme.test_action.build_configuration = "Debug" + +save_root = File.directory?(ws_dir) ? ws_dir : File.dirname(proj_path) +scheme.save_as(save_root, "HelloCodenameOne-CI", true) +' + +# Show which scheme file we ended up with +WS_XCSCHEME="$PROJECT_DIR/HelloCodenameOne.xcworkspace/xcshareddata/xcschemes/HelloCodenameOne-CI.xcscheme" +PRJ_XCSCHEME="$PROJECT_DIR/xcshareddata/xcschemes/HelloCodenameOne-CI.xcscheme" +if [ -f "$WS_XCSCHEME" ]; then + bia_log "CI scheme (workspace): $WS_XCSCHEME"; grep -n "BlueprintName" "$WS_XCSCHEME" || true +elif [ -f "$PRJ_XCSCHEME" ]; then + bia_log "CI scheme (project): $PRJ_XCSCHEME"; grep -n "BlueprintName" "$PRJ_XCSCHEME" || true +else + bia_log "Warning: CI scheme not found after generation" +fi + +# Patch PBX TEST_HOST (remove any "-src" suffix that can break unit-tests) +PBXPROJ="$PROJECT_DIR/HelloCodenameOne.xcodeproj/project.pbxproj" +if [ -f "$PBXPROJ" ]; then + bia_log "Patching TEST_HOST in $PBXPROJ (remove '-src' suffix)" + cp "$PBXPROJ" "$PBXPROJ.bak" + perl -0777 -pe 's/(TEST_HOST = .*?\.app\/)([^"\/]+)-src(";\n)/$1$2$3/s' \ + "$PBXPROJ.bak" > "$PBXPROJ" + grep -n "TEST_HOST =" "$PBXPROJ" || true +fi + +# CocoaPods (project contains a Podfile but usually empty — fine) if [ -f "$PROJECT_DIR/Podfile" ]; then bia_log "Installing CocoaPods dependencies" ( @@ -187,6 +324,11 @@ else bia_log "Podfile not found in generated project; skipping pod install" fi +# Remove any user schemes that could shadow the shared CI scheme +rm -rf "$PROJECT_DIR"/xcuserdata 2>/dev/null || true +find "$PROJECT_DIR" -maxdepth 1 -name "*.xcworkspace" -type d -exec rm -rf {}/xcuserdata \; 2>/dev/null || true + +# Locate workspace for the next step WORKSPACE="" for candidate in "$PROJECT_DIR"/*.xcworkspace; do if [ -d "$candidate" ]; then @@ -199,35 +341,23 @@ if [ -z "$WORKSPACE" ]; then ls "$PROJECT_DIR" >&2 || true exit 1 fi +bia_log "Found xcworkspace: $WORKSPACE" -bia_log "Building workspace $WORKSPACE with scheme $MAIN_NAME" -( - cd "$PROJECT_DIR" - xcodebuild \ - -workspace "$WORKSPACE" \ - -scheme "$MAIN_NAME" \ - -sdk iphonesimulator \ - -configuration Debug \ - -destination 'generic/platform=iOS Simulator' \ - -derivedDataPath "$DERIVED_DATA_DIR" \ - CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \ - build -) - -PRODUCT_APP="" -while IFS= read -r app_path; do - PRODUCT_APP="$app_path" - break -done < <(find "$DERIVED_DATA_DIR" -type d -name '*.app' -print 2>/dev/null) -if [ -n "$PRODUCT_APP" ]; then - bia_log "Successfully built iOS simulator app at $PRODUCT_APP" -fi +SCHEME="${MAIN_NAME}-CI" +# Make these visible to the next GH Actions step if [ -n "${GITHUB_OUTPUT:-}" ]; then { echo "workspace=$WORKSPACE" - [ -n "$PRODUCT_APP" ] && echo "app_bundle=$PRODUCT_APP" + echo "scheme=$SCHEME" } >> "$GITHUB_OUTPUT" fi -bia_log "iOS workspace build completed successfully" +bia_log "Emitted outputs -> workspace=$WORKSPACE, scheme=$SCHEME" + +# (Optional) dump xcodebuild -list for debugging +ARTIFACTS_DIR="${ARTIFACTS_DIR:-$REPO_ROOT/artifacts}" +mkdir -p "$ARTIFACTS_DIR" +xcodebuild -workspace "$WORKSPACE" -list > "$ARTIFACTS_DIR/xcodebuild-list.txt" 2>&1 || true + +exit 0 \ No newline at end of file diff --git a/scripts/ios/create-shared-scheme.py b/scripts/ios/create-shared-scheme.py new file mode 100755 index 0000000000..12b9f63efa --- /dev/null +++ b/scripts/ios/create-shared-scheme.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Ensure an Xcode scheme exists that wires the UI test bundle for Codename One CI. + +The Codename One iOS template historically only shipped a user-specific scheme, +which means fresh CI machines don't see any test actions when invoking +``xcodebuild test``. This helper inspects the generated project, discovers the +primary application target and any associated unit/UI test bundles, and emits a +shared scheme that drives them. + +Usage: + create-shared-scheme.py [scheme_name] + +The script writes the shared scheme into both the .xcodeproj and any sibling +.xcworkspace directories so either entry point exposes the test action. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, List, Optional + + +@dataclass +class Target: + identifier: str + name: str + product_type: str + product_name: Optional[str] + + @property + def buildable_name(self) -> str: + if self.product_name: + return self.product_name + if self.product_type.endswith(".application"): + return f"{self.name}.app" + if self.product_type.endswith(".bundle.ui-testing") or self.product_type.endswith(".bundle.unit-test"): + return f"{self.name}.xctest" + return self.name + + +TARGET_BLOCK_RE = re.compile( + r""" + ^\s*(?P[0-9A-F]{24})\s+/\*\s+(?P[^*]+)\s+\*/\s+=\s+\{ + (?P.*?) + ^\s*\}; + """, + re.MULTILINE | re.DOTALL | re.VERBOSE, +) + + +def parse_targets(project_file: Path) -> List[Target]: + content = project_file.read_text(encoding="utf-8") + targets: List[Target] = [] + for match in TARGET_BLOCK_RE.finditer(content): + body = match.group("body") + if "isa = PBXNativeTarget;" not in body: + continue + name = _search_value(body, r"name = (?P[^;]+);") + product_type = _search_value(body, r"productType = \"(?P[^\"]+)\";") + product_name = _search_value(body, r"productReference = [0-9A-F]{24} /\* (?P[^*]+) \*/;") + if not name or not product_type: + continue + targets.append( + Target( + identifier=match.group("identifier"), + name=name.strip().strip('"'), + product_type=product_type.strip(), + product_name=product_name.strip() if product_name else None, + ) + ) + return targets + + +def _search_value(text: str, pattern: str) -> Optional[str]: + m = re.search(pattern, text) + return m.group("value") if m else None + + +def choose_targets(targets: Iterable[Target]) -> tuple[Optional[Target], Optional[Target], Optional[Target]]: + app = None + ui = None + unit = None + for target in targets: + if target.product_type.endswith(".application") and app is None: + app = target + elif target.product_type.endswith(".bundle.ui-testing") and ui is None: + ui = target + elif target.product_type.endswith(".bundle.unit-test") and unit is None: + unit = target + return app, unit, ui + + +def render_testable(target: Target, container: str) -> str: + return f""" + + + """ + + +def render_scheme( + scheme_name: str, + project_container: str, + app: Target, + testables: List[str], +) -> str: + testables_block = "\n".join(testables) if testables else "" + return f""" + + + + + + + + + + + +{testables_block} + + + + + + + + + + + + + + + + + + + + + + + + + +""" + + +def ensure_scheme(destination: Path, scheme_name: str, xml: str) -> None: + destination.mkdir(parents=True, exist_ok=True) + scheme_path = destination / f"{scheme_name}.xcscheme" + scheme_path.write_text(xml, encoding="utf-8") + + +def main(argv: List[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("project_dir", type=Path) + parser.add_argument("scheme_name", nargs="?", help="Override the generated scheme name") + args = parser.parse_args(argv[1:]) + + project_dir: Path = args.project_dir.resolve() + if not project_dir.is_dir(): + print(f"error: project directory not found: {project_dir}", file=sys.stderr) + return 1 + + try: + xcodeproj = next(project_dir.glob("*.xcodeproj")) + except StopIteration: + print(f"error: unable to locate an .xcodeproj under {project_dir}", file=sys.stderr) + return 1 + + project_container = xcodeproj.name + project_file = xcodeproj / "project.pbxproj" + if not project_file.is_file(): + print(f"error: missing project file: {project_file}", file=sys.stderr) + return 1 + + targets = parse_targets(project_file) + if not targets: + print(f"error: no build targets discovered in {project_file}", file=sys.stderr) + return 1 + + app, unit, ui = choose_targets(targets) + if not app: + print("error: unable to find application target", file=sys.stderr) + return 1 + + scheme_name = args.scheme_name or app.name + + # Prefer UI tests only. Include unit tests only if there is no UI test target. + testables: List[str] = [] + if ui is not None: + testables.append(render_testable(ui, project_container)) + elif unit is not None: + testables.append(render_testable(unit, project_container)) + + if not testables: + print("warning: no unit or UI test targets discovered; emitting app-only scheme", file=sys.stderr) + + xml = render_scheme(scheme_name, project_container, app, testables) + + destinations = [xcodeproj / "xcshareddata" / "xcschemes"] + destinations.extend(ws / "xcshareddata" / "xcschemes" for ws in project_dir.glob("*.xcworkspace")) + + for dest in destinations: + ensure_scheme(dest, scheme_name, xml) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/ios/screenshots/README.md b/scripts/ios/screenshots/README.md new file mode 100644 index 0000000000..698c4d16c5 --- /dev/null +++ b/scripts/ios/screenshots/README.md @@ -0,0 +1,3 @@ +# iOS screenshot baselines + +This directory stores the reference images that the CI iOS UI tests compare against. Add PNG files named after the test identifiers (e.g. `MainActivity.png`) once the first successful baseline has been captured. diff --git a/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl b/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl new file mode 100644 index 0000000000..e99b2fb0ae --- /dev/null +++ b/scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl @@ -0,0 +1,74 @@ +import XCTest + +final class HelloCodenameOneUITests: XCTestCase { + private var app: XCUIApplication! + private var outputDirectory: URL! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + + // Locale for determinism + app.launchArguments += ["-AppleLocale", "en_US", "-AppleLanguages", "(en)"] + // Tip: force light mode or content size if you need pixel-stable shots + // app.launchArguments += ["-uiuserInterfaceStyle", "Light"] + + // IMPORTANT: write to the app's sandbox, not a host path + let tmp = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + if let tag = ProcessInfo.processInfo.environment["CN1SS_OUTPUT_DIR"], !tag.isEmpty { + outputDirectory = tmp.appendingPathComponent(tag, isDirectory: true) + } else { + outputDirectory = tmp.appendingPathComponent("cn1screens", isDirectory: true) + } + try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true) + + app.launch() + waitForStableFrame() + } + + override func tearDownWithError() throws { + app?.terminate() + app = nil + } + + private func captureScreenshot(named name: String) throws { + let shot = XCUIScreen.main.screenshot() + + // Save into sandbox tmp (optional – mainly for local debugging) + let pngURL = outputDirectory.appendingPathComponent("\(name).png") + do { try shot.pngRepresentation.write(to: pngURL) } catch { /* ignore */ } + + // ALWAYS attach so we can export from the .xcresult + let att = XCTAttachment(screenshot: shot) + att.name = name + att.lifetime = .keepAlways + add(att) + } + + /// Wait for foreground + a short settle time + private func waitForStableFrame(timeout: TimeInterval = 30, settle: TimeInterval = 1.2) { + _ = app.wait(for: .runningForeground, timeout: timeout) + RunLoop.current.run(until: Date(timeIntervalSinceNow: settle)) + } + + /// Tap using normalized coordinates (0...1) + private func tapNormalized(_ dx: CGFloat, _ dy: CGFloat) { + let origin = app.coordinate(withNormalizedOffset: .zero) + let target = origin.withOffset(.init(dx: app.frame.size.width * dx, + dy: app.frame.size.height * dy)) + target.tap() + } + + func testMainScreenScreenshot() throws { + waitForStableFrame() + try captureScreenshot(named: "MainActivity") + } + + func testBrowserComponentScreenshot() throws { + waitForStableFrame() + tapNormalized(0.5, 0.70) + // tiny retry to allow BrowserComponent to render + RunLoop.current.run(until: Date(timeIntervalSinceNow: 2.0)) + try captureScreenshot(named: "BrowserComponent") + } +} \ No newline at end of file diff --git a/scripts/run-ios-ui-tests.sh b/scripts/run-ios-ui-tests.sh new file mode 100755 index 0000000000..c5c99f7935 --- /dev/null +++ b/scripts/run-ios-ui-tests.sh @@ -0,0 +1,322 @@ +#!/usr/bin/env bash +# Run Codename One iOS UI tests on the simulator and compare screenshots +set -euo pipefail + +ri_log() { echo "[run-ios-ui-tests] $1"; } + +if [ $# -lt 1 ]; then + ri_log "Usage: $0 [app_bundle] [scheme]" >&2 + exit 2 +fi + +WORKSPACE_PATH="$1" +APP_BUNDLE_PATH="${2:-}" +REQUESTED_SCHEME="${3:-}" + +# If $2 isn’t a dir and $3 is empty, treat $2 as the scheme. +if [ -n "$APP_BUNDLE_PATH" ] && [ ! -d "$APP_BUNDLE_PATH" ] && [ -z "$REQUESTED_SCHEME" ]; then + REQUESTED_SCHEME="$APP_BUNDLE_PATH" + APP_BUNDLE_PATH="" +fi + +if [ ! -d "$WORKSPACE_PATH" ]; then + ri_log "Workspace not found at $WORKSPACE_PATH" >&2 + exit 3 +fi + +if [ -n "$APP_BUNDLE_PATH" ]; then + ri_log "Using simulator app bundle at $APP_BUNDLE_PATH" +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$REPO_ROOT" + +TMPDIR="${TMPDIR:-/tmp}"; TMPDIR="${TMPDIR%/}" +DOWNLOAD_DIR="${TMPDIR}/codenameone-tools" +ENV_DIR="$DOWNLOAD_DIR/tools" +ENV_FILE="$ENV_DIR/env.sh" + +ri_log "Loading workspace environment from $ENV_FILE" +[ -f "$ENV_FILE" ] || { ri_log "Missing env file: $ENV_FILE"; exit 3; } +# shellcheck disable=SC1090 +source "$ENV_FILE" + +# Use the same Xcode as the build step +export DEVELOPER_DIR="/Applications/Xcode_16.4.app/Contents/Developer" +export PATH="$DEVELOPER_DIR/usr/bin:$PATH" + +if [ -z "${JAVA17_HOME:-}" ] || [ ! -x "$JAVA17_HOME/bin/java" ]; then + ri_log "JAVA17_HOME not set correctly" >&2 + exit 3 +fi +if ! command -v xcodebuild >/dev/null 2>&1; then + ri_log "xcodebuild not found" >&2 + exit 3 +fi +if ! command -v xcrun >/dev/null 2>&1; then + ri_log "xcrun not found" >&2 + exit 3 +fi + +JAVA17_BIN="$JAVA17_HOME/bin/java" + +ARTIFACTS_DIR="${ARTIFACTS_DIR:-${GITHUB_WORKSPACE:-$REPO_ROOT}/artifacts}" +mkdir -p "$ARTIFACTS_DIR" +TEST_LOG="$ARTIFACTS_DIR/xcodebuild-test.log" + +if [ -z "$REQUESTED_SCHEME" ]; then + if [[ "$WORKSPACE_PATH" == *.xcworkspace ]]; then + REQUESTED_SCHEME="$(basename "$WORKSPACE_PATH" .xcworkspace)" + else + REQUESTED_SCHEME="$(basename "$WORKSPACE_PATH")" + fi +fi +SCHEME="$REQUESTED_SCHEME" +ri_log "Using scheme $SCHEME" + +SCREENSHOT_REF_DIR="$SCRIPT_DIR/ios/screenshots" +SCREENSHOT_TMP_DIR="$(mktemp -d "${TMPDIR}/cn1-ios-tests-XXXXXX" 2>/dev/null || echo "${TMPDIR}/cn1-ios-tests")" +SCREENSHOT_RAW_DIR="$SCREENSHOT_TMP_DIR/raw" +SCREENSHOT_PREVIEW_DIR="$SCREENSHOT_TMP_DIR/previews" +RESULT_BUNDLE="$SCREENSHOT_TMP_DIR/test-results.xcresult" +mkdir -p "$SCREENSHOT_RAW_DIR" "$SCREENSHOT_PREVIEW_DIR" + +export CN1SS_OUTPUT_DIR="$SCREENSHOT_RAW_DIR" +export CN1SS_PREVIEW_DIR="$SCREENSHOT_PREVIEW_DIR" + +# Patch scheme env vars to point to our runtime dirs +SCHEME_FILE="$WORKSPACE_PATH/xcshareddata/xcschemes/$SCHEME.xcscheme" +if [ -f "$SCHEME_FILE" ]; then + if sed --version >/dev/null 2>&1; then + # GNU sed + sed -i -e "s|__CN1SS_OUTPUT_DIR__|$SCREENSHOT_RAW_DIR|g" \ + -e "s|__CN1SS_PREVIEW_DIR__|$SCREENSHOT_PREVIEW_DIR|g" "$SCHEME_FILE" + else + # BSD sed (macOS) + sed -i '' -e "s|__CN1SS_OUTPUT_DIR__|$SCREENSHOT_RAW_DIR|g" \ + -e "s|__CN1SS_PREVIEW_DIR__|$SCREENSHOT_PREVIEW_DIR|g" "$SCHEME_FILE" + fi + ri_log "Injected CN1SS_* envs into scheme: $SCHEME_FILE" +else + ri_log "Scheme file not found for env injection: $SCHEME_FILE" +fi + +auto_select_destination() { + if ! command -v python3 >/dev/null 2>&1; then + return + fi + + local show_dest selected + if show_dest="$(xcodebuild -workspace "$WORKSPACE_PATH" -scheme "$SCHEME" -showdestinations 2>/dev/null)"; then + selected="$( + printf '%s\n' "$show_dest" | python3 - <<'PY' +import re, sys +def parse_version_tuple(v): return tuple(int(p) if p.isdigit() else 0 for p in v.split('.') if p) +for block in re.findall(r"\{([^}]+)\}", sys.stdin.read()): + f = dict(s.split(':',1) for s in block.split(',') if ':' in s) + if f.get('platform')!='iOS Simulator': continue + name=f.get('name',''); os=f.get('OS') or f.get('os') or '' + pri=2 if 'iPhone' in name else (1 if 'iPad' in name else 0) + print(f"__CAND__|{pri}|{'.'.join(map(str,parse_version_tuple(os.replace('latest',''))))}|{name}|{os}|{f.get('id','')}") +cands=[l.split('|',5) for l in sys.stdin if False] +PY + )" + fi + + if [ -z "${selected:-}" ]; then + if command -v xcrun >/dev/null 2>&1; then + selected="$( + xcrun simctl list devices --json 2>/dev/null | python3 - <<'PY' +import json, sys +def parse_version_tuple(v): return tuple(int(p) if p.isdigit() else 0 for p in v.split('.') if p) +try: data=json.load(sys.stdin) +except: sys.exit(0) +c=[] +for runtime, entries in (data.get('devices') or {}).items(): + if 'iOS' not in runtime: continue + ver=runtime.split('iOS-')[-1].replace('-','.') + vt=parse_version_tuple(ver) + for e in entries or []: + if not e.get('isAvailable'): continue + name=e.get('name') or ''; ident=e.get('udid') or '' + pri=2 if 'iPhone' in name else (1 if 'iPad' in name else 0) + c.append((pri, vt, name, ident)) +if c: + pri, vt, name, ident = sorted(c, reverse=True)[0] + print(f"platform=iOS Simulator,id={ident}") +PY + )" + fi + fi + + if [ -n "${selected:-}" ]; then + echo "$selected" + fi +} + +SIM_DESTINATION="${IOS_SIM_DESTINATION:-}" +if [ -z "$SIM_DESTINATION" ]; then + SELECTED_DESTINATION="$(auto_select_destination || true)" + if [ -n "${SELECTED_DESTINATION:-}" ]; then + SIM_DESTINATION="$SELECTED_DESTINATION" + ri_log "Auto-selected simulator destination '$SIM_DESTINATION'" + else + ri_log "Simulator auto-selection did not return a destination" + fi +fi +if [ -z "$SIM_DESTINATION" ]; then + SIM_DESTINATION="platform=iOS Simulator,name=iPhone 16" + ri_log "Falling back to default simulator destination '$SIM_DESTINATION'" +fi + +ri_log "Running UI tests on destination '$SIM_DESTINATION'" + +DERIVED_DATA_DIR="$SCREENSHOT_TMP_DIR/derived" +rm -rf "$DERIVED_DATA_DIR" + +# Run only the UI test bundle +UI_TEST_TARGET="${UI_TEST_TARGET:-HelloCodenameOneUITests}" +XCODE_TEST_FILTERS=( + -only-testing:"${UI_TEST_TARGET}" + -skip-testing:HelloCodenameOneTests +) + +set -o pipefail +if ! xcodebuild \ + -workspace "$WORKSPACE_PATH" \ + -scheme "$SCHEME" \ + -sdk iphonesimulator \ + -configuration Debug \ + -destination "$SIM_DESTINATION" \ + -derivedDataPath "$DERIVED_DATA_DIR" \ + -resultBundlePath "$RESULT_BUNDLE" \ + "${XCODE_TEST_FILTERS[@]}" \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \ + GENERATE_INFOPLIST_FILE=YES \ + test | tee "$TEST_LOG"; then + ri_log "STAGE:XCODE_TEST_FAILED -> See $TEST_LOG" + exit 10 +fi +set +o pipefail + +# --- If no PNG files in $SCREENSHOT_RAW_DIR, export from the .xcresult attachments --- +if [ ! "$(find "$SCREENSHOT_RAW_DIR" -type f -name '*.png' -print -quit)" ] && [ -d "$RESULT_BUNDLE" ]; then + ri_log "No raw PNGs yet; exporting PNG attachments from $RESULT_BUNDLE" + + ATT_LIST="$SCREENSHOT_TMP_DIR/xcresult-attachments.txt" + + # Walk the result bundle and collect (attachment_id, file_name) + python3 - "$RESULT_BUNDLE" "$ATT_LIST" <<'PY' +import json, subprocess, sys, os, shlex + +bundle = sys.argv[1] +out_list = sys.argv[2] + +def norm_id(x): + # Accept strings or dicts like {"_value": "..."} or {"id": "..."} + if isinstance(x, dict): + v = x.get('_value') or x.get('id') or x.get('identifier') + if isinstance(v, dict): + v = v.get('_value') + return v + return x + +def xcget(obj_id=None): + # Xcode 16.4 wants --legacy even for "get object" + base = ["xcrun","xcresulttool","get","object","--legacy","--path",bundle,"--format","json"] + if obj_id: + base += ["--id", norm_id(obj_id)] + try: + out = subprocess.check_output(base) + return json.loads(out) + except subprocess.CalledProcessError as e: + # Last-ditch: try non-legacy (older toolchains) + alt = ["xcrun","xcresulttool","get","object","--path",bundle,"--format","json"] + if obj_id: + alt += ["--id", norm_id(obj_id)] + out = subprocess.check_output(alt) + return json.loads(out) + +def arr(node, key): + v = node.get(key) + if isinstance(v, dict) and "_values" in v: + return v["_values"] or [] + if isinstance(v, list): + return v + return [] + +def sval(node, key): + v = node.get(key) + if isinstance(v, dict) and "_value" in v: + return v["_value"] + if isinstance(v, str): + return v + return None + +def walk_tests(obj, hits): + for test in arr(obj, "tests"): + # Recurse into subtests + for st in arr(test, "subtests"): + walk_tests(st, hits) + # Activities → attachments + for act in arr(test, "activitySummaries"): + for att in arr(act, "attachments"): + if (att.get("_type",{}).get("_name") == "ActionTestAttachment"): + uti = sval(att, "uniformTypeIdentifier") or "" + name = sval(att, "filename") or "" + pref = (att.get("payloadRef") or {}).get("id") + if pref and (("png" in (uti or "").lower()) or ("jpeg" in (uti or "").lower()) or (name.lower().endswith((".png",".jpg",".jpeg")))): + if not name: name = "attachment.png" + hits.append((norm_id(pref), name)) + +root = xcget() +hits = [] + +# Dive: actions[] → actionResult.testsRef -> summaries -> testableSummaries -> tests... +for action in arr(root, "actions"): + result = action.get("actionResult") or {} + tests_id = norm_id(result.get("testsRef", {}).get("id")) + if not tests_id: + continue + plan_summaries = xcget(tests_id) # "ActionTestPlanRunSummaries" + for summ in arr(plan_summaries, "summaries"): + for testable in arr(summ, "testableSummaries"): + walk_tests(testable, hits) + +# De-dupe on id +seen=set() +with open(out_list, "w") as f: + for att_id, fname in hits: + if not att_id or att_id in seen: + continue + seen.add(att_id) + base, ext = os.path.splitext(fname) + if not ext: ext = ".png" + if not base: base = "attachment" + f.write(f"{att_id} {base}{ext}\n") +PY + + # Export each attachment id as a file + while IFS=$' \t' read -r ATT_ID FNAME; do + [ -n "$ATT_ID" ] || continue + OUT="$SCREENSHOT_RAW_DIR/$FNAME" + if [ -f "$OUT" ]; then + base="${FNAME%.*}"; ext="${FNAME##*.}" + n=2; while [ -f "$SCREENSHOT_RAW_DIR/${base}-${n}.${ext}" ]; do n=$((n+1)); done + OUT="$SCREENSHOT_RAW_DIR/${base}-${n}.${ext}" + fi + # Prefer --legacy on Xcode 16.4; fallback to non-legacy + if ! xcrun xcresulttool export --legacy --path "$RESULT_BUNDLE" --id "$ATT_ID" --output-path "$OUT" 2>/dev/null; then + xcrun xcresulttool export --path "$RESULT_BUNDLE" --id "$ATT_ID" --output-path "$OUT" || true + fi + [ -f "$OUT" ] && ri_log "Exported attachment -> $OUT" + done < "$ATT_LIST" +fi + +PNG_FILES=() +while IFS= read -r png; do + [ -n "$png" ] || continue + PNG_FILES+=("$png") +done < <(find "$SCREENSHOT_RAW_DIR" -type f -name '*.png' -print | sort) + diff --git a/scripts/templates/HelloCodenameOne.java.tmpl b/scripts/templates/HelloCodenameOne.java.tmpl index c1f42a6ee2..32656416ee 100644 --- a/scripts/templates/HelloCodenameOne.java.tmpl +++ b/scripts/templates/HelloCodenameOne.java.tmpl @@ -1,12 +1,18 @@ package @PACKAGE@; +import com.codename1.ui.Button; +import com.codename1.ui.BrowserComponent; +import com.codename1.ui.Container; import com.codename1.ui.Display; +import com.codename1.ui.FontImage; import com.codename1.ui.Form; import com.codename1.ui.Label; import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; public class @MAIN_NAME@ { private Form current; + private Form mainForm; public void init(Object context) { // No special initialization required for this sample @@ -17,9 +23,7 @@ public class @MAIN_NAME@ { current.show(); return; } - Form helloForm = new Form("Hello Codename One", new BorderLayout()); - helloForm.add(BorderLayout.CENTER, new Label("Hello Codename One")); - helloForm.show(); + showMainForm(); } public void stop() { @@ -29,4 +33,59 @@ public class @MAIN_NAME@ { public void destroy() { // Nothing to clean up for this sample } -} \ No newline at end of file + + private void showMainForm() { + if (mainForm == null) { + mainForm = new Form("Main Screen", new BorderLayout()); + + Container content = new Container(BoxLayout.y()); + content.getAllStyles().setBgColor(0x1f2937); + content.getAllStyles().setBgTransparency(255); + content.getAllStyles().setPadding(6, 6, 6, 6); + content.getAllStyles().setFgColor(0xf9fafb); + + Label heading = new Label("Hello Codename One"); + heading.getAllStyles().setFgColor(0x38bdf8); + heading.getAllStyles().setMargin(0, 4, 0, 0); + + Label body = new Label("Instrumentation main activity preview"); + body.getAllStyles().setFgColor(0xf9fafb); + + Button openBrowser = new Button("Open Browser Screen"); + openBrowser.addActionListener(evt -> showBrowserForm()); + + content.add(heading); + content.add(body); + content.add(openBrowser); + + mainForm.add(BorderLayout.CENTER, content); + } + current = mainForm; + mainForm.show(); + } + + private void showBrowserForm() { + Form browserForm = new Form("Browser Screen", new BorderLayout()); + + BrowserComponent browser = new BrowserComponent(); + browser.setPage(buildBrowserHtml(), null); + browserForm.add(BorderLayout.CENTER, browser); + browserForm.getToolbar().addMaterialCommandToLeftBar( + "Back", + FontImage.MATERIAL_ARROW_BACK, + evt -> showMainForm() + ); + + current = browserForm; + browserForm.show(); + } + + private String buildBrowserHtml() { + return "" + + "" + + "

Codename One

" + + "

BrowserComponent instrumentation test content.

"; + } +}