diff --git a/.github/workflows/archetype-smoke.yml b/.github/workflows/archetype-smoke.yml new file mode 100644 index 0000000000..f5ad615384 --- /dev/null +++ b/.github/workflows/archetype-smoke.yml @@ -0,0 +1,64 @@ +name: Archetype simulator smoke + +# Smoke-tests that an archetype-generated project can run `cn1:css` (the +# CN1CSSCLI subprocess that boots JavaSEPort) with `cn1.autoDefaultResourceBundle` +# forced on. This reproduces the user-side preference state that masked +# issue #4850 in CI for six months: the bug was deterministic on the user +# code path but invisible to CI runners with the default-false preference. +# +# Path-gated to the modules whose changes can break this code path so we +# don't run the heavy archetype-generate-and-build for unrelated edits. +on: + pull_request: + branches: [ master ] + paths: + - 'Ports/JavaSE/**' + - 'CodenameOneDesigner/**' + - 'CodenameOne/src/com/codename1/ui/plaf/UIManager.java' + - 'maven/codenameone-maven-plugin/**' + - 'maven/integration-tests/cn1app-archetype-test.sh' + - 'maven/integration-tests/inc/**' + - '.github/workflows/archetype-smoke.yml' + push: + branches: [ master ] + paths: + - 'Ports/JavaSE/**' + - 'CodenameOneDesigner/**' + - 'CodenameOne/src/com/codename1/ui/plaf/UIManager.java' + - 'maven/codenameone-maven-plugin/**' + - 'maven/integration-tests/cn1app-archetype-test.sh' + - 'maven/integration-tests/inc/**' + - '.github/workflows/archetype-smoke.yml' + +jobs: + archetype-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 8 + uses: actions/setup-java@v4 + with: + java-version: '8' + distribution: 'temurin' + cache: 'maven' + - name: Install xvfb + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends xvfb + - name: Build Codename One Maven artifacts + run: | + cd maven + xvfb-run -a mvn install -Plocal-dev-javase -DskipTests -Dmaven.javadoc.skip=true + - name: Fetch and install archetype projects + uses: carlosperate/download-file-action@v1.0.3 + with: + file-url: https://github.com/shannah/cn1-maven-archetypes/archive/refs/heads/master.zip + file-name: cn1-maven-archetypes.zip + - name: Install archetype to local repo + run: | + unzip cn1-maven-archetypes.zip + cd cn1-maven-archetypes-master + xvfb-run -a mvn install archetype:update-local-catalog -Plocal-dev-javase + xvfb-run -a mvn archetype:crawl -Plocal-dev-javase + - name: Run archetype simulator smoke (auto-bundle pref forced on) + run: | + cd maven/integration-tests + xvfb-run -a bash cn1app-archetype-test.sh diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 0e6bd82d01..24e7969d9f 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -14443,10 +14443,36 @@ private void loadFromFile() { properties.clear(); properties.load(in); super.clear(); + java.util.List wormholeKeys = null; for (String name : properties.stringPropertyNames()) { - putInternal(name, properties.getProperty(name)); + String value = properties.getProperty(name); + // Legacy self-heal for issue #4850: older Codename One versions + // (<= 7.0.236) whose `get(Object)` echoed missing keys back as + // their own value persisted those echoes to disk. Now that the + // @-key auto-fabrication guard is in place new echoes are + // blocked, but on-disk `@k=@k` entries written by older + // versions still flow through `super.get` as non-null and + // crash `UIManager.setBundle` -> `parseTextFieldInputMode` on + // a token with no `=`. Drop them at load time and rewrite the + // file so legacy projects self-heal on the next simulator + // boot instead of crashing forever. + if (name != null && name.startsWith("@") && name.equals(value)) { + if (wormholeKeys == null) { + wormholeKeys = new java.util.ArrayList(); + } + wormholeKeys.add(name); + continue; + } + putInternal(name, value); + } + if (wormholeKeys != null) { + for (String name : wormholeKeys) { + properties.remove(name); + } + persist(); + } else { + dirty = false; } - dirty = false; } catch (IOException err) { Log.e(err); } diff --git a/maven/integration-tests/android-native-interface-test.sh b/maven/integration-tests/android-native-interface-test.sh index 8ae574570b..254353f629 100644 --- a/maven/integration-tests/android-native-interface-test.sh +++ b/maven/integration-tests/android-native-interface-test.sh @@ -1,5 +1,10 @@ #!/bin/bash -exit 0 #Failing for some reason... need to investigate, but will do it later +# Disabled pending investigation. Previously this script started with `exit 0` +# directly, which silently masqueraded as a green check in `all.sh` (which uses +# `set -e` and would otherwise have surfaced the disablement). Print an +# explicit SKIP line so the suite log makes the gap visible. +echo "[SKIP] android-native-interface-test.sh - disabled, needs reinvestigation" +exit 0 SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" set -e source $SCRIPTPATH/inc/env.sh diff --git a/maven/integration-tests/cn1app-archetype-test.sh b/maven/integration-tests/cn1app-archetype-test.sh index ceb36a4937..fd47756e62 100644 --- a/maven/integration-tests/cn1app-archetype-test.sh +++ b/maven/integration-tests/cn1app-archetype-test.sh @@ -2,6 +2,15 @@ SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" set -e source $SCRIPTPATH/inc/env.sh +source $SCRIPTPATH/inc/auto-bundle-pref.sh + +# Force `cn1.autoDefaultResourceBundle=true` for the duration of the test so +# the `cn1:css` subprocess takes the JavaSEPort.enableAutoLocalizationBundle +# branch -- the same path that crashed for end users in #4850 but is invisible +# in CI under the default-false preference. +set_auto_bundle_pref true +trap 'set_auto_bundle_pref false' EXIT + cd $SCRIPTPATH/build if [ -d myapp1 ]; then rm -rf myapp1 diff --git a/maven/integration-tests/inc/auto-bundle-pref.sh b/maven/integration-tests/inc/auto-bundle-pref.sh new file mode 100644 index 0000000000..29d086a262 --- /dev/null +++ b/maven/integration-tests/inc/auto-bundle-pref.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Helpers to flip the JavaSEPort `cn1.autoDefaultResourceBundle` preference for +# the duration of an integration test. The preference normally defaults to +# false, which masks bugs in JavaSEPort.enableAutoLocalizationBundle from CI - +# end users with the menu toggled on (see issue #4850) experience the gated +# code path instead. Tests that exercise `cn1:css` / `cn1:run` need to flip +# the pref true before the build to reproduce the user-side environment. +# +# Usage: +# source "$SCRIPTPATH/inc/auto-bundle-pref.sh" +# set_auto_bundle_pref true +# trap 'set_auto_bundle_pref false' EXIT +# +# The pref is stored in `Preferences.userRoot().node("/com/codename1/impl/javase")`, +# i.e. `~/.java/.userPrefs/com/codename1/impl/javase/prefs.xml`. Forked +# `cn1:css` subprocesses inherit the same `user.home` from Maven and read +# the same backing store, so a parent-process flush is visible to children. + +set_auto_bundle_pref() { + local value="${1:-true}" + local workdir + workdir="$(mktemp -d -t cn1-auto-bundle-pref.XXXXXX)" + cat > "$workdir/SetAutoBundlePref.java" <<'EOF' +import java.util.prefs.Preferences; + +public final class SetAutoBundlePref { + public static void main(String[] args) throws Exception { + boolean value = args.length > 0 ? Boolean.parseBoolean(args[0]) : true; + Preferences node = Preferences.userRoot().node("/com/codename1/impl/javase"); + node.putBoolean("cn1.autoDefaultResourceBundle", value); + node.flush(); + System.out.println("[auto-bundle-pref] cn1.autoDefaultResourceBundle=" + value); + } +} +EOF + ( cd "$workdir" && javac SetAutoBundlePref.java && java SetAutoBundlePref "$value" ) + local rc=$? + rm -rf "$workdir" + return $rc +} diff --git a/tests/core/test/com/codename1/impl/javase/AutoLocalizationBundleTest.java b/tests/core/test/com/codename1/impl/javase/AutoLocalizationBundleTest.java index 4d9db36610..e7f888c056 100644 --- a/tests/core/test/com/codename1/impl/javase/AutoLocalizationBundleTest.java +++ b/tests/core/test/com/codename1/impl/javase/AutoLocalizationBundleTest.java @@ -1,8 +1,11 @@ package com.codename1.impl.javase; import com.codename1.testing.AbstractTest; +import com.codename1.ui.plaf.UIManager; import java.io.File; import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.OutputStream; import java.lang.reflect.Constructor; import java.util.HashMap; import java.util.Map; @@ -77,12 +80,112 @@ public boolean runTest() throws Exception { bundleReloadedMap.put("@rtl", "true"); assertEqual("true", bundleReloadedMap.get("@rtl"), "Existing meta-key values should still be returned"); + verifySetBundleSmokeOnFreshProject(ctor, tempDir); + verifySetBundleHealsLegacyWormholeFile(ctor, tempDir); + return true; } finally { deleteRecursive(tempDir); } } + /// Smoke-tests the full simulator-init handoff: AutoLocalizationBundle wrapped around a + /// fresh project's empty `src/main/l10n` directory, then handed to `UIManager.setBundle` + /// the same way `JavaSEPort.enableAutoLocalizationBundle` does at simulator boot. + /// + /// Pre-fix this combination crashed deterministically: `setBundle` queried `@im` on the + /// bundle, the bundle echoed `@im` back, `setBundle` tokenized that to `["@im"]`, queried + /// `@im-@im`, got `@im-@im` back, and `parseTextFieldInputMode` blew up on + /// `substring(0, indexOf('='))` for a token with no `=` (issue #4850). + /// + /// This regression was invisible in CI because `enableAutoLocalizationBundle` is gated on + /// `cn1.autoDefaultResourceBundle`, which CI runners default to false but real users have + /// stuck to true via the simulator menu. Exercising the gated code path directly here + /// makes the regression catchable regardless of preference state. + private void verifySetBundleSmokeOnFreshProject(Constructor ctor, File tempDir) throws Exception { + File freshProjectDir = new File(tempDir, "fresh-project"); + File freshBundleFile = new File(new File(freshProjectDir, "src" + File.separator + "main" + File.separator + "l10n"), "Bundle.properties"); + + Object freshBundle = ctor.newInstance(freshBundleFile, null); + @SuppressWarnings("unchecked") + Map freshBundleMap = (Map) freshBundle; + + UIManager manager = UIManager.getInstance(); + Map savedBundle = manager.getBundle(); + try { + // Pre-fix: throws StringIndexOutOfBoundsException out of parseTextFieldInputMode. + manager.setBundle(freshBundleMap); + assertSame(freshBundleMap, manager.getBundle(), "setBundle should install the AutoLocalizationBundle on a fresh project"); + } finally { + manager.setBundle(savedBundle); + } + } + + /// Reproduces ThomasH99's report on issue #4850: even after the @-key fabrication + /// guard shipped in 7.0.237, existing user projects still crash the same way during + /// `cn1:css` because their on-disk `Bundle.properties` was poisoned by older + /// Codename One versions whose `AutoLocalizationBundle.get` echoed and persisted + /// `@key=@key` self-references. + /// + /// Pre-cleanup-fix: + /// - `loadFromFile` reads `@im=@im` and `@im-@im=@im-@im` straight into the Hashtable. + /// - `setBundle` calls `bundle.get("@im")` -> super.get returns the persisted "@im" + /// (not null), so the @-prefix null-guard added in `ba8044ef0` is bypassed. + /// - setBundle tokenizes "@im" to `["@im"]`, looks up "@im-@im" (returns the persisted + /// "@im-@im" string), and `parseTextFieldInputMode("@im-@im")` crashes on + /// `substring(0, indexOf('='))` for a token with no `=`. + /// + /// Post-cleanup-fix: + /// - `loadFromFile` drops `@k=@k` self-references at load time and rewrites the file, + /// so the bundle behaves like a fresh one and self-heals legacy projects. + private void verifySetBundleHealsLegacyWormholeFile(Constructor ctor, File tempDir) throws Exception { + File legacyProjectDir = new File(tempDir, "legacy-project"); + File legacyL10nDir = new File(legacyProjectDir, "src" + File.separator + "main" + File.separator + "l10n"); + if (!legacyL10nDir.mkdirs()) { + throw new RuntimeException("Failed to create legacy l10n dir " + legacyL10nDir); + } + File legacyBundleFile = new File(legacyL10nDir, "Bundle.properties"); + + Properties poisoned = new Properties(); + poisoned.setProperty("@im", "@im"); + poisoned.setProperty("@im-@im", "@im-@im"); + poisoned.setProperty("@rtl", "@rtl"); + poisoned.setProperty("hello", "world"); + try (OutputStream out = new FileOutputStream(legacyBundleFile)) { + poisoned.store(out, "legacy wormhole-poisoned bundle"); + } + + Object legacyBundle = ctor.newInstance(legacyBundleFile, null); + @SuppressWarnings("unchecked") + Map legacyBundleMap = (Map) legacyBundle; + + // The constructor must self-heal the in-memory state: `@k=@k` self-references + // are read from disk but should not flow through to callers. + assertNull(legacyBundleMap.get("@im"), "Legacy `@im=@im` self-reference must be healed at load"); + assertNull(legacyBundleMap.get("@im-@im"), "Legacy `@im-@im=@im-@im` self-reference must be healed at load"); + assertNull(legacyBundleMap.get("@rtl"), "Legacy `@rtl=@rtl` self-reference must be healed at load"); + assertEqual("world", legacyBundleMap.get("hello"), "Non-wormhole entries must survive the load-time clean"); + + // The on-disk file must also be rewritten so the corruption doesn't keep biting + // on every subsequent simulator boot. + Properties cleaned = load(legacyBundleFile); + assertNull(cleaned.getProperty("@im"), "Legacy `@im=@im` self-reference must be wiped from the file"); + assertNull(cleaned.getProperty("@im-@im"), "Legacy `@im-@im=@im-@im` self-reference must be wiped from the file"); + assertNull(cleaned.getProperty("@rtl"), "Legacy `@rtl=@rtl` self-reference must be wiped from the file"); + assertEqual("world", cleaned.getProperty("hello"), "Non-wormhole entries must survive the file rewrite"); + + UIManager manager = UIManager.getInstance(); + Map savedBundle = manager.getBundle(); + try { + // Pre-cleanup-fix: throws StringIndexOutOfBoundsException out of + // parseTextFieldInputMode because `bundle.get("@im")` returned "@im". + manager.setBundle(legacyBundleMap); + assertSame(legacyBundleMap, manager.getBundle(), "setBundle should install the bundle on a legacy poisoned project"); + } finally { + manager.setBundle(savedBundle); + } + } + private Properties load(File file) throws Exception { Properties props = new Properties(); if (file.exists()) {